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,304 @@
1
+ """generate_entry_point: scaffold an entry-point adapter and test stub (T055)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tomllib
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ import yaml
12
+ from rich.console import Console
13
+ from rich.tree import Tree
14
+
15
+ from scaffold_ca_python.core.module_builder import ModuleBuilder
16
+ from scaffold_ca_python.core.name_utils import ScaffoldError
17
+ from scaffold_ca_python.core.project_detector import find_project_root
18
+ from scaffold_ca_python.factory import ModuleFactory
19
+ from scaffold_ca_python.factory.entry_points.ep_agent import EntryPointAgent
20
+ from scaffold_ca_python.factory.entry_points.ep_generic import EntryPointGeneric
21
+ from scaffold_ca_python.factory.entry_points.ep_mcp import EntryPointMcp
22
+ from scaffold_ca_python.factory.entry_points.ep_restapi import EntryPointRestApi
23
+ from scaffold_ca_python.models.context import ModuleContext, ProjectContext
24
+ from scaffold_ca_python.models.layer import Layer
25
+
26
+ console = Console()
27
+
28
+ _REGISTRY: dict[str, type[ModuleFactory]] = {
29
+ "restapi": EntryPointRestApi,
30
+ "agent": EntryPointAgent,
31
+ "mcp": EntryPointMcp,
32
+ "generic": EntryPointGeneric,
33
+ }
34
+
35
+ _TYPE_HELP = "Entry-point type: restapi, agent, mcp, generic."
36
+ _SWAGGER_HELP = "Path to OpenAPI YAML/JSON (restapi only)."
37
+ _KAFKA_HELP = "Add async Kafka consumer stub (agent type only)."
38
+ _MCP_CLIENT_HELP = "Add MCP tool-call client stub (agent type only)."
39
+ _GEP_HELP = "Scaffold an entry-point adapter and test stub. Alias 'gep'."
40
+ _GEP_EPILOG = (
41
+ "Types:\n\n"
42
+ " * restapi FastAPI REST API entry point\n\n"
43
+ " * agent A2A agent entry point\n\n"
44
+ " * mcp MCP server entry point\n\n"
45
+ " * generic Plain entry point\n\n"
46
+ " ==================================================\n\n"
47
+ "Notes: --enable-kafka and --enable-mcp-client are agent type only.\n\n"
48
+ " ==================================================\n\n"
49
+ "Examples:\n\n"
50
+ " * scaffold gep --type restapi\n\n"
51
+ " * scaffold gep --type agent --enable-kafka\n\n"
52
+ " * scaffold gep --type mcp\n\n"
53
+ " * scaffold generate-entry-point --type generic\n"
54
+ )
55
+
56
+
57
+ def _load_project_context(project_root: Path) -> ProjectContext:
58
+ """Load project context from pyproject.toml."""
59
+ pyproject_path = project_root / "pyproject.toml"
60
+ with open(pyproject_path, "rb") as f:
61
+ data = tomllib.load(f)
62
+ project_name = data.get("project", {}).get("name", "project").replace("-", "_")
63
+ return ProjectContext(name=project_name)
64
+
65
+
66
+ def _generate_entry_point_impl(
67
+ type_: str,
68
+ swagger: str | None,
69
+ enable_kafka: bool,
70
+ enable_mcp_client: bool,
71
+ with_resources: bool,
72
+ with_prompts: bool,
73
+ dry_run: bool,
74
+ ) -> None:
75
+ # --- Validate type ---
76
+ if type_ not in _REGISTRY:
77
+ console.print(f"[red]Error:[/red] Unknown type '{type_}'. Allowed: {', '.join(_REGISTRY.keys())}.")
78
+ raise typer.Exit(code=1) from None
79
+
80
+ # --- Flag compatibility checks ---
81
+ if swagger and type_ != "restapi":
82
+ console.print("[red]Error:[/red] --swagger is only valid with --type restapi.")
83
+ raise typer.Exit(code=1) from None
84
+
85
+ if enable_mcp_client and type_ == "mcp":
86
+ console.print("[red]Error:[/red] --enable-mcp-client is not valid with --type mcp.")
87
+ raise typer.Exit(code=1) from None
88
+
89
+ if (with_resources or with_prompts) and type_ != "mcp":
90
+ console.print("[red]Error:[/red] --with-resources and --with-prompts are only valid with --type mcp.")
91
+ raise typer.Exit(code=1) from None
92
+
93
+ # --- Validate swagger path ---
94
+ routes: list[tuple[str, str]] = []
95
+ if swagger:
96
+ swagger_path = Path(swagger)
97
+ if not swagger_path.exists():
98
+ console.print(f"[red]Error:[/red] Swagger file '{swagger}' not found.")
99
+ raise typer.Exit(code=1) from None
100
+ routes = _parse_swagger(swagger_path)
101
+
102
+ # --- Locate project root ---
103
+ try:
104
+ project_root = find_project_root()
105
+ except ScaffoldError:
106
+ console.print("[red]Error:[/red] No scaffold-ca-python project found. Run 'scaffold ca' first.")
107
+ raise typer.Exit(code=1) from None
108
+
109
+ project_ctx = _load_project_context(project_root)
110
+
111
+ # --- Compatibility guard (restapi vs mcp/agent) --------------------------
112
+ if type_ == "restapi":
113
+ pkg_ep = project_root / "src" / project_ctx.python_package / "infrastructure" / "entry_points"
114
+ for incompatible in ("mcp_server", "agent"):
115
+ conflict_dir = pkg_ep / incompatible
116
+ if conflict_dir.exists():
117
+ console.print(
118
+ f"[red]Error:[/red] Incompatible entry point '[bold]{incompatible}[/bold]' "
119
+ f"already exists at: {conflict_dir.relative_to(project_root)}\n"
120
+ "[dim]Hint:[/dim] A project may have only one entry-point type. "
121
+ f"Remove '{conflict_dir.relative_to(project_root)}' before adding restapi."
122
+ )
123
+ raise typer.Exit(code=1) from None
124
+
125
+ if type_ in ("mcp", "agent"):
126
+ restapi_dir = project_root / "src" / project_ctx.python_package / "infrastructure" / "entry_points" / "api"
127
+ if restapi_dir.exists():
128
+ console.print(
129
+ f"[red]Error:[/red] Incompatible entry point '[bold]restapi[/bold]' "
130
+ f"already exists at: {restapi_dir.relative_to(project_root)}\n"
131
+ "[dim]Hint:[/dim] A project may have only one entry-point type. "
132
+ f"Remove '{restapi_dir.relative_to(project_root)}' before adding {type_}."
133
+ )
134
+ raise typer.Exit(code=1) from None
135
+
136
+ # --- Determine subdir and module context ---
137
+ if type_ == "restapi":
138
+ subdir = "api/v1"
139
+ elif type_ == "mcp":
140
+ subdir = "mcp_server"
141
+ else:
142
+ subdir = type_
143
+ module_ctx = ModuleContext(
144
+ name=type_.replace("-", "_"),
145
+ layer=Layer.ENTRY_POINTS,
146
+ project=project_ctx,
147
+ subtype=type_,
148
+ )
149
+
150
+ pkg = project_ctx.python_package
151
+ src_dir = project_root / "src" / pkg / "infrastructure" / "entry_points" / subdir
152
+
153
+ # --- Duplicate guard ---
154
+ if src_dir.exists():
155
+ console.print(
156
+ f"[red]Error:[/red] Directory '{src_dir.relative_to(project_root)}/' already exists.\n"
157
+ "[dim]Hint:[/dim] Choose a different type or remove the existing directory first."
158
+ )
159
+ raise typer.Exit(code=1) from None
160
+
161
+ # --- Create builder and get factory from registry ---
162
+ builder = ModuleBuilder(
163
+ project_root=project_root,
164
+ project_ctx=project_ctx,
165
+ module_ctx=module_ctx,
166
+ dry_run=dry_run,
167
+ )
168
+
169
+ # Add flags to builder params for factory use
170
+ builder.add_param("routes", routes)
171
+ builder.add_param("enable_kafka", enable_kafka)
172
+ builder.add_param("enable_mcp_client", enable_mcp_client)
173
+ builder.add_param("with_resources", with_resources)
174
+ builder.add_param("with_prompts", with_prompts)
175
+
176
+ # Get factory class and instantiate
177
+ factory_class = _REGISTRY[type_]
178
+ factory = factory_class()
179
+
180
+ # Invoke factory to build via ModuleBuilder
181
+ factory.build(builder)
182
+
183
+ # Persist all operations
184
+ created = builder.persist()
185
+
186
+ # --- Display results ---
187
+ if dry_run:
188
+ tree = Tree(f"[bold]{subdir}[/bold] (dry run)")
189
+ for p in sorted(created):
190
+ tree.add(str(p.relative_to(project_root)))
191
+ console.print(tree)
192
+
193
+ # Check if dependencies were collected by looking at builder internals
194
+ if builder._dependencies:
195
+ console.print(f"[dim]Would add to [project.dependencies]: {', '.join(builder._dependencies)}[/dim]")
196
+
197
+ if type_ == "restapi":
198
+ main_py = project_root / "src" / pkg / "main.py"
199
+ if main_py.exists():
200
+ console.print(f"[dim]Would delete: src/{pkg}/main.py[/dim]")
201
+ console.print(f'[dim]Would update [project.scripts]: {pkg} = "{pkg}.server:start_server"[/dim]')
202
+ else:
203
+ console.print(f"[yellow]⚠[/yellow] main.py will be replaced with {type_} entrypoint.")
204
+ return
205
+
206
+ console.print(f"[green]✓[/green] Entry point [bold]{subdir}[/bold] created. Created {len(created)} file(s).")
207
+
208
+ # --- Show results for special handling ---
209
+ if type_ == "restapi":
210
+ main_py = project_root / "src" / pkg / "main.py"
211
+ if main_py.exists():
212
+ console.print(f"[green]\u2713[/green] Deleted src/{pkg}/main.py")
213
+ console.print(f'[green]\u2713[/green] Updated [project.scripts]: {pkg} = "{pkg}.server:start_server"')
214
+ else:
215
+ console.print(f"[yellow]⚠[/yellow] main.py replaced with {type_} entrypoint.")
216
+
217
+
218
+ def _parse_swagger(path: Path) -> list[tuple[str, str]]:
219
+ """Parse an OpenAPI YAML/JSON file and return (path, method) pairs."""
220
+ text = path.read_text(encoding="utf-8")
221
+ if path.suffix.lower() in (".yaml", ".yml"):
222
+ raw: object = yaml.safe_load(text)
223
+ else:
224
+ raw = json.loads(text)
225
+ if not isinstance(raw, dict):
226
+ return []
227
+ paths_value = raw.get("paths", {})
228
+ if not isinstance(paths_value, dict):
229
+ return []
230
+ result: list[tuple[str, str]] = []
231
+ for route_path, methods in paths_value.items():
232
+ if isinstance(methods, dict):
233
+ for method in methods:
234
+ if method.lower() in ("get", "post", "put", "patch", "delete", "head", "options"):
235
+ result.append((str(route_path), method.lower()))
236
+ return result
237
+
238
+
239
+ def register(app: typer.Typer) -> None:
240
+ """Register gep / generate-entry-point commands onto *app*."""
241
+
242
+ @app.command(
243
+ "generate-entry-point",
244
+ help=_GEP_HELP,
245
+ epilog=_GEP_EPILOG,
246
+ )
247
+ @app.command("gep", hidden=True, help=_GEP_HELP, epilog=_GEP_EPILOG)
248
+ def generate_entry_point(
249
+ ctx: typer.Context,
250
+ type_: Annotated[str | None, typer.Option("--type", help=_TYPE_HELP, rich_help_panel="Required")] = None,
251
+ swagger: Annotated[str | None, typer.Option("--swagger", help=_SWAGGER_HELP, rich_help_panel="Options")] = None,
252
+ enable_kafka: Annotated[
253
+ bool,
254
+ typer.Option(
255
+ "--enable-kafka/--no-enable-kafka",
256
+ help=_KAFKA_HELP,
257
+ rich_help_panel="Options",
258
+ show_default=True,
259
+ ),
260
+ ] = False,
261
+ enable_mcp_client: Annotated[
262
+ bool,
263
+ typer.Option(
264
+ "--enable-mcp-client/--no-enable-mcp-client",
265
+ help=_MCP_CLIENT_HELP,
266
+ rich_help_panel="Options",
267
+ show_default=True,
268
+ ),
269
+ ] = False,
270
+ with_resources: Annotated[
271
+ bool,
272
+ typer.Option(
273
+ "--with-resources/--no-with-resources",
274
+ help="Generate resources.py primitive (mcp only).",
275
+ rich_help_panel="Options",
276
+ show_default=True,
277
+ ),
278
+ ] = False,
279
+ with_prompts: Annotated[
280
+ bool,
281
+ typer.Option(
282
+ "--with-prompts/--no-with-prompts",
283
+ help="Generate prompts.py primitive (mcp only).",
284
+ rich_help_panel="Options",
285
+ show_default=True,
286
+ ),
287
+ ] = False,
288
+ dry_run: Annotated[
289
+ bool,
290
+ typer.Option(
291
+ "--dry-run/--no-dry-run",
292
+ help="Preview without writing.",
293
+ rich_help_panel="Options",
294
+ show_default=True,
295
+ ),
296
+ ] = False,
297
+ ) -> None:
298
+ """Scaffold an entry point inside infrastructure/entry_points/."""
299
+ if type_ is None:
300
+ typer.echo(ctx.get_help())
301
+ raise typer.Exit(0)
302
+ _generate_entry_point_impl(
303
+ type_, swagger, enable_kafka, enable_mcp_client, with_resources, with_prompts, dry_run
304
+ )
@@ -0,0 +1,135 @@
1
+ """generate_helper: scaffold a helper utility class and test stub (T037)."""
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.helper_factory import HelperFactory
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
+ "helper": HelperFactory,
25
+ }
26
+
27
+ _GH_HELP = "Scaffold a helper utility module and test stub. Alias 'gh'."
28
+ _GH_EPILOG = "Example:\n\n * scaffold gh --name JsonParser\n\n * scaffold generate-helper --name JsonParser\n\n"
29
+
30
+
31
+ def _generate_helper_impl(name: str, dry_run: bool) -> None:
32
+ # --- Validate name ---
33
+ try:
34
+ validate_name(name)
35
+ except ScaffoldError as exc:
36
+ console.print(f"[red]Error:[/red] {exc}")
37
+ raise typer.Exit(code=1) from None
38
+
39
+ # --- Locate project root ---
40
+ try:
41
+ project_root = find_project_root()
42
+ except ScaffoldError:
43
+ console.print("[red]Error:[/red] No scaffold-ca-python project found. Run 'scaffold ca' first.")
44
+ raise typer.Exit(code=1) from None
45
+
46
+ project_ctx = _load_project_context(project_root)
47
+ module_ctx = ModuleContext(name=name, layer=Layer.HELPERS, project=project_ctx)
48
+
49
+ pkg = project_ctx.python_package
50
+ snake = to_snake_case(name)
51
+ src_dir = project_root / "src" / pkg / "infrastructure" / "helpers" / snake
52
+
53
+ # --- Duplicate guard ---
54
+ if src_dir.exists():
55
+ console.print(
56
+ f"[red]Error:[/red] Directory '{src_dir.relative_to(project_root)}/' already exists.\n"
57
+ "[dim]Hint:[/dim] Choose a different name or remove the existing directory first."
58
+ )
59
+ raise typer.Exit(code=1) from None
60
+
61
+ # --- Create builder and get factory from registry ---
62
+ builder = ModuleBuilder(
63
+ project_root=project_root,
64
+ project_ctx=project_ctx,
65
+ module_ctx=module_ctx,
66
+ dry_run=dry_run,
67
+ )
68
+
69
+ # Get factory class and instantiate
70
+ factory_class = _REGISTRY["helper"]
71
+ factory = factory_class()
72
+
73
+ # Invoke factory to build via ModuleBuilder
74
+ factory.build(builder)
75
+
76
+ # Persist all operations
77
+ created = builder.persist()
78
+
79
+ # --- Display results ---
80
+ if dry_run:
81
+ tree = Tree(f"[bold]{snake}[/bold] (dry run)")
82
+ for p in sorted(created):
83
+ tree.add(str(p.relative_to(project_root)))
84
+ console.print(tree)
85
+ return
86
+
87
+ msg = f"[green]✓[/green] Helper [bold]{module_ctx.class_name}[/bold] created. Created {len(created)} file(s)."
88
+ console.print(msg)
89
+
90
+
91
+ def register(app: typer.Typer) -> None:
92
+ """Register gh / generate-helper commands onto *app*."""
93
+
94
+ @app.command(
95
+ "generate-helper",
96
+ help=_GH_HELP,
97
+ epilog=_GH_EPILOG,
98
+ )
99
+ @app.command("gh", hidden=True, help=_GH_HELP, epilog=_GH_EPILOG)
100
+ def generate_helper(
101
+ ctx: typer.Context,
102
+ name: Annotated[
103
+ str | None, typer.Option("--name", help="Helper name (PascalCase).", rich_help_panel="Required")
104
+ ] = None,
105
+ dry_run: Annotated[
106
+ bool,
107
+ typer.Option(
108
+ "--dry-run/--no-dry-run",
109
+ help="Preview without writing.",
110
+ rich_help_panel="Options",
111
+ show_default=True,
112
+ ),
113
+ ] = False,
114
+ ) -> None:
115
+ """Scaffold a helper in infrastructure/helpers/."""
116
+ if name is None:
117
+ typer.echo(ctx.get_help())
118
+ raise typer.Exit(0)
119
+ _generate_helper_impl(name, dry_run)
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ #
124
+ # Helpers
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _load_project_context(root: Path) -> ProjectContext:
129
+ pyproject = root / "pyproject.toml"
130
+ with pyproject.open("rb") as fh:
131
+ data = tomllib.load(fh)
132
+ section = data.get("tool", {}).get("scaffold-ca-python", {})
133
+ return ProjectContext(
134
+ name=section.get("name", root.name),
135
+ )
@@ -0,0 +1,134 @@
1
+ """generate_model: scaffold a domain model class and test stub (T033)."""
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, 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.model_factory import ModelFactory
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
+ "model": ModelFactory,
25
+ }
26
+ _GM_HELP = "Scaffold a Pydantic v2 domain model and test stub. Alias 'gm'."
27
+ _GM_EPILOG = "Example:\n\n * scaffold gm --name Order\n\n * scaffold generate-model --name Order\n\n"
28
+
29
+
30
+ def _generate_model_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_MODEL, project=project_ctx)
47
+
48
+ pkg = project_ctx.python_package
49
+ src_dir = project_root / "src" / pkg / "domain" / "model"
50
+
51
+ # --- Duplicate guard ---
52
+ src_path = src_dir / f"{module_ctx.module_name}.py"
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["model"]
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]{module_ctx.module_name}[/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 = f"[green]✓[/green] Model [bold]{module_ctx.class_name}[/bold] created. Created {len(created)} file(s)."
87
+ console.print(msg)
88
+
89
+
90
+ def register(app: typer.Typer) -> None:
91
+ """Register gm / generate-model commands onto *app*."""
92
+
93
+ # Both names route to the same handler; add further aliases by stacking @app.command before the def.
94
+ @app.command(
95
+ "generate-model",
96
+ help=_GM_HELP,
97
+ epilog=_GM_EPILOG,
98
+ )
99
+ @app.command("gm", hidden=True, help=_GM_HELP, epilog=_GM_EPILOG)
100
+ def generate_model(
101
+ ctx: typer.Context,
102
+ name: Annotated[
103
+ str | None, typer.Option("--name", help="Model name (PascalCase).", rich_help_panel="Required")
104
+ ] = None,
105
+ dry_run: Annotated[
106
+ bool,
107
+ typer.Option(
108
+ "--dry-run/--no-dry-run",
109
+ help="Preview without writing.",
110
+ rich_help_panel="Options",
111
+ show_default=True,
112
+ ),
113
+ ] = False,
114
+ ) -> None:
115
+ """Scaffold a domain model in domain/model/."""
116
+ if name is None:
117
+ typer.echo(ctx.get_help())
118
+ raise typer.Exit(0)
119
+ _generate_model_impl(name, dry_run)
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Helpers
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def _load_project_context(root: Path) -> ProjectContext:
128
+ pyproject = root / "pyproject.toml"
129
+ with pyproject.open("rb") as fh:
130
+ data = tomllib.load(fh)
131
+ section = data.get("tool", {}).get("scaffold-ca-python", {})
132
+ return ProjectContext(
133
+ name=section.get("name", root.name),
134
+ )