Skip to content

Commit 97d93c0

Browse files
authored
Introduce Command Structure for CLI with New Generate, Info, and Validate Commands (#5)
**Overview:** This pull request refactors and enhances the command-line interface (CLI) of the project by introducing a structured command system. The main additions include the implementation of new commands—`generate`, `info`, and `validate`—which provide better organization and flexibility for interacting with the project’s structure generation capabilities. **Changes:** - **New Commands:** - Added `GenerateCommand` to handle the creation of project structures based on a YAML configuration file. - Added `InfoCommand` to display information about the package. - Added `ValidateCommand` to validate the YAML configuration file used for structure generation. - **Refactoring:** - Reorganized the main CLI entry point (`struct_module/main.py`) to support subcommands using the argparse module. - Removed redundant code from the `utils.py` file, consolidating the responsibilities into the newly created command classes. - Updated the `setup.py` to reflect the new command structure, ensuring that the correct entry points are used. - **README Updates:** - Adjusted the documentation to reflect changes in CLI usage, specifically replacing instances of the old command syntax with the new `generate` subcommand. - **Test Suite Changes:** - Removed obsolete test scripts that no longer align with the new command structure, ensuring that the test suite remains relevant to the current codebase. **Justification:** This refactor significantly improves the maintainability and extensibility of the CLI, making it easier to introduce new commands and features in the future. By organizing commands into distinct classes, we enhance the clarity and separation of concerns within the codebase. **Impact:** - Users will need to update their workflows to use the new `generate`, `info`, and `validate` subcommands. - Existing documentation and usage examples are updated to align with the new command structure, ensuring a smooth transition for users. - This change lays the groundwork for further enhancements to the CLI, potentially leading to more robust and feature-rich command options in the future.
1 parent b3e18c1 commit 97d93c0

10 files changed

Lines changed: 170 additions & 330 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ docker run \
7272
-v $(pwd):/workdir \
7373
-e OPENAI_API_KEY=your-key \
7474
-u $(id -u):$(id -g) \
75-
ghcr.io/httpdss/struct:main \
75+
ghcr.io/httpdss/struct:main generate \
7676
/workdir/example/structure.yaml \
7777
/workdir/example_output
7878
```
@@ -97,7 +97,7 @@ cd example/
9797
touch structure.yaml
9898
vim structure.yaml # copy the content from the example folder
9999
export OPENAI_API_KEY=something
100-
struct structure.yaml .
100+
struct generate structure.yaml .
101101
```
102102

103103
## 📝 Usage
@@ -121,13 +121,13 @@ usage: struct [-h] [--log LOG] [--dry-run] [--vars VARS] [--backup BACKUP] [--fi
121121
### Simple Example
122122

123123
```sh
124-
struct /path/to/your/structure.yaml /path/to/your/output/directory
124+
struct generate /path/to/your/structure.yaml /path/to/your/output/directory
125125
```
126126

127127
### More Complete Example
128128

129129
```sh
130-
struct \
130+
struct generate \
131131
--log=DEBUG \
132132
--dry-run \
133133
--vars="project_name=MyProject,author_name=JohnDoe" \

docker-compose.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ services:
77
env_file:
88
- .env
99
command: [
10+
"generate",
1011
"--log=DEBUG",
1112
"--dry-run",
1213
"--vars=project_name=MyProject,author_name=JohnDoe",
@@ -24,6 +25,7 @@ services:
2425
env_file:
2526
- .env
2627
command: [
28+
"generate",
2729
"--log=DEBUG",
2830
"--vars=project_name=MyProject,author_name=JohnDoe",
2931
"--backup=/app/backup",

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def parse_requirements(filename):
1212
install_requires=parse_requirements('requirements.txt'),
1313
entry_points={
1414
'console_scripts': [
15-
'struct = struct_module:main',
15+
'struct = struct_module.main:main',
1616
],
1717
},
1818
)

struct_module/commands/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import logging
2+
3+
# Base command class
4+
class Command:
5+
def __init__(self, parser):
6+
self.parser = parser
7+
self.logger = logging.getLogger(__name__)
8+
self.add_common_arguments()
9+
10+
def add_common_arguments(self):
11+
self.parser.add_argument('-l', '--log', type=str, default='INFO', help='Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)')
12+
self.parser.add_argument('-c', '--config-file', type=str, help='Path to a configuration file')
13+
self.parser.add_argument('-i', '--log-file', type=str, help='Path to a log file')
14+
15+
def execute(self, args):
16+
raise NotImplementedError("Subclasses should implement this!")

struct_module/commands/generate.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from struct_module.commands import Command
2+
import os
3+
import yaml
4+
from struct_module.file_item import FileItem
5+
6+
# Generate command class
7+
class GenerateCommand(Command):
8+
def __init__(self, parser):
9+
super().__init__(parser)
10+
parser.add_argument('yaml_file', type=str, help='Path to the YAML configuration file')
11+
parser.add_argument('base_path', type=str, help='Base path where the structure will be created')
12+
parser.add_argument('-d', '--dry-run', action='store_true', help='Perform a dry run without creating any files or directories')
13+
parser.add_argument('-v', '--vars', type=str, help='Template variables in the format KEY1=value1,KEY2=value2')
14+
parser.add_argument('-b', '--backup', type=str, help='Path to the backup folder')
15+
parser.add_argument('-f', '--file-strategy', type=str, choices=['overwrite', 'skip', 'append', 'rename', 'backup'], default='overwrite', help='Strategy for handling existing files')
16+
parser.add_argument('-p', '--global-system-prompt', type=str, help='Global system prompt for OpenAI')
17+
parser.set_defaults(func=self.execute)
18+
19+
def execute(self, args):
20+
self.logger.info(f"Generating structure at {args.base_path} with config {args.yaml_file}")
21+
22+
if args.backup and not os.path.exists(args.backup):
23+
os.makedirs(args.backup)
24+
25+
if args.base_path and not os.path.exists(args.base_path):
26+
self.logger.info(f"Creating base path: {args.base_path}")
27+
os.makedirs(args.base_path)
28+
29+
self._create_structure(args)
30+
31+
32+
def _create_structure(self, args):
33+
with open(args.yaml_file, 'r') as f:
34+
config = yaml.safe_load(f)
35+
36+
template_vars = dict(item.split('=') for item in args.vars.split(',')) if args.vars else None
37+
structure = config.get('structure', [])
38+
39+
for item in structure:
40+
self.logger.debug(f"Processing item: {item}")
41+
for name, content in item.items():
42+
self.logger.debug(f"Processing name: {name}, content: {content}")
43+
if isinstance(content, dict):
44+
content["name"] = name
45+
content["global_system_prompt"] = args.global_system_prompt
46+
file_item = FileItem(content)
47+
file_item.fetch_content()
48+
elif isinstance(content, str):
49+
file_item = FileItem({"name": name, "content": content})
50+
51+
file_item.apply_template_variables(template_vars)
52+
file_item.process_prompt(args.dry_run)
53+
file_item.create(
54+
args.base_path,
55+
args.dry_run or False,
56+
args.backup_path or None,
57+
args.file_strategy or 'overwrite'
58+
)

struct_module/commands/info.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from struct_module.commands import Command
2+
# Info command class
3+
class InfoCommand(Command):
4+
def __init__(self, parser):
5+
super().__init__(parser)
6+
parser.set_defaults(func=self.execute)
7+
8+
def execute(self, args):
9+
print("STRUCT")
10+
print("")
11+
print("Generate project structure from YAML configuration.")
12+
print("Commands:")
13+
print(" generate Generate the project structure")
14+
print(" validate Validate the YAML configuration file")
15+
print(" info Show information about the package")

struct_module/commands/validate.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import os
2+
import yaml
3+
from dotenv import load_dotenv
4+
from struct_module.commands import Command
5+
6+
load_dotenv()
7+
8+
openai_api_key = os.getenv("OPENAI_API_KEY")
9+
openai_model = os.getenv("OPENAI_MODEL")
10+
11+
# Validate command class
12+
class ValidateCommand(Command):
13+
def __init__(self, parser):
14+
super().__init__(parser)
15+
parser.add_argument('yaml_file', type=str, help='Path to the YAML configuration file')
16+
parser.set_defaults(func=self.execute)
17+
18+
def execute(self, args):
19+
self.logger.info(f"Validating {args.yaml_file}")
20+
21+
with open(args.yaml_file, 'r') as f:
22+
config = yaml.safe_load(f)
23+
24+
self._validate_configuration(config.get('structure', []))
25+
26+
27+
def _validate_configuration(self, structure):
28+
if not isinstance(structure, list):
29+
raise ValueError("The 'structure' key must be a list.")
30+
for item in structure:
31+
if not isinstance(item, dict):
32+
raise ValueError("Each item in the 'structure' list must be a dictionary.")
33+
for name, content in item.items():
34+
if not isinstance(name, str):
35+
raise ValueError("Each name in the 'structure' item must be a string.")
36+
if isinstance(content, dict):
37+
# Check that any of the keys 'content', 'file' or 'prompt' is present
38+
if 'content' not in content and 'file' not in content and 'user_prompt' not in content:
39+
raise ValueError(f"Dictionary item '{name}' must contain either 'content' or 'file' or 'user_prompt' key.")
40+
# Check if 'file' key is present and its value is a string
41+
if 'file' in content and not isinstance(content['file'], str):
42+
raise ValueError(f"The 'file' value for '{name}' must be a string.")
43+
# Check if 'permissions' key is present and its value is a string
44+
if 'permissions' in content and not isinstance(content['permissions'], str):
45+
raise ValueError(f"The 'permissions' value for '{name}' must be a string.")
46+
# Check if 'prompt' key is present and its value is a string
47+
if 'prompt' in content and not isinstance(content['prompt'], str):
48+
raise ValueError(f"The 'prompt' value for '{name}' must be a string.")
49+
# Check if 'prompt' key is present but no OpenAI API key is found
50+
if 'prompt' in content and not openai_api_key:
51+
raise ValueError("Using prompt property and no OpenAI API key was found. Please set it in the .env file.")
52+
elif not isinstance(content, str):
53+
raise ValueError(f"The content of '{name}' must be a string or dictionary.")
54+
self.logger.info("Configuration validation passed.")

struct_module/main.py

Lines changed: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import os
22
import logging
3-
import yaml
43
from dotenv import load_dotenv
5-
from .utils import read_config_file, merge_configs, validate_configuration, create_structure
4+
from struct_module.utils import read_config_file, merge_configs
5+
from struct_module.commands.generate import GenerateCommand
6+
from struct_module.commands.info import InfoCommand
7+
from struct_module.commands.validate import ValidateCommand
8+
9+
import argparse
610

711
load_dotenv()
812

@@ -14,59 +18,40 @@
1418

1519

1620
def main():
17-
import argparse
18-
1921
parser = argparse.ArgumentParser(
2022
description="Generate project structure from YAML configuration.",
2123
prog="struct",
2224
epilog="Thanks for using %(prog)s! :)",
23-
2425
)
25-
parser.add_argument('yaml_file', type=str, help='Path to the YAML configuration file')
26-
parser.add_argument('base_path', type=str, help='Base path where the structure will be created')
27-
parser.add_argument('-c', '--config-file', type=str, help='Path to a configuration file')
28-
parser.add_argument('-l', '--log', type=str, default='INFO', help='Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)')
29-
parser.add_argument('-d', '--dry-run', action='store_true', help='Perform a dry run without creating any files or directories')
30-
parser.add_argument('-v', '--vars', type=str, help='Template variables in the format KEY1=value1,KEY2=value2')
31-
parser.add_argument('-b', '--backup', type=str, help='Path to the backup folder')
32-
parser.add_argument('-f', '--file-strategy', type=str, choices=['overwrite', 'skip', 'append', 'rename', 'backup'], default='overwrite', help='Strategy for handling existing files')
33-
parser.add_argument('-i', '--log-file', type=str, help='Path to a log file')
34-
parser.add_argument('-p', '--global-system-prompt', type=str, help='Global system prompt for OpenAI')
3526

27+
# Create subparsers
28+
subparsers = parser.add_subparsers()
29+
30+
31+
InfoCommand(subparsers.add_parser('info', help='Show information about the package'))
32+
ValidateCommand(subparsers.add_parser('validate', help='Validate the YAML configuration file'))
33+
GenerateCommand(subparsers.add_parser('generate', help='Generate the project structure'))
3634
args = parser.parse_args()
3735

36+
# Check if a subcommand was provided
37+
if not hasattr(args, 'func'):
38+
parser.print_help()
39+
parser.exit()
40+
3841
# Read config file if provided
3942
if args.config_file:
4043
file_config = read_config_file(args.config_file)
4144
args = argparse.Namespace(**merge_configs(file_config, args))
4245

4346
logging_level = getattr(logging, args.log.upper(), logging.INFO)
44-
template_vars = dict(item.split('=') for item in args.vars.split(',')) if args.vars else None
45-
backup_path = args.backup
46-
47-
if backup_path and not os.path.exists(backup_path):
48-
os.makedirs(backup_path)
49-
50-
if args.base_path and not os.path.exists(args.base_path):
51-
logging.info(f"Creating base path: {args.base_path}")
52-
os.makedirs(args.base_path)
5347

5448
logging.basicConfig(
5549
level=logging_level,
5650
filename=args.log_file,
5751
format='[%(asctime)s][%(levelname)s][struct] >>> %(message)s',
5852
)
59-
logging.info(f"Starting to create project structure from {args.yaml_file} in {args.base_path}")
60-
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}")
61-
62-
with open(args.yaml_file, 'r') as f:
63-
config = yaml.safe_load(f)
64-
65-
validate_configuration(config.get('structure', []))
66-
create_structure(args.base_path, config.get('structure', []), args.dry_run, template_vars, backup_path, args.file_strategy, args.global_system_prompt)
67-
68-
logging.info("Finished creating project structure")
6953

54+
args.func(args)
7055

7156
if __name__ == "__main__":
7257
main()

struct_module/utils.py

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,52 +10,10 @@
1010
openai_model = os.getenv("OPENAI_MODEL")
1111

1212

13-
def validate_configuration(structure):
14-
if not isinstance(structure, list):
15-
raise ValueError("The 'structure' key must be a list.")
16-
for item in structure:
17-
if not isinstance(item, dict):
18-
raise ValueError("Each item in the 'structure' list must be a dictionary.")
19-
for name, content in item.items():
20-
if not isinstance(name, str):
21-
raise ValueError("Each name in the 'structure' item must be a string.")
22-
if isinstance(content, dict):
23-
# Check that any of the keys 'content', 'file' or 'prompt' is present
24-
if 'content' not in content and 'file' not in content and 'user_prompt' not in content:
25-
raise ValueError(f"Dictionary item '{name}' must contain either 'content' or 'file' or 'user_prompt' key.")
26-
# Check if 'file' key is present and its value is a string
27-
if 'file' in content and not isinstance(content['file'], str):
28-
raise ValueError(f"The 'file' value for '{name}' must be a string.")
29-
# Check if 'permissions' key is present and its value is a string
30-
if 'permissions' in content and not isinstance(content['permissions'], str):
31-
raise ValueError(f"The 'permissions' value for '{name}' must be a string.")
32-
# Check if 'prompt' key is present and its value is a string
33-
if 'prompt' in content and not isinstance(content['prompt'], str):
34-
raise ValueError(f"The 'prompt' value for '{name}' must be a string.")
35-
# Check if 'prompt' key is present but no OpenAI API key is found
36-
if 'prompt' in content and not openai_api_key:
37-
raise ValueError("Using prompt property and no OpenAI API key was found. Please set it in the .env file.")
38-
elif not isinstance(content, str):
39-
raise ValueError(f"The content of '{name}' must be a string or dictionary.")
40-
logging.info("Configuration validation passed.")
4113

4214

43-
def create_structure(base_path, structure, dry_run=False, template_vars=None, backup_path=None, file_strategy='overwrite', global_system_prompt=None):
44-
for item in structure:
45-
logging.debug(f"Processing item: {item}")
46-
for name, content in item.items():
47-
logging.debug(f"Processing name: {name}, content: {content}")
48-
if isinstance(content, dict):
49-
content["name"] = name
50-
content["global_system_prompt"] = global_system_prompt
51-
file_item = FileItem(content)
52-
file_item.fetch_content()
53-
elif isinstance(content, str):
54-
file_item = FileItem({"name": name, "content": content})
5515

56-
file_item.apply_template_variables(template_vars)
57-
file_item.process_prompt(dry_run)
58-
file_item.create(base_path, dry_run, backup_path, file_strategy)
16+
5917

6018

6119
def read_config_file(file_path):

0 commit comments

Comments
 (0)