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.
cli_wizard/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """CLI Wizard - Build modern CLI from OpenAPI"""
cli_wizard/cli.py ADDED
@@ -0,0 +1,37 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Main CLI module for CLI Wizard."""
5
+
6
+ import click
7
+ import logging
8
+ from cli_wizard.constants import __version__
9
+ from cli_wizard.commands.config import config
10
+ from cli_wizard.commands.generate import generate
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(version=__version__, prog_name="cli-wizard")
15
+ @click.option("--debug", "-d", is_flag=True, help="Enable debug output")
16
+ @click.pass_context
17
+ def main(ctx: click.Context, debug: bool) -> None:
18
+ """CLI Wizard - Generate modern CLI from OpenAPI."""
19
+ ctx.ensure_object(dict)
20
+ ctx.obj["debug"] = debug
21
+
22
+ # Configure logging
23
+ level = logging.DEBUG if debug else logging.INFO
24
+ logging.basicConfig(
25
+ level=level,
26
+ format="%(asctime)s %(levelname)s: %(message)s",
27
+ datefmt="%Y-%m-%dT%H:%M:%SZ",
28
+ )
29
+ logging.Formatter.converter = lambda *args: __import__("time").gmtime()
30
+
31
+
32
+ main.add_command(config)
33
+ main.add_command(generate)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
@@ -0,0 +1,4 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Commands package for CLI Wizard."""
@@ -0,0 +1,36 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Common utilities for CLI Wizard commands."""
5
+
6
+ import click
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def configure_logging(debug: bool = False) -> None:
13
+ """Configure logging with UTC timestamps.
14
+
15
+ Args:
16
+ debug: Whether to enable debug level logging
17
+ """
18
+ import time
19
+
20
+ for handler in logging.root.handlers[:]:
21
+ logging.root.removeHandler(handler)
22
+ level = logging.DEBUG if debug else logging.INFO
23
+ logging.basicConfig(
24
+ level=level,
25
+ format="%(asctime)s %(levelname)s: %(message)s",
26
+ datefmt="%Y-%m-%dT%H:%M:%SZ",
27
+ )
28
+ logging.Formatter.converter = time.gmtime
29
+
30
+
31
+ debug_option = click.option(
32
+ "--debug",
33
+ "-d",
34
+ is_flag=True,
35
+ help="Enable debug output",
36
+ )
@@ -0,0 +1,82 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Configuration commands for CLI Wizard."""
5
+
6
+ import click
7
+ import logging
8
+ import json
9
+ from cli_wizard.config.configuration import (
10
+ load_config,
11
+ save_config,
12
+ get_config_path,
13
+ )
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @click.group()
19
+ def config() -> None:
20
+ """Manage configurations."""
21
+
22
+
23
+ @config.command()
24
+ @click.argument("key")
25
+ @click.argument("value")
26
+ def set(key: str, value: str) -> None:
27
+ """Set a configuration value."""
28
+ cfg = load_config()
29
+ old_value = cfg.get(key)
30
+ cfg[key] = value
31
+ save_config(cfg)
32
+
33
+ result = {"key": key, "value": value, "oldValue": old_value}
34
+ print(json.dumps(result, indent=2))
35
+
36
+
37
+ @config.command()
38
+ @click.argument("key")
39
+ def get(key: str) -> None:
40
+ """Get a configuration value."""
41
+ cfg = load_config()
42
+ if key not in cfg:
43
+ logger.error(f"Unknown configuration key '{key}'")
44
+ return
45
+
46
+ result = {"key": key, "value": cfg[key]}
47
+ print(json.dumps(result, indent=2))
48
+
49
+
50
+ @config.command()
51
+ @click.argument("key")
52
+ def unset(key: str) -> None:
53
+ """Unset a configuration value (set to None)."""
54
+ cfg = load_config()
55
+ if key not in cfg:
56
+ logger.error(f"Unknown configuration key '{key}'")
57
+ return
58
+
59
+ old_value = cfg.get(key)
60
+ cfg[key] = None
61
+ save_config(cfg)
62
+
63
+ result = {"key": key, "value": None, "oldValue": old_value}
64
+ print(json.dumps(result, indent=2))
65
+
66
+
67
+ @config.command()
68
+ def show() -> None:
69
+ """Show all configuration values as JSON."""
70
+ cfg = load_config()
71
+ print(json.dumps(cfg, indent=2))
72
+
73
+
74
+ @config.command()
75
+ def reset() -> None:
76
+ """Reset configuration to defaults and delete local config file."""
77
+ cfg = load_config()
78
+ print(json.dumps(cfg, indent=2))
79
+
80
+ config_path = get_config_path()
81
+ if config_path.exists():
82
+ config_path.unlink()
@@ -0,0 +1,231 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Generate command for CLI Wizard."""
5
+
6
+ import re
7
+ from typing import Any
8
+
9
+ import click
10
+ import logging
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+ from pydantic import ValidationError
16
+
17
+ from cli_wizard.config.configuration import load_default_config
18
+ from cli_wizard.config.schema import Config
19
+ from cli_wizard.generator import OpenApiParser, CliGenerator
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Load defaults from config
24
+ _defaults = load_default_config()
25
+ _default_openapi = _defaults.get("OpenApiFileName", "openapi.yaml")
26
+ _default_config = _defaults.get("ConfigFileName", "config.yaml")
27
+ _default_output = _defaults.get("OutputDir", "cli")
28
+
29
+
30
+ @click.command()
31
+ @click.option(
32
+ "--working-dir",
33
+ "-w",
34
+ type=click.Path(exists=True, file_okay=False, resolve_path=True),
35
+ default=None,
36
+ help="Working directory for resolving relative paths",
37
+ )
38
+ @click.option(
39
+ "--openapi",
40
+ "-o",
41
+ type=click.Path(dir_okay=False),
42
+ default=_default_openapi,
43
+ help=f"Path to OpenAPI spec file in YAML or JSON format (default: {_default_openapi})",
44
+ )
45
+ @click.option(
46
+ "--config",
47
+ "-c",
48
+ type=click.Path(dir_okay=False),
49
+ default=_default_config,
50
+ help=f"Path to config YAML file (default: {_default_config})",
51
+ )
52
+ @click.option(
53
+ "--output",
54
+ "-d",
55
+ type=click.Path(file_okay=False),
56
+ default=_default_output,
57
+ help=f"Output directory for generated CLI (default: {_default_output})",
58
+ )
59
+ @click.pass_context
60
+ def generate(
61
+ ctx: click.Context,
62
+ working_dir: str | None,
63
+ openapi: str,
64
+ config: str,
65
+ output: str,
66
+ ) -> None:
67
+ """Generate a CLI from an OpenAPI spec and config file."""
68
+ debug = ctx.obj.get("debug", False) if ctx.obj else False
69
+
70
+ # Resolve paths relative to working directory
71
+ base_dir = Path(working_dir) if working_dir else Path.cwd()
72
+ openapi_path = (
73
+ base_dir / openapi if not Path(openapi).is_absolute() else Path(openapi)
74
+ )
75
+ config_path = base_dir / config if not Path(config).is_absolute() else Path(config)
76
+
77
+ if debug:
78
+ logger.debug(f"Working directory: {base_dir}")
79
+ logger.debug(f"OpenAPI spec: {openapi_path}")
80
+ logger.debug(f"Config file: {config_path}")
81
+
82
+ # Validate input files exist
83
+ if not openapi_path.exists():
84
+ click.secho(
85
+ f"✗ OpenAPI spec file not found: {openapi_path}", fg="red", err=True
86
+ )
87
+ raise SystemExit(1)
88
+ if not config_path.exists():
89
+ click.secho(f"✗ Config file not found: {config_path}", fg="red", err=True)
90
+ raise SystemExit(1)
91
+
92
+ # Load and validate configuration
93
+ cli_config = _load_cli_config(config_path)
94
+
95
+ # Resolve output path (CLI option > config > default)
96
+ output_dir: str = output
97
+ if output == _default_output:
98
+ config_output = cli_config.get("OutputDir")
99
+ if config_output is not None:
100
+ output_dir = str(config_output)
101
+ output_path = (
102
+ base_dir / output_dir
103
+ if not Path(output_dir).is_absolute()
104
+ else Path(output_dir)
105
+ )
106
+
107
+ if debug:
108
+ logger.debug(f"Output directory: {output_path}")
109
+
110
+ # Get CLI name and package name from config
111
+ cli_name = cli_config["PackageName"]
112
+ # Convert hyphens to underscores for Python package compatibility
113
+ package_name = cli_name.replace("-", "_")
114
+
115
+ # Parse OpenAPI spec
116
+ click.secho("📄 Parsing OpenAPI spec: ", fg="cyan", nl=False)
117
+ click.echo(openapi_path)
118
+ parser = OpenApiParser(str(openapi_path))
119
+
120
+ groups = parser.parse(
121
+ exclude_tags=cli_config.get("ExcludeTags", []),
122
+ include_tags=cli_config.get("IncludeTags", []),
123
+ tag_mapping=cli_config.get("TagMapping", {}),
124
+ )
125
+
126
+ if not groups:
127
+ click.secho("✗ No operations found in OpenAPI spec", fg="red", err=True)
128
+ raise SystemExit(1)
129
+
130
+ # Clean up output directory before generating
131
+ if output_path.exists():
132
+ # Check if we're inside the output directory
133
+ try:
134
+ cwd = Path.cwd()
135
+ if output_path in cwd.parents or output_path == cwd:
136
+ click.secho(
137
+ f"✗ Cannot clean output directory while inside it. "
138
+ f"Please run from a different directory.",
139
+ fg="red",
140
+ err=True,
141
+ )
142
+ raise SystemExit(1)
143
+ except OSError:
144
+ # Current directory may already be deleted
145
+ pass
146
+ click.secho("🧹 Cleaning output directory: ", fg="cyan", nl=False)
147
+ click.echo(output_path)
148
+ shutil.rmtree(output_path)
149
+
150
+ # Generate CLI project
151
+ click.secho("⚙️ Generating CLI project: ", fg="cyan", nl=False)
152
+ click.echo(output_path)
153
+ generator = CliGenerator(config=cli_config, config_dir=config_path.parent)
154
+ generator.generate(groups, output_path, cli_name, package_name)
155
+
156
+ # Summary
157
+ click.secho(f"\n✓ Generated CLI '{cli_name}'", fg="green", bold=True)
158
+ click.secho(" 📁 Location: ", fg="white", nl=False)
159
+ click.echo(output_path)
160
+ click.secho(" 📦 Package: ", fg="white", nl=False)
161
+ click.echo(package_name)
162
+ click.secho(" 🔧 Commands: ", fg="white", nl=False)
163
+ click.echo(f"{len(groups)} groups")
164
+ for tag, group in groups.items():
165
+ click.secho(f" • {group.cli_name}", fg="yellow", nl=False)
166
+ click.echo(f" ({len(group.operations)} commands)")
167
+
168
+ click.echo()
169
+ click.secho("📋 Next steps:", fg="cyan", bold=True)
170
+ click.echo(f" pip install -e {output_path}")
171
+ click.echo(f" {cli_name} --help")
172
+
173
+
174
+ def _load_cli_config(config_path: Path) -> dict:
175
+ """Load and validate CLI generator configuration from YAML file."""
176
+ try:
177
+ with open(config_path) as f:
178
+ raw_config = yaml.safe_load(f) or {}
179
+ except (yaml.YAMLError, IOError) as e:
180
+ click.secho(f"✗ Could not load config file: {e}", fg="red", err=True)
181
+ raise SystemExit(1)
182
+
183
+ # Validate with Pydantic schema
184
+ try:
185
+ validated = Config(**raw_config)
186
+ config = validated.model_dump()
187
+ except ValidationError as e:
188
+ click.secho("✗ Invalid configuration:", fg="red", err=True)
189
+ for error in e.errors():
190
+ field = ".".join(str(loc) for loc in error["loc"])
191
+ click.secho(f" • {field}: {error['msg']}", fg="red", err=True)
192
+ raise SystemExit(1)
193
+
194
+ # Expand #[Param] references
195
+ return _expand_config_references(config)
196
+
197
+
198
+ def _expand_config_references(config: dict[str, Any]) -> dict[str, Any]:
199
+ """Expand #[Param] references in config values recursively.
200
+
201
+ Supports referencing other config parameters using #[ParamName] syntax.
202
+ Environment variables using ${VAR} syntax are left as-is for runtime expansion.
203
+ Recursively expands until no more references remain.
204
+ """
205
+
206
+ def expand_value(value: Any, config: dict[str, Any]) -> Any:
207
+ if isinstance(value, str):
208
+ # Keep expanding until no more #[Param] references
209
+ pattern = r"#\[(\w+)\]"
210
+ prev_value: str | None = None
211
+ while prev_value != value:
212
+ prev_value = value
213
+ matches = re.findall(pattern, value)
214
+ for param_name in matches:
215
+ if param_name in config:
216
+ replacement = config[param_name]
217
+ if isinstance(replacement, str):
218
+ value = value.replace(f"#[{param_name}]", replacement)
219
+ return value
220
+ elif isinstance(value, dict):
221
+ return {k: expand_value(v, config) for k, v in value.items()}
222
+ elif isinstance(value, list):
223
+ return [expand_value(item, config) for item in value]
224
+ return value
225
+
226
+ # First pass: expand all values
227
+ expanded: dict[str, Any] = expand_value(config, config)
228
+
229
+ # Second pass: re-expand with updated config to handle nested references
230
+ result: dict[str, Any] = expand_value(expanded, expanded)
231
+ return result
File without changes
@@ -0,0 +1,53 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Configuration utilities for CLI Wizard."""
5
+
6
+ import yaml
7
+ from pathlib import Path
8
+ from typing import Dict, Any
9
+ import importlib.resources
10
+
11
+
12
+ def get_config_path() -> Path:
13
+ """Get the configuration file path."""
14
+ config_dir = Path.home() / ".cli_wizard"
15
+ config_dir.mkdir(exist_ok=True)
16
+ return config_dir / "config.yaml"
17
+
18
+
19
+ def load_default_config() -> Dict[str, Any]:
20
+ """Load default configuration from packaged default_config.yaml."""
21
+ try:
22
+ with importlib.resources.open_text(
23
+ "cli_wizard.config", "default_config.yaml"
24
+ ) as f:
25
+ data = yaml.safe_load(f)
26
+ return data if isinstance(data, dict) else {}
27
+ except (yaml.YAMLError, IOError, FileNotFoundError):
28
+ return {}
29
+
30
+
31
+ def load_config() -> Dict[str, Any]:
32
+ """Load configuration from file, merging with defaults."""
33
+ default_config = load_default_config()
34
+ config_path = get_config_path()
35
+
36
+ if not config_path.exists():
37
+ return default_config
38
+
39
+ try:
40
+ with open(config_path, "r") as f:
41
+ user_config = yaml.safe_load(f)
42
+ if isinstance(user_config, dict):
43
+ default_config.update(user_config)
44
+ return default_config
45
+ except (yaml.YAMLError, IOError):
46
+ return default_config
47
+
48
+
49
+ def save_config(config: Dict[str, Any]) -> None:
50
+ """Save configuration to file."""
51
+ config_path = get_config_path()
52
+ with open(config_path, "w") as f:
53
+ yaml.safe_dump(config, f, indent=2)
@@ -0,0 +1,7 @@
1
+ ConfigFileName: config.yaml
2
+ OpenApiFileName: openapi.json
3
+ OutputDir: cli
4
+ # PackageName sets the Python package name for the generated CLI.
5
+ # Hyphens are automatically converted to underscores for Python compatibility.
6
+ # Example: "my-cli" becomes "my_cli" for the package folder.
7
+ PackageName: my-cli
@@ -0,0 +1,182 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Configuration schema for CLI Wizard."""
5
+
6
+ import re
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, Field, field_validator
10
+
11
+
12
+ class Config(BaseModel):
13
+ """CLI Wizard configuration schema."""
14
+
15
+ # Required parameters
16
+ PackageName: str = Field(
17
+ ..., description="Python package name for the generated CLI"
18
+ )
19
+ DefaultBaseUrl: str = Field(..., description="Default API base URL")
20
+
21
+ # Output settings
22
+ OutputDir: str = Field(
23
+ default="#[PackageName]",
24
+ description="Output directory for the generated CLI project",
25
+ )
26
+ MainDir: str = Field(
27
+ default="${HOME}/.#[PackageName]",
28
+ description="Main directory for CLI data (config, cache, logging, etc.)",
29
+ )
30
+ ProfileFile: str = Field(
31
+ default="#[MainDir]/profiles.yaml",
32
+ description="Path to profiles YAML file",
33
+ )
34
+
35
+ # OpenAPI settings
36
+ OpenapiSpec: str = Field(
37
+ default="openapi.json",
38
+ description="Path to OpenAPI spec (relative to config file or absolute)",
39
+ )
40
+ ExcludeTags: list[str] = Field(
41
+ default_factory=list,
42
+ description="Tags to exclude from generation",
43
+ )
44
+ IncludeTags: list[str] = Field(
45
+ default_factory=list,
46
+ description="Tags to include (if empty, all non-excluded tags are included)",
47
+ )
48
+ TagMapping: dict[str, str] = Field(
49
+ default_factory=dict,
50
+ description="Map OpenAPI tags to CLI command group names",
51
+ )
52
+ CommandMapping: dict[str, str] = Field(
53
+ default_factory=dict,
54
+ description="Customize command names (operationId -> command name)",
55
+ )
56
+
57
+ # Output formatting
58
+ OutputFormat: Literal["json", "table", "yaml"] = Field(
59
+ default="json",
60
+ description="Default output format",
61
+ )
62
+ OutputColors: bool = Field(
63
+ default=True,
64
+ description="Enable colored output",
65
+ )
66
+ JsonIndent: int = Field(
67
+ default=2,
68
+ ge=0,
69
+ description="JSON indentation",
70
+ )
71
+ TableStyle: Literal["ascii", "rounded", "minimal", "markdown"] = Field(
72
+ default="rounded",
73
+ description="Table style",
74
+ )
75
+
76
+ # Splash screen
77
+ SplashFile: str | None = Field(
78
+ default=None,
79
+ description="Path to splash text file (relative to config or absolute)",
80
+ )
81
+ SplashColor: str = Field(
82
+ default="#FFFFFF",
83
+ description="Color for splash text (hex code)",
84
+ )
85
+
86
+ # Logging
87
+ LogLevel: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
88
+ default="INFO",
89
+ description="Default log level",
90
+ )
91
+ LogFormat: str = Field(
92
+ default="[%(levelname)s] %(asctime)s %(message)s",
93
+ description="Log message format (Python logging format)",
94
+ )
95
+ LogTimestampFormat: str = Field(
96
+ default="%Y-%m-%dT%H:%M:%S",
97
+ description="Timestamp format for log messages (strftime format)",
98
+ )
99
+ LogTimezone: Literal["UTC", "Local"] = Field(
100
+ default="UTC",
101
+ description="Timezone for log timestamps",
102
+ )
103
+ LogColorStyle: Literal["full", "level"] = Field(
104
+ default="level",
105
+ description="Log color style: 'full' colors entire line, 'level' colors only the level prefix",
106
+ )
107
+ LogColorDebug: str = Field(
108
+ default="#808080",
109
+ description="Color for DEBUG log level (hex code)",
110
+ )
111
+ LogColorInfo: str = Field(
112
+ default="#00FF00",
113
+ description="Color for INFO log level (hex code)",
114
+ )
115
+ LogColorWarning: str = Field(
116
+ default="#FFFF00",
117
+ description="Color for WARNING log level (hex code)",
118
+ )
119
+ LogColorError: str = Field(
120
+ default="#FF0000",
121
+ description="Color for ERROR log level (hex code)",
122
+ )
123
+ LogFile: str | None = Field(
124
+ default=None,
125
+ description="Path to log file (None means no file logging)",
126
+ )
127
+ LogRotationType: Literal["size", "days"] = Field(
128
+ default="days",
129
+ description="Log rotation type: 'size' for file size, 'days' for time-based",
130
+ )
131
+ LogRotationSize: int = Field(
132
+ default=10,
133
+ ge=1,
134
+ description="Log rotation size in MB (when LogRotationType is 'size')",
135
+ )
136
+ LogRotationDays: int = Field(
137
+ default=30,
138
+ ge=1,
139
+ description="Log rotation interval in days (when LogRotationType is 'days')",
140
+ )
141
+ LogRotationBackupCount: int = Field(
142
+ default=5,
143
+ ge=0,
144
+ description="Number of backup log files to keep",
145
+ )
146
+
147
+ # API client settings
148
+ Timeout: int = Field(
149
+ default=30,
150
+ ge=1,
151
+ description="Request timeout in seconds",
152
+ )
153
+ CaFile: str | None = Field(
154
+ default=None,
155
+ description="CA certificate file for SSL verification (relative to config or absolute)",
156
+ )
157
+ RetryMaxAttempts: int = Field(
158
+ default=3,
159
+ ge=0,
160
+ description="Retry max attempts",
161
+ )
162
+ RetryBackoffFactor: float = Field(
163
+ default=0.5,
164
+ ge=0,
165
+ description="Retry backoff factor",
166
+ )
167
+
168
+ @field_validator(
169
+ "SplashColor",
170
+ "LogColorDebug",
171
+ "LogColorInfo",
172
+ "LogColorWarning",
173
+ "LogColorError",
174
+ )
175
+ @classmethod
176
+ def validate_hex_color(cls, v: str) -> str:
177
+ """Validate that color is a valid hex color code."""
178
+ if not re.match(r"^#[0-9A-Fa-f]{6}$", v):
179
+ raise ValueError(f"Invalid hex color code: {v}. Must be in format #RRGGBB")
180
+ return v.upper()
181
+
182
+ model_config = {"extra": "forbid"}
@@ -0,0 +1,11 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Constants for CLI Wizard."""
5
+
6
+ from importlib.metadata import version, PackageNotFoundError
7
+
8
+ try:
9
+ __version__ = version("cli-wizard")
10
+ except PackageNotFoundError:
11
+ __version__ = "0.0.0"
@@ -0,0 +1,22 @@
1
+ # Copyright (c) 2026, Giacomo Marciani
2
+ # Licensed under the MIT License
3
+
4
+ """Generator package for CLI Wizard."""
5
+
6
+ from cli_wizard.generator.parser import OpenApiParser
7
+ from cli_wizard.generator.generator import CliGenerator
8
+ from cli_wizard.generator.models import (
9
+ Parameter,
10
+ RequestBodyProperty,
11
+ Operation,
12
+ CommandGroup,
13
+ )
14
+
15
+ __all__ = [
16
+ "OpenApiParser",
17
+ "CliGenerator",
18
+ "Parameter",
19
+ "RequestBodyProperty",
20
+ "Operation",
21
+ "CommandGroup",
22
+ ]