scaffold-ca-python 0.2.0__py3-none-any.whl → 0.2.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 (50) hide show
  1. scaffold_ca_python/commands/_entry_point_rules.py +53 -0
  2. scaffold_ca_python/commands/_shared.py +109 -0
  3. scaffold_ca_python/commands/add_mcp_tool.py +200 -0
  4. scaffold_ca_python/commands/delete_module.py +19 -110
  5. scaffold_ca_python/commands/generate_driven_adapter.py +43 -106
  6. scaffold_ca_python/commands/generate_entry_point.py +107 -451
  7. scaffold_ca_python/commands/generate_helper.py +25 -82
  8. scaffold_ca_python/commands/generate_model.py +25 -82
  9. scaffold_ca_python/commands/generate_pipeline.py +47 -91
  10. scaffold_ca_python/commands/generate_project.py +75 -105
  11. scaffold_ca_python/commands/generate_use_case.py +26 -81
  12. scaffold_ca_python/commands/update_project.py +19 -32
  13. scaffold_ca_python/commands/validate_structure.py +8 -12
  14. scaffold_ca_python/core/file_writer.py +55 -70
  15. scaffold_ca_python/core/mcp_tool_spec.py +1 -1
  16. scaffold_ca_python/core/module_builder.py +13 -6
  17. scaffold_ca_python/core/module_locator.py +45 -0
  18. scaffold_ca_python/core/name_utils.py +1 -6
  19. scaffold_ca_python/core/openapi_routes.py +34 -0
  20. scaffold_ca_python/core/project_detector.py +14 -9
  21. scaffold_ca_python/core/structure_validator.py +66 -59
  22. scaffold_ca_python/core/template_renderer.py +1 -9
  23. scaffold_ca_python/factory/driven_adapters/__init__.py +14 -0
  24. scaffold_ca_python/factory/driven_adapters/base.py +23 -0
  25. scaffold_ca_python/factory/driven_adapters/da_generic.py +3 -3
  26. scaffold_ca_python/factory/driven_adapters/da_rest_consumer.py +4 -3
  27. scaffold_ca_python/factory/driven_adapters/da_secrets.py +4 -3
  28. scaffold_ca_python/factory/entry_points/__init__.py +16 -0
  29. scaffold_ca_python/factory/entry_points/base.py +63 -0
  30. scaffold_ca_python/factory/entry_points/ep_agent.py +13 -4
  31. scaffold_ca_python/factory/entry_points/ep_generic.py +13 -4
  32. scaffold_ca_python/factory/entry_points/ep_mcp.py +15 -8
  33. scaffold_ca_python/factory/entry_points/ep_restapi.py +34 -6
  34. scaffold_ca_python/factory/pipelines/__init__.py +20 -0
  35. scaffold_ca_python/factory/pipelines/pipeline_factory.py +27 -0
  36. scaffold_ca_python/factory/simple/delete_module_factory.py +10 -11
  37. scaffold_ca_python/factory/simple/helper_factory.py +1 -1
  38. scaffold_ca_python/factory/simple/model_factory.py +1 -1
  39. scaffold_ca_python/factory/simple/use_case_factory.py +1 -1
  40. scaffold_ca_python/models/context.py +1 -1
  41. scaffold_ca_python/models/layer.py +1 -1
  42. scaffold_ca_python/models/options.py +53 -0
  43. scaffold_ca_python/models/violation.py +1 -1
  44. scaffold_ca_python/templates/project/empty.jinja2 +0 -0
  45. scaffold_ca_python/templates/project/package_init.py.jinja2 +1 -0
  46. {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.2.1.dist-info}/METADATA +1 -1
  47. {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.2.1.dist-info}/RECORD +50 -38
  48. {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.2.1.dist-info}/WHEEL +0 -0
  49. {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.2.1.dist-info}/entry_points.txt +0 -0
  50. {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,53 @@
1
+ """Validation rules for entry-point creation: flag compatibility and conflict detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ import typer
9
+
10
+ from scaffold_ca_python.commands._shared import console
11
+ from scaffold_ca_python.factory.entry_points.base import EntryPointFactory, EntryPointFlag
12
+
13
+ if TYPE_CHECKING:
14
+ pass
15
+
16
+
17
+ def check_flags(
18
+ type_key: str,
19
+ factory: type[EntryPointFactory],
20
+ active_flags: list[EntryPointFlag],
21
+ registry: dict[str, type[EntryPointFactory]],
22
+ ) -> None:
23
+ """Exit with an error if any active flag is not supported by the factory."""
24
+ for flag in active_flags:
25
+ if flag not in factory.supported_flags:
26
+ types_supporting = [k for k, f in registry.items() if flag in f.supported_flags]
27
+ if len(types_supporting) == 1:
28
+ msg = f"{flag.label} {flag.verb} only valid with --type {types_supporting[0]}."
29
+ else:
30
+ msg = f"{flag.label} {flag.verb} not valid with --type {type_key}."
31
+ console.print(f"[red]Error:[/red] {msg}")
32
+ raise typer.Exit(code=1)
33
+
34
+
35
+ def check_conflicts(
36
+ project_root: Path,
37
+ pkg: str,
38
+ type_key: str,
39
+ factory: type[EntryPointFactory],
40
+ ) -> None:
41
+ """Exit with an error if any conflicting entry-point directory already exists."""
42
+ pkg_ep = project_root / "src" / pkg / "infrastructure" / "entry_points"
43
+ for conflict in factory.conflicts:
44
+ conflict_dir = pkg_ep / conflict.subdir
45
+ if conflict_dir.exists():
46
+ rel = conflict_dir.relative_to(project_root)
47
+ console.print(
48
+ f"[red]Error:[/red] Incompatible entry point '[bold]{conflict.label}[/bold]' "
49
+ f"already exists at: {rel}\n"
50
+ "[dim]Hint:[/dim] A project may have only one entry-point type. "
51
+ f"Remove '{rel}' before adding {type_key}."
52
+ )
53
+ raise typer.Exit(code=1)
@@ -0,0 +1,109 @@
1
+ """Shared utilities for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Literal, NoReturn
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.markup import escape
12
+ from rich.tree import Tree
13
+
14
+ from scaffold_ca_python.core.name_utils import ScaffoldError
15
+ from scaffold_ca_python.core.project_detector import find_project_root
16
+
17
+ if TYPE_CHECKING:
18
+ from scaffold_ca_python.core.module_builder import ModuleBuilder
19
+ from scaffold_ca_python.factory import ModuleFactory
20
+
21
+ console = Console()
22
+
23
+ PROJECT_NOT_FOUND = "No scaffold-ca-python project found. Run 'scaffold ca' first."
24
+
25
+
26
+ def fail(message: str, hint: str | None = None) -> NoReturn:
27
+ """Print an error (and optional hint) then exit with code 1."""
28
+ console.print(f"[red]Error:[/red] {message}")
29
+ if hint is not None:
30
+ console.print(f"[dim]Hint:[/dim] {hint}")
31
+ raise typer.Exit(code=1)
32
+
33
+
34
+ def require_project_root(not_found_message: str | None = PROJECT_NOT_FOUND) -> Path:
35
+ """Return the project root, or exit 1 if not found.
36
+
37
+ Args:
38
+ not_found_message: Text to print on failure. Pass ``None`` to print the
39
+ ``ScaffoldError`` text from ``find_project_root`` instead.
40
+ """
41
+ try:
42
+ return find_project_root()
43
+ except ScaffoldError as exc:
44
+ if not_found_message is None:
45
+ console.print(f"[red]Error:[/red] {exc}")
46
+ else:
47
+ console.print(f"[red]Error:[/red] {not_found_message}")
48
+ raise typer.Exit(code=1) from None
49
+
50
+
51
+ def validate_name_or_exit(name: str, validator: Callable[[str], object]) -> None:
52
+ """Run *validator* on *name*, calling ``fail()`` if it raises ``ScaffoldError``."""
53
+ try:
54
+ validator(name)
55
+ except ScaffoldError as exc:
56
+ fail(str(exc))
57
+
58
+
59
+ def ensure_absent(
60
+ path: Path,
61
+ project_root: Path,
62
+ *,
63
+ kind: Literal["File", "Directory"],
64
+ hint: str,
65
+ ) -> None:
66
+ """Exit 1 if *path* already exists."""
67
+ if not path.exists():
68
+ return
69
+ rel = path.relative_to(project_root)
70
+ suffix = "/" if kind == "Directory" else ""
71
+ fail(f"{kind} '{rel}{suffix}' already exists.", hint=hint)
72
+
73
+
74
+ def print_dry_run_tree(title: str, paths: Iterable[Path], base: Path) -> None:
75
+ """Render a Rich tree of *paths* (sorted, relative to *base*) with a dry-run label."""
76
+ tree = Tree(f"[bold]{title}[/bold] (dry run)")
77
+ for p in sorted(paths):
78
+ try:
79
+ tree.add(str(p.relative_to(base)))
80
+ except ValueError:
81
+ tree.add(str(p))
82
+ console.print(tree)
83
+
84
+
85
+ def print_pending_dependencies(builder: ModuleBuilder) -> None:
86
+ """Print dry-run summaries for queued dependency injections."""
87
+ if builder.pending_dependencies:
88
+ target = escape("[project.dependencies]")
89
+ console.print(f"[dim]Would add to {target}: {', '.join(builder.pending_dependencies)}[/dim]")
90
+ for grp, pkgs in builder.pending_group_dependencies.items():
91
+ target = escape(f"[dependency-groups.{grp}]")
92
+ console.print(f"[dim]Would add to {target}: {', '.join(pkgs)}[/dim]")
93
+
94
+
95
+ def build_and_persist(factory: ModuleFactory, builder: ModuleBuilder) -> list[Path]:
96
+ """Build and persist module operations, returning the affected paths."""
97
+ factory.build(builder)
98
+ return builder.persist()
99
+
100
+
101
+ def dry_run_option(help: str = "Preview without writing.") -> typer.models.OptionInfo:
102
+ """Return a ``--dry-run/--no-dry-run`` Typer option."""
103
+ return typer.models.OptionInfo(
104
+ default=False,
105
+ param_decls=("--dry-run/--no-dry-run",),
106
+ help=help,
107
+ rich_help_panel="Options",
108
+ show_default=True,
109
+ )
@@ -0,0 +1,200 @@
1
+ """add_mcp_tool: append a tool stub to an existing MCP entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import os
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ import typer
11
+ from rich.syntax import Syntax
12
+
13
+ from scaffold_ca_python.commands._shared import console, require_project_root
14
+ from scaffold_ca_python.core import mcp_tool_spec
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 load_project_context, resolve_tests_root
18
+ from scaffold_ca_python.models.context import ProjectContext
19
+
20
+ if TYPE_CHECKING:
21
+ from scaffold_ca_python.models.options import EntryPointOptions
22
+
23
+ _TEST_TOOLS_STRUCTURE_ERROR = (
24
+ "[red]Error:[/red] tests/infrastructure/entry_points/mcp_server/test_tools.py is missing or "
25
+ "does not have\n"
26
+ "the structure --add-tool needs to append a registration test.\n"
27
+ "[dim]Hint:[/dim] Restore test_tools.py, or regenerate the entry point with "
28
+ "[cyan]scaffold gep --type mcp[/cyan]."
29
+ )
30
+
31
+
32
+ def _check_add_tool_flags(options: EntryPointOptions) -> None:
33
+ flags = options.creation_flags()
34
+ if flags:
35
+ console.print(f"[red]Error:[/red] --add-tool is incompatible with {flags[0]}.")
36
+ raise typer.Exit(code=1)
37
+ if options.type_ != "mcp":
38
+ console.print("[red]Error:[/red] --add-tool is only valid with --type mcp.")
39
+ raise typer.Exit(code=1)
40
+ if options.name is None:
41
+ console.print("[red]Error:[/red] --name is required with --add-tool.")
42
+ raise typer.Exit(code=1)
43
+
44
+
45
+ def _build_spec(options: EntryPointOptions) -> mcp_tool_spec.ToolSpec:
46
+ if options.name is None: # guard; _check_add_tool_flags exits before this if name is None
47
+ raise typer.Exit(code=1)
48
+ params = list(options.params) if options.params else None
49
+ try:
50
+ return mcp_tool_spec.build_tool_spec(options.name, params, options.returns, options.description)
51
+ except (ValueError, ScaffoldError) as e:
52
+ console.print(f"[red]Error:[/red] {e}")
53
+ raise typer.Exit(code=1) from None
54
+
55
+
56
+ def _resolve_paths(project_root: Path, ctx: ProjectContext) -> tuple[Path, Path]:
57
+ tools_py = project_root / "src" / ctx.python_package / "infrastructure" / "entry_points" / "mcp_server" / "tools.py"
58
+ test_tools_py = (
59
+ resolve_tests_root(project_root) / "infrastructure" / "entry_points" / "mcp_server" / "test_tools.py"
60
+ )
61
+ return tools_py, test_tools_py
62
+
63
+
64
+ def _read_sources(paths: tuple[Path, Path]) -> tuple[str, str]:
65
+ tools_py, test_tools_py = paths
66
+ if not tools_py.exists():
67
+ console.print(
68
+ "[red]Error:[/red] No MCP entry point found at infrastructure/entry_points/mcp_server/.\n"
69
+ "[dim]Hint:[/dim] Run [cyan]scaffold gep --type mcp[/cyan] first to create one."
70
+ )
71
+ raise typer.Exit(code=1)
72
+ try:
73
+ tools_src = tools_py.read_text(encoding="utf-8")
74
+ except OSError as e:
75
+ console.print(f"[red]Error:[/red] Cannot read {tools_py}: {e}")
76
+ raise typer.Exit(code=1) from None
77
+ try:
78
+ test_tools_src = test_tools_py.read_text(encoding="utf-8")
79
+ except OSError:
80
+ console.print(_TEST_TOOLS_STRUCTURE_ERROR)
81
+ raise typer.Exit(code=1) from None
82
+ return tools_src, test_tools_src
83
+
84
+
85
+ def _check_structure(sources: tuple[str, str], spec: mcp_tool_spec.ToolSpec) -> None:
86
+ tools_src, test_tools_src = sources
87
+ if not mcp_tool_spec.has_bind_tools_function(tools_src):
88
+ console.print(
89
+ "[red]Error:[/red] infrastructure/entry_points/mcp_server/tools.py was generated by an older "
90
+ "scaffold\nversion and does not support --add-tool.\n"
91
+ "[dim]Hint:[/dim] Make [cyan]async def bind_tools[/cyan] the last function in tools.py, or "
92
+ "regenerate the entry point\n(back up any custom tools first) with [cyan]scaffold gep --type mcp[/cyan]."
93
+ )
94
+ raise typer.Exit(code=1)
95
+ if not mcp_tool_spec.has_test_registration_structure(test_tools_src):
96
+ console.print(_TEST_TOOLS_STRUCTURE_ERROR)
97
+ raise typer.Exit(code=1)
98
+ existing = mcp_tool_spec.find_existing_tool_names(tools_src)
99
+ if spec.name in existing:
100
+ if spec.name == "bind_tools":
101
+ console.print(
102
+ f"[red]Error:[/red] '{spec.name}' is reserved by the tool-binding function and "
103
+ "cannot be used as a tool name."
104
+ )
105
+ else:
106
+ console.print(f"[red]Error:[/red] A tool named '{spec.name}' already exists in tools.py.")
107
+ raise typer.Exit(code=1)
108
+
109
+
110
+ def _check_writable(paths: tuple[Path, Path], project_root: Path) -> None:
111
+ for path in paths:
112
+ if not os.access(path, os.W_OK):
113
+ console.print(
114
+ f"[red]Error:[/red] Cannot write to {path.relative_to(project_root)}.\n"
115
+ "[dim]Hint:[/dim] Check the file's permissions and re-run the command."
116
+ )
117
+ raise typer.Exit(code=1)
118
+
119
+
120
+ def _persist_with_restore(
121
+ builder: ModuleBuilder,
122
+ originals: tuple[Path, str, Path, str],
123
+ ) -> list[Path]:
124
+ tools_py, tools_src, test_tools_py, test_tools_src = originals
125
+ try:
126
+ return builder.persist()
127
+ except OSError as e:
128
+ try:
129
+ tools_py.write_text(tools_src, encoding="utf-8")
130
+ test_tools_py.write_text(test_tools_src, encoding="utf-8")
131
+ except OSError:
132
+ pass
133
+ fname = Path(e.filename).name if e.filename else "tools.py"
134
+ console.print(
135
+ f"[red]Error:[/red] Failed to write {fname} ({e}). "
136
+ "tools.py was restored; no changes were made.\n"
137
+ "[dim]Hint:[/dim] Fix the underlying problem (permissions, disk space) and re-run the command."
138
+ )
139
+ raise typer.Exit(code=1) from None
140
+
141
+
142
+ def _report_add_tool(
143
+ modified_paths: list[Path],
144
+ tool_stub: str,
145
+ test_stub: str,
146
+ spec: mcp_tool_spec.ToolSpec,
147
+ project_root: Path,
148
+ dry_run: bool,
149
+ ) -> None:
150
+ if dry_run:
151
+ console.print("\n[yellow]Dry run:[/yellow] Would modify:\n")
152
+ for path in modified_paths:
153
+ console.print(f" • {path.relative_to(project_root)}")
154
+ console.print("\n[yellow]Tool stub preview:[/yellow]")
155
+ console.print(Syntax(tool_stub, "python", theme="monokai", line_numbers=False))
156
+ console.print("\n[yellow]Test stub preview:[/yellow]")
157
+ console.print(Syntax(test_stub, "python", theme="monokai", line_numbers=False))
158
+ else:
159
+ console.print(f"[green]✓[/green] Added tool '{spec.name}' to MCP entry point.")
160
+
161
+
162
+ def add_mcp_tool(options: EntryPointOptions) -> None:
163
+ """Add a tool stub to an existing MCP entry point."""
164
+ _check_add_tool_flags(options)
165
+ spec = _build_spec(options)
166
+ project_root = require_project_root(not_found_message=None)
167
+ project_ctx = load_project_context(project_root)
168
+ paths = _resolve_paths(project_root, project_ctx)
169
+ sources = _read_sources(paths)
170
+ _check_structure(sources, spec)
171
+
172
+ tools_py, test_tools_py = paths
173
+ tools_src, test_tools_src = sources
174
+
175
+ builder = ModuleBuilder(project_root=project_root, project_ctx=project_ctx, dry_run=options.dry_run)
176
+ tool_stub = builder.render(
177
+ "entry_point/mcp/tool_stub.py.jinja2",
178
+ {
179
+ "name": spec.name,
180
+ "params": [{"name": p.name, "type": p.type} for p in spec.params],
181
+ "returns": spec.returns,
182
+ "docstring": spec.docstring,
183
+ },
184
+ )
185
+ test_stub = builder.render("entry_point/mcp/test_tool_stub.py.jinja2", {"name": spec.name})
186
+
187
+ try:
188
+ ast.parse(tools_src + tool_stub)
189
+ except SyntaxError:
190
+ console.print(
191
+ "[red]Error:[/red] infrastructure/entry_points/mcp_server/tools.py cannot accept the new tool stub.\n"
192
+ "[dim]Hint:[/dim] Regenerate the entry point with [cyan]scaffold gep --type mcp[/cyan]."
193
+ )
194
+ raise typer.Exit(code=1) from None
195
+
196
+ _check_writable(paths, project_root)
197
+ builder.append_to_file(tools_py, tool_stub)
198
+ builder.append_to_file(test_tools_py, test_stub)
199
+ modified = _persist_with_restore(builder, (tools_py, tools_src, test_tools_py, test_tools_src))
200
+ _report_add_tool(modified, tool_stub, test_stub, spec, project_root, options.dry_run)
@@ -1,132 +1,58 @@
1
- """delete_module: safely remove a previously generated module and its test mirror (T039)."""
1
+ """delete_module: safely remove a previously generated module and its test mirror."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import tomllib
6
5
  from pathlib import Path
7
6
  from typing import Annotated
8
7
 
9
8
  import typer
10
- from rich.console import Console
11
9
 
10
+ from scaffold_ca_python.commands._shared import console, require_project_root, validate_name_or_exit
12
11
  from scaffold_ca_python.core.module_builder import ModuleBuilder
13
- from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_module_name
14
- from scaffold_ca_python.core.project_detector import find_project_root, resolve_tests_root
15
- from scaffold_ca_python.factory import ModuleFactory
12
+ from scaffold_ca_python.core.module_locator import find_module_targets
13
+ from scaffold_ca_python.core.name_utils import to_snake_case, validate_module_name
14
+ from scaffold_ca_python.core.project_detector import load_project_context
16
15
  from scaffold_ca_python.factory.simple.delete_module_factory import DeleteModuleFactory
17
- from scaffold_ca_python.models.context import ModuleContext, ProjectContext
16
+ from scaffold_ca_python.models.context import ModuleContext
18
17
  from scaffold_ca_python.models.layer import Layer
19
18
 
20
- console = Console()
21
-
22
- _REGISTRY: dict[str, type[ModuleFactory]] = {
23
- "delete": DeleteModuleFactory,
24
- }
25
-
26
19
  _DM_HELP = "Delete a previously generated module and its test mirror. Alias 'dm'."
27
20
  _DM_EPILOG = "Examples:\n\n * scaffold dm --name order\n\n * scaffold delete-module --name my_adapter\n\n"
28
21
 
29
22
 
30
- # ---------------------------------------------------------------------------
31
- # Module discovery helpers
32
- # ---------------------------------------------------------------------------
33
-
34
-
35
- def _find_module_targets(pkg: str, project_root: Path, snake: str) -> list[tuple[Path, Path | None]]:
36
- """Return a list of (target_path, test_path_or_None) for all matches of *snake*.
37
-
38
- Searches across:
39
- 1. domain/model/<snake>.py
40
- 2. domain/usecase/<snake>.py
41
- 3. infrastructure/driven_adapters/<snake>/
42
- 4. infrastructure/entry_points/<snake>/
43
- 5. infrastructure/helpers/<snake>/
44
- """
45
- src_root = project_root / "src" / pkg
46
- tests_root = resolve_tests_root(project_root)
47
-
48
- candidates: list[tuple[Path, Path | None]] = []
49
-
50
- # Single-file layers (domain/model, domain/usecase)
51
- for src_rel, test_rel in [
52
- (src_root / "domain" / "model" / f"{snake}.py", tests_root / "domain" / "model" / f"test_{snake}.py"),
53
- (src_root / "domain" / "usecase" / f"{snake}.py", tests_root / "domain" / "usecase" / f"test_{snake}.py"),
54
- ]:
55
- if src_rel.exists():
56
- candidates.append((src_rel, test_rel if test_rel.exists() else None))
57
-
58
- # Directory-based layers (infrastructure sub-dirs)
59
- for src_rel, test_rel in [
60
- (
61
- src_root / "infrastructure" / "driven_adapters" / snake,
62
- tests_root / "infrastructure" / "driven_adapters" / snake,
63
- ),
64
- (
65
- src_root / "infrastructure" / "entry_points" / snake,
66
- tests_root / "infrastructure" / "entry_points" / snake,
67
- ),
68
- (
69
- src_root / "infrastructure" / "helpers" / snake,
70
- tests_root / "infrastructure" / "helpers" / snake,
71
- ),
72
- ]:
73
- if src_rel.exists():
74
- candidates.append((src_rel, test_rel if test_rel.exists() else None))
75
-
76
- return candidates
77
-
78
-
79
- # ---------------------------------------------------------------------------
80
- # Implementation
81
- # ---------------------------------------------------------------------------
82
-
83
-
84
- def _delete_module_impl(name: str, confirm: bool, dry_run: bool) -> None:
85
- # Validate name
23
+ def _display(path: Path, root: Path) -> Path:
86
24
  try:
87
- validate_module_name(name)
88
- except ScaffoldError as exc:
89
- console.print(f"[red]Error:[/red] {exc}")
90
- raise typer.Exit(code=1) from None
25
+ return path.relative_to(root)
26
+ except ValueError:
27
+ return path
91
28
 
92
- # Locate project root
93
- try:
94
- project_root = find_project_root()
95
- except ScaffoldError:
96
- console.print("[red]Error:[/red] No scaffold-ca-python project found. Run 'scaffold ca' first.")
97
- raise typer.Exit(code=1) from None
98
29
 
99
- project_ctx = _load_project_context(project_root)
30
+ def _delete_module_impl(name: str, confirm: bool, dry_run: bool) -> None:
31
+ validate_name_or_exit(name, validate_module_name)
32
+ project_root = require_project_root()
33
+ project_ctx = load_project_context(project_root)
100
34
 
101
35
  # Create a placeholder ModuleContext for the factory (layer doesn't matter for delete)
102
36
  module_ctx = ModuleContext(name=name, layer=Layer.HELPERS, project=project_ctx)
103
37
 
104
38
  # Find targets
105
39
  snake = to_snake_case(name)
106
- targets = _find_module_targets(project_ctx.python_package, project_root, snake)
40
+ targets = find_module_targets(project_root, project_ctx.python_package, snake)
107
41
 
108
42
  if not targets:
109
43
  console.print(
110
44
  f"[red]Error:[/red] No module named '{snake}' found in project.\n"
111
45
  "[dim]Hint:[/dim] Check spelling with 'scaffold vs' to list modules."
112
46
  )
113
- raise typer.Exit(code=1) from None
47
+ raise typer.Exit(code=1)
114
48
 
115
49
  # dry_run OR no --confirm → preview mode only
116
50
  if dry_run or not confirm:
117
51
  console.print("The following files would be deleted:\n")
118
52
  for src_path, test_path in targets:
119
- try:
120
- display = src_path.relative_to(project_root)
121
- except ValueError:
122
- display = src_path
123
- console.print(f" {display}")
53
+ console.print(f" {_display(src_path, project_root)}")
124
54
  if test_path:
125
- try:
126
- display = test_path.relative_to(project_root)
127
- except ValueError:
128
- display = test_path
129
- console.print(f" {display}")
55
+ console.print(f" {_display(test_path, project_root)}")
130
56
  console.print("\nRun with --confirm to proceed.")
131
57
  return
132
58
 
@@ -138,9 +64,7 @@ def _delete_module_impl(name: str, confirm: bool, dry_run: bool) -> None:
138
64
  dry_run=False,
139
65
  )
140
66
 
141
- factory_class = _REGISTRY["delete"]
142
- factory = factory_class()
143
- factory.build(builder)
67
+ DeleteModuleFactory().build(builder)
144
68
 
145
69
  # Persist deletion
146
70
  builder.persist()
@@ -184,18 +108,3 @@ def register(app: typer.Typer) -> None:
184
108
  typer.echo(ctx.get_help())
185
109
  raise typer.Exit(0)
186
110
  _delete_module_impl(name, confirm, dry_run)
187
-
188
-
189
- # ---------------------------------------------------------------------------
190
- # Helpers
191
- # ---------------------------------------------------------------------------
192
-
193
-
194
- def _load_project_context(root: Path) -> ProjectContext:
195
- pyproject = root / "pyproject.toml"
196
- with pyproject.open("rb") as fh:
197
- data = tomllib.load(fh)
198
- section = data.get("tool", {}).get("scaffold-ca-python", {})
199
- return ProjectContext(
200
- name=section.get("name", root.name),
201
- )