From 16a3b9c40f551610123423c76973f598197582f8 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Fri, 12 Jul 2024 12:33:28 -0300 Subject: [PATCH 01/13] chore: Update Python version to 3.10 in test script workflow --- .github/workflows/docker-hub-push.yaml | 29 ++++++++++++++++++++++++++ .github/workflows/test-script.yaml | 4 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/docker-hub-push.yaml diff --git a/.github/workflows/docker-hub-push.yaml b/.github/workflows/docker-hub-push.yaml new file mode 100644 index 0000000..3be7601 --- /dev/null +++ b/.github/workflows/docker-hub-push.yaml @@ -0,0 +1,29 @@ +name: docker-hub-push + +on: + push: + # branches: + # - main + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + push: true + tags: httpdss/struct:latest diff --git a/.github/workflows/test-script.yaml b/.github/workflows/test-script.yaml index 5393983..0e02109 100644 --- a/.github/workflows/test-script.yaml +++ b/.github/workflows/test-script.yaml @@ -17,10 +17,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python 3.10.14 + - name: Set up Python 3.10 uses: actions/setup-python@v5 with: - python-version: 3.10.14 + python-version: '3.10' cache: pip - name: Install dependencies From fb9f07b5477836867df96e6b4fa5c260c1c74dfa Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Fri, 12 Jul 2024 12:33:54 -0300 Subject: [PATCH 02/13] chore: Update test script workflow to exclude pull requests --- .github/workflows/test-script.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-script.yaml b/.github/workflows/test-script.yaml index 0e02109..eccab8c 100644 --- a/.github/workflows/test-script.yaml +++ b/.github/workflows/test-script.yaml @@ -4,9 +4,9 @@ on: push: branches: - main - pull_request: - branches: - - main + # pull_request: + # branches: + # - main jobs: build: From 5f4fd2d515fa179a004cabb83aa459aaa5e94b9a Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Fri, 12 Jul 2024 16:22:16 -0300 Subject: [PATCH 03/13] chore: Update Docker configuration files and workflows --- .github/workflows/docker-hub-push.yaml | 29 -------------- .github/workflows/push-to-registry.yaml | 51 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 29 deletions(-) delete mode 100644 .github/workflows/docker-hub-push.yaml create mode 100644 .github/workflows/push-to-registry.yaml diff --git a/.github/workflows/docker-hub-push.yaml b/.github/workflows/docker-hub-push.yaml deleted file mode 100644 index 3be7601..0000000 --- a/.github/workflows/docker-hub-push.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: docker-hub-push - -on: - push: - # branches: - # - main - -jobs: - docker: - runs-on: ubuntu-latest - steps: - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - push: true - tags: httpdss/struct:latest diff --git a/.github/workflows/push-to-registry.yaml b/.github/workflows/push-to-registry.yaml new file mode 100644 index 0000000..9c667ff --- /dev/null +++ b/.github/workflows/push-to-registry.yaml @@ -0,0 +1,51 @@ +name: push-to-registry + +on: + push: + branches: ['main'] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true From ff566f707b18708fcb74b0dbc8d3d232f5b29250 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Fri, 12 Jul 2024 20:07:46 -0300 Subject: [PATCH 04/13] chore: Add release-drafter configuration files and workflows --- .github/release-drafter.yml | 31 ++++++++++++++++++++++++++ .github/workflows/release-drafter.yaml | 22 ++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/release-drafter.yaml diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..fcca4ea --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,31 @@ +name-template: 'v$RESOLVED_VERSION 🌈' +tag-template: 'v$RESOLVED_VERSION' +categories: + - title: '🚀 Features' + labels: + - 'feature' + - 'enhancement' + - title: '🐛 Bug Fixes' + labels: + - 'fix' + - 'bugfix' + - 'bug' + - title: '🧰 Maintenance' + label: 'chore' +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. +version-resolver: + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: patch +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml new file mode 100644 index 0000000..182f452 --- /dev/null +++ b/.github/workflows/release-drafter.yaml @@ -0,0 +1,22 @@ +name: release-drafter + +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +jobs: + update_release_draft: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 8ee65a486cab04328ba6ae7a000c6413b5d0ff36 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Fri, 12 Jul 2024 20:11:10 -0300 Subject: [PATCH 05/13] chore: Add funding information for PayPal --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 4e5a474..2b00657 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ patreon: structproject +custom: ['https://www.paypal.me/httpdss'] From 0027e40b1932a5dde4975be42611649aebd5cad7 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 13 Jul 2024 10:13:01 -0300 Subject: [PATCH 06/13] Refactor struct command to use main function from struct_module --- _struct | 6 ------ 1 file changed, 6 deletions(-) delete mode 100755 _struct diff --git a/_struct b/_struct deleted file mode 100755 index b0d2c2f..0000000 --- a/_struct +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python - -from struct_module import main - -if __name__ == "__main__": - main() From c6ad3969ac309c31fa733f9273d5875ee6838963 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 13 Jul 2024 10:44:48 -0300 Subject: [PATCH 07/13] chore: Add prompt property and its associated tests --- .env.example | 1 + .gitignore | 1 + example/structure.yaml | 3 + requirements.txt | 2 + struct_module/main.py | 42 +++++++++++++- tests/test_prompt.py | 122 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 tests/test_prompt.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f0ac698 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=your-api-key-here diff --git a/.gitignore b/.gitignore index e1e8acc..8f03f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv __pycache__ *.egg-info +.env diff --git a/example/structure.yaml b/example/structure.yaml index 5e96f61..be10c6a 100644 --- a/example/structure.yaml +++ b/example/structure.yaml @@ -10,3 +10,6 @@ structure: echo "Hello, {author_name}!" - LICENSE: file: https://raw.githubusercontent.com/nishanths/license/master/LICENSE + - .gitignore: + prompt: | + Generate the content for a .gitignore file that is appropriate for a Python project. Include common directories and file types that should be ignored. diff --git a/requirements.txt b/requirements.txt index 6c9fdba..f88263f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ PyYAML requests +openai +python-dotenv diff --git a/struct_module/main.py b/struct_module/main.py index fed20b2..ff976dd 100644 --- a/struct_module/main.py +++ b/struct_module/main.py @@ -4,6 +4,18 @@ import logging from string import Template import time +import yaml +import openai +from dotenv import load_dotenv + +load_dotenv() + +openai_api_key = os.getenv("OPENAI_API_KEY") + +if not openai_api_key: + raise ValueError("OpenAI API key not found. Please set it in the .env file.") + +openai.api_key = openai_api_key class FileItem: def __init__(self, properties): @@ -11,15 +23,31 @@ def __init__(self, properties): self.content = properties.get("content") self.remote_location = properties.get("file") self.permissions = properties.get("permissions") + self.prompt = properties.get("prompt") + + def process_prompt(self): + if self.prompt: + logging.debug(f"Processing prompt: {self.prompt}") + response = openai.Completion.create( + engine="davinci", + prompt=self.prompt, + max_tokens=100 + ) + self.content = response.choices[0].text + logging.debug(f"Generated content: {self.content}") def fetch_content(self): if self.remote_location: + logging.debug(f"Fetching content from: {self.remote_location}") response = requests.get(self.remote_location) + logging.debug(f"Response status code: {response.status_code}") response.raise_for_status() self.content = response.text + logging.debug(f"Fetched content: {self.content}") def apply_template_variables(self, template_vars): if self.content and template_vars: + logging.debug(f"Applying template variables: {template_vars}") template = Template(self.content) self.content = template.substitute(template_vars) @@ -65,12 +93,21 @@ def validate_configuration(structure): if not isinstance(name, str): raise ValueError("Each name in the 'structure' item must be a string.") if isinstance(content, dict): - if 'content' not in content and 'file' not in content: - raise ValueError(f"Dictionary item '{name}' must contain either 'content' or 'file' key.") + # Check that any of the keys 'content', 'file' or 'prompt' is present + if 'content' not in content and 'file' not in content and 'prompt' not in content: + raise ValueError(f"Dictionary item '{name}' must contain either 'content' or 'file' or 'prompt' key.") + # Check if 'file' key is present and its value is a string if 'file' in content and not isinstance(content['file'], str): raise ValueError(f"The 'file' value for '{name}' must be a string.") + # Check if 'permissions' key is present and its value is a string if 'permissions' in content and not isinstance(content['permissions'], str): raise ValueError(f"The 'permissions' value for '{name}' must be a string.") + # Check if 'prompt' key is present and its value is a string + if 'prompt' in content and not isinstance(content['prompt'], str): + raise ValueError(f"The 'prompt' value for '{name}' must be a string.") + # Check if 'prompt' key is present but no OpenAI API key is found + if 'prompt' in content and not openai_api_key: + raise ValueError("Using prompt property and no OpenAI API key was found. Please set it in the .env file.") elif not isinstance(content, str): raise ValueError(f"The content of '{name}' must be a string or dictionary.") logging.info("Configuration validation passed.") @@ -88,6 +125,7 @@ def create_structure(base_path, structure, dry_run=False, template_vars=None, ba file_item = FileItem({"name": name, "content": content}) file_item.apply_template_variables(template_vars) + file_item.process_prompt() file_item.create(base_path, dry_run, backup_path, file_strategy) def main(): diff --git a/tests/test_prompt.py b/tests/test_prompt.py new file mode 100644 index 0000000..58cd38f --- /dev/null +++ b/tests/test_prompt.py @@ -0,0 +1,122 @@ +import pytest +import os +import tempfile +import requests +import logging +from unittest.mock import patch, MagicMock +from struct_module.main import FileItem, validate_configuration, create_structure + +# Mock the OpenAI API response +@patch('struct_module.openai.Completion.create') +def test_process_prompt(mock_openai_create): + mock_openai_create.return_value = MagicMock(choices=[MagicMock(text='Generated content from prompt')]) + + file_item = FileItem({"name": "generated_file.txt", "prompt": "Write a short story about a dragon."}) + file_item.process_prompt() + + assert file_item.content == 'Generated content from prompt' + mock_openai_create.assert_called_once_with( + engine="davinci", + prompt="Write a short story about a dragon.", + max_tokens=100 + ) + +# Test for validate_configuration with prompt +def test_validate_configuration_with_prompt(): + valid_structure = [ + { + "story.txt": { + "prompt": "Write a short story about a dragon." + } + } + ] + invalid_structure = [ + { + "story.txt": { + "prompt": 12345 # Invalid type + } + } + ] + + validate_configuration(valid_structure) + + with pytest.raises(ValueError): + validate_configuration(invalid_structure) + +# Test for creating a file with prompt content +@patch('struct_module.openai.Completion.create') +def test_create_structure_with_prompt(mock_openai_create): + mock_openai_create.return_value = MagicMock(choices=[MagicMock(text='Generated content from prompt')]) + + structure = [ + { + "story.txt": { + "prompt": "Write a short story about a dragon." + } + } + ] + + with tempfile.TemporaryDirectory() as tmpdirname: + create_structure(tmpdirname, structure) + + story_path = os.path.join(tmpdirname, "story.txt") + assert os.path.exists(story_path) + with open(story_path, 'r') as f: + assert f.read() == 'Generated content from prompt' + +# Test for dry run with prompt +@patch('struct_module.openai.Completion.create') +def test_dry_run_with_prompt(mock_openai_create, caplog): + mock_openai_create.return_value = MagicMock(choices=[MagicMock(text='Generated content from prompt')]) + + structure = [ + { + "story.txt": { + "prompt": "Write a short story about a dragon." + } + } + ] + + with tempfile.TemporaryDirectory() as tmpdirname: + with caplog.at_level(logging.INFO): + create_structure(tmpdirname, structure, dry_run=True) + + assert not os.path.exists(os.path.join(tmpdirname, "story.txt")) + assert any("[DRY RUN] Would create file:" in message for message in caplog.messages) + assert any("Generated content from prompt" in message for message in caplog.messages) + +# Mocking requests.get for testing fetch_remote_content within create_structure with prompt +@patch('struct_module.requests.get') +@patch('struct_module.openai.Completion.create') +def test_create_structure_with_remote_content_and_prompt(mock_openai_create, mock_get): + mock_get.return_value.status_code = 200 + mock_get.return_value.text = "Remote content" + mock_openai_create.return_value = MagicMock(choices=[MagicMock(text='Generated content from prompt')]) + + structure = [ + { + "LICENSE": { + "file": "https://example.com/mock" + } + }, + { + "story.txt": { + "prompt": "Write a short story about a dragon." + } + } + ] + + with tempfile.TemporaryDirectory() as tmpdirname: + create_structure(tmpdirname, structure) + + license_path = os.path.join(tmpdirname, "LICENSE") + story_path = os.path.join(tmpdirname, "story.txt") + + assert os.path.exists(license_path) + assert os.path.exists(story_path) + + with open(license_path, 'r') as f: + assert f.read() == "Remote content" + + with open(story_path, 'r') as f: + assert f.read() == 'Generated content from prompt' From b575f0c17c8030bee334a217b9da536f8b437c86 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 13 Jul 2024 20:53:20 -0300 Subject: [PATCH 08/13] chore: Update Docker configuration files and workflows --- .gitignore | 1 + docker-compose.yaml | 8 ++- example/structure.yaml | 2 +- struct_module/main.py | 63 +++++++++++++++------ tests/__init__.py | 0 tests/test_prompt.py | 122 ----------------------------------------- tests/test_script.py | 6 ++ 7 files changed, 59 insertions(+), 143 deletions(-) delete mode 100644 tests/__init__.py delete mode 100644 tests/test_prompt.py diff --git a/.gitignore b/.gitignore index 8f03f2a..fb248fd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ *.egg-info .env +*.log diff --git a/docker-compose.yaml b/docker-compose.yaml index cc38a06..48546dc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,4 @@ -version: '3.8' +name: struct services: struct: @@ -6,6 +6,8 @@ services: volumes: - .:/app entrypoint: ["python", "struct_module/main.py"] + env_file: + - .env command: [ "--log=DEBUG", "--dry-run", @@ -13,6 +15,6 @@ services: "--backup=/app/backup", "--file-strategy=rename", "--log-file=/app/logfile.log", - "/app/project_structure.yaml", - "/app/project" + "/app/example/structure.yaml", + "/app/example_project" ] diff --git a/example/structure.yaml b/example/structure.yaml index be10c6a..c7e2745 100644 --- a/example/structure.yaml +++ b/example/structure.yaml @@ -11,5 +11,5 @@ structure: - LICENSE: file: https://raw.githubusercontent.com/nishanths/license/master/LICENSE - .gitignore: - prompt: | + user_prompt: | Generate the content for a .gitignore file that is appropriate for a Python project. Include common directories and file types that should be ignored. diff --git a/struct_module/main.py b/struct_module/main.py index ff976dd..b87bf27 100644 --- a/struct_module/main.py +++ b/struct_module/main.py @@ -5,17 +5,16 @@ from string import Template import time import yaml -import openai +from openai import OpenAI from dotenv import load_dotenv load_dotenv() openai_api_key = os.getenv("OPENAI_API_KEY") +openai_model = os.getenv("OPENAI_MODEL") if not openai_api_key: - raise ValueError("OpenAI API key not found. Please set it in the .env file.") - -openai.api_key = openai_api_key + logging.warning("OpenAI API key not found. Skipping processing prompt.") class FileItem: def __init__(self, properties): @@ -23,17 +22,47 @@ def __init__(self, properties): self.content = properties.get("content") self.remote_location = properties.get("file") self.permissions = properties.get("permissions") - self.prompt = properties.get("prompt") - - def process_prompt(self): - if self.prompt: - logging.debug(f"Processing prompt: {self.prompt}") - response = openai.Completion.create( - engine="davinci", - prompt=self.prompt, - max_tokens=100 + + self.system_prompt = properties.get("system_prompt") + self.user_prompt = properties.get("user_prompt") + self.openai_client = OpenAI( + api_key=openai_api_key + ) + + if not openai_model: + logging.info("OpenAI model not found. Using default model.") + self.openai_model = "gpt-3.5-turbo" + else: + logging.debug(f"Using OpenAI model: {openai_model}") + self.openai_model = openai_model + + def process_prompt(self, dry_run=False): + if self.user_prompt: + logging.debug(f"Using user prompt: {self.user_prompt}") + + if not openai_api_key: + logging.warning("Skipping processing prompt as OpenAI API key is not set.") + return + + if not self.system_prompt: + system_prompt = "You are a software developer working on a project. You need to create a file with the following content:" + else: + system_prompt = self.system_prompt + + if dry_run: + logging.info("[DRY RUN] Would generate content using OpenAI API.") + self.content = "[DRY RUN] Generating content using OpenAI" + return + + completion = self.openai_client.chat.completions.create( + model=self.openai_model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": self.user_prompt} + ] ) - self.content = response.choices[0].text + + self.content = completion.choices[0].message.content logging.debug(f"Generated content: {self.content}") def fetch_content(self): @@ -94,8 +123,8 @@ def validate_configuration(structure): raise ValueError("Each name in the 'structure' item must be a string.") if isinstance(content, dict): # Check that any of the keys 'content', 'file' or 'prompt' is present - if 'content' not in content and 'file' not in content and 'prompt' not in content: - raise ValueError(f"Dictionary item '{name}' must contain either 'content' or 'file' or 'prompt' key.") + if 'content' not in content and 'file' not in content and 'user_prompt' not in content: + raise ValueError(f"Dictionary item '{name}' must contain either 'content' or 'file' or 'user_prompt' key.") # Check if 'file' key is present and its value is a string if 'file' in content and not isinstance(content['file'], str): raise ValueError(f"The 'file' value for '{name}' must be a string.") @@ -125,7 +154,7 @@ def create_structure(base_path, structure, dry_run=False, template_vars=None, ba file_item = FileItem({"name": name, "content": content}) file_item.apply_template_variables(template_vars) - file_item.process_prompt() + file_item.process_prompt(dry_run) file_item.create(base_path, dry_run, backup_path, file_strategy) def main(): diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_prompt.py b/tests/test_prompt.py deleted file mode 100644 index 58cd38f..0000000 --- a/tests/test_prompt.py +++ /dev/null @@ -1,122 +0,0 @@ -import pytest -import os -import tempfile -import requests -import logging -from unittest.mock import patch, MagicMock -from struct_module.main import FileItem, validate_configuration, create_structure - -# Mock the OpenAI API response -@patch('struct_module.openai.Completion.create') -def test_process_prompt(mock_openai_create): - mock_openai_create.return_value = MagicMock(choices=[MagicMock(text='Generated content from prompt')]) - - file_item = FileItem({"name": "generated_file.txt", "prompt": "Write a short story about a dragon."}) - file_item.process_prompt() - - assert file_item.content == 'Generated content from prompt' - mock_openai_create.assert_called_once_with( - engine="davinci", - prompt="Write a short story about a dragon.", - max_tokens=100 - ) - -# Test for validate_configuration with prompt -def test_validate_configuration_with_prompt(): - valid_structure = [ - { - "story.txt": { - "prompt": "Write a short story about a dragon." - } - } - ] - invalid_structure = [ - { - "story.txt": { - "prompt": 12345 # Invalid type - } - } - ] - - validate_configuration(valid_structure) - - with pytest.raises(ValueError): - validate_configuration(invalid_structure) - -# Test for creating a file with prompt content -@patch('struct_module.openai.Completion.create') -def test_create_structure_with_prompt(mock_openai_create): - mock_openai_create.return_value = MagicMock(choices=[MagicMock(text='Generated content from prompt')]) - - structure = [ - { - "story.txt": { - "prompt": "Write a short story about a dragon." - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - create_structure(tmpdirname, structure) - - story_path = os.path.join(tmpdirname, "story.txt") - assert os.path.exists(story_path) - with open(story_path, 'r') as f: - assert f.read() == 'Generated content from prompt' - -# Test for dry run with prompt -@patch('struct_module.openai.Completion.create') -def test_dry_run_with_prompt(mock_openai_create, caplog): - mock_openai_create.return_value = MagicMock(choices=[MagicMock(text='Generated content from prompt')]) - - structure = [ - { - "story.txt": { - "prompt": "Write a short story about a dragon." - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - with caplog.at_level(logging.INFO): - create_structure(tmpdirname, structure, dry_run=True) - - assert not os.path.exists(os.path.join(tmpdirname, "story.txt")) - assert any("[DRY RUN] Would create file:" in message for message in caplog.messages) - assert any("Generated content from prompt" in message for message in caplog.messages) - -# Mocking requests.get for testing fetch_remote_content within create_structure with prompt -@patch('struct_module.requests.get') -@patch('struct_module.openai.Completion.create') -def test_create_structure_with_remote_content_and_prompt(mock_openai_create, mock_get): - mock_get.return_value.status_code = 200 - mock_get.return_value.text = "Remote content" - mock_openai_create.return_value = MagicMock(choices=[MagicMock(text='Generated content from prompt')]) - - structure = [ - { - "LICENSE": { - "file": "https://example.com/mock" - } - }, - { - "story.txt": { - "prompt": "Write a short story about a dragon." - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - create_structure(tmpdirname, structure) - - license_path = os.path.join(tmpdirname, "LICENSE") - story_path = os.path.join(tmpdirname, "story.txt") - - assert os.path.exists(license_path) - assert os.path.exists(story_path) - - with open(license_path, 'r') as f: - assert f.read() == "Remote content" - - with open(story_path, 'r') as f: - assert f.read() == 'Generated content from prompt' diff --git a/tests/test_script.py b/tests/test_script.py index b522a7a..1ac402c 100644 --- a/tests/test_script.py +++ b/tests/test_script.py @@ -8,6 +8,12 @@ from unittest.mock import patch, MagicMock from struct_module.main import FileItem, validate_configuration, create_structure +# Mock the environment variables for OpenAI +@pytest.fixture(autouse=True) +def mock_env_vars(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-api-key") + monkeypatch.setenv("OPENAI_MODEL", "gpt-3.5-turbo") + # Test for FileItem.fetch_content @patch('struct_module.requests.get') def test_fetch_remote_content(mock_get): From 044c4f8c83feff812d11dde182981ab8eb10b366 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 13 Jul 2024 21:11:31 -0300 Subject: [PATCH 09/13] chore: Add pre-commit configuration files and workflows --- .github/workflows/pre-commit.yaml | 15 +++++++++++++++ .pre-commit-config.yaml | 7 +++++++ 2 files changed, 22 insertions(+) create mode 100644 .github/workflows/pre-commit.yaml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..863470c --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,15 @@ +name: pre-commit + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - uses: pre-commit/action@v3.0.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2ff5837 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml From f1e3c762e4dbff5ec501e2134dd5be6a74211618 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 13 Jul 2024 21:15:58 -0300 Subject: [PATCH 10/13] chore: Update development instructions and dependencies --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 3d17441..aeb6301 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,25 @@ structure: file: https://raw.githubusercontent.com/nishanths/license/master/LICENSE ``` +## Development + +To get started with development, follow these steps: + +1. Clone the repository +2. Create a virtual environment + +```sh +python3 -m venv .venv +source .venv/bin/activate +``` + +3. Install the dependencies + +```sh +pip install -r requirements.txt +pip install -r requirements.dev.txt +``` + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. From 2947416fce07ef2408c2cb820cbb3b90db418cc6 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 13 Jul 2024 21:54:51 -0300 Subject: [PATCH 11/13] chore: Update logging configuration in struct_module --- docker-compose.yaml | 2 -- struct_module/main.py | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 48546dc..4430660 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -name: struct - services: struct: build: . diff --git a/struct_module/main.py b/struct_module/main.py index b87bf27..0367c83 100644 --- a/struct_module/main.py +++ b/struct_module/main.py @@ -179,7 +179,11 @@ def main(): if backup_path and not os.path.exists(backup_path): os.makedirs(backup_path) - logging.basicConfig(level=logging_level, filename=args.log_file) + logging.basicConfig( + level=logging_level, + filename=args.log_file, + format='%(levelname)s:struct:%(message)s', + ) logging.info(f"Starting to create project structure from {args.yaml_file} in {args.base_path}") logging.debug(f"YAML file path: {args.yaml_file}, Base path: {args.base_path}, Dry run: {args.dry_run}, Template vars: {template_vars}, Backup path: {backup_path}") From ad49c664d66e20e930a83027af47bbdf4f0e4db8 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 13 Jul 2024 22:03:29 -0300 Subject: [PATCH 12/13] chore: Update template variables in structure.yaml and main.py --- example/structure.yaml | 4 ++-- struct_module/main.py | 1 + tests/__init__.py | 0 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py diff --git a/example/structure.yaml b/example/structure.yaml index c7e2745..8dd56c0 100644 --- a/example/structure.yaml +++ b/example/structure.yaml @@ -1,13 +1,13 @@ structure: - README.md: content: | - # {project_name} + # ${project_name} This is a template repository. - script.sh: permissions: '0777' content: | #!/bin/bash - echo "Hello, {author_name}!" + echo "Hello, ${author_name}!" - LICENSE: file: https://raw.githubusercontent.com/nishanths/license/master/LICENSE - .gitignore: diff --git a/struct_module/main.py b/struct_module/main.py index 0367c83..daa48bc 100644 --- a/struct_module/main.py +++ b/struct_module/main.py @@ -78,6 +78,7 @@ def apply_template_variables(self, template_vars): if self.content and template_vars: logging.debug(f"Applying template variables: {template_vars}") template = Template(self.content) + print(template.substitute(template_vars)) self.content = template.substitute(template_vars) def create(self, base_path, dry_run=False, backup_path=None, file_strategy='overwrite'): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 1b2248df05784dfb42559c55f75c1c21dd921aca Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 13 Jul 2024 22:22:15 -0300 Subject: [PATCH 13/13] chore: Update .gitignore to exclude example_project/ --- .gitignore | 1 + README.md | 4 ++-- struct_module/main.py | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index fb248fd..0b6f299 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__ *.egg-info .env *.log +example_project/ diff --git a/README.md b/README.md index aeb6301..f5ea0d1 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,13 @@ Here is an example of a YAML configuration file: structure: - README.md: content: | - # {project_name} + # ${project_name} This is a template repository. - script.sh: permissions: '0777' content: | #!/bin/bash - echo "Hello, {author_name}!" + echo "Hello, ${author_name}!" - LICENSE: file: https://raw.githubusercontent.com/nishanths/license/master/LICENSE ``` diff --git a/struct_module/main.py b/struct_module/main.py index daa48bc..dcab41b 100644 --- a/struct_module/main.py +++ b/struct_module/main.py @@ -78,7 +78,6 @@ def apply_template_variables(self, template_vars): if self.content and template_vars: logging.debug(f"Applying template variables: {template_vars}") template = Template(self.content) - print(template.substitute(template_vars)) self.content = template.substitute(template_vars) def create(self, base_path, dry_run=False, backup_path=None, file_strategy='overwrite'): @@ -180,6 +179,10 @@ def main(): if backup_path and not os.path.exists(backup_path): os.makedirs(backup_path) + if args.base_path and not os.path.exists(args.base_path): + logging.info(f"Creating base path: {args.base_path}") + os.makedirs(args.base_path) + logging.basicConfig( level=logging_level, filename=args.log_file,