cli-wizard 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """CLI code generator using Jinja2 templates."""
5
+
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from jinja2 import Environment, PackageLoader
11
+
12
+ from cli_wizard.generator.models import CommandGroup, Operation
13
+
14
+
15
+ def _build_url_path(op: Operation) -> str:
16
+ """Build URL path with Python variable substitutions."""
17
+ path = op.path
18
+ for param in op.parameters:
19
+ if param.location == "path":
20
+ path = path.replace(f"{{{param.name}}}", f"{{{param.python_name}}}")
21
+ return path
22
+
23
+
24
+ class CliGenerator:
25
+ """Generates Click CLI code from parsed OpenAPI."""
26
+
27
+ def __init__(
28
+ self, config: dict[str, Any] | None = None, config_dir: Path | None = None
29
+ ) -> None:
30
+ """Initialize generator with package templates."""
31
+ self.config = config or {}
32
+ self.config_dir = config_dir or Path.cwd()
33
+ self.env = Environment(
34
+ loader=PackageLoader("cli_wizard", "templates"),
35
+ trim_blocks=True,
36
+ lstrip_blocks=True,
37
+ )
38
+ self.env.filters["url_path"] = _build_url_path
39
+
40
+ def generate(
41
+ self,
42
+ groups: dict[str, CommandGroup],
43
+ output_dir: Path,
44
+ cli_name: str,
45
+ package_name: str,
46
+ ) -> None:
47
+ """Generate a complete CLI project."""
48
+ self.package_name = package_name
49
+ output_dir.mkdir(parents=True, exist_ok=True)
50
+
51
+ # Create project structure
52
+ src_dir = output_dir / "src" / package_name
53
+ commands_dir = src_dir / "commands"
54
+ resources_dir = src_dir / "resources"
55
+ commands_dir.mkdir(parents=True, exist_ok=True)
56
+ resources_dir.mkdir(parents=True, exist_ok=True)
57
+
58
+ # Copy resources (e.g., CA file, splash file)
59
+ ca_file_name = self._copy_ca_file(resources_dir)
60
+ splash_file_name = self._copy_splash_file(resources_dir)
61
+
62
+ # Compute main_dir with variable substitution
63
+ main_dir = self._compute_main_dir(package_name)
64
+
65
+ # Generate project files
66
+ self._generate_pyproject(output_dir, cli_name, package_name)
67
+ self._generate_readme(output_dir, cli_name)
68
+ self._generate_version(output_dir)
69
+
70
+ # Generate package files
71
+ self._generate_package_init(src_dir, package_name)
72
+ self._generate_cli_main(src_dir, package_name, groups)
73
+ self._generate_client(src_dir)
74
+ self._generate_logging(src_dir)
75
+ self._generate_profile(src_dir)
76
+ self._generate_constants(src_dir, ca_file_name, splash_file_name, main_dir)
77
+
78
+ # Generate commands
79
+ self._generate_commands_init(commands_dir, groups)
80
+ self._generate_config_commands(commands_dir)
81
+ for tag, group in groups.items():
82
+ self._generate_command_group(group, commands_dir)
83
+
84
+ def _generate_pyproject(
85
+ self, output_dir: Path, cli_name: str, package_name: str
86
+ ) -> None:
87
+ """Generate pyproject.toml."""
88
+ template = self.env.get_template("pyproject.toml.j2")
89
+ content = template.render(
90
+ cli_name=cli_name,
91
+ package_name=package_name,
92
+ config=self.config,
93
+ )
94
+ with open(output_dir / "pyproject.toml", "w") as f:
95
+ f.write(content)
96
+
97
+ def _generate_readme(self, output_dir: Path, cli_name: str) -> None:
98
+ """Generate README.md."""
99
+ template = self.env.get_template("README.md.j2")
100
+ content = template.render(cli_name=cli_name, config=self.config)
101
+ with open(output_dir / "README.md", "w") as f:
102
+ f.write(content)
103
+
104
+ def _generate_version(self, output_dir: Path) -> None:
105
+ """Generate VERSION file."""
106
+ version = self.config.get("version", "0.1.0")
107
+ with open(output_dir / "VERSION", "w") as f:
108
+ f.write(f"{version}\n")
109
+
110
+ def _generate_package_init(self, src_dir: Path, package_name: str) -> None:
111
+ """Generate package __init__.py."""
112
+ template = self.env.get_template("package_init.py.j2")
113
+ content = template.render(package_name=package_name)
114
+ with open(src_dir / "__init__.py", "w") as f:
115
+ f.write(content)
116
+
117
+ def _generate_cli_main(
118
+ self, src_dir: Path, package_name: str, groups: dict[str, CommandGroup]
119
+ ) -> None:
120
+ """Generate main CLI entry point."""
121
+ template = self.env.get_template("cli.py.j2")
122
+ content = template.render(
123
+ package_name=package_name,
124
+ groups=groups,
125
+ config=self.config,
126
+ )
127
+ with open(src_dir / "cli.py", "w") as f:
128
+ f.write(content)
129
+
130
+ def _generate_client(self, src_dir: Path) -> None:
131
+ """Generate API client module."""
132
+ template = self.env.get_template("client.py.j2")
133
+ content = template.render(config=self.config)
134
+ with open(src_dir / "client.py", "w") as f:
135
+ f.write(content)
136
+
137
+ def _generate_logging(self, src_dir: Path) -> None:
138
+ """Generate logging module."""
139
+ template = self.env.get_template("logging.py.j2")
140
+ content = template.render(config=self.config)
141
+ with open(src_dir / "logging.py", "w") as f:
142
+ f.write(content)
143
+
144
+ def _generate_profile(self, src_dir: Path) -> None:
145
+ """Generate profile module."""
146
+ template = self.env.get_template("profile.py.j2")
147
+ content = template.render(config=self.config)
148
+ with open(src_dir / "profile.py", "w") as f:
149
+ f.write(content)
150
+
151
+ def _generate_constants(
152
+ self,
153
+ src_dir: Path,
154
+ ca_file_name: str | None,
155
+ splash_file_name: str | None,
156
+ main_dir: str,
157
+ ) -> None:
158
+ """Generate constants module."""
159
+ template = self.env.get_template("constants.py.j2")
160
+ content = template.render(
161
+ config=self.config,
162
+ ca_file_name=ca_file_name,
163
+ splash_file_name=splash_file_name,
164
+ main_dir=main_dir,
165
+ )
166
+ with open(src_dir / "constants.py", "w") as f:
167
+ f.write(content)
168
+
169
+ def _copy_ca_file(self, resources_dir: Path) -> str | None:
170
+ """Copy CA file to resources directory if specified in config."""
171
+ ca_file = self.config.get("CaFile")
172
+ if not ca_file:
173
+ return None
174
+
175
+ # Resolve CA file path relative to config directory
176
+ ca_path = Path(ca_file)
177
+ if not ca_path.is_absolute():
178
+ ca_path = self.config_dir / ca_file
179
+
180
+ if not ca_path.exists():
181
+ return None
182
+
183
+ # Copy to resources directory with original filename
184
+ dest_path = resources_dir / ca_path.name
185
+ shutil.copy2(ca_path, dest_path)
186
+ return ca_path.name
187
+
188
+ def _compute_main_dir(self, package_name: str) -> str:
189
+ """Get main directory path from config.
190
+
191
+ The #[Param] references should already be resolved by config loader.
192
+ ${VAR} environment variables are kept as-is for runtime expansion.
193
+ """
194
+ main_dir = self.config.get("MainDir")
195
+ if main_dir is not None:
196
+ return str(main_dir)
197
+ return f"${{HOME}}/.{package_name}"
198
+
199
+ def _copy_splash_file(self, resources_dir: Path) -> str | None:
200
+ """Copy splash file to resources directory if specified in config."""
201
+ splash_file = self.config.get("SplashFile")
202
+ if not splash_file:
203
+ return None
204
+
205
+ # Resolve splash file path relative to config directory
206
+ splash_path = Path(splash_file)
207
+ if not splash_path.is_absolute():
208
+ splash_path = self.config_dir / splash_file
209
+
210
+ if not splash_path.exists():
211
+ return None
212
+
213
+ # Copy to resources directory with original filename
214
+ dest_path = resources_dir / splash_path.name
215
+ shutil.copy2(splash_path, dest_path)
216
+ return splash_path.name
217
+
218
+ def _generate_commands_init(
219
+ self, commands_dir: Path, groups: dict[str, CommandGroup]
220
+ ) -> None:
221
+ """Generate commands __init__.py."""
222
+ template = self.env.get_template("commands_init.py.j2")
223
+ content = template.render(groups=groups)
224
+ with open(commands_dir / "__init__.py", "w") as f:
225
+ f.write(content)
226
+
227
+ def _generate_config_commands(self, commands_dir: Path) -> None:
228
+ """Generate config commands module."""
229
+ template = self.env.get_template("commands_config.py.j2")
230
+ content = template.render(package_name=self.package_name, config=self.config)
231
+ with open(commands_dir / "config.py", "w") as f:
232
+ f.write(content)
233
+
234
+ def _generate_command_group(self, group: CommandGroup, commands_dir: Path) -> None:
235
+ """Generate a command group file."""
236
+ template = self.env.get_template("command_group.py.j2")
237
+ content = template.render(
238
+ group=group, config=self.config, package_name=self.package_name
239
+ )
240
+ with open(commands_dir / f"{group.module_name}.py", "w") as f:
241
+ f.write(content)
@@ -0,0 +1,123 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Data models for CLI generation."""
5
+
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class Parameter:
13
+ """Represents an API parameter."""
14
+
15
+ name: str
16
+ location: str # path, query, header
17
+ param_type: str
18
+ required: bool
19
+ description: str = ""
20
+ default: Any = None
21
+ enum: list[str] = field(default_factory=list)
22
+
23
+ @property
24
+ def cli_name(self) -> str:
25
+ """Get CLI-friendly parameter name (kebab-case)."""
26
+ return re.sub(r"([a-z])([A-Z])", r"\1-\2", self.name).lower().replace("_", "-")
27
+
28
+ @property
29
+ def python_name(self) -> str:
30
+ """Get Python-friendly parameter name (snake_case)."""
31
+ name = re.sub(r"([a-z])([A-Z])", r"\1_\2", self.name).lower()
32
+ return name.replace("-", "_")
33
+
34
+ @property
35
+ def click_type(self) -> str:
36
+ """Get Click type for this parameter."""
37
+ type_map = {
38
+ "string": "str",
39
+ "integer": "int",
40
+ "number": "float",
41
+ "boolean": "bool",
42
+ }
43
+ return type_map.get(self.param_type, "str")
44
+
45
+
46
+ @dataclass
47
+ class RequestBodyProperty:
48
+ """Represents a request body property."""
49
+
50
+ name: str
51
+ prop_type: str
52
+ required: bool
53
+ description: str = ""
54
+
55
+ @property
56
+ def cli_name(self) -> str:
57
+ """Get CLI-friendly name (kebab-case)."""
58
+ name = re.sub(r"([a-z])([A-Z])", r"\1-\2", self.name).lower()
59
+ return name.replace("_", "-")
60
+
61
+ @property
62
+ def python_name(self) -> str:
63
+ """Get Python-friendly name (snake_case)."""
64
+ name = re.sub(r"([a-z])([A-Z])", r"\1_\2", self.name).lower()
65
+ return name.replace("-", "_")
66
+
67
+ @property
68
+ def click_type(self) -> str:
69
+ """Get Click type."""
70
+ type_map = {
71
+ "string": "str",
72
+ "integer": "int",
73
+ "number": "float",
74
+ "boolean": "bool",
75
+ }
76
+ return type_map.get(self.prop_type, "str")
77
+
78
+
79
+ @dataclass
80
+ class Operation:
81
+ """Represents an API operation."""
82
+
83
+ operation_id: str
84
+ method: str
85
+ path: str
86
+ summary: str
87
+ description: str
88
+ tags: list[str]
89
+ parameters: list[Parameter]
90
+ body_properties: list[RequestBodyProperty] = field(default_factory=list)
91
+
92
+ @property
93
+ def _base_operation_id(self) -> str:
94
+ """Get the base operation ID without module path (e.g., 'server.get_greetings' -> 'get_greetings')."""
95
+ if "." in self.operation_id:
96
+ return self.operation_id.rsplit(".", 1)[-1]
97
+ return self.operation_id
98
+
99
+ @property
100
+ def command_name(self) -> str:
101
+ """Get CLI command name from operation ID (kebab-case)."""
102
+ name = re.sub(r"(?<!^)(?=[A-Z])", "-", self._base_operation_id).lower()
103
+ return name.replace("_", "-")
104
+
105
+ @property
106
+ def function_name(self) -> str:
107
+ """Get Python function name from operation ID (snake_case)."""
108
+ return self.command_name.replace("-", "_")
109
+
110
+
111
+ @dataclass
112
+ class CommandGroup:
113
+ """Represents a group of commands (from a tag)."""
114
+
115
+ name: str
116
+ cli_name: str
117
+ description: str
118
+ operations: list[Operation] = field(default_factory=list)
119
+
120
+ @property
121
+ def module_name(self) -> str:
122
+ """Get Python module name."""
123
+ return self.cli_name.replace("-", "_")
@@ -0,0 +1,171 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """OpenAPI specification parser."""
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from cli_wizard.generator.models import (
13
+ CommandGroup,
14
+ Operation,
15
+ Parameter,
16
+ RequestBodyProperty,
17
+ )
18
+
19
+
20
+ class OpenApiParser:
21
+ """Parses OpenAPI specification."""
22
+
23
+ def __init__(self, spec_path: str) -> None:
24
+ """Initialize parser with spec path."""
25
+ self.spec_path = Path(spec_path)
26
+ self.spec = self._load_spec()
27
+
28
+ def _load_spec(self) -> dict[str, Any]:
29
+ """Load OpenAPI spec from file (JSON or YAML)."""
30
+ with open(self.spec_path) as f:
31
+ content = f.read()
32
+
33
+ # Try JSON first, then YAML
34
+ if self.spec_path.suffix.lower() == ".json":
35
+ result = json.loads(content)
36
+ return dict(result) if isinstance(result, dict) else {}
37
+ elif self.spec_path.suffix.lower() in (".yaml", ".yml"):
38
+ result = yaml.safe_load(content)
39
+ return dict(result) if isinstance(result, dict) else {}
40
+ else:
41
+ # Try to detect format
42
+ try:
43
+ result = json.loads(content)
44
+ return dict(result) if isinstance(result, dict) else {}
45
+ except json.JSONDecodeError:
46
+ result = yaml.safe_load(content)
47
+ return dict(result) if isinstance(result, dict) else {}
48
+
49
+ def parse(
50
+ self,
51
+ exclude_tags: list[str] | None = None,
52
+ include_tags: list[str] | None = None,
53
+ tag_mapping: dict[str, str] | None = None,
54
+ ) -> dict[str, CommandGroup]:
55
+ """Parse the OpenAPI spec into command groups."""
56
+ exclude_tags = exclude_tags or []
57
+ include_tags = include_tags or []
58
+ tag_mapping = tag_mapping or {}
59
+
60
+ groups: dict[str, CommandGroup] = {}
61
+
62
+ for path, path_item in self.spec.get("paths", {}).items():
63
+ for method, operation in path_item.items():
64
+ if method not in ("get", "post", "put", "patch", "delete"):
65
+ continue
66
+
67
+ tags = operation.get("tags", ["default"])
68
+ for tag in tags:
69
+ if tag in exclude_tags:
70
+ continue
71
+ if include_tags and tag not in include_tags:
72
+ continue
73
+
74
+ if tag not in groups:
75
+ cli_name = tag_mapping.get(tag, self._tag_to_cli_name(tag))
76
+ groups[tag] = CommandGroup(
77
+ name=tag,
78
+ cli_name=cli_name,
79
+ description=self._get_tag_description(tag),
80
+ )
81
+
82
+ op = self._parse_operation(path, method, operation)
83
+ groups[tag].operations.append(op)
84
+
85
+ return groups
86
+
87
+ def _tag_to_cli_name(self, tag: str) -> str:
88
+ """Convert tag name to CLI-friendly name."""
89
+ return tag.lower().replace(" ", "-")
90
+
91
+ def _get_tag_description(self, tag: str) -> str:
92
+ """Get tag description from spec."""
93
+ for tag_def in self.spec.get("tags", []):
94
+ if tag_def.get("name") == tag:
95
+ desc = tag_def.get("description", f"{tag} commands")
96
+ return str(desc)
97
+ return f"{tag} commands"
98
+
99
+ def _parse_operation(
100
+ self, path: str, method: str, operation: dict[str, Any]
101
+ ) -> Operation:
102
+ """Parse a single operation."""
103
+ parameters = []
104
+ for param in operation.get("parameters", []):
105
+ parameters.append(self._parse_parameter(param))
106
+
107
+ body_properties: list[RequestBodyProperty] = []
108
+ if "requestBody" in operation:
109
+ body_properties = self._parse_request_body(operation["requestBody"])
110
+
111
+ return Operation(
112
+ operation_id=operation.get("operationId", f"{method}_{path}"),
113
+ method=method.upper(),
114
+ path=path,
115
+ summary=operation.get("summary", ""),
116
+ description=operation.get("description", ""),
117
+ tags=operation.get("tags", []),
118
+ parameters=parameters,
119
+ body_properties=body_properties,
120
+ )
121
+
122
+ def _parse_parameter(self, param: dict[str, Any]) -> Parameter:
123
+ """Parse a parameter definition."""
124
+ schema = param.get("schema", {})
125
+ return Parameter(
126
+ name=param["name"],
127
+ location=param["in"],
128
+ param_type=schema.get("type", "string"),
129
+ required=param.get("required", False),
130
+ description=param.get("description", ""),
131
+ default=schema.get("default"),
132
+ enum=schema.get("enum", []),
133
+ )
134
+
135
+ def _parse_request_body(self, body: dict[str, Any]) -> list[RequestBodyProperty]:
136
+ """Parse request body definition into properties."""
137
+ content = body.get("content", {})
138
+ json_content = content.get("application/json", {})
139
+ schema = json_content.get("schema", {})
140
+
141
+ # Handle $ref
142
+ if "$ref" in schema:
143
+ schema = self._resolve_ref(schema["$ref"])
144
+
145
+ if not schema:
146
+ return []
147
+
148
+ required_props = schema.get("required", [])
149
+ properties = []
150
+
151
+ for prop_name, prop_schema in schema.get("properties", {}).items():
152
+ properties.append(
153
+ RequestBodyProperty(
154
+ name=prop_name,
155
+ prop_type=prop_schema.get("type", "string"),
156
+ required=prop_name in required_props,
157
+ description=prop_schema.get("description", ""),
158
+ )
159
+ )
160
+
161
+ return properties
162
+
163
+ def _resolve_ref(self, ref: str) -> dict[str, Any]:
164
+ """Resolve a $ref to its schema."""
165
+ parts = ref.split("/")
166
+ if len(parts) != 4 or parts[1] != "components":
167
+ return {}
168
+
169
+ schema_name = parts[3]
170
+ result = self.spec.get("components", {}).get("schemas", {}).get(schema_name, {})
171
+ return dict(result) if isinstance(result, dict) else {}
@@ -0,0 +1,31 @@
1
+ # {{ cli_name }}
2
+
3
+ CLI generated by [cli-wizard](https://github.com/gmarciani/cli-wizard) from OpenAPI specification.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install -e .
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ {{ cli_name }} --help
15
+ ```
16
+
17
+ {% if config.api and config.api.default_base_url %}
18
+ ## Configuration
19
+
20
+ Set the API base URL:
21
+
22
+ ```bash
23
+ export API_BASE_URL="{{ config.api.default_base_url }}"
24
+ ```
25
+
26
+ Set the API key (if required):
27
+
28
+ ```bash
29
+ export API_KEY="your-api-key"
30
+ ```
31
+ {% endif %}
@@ -0,0 +1,85 @@
1
+ # AUTO-GENERATED FILE - DO NOT EDIT
2
+ # Generated from OpenAPI specification by cli-wizard
3
+
4
+ """Main CLI entry point."""
5
+
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from {{ package_name }}.constants import __version__, DEFAULT_BASE_URL, DEFAULT_CA_FILE, SPLASH_ENABLED, SPLASH_FILE, SPLASH_COLOR
13
+ from {{ package_name }}.logging import set_debug
14
+ from {{ package_name }}.profile import load_profile
15
+ from {{ package_name }}.commands.config import config
16
+ {% for tag, group in groups.items() %}
17
+ from {{ package_name }}.commands.{{ group.module_name }} import {{ group.module_name }}
18
+ {% endfor %}
19
+
20
+
21
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
22
+ """Convert hex color to RGB tuple."""
23
+ hex_color = hex_color.lstrip("#")
24
+ return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
25
+
26
+
27
+ def _show_splash() -> None:
28
+ """Display splash screen if enabled."""
29
+ if SPLASH_ENABLED and SPLASH_FILE and SPLASH_FILE.exists():
30
+ splash_text = SPLASH_FILE.read_text()
31
+ if SPLASH_COLOR:
32
+ rgb = _hex_to_rgb(SPLASH_COLOR)
33
+ click.secho(splash_text, fg=rgb)
34
+ else:
35
+ click.echo(splash_text)
36
+
37
+
38
+ # Show splash on startup (before Click processes --help or --version)
39
+ _show_splash()
40
+
41
+
42
+ @click.group(invoke_without_command=True)
43
+ @click.version_option(version=__version__)
44
+ @click.option("--profile", "-p", default="default", help="Profile name to use")
45
+ @click.option("--debug", "-d", is_flag=True, help="Enable debug output")
46
+ @click.option("--base-url", "-u", envvar="API_BASE_URL", help="API base URL")
47
+ @click.option("--no-verify-ssl", is_flag=True, default=False, help="Disable SSL certificate verification")
48
+ @click.option("--ca-file", type=click.Path(exists=True, dir_okay=False, path_type=Path), help="CA certificate file for SSL verification")
49
+ @click.pass_context
50
+ def main(
51
+ ctx: click.Context,
52
+ profile: str,
53
+ debug: bool,
54
+ base_url: str | None,
55
+ no_verify_ssl: bool,
56
+ ca_file: Path | None,
57
+ ) -> None:
58
+ """{{ config.get('Description', 'Generated CLI') }}"""
59
+ ctx.ensure_object(dict)
60
+
61
+ # Enable debug logging if --debug flag is set
62
+ set_debug(debug)
63
+
64
+ # Load profile
65
+ load_profile(profile)
66
+
67
+ ctx.obj["profile"] = profile
68
+ ctx.obj["debug"] = debug
69
+ ctx.obj["base_url"] = base_url
70
+ ctx.obj["no_verify_ssl"] = no_verify_ssl
71
+ ctx.obj["ca_file"] = ca_file
72
+
73
+ # Show help when no subcommand is invoked
74
+ if ctx.invoked_subcommand is None:
75
+ click.echo(ctx.get_help())
76
+
77
+
78
+ main.add_command(config)
79
+ {% for tag, group in groups.items() %}
80
+ main.add_command({{ group.module_name }})
81
+ {% endfor %}
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()