scaffold-ca-python 0.2.0__py3-none-any.whl → 0.3.0__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/commands/_entry_point_rules.py +53 -0
- scaffold_ca_python/commands/_shared.py +109 -0
- scaffold_ca_python/commands/add_mcp_tool.py +200 -0
- scaffold_ca_python/commands/delete_module.py +19 -110
- scaffold_ca_python/commands/generate_driven_adapter.py +43 -106
- scaffold_ca_python/commands/generate_entry_point.py +107 -451
- scaffold_ca_python/commands/generate_helper.py +25 -82
- scaffold_ca_python/commands/generate_model.py +39 -84
- scaffold_ca_python/commands/generate_pipeline.py +47 -91
- scaffold_ca_python/commands/generate_project.py +75 -105
- scaffold_ca_python/commands/generate_use_case.py +26 -81
- scaffold_ca_python/commands/update_project.py +19 -32
- scaffold_ca_python/commands/validate_structure.py +8 -12
- scaffold_ca_python/core/file_writer.py +55 -70
- scaffold_ca_python/core/mcp_tool_spec.py +1 -1
- scaffold_ca_python/core/module_builder.py +13 -6
- scaffold_ca_python/core/module_locator.py +51 -0
- scaffold_ca_python/core/name_utils.py +1 -6
- scaffold_ca_python/core/openapi_routes.py +34 -0
- scaffold_ca_python/core/project_detector.py +14 -9
- scaffold_ca_python/core/structure_validator.py +66 -59
- scaffold_ca_python/core/template_renderer.py +1 -9
- scaffold_ca_python/factory/driven_adapters/__init__.py +14 -0
- scaffold_ca_python/factory/driven_adapters/base.py +23 -0
- scaffold_ca_python/factory/driven_adapters/da_generic.py +3 -3
- scaffold_ca_python/factory/driven_adapters/da_rest_consumer.py +4 -3
- scaffold_ca_python/factory/driven_adapters/da_secrets.py +4 -3
- scaffold_ca_python/factory/entry_points/__init__.py +16 -0
- scaffold_ca_python/factory/entry_points/base.py +63 -0
- scaffold_ca_python/factory/entry_points/ep_agent.py +13 -4
- scaffold_ca_python/factory/entry_points/ep_generic.py +13 -4
- scaffold_ca_python/factory/entry_points/ep_mcp.py +15 -8
- scaffold_ca_python/factory/entry_points/ep_restapi.py +34 -6
- scaffold_ca_python/factory/pipelines/__init__.py +20 -0
- scaffold_ca_python/factory/pipelines/pipeline_factory.py +27 -0
- scaffold_ca_python/factory/simple/delete_module_factory.py +13 -48
- scaffold_ca_python/factory/simple/helper_factory.py +1 -1
- scaffold_ca_python/factory/simple/model_factory.py +29 -24
- scaffold_ca_python/factory/simple/use_case_factory.py +1 -1
- scaffold_ca_python/models/context.py +1 -1
- scaffold_ca_python/models/layer.py +1 -1
- scaffold_ca_python/models/options.py +53 -0
- scaffold_ca_python/models/violation.py +1 -1
- scaffold_ca_python/templates/model/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/model/gateway.py.jinja2 +7 -0
- scaffold_ca_python/templates/model/gateways_init.py.jinja2 +1 -0
- scaffold_ca_python/templates/model/test_gateway.py.jinja2 +9 -0
- scaffold_ca_python/templates/model/test_model.py.jinja2 +1 -1
- scaffold_ca_python/templates/project/empty.jinja2 +0 -0
- scaffold_ca_python/templates/project/package_init.py.jinja2 +1 -0
- {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.3.0.dist-info}/METADATA +13 -2
- {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.3.0.dist-info}/RECORD +55 -39
- {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.3.0.dist-info}/WHEEL +0 -0
- {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.3.0.dist-info}/entry_points.txt +0 -0
- {scaffold_ca_python-0.2.0.dist-info → scaffold_ca_python-0.3.0.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
|
|
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.
|
|
14
|
-
from scaffold_ca_python.core.
|
|
15
|
-
from scaffold_ca_python.
|
|
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
|
|
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
|
-
|
|
88
|
-
except
|
|
89
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
)
|