scaffold-ca-python 0.1.1__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.
- scaffold_ca_python/__init__.py +1 -0
- scaffold_ca_python/cli.py +39 -0
- scaffold_ca_python/commands/__init__.py +0 -0
- scaffold_ca_python/commands/delete_module.py +216 -0
- scaffold_ca_python/commands/generate_driven_adapter.py +182 -0
- scaffold_ca_python/commands/generate_entry_point.py +304 -0
- scaffold_ca_python/commands/generate_helper.py +135 -0
- scaffold_ca_python/commands/generate_model.py +134 -0
- scaffold_ca_python/commands/generate_pipeline.py +158 -0
- scaffold_ca_python/commands/generate_project.py +189 -0
- scaffold_ca_python/commands/generate_use_case.py +136 -0
- scaffold_ca_python/commands/update_project.py +84 -0
- scaffold_ca_python/commands/validate_structure.py +90 -0
- scaffold_ca_python/core/__init__.py +0 -0
- scaffold_ca_python/core/file_writer.py +128 -0
- scaffold_ca_python/core/module_builder.py +127 -0
- scaffold_ca_python/core/name_utils.py +59 -0
- scaffold_ca_python/core/project_detector.py +93 -0
- scaffold_ca_python/core/pyproject_writer.py +169 -0
- scaffold_ca_python/core/structure_validator.py +142 -0
- scaffold_ca_python/core/template_renderer.py +100 -0
- scaffold_ca_python/factory/__init__.py +16 -0
- scaffold_ca_python/factory/driven_adapters/__init__.py +0 -0
- scaffold_ca_python/factory/driven_adapters/da_generic.py +65 -0
- scaffold_ca_python/factory/driven_adapters/da_rest_consumer.py +64 -0
- scaffold_ca_python/factory/driven_adapters/da_secrets.py +64 -0
- scaffold_ca_python/factory/entry_points/__init__.py +0 -0
- scaffold_ca_python/factory/entry_points/ep_agent.py +91 -0
- scaffold_ca_python/factory/entry_points/ep_generic.py +75 -0
- scaffold_ca_python/factory/entry_points/ep_mcp.py +138 -0
- scaffold_ca_python/factory/entry_points/ep_restapi.py +133 -0
- scaffold_ca_python/factory/simple/__init__.py +0 -0
- scaffold_ca_python/factory/simple/delete_module_factory.py +85 -0
- scaffold_ca_python/factory/simple/helper_factory.py +67 -0
- scaffold_ca_python/factory/simple/model_factory.py +57 -0
- scaffold_ca_python/factory/simple/use_case_factory.py +59 -0
- scaffold_ca_python/models/__init__.py +0 -0
- scaffold_ca_python/models/context.py +60 -0
- scaffold_ca_python/models/file_operation.py +47 -0
- scaffold_ca_python/models/layer.py +41 -0
- scaffold_ca_python/models/violation.py +26 -0
- scaffold_ca_python/templates/__init__.py +0 -0
- scaffold_ca_python/templates/driven_adapter/generic/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/driven_adapter/generic/adapter.py.jinja2 +18 -0
- scaffold_ca_python/templates/driven_adapter/generic/test_adapter.py.jinja2 +22 -0
- scaffold_ca_python/templates/driven_adapter/rest_consumer/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/driven_adapter/rest_consumer/rest_consumer.py.jinja2 +27 -0
- scaffold_ca_python/templates/driven_adapter/rest_consumer/test_rest_consumer.py.jinja2 +24 -0
- scaffold_ca_python/templates/driven_adapter/secrets/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/driven_adapter/secrets/secrets_adapter.py.jinja2 +37 -0
- scaffold_ca_python/templates/driven_adapter/secrets/test_secrets_adapter.py.jinja2 +26 -0
- scaffold_ca_python/templates/entry_point/agent/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/agent/agent.py.jinja2 +49 -0
- scaffold_ca_python/templates/entry_point/agent/card.py.jinja2 +15 -0
- scaffold_ca_python/templates/entry_point/agent/entrypoint_main.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/agent/test_agent.py.jinja2 +20 -0
- scaffold_ca_python/templates/entry_point/generic/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/generic/entrypoint_main.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/generic/handler.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/generic/test_handler.py.jinja2 +35 -0
- scaffold_ca_python/templates/entry_point/mcp/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/mcp/app.py.jinja2 +51 -0
- scaffold_ca_python/templates/entry_point/mcp/prompts.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/mcp/resources.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/mcp/server.py.jinja2 +27 -0
- scaffold_ca_python/templates/entry_point/mcp/test_app.py.jinja2 +32 -0
- scaffold_ca_python/templates/entry_point/mcp/test_prompts.py.jinja2 +40 -0
- scaffold_ca_python/templates/entry_point/mcp/test_resources.py.jinja2 +47 -0
- scaffold_ca_python/templates/entry_point/mcp/test_tools.py.jinja2 +40 -0
- scaffold_ca_python/templates/entry_point/mcp/tools.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/restapi/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/restapi/app.py.jinja2 +78 -0
- scaffold_ca_python/templates/entry_point/restapi/exception_handler.py.jinja2 +35 -0
- scaffold_ca_python/templates/entry_point/restapi/health.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/restapi/rest_controller.py.jinja2 +26 -0
- scaffold_ca_python/templates/entry_point/restapi/server.py.jinja2 +5 -0
- scaffold_ca_python/templates/entry_point/restapi/test_app.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/restapi/test_exception_handler.py.jinja2 +44 -0
- scaffold_ca_python/templates/entry_point/restapi/test_rest_controller.py.jinja2 +35 -0
- scaffold_ca_python/templates/entry_point/restapi/test_server.py.jinja2 +15 -0
- scaffold_ca_python/templates/helper/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/helper/helper.py.jinja2 +7 -0
- scaffold_ca_python/templates/helper/test_helper.py.jinja2 +8 -0
- scaffold_ca_python/templates/model/model.py.jinja2 +9 -0
- scaffold_ca_python/templates/model/test_model.py.jinja2 +8 -0
- scaffold_ca_python/templates/pipeline/azure/azure_pipelines.yml.jinja2 +28 -0
- scaffold_ca_python/templates/pipeline/github/ci.yml.jinja2 +34 -0
- scaffold_ca_python/templates/project/README.jinja2 +30 -0
- scaffold_ca_python/templates/project/application/config/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/project/application/config/config.py.jinja2 +12 -0
- scaffold_ca_python/templates/project/application/config/container.py.jinja2 +17 -0
- scaffold_ca_python/templates/project/application/config/driven_adapters_container.py.jinja2 +14 -0
- scaffold_ca_python/templates/project/application/config/resource_container.py.jinja2 +17 -0
- scaffold_ca_python/templates/project/application/config/usecases_container.py.jinja2 +16 -0
- scaffold_ca_python/templates/project/dockerfile.jinja2 +22 -0
- scaffold_ca_python/templates/project/dockerignore.jinja2 +19 -0
- scaffold_ca_python/templates/project/gitignore.jinja2 +64 -0
- scaffold_ca_python/templates/project/layer_init.jinja2 +1 -0
- scaffold_ca_python/templates/project/main.py.jinja2 +10 -0
- scaffold_ca_python/templates/project/mypy_ini.jinja2 +5 -0
- scaffold_ca_python/templates/project/pyproject_toml.jinja2 +66 -0
- scaffold_ca_python/templates/project/python_version.jinja2 +1 -0
- scaffold_ca_python/templates/use_case/test_use_case.py.jinja2 +12 -0
- scaffold_ca_python/templates/use_case/use_case.py.jinja2 +9 -0
- scaffold_ca_python-0.1.1.dist-info/METADATA +285 -0
- scaffold_ca_python-0.1.1.dist-info/RECORD +109 -0
- scaffold_ca_python-0.1.1.dist-info/WHEEL +4 -0
- scaffold_ca_python-0.1.1.dist-info/entry_points.txt +3 -0
- scaffold_ca_python-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""generate_pipeline: scaffold a CI/CD pipeline configuration file (T069)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.tree import Tree
|
|
13
|
+
|
|
14
|
+
from scaffold_ca_python.core.file_writer import FileWriter
|
|
15
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError
|
|
16
|
+
from scaffold_ca_python.core.project_detector import find_project_root
|
|
17
|
+
from scaffold_ca_python.core.template_renderer import TemplateRenderer
|
|
18
|
+
from scaffold_ca_python.models.context import ProjectContext
|
|
19
|
+
from scaffold_ca_python.models.file_operation import CreateFile, FileOperation, GeneratedFile
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
renderer = TemplateRenderer()
|
|
23
|
+
writer = FileWriter()
|
|
24
|
+
|
|
25
|
+
_ALLOWED_PROVIDERS = ("github", "azure")
|
|
26
|
+
|
|
27
|
+
_GPIPE_HELP = "Scaffold a CI/CD pipeline configuration file. Alias: 'gpipe'"
|
|
28
|
+
_GPIPE_EPILOG = (
|
|
29
|
+
"Providers:\n\n"
|
|
30
|
+
" * github GitHub Actions (.github/workflows/ci.yml)\n\n"
|
|
31
|
+
" * azure Azure Pipelines (azure-pipelines.yml)\n\n"
|
|
32
|
+
" ==================================================\n\n"
|
|
33
|
+
"Note: --provider is required.\n\n"
|
|
34
|
+
"Examples:\n\n"
|
|
35
|
+
" * scaffold gpipe --provider github\n\n"
|
|
36
|
+
" * scaffold generate-pipeline --provider azure"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Module-level option default needed with from __future__ import annotations
|
|
40
|
+
_PROVIDER_DEFAULT: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _generate_pipeline_impl(provider: str | None, dry_run: bool) -> None:
|
|
44
|
+
# --- Validate provider presence ---
|
|
45
|
+
if not provider:
|
|
46
|
+
console.print(f"[red]Error:[/red] --provider is required. Choose from: {', '.join(_ALLOWED_PROVIDERS)}.")
|
|
47
|
+
raise typer.Exit(code=1) from None
|
|
48
|
+
|
|
49
|
+
# --- Validate provider value ---
|
|
50
|
+
if provider not in _ALLOWED_PROVIDERS:
|
|
51
|
+
console.print(f"[red]Error:[/red] Unknown provider '{provider}'. Allowed: {', '.join(_ALLOWED_PROVIDERS)}.")
|
|
52
|
+
raise typer.Exit(code=1) from None
|
|
53
|
+
|
|
54
|
+
# --- Locate project root ---
|
|
55
|
+
try:
|
|
56
|
+
project_root = find_project_root()
|
|
57
|
+
except ScaffoldError:
|
|
58
|
+
console.print("[red]Error:[/red] No scaffold-ca-python project found. Run 'scaffold ca' first.")
|
|
59
|
+
raise typer.Exit(code=1) from None
|
|
60
|
+
|
|
61
|
+
project_ctx = _load_project_context(project_root)
|
|
62
|
+
ctx_dict = project_ctx.model_dump()
|
|
63
|
+
|
|
64
|
+
# --- Resolve output path ---
|
|
65
|
+
if provider == "github":
|
|
66
|
+
out_path = project_root / ".github" / "workflows" / "ci.yml"
|
|
67
|
+
template = "pipeline/github/ci.yml.jinja2"
|
|
68
|
+
display_name = "ci.yml"
|
|
69
|
+
else: # azure
|
|
70
|
+
out_path = project_root / "azure-pipelines.yml"
|
|
71
|
+
template = "pipeline/azure/azure_pipelines.yml.jinja2"
|
|
72
|
+
display_name = "azure-pipelines.yml"
|
|
73
|
+
|
|
74
|
+
# --- Duplicate guard ---
|
|
75
|
+
if out_path.exists():
|
|
76
|
+
console.print(
|
|
77
|
+
f"[red]Error:[/red] '{display_name}' already exists at "
|
|
78
|
+
f"'{out_path.relative_to(project_root)}'. Use --force to overwrite.\n"
|
|
79
|
+
"[dim]Hint:[/dim] Remove the existing file first or choose a different provider."
|
|
80
|
+
)
|
|
81
|
+
raise typer.Exit(code=1) from None
|
|
82
|
+
|
|
83
|
+
operations: list[FileOperation] = [
|
|
84
|
+
CreateFile(
|
|
85
|
+
file=GeneratedFile(
|
|
86
|
+
path=out_path,
|
|
87
|
+
content=renderer.render_string(_tmpl(template), ctx_dict),
|
|
88
|
+
template_name=template,
|
|
89
|
+
)
|
|
90
|
+
),
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
if dry_run:
|
|
94
|
+
preview = writer.execute(operations, dry_run=True)
|
|
95
|
+
tree = Tree(f"[bold]{display_name}[/bold] (dry run)")
|
|
96
|
+
for p in sorted(preview):
|
|
97
|
+
tree.add(str(p.relative_to(project_root)))
|
|
98
|
+
console.print(tree)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
created = writer.execute(operations, dry_run=False)
|
|
102
|
+
console.print(f"[green]✓[/green] Pipeline [bold]{display_name}[/bold] created. Created {len(created)} file(s).")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def register(app: typer.Typer) -> None:
|
|
106
|
+
"""Register gpipe / generate-pipeline commands onto *app*."""
|
|
107
|
+
|
|
108
|
+
@app.command(
|
|
109
|
+
"generate-pipeline",
|
|
110
|
+
help=_GPIPE_HELP,
|
|
111
|
+
epilog=_GPIPE_EPILOG,
|
|
112
|
+
)
|
|
113
|
+
@app.command("gpipe", hidden=True, help=_GPIPE_HELP, epilog=_GPIPE_EPILOG)
|
|
114
|
+
def generate_pipeline(
|
|
115
|
+
ctx: typer.Context,
|
|
116
|
+
provider: Annotated[
|
|
117
|
+
str | None,
|
|
118
|
+
typer.Option(
|
|
119
|
+
"--provider",
|
|
120
|
+
help="Pipeline provider (github, azure).",
|
|
121
|
+
rich_help_panel="Required",
|
|
122
|
+
),
|
|
123
|
+
] = None,
|
|
124
|
+
dry_run: Annotated[
|
|
125
|
+
bool,
|
|
126
|
+
typer.Option(
|
|
127
|
+
"--dry-run/--no-dry-run",
|
|
128
|
+
help="Preview without writing.",
|
|
129
|
+
rich_help_panel="Options",
|
|
130
|
+
show_default=True,
|
|
131
|
+
),
|
|
132
|
+
] = False,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Scaffold a CI/CD pipeline file for the specified provider."""
|
|
135
|
+
if provider is None:
|
|
136
|
+
typer.echo(ctx.get_help())
|
|
137
|
+
raise typer.Exit(0)
|
|
138
|
+
_generate_pipeline_impl(provider, dry_run)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Helpers
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _load_project_context(root: Path) -> ProjectContext:
|
|
147
|
+
pyproject = root / "pyproject.toml"
|
|
148
|
+
with pyproject.open("rb") as fh:
|
|
149
|
+
data = tomllib.load(fh)
|
|
150
|
+
section = data.get("tool", {}).get("scaffold-ca-python", {})
|
|
151
|
+
return ProjectContext(
|
|
152
|
+
name=section.get("name", root.name),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _tmpl(name: str) -> str:
|
|
157
|
+
ref = importlib.resources.files("scaffold_ca_python.templates").joinpath(name)
|
|
158
|
+
return ref.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""generate_project: scaffold a full Clean Architecture project skeleton (T032)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.tree import Tree
|
|
12
|
+
|
|
13
|
+
from scaffold_ca_python.core.file_writer import FileWriter
|
|
14
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_name
|
|
15
|
+
from scaffold_ca_python.core.template_renderer import TemplateRenderer
|
|
16
|
+
from scaffold_ca_python.models.context import ProjectContext
|
|
17
|
+
from scaffold_ca_python.models.file_operation import CreateFile, FileOperation, GeneratedFile
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
renderer = TemplateRenderer()
|
|
21
|
+
writer = FileWriter()
|
|
22
|
+
|
|
23
|
+
# Layers: (subpath-under-src/<pkg>, label for layer_init.jinja2)
|
|
24
|
+
_LAYER_DIRS: list[tuple[str, str]] = [
|
|
25
|
+
("application", "application"),
|
|
26
|
+
("domain/model", "domain/model"),
|
|
27
|
+
("domain/usecase", "domain/usecase"),
|
|
28
|
+
("infrastructure/driven_adapters", "infrastructure/driven-adapters"),
|
|
29
|
+
("infrastructure/entry_points", "infrastructure/entry-points"),
|
|
30
|
+
("infrastructure/helpers", "infrastructure/helpers"),
|
|
31
|
+
]
|
|
32
|
+
_GCA = "Scaffold a new Clean Architecture project. Alias: 'ca'"
|
|
33
|
+
_GCA_EPILOG = "Examples:\n\n * scaffold ca --name my-project\n\n * scaffold clean-architecture --name my_project"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _generate_project_impl(
|
|
37
|
+
name: str,
|
|
38
|
+
dry_run: bool,
|
|
39
|
+
) -> None:
|
|
40
|
+
try:
|
|
41
|
+
validate_name(name)
|
|
42
|
+
except ScaffoldError as exc:
|
|
43
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
44
|
+
raise typer.Exit(code=1) from None
|
|
45
|
+
|
|
46
|
+
python_pkg = to_snake_case(name)
|
|
47
|
+
target_dir = Path.cwd() / name
|
|
48
|
+
|
|
49
|
+
if target_dir.exists():
|
|
50
|
+
console.print(
|
|
51
|
+
f"[red]Error:[/red] Directory '{python_pkg}/' already exists. "
|
|
52
|
+
"Aborting to prevent data loss.\n"
|
|
53
|
+
"[dim]Hint:[/dim] Choose a different project name or remove the existing directory first."
|
|
54
|
+
)
|
|
55
|
+
raise typer.Exit(code=1)
|
|
56
|
+
|
|
57
|
+
ctx = ProjectContext(name=name)
|
|
58
|
+
ctx_dict = ctx.model_dump()
|
|
59
|
+
ctx_dict["created_at"] = datetime.now(tz=UTC).isoformat()
|
|
60
|
+
|
|
61
|
+
operations: list[FileOperation] = []
|
|
62
|
+
|
|
63
|
+
# --- Config files at project root ----------------------------------------
|
|
64
|
+
_add(
|
|
65
|
+
operations,
|
|
66
|
+
target_dir / "pyproject.toml",
|
|
67
|
+
renderer.render_string(_tmpl("project/pyproject_toml.jinja2"), ctx_dict),
|
|
68
|
+
)
|
|
69
|
+
_add(operations, target_dir / "README.md", renderer.render_string(_tmpl("project/README.jinja2"), ctx_dict))
|
|
70
|
+
_add(operations, target_dir / ".gitignore", renderer.render_string(_tmpl("project/gitignore.jinja2"), ctx_dict))
|
|
71
|
+
_add(operations, target_dir / "mypy.ini", renderer.render_string(_tmpl("project/mypy_ini.jinja2"), ctx_dict))
|
|
72
|
+
_add(operations, target_dir / "Dockerfile", renderer.render_string(_tmpl("project/dockerfile.jinja2"), ctx_dict))
|
|
73
|
+
_add(
|
|
74
|
+
operations, target_dir / ".dockerignore", renderer.render_string(_tmpl("project/dockerignore.jinja2"), ctx_dict)
|
|
75
|
+
)
|
|
76
|
+
_add(
|
|
77
|
+
operations,
|
|
78
|
+
target_dir / ".python-version",
|
|
79
|
+
renderer.render_string(_tmpl("project/python_version.jinja2"), ctx_dict),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# --- DI config files -----------------------------------------------------
|
|
83
|
+
_di_cfg = target_dir / "src" / python_pkg / "application" / "config"
|
|
84
|
+
for _fname in (
|
|
85
|
+
"__init__.py",
|
|
86
|
+
"config.py",
|
|
87
|
+
"resource_container.py",
|
|
88
|
+
"driven_adapters_container.py",
|
|
89
|
+
"usecases_container.py",
|
|
90
|
+
"container.py",
|
|
91
|
+
):
|
|
92
|
+
_add(
|
|
93
|
+
operations,
|
|
94
|
+
_di_cfg / _fname,
|
|
95
|
+
renderer.render_string(_tmpl(f"project/application/config/{_fname}.jinja2"), ctx_dict),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# --- src/<pkg>/main.py ---------------------------------------------------
|
|
99
|
+
_add(
|
|
100
|
+
operations,
|
|
101
|
+
target_dir / "src" / python_pkg / "main.py",
|
|
102
|
+
renderer.render_string(_tmpl("project/main.py.jinja2"), ctx_dict),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# --- src/<pkg>/__init__.py ------------------------------------------------
|
|
106
|
+
_add(operations, target_dir / "src" / python_pkg / "__init__.py", f'"""{name} package."""\n')
|
|
107
|
+
|
|
108
|
+
# --- Layer __init__.py stubs ---------------------------------------------
|
|
109
|
+
for layer_path, layer_label in _LAYER_DIRS:
|
|
110
|
+
layer_ctx = {**ctx_dict, "layer": layer_label}
|
|
111
|
+
_add(
|
|
112
|
+
operations,
|
|
113
|
+
target_dir / "src" / python_pkg / layer_path / "__init__.py",
|
|
114
|
+
renderer.render_string(_tmpl("project/layer_init.jinja2"), layer_ctx),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# --- tests/__init__.py ---------------------------------------------------
|
|
118
|
+
_add(operations, target_dir / "src" / "tests" / "__init__.py", "")
|
|
119
|
+
|
|
120
|
+
if dry_run:
|
|
121
|
+
preview = writer.execute(operations, dry_run=True)
|
|
122
|
+
tree = Tree(f"[bold]{python_pkg}/[/bold] (dry run)")
|
|
123
|
+
for p in sorted(preview):
|
|
124
|
+
tree.add(str(p.relative_to(target_dir)))
|
|
125
|
+
console.print(tree)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
created = writer.execute(operations, dry_run=False)
|
|
129
|
+
console.print(
|
|
130
|
+
f"[green]✓[/green] Project [bold]{python_pkg}/[/bold] created successfully. Created {len(created)} file(s)."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def register(app: typer.Typer) -> None:
|
|
135
|
+
"""Register clean-architecture / ca commands onto *app*."""
|
|
136
|
+
|
|
137
|
+
@app.command(
|
|
138
|
+
"clean-architecture",
|
|
139
|
+
help=_GCA,
|
|
140
|
+
epilog=_GCA_EPILOG,
|
|
141
|
+
)
|
|
142
|
+
@app.command(
|
|
143
|
+
"ca",
|
|
144
|
+
hidden=True,
|
|
145
|
+
help=_GCA,
|
|
146
|
+
epilog=_GCA_EPILOG,
|
|
147
|
+
)
|
|
148
|
+
def clean_architecture(
|
|
149
|
+
ctx: typer.Context,
|
|
150
|
+
name: Annotated[
|
|
151
|
+
str,
|
|
152
|
+
typer.Option(
|
|
153
|
+
"--name",
|
|
154
|
+
help="Project name (PascalCase or snake_case).",
|
|
155
|
+
rich_help_panel="Required",
|
|
156
|
+
),
|
|
157
|
+
] = None, # type: ignore[assignment]
|
|
158
|
+
dry_run: Annotated[
|
|
159
|
+
bool,
|
|
160
|
+
typer.Option(
|
|
161
|
+
"--dry-run/--no-dry-run",
|
|
162
|
+
help="Preview files without writing.",
|
|
163
|
+
rich_help_panel="Options",
|
|
164
|
+
show_default=True,
|
|
165
|
+
),
|
|
166
|
+
] = False,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Scaffold a complete Clean Architecture Python project."""
|
|
169
|
+
if name is None:
|
|
170
|
+
typer.echo(ctx.get_help())
|
|
171
|
+
raise typer.Exit(0)
|
|
172
|
+
_generate_project_impl(name, dry_run)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Helpers
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _add(operations: list[FileOperation], path: Path, content: str) -> None:
|
|
181
|
+
operations.append(CreateFile(file=GeneratedFile(path=path, content=content, template_name="")))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _tmpl(name: str) -> str:
|
|
185
|
+
"""Read a bundled template source string via the renderer's resource loader."""
|
|
186
|
+
import importlib.resources
|
|
187
|
+
|
|
188
|
+
ref = importlib.resources.files("scaffold_ca_python.templates").joinpath(name)
|
|
189
|
+
return ref.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""generate_use_case: scaffold an async use case class and test stub (T035)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.tree import Tree
|
|
12
|
+
|
|
13
|
+
from scaffold_ca_python.core.module_builder import ModuleBuilder
|
|
14
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_name
|
|
15
|
+
from scaffold_ca_python.core.project_detector import find_project_root
|
|
16
|
+
from scaffold_ca_python.factory import ModuleFactory
|
|
17
|
+
from scaffold_ca_python.factory.simple.use_case_factory import UseCaseFactory
|
|
18
|
+
from scaffold_ca_python.models.context import ModuleContext, ProjectContext
|
|
19
|
+
from scaffold_ca_python.models.layer import Layer
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
_REGISTRY: dict[str, type[ModuleFactory]] = {
|
|
24
|
+
"use_case": UseCaseFactory,
|
|
25
|
+
}
|
|
26
|
+
_GUC_HELP = "Scaffold a use case in domain/usecase/. Alias 'guc'."
|
|
27
|
+
_GUC_EPILOG = "Example:\n\n * scaffold guc --name CreateOrder\n\n * scaffold generate-use-case --name CreateOrder\n\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _generate_use_case_impl(name: str, dry_run: bool) -> None:
|
|
31
|
+
# --- Validate name ---
|
|
32
|
+
try:
|
|
33
|
+
validate_name(name)
|
|
34
|
+
except ScaffoldError as exc:
|
|
35
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
36
|
+
raise typer.Exit(code=1) from None
|
|
37
|
+
|
|
38
|
+
# --- Locate project root ---
|
|
39
|
+
try:
|
|
40
|
+
project_root = find_project_root()
|
|
41
|
+
except ScaffoldError:
|
|
42
|
+
console.print("[red]Error:[/red] No scaffold-ca-python project found. Run 'scaffold ca' first.")
|
|
43
|
+
raise typer.Exit(code=1) from None
|
|
44
|
+
|
|
45
|
+
project_ctx = _load_project_context(project_root)
|
|
46
|
+
module_ctx = ModuleContext(name=name, layer=Layer.DOMAIN_USECASE, project=project_ctx)
|
|
47
|
+
|
|
48
|
+
pkg = project_ctx.python_package
|
|
49
|
+
snake = to_snake_case(name)
|
|
50
|
+
src_path = project_root / "src" / pkg / "domain" / "usecase" / f"{snake}.py"
|
|
51
|
+
|
|
52
|
+
# --- Duplicate guard ---
|
|
53
|
+
if src_path.exists():
|
|
54
|
+
console.print(
|
|
55
|
+
f"[red]Error:[/red] File '{src_path.relative_to(project_root)}' already exists.\n"
|
|
56
|
+
"[dim]Hint:[/dim] Choose a different name or remove the existing file first."
|
|
57
|
+
)
|
|
58
|
+
raise typer.Exit(code=1) from None
|
|
59
|
+
|
|
60
|
+
# --- Create builder and get factory from registry ---
|
|
61
|
+
builder = ModuleBuilder(
|
|
62
|
+
project_root=project_root,
|
|
63
|
+
project_ctx=project_ctx,
|
|
64
|
+
module_ctx=module_ctx,
|
|
65
|
+
dry_run=dry_run,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Get factory class and instantiate
|
|
69
|
+
factory_class = _REGISTRY["use_case"]
|
|
70
|
+
factory = factory_class()
|
|
71
|
+
|
|
72
|
+
# Invoke factory to build via ModuleBuilder
|
|
73
|
+
factory.build(builder)
|
|
74
|
+
|
|
75
|
+
# Persist all operations
|
|
76
|
+
created = builder.persist()
|
|
77
|
+
|
|
78
|
+
# --- Display results ---
|
|
79
|
+
if dry_run:
|
|
80
|
+
tree = Tree(f"[bold]{snake}[/bold] (dry run)")
|
|
81
|
+
for p in sorted(created):
|
|
82
|
+
tree.add(str(p.relative_to(project_root)))
|
|
83
|
+
console.print(tree)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
msg = (
|
|
87
|
+
f"[green]\u2713[/green] Use case [bold]{module_ctx.class_name}UseCase[/bold] "
|
|
88
|
+
f"created. Created {len(created)} file(s)."
|
|
89
|
+
)
|
|
90
|
+
console.print(msg)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def register(app: typer.Typer) -> None:
|
|
94
|
+
"""Register guc / generate-use-case commands onto *app*."""
|
|
95
|
+
|
|
96
|
+
@app.command(
|
|
97
|
+
"generate-use-case",
|
|
98
|
+
help=_GUC_HELP,
|
|
99
|
+
epilog=_GUC_EPILOG,
|
|
100
|
+
)
|
|
101
|
+
@app.command("guc", hidden=True, help=_GUC_HELP, epilog=_GUC_EPILOG)
|
|
102
|
+
def generate_use_case(
|
|
103
|
+
ctx: typer.Context,
|
|
104
|
+
name: Annotated[
|
|
105
|
+
str | None, typer.Option("--name", help="Use case name (PascalCase).", rich_help_panel="Required")
|
|
106
|
+
] = None,
|
|
107
|
+
dry_run: Annotated[
|
|
108
|
+
bool,
|
|
109
|
+
typer.Option(
|
|
110
|
+
"--dry-run/--no-dry-run",
|
|
111
|
+
help="Preview without writing.",
|
|
112
|
+
rich_help_panel="Options",
|
|
113
|
+
show_default=True,
|
|
114
|
+
),
|
|
115
|
+
] = False,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Scaffold a use case in domain/usecase/."""
|
|
118
|
+
if name is None:
|
|
119
|
+
typer.echo(ctx.get_help())
|
|
120
|
+
raise typer.Exit(0)
|
|
121
|
+
_generate_use_case_impl(name, dry_run)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Helpers
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load_project_context(root: Path) -> ProjectContext:
|
|
130
|
+
pyproject = root / "pyproject.toml"
|
|
131
|
+
with pyproject.open("rb") as fh:
|
|
132
|
+
data = tomllib.load(fh)
|
|
133
|
+
section = data.get("tool", {}).get("scaffold-ca-python", {})
|
|
134
|
+
return ProjectContext(
|
|
135
|
+
name=section.get("name", root.name),
|
|
136
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""update_project: upgrade dependencies via uv (T075)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError
|
|
12
|
+
from scaffold_ca_python.core.project_detector import find_project_root
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
_GUP = "Update project dependencies via uv lock --upgrade + uv sync. Alias: 'up'"
|
|
17
|
+
_GUP_EPILOG = "Example:\n\n * scaffold up --dry-run\n\n * scaffold update-project --dry-run"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _update_project_impl(dry_run: bool) -> None:
|
|
21
|
+
# Locate project root
|
|
22
|
+
try:
|
|
23
|
+
project_root = find_project_root()
|
|
24
|
+
except ScaffoldError:
|
|
25
|
+
console.print("[red]Error:[/red] No scaffold-ca-python project found. Run 'scaffold ca' first.")
|
|
26
|
+
raise typer.Exit(code=1) from None
|
|
27
|
+
|
|
28
|
+
lock_cmd = ["uv", "lock", "--upgrade"]
|
|
29
|
+
sync_cmd = ["uv", "sync"]
|
|
30
|
+
|
|
31
|
+
if dry_run:
|
|
32
|
+
console.print("Would run:")
|
|
33
|
+
console.print(f" {' '.join(lock_cmd)} (cwd: {project_root})")
|
|
34
|
+
console.print(f" {' '.join(sync_cmd)} (cwd: {project_root})")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
subprocess.run(lock_cmd, check=True, cwd=project_root)
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
console.print(
|
|
41
|
+
"[red]Error:[/red] 'uv' is not installed or not on PATH. Install it from https://docs.astral.sh/uv/"
|
|
42
|
+
)
|
|
43
|
+
raise typer.Exit(code=1) from None
|
|
44
|
+
except subprocess.CalledProcessError:
|
|
45
|
+
console.print("[red]Error:[/red] Dependency lock failed. See output above.")
|
|
46
|
+
raise typer.Exit(code=2) from None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
subprocess.run(sync_cmd, check=True, cwd=project_root)
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
console.print(
|
|
52
|
+
"[red]Error:[/red] 'uv' is not installed or not on PATH. Install it from https://docs.astral.sh/uv/"
|
|
53
|
+
)
|
|
54
|
+
raise typer.Exit(code=1) from None
|
|
55
|
+
except subprocess.CalledProcessError:
|
|
56
|
+
console.print("[red]Error:[/red] Sync failed. See output above.")
|
|
57
|
+
raise typer.Exit(code=2) from None
|
|
58
|
+
|
|
59
|
+
console.print("[green]✓[/green] Locked updated dependencies.")
|
|
60
|
+
console.print("[green]✓[/green] Synced virtual environment.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def register(app: typer.Typer) -> None:
|
|
64
|
+
"""Register up / update-project commands onto *app*."""
|
|
65
|
+
|
|
66
|
+
@app.command(
|
|
67
|
+
"update-project",
|
|
68
|
+
help=_GUP,
|
|
69
|
+
epilog=_GUP_EPILOG,
|
|
70
|
+
)
|
|
71
|
+
@app.command("up", hidden=True, help=_GUP, epilog=_GUP_EPILOG)
|
|
72
|
+
def update_project(
|
|
73
|
+
dry_run: Annotated[
|
|
74
|
+
bool,
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--dry-run/--no-dry-run",
|
|
77
|
+
help="Preview commands without running.",
|
|
78
|
+
rich_help_panel="Options",
|
|
79
|
+
show_default=True,
|
|
80
|
+
),
|
|
81
|
+
] = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Update dependencies using uv."""
|
|
84
|
+
_update_project_impl(dry_run)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""validate_structure: AST-based CA layer import checker (T060)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError
|
|
12
|
+
from scaffold_ca_python.core.project_detector import find_project_root
|
|
13
|
+
from scaffold_ca_python.core.structure_validator import StructureValidator
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
_validator = StructureValidator()
|
|
17
|
+
|
|
18
|
+
_GVS_HELP = "Scan src/ for Clean Architecture import violations. Alias: 'vs'."
|
|
19
|
+
_GVS_EPILOG = "Example:\n\n * scaffold vs\n\n * scaffold validate-structure\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _validate_structure_impl(dry_run: bool) -> None:
|
|
23
|
+
try:
|
|
24
|
+
project_root = find_project_root()
|
|
25
|
+
except ScaffoldError:
|
|
26
|
+
console.print("[red]Error:[/red] No scaffold-ca-python project found. Run 'scaffold ca' first.")
|
|
27
|
+
raise typer.Exit(code=1) from None
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
report = _validator.validate(project_root)
|
|
31
|
+
except ScaffoldError as exc:
|
|
32
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
33
|
+
raise typer.Exit(code=1) from None
|
|
34
|
+
|
|
35
|
+
if report.passed:
|
|
36
|
+
console.print(f"[green]✓[/green] Validated [bold]{report.files_scanned}[/bold] file(s) — no violations found.")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
# --- Print violation table ---
|
|
40
|
+
table = Table(
|
|
41
|
+
title=f"[red]✗[/red] {len(report.violations)} violation(s) found",
|
|
42
|
+
show_header=True,
|
|
43
|
+
header_style="bold",
|
|
44
|
+
)
|
|
45
|
+
table.add_column("File", style="cyan", no_wrap=False)
|
|
46
|
+
table.add_column("Line", style="yellow", justify="right")
|
|
47
|
+
table.add_column("Import", style="white", no_wrap=False)
|
|
48
|
+
table.add_column("Hint", style="dim", no_wrap=False)
|
|
49
|
+
|
|
50
|
+
for v in report.violations:
|
|
51
|
+
try:
|
|
52
|
+
file_str = str(v.source_file.relative_to(project_root))
|
|
53
|
+
except ValueError:
|
|
54
|
+
file_str = str(v.source_file)
|
|
55
|
+
|
|
56
|
+
table.add_row(
|
|
57
|
+
file_str,
|
|
58
|
+
str(v.line_number),
|
|
59
|
+
v.import_statement,
|
|
60
|
+
v.resolution_hint,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
console.print(table)
|
|
64
|
+
|
|
65
|
+
if not dry_run:
|
|
66
|
+
raise typer.Exit(code=1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register(app: typer.Typer) -> None:
|
|
70
|
+
"""Register vs / validate-structure commands onto *app*."""
|
|
71
|
+
|
|
72
|
+
@app.command(
|
|
73
|
+
"validate-structure",
|
|
74
|
+
help=_GVS_HELP,
|
|
75
|
+
epilog=_GVS_EPILOG,
|
|
76
|
+
)
|
|
77
|
+
@app.command("vs", hidden=True, help=_GVS_HELP, epilog=_GVS_EPILOG)
|
|
78
|
+
def validate_structure(
|
|
79
|
+
dry_run: Annotated[
|
|
80
|
+
bool,
|
|
81
|
+
typer.Option(
|
|
82
|
+
"--dry-run/--no-dry-run",
|
|
83
|
+
help="Print results but always exit 0.",
|
|
84
|
+
rich_help_panel="Options",
|
|
85
|
+
show_default=True,
|
|
86
|
+
),
|
|
87
|
+
] = False,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Validate Clean Architecture layer boundaries in the current project."""
|
|
90
|
+
_validate_structure_impl(dry_run)
|
|
File without changes
|