cli-wizard 2.0.0__tar.gz → 2.1.0__tar.gz
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-2.0.0/src/cli_wizard.egg-info → cli_wizard-2.1.0}/PKG-INFO +16 -18
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/README.md +1 -1
- cli_wizard-2.1.0/VERSION +1 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/pyproject.toml +19 -18
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/cli.py +4 -2
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/commands/bootstrap.py +60 -29
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/commands/common.py +2 -1
- cli_wizard-2.1.0/src/cli_wizard/commands/config.py +106 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/commands/generate.py +69 -30
- cli_wizard-2.1.0/src/cli_wizard/config/configuration.py +77 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/config/schema.py +131 -9
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/constants.py +1 -1
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/generator/generator.py +104 -19
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/generator/models.py +4 -1
- cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/changelog_enforcer.yaml → cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/changelog-enforcer.yaml +1 -1
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/workflows/codeql.yaml +4 -4
- cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/docs.yaml → cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/docs.yaml.j2 +6 -6
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/workflows/labeler.yaml +1 -1
- cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/pr-validation.yaml → cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/pr-validation.yaml.j2 +5 -5
- cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/release.yaml → cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/release.yaml.j2 +5 -5
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/workflows/sync-labels.yaml +2 -2
- cli_wizard-2.1.0/src/cli_wizard/templates/.github/workflows/test.yaml.j2 +46 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/Makefile.j2 +4 -3
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/pre-commit-config.yaml.j2 +7 -11
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/pyproject.toml.j2 +20 -19
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/client.py.j2 +2 -2
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/commands/__init__.py.j2 +1 -1
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/logging.py.j2 +1 -1
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/profile.py.j2 +2 -2
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/tests/{{ PackageName }}/cli_test.py.j2 +2 -4
- cli_wizard-2.1.0/src/cli_wizard/templates/tox.ini.j2 +27 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0/src/cli_wizard.egg-info}/PKG-INFO +16 -18
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard.egg-info/SOURCES.txt +5 -5
- cli_wizard-2.1.0/src/cli_wizard.egg-info/requires.txt +24 -0
- cli_wizard-2.0.0/VERSION +0 -1
- cli_wizard-2.0.0/src/cli_wizard/commands/config.py +0 -82
- cli_wizard-2.0.0/src/cli_wizard/config/configuration.py +0 -45
- cli_wizard-2.0.0/src/cli_wizard/templates/.github/workflows/test.yaml +0 -46
- cli_wizard-2.0.0/src/cli_wizard/templates/tox.ini.j2 +0 -27
- cli_wizard-2.0.0/src/cli_wizard.egg-info/requires.txt +0 -26
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/LICENSE +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/MANIFEST.in +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/setup.cfg +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/__init__.py +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/commands/__init__.py +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/config/__init__.py +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/generator/__init__.py +3 -3
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/generator/parser.py +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/CODEOWNERS.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/ISSUE_TEMPLATE/bug-report.yml.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/ISSUE_TEMPLATE/config.yml.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/ISSUE_TEMPLATE/feature-request.yml.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/dependabot.yaml +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/labeler.yaml +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.github/labels.yaml.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/.gitignore.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/CHANGELOG.md.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/DEVELOPMENT.md.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/LICENSE.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/MANIFEST.in.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/README.md.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/VERSION.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/cli-wizard.yaml.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/__init__.py.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/cli.py.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/commands/config.py.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/commands/group.py.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard/templates/src/{{ PackageName }}/constants.py.j2 +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard.egg-info/dependency_links.txt +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard.egg-info/entry_points.txt +0 -0
- {cli_wizard-2.0.0 → cli_wizard-2.1.0}/src/cli_wizard.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cli-wizard
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Generate modern CLIs from OpenAPI specifications
|
|
5
5
|
Author-email: Giacomo Marciani <giacomo.marciani@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -17,24 +17,22 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
17
17
|
Requires-Python: >=3.12
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE
|
|
20
|
-
Requires-Dist: click~=8.
|
|
21
|
-
Requires-Dist: Jinja2~=3.1
|
|
22
|
-
Requires-Dist: pydantic~=2.
|
|
23
|
-
Requires-Dist: PyYAML~=6.0
|
|
24
|
-
Requires-Dist: requests~=2.
|
|
20
|
+
Requires-Dist: click~=8.4
|
|
21
|
+
Requires-Dist: Jinja2~=3.1
|
|
22
|
+
Requires-Dist: pydantic~=2.13
|
|
23
|
+
Requires-Dist: PyYAML~=6.0
|
|
24
|
+
Requires-Dist: requests~=2.34
|
|
25
|
+
Requires-Dist: ruff~=0.16.2
|
|
25
26
|
Provides-Extra: dev
|
|
26
|
-
Requires-Dist:
|
|
27
|
-
Requires-Dist:
|
|
28
|
-
Requires-Dist:
|
|
29
|
-
Requires-Dist:
|
|
30
|
-
Requires-Dist:
|
|
31
|
-
Requires-Dist:
|
|
32
|
-
Requires-Dist:
|
|
33
|
-
Requires-Dist: pytest-cov~=7.0; extra == "dev"
|
|
34
|
-
Requires-Dist: tox~=4.34; extra == "dev"
|
|
35
|
-
Requires-Dist: twine~=6.2; extra == "dev"
|
|
27
|
+
Requires-Dist: build~=1.5; extra == "dev"
|
|
28
|
+
Requires-Dist: mypy~=2.3; extra == "dev"
|
|
29
|
+
Requires-Dist: pre-commit~=4.6; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest~=9.1; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov~=7.1; extra == "dev"
|
|
32
|
+
Requires-Dist: tox~=4.58; extra == "dev"
|
|
33
|
+
Requires-Dist: twine<8.0,>=6.2; extra == "dev"
|
|
36
34
|
Requires-Dist: types-PyYAML~=6.0; extra == "dev"
|
|
37
|
-
Requires-Dist: types-requests~=2.
|
|
35
|
+
Requires-Dist: types-requests~=2.33; extra == "dev"
|
|
38
36
|
Provides-Extra: docs
|
|
39
37
|
Requires-Dist: sphinx~=9.1; extra == "docs"
|
|
40
38
|
Requires-Dist: sphinx-click~=6.2; extra == "docs"
|
|
@@ -54,7 +52,7 @@ Dynamic: license-file
|
|
|
54
52
|
[](https://github.com/gmarciani/cli-wizard/actions)
|
|
55
53
|
[](https://github.com/gmarciani/cli-wizard/actions)
|
|
56
54
|
[](https://codecov.io/gh/gmarciani/cli-wizard)
|
|
57
|
-
[](https://github.com/astral-sh/ruff)
|
|
58
56
|
[](https://pypi.org/project/cli-wizard)
|
|
59
57
|
|
|
60
58
|
</div>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://github.com/gmarciani/cli-wizard/actions)
|
|
10
10
|
[](https://github.com/gmarciani/cli-wizard/actions)
|
|
11
11
|
[](https://codecov.io/gh/gmarciani/cli-wizard)
|
|
12
|
-
[](https://github.com/astral-sh/ruff)
|
|
13
13
|
[](https://pypi.org/project/cli-wizard)
|
|
14
14
|
|
|
15
15
|
</div>
|
cli_wizard-2.1.0/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.1.0
|
|
@@ -22,27 +22,25 @@ classifiers = [
|
|
|
22
22
|
]
|
|
23
23
|
requires-python = ">=3.12"
|
|
24
24
|
dependencies = [
|
|
25
|
-
"click~=8.
|
|
26
|
-
"Jinja2~=3.1
|
|
27
|
-
"pydantic~=2.
|
|
28
|
-
"PyYAML~=6.0
|
|
29
|
-
"requests~=2.
|
|
25
|
+
"click~=8.4",
|
|
26
|
+
"Jinja2~=3.1",
|
|
27
|
+
"pydantic~=2.13",
|
|
28
|
+
"PyYAML~=6.0",
|
|
29
|
+
"requests~=2.34",
|
|
30
|
+
"ruff~=0.16.2",
|
|
30
31
|
]
|
|
31
32
|
|
|
32
33
|
[project.optional-dependencies]
|
|
33
34
|
dev = [
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"pytest-cov~=7.0",
|
|
42
|
-
"tox~=4.34",
|
|
43
|
-
"twine~=6.2",
|
|
35
|
+
"build~=1.5",
|
|
36
|
+
"mypy~=2.3",
|
|
37
|
+
"pre-commit~=4.6",
|
|
38
|
+
"pytest~=9.1",
|
|
39
|
+
"pytest-cov~=7.1",
|
|
40
|
+
"tox~=4.58",
|
|
41
|
+
"twine>=6.2,<8.0",
|
|
44
42
|
"types-PyYAML~=6.0",
|
|
45
|
-
"types-requests~=2.
|
|
43
|
+
"types-requests~=2.33",
|
|
46
44
|
]
|
|
47
45
|
docs = [
|
|
48
46
|
"sphinx~=9.1",
|
|
@@ -76,9 +74,12 @@ cli_wizard = [
|
|
|
76
74
|
[tool.setuptools.dynamic]
|
|
77
75
|
version = {file = "VERSION"}
|
|
78
76
|
|
|
79
|
-
[tool.
|
|
77
|
+
[tool.ruff]
|
|
80
78
|
line-length = 88
|
|
81
|
-
target-version =
|
|
79
|
+
target-version = "py312"
|
|
80
|
+
|
|
81
|
+
[tool.ruff.lint]
|
|
82
|
+
select = ["E", "F", "W", "I"]
|
|
82
83
|
|
|
83
84
|
[tool.mypy]
|
|
84
85
|
python_version = "3.12"
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
"""Main CLI module for CLI Wizard."""
|
|
5
5
|
|
|
6
|
-
import click
|
|
7
6
|
import logging
|
|
8
|
-
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
9
10
|
from cli_wizard.commands.bootstrap import bootstrap
|
|
10
11
|
from cli_wizard.commands.config import config
|
|
11
12
|
from cli_wizard.commands.generate import generate
|
|
13
|
+
from cli_wizard.constants import __version__
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
@click.group(help="CLI Wizard - Generate modern CLI from OpenAPI.")
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
import getpass
|
|
7
7
|
import logging
|
|
8
8
|
import re
|
|
9
|
-
from pathlib import Path
|
|
10
9
|
from datetime import date
|
|
10
|
+
from pathlib import Path
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
import click
|
|
@@ -22,6 +22,10 @@ from cli_wizard.generator import CliGenerator
|
|
|
22
22
|
logger = logging.getLogger(__name__)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
# Width beyond which PyYAML would wrap a scalar onto a second line
|
|
26
|
+
YAML_NO_WRAP_WIDTH = 2**31 - 1
|
|
27
|
+
|
|
28
|
+
|
|
25
29
|
# Parameters prompted during bootstrap (in order)
|
|
26
30
|
BOOTSTRAP_PARAMS: list[str] = [
|
|
27
31
|
"CommandName",
|
|
@@ -115,7 +119,8 @@ You will be guided through a step by step procedure to generate
|
|
|
115
119
|
a basic CLI and an extensible configuration file to evolve it.
|
|
116
120
|
No OpenAPI file is required.
|
|
117
121
|
|
|
118
|
-
PATH is the directory where the project will be created.
|
|
122
|
+
PATH is the directory where the project will be created.
|
|
123
|
+
It can be a relative or absolute path."""
|
|
119
124
|
)
|
|
120
125
|
@click.argument(
|
|
121
126
|
"path",
|
|
@@ -253,13 +258,17 @@ def _yaml_value(value: Any) -> str:
|
|
|
253
258
|
elif isinstance(value, bool):
|
|
254
259
|
return "true" if value else "false"
|
|
255
260
|
elif isinstance(value, str):
|
|
256
|
-
#
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
#
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
# Always double-quoted, and escaped by PyYAML rather than by hand, so
|
|
262
|
+
# that quotes, backslashes and control characters survive the round
|
|
263
|
+
# trip through the file. The template writes one value per line, so
|
|
264
|
+
# line wrapping is disabled.
|
|
265
|
+
return yaml.safe_dump(
|
|
266
|
+
value,
|
|
267
|
+
default_style='"',
|
|
268
|
+
default_flow_style=True,
|
|
269
|
+
allow_unicode=True,
|
|
270
|
+
width=YAML_NO_WRAP_WIDTH,
|
|
271
|
+
).strip()
|
|
263
272
|
elif isinstance(value, (int, float)):
|
|
264
273
|
return str(value)
|
|
265
274
|
elif isinstance(value, list):
|
|
@@ -321,32 +330,54 @@ def _load_cli_config(config_path: Path) -> dict:
|
|
|
321
330
|
raise SystemExit(1)
|
|
322
331
|
|
|
323
332
|
# Expand #[Param] references
|
|
324
|
-
|
|
333
|
+
try:
|
|
334
|
+
return _expand_config_references(config)
|
|
335
|
+
except ValueError as e:
|
|
336
|
+
click.secho("✗ Invalid configuration:", fg="red", err=True)
|
|
337
|
+
click.secho(f" • {e}", fg="red", err=True)
|
|
338
|
+
raise SystemExit(1)
|
|
325
339
|
|
|
326
340
|
|
|
327
341
|
def _expand_config_references(config: dict[str, Any]) -> dict[str, Any]:
|
|
328
|
-
"""Expand #[Param] references in config values recursively.
|
|
342
|
+
"""Expand #[Param] references in config values recursively.
|
|
343
|
+
|
|
344
|
+
Supports referencing other config parameters using #[ParamName] syntax.
|
|
345
|
+
Environment variables using ${VAR} syntax are left as-is for runtime expansion.
|
|
346
|
+
References to unknown or non-string parameters are left as-is. A parameter
|
|
347
|
+
that references itself, directly or through other parameters, raises a
|
|
348
|
+
ValueError rather than expanding forever.
|
|
349
|
+
"""
|
|
350
|
+
pattern = re.compile(r"#\[(\w+)\]")
|
|
351
|
+
resolved: dict[str, str] = {}
|
|
352
|
+
|
|
353
|
+
def resolve(name: str, chain: tuple[str, ...]) -> str:
|
|
354
|
+
"""Resolve a top-level parameter, refusing to expand it into itself."""
|
|
355
|
+
if name in chain:
|
|
356
|
+
path = " -> ".join(chain + (name,))
|
|
357
|
+
raise ValueError(
|
|
358
|
+
f"Circular #[Param] reference in configuration: {path} "
|
|
359
|
+
f'(value of {name!r}: "{config[name]}")'
|
|
360
|
+
)
|
|
361
|
+
if name not in resolved:
|
|
362
|
+
resolved[name] = substitute(config[name], chain + (name,))
|
|
363
|
+
return resolved[name]
|
|
364
|
+
|
|
365
|
+
def substitute(value: str, chain: tuple[str, ...]) -> str:
|
|
366
|
+
def replace(match: re.Match[str]) -> str:
|
|
367
|
+
param_name = match.group(1)
|
|
368
|
+
if isinstance(config.get(param_name), str):
|
|
369
|
+
return resolve(param_name, chain)
|
|
370
|
+
return match.group(0)
|
|
371
|
+
|
|
372
|
+
return pattern.sub(replace, value)
|
|
329
373
|
|
|
330
|
-
def expand_value(value: Any
|
|
374
|
+
def expand_value(value: Any) -> Any:
|
|
331
375
|
if isinstance(value, str):
|
|
332
|
-
|
|
333
|
-
prev_value: str | None = None
|
|
334
|
-
while prev_value != value:
|
|
335
|
-
prev_value = value
|
|
336
|
-
matches = re.findall(pattern, value)
|
|
337
|
-
for param_name in matches:
|
|
338
|
-
if param_name in cfg:
|
|
339
|
-
replacement = cfg[param_name]
|
|
340
|
-
if isinstance(replacement, str):
|
|
341
|
-
value = value.replace(f"#[{param_name}]", replacement)
|
|
342
|
-
return value
|
|
376
|
+
return substitute(value, ())
|
|
343
377
|
elif isinstance(value, dict):
|
|
344
|
-
return {k: expand_value(v
|
|
378
|
+
return {k: expand_value(v) for k, v in value.items()}
|
|
345
379
|
elif isinstance(value, list):
|
|
346
|
-
return [expand_value(item
|
|
380
|
+
return [expand_value(item) for item in value]
|
|
347
381
|
return value
|
|
348
382
|
|
|
349
|
-
|
|
350
|
-
result = expand_value(expanded, expanded)
|
|
351
|
-
# expand_value always returns a dict when given a dict
|
|
352
|
-
return dict(result) if isinstance(result, dict) else config
|
|
383
|
+
return {key: expand_value(value) for key, value in config.items()}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Copyright (c) 2026, Giacomo Marciani
|
|
2
|
+
# Licensed under the MIT License
|
|
3
|
+
|
|
4
|
+
"""Configuration commands for CLI Wizard."""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from cli_wizard.config.configuration import (
|
|
13
|
+
get_config_path,
|
|
14
|
+
load_config,
|
|
15
|
+
load_stored_config,
|
|
16
|
+
save_config,
|
|
17
|
+
)
|
|
18
|
+
from cli_wizard.config.schema import Config
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _check_known_key(key: str) -> None:
|
|
24
|
+
"""Abort if the key is not a configuration field."""
|
|
25
|
+
if key not in Config.model_fields:
|
|
26
|
+
raise click.ClickException(f"Unknown configuration key '{key}'")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group(help="Manage configurations.")
|
|
30
|
+
def config() -> None:
|
|
31
|
+
"""Config command group implementation."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@config.command(help="Set a configuration value.")
|
|
35
|
+
@click.argument("key")
|
|
36
|
+
@click.argument("value")
|
|
37
|
+
def set(key: str, value: str) -> None:
|
|
38
|
+
"""Set command implementation."""
|
|
39
|
+
_check_known_key(key)
|
|
40
|
+
|
|
41
|
+
stored = load_stored_config()
|
|
42
|
+
old_value = load_config().get(key)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
stored[key] = Config.parse_cli_value(key, value)
|
|
46
|
+
save_config(stored)
|
|
47
|
+
except ValidationError as e:
|
|
48
|
+
detail = "; ".join(item["msg"] for item in e.errors())
|
|
49
|
+
raise click.ClickException(f"Invalid value for '{key}': {detail}") from e
|
|
50
|
+
except ValueError as e:
|
|
51
|
+
raise click.ClickException(f"Invalid value for '{key}': {e}") from e
|
|
52
|
+
|
|
53
|
+
result = {"key": key, "value": load_config()[key], "oldValue": old_value}
|
|
54
|
+
print(json.dumps(result, indent=2))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@config.command(help="Get a configuration value.")
|
|
58
|
+
@click.argument("key")
|
|
59
|
+
def get(key: str) -> None:
|
|
60
|
+
"""Get command implementation."""
|
|
61
|
+
_check_known_key(key)
|
|
62
|
+
|
|
63
|
+
result = {"key": key, "value": load_config()[key]}
|
|
64
|
+
print(json.dumps(result, indent=2))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@config.command(help="Unset a configuration value, reverting it to the default.")
|
|
68
|
+
@click.argument("key")
|
|
69
|
+
def unset(key: str) -> None:
|
|
70
|
+
"""Unset command implementation.
|
|
71
|
+
|
|
72
|
+
Removes the key so the schema default applies again. Every field has a
|
|
73
|
+
default, so this cannot leave a config the schema rejects.
|
|
74
|
+
"""
|
|
75
|
+
_check_known_key(key)
|
|
76
|
+
|
|
77
|
+
stored = load_stored_config()
|
|
78
|
+
if key in stored:
|
|
79
|
+
old_value = stored.pop(key)
|
|
80
|
+
save_config(stored)
|
|
81
|
+
else:
|
|
82
|
+
logger.info(f"'{key}' is not set; it already uses the schema default.")
|
|
83
|
+
old_value = None
|
|
84
|
+
|
|
85
|
+
result = {"key": key, "value": load_config()[key], "oldValue": old_value}
|
|
86
|
+
print(json.dumps(result, indent=2))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@config.command(help="Show all configuration values as JSON.")
|
|
90
|
+
def show() -> None:
|
|
91
|
+
"""Show command implementation."""
|
|
92
|
+
print(json.dumps(load_config(), indent=2))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@config.command(help="Reset configuration to defaults and delete local config file.")
|
|
96
|
+
def reset() -> None:
|
|
97
|
+
"""Reset command implementation.
|
|
98
|
+
|
|
99
|
+
Deletes before reading: this is the way back from a config file the schema
|
|
100
|
+
rejects, so it must not depend on that file being loadable.
|
|
101
|
+
"""
|
|
102
|
+
config_path = get_config_path()
|
|
103
|
+
if config_path.exists():
|
|
104
|
+
config_path.unlink()
|
|
105
|
+
|
|
106
|
+
print(json.dumps(Config().model_dump(), indent=2))
|
|
@@ -3,31 +3,33 @@
|
|
|
3
3
|
|
|
4
4
|
"""Generate command for CLI Wizard."""
|
|
5
5
|
|
|
6
|
-
import re
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
import click
|
|
10
6
|
import logging
|
|
7
|
+
import re
|
|
11
8
|
import shutil
|
|
12
9
|
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
13
11
|
|
|
12
|
+
import click
|
|
14
13
|
import yaml
|
|
15
14
|
from pydantic import ValidationError
|
|
16
15
|
|
|
17
16
|
from cli_wizard.config.schema import Config
|
|
18
17
|
from cli_wizard.constants import CONFIG_FILE_NAME
|
|
19
|
-
from cli_wizard.generator import
|
|
18
|
+
from cli_wizard.generator import CliGenerator, OpenApiParser
|
|
19
|
+
from cli_wizard.generator.generator import RuffNotFoundError, resolve_ruff
|
|
20
20
|
|
|
21
21
|
logger = logging.getLogger(__name__)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
@click.command(
|
|
24
|
+
@click.command(
|
|
25
|
+
help="""Generate the CLI from config and OpenAPI spec.
|
|
25
26
|
|
|
26
27
|
PATH is the output directory where the CLI project will be generated.
|
|
27
28
|
It can be a relative or absolute path.
|
|
28
29
|
|
|
29
30
|
If --api is provided, API commands will be generated from the OpenAPI spec.
|
|
30
|
-
Otherwise, a functional CLI is generated without API commands."""
|
|
31
|
+
Otherwise, a functional CLI is generated without API commands."""
|
|
32
|
+
)
|
|
31
33
|
@click.argument(
|
|
32
34
|
"path",
|
|
33
35
|
type=click.Path(file_okay=False, resolve_path=True),
|
|
@@ -47,12 +49,19 @@ Otherwise, a functional CLI is generated without API commands.""")
|
|
|
47
49
|
default=None,
|
|
48
50
|
help="Path to OpenAPI spec file in YAML or JSON format (optional)",
|
|
49
51
|
)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--force",
|
|
54
|
+
"-f",
|
|
55
|
+
is_flag=True,
|
|
56
|
+
help="Skip confirmation prompt if output directory exists and is not empty",
|
|
57
|
+
)
|
|
50
58
|
@click.pass_context
|
|
51
59
|
def generate(
|
|
52
60
|
ctx: click.Context,
|
|
53
61
|
path: str,
|
|
54
62
|
configuration: str,
|
|
55
63
|
api: str | None,
|
|
64
|
+
force: bool,
|
|
56
65
|
) -> None:
|
|
57
66
|
"""Generate command implementation."""
|
|
58
67
|
debug = ctx.obj.get("debug", False) if ctx.obj else False
|
|
@@ -116,6 +125,13 @@ def generate(
|
|
|
116
125
|
fg="cyan",
|
|
117
126
|
)
|
|
118
127
|
|
|
128
|
+
# Verify the formatter before deleting the previous output
|
|
129
|
+
try:
|
|
130
|
+
resolve_ruff()
|
|
131
|
+
except RuffNotFoundError as e:
|
|
132
|
+
click.secho(f"✗ {e}", fg="red", err=True)
|
|
133
|
+
raise SystemExit(1)
|
|
134
|
+
|
|
119
135
|
# Clean up output directory before generating
|
|
120
136
|
if output_path.exists():
|
|
121
137
|
# Check if we're inside the output directory
|
|
@@ -132,6 +148,15 @@ def generate(
|
|
|
132
148
|
except OSError:
|
|
133
149
|
# Current directory may already be deleted
|
|
134
150
|
pass
|
|
151
|
+
|
|
152
|
+
# Confirm before destroying a directory that holds work
|
|
153
|
+
if not force and any(output_path.iterdir()):
|
|
154
|
+
click.confirm(
|
|
155
|
+
f"⚠️ Output directory '{output_path}' is not empty. "
|
|
156
|
+
"Its entire contents will be deleted. Continue?",
|
|
157
|
+
abort=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
135
160
|
click.secho("🧹 Cleaning output directory: ", fg="cyan", nl=False)
|
|
136
161
|
click.echo(output_path)
|
|
137
162
|
shutil.rmtree(output_path)
|
|
@@ -185,7 +210,12 @@ def _load_cli_config(config_path: Path) -> dict:
|
|
|
185
210
|
raise SystemExit(1)
|
|
186
211
|
|
|
187
212
|
# Expand #[Param] references
|
|
188
|
-
|
|
213
|
+
try:
|
|
214
|
+
return _expand_config_references(config)
|
|
215
|
+
except ValueError as e:
|
|
216
|
+
click.secho("✗ Invalid configuration:", fg="red", err=True)
|
|
217
|
+
click.secho(f" • {e}", fg="red", err=True)
|
|
218
|
+
raise SystemExit(1)
|
|
189
219
|
|
|
190
220
|
|
|
191
221
|
def _expand_config_references(config: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -193,32 +223,41 @@ def _expand_config_references(config: dict[str, Any]) -> dict[str, Any]:
|
|
|
193
223
|
|
|
194
224
|
Supports referencing other config parameters using #[ParamName] syntax.
|
|
195
225
|
Environment variables using ${VAR} syntax are left as-is for runtime expansion.
|
|
196
|
-
|
|
226
|
+
References to unknown or non-string parameters are left as-is. A parameter
|
|
227
|
+
that references itself, directly or through other parameters, raises a
|
|
228
|
+
ValueError rather than expanding forever.
|
|
197
229
|
"""
|
|
230
|
+
pattern = re.compile(r"#\[(\w+)\]")
|
|
231
|
+
resolved: dict[str, str] = {}
|
|
232
|
+
|
|
233
|
+
def resolve(name: str, chain: tuple[str, ...]) -> str:
|
|
234
|
+
"""Resolve a top-level parameter, refusing to expand it into itself."""
|
|
235
|
+
if name in chain:
|
|
236
|
+
path = " -> ".join(chain + (name,))
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"Circular #[Param] reference in configuration: {path} "
|
|
239
|
+
f'(value of {name!r}: "{config[name]}")'
|
|
240
|
+
)
|
|
241
|
+
if name not in resolved:
|
|
242
|
+
resolved[name] = substitute(config[name], chain + (name,))
|
|
243
|
+
return resolved[name]
|
|
244
|
+
|
|
245
|
+
def substitute(value: str, chain: tuple[str, ...]) -> str:
|
|
246
|
+
def replace(match: re.Match[str]) -> str:
|
|
247
|
+
param_name = match.group(1)
|
|
248
|
+
if isinstance(config.get(param_name), str):
|
|
249
|
+
return resolve(param_name, chain)
|
|
250
|
+
return match.group(0)
|
|
198
251
|
|
|
199
|
-
|
|
252
|
+
return pattern.sub(replace, value)
|
|
253
|
+
|
|
254
|
+
def expand_value(value: Any) -> Any:
|
|
200
255
|
if isinstance(value, str):
|
|
201
|
-
|
|
202
|
-
pattern = r"#\[(\w+)\]"
|
|
203
|
-
prev_value: str | None = None
|
|
204
|
-
while prev_value != value:
|
|
205
|
-
prev_value = value
|
|
206
|
-
matches = re.findall(pattern, value)
|
|
207
|
-
for param_name in matches:
|
|
208
|
-
if param_name in config:
|
|
209
|
-
replacement = config[param_name]
|
|
210
|
-
if isinstance(replacement, str):
|
|
211
|
-
value = value.replace(f"#[{param_name}]", replacement)
|
|
212
|
-
return value
|
|
256
|
+
return substitute(value, ())
|
|
213
257
|
elif isinstance(value, dict):
|
|
214
|
-
return {k: expand_value(v
|
|
258
|
+
return {k: expand_value(v) for k, v in value.items()}
|
|
215
259
|
elif isinstance(value, list):
|
|
216
|
-
return [expand_value(item
|
|
260
|
+
return [expand_value(item) for item in value]
|
|
217
261
|
return value
|
|
218
262
|
|
|
219
|
-
|
|
220
|
-
expanded: dict[str, Any] = expand_value(config, config)
|
|
221
|
-
|
|
222
|
-
# Second pass: re-expand with updated config to handle nested references
|
|
223
|
-
result: dict[str, Any] = expand_value(expanded, expanded)
|
|
224
|
-
return result
|
|
263
|
+
return {key: expand_value(value) for key, value in config.items()}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright (c) 2026, Giacomo Marciani
|
|
2
|
+
# Licensed under the MIT License
|
|
3
|
+
|
|
4
|
+
"""Configuration utilities for CLI Wizard.
|
|
5
|
+
|
|
6
|
+
``load_stored_config`` returns only the keys the user explicitly set, and that
|
|
7
|
+
is what gets persisted. ``load_config`` returns those merged over the schema
|
|
8
|
+
defaults with derivations applied, and that is what consumers read. Keeping
|
|
9
|
+
them apart is what lets a later ``ProjectName`` change re-derive
|
|
10
|
+
``CommandName`` instead of being pinned by a value that was only ever a
|
|
11
|
+
default.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from cli_wizard.config.schema import Config
|
|
21
|
+
from cli_wizard.constants import CONFIG_FILE_NAME
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_config_path() -> Path:
|
|
27
|
+
"""Get the configuration file path."""
|
|
28
|
+
config_dir = Path.home() / ".cli_wizard"
|
|
29
|
+
config_dir.mkdir(exist_ok=True)
|
|
30
|
+
return config_dir / CONFIG_FILE_NAME
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_stored_config() -> Dict[str, Any]:
|
|
34
|
+
"""Load only the values explicitly stored in the config file.
|
|
35
|
+
|
|
36
|
+
A file that cannot be read, parsed or validated degrades to an empty
|
|
37
|
+
config with a warning, so it can never lock the user out of the commands
|
|
38
|
+
that would repair it.
|
|
39
|
+
"""
|
|
40
|
+
config_path = get_config_path()
|
|
41
|
+
|
|
42
|
+
if not config_path.exists():
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
with open(config_path, "r") as f:
|
|
47
|
+
stored = yaml.safe_load(f)
|
|
48
|
+
if not isinstance(stored, dict):
|
|
49
|
+
raise ValueError("file does not contain a mapping")
|
|
50
|
+
Config(**stored) # Validate only; load_config() builds the merged view.
|
|
51
|
+
return stored
|
|
52
|
+
except (yaml.YAMLError, IOError, ValueError) as e:
|
|
53
|
+
# Pydantic's ValidationError is a ValueError.
|
|
54
|
+
logger.warning(
|
|
55
|
+
f"Ignoring invalid configuration file '{config_path}' ({e}). "
|
|
56
|
+
"Run 'cli-wizard config reset' to recreate it."
|
|
57
|
+
)
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_config() -> Dict[str, Any]:
|
|
62
|
+
"""Load configuration from file, using schema defaults for missing values."""
|
|
63
|
+
return Config(**load_stored_config()).model_dump()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_config(config: Dict[str, Any]) -> None:
|
|
67
|
+
"""Save configuration to file, validating and coercing it first.
|
|
68
|
+
|
|
69
|
+
Only the keys present in ``config`` are persisted: writing back the full
|
|
70
|
+
merged dump would freeze derived values such as ``CommandName``.
|
|
71
|
+
"""
|
|
72
|
+
validated = Config(**config)
|
|
73
|
+
stored = {key: getattr(validated, key) for key in config}
|
|
74
|
+
|
|
75
|
+
config_path = get_config_path()
|
|
76
|
+
with open(config_path, "w") as f:
|
|
77
|
+
yaml.safe_dump(stored, f, indent=2)
|