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.
Files changed (109) hide show
  1. scaffold_ca_python/__init__.py +1 -0
  2. scaffold_ca_python/cli.py +39 -0
  3. scaffold_ca_python/commands/__init__.py +0 -0
  4. scaffold_ca_python/commands/delete_module.py +216 -0
  5. scaffold_ca_python/commands/generate_driven_adapter.py +182 -0
  6. scaffold_ca_python/commands/generate_entry_point.py +304 -0
  7. scaffold_ca_python/commands/generate_helper.py +135 -0
  8. scaffold_ca_python/commands/generate_model.py +134 -0
  9. scaffold_ca_python/commands/generate_pipeline.py +158 -0
  10. scaffold_ca_python/commands/generate_project.py +189 -0
  11. scaffold_ca_python/commands/generate_use_case.py +136 -0
  12. scaffold_ca_python/commands/update_project.py +84 -0
  13. scaffold_ca_python/commands/validate_structure.py +90 -0
  14. scaffold_ca_python/core/__init__.py +0 -0
  15. scaffold_ca_python/core/file_writer.py +128 -0
  16. scaffold_ca_python/core/module_builder.py +127 -0
  17. scaffold_ca_python/core/name_utils.py +59 -0
  18. scaffold_ca_python/core/project_detector.py +93 -0
  19. scaffold_ca_python/core/pyproject_writer.py +169 -0
  20. scaffold_ca_python/core/structure_validator.py +142 -0
  21. scaffold_ca_python/core/template_renderer.py +100 -0
  22. scaffold_ca_python/factory/__init__.py +16 -0
  23. scaffold_ca_python/factory/driven_adapters/__init__.py +0 -0
  24. scaffold_ca_python/factory/driven_adapters/da_generic.py +65 -0
  25. scaffold_ca_python/factory/driven_adapters/da_rest_consumer.py +64 -0
  26. scaffold_ca_python/factory/driven_adapters/da_secrets.py +64 -0
  27. scaffold_ca_python/factory/entry_points/__init__.py +0 -0
  28. scaffold_ca_python/factory/entry_points/ep_agent.py +91 -0
  29. scaffold_ca_python/factory/entry_points/ep_generic.py +75 -0
  30. scaffold_ca_python/factory/entry_points/ep_mcp.py +138 -0
  31. scaffold_ca_python/factory/entry_points/ep_restapi.py +133 -0
  32. scaffold_ca_python/factory/simple/__init__.py +0 -0
  33. scaffold_ca_python/factory/simple/delete_module_factory.py +85 -0
  34. scaffold_ca_python/factory/simple/helper_factory.py +67 -0
  35. scaffold_ca_python/factory/simple/model_factory.py +57 -0
  36. scaffold_ca_python/factory/simple/use_case_factory.py +59 -0
  37. scaffold_ca_python/models/__init__.py +0 -0
  38. scaffold_ca_python/models/context.py +60 -0
  39. scaffold_ca_python/models/file_operation.py +47 -0
  40. scaffold_ca_python/models/layer.py +41 -0
  41. scaffold_ca_python/models/violation.py +26 -0
  42. scaffold_ca_python/templates/__init__.py +0 -0
  43. scaffold_ca_python/templates/driven_adapter/generic/__init__.py.jinja2 +1 -0
  44. scaffold_ca_python/templates/driven_adapter/generic/adapter.py.jinja2 +18 -0
  45. scaffold_ca_python/templates/driven_adapter/generic/test_adapter.py.jinja2 +22 -0
  46. scaffold_ca_python/templates/driven_adapter/rest_consumer/__init__.py.jinja2 +1 -0
  47. scaffold_ca_python/templates/driven_adapter/rest_consumer/rest_consumer.py.jinja2 +27 -0
  48. scaffold_ca_python/templates/driven_adapter/rest_consumer/test_rest_consumer.py.jinja2 +24 -0
  49. scaffold_ca_python/templates/driven_adapter/secrets/__init__.py.jinja2 +1 -0
  50. scaffold_ca_python/templates/driven_adapter/secrets/secrets_adapter.py.jinja2 +37 -0
  51. scaffold_ca_python/templates/driven_adapter/secrets/test_secrets_adapter.py.jinja2 +26 -0
  52. scaffold_ca_python/templates/entry_point/agent/__init__.py.jinja2 +1 -0
  53. scaffold_ca_python/templates/entry_point/agent/agent.py.jinja2 +49 -0
  54. scaffold_ca_python/templates/entry_point/agent/card.py.jinja2 +15 -0
  55. scaffold_ca_python/templates/entry_point/agent/entrypoint_main.py.jinja2 +13 -0
  56. scaffold_ca_python/templates/entry_point/agent/test_agent.py.jinja2 +20 -0
  57. scaffold_ca_python/templates/entry_point/generic/__init__.py.jinja2 +1 -0
  58. scaffold_ca_python/templates/entry_point/generic/entrypoint_main.py.jinja2 +13 -0
  59. scaffold_ca_python/templates/entry_point/generic/handler.py.jinja2 +13 -0
  60. scaffold_ca_python/templates/entry_point/generic/test_handler.py.jinja2 +35 -0
  61. scaffold_ca_python/templates/entry_point/mcp/__init__.py.jinja2 +1 -0
  62. scaffold_ca_python/templates/entry_point/mcp/app.py.jinja2 +51 -0
  63. scaffold_ca_python/templates/entry_point/mcp/prompts.py.jinja2 +22 -0
  64. scaffold_ca_python/templates/entry_point/mcp/resources.py.jinja2 +22 -0
  65. scaffold_ca_python/templates/entry_point/mcp/server.py.jinja2 +27 -0
  66. scaffold_ca_python/templates/entry_point/mcp/test_app.py.jinja2 +32 -0
  67. scaffold_ca_python/templates/entry_point/mcp/test_prompts.py.jinja2 +40 -0
  68. scaffold_ca_python/templates/entry_point/mcp/test_resources.py.jinja2 +47 -0
  69. scaffold_ca_python/templates/entry_point/mcp/test_tools.py.jinja2 +40 -0
  70. scaffold_ca_python/templates/entry_point/mcp/tools.py.jinja2 +22 -0
  71. scaffold_ca_python/templates/entry_point/restapi/__init__.py.jinja2 +1 -0
  72. scaffold_ca_python/templates/entry_point/restapi/app.py.jinja2 +78 -0
  73. scaffold_ca_python/templates/entry_point/restapi/exception_handler.py.jinja2 +35 -0
  74. scaffold_ca_python/templates/entry_point/restapi/health.py.jinja2 +13 -0
  75. scaffold_ca_python/templates/entry_point/restapi/rest_controller.py.jinja2 +26 -0
  76. scaffold_ca_python/templates/entry_point/restapi/server.py.jinja2 +5 -0
  77. scaffold_ca_python/templates/entry_point/restapi/test_app.py.jinja2 +22 -0
  78. scaffold_ca_python/templates/entry_point/restapi/test_exception_handler.py.jinja2 +44 -0
  79. scaffold_ca_python/templates/entry_point/restapi/test_rest_controller.py.jinja2 +35 -0
  80. scaffold_ca_python/templates/entry_point/restapi/test_server.py.jinja2 +15 -0
  81. scaffold_ca_python/templates/helper/__init__.py.jinja2 +1 -0
  82. scaffold_ca_python/templates/helper/helper.py.jinja2 +7 -0
  83. scaffold_ca_python/templates/helper/test_helper.py.jinja2 +8 -0
  84. scaffold_ca_python/templates/model/model.py.jinja2 +9 -0
  85. scaffold_ca_python/templates/model/test_model.py.jinja2 +8 -0
  86. scaffold_ca_python/templates/pipeline/azure/azure_pipelines.yml.jinja2 +28 -0
  87. scaffold_ca_python/templates/pipeline/github/ci.yml.jinja2 +34 -0
  88. scaffold_ca_python/templates/project/README.jinja2 +30 -0
  89. scaffold_ca_python/templates/project/application/config/__init__.py.jinja2 +1 -0
  90. scaffold_ca_python/templates/project/application/config/config.py.jinja2 +12 -0
  91. scaffold_ca_python/templates/project/application/config/container.py.jinja2 +17 -0
  92. scaffold_ca_python/templates/project/application/config/driven_adapters_container.py.jinja2 +14 -0
  93. scaffold_ca_python/templates/project/application/config/resource_container.py.jinja2 +17 -0
  94. scaffold_ca_python/templates/project/application/config/usecases_container.py.jinja2 +16 -0
  95. scaffold_ca_python/templates/project/dockerfile.jinja2 +22 -0
  96. scaffold_ca_python/templates/project/dockerignore.jinja2 +19 -0
  97. scaffold_ca_python/templates/project/gitignore.jinja2 +64 -0
  98. scaffold_ca_python/templates/project/layer_init.jinja2 +1 -0
  99. scaffold_ca_python/templates/project/main.py.jinja2 +10 -0
  100. scaffold_ca_python/templates/project/mypy_ini.jinja2 +5 -0
  101. scaffold_ca_python/templates/project/pyproject_toml.jinja2 +66 -0
  102. scaffold_ca_python/templates/project/python_version.jinja2 +1 -0
  103. scaffold_ca_python/templates/use_case/test_use_case.py.jinja2 +12 -0
  104. scaffold_ca_python/templates/use_case/use_case.py.jinja2 +9 -0
  105. scaffold_ca_python-0.1.1.dist-info/METADATA +285 -0
  106. scaffold_ca_python-0.1.1.dist-info/RECORD +109 -0
  107. scaffold_ca_python-0.1.1.dist-info/WHEEL +4 -0
  108. scaffold_ca_python-0.1.1.dist-info/entry_points.txt +3 -0
  109. 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