diff --git a/README.md b/README.md index 99dfbc4..6d5c410 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ docker run \ -v $(pwd):/workdir \ -e OPENAI_API_KEY=your-key \ -u $(id -u):$(id -g) \ - ghcr.io/httpdss/struct:main \ + ghcr.io/httpdss/struct:main generate \ /workdir/example/structure.yaml \ /workdir/example_output ``` @@ -97,7 +97,7 @@ cd example/ touch structure.yaml vim structure.yaml # copy the content from the example folder export OPENAI_API_KEY=something -struct structure.yaml . +struct generate structure.yaml . ``` ## 📝 Usage @@ -121,13 +121,13 @@ usage: struct [-h] [--log LOG] [--dry-run] [--vars VARS] [--backup BACKUP] [--fi ### Simple Example ```sh -struct /path/to/your/structure.yaml /path/to/your/output/directory +struct generate /path/to/your/structure.yaml /path/to/your/output/directory ``` ### More Complete Example ```sh -struct \ +struct generate \ --log=DEBUG \ --dry-run \ --vars="project_name=MyProject,author_name=JohnDoe" \ diff --git a/docker-compose.yaml b/docker-compose.yaml index a97c4e6..8a4afd2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,7 @@ services: env_file: - .env command: [ + "generate", "--log=DEBUG", "--dry-run", "--vars=project_name=MyProject,author_name=JohnDoe", @@ -24,6 +25,7 @@ services: env_file: - .env command: [ + "generate", "--log=DEBUG", "--vars=project_name=MyProject,author_name=JohnDoe", "--backup=/app/backup", diff --git a/setup.py b/setup.py index c0aa8bc..8220110 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ def parse_requirements(filename): install_requires=parse_requirements('requirements.txt'), entry_points={ 'console_scripts': [ - 'struct = struct_module:main', + 'struct = struct_module.main:main', ], }, ) diff --git a/struct_module/commands/__init__.py b/struct_module/commands/__init__.py new file mode 100644 index 0000000..3e7c295 --- /dev/null +++ b/struct_module/commands/__init__.py @@ -0,0 +1,16 @@ +import logging + +# Base command class +class Command: + def __init__(self, parser): + self.parser = parser + self.logger = logging.getLogger(__name__) + self.add_common_arguments() + + def add_common_arguments(self): + self.parser.add_argument('-l', '--log', type=str, default='INFO', help='Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)') + self.parser.add_argument('-c', '--config-file', type=str, help='Path to a configuration file') + self.parser.add_argument('-i', '--log-file', type=str, help='Path to a log file') + + def execute(self, args): + raise NotImplementedError("Subclasses should implement this!") diff --git a/struct_module/commands/generate.py b/struct_module/commands/generate.py new file mode 100644 index 0000000..b092304 --- /dev/null +++ b/struct_module/commands/generate.py @@ -0,0 +1,58 @@ +from struct_module.commands import Command +import os +import yaml +from struct_module.file_item import FileItem + +# Generate command class +class GenerateCommand(Command): + def __init__(self, parser): + super().__init__(parser) + parser.add_argument('yaml_file', type=str, help='Path to the YAML configuration file') + parser.add_argument('base_path', type=str, help='Base path where the structure will be created') + parser.add_argument('-d', '--dry-run', action='store_true', help='Perform a dry run without creating any files or directories') + parser.add_argument('-v', '--vars', type=str, help='Template variables in the format KEY1=value1,KEY2=value2') + parser.add_argument('-b', '--backup', type=str, help='Path to the backup folder') + parser.add_argument('-f', '--file-strategy', type=str, choices=['overwrite', 'skip', 'append', 'rename', 'backup'], default='overwrite', help='Strategy for handling existing files') + parser.add_argument('-p', '--global-system-prompt', type=str, help='Global system prompt for OpenAI') + parser.set_defaults(func=self.execute) + + def execute(self, args): + self.logger.info(f"Generating structure at {args.base_path} with config {args.yaml_file}") + + if args.backup and not os.path.exists(args.backup): + os.makedirs(args.backup) + + if args.base_path and not os.path.exists(args.base_path): + self.logger.info(f"Creating base path: {args.base_path}") + os.makedirs(args.base_path) + + self._create_structure(args) + + + def _create_structure(self, args): + with open(args.yaml_file, 'r') as f: + config = yaml.safe_load(f) + + template_vars = dict(item.split('=') for item in args.vars.split(',')) if args.vars else None + structure = config.get('structure', []) + + for item in structure: + self.logger.debug(f"Processing item: {item}") + for name, content in item.items(): + self.logger.debug(f"Processing name: {name}, content: {content}") + if isinstance(content, dict): + content["name"] = name + content["global_system_prompt"] = args.global_system_prompt + file_item = FileItem(content) + file_item.fetch_content() + elif isinstance(content, str): + file_item = FileItem({"name": name, "content": content}) + + file_item.apply_template_variables(template_vars) + file_item.process_prompt(args.dry_run) + file_item.create( + args.base_path, + args.dry_run or False, + args.backup_path or None, + args.file_strategy or 'overwrite' + ) diff --git a/struct_module/commands/info.py b/struct_module/commands/info.py new file mode 100644 index 0000000..a0b0691 --- /dev/null +++ b/struct_module/commands/info.py @@ -0,0 +1,15 @@ +from struct_module.commands import Command +# Info command class +class InfoCommand(Command): + def __init__(self, parser): + super().__init__(parser) + parser.set_defaults(func=self.execute) + + def execute(self, args): + print("STRUCT") + print("") + print("Generate project structure from YAML configuration.") + print("Commands:") + print(" generate Generate the project structure") + print(" validate Validate the YAML configuration file") + print(" info Show information about the package") diff --git a/struct_module/commands/validate.py b/struct_module/commands/validate.py new file mode 100644 index 0000000..310beab --- /dev/null +++ b/struct_module/commands/validate.py @@ -0,0 +1,54 @@ +import os +import yaml +from dotenv import load_dotenv +from struct_module.commands import Command + +load_dotenv() + +openai_api_key = os.getenv("OPENAI_API_KEY") +openai_model = os.getenv("OPENAI_MODEL") + +# Validate command class +class ValidateCommand(Command): + def __init__(self, parser): + super().__init__(parser) + parser.add_argument('yaml_file', type=str, help='Path to the YAML configuration file') + parser.set_defaults(func=self.execute) + + def execute(self, args): + self.logger.info(f"Validating {args.yaml_file}") + + with open(args.yaml_file, 'r') as f: + config = yaml.safe_load(f) + + self._validate_configuration(config.get('structure', [])) + + + def _validate_configuration(self, structure): + if not isinstance(structure, list): + raise ValueError("The 'structure' key must be a list.") + for item in structure: + if not isinstance(item, dict): + raise ValueError("Each item in the 'structure' list must be a dictionary.") + for name, content in item.items(): + if not isinstance(name, str): + 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 '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.") + # 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.") + self.logger.info("Configuration validation passed.") diff --git a/struct_module/main.py b/struct_module/main.py index 8561005..00972e4 100644 --- a/struct_module/main.py +++ b/struct_module/main.py @@ -1,8 +1,12 @@ import os import logging -import yaml from dotenv import load_dotenv -from .utils import read_config_file, merge_configs, validate_configuration, create_structure +from struct_module.utils import read_config_file, merge_configs +from struct_module.commands.generate import GenerateCommand +from struct_module.commands.info import InfoCommand +from struct_module.commands.validate import ValidateCommand + +import argparse load_dotenv() @@ -14,59 +18,40 @@ def main(): - import argparse - parser = argparse.ArgumentParser( description="Generate project structure from YAML configuration.", prog="struct", epilog="Thanks for using %(prog)s! :)", - ) - parser.add_argument('yaml_file', type=str, help='Path to the YAML configuration file') - parser.add_argument('base_path', type=str, help='Base path where the structure will be created') - parser.add_argument('-c', '--config-file', type=str, help='Path to a configuration file') - parser.add_argument('-l', '--log', type=str, default='INFO', help='Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)') - parser.add_argument('-d', '--dry-run', action='store_true', help='Perform a dry run without creating any files or directories') - parser.add_argument('-v', '--vars', type=str, help='Template variables in the format KEY1=value1,KEY2=value2') - parser.add_argument('-b', '--backup', type=str, help='Path to the backup folder') - parser.add_argument('-f', '--file-strategy', type=str, choices=['overwrite', 'skip', 'append', 'rename', 'backup'], default='overwrite', help='Strategy for handling existing files') - parser.add_argument('-i', '--log-file', type=str, help='Path to a log file') - parser.add_argument('-p', '--global-system-prompt', type=str, help='Global system prompt for OpenAI') + # Create subparsers + subparsers = parser.add_subparsers() + + + InfoCommand(subparsers.add_parser('info', help='Show information about the package')) + ValidateCommand(subparsers.add_parser('validate', help='Validate the YAML configuration file')) + GenerateCommand(subparsers.add_parser('generate', help='Generate the project structure')) args = parser.parse_args() + # Check if a subcommand was provided + if not hasattr(args, 'func'): + parser.print_help() + parser.exit() + # Read config file if provided if args.config_file: file_config = read_config_file(args.config_file) args = argparse.Namespace(**merge_configs(file_config, args)) logging_level = getattr(logging, args.log.upper(), logging.INFO) - template_vars = dict(item.split('=') for item in args.vars.split(',')) if args.vars else None - backup_path = args.backup - - 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, format='[%(asctime)s][%(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}") - - with open(args.yaml_file, 'r') as f: - config = yaml.safe_load(f) - - validate_configuration(config.get('structure', [])) - create_structure(args.base_path, config.get('structure', []), args.dry_run, template_vars, backup_path, args.file_strategy, args.global_system_prompt) - - logging.info("Finished creating project structure") + args.func(args) if __name__ == "__main__": main() diff --git a/struct_module/utils.py b/struct_module/utils.py index 3a8b131..01d52fa 100644 --- a/struct_module/utils.py +++ b/struct_module/utils.py @@ -10,52 +10,10 @@ openai_model = os.getenv("OPENAI_MODEL") -def validate_configuration(structure): - if not isinstance(structure, list): - raise ValueError("The 'structure' key must be a list.") - for item in structure: - if not isinstance(item, dict): - raise ValueError("Each item in the 'structure' list must be a dictionary.") - for name, content in item.items(): - if not isinstance(name, str): - 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 '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.") - # 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.") -def create_structure(base_path, structure, dry_run=False, template_vars=None, backup_path=None, file_strategy='overwrite', global_system_prompt=None): - for item in structure: - logging.debug(f"Processing item: {item}") - for name, content in item.items(): - logging.debug(f"Processing name: {name}, content: {content}") - if isinstance(content, dict): - content["name"] = name - content["global_system_prompt"] = global_system_prompt - file_item = FileItem(content) - file_item.fetch_content() - elif isinstance(content, str): - file_item = FileItem({"name": name, "content": content}) - file_item.apply_template_variables(template_vars) - file_item.process_prompt(dry_run) - file_item.create(base_path, dry_run, backup_path, file_strategy) + def read_config_file(file_path): diff --git a/tests/test_script.py b/tests/test_script.py deleted file mode 100644 index 491b377..0000000 --- a/tests/test_script.py +++ /dev/null @@ -1,248 +0,0 @@ -import pytest -import os -import tempfile -import time -import logging -from unittest.mock import patch -from struct_module.utils 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.file_item.requests.get') -def test_fetch_remote_content(mock_get): - mock_get.return_value.status_code = 200 - mock_get.return_value.text = "Mocked content" - - file_item = FileItem({"name": "LICENSE", "file": "https://example.com/mock"}) - file_item.fetch_content() - - assert file_item.content == "Mocked content" - mock_get.assert_called_once_with("https://example.com/mock") - - -# Test for FileItem.apply_template_variables -def test_apply_template_variables(): - file_item = FileItem({"name": "README.md", "content": "Hello, ${name}!"}) - template_vars = {"name": "World"} - - file_item.apply_template_variables(template_vars) - - assert file_item.content == "Hello, World!" - - -# Test for validate_configuration -def test_validate_configuration(): - valid_structure = [ - { - "README.md": { - "content": "This is a README file." - } - } - ] - invalid_structure = [ - { - "README.md": { - "invalid_key": "This should cause validation to fail." - } - } - ] - - validate_configuration(valid_structure) - - with pytest.raises(ValueError): - validate_configuration(invalid_structure) - - -# Test for FileItem.create with different strategies -def test_create_file(): - structure = [ - { - "README.md": { - "content": "This is a README file." - } - }, - { - "script.sh": { - "permissions": '0777', - "content": "#!/bin/bash\necho 'Hello, World!'" - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - create_structure(tmpdirname, structure) - - # Check if README.md was created with correct content - readme_path = os.path.join(tmpdirname, "README.md") - assert os.path.exists(readme_path) - with open(readme_path, 'r') as f: - assert f.read() == "This is a README file." - - # Check if script.sh was created with correct content and permissions - script_path = os.path.join(tmpdirname, "script.sh") - assert os.path.exists(script_path) - with open(script_path, 'r') as f: - assert f.read() == "#!/bin/bash\necho 'Hello, World!'" - assert oct(os.stat(script_path).st_mode)[-3:] == '777' - - -# Test for dry run -def test_dry_run(caplog): - structure = [ - { - "README.md": { - "content": "This is a README file." - } - } - ] - - 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, "README.md")) - assert any("[DRY RUN] Would create file:" in message - for message in caplog.messages) - - -# Mocking requests.get for testing fetch_remote_content within create_structure -@patch('struct_module.file_item.requests.get') -def test_create_structure_with_remote_content(mock_get): - mock_get.return_value.status_code = 200 - mock_get.return_value.text = "Remote content" - - structure = [ - { - "LICENSE": { - "file": "https://example.com/mock" - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - create_structure(tmpdirname, structure) - - license_path = os.path.join(tmpdirname, "LICENSE") - assert os.path.exists(license_path) - with open(license_path, 'r') as f: - assert f.read() == "Remote content" - - -# Test for backup strategy -def test_backup_strategy(): - structure = [ - { - "README.md": { - "content": "This is a README file." - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - readme_path = os.path.join(tmpdirname, "README.md") - with open(readme_path, 'w') as f: - f.write("Existing content") - - backup_path = os.path.join(tmpdirname, "backup") - os.makedirs(backup_path) - - create_structure(tmpdirname, - structure, - backup_path=backup_path, - file_strategy='backup') - - assert os.path.exists(os.path.join(backup_path, "README.md")) - with open(readme_path, 'r') as f: - assert f.read() == "This is a README file." - - -# Test for skip strategy -def test_skip_strategy(): - structure = [ - { - "README.md": { - "content": "This is a README file." - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - readme_path = os.path.join(tmpdirname, "README.md") - with open(readme_path, 'w') as f: - f.write("Existing content") - - create_structure(tmpdirname, structure, file_strategy='skip') - - with open(readme_path, 'r') as f: - assert f.read() == "Existing content" - - -# Test for append strategy -def test_append_strategy(): - structure = [ - { - "README.md": { - "content": " Appended content." - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - readme_path = os.path.join(tmpdirname, "README.md") - with open(readme_path, 'w') as f: - f.write("Existing content.") - - create_structure(tmpdirname, structure, file_strategy='append') - - with open(readme_path, 'r') as f: - assert f.read() == "Existing content. Appended content." - - -# Test for rename strategy -def test_rename_strategy(): - structure = [ - { - "README.md": { - "content": "This is a new README file." - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - readme_path = os.path.join(tmpdirname, "README.md") - with open(readme_path, 'w') as f: - f.write("Existing content") - - create_structure(tmpdirname, structure, file_strategy='rename') - - new_name = f"{readme_path}.{int(time.time())}" - assert os.path.exists(new_name) - with open(readme_path, 'r') as f: - assert f.read() == "This is a new README file." - - -# Test for directory creation -def test_create_structure_creates_directory(): - structure = [ - { - "dir1/dir2/file.txt": { - "content": "This is a nested file." - } - } - ] - - with tempfile.TemporaryDirectory() as tmpdirname: - create_structure(tmpdirname, structure) - - nested_file_path = os.path.join(tmpdirname, "dir1/dir2/file.txt") - assert os.path.exists(nested_file_path) - assert os.path.isdir(os.path.dirname(nested_file_path)) - with open(nested_file_path, 'r') as f: - assert f.read() == "This is a nested file."