scaffold-ca-python 0.1.3__py3-none-any.whl → 0.2.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/delete_module.py +6 -21
- scaffold_ca_python/commands/generate_driven_adapter.py +11 -6
- scaffold_ca_python/commands/generate_entry_point.py +298 -9
- scaffold_ca_python/commands/generate_helper.py +4 -4
- scaffold_ca_python/commands/generate_model.py +4 -4
- scaffold_ca_python/commands/generate_project.py +1 -1
- scaffold_ca_python/commands/generate_use_case.py +6 -4
- scaffold_ca_python/core/file_writer.py +27 -2
- scaffold_ca_python/core/mcp_tool_spec.py +325 -0
- scaffold_ca_python/core/module_builder.py +54 -4
- scaffold_ca_python/core/name_utils.py +14 -0
- scaffold_ca_python/core/pyproject_writer.py +84 -1
- scaffold_ca_python/factory/driven_adapters/da_rest_consumer.py +5 -3
- scaffold_ca_python/factory/driven_adapters/da_secrets.py +2 -2
- scaffold_ca_python/factory/entry_points/ep_agent.py +2 -2
- scaffold_ca_python/factory/entry_points/ep_mcp.py +7 -7
- scaffold_ca_python/factory/entry_points/ep_restapi.py +8 -8
- scaffold_ca_python/models/file_operation.py +19 -1
- scaffold_ca_python/templates/driven_adapter/rest_consumer/test_rest_consumer.py.jinja2 +1 -1
- scaffold_ca_python/templates/entry_point/agent/agent.py.jinja2 +5 -4
- scaffold_ca_python/templates/entry_point/mcp/app.py.jinja2 +6 -3
- scaffold_ca_python/templates/entry_point/mcp/prompts.py.jinja2 +2 -2
- scaffold_ca_python/templates/entry_point/mcp/resources.py.jinja2 +2 -2
- scaffold_ca_python/templates/entry_point/mcp/server.py.jinja2 +1 -1
- scaffold_ca_python/templates/entry_point/mcp/test_app.py.jinja2 +0 -2
- scaffold_ca_python/templates/entry_point/mcp/test_tool_stub.py.jinja2 +8 -0
- scaffold_ca_python/templates/entry_point/mcp/tool_stub.py.jinja2 +5 -0
- scaffold_ca_python/templates/entry_point/mcp/tools.py.jinja2 +2 -2
- scaffold_ca_python/templates/entry_point/restapi/app.py.jinja2 +1 -1
- scaffold_ca_python/templates/entry_point/restapi/test_app.py.jinja2 +3 -3
- scaffold_ca_python/templates/project/application/config/container.py.jinja2 +1 -1
- scaffold_ca_python/templates/project/application/config/driven_adapters_container.py.jinja2 +1 -1
- scaffold_ca_python/templates/project/application/config/resource_container.py.jinja2 +2 -2
- scaffold_ca_python/templates/project/pyproject_toml.jinja2 +15 -8
- {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/METADATA +18 -14
- {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/RECORD +39 -36
- {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/WHEEL +1 -1
- {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/entry_points.txt +0 -0
- {scaffold_ca_python-0.1.3.dist-info → scaffold_ca_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -10,7 +10,7 @@ import typer
|
|
|
10
10
|
from rich.console import Console
|
|
11
11
|
|
|
12
12
|
from scaffold_ca_python.core.module_builder import ModuleBuilder
|
|
13
|
-
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case,
|
|
13
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_module_name
|
|
14
14
|
from scaffold_ca_python.core.project_detector import find_project_root, resolve_tests_root
|
|
15
15
|
from scaffold_ca_python.factory import ModuleFactory
|
|
16
16
|
from scaffold_ca_python.factory.simple.delete_module_factory import DeleteModuleFactory
|
|
@@ -24,7 +24,7 @@ _REGISTRY: dict[str, type[ModuleFactory]] = {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
_DM_HELP = "Delete a previously generated module and its test mirror. Alias 'dm'."
|
|
27
|
-
_DM_EPILOG = "Examples:\n\n * scaffold dm --name
|
|
27
|
+
_DM_EPILOG = "Examples:\n\n * scaffold dm --name order\n\n * scaffold delete-module --name my_adapter\n\n"
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
# ---------------------------------------------------------------------------
|
|
@@ -32,7 +32,7 @@ _DM_EPILOG = "Examples:\n\n * scaffold dm --name Order\n\n * scaffold delete-m
|
|
|
32
32
|
# ---------------------------------------------------------------------------
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def _find_module_targets(project_root: Path, snake: str) -> list[tuple[Path, Path | None]]:
|
|
35
|
+
def _find_module_targets(pkg: str, project_root: Path, snake: str) -> list[tuple[Path, Path | None]]:
|
|
36
36
|
"""Return a list of (target_path, test_path_or_None) for all matches of *snake*.
|
|
37
37
|
|
|
38
38
|
Searches across:
|
|
@@ -42,7 +42,6 @@ def _find_module_targets(project_root: Path, snake: str) -> list[tuple[Path, Pat
|
|
|
42
42
|
4. infrastructure/entry_points/<snake>/
|
|
43
43
|
5. infrastructure/helpers/<snake>/
|
|
44
44
|
"""
|
|
45
|
-
pkg = _get_python_package(project_root)
|
|
46
45
|
src_root = project_root / "src" / pkg
|
|
47
46
|
tests_root = resolve_tests_root(project_root)
|
|
48
47
|
|
|
@@ -77,20 +76,6 @@ def _find_module_targets(project_root: Path, snake: str) -> list[tuple[Path, Pat
|
|
|
77
76
|
return candidates
|
|
78
77
|
|
|
79
78
|
|
|
80
|
-
def _get_python_package(root: Path) -> str:
|
|
81
|
-
pyproject = root / "pyproject.toml"
|
|
82
|
-
with pyproject.open("rb") as fh:
|
|
83
|
-
data = tomllib.load(fh)
|
|
84
|
-
section = data.get("tool", {}).get("scaffold-ca-python", {})
|
|
85
|
-
name = section.get("name", root.name)
|
|
86
|
-
import re
|
|
87
|
-
|
|
88
|
-
s = name.replace("-", "_")
|
|
89
|
-
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s)
|
|
90
|
-
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
|
91
|
-
return s.lower()
|
|
92
|
-
|
|
93
|
-
|
|
94
79
|
# ---------------------------------------------------------------------------
|
|
95
80
|
# Implementation
|
|
96
81
|
# ---------------------------------------------------------------------------
|
|
@@ -99,7 +84,7 @@ def _get_python_package(root: Path) -> str:
|
|
|
99
84
|
def _delete_module_impl(name: str, confirm: bool, dry_run: bool) -> None:
|
|
100
85
|
# Validate name
|
|
101
86
|
try:
|
|
102
|
-
|
|
87
|
+
validate_module_name(name)
|
|
103
88
|
except ScaffoldError as exc:
|
|
104
89
|
console.print(f"[red]Error:[/red] {exc}")
|
|
105
90
|
raise typer.Exit(code=1) from None
|
|
@@ -118,7 +103,7 @@ def _delete_module_impl(name: str, confirm: bool, dry_run: bool) -> None:
|
|
|
118
103
|
|
|
119
104
|
# Find targets
|
|
120
105
|
snake = to_snake_case(name)
|
|
121
|
-
targets = _find_module_targets(project_root, snake)
|
|
106
|
+
targets = _find_module_targets(project_ctx.python_package, project_root, snake)
|
|
122
107
|
|
|
123
108
|
if not targets:
|
|
124
109
|
console.print(
|
|
@@ -174,7 +159,7 @@ def register(app: typer.Typer) -> None:
|
|
|
174
159
|
def delete_module(
|
|
175
160
|
ctx: typer.Context,
|
|
176
161
|
name: Annotated[
|
|
177
|
-
str | None, typer.Option("--name", help="Module name (
|
|
162
|
+
str | None, typer.Option("--name", help="Module name (snake_case).", rich_help_panel="Required")
|
|
178
163
|
] = None,
|
|
179
164
|
confirm: Annotated[
|
|
180
165
|
bool,
|
|
@@ -8,10 +8,11 @@ from typing import Annotated
|
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
10
|
from rich.console import Console
|
|
11
|
+
from rich.markup import escape
|
|
11
12
|
from rich.tree import Tree
|
|
12
13
|
|
|
13
14
|
from scaffold_ca_python.core.module_builder import ModuleBuilder
|
|
14
|
-
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case,
|
|
15
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_module_name
|
|
15
16
|
from scaffold_ca_python.core.project_detector import find_project_root
|
|
16
17
|
from scaffold_ca_python.factory import ModuleFactory
|
|
17
18
|
from scaffold_ca_python.factory.driven_adapters.da_generic import DrivenAdapterGeneric
|
|
@@ -37,7 +38,7 @@ _GDA_EPILOG = (
|
|
|
37
38
|
" ==================================================\n\n"
|
|
38
39
|
"Examples:\n\n"
|
|
39
40
|
" * scaffold gda --type rest-consumer\n\n"
|
|
40
|
-
" * scaffold generate-driven-adapter --type generic --name
|
|
41
|
+
" * scaffold generate-driven-adapter --type generic --name my_adapter\n\n"
|
|
41
42
|
)
|
|
42
43
|
|
|
43
44
|
|
|
@@ -55,7 +56,7 @@ def _generate_driven_adapter_impl(type_: str, name: str | None, dry_run: bool) -
|
|
|
55
56
|
# --- Validate name if provided ---
|
|
56
57
|
if name:
|
|
57
58
|
try:
|
|
58
|
-
|
|
59
|
+
validate_module_name(name)
|
|
59
60
|
except ScaffoldError as exc:
|
|
60
61
|
console.print(f"[red]Error:[/red] {exc}")
|
|
61
62
|
raise typer.Exit(code=1) from None
|
|
@@ -116,8 +117,12 @@ def _generate_driven_adapter_impl(type_: str, name: str | None, dry_run: bool) -
|
|
|
116
117
|
for p in sorted(created):
|
|
117
118
|
tree.add(str(p.relative_to(project_root)))
|
|
118
119
|
console.print(tree)
|
|
119
|
-
if builder.
|
|
120
|
-
|
|
120
|
+
if builder.pending_dependencies:
|
|
121
|
+
target = escape("[project.dependencies]")
|
|
122
|
+
console.print(f"[dim]Would add to {target}: {', '.join(builder.pending_dependencies)}[/dim]")
|
|
123
|
+
for grp, pkgs in builder.pending_group_dependencies.items():
|
|
124
|
+
target = escape(f"[dependency-groups.{grp}]")
|
|
125
|
+
console.print(f"[dim]Would add to {target}: {', '.join(pkgs)}[/dim]")
|
|
121
126
|
return
|
|
122
127
|
|
|
123
128
|
console.print(f"[green]✓[/green] Driven adapter [bold]{subdir}[/bold] created. Created {len(created)} file(s).")
|
|
@@ -146,7 +151,7 @@ def register(app: typer.Typer) -> None:
|
|
|
146
151
|
str | None,
|
|
147
152
|
typer.Option(
|
|
148
153
|
"--name",
|
|
149
|
-
help="Adapter name (required for --type generic).",
|
|
154
|
+
help="Adapter name in snake_case, e.g. my_adapter (required for --type generic).",
|
|
150
155
|
rich_help_panel="Options",
|
|
151
156
|
),
|
|
152
157
|
] = None,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import ast
|
|
5
6
|
import json
|
|
6
7
|
import tomllib
|
|
7
8
|
from pathlib import Path
|
|
@@ -10,11 +11,14 @@ from typing import Annotated
|
|
|
10
11
|
import typer
|
|
11
12
|
import yaml
|
|
12
13
|
from rich.console import Console
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
from rich.syntax import Syntax
|
|
13
16
|
from rich.tree import Tree
|
|
14
17
|
|
|
18
|
+
from scaffold_ca_python.core import mcp_tool_spec
|
|
15
19
|
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
|
|
20
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case
|
|
21
|
+
from scaffold_ca_python.core.project_detector import find_project_root, resolve_tests_root
|
|
18
22
|
from scaffold_ca_python.factory import ModuleFactory
|
|
19
23
|
from scaffold_ca_python.factory.entry_points.ep_agent import EntryPointAgent
|
|
20
24
|
from scaffold_ca_python.factory.entry_points.ep_generic import EntryPointGeneric
|
|
@@ -44,12 +48,11 @@ _GEP_EPILOG = (
|
|
|
44
48
|
" * mcp MCP server entry point\n\n"
|
|
45
49
|
" * generic Plain entry point\n\n"
|
|
46
50
|
" ==================================================\n\n"
|
|
47
|
-
"Notes: --enable-kafka and --enable-mcp-client are agent type only.\n\n"
|
|
48
|
-
" ==================================================\n\n"
|
|
49
51
|
"Examples:\n\n"
|
|
50
52
|
" * scaffold gep --type restapi\n\n"
|
|
51
53
|
" * scaffold gep --type agent --enable-kafka\n\n"
|
|
52
54
|
" * scaffold gep --type mcp\n\n"
|
|
55
|
+
" * scaffold gep --type mcp --add-tool --name create_file --param path:str --returns str\n\n"
|
|
53
56
|
" * scaffold generate-entry-point --type generic\n"
|
|
54
57
|
)
|
|
55
58
|
|
|
@@ -59,10 +62,239 @@ def _load_project_context(project_root: Path) -> ProjectContext:
|
|
|
59
62
|
pyproject_path = project_root / "pyproject.toml"
|
|
60
63
|
with open(pyproject_path, "rb") as f:
|
|
61
64
|
data = tomllib.load(f)
|
|
62
|
-
project_name = data.get("project", {}).get("name", "project")
|
|
65
|
+
project_name = to_snake_case(data.get("project", {}).get("name", "project"))
|
|
63
66
|
return ProjectContext(name=project_name)
|
|
64
67
|
|
|
65
68
|
|
|
69
|
+
def _add_tool_impl(
|
|
70
|
+
type_: str,
|
|
71
|
+
swagger: str | None,
|
|
72
|
+
enable_kafka: bool,
|
|
73
|
+
enable_mcp_client: bool,
|
|
74
|
+
with_resources: bool,
|
|
75
|
+
with_prompts: bool,
|
|
76
|
+
name: str | None,
|
|
77
|
+
param: list[str] | None,
|
|
78
|
+
returns: str,
|
|
79
|
+
description: str | None,
|
|
80
|
+
dry_run: bool,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Add a tool to an existing MCP entry point (T018-T021, T027-T030a).
|
|
83
|
+
|
|
84
|
+
Implements User Story 1 and 2: appends a tool stub to tools.py and a registration
|
|
85
|
+
test to test_tools.py in an existing MCP project, with comprehensive guard rails.
|
|
86
|
+
"""
|
|
87
|
+
# T028: Flag/type incompatibility guards (FR-011, FR-012) - before project access
|
|
88
|
+
# Check creation-only flags
|
|
89
|
+
creation_flags = []
|
|
90
|
+
if swagger is not None:
|
|
91
|
+
creation_flags.append("--swagger")
|
|
92
|
+
if enable_kafka:
|
|
93
|
+
creation_flags.append("--enable-kafka")
|
|
94
|
+
if enable_mcp_client:
|
|
95
|
+
creation_flags.append("--enable-mcp-client")
|
|
96
|
+
if with_resources:
|
|
97
|
+
creation_flags.append("--with-resources")
|
|
98
|
+
if with_prompts:
|
|
99
|
+
creation_flags.append("--with-prompts")
|
|
100
|
+
|
|
101
|
+
if creation_flags:
|
|
102
|
+
flag = creation_flags[0]
|
|
103
|
+
console.print(f"[red]Error:[/red] --add-tool is incompatible with {flag}.")
|
|
104
|
+
raise typer.Exit(code=1)
|
|
105
|
+
|
|
106
|
+
# Check type compatibility (FR-012) - handled by T016(a) for --type omitted
|
|
107
|
+
# Here we check non-mcp types
|
|
108
|
+
if type_ != "mcp":
|
|
109
|
+
console.print("[red]Error:[/red] --add-tool is only valid with --type mcp.")
|
|
110
|
+
raise typer.Exit(code=1)
|
|
111
|
+
|
|
112
|
+
# T018: Format-only validation (no filesystem access yet)
|
|
113
|
+
if name is None:
|
|
114
|
+
console.print("[red]Error:[/red] --name is required with --add-tool.")
|
|
115
|
+
raise typer.Exit(code=1)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
tool_spec = mcp_tool_spec.build_tool_spec(name, param, returns, description)
|
|
119
|
+
except (ValueError, ScaffoldError) as e:
|
|
120
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
121
|
+
raise typer.Exit(code=1) from None
|
|
122
|
+
|
|
123
|
+
# T019: Resolve project root and check entry point exists
|
|
124
|
+
try:
|
|
125
|
+
project_root = find_project_root()
|
|
126
|
+
except ScaffoldError as e:
|
|
127
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
128
|
+
raise typer.Exit(code=1) from None
|
|
129
|
+
|
|
130
|
+
project_ctx = _load_project_context(project_root)
|
|
131
|
+
|
|
132
|
+
tools_py_path = (
|
|
133
|
+
project_root
|
|
134
|
+
/ "src"
|
|
135
|
+
/ project_ctx.python_package
|
|
136
|
+
/ "infrastructure"
|
|
137
|
+
/ "entry_points"
|
|
138
|
+
/ "mcp_server"
|
|
139
|
+
/ "tools.py"
|
|
140
|
+
)
|
|
141
|
+
test_tools_py_path = (
|
|
142
|
+
resolve_tests_root(project_root) / "infrastructure" / "entry_points" / "mcp_server" / "test_tools.py"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if not tools_py_path.exists():
|
|
146
|
+
console.print(
|
|
147
|
+
"[red]Error:[/red] No MCP entry point found at infrastructure/entry_points/mcp_server/.\n"
|
|
148
|
+
"[dim]Hint:[/dim] Run [cyan]scaffold gep --type mcp[/cyan] first to create one."
|
|
149
|
+
)
|
|
150
|
+
raise typer.Exit(code=1)
|
|
151
|
+
|
|
152
|
+
# T020: Read files and check structure
|
|
153
|
+
try:
|
|
154
|
+
original_tools_py = tools_py_path.read_text(encoding="utf-8")
|
|
155
|
+
except OSError as e:
|
|
156
|
+
console.print(f"[red]Error:[/red] Cannot read {tools_py_path}: {e}")
|
|
157
|
+
raise typer.Exit(code=1) from None
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
original_test_tools_py = test_tools_py_path.read_text(encoding="utf-8")
|
|
161
|
+
except OSError:
|
|
162
|
+
console.print(
|
|
163
|
+
"[red]Error:[/red] tests/infrastructure/entry_points/mcp_server/test_tools.py is missing or "
|
|
164
|
+
"does not have\n"
|
|
165
|
+
"the structure --add-tool needs to append a registration test.\n"
|
|
166
|
+
"[dim]Hint:[/dim] Restore test_tools.py, or regenerate the entry point with "
|
|
167
|
+
"[cyan]scaffold gep --type mcp[/cyan]."
|
|
168
|
+
)
|
|
169
|
+
raise typer.Exit(code=1)
|
|
170
|
+
|
|
171
|
+
# Check bind_tools is last top-level statement
|
|
172
|
+
if not mcp_tool_spec.has_bind_tools_function(original_tools_py):
|
|
173
|
+
console.print(
|
|
174
|
+
"[red]Error:[/red] infrastructure/entry_points/mcp_server/tools.py was generated by an older "
|
|
175
|
+
"scaffold\n"
|
|
176
|
+
"version and does not support --add-tool.\n"
|
|
177
|
+
"[dim]Hint:[/dim] Make [cyan]async def bind_tools[/cyan] the last function in tools.py, or "
|
|
178
|
+
"regenerate the entry point\n"
|
|
179
|
+
"(back up any custom tools first) with [cyan]scaffold gep --type mcp[/cyan]."
|
|
180
|
+
)
|
|
181
|
+
raise typer.Exit(code=1)
|
|
182
|
+
|
|
183
|
+
# Check test file has required structure
|
|
184
|
+
if not mcp_tool_spec.has_test_registration_structure(original_test_tools_py):
|
|
185
|
+
console.print(
|
|
186
|
+
"[red]Error:[/red] tests/infrastructure/entry_points/mcp_server/test_tools.py is missing or "
|
|
187
|
+
"does not have\n"
|
|
188
|
+
"the structure --add-tool needs to append a registration test.\n"
|
|
189
|
+
"[dim]Hint:[/dim] Restore test_tools.py, or regenerate the entry point with "
|
|
190
|
+
"[cyan]scaffold gep --type mcp[/cyan]."
|
|
191
|
+
)
|
|
192
|
+
raise typer.Exit(code=1)
|
|
193
|
+
|
|
194
|
+
# Check for duplicate tool names
|
|
195
|
+
existing_names = mcp_tool_spec.find_existing_tool_names(original_tools_py)
|
|
196
|
+
if tool_spec.name in existing_names:
|
|
197
|
+
if tool_spec.name == "bind_tools":
|
|
198
|
+
console.print(
|
|
199
|
+
f"[red]Error:[/red] '{tool_spec.name}' is reserved by the tool-binding function and "
|
|
200
|
+
"cannot be used as a tool name."
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
console.print(f"[red]Error:[/red] A tool named '{tool_spec.name}' already exists in tools.py.")
|
|
204
|
+
raise typer.Exit(code=1)
|
|
205
|
+
|
|
206
|
+
# T021: Render templates and append
|
|
207
|
+
builder = ModuleBuilder(
|
|
208
|
+
project_root=project_root,
|
|
209
|
+
project_ctx=project_ctx,
|
|
210
|
+
dry_run=dry_run,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Render tool stub (4-space-indented nested function)
|
|
214
|
+
tool_stub_content = builder.render(
|
|
215
|
+
"entry_point/mcp/tool_stub.py.jinja2",
|
|
216
|
+
{
|
|
217
|
+
"name": tool_spec.name,
|
|
218
|
+
"params": [{"name": p.name, "type": p.type} for p in tool_spec.params],
|
|
219
|
+
"returns": tool_spec.returns,
|
|
220
|
+
"docstring": tool_spec.docstring,
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Render test stub
|
|
225
|
+
test_stub_content = builder.render(
|
|
226
|
+
"entry_point/mcp/test_tool_stub.py.jinja2",
|
|
227
|
+
{"name": tool_spec.name},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Safety check: ensure appended content will parse
|
|
231
|
+
try:
|
|
232
|
+
ast.parse(original_tools_py + tool_stub_content)
|
|
233
|
+
except SyntaxError:
|
|
234
|
+
console.print(
|
|
235
|
+
"[red]Error:[/red] infrastructure/entry_points/mcp_server/tools.py cannot accept the new tool stub.\n"
|
|
236
|
+
"[dim]Hint:[/dim] Regenerate the entry point with [cyan]scaffold gep --type mcp[/cyan]."
|
|
237
|
+
)
|
|
238
|
+
raise typer.Exit(code=1)
|
|
239
|
+
|
|
240
|
+
# T030a: Check writability before any writes (FR-019)
|
|
241
|
+
import os
|
|
242
|
+
|
|
243
|
+
if not os.access(tools_py_path, os.W_OK):
|
|
244
|
+
console.print(
|
|
245
|
+
f"[red]Error:[/red] Cannot write to {tools_py_path.relative_to(project_root)}.\n"
|
|
246
|
+
"[dim]Hint:[/dim] Check the file's permissions and re-run the command."
|
|
247
|
+
)
|
|
248
|
+
raise typer.Exit(code=1)
|
|
249
|
+
|
|
250
|
+
if not os.access(test_tools_py_path, os.W_OK):
|
|
251
|
+
console.print(
|
|
252
|
+
f"[red]Error:[/red] Cannot write to {test_tools_py_path.relative_to(project_root)}.\n"
|
|
253
|
+
"[dim]Hint:[/dim] Check the file's permissions and re-run the command."
|
|
254
|
+
)
|
|
255
|
+
raise typer.Exit(code=1)
|
|
256
|
+
|
|
257
|
+
# Queue append operations
|
|
258
|
+
builder.append_to_file(tools_py_path, tool_stub_content)
|
|
259
|
+
builder.append_to_file(test_tools_py_path, test_stub_content)
|
|
260
|
+
|
|
261
|
+
# T030a: Persist changes with restore-on-failure (FR-019)
|
|
262
|
+
try:
|
|
263
|
+
modified_paths = builder.persist()
|
|
264
|
+
except OSError as e:
|
|
265
|
+
# Restore original content
|
|
266
|
+
try:
|
|
267
|
+
tools_py_path.write_text(original_tools_py, encoding="utf-8")
|
|
268
|
+
test_tools_py_path.write_text(original_test_tools_py, encoding="utf-8")
|
|
269
|
+
except OSError:
|
|
270
|
+
pass # Best effort restore
|
|
271
|
+
|
|
272
|
+
# Determine which file failed
|
|
273
|
+
error_str = str(e).lower()
|
|
274
|
+
if "test_tools" in error_str or str(test_tools_py_path) in str(e):
|
|
275
|
+
failed_file = "test_tools.py"
|
|
276
|
+
else:
|
|
277
|
+
failed_file = "tools.py"
|
|
278
|
+
|
|
279
|
+
console.print(
|
|
280
|
+
f"[red]Error:[/red] Failed to write {failed_file} ({e}). "
|
|
281
|
+
"tools.py was restored; no changes were made.\n"
|
|
282
|
+
"[dim]Hint:[/dim] Fix the underlying problem (permissions, disk space) and re-run the command."
|
|
283
|
+
)
|
|
284
|
+
raise typer.Exit(code=1) from None
|
|
285
|
+
|
|
286
|
+
if dry_run:
|
|
287
|
+
console.print("\n[yellow]Dry run:[/yellow] Would modify:\n")
|
|
288
|
+
for path in modified_paths:
|
|
289
|
+
console.print(f" • {path.relative_to(project_root)}")
|
|
290
|
+
console.print("\n[yellow]Tool stub preview:[/yellow]")
|
|
291
|
+
console.print(Syntax(tool_stub_content, "python", theme="monokai", line_numbers=False))
|
|
292
|
+
console.print("\n[yellow]Test stub preview:[/yellow]")
|
|
293
|
+
console.print(Syntax(test_stub_content, "python", theme="monokai", line_numbers=False))
|
|
294
|
+
else:
|
|
295
|
+
console.print(f"[green]✓[/green] Added tool '{tool_spec.name}' to MCP entry point.")
|
|
296
|
+
|
|
297
|
+
|
|
66
298
|
def _generate_entry_point_impl(
|
|
67
299
|
type_: str,
|
|
68
300
|
swagger: str | None,
|
|
@@ -70,8 +302,30 @@ def _generate_entry_point_impl(
|
|
|
70
302
|
enable_mcp_client: bool,
|
|
71
303
|
with_resources: bool,
|
|
72
304
|
with_prompts: bool,
|
|
305
|
+
add_tool: bool,
|
|
306
|
+
name: str | None,
|
|
307
|
+
param: list[str] | None,
|
|
308
|
+
returns: str,
|
|
309
|
+
description: str | None,
|
|
73
310
|
dry_run: bool,
|
|
74
311
|
) -> None:
|
|
312
|
+
# T016(b): Dispatch to --add-tool implementation if flag is set
|
|
313
|
+
if add_tool:
|
|
314
|
+
_add_tool_impl(
|
|
315
|
+
type_,
|
|
316
|
+
swagger,
|
|
317
|
+
enable_kafka,
|
|
318
|
+
enable_mcp_client,
|
|
319
|
+
with_resources,
|
|
320
|
+
with_prompts,
|
|
321
|
+
name,
|
|
322
|
+
param,
|
|
323
|
+
returns,
|
|
324
|
+
description,
|
|
325
|
+
dry_run,
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
|
|
75
329
|
# --- Validate type ---
|
|
76
330
|
if type_ not in _REGISTRY:
|
|
77
331
|
console.print(f"[red]Error:[/red] Unknown type '{type_}'. Allowed: {', '.join(_REGISTRY.keys())}.")
|
|
@@ -190,9 +444,10 @@ def _generate_entry_point_impl(
|
|
|
190
444
|
tree.add(str(p.relative_to(project_root)))
|
|
191
445
|
console.print(tree)
|
|
192
446
|
|
|
193
|
-
#
|
|
194
|
-
if builder.
|
|
195
|
-
|
|
447
|
+
# Report any runtime dependencies queued for injection
|
|
448
|
+
if builder.pending_dependencies:
|
|
449
|
+
target = escape("[project.dependencies]")
|
|
450
|
+
console.print(f"[dim]Would add to {target}: {', '.join(builder.pending_dependencies)}[/dim]")
|
|
196
451
|
|
|
197
452
|
if type_ == "restapi":
|
|
198
453
|
main_py = project_root / "src" / pkg / "main.py"
|
|
@@ -285,6 +540,24 @@ def register(app: typer.Typer) -> None:
|
|
|
285
540
|
show_default=True,
|
|
286
541
|
),
|
|
287
542
|
] = False,
|
|
543
|
+
add_tool: Annotated[
|
|
544
|
+
bool,
|
|
545
|
+
typer.Option(
|
|
546
|
+
"--add-tool/--no-add-tool",
|
|
547
|
+
help="Add a tool to an existing MCP entry point (mcp only).",
|
|
548
|
+
rich_help_panel="Options",
|
|
549
|
+
show_default=True,
|
|
550
|
+
),
|
|
551
|
+
] = False,
|
|
552
|
+
name: Annotated[str | None, typer.Option("--name", help="Tool name (required with --add-tool).")] = None,
|
|
553
|
+
param: Annotated[
|
|
554
|
+
list[str] | None,
|
|
555
|
+
typer.Option("--param", help="Tool parameter in 'name:type' format (repeatable)."),
|
|
556
|
+
] = None,
|
|
557
|
+
returns: Annotated[str, typer.Option("--returns", help="Tool return type.")] = "str",
|
|
558
|
+
description: Annotated[
|
|
559
|
+
str | None, typer.Option("--description", help="Tool description for docstring.")
|
|
560
|
+
] = None,
|
|
288
561
|
dry_run: Annotated[
|
|
289
562
|
bool,
|
|
290
563
|
typer.Option(
|
|
@@ -296,9 +569,25 @@ def register(app: typer.Typer) -> None:
|
|
|
296
569
|
] = False,
|
|
297
570
|
) -> None:
|
|
298
571
|
"""Scaffold an entry point inside infrastructure/entry_points/."""
|
|
572
|
+
# T016(a): Check --add-tool without --type before the help shortcut
|
|
573
|
+
if add_tool and type_ is None:
|
|
574
|
+
console.print("[red]Error:[/red] --add-tool is only valid with --type mcp.")
|
|
575
|
+
raise typer.Exit(code=1)
|
|
576
|
+
|
|
299
577
|
if type_ is None:
|
|
300
578
|
typer.echo(ctx.get_help())
|
|
301
579
|
raise typer.Exit(0)
|
|
302
580
|
_generate_entry_point_impl(
|
|
303
|
-
type_,
|
|
581
|
+
type_,
|
|
582
|
+
swagger,
|
|
583
|
+
enable_kafka,
|
|
584
|
+
enable_mcp_client,
|
|
585
|
+
with_resources,
|
|
586
|
+
with_prompts,
|
|
587
|
+
add_tool,
|
|
588
|
+
name,
|
|
589
|
+
param,
|
|
590
|
+
returns,
|
|
591
|
+
description,
|
|
592
|
+
dry_run,
|
|
304
593
|
)
|
|
@@ -11,7 +11,7 @@ from rich.console import Console
|
|
|
11
11
|
from rich.tree import Tree
|
|
12
12
|
|
|
13
13
|
from scaffold_ca_python.core.module_builder import ModuleBuilder
|
|
14
|
-
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case,
|
|
14
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_module_name
|
|
15
15
|
from scaffold_ca_python.core.project_detector import find_project_root
|
|
16
16
|
from scaffold_ca_python.factory import ModuleFactory
|
|
17
17
|
from scaffold_ca_python.factory.simple.helper_factory import HelperFactory
|
|
@@ -25,13 +25,13 @@ _REGISTRY: dict[str, type[ModuleFactory]] = {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
_GH_HELP = "Scaffold a helper utility module and test stub. Alias 'gh'."
|
|
28
|
-
_GH_EPILOG = "Example:\n\n * scaffold gh --name
|
|
28
|
+
_GH_EPILOG = "Example:\n\n * scaffold gh --name json_parser\n\n * scaffold generate-helper --name json_parser\n\n"
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def _generate_helper_impl(name: str, dry_run: bool) -> None:
|
|
32
32
|
# --- Validate name ---
|
|
33
33
|
try:
|
|
34
|
-
|
|
34
|
+
validate_module_name(name)
|
|
35
35
|
except ScaffoldError as exc:
|
|
36
36
|
console.print(f"[red]Error:[/red] {exc}")
|
|
37
37
|
raise typer.Exit(code=1) from None
|
|
@@ -100,7 +100,7 @@ def register(app: typer.Typer) -> None:
|
|
|
100
100
|
def generate_helper(
|
|
101
101
|
ctx: typer.Context,
|
|
102
102
|
name: Annotated[
|
|
103
|
-
str | None, typer.Option("--name", help="Helper name (
|
|
103
|
+
str | None, typer.Option("--name", help="Helper name (snake_case).", rich_help_panel="Required")
|
|
104
104
|
] = None,
|
|
105
105
|
dry_run: Annotated[
|
|
106
106
|
bool,
|
|
@@ -11,7 +11,7 @@ from rich.console import Console
|
|
|
11
11
|
from rich.tree import Tree
|
|
12
12
|
|
|
13
13
|
from scaffold_ca_python.core.module_builder import ModuleBuilder
|
|
14
|
-
from scaffold_ca_python.core.name_utils import ScaffoldError,
|
|
14
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError, validate_module_name
|
|
15
15
|
from scaffold_ca_python.core.project_detector import find_project_root
|
|
16
16
|
from scaffold_ca_python.factory import ModuleFactory
|
|
17
17
|
from scaffold_ca_python.factory.simple.model_factory import ModelFactory
|
|
@@ -24,13 +24,13 @@ _REGISTRY: dict[str, type[ModuleFactory]] = {
|
|
|
24
24
|
"model": ModelFactory,
|
|
25
25
|
}
|
|
26
26
|
_GM_HELP = "Scaffold a Pydantic v2 domain model and test stub. Alias 'gm'."
|
|
27
|
-
_GM_EPILOG = "Example:\n\n * scaffold gm --name
|
|
27
|
+
_GM_EPILOG = "Example:\n\n * scaffold gm --name order\n\n * scaffold generate-model --name order\n\n"
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def _generate_model_impl(name: str, dry_run: bool) -> None:
|
|
31
31
|
# --- Validate name ---
|
|
32
32
|
try:
|
|
33
|
-
|
|
33
|
+
validate_module_name(name)
|
|
34
34
|
except ScaffoldError as exc:
|
|
35
35
|
console.print(f"[red]Error:[/red] {exc}")
|
|
36
36
|
raise typer.Exit(code=1) from None
|
|
@@ -100,7 +100,7 @@ def register(app: typer.Typer) -> None:
|
|
|
100
100
|
def generate_model(
|
|
101
101
|
ctx: typer.Context,
|
|
102
102
|
name: Annotated[
|
|
103
|
-
str | None, typer.Option("--name", help="Model name (
|
|
103
|
+
str | None, typer.Option("--name", help="Model name (snake_case).", rich_help_panel="Required")
|
|
104
104
|
] = None,
|
|
105
105
|
dry_run: Annotated[
|
|
106
106
|
bool,
|
|
@@ -151,7 +151,7 @@ def register(app: typer.Typer) -> None:
|
|
|
151
151
|
str,
|
|
152
152
|
typer.Option(
|
|
153
153
|
"--name",
|
|
154
|
-
help="Project name (
|
|
154
|
+
help="Project name (kebab-case or snake_case).",
|
|
155
155
|
rich_help_panel="Required",
|
|
156
156
|
),
|
|
157
157
|
] = None, # type: ignore[assignment]
|
|
@@ -11,7 +11,7 @@ from rich.console import Console
|
|
|
11
11
|
from rich.tree import Tree
|
|
12
12
|
|
|
13
13
|
from scaffold_ca_python.core.module_builder import ModuleBuilder
|
|
14
|
-
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case,
|
|
14
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case, validate_module_name
|
|
15
15
|
from scaffold_ca_python.core.project_detector import find_project_root
|
|
16
16
|
from scaffold_ca_python.factory import ModuleFactory
|
|
17
17
|
from scaffold_ca_python.factory.simple.use_case_factory import UseCaseFactory
|
|
@@ -24,13 +24,15 @@ _REGISTRY: dict[str, type[ModuleFactory]] = {
|
|
|
24
24
|
"use_case": UseCaseFactory,
|
|
25
25
|
}
|
|
26
26
|
_GUC_HELP = "Scaffold a use case in domain/usecase/. Alias 'guc'."
|
|
27
|
-
_GUC_EPILOG =
|
|
27
|
+
_GUC_EPILOG = (
|
|
28
|
+
"Example:\n\n * scaffold guc --name create_order\n\n * scaffold generate-use-case --name create_order\n\n"
|
|
29
|
+
)
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
def _generate_use_case_impl(name: str, dry_run: bool) -> None:
|
|
31
33
|
# --- Validate name ---
|
|
32
34
|
try:
|
|
33
|
-
|
|
35
|
+
validate_module_name(name)
|
|
34
36
|
except ScaffoldError as exc:
|
|
35
37
|
console.print(f"[red]Error:[/red] {exc}")
|
|
36
38
|
raise typer.Exit(code=1) from None
|
|
@@ -102,7 +104,7 @@ def register(app: typer.Typer) -> None:
|
|
|
102
104
|
def generate_use_case(
|
|
103
105
|
ctx: typer.Context,
|
|
104
106
|
name: Annotated[
|
|
105
|
-
str | None, typer.Option("--name", help="Use case name (
|
|
107
|
+
str | None, typer.Option("--name", help="Use case name (snake_case).", rich_help_panel="Required")
|
|
106
108
|
] = None,
|
|
107
109
|
dry_run: Annotated[
|
|
108
110
|
bool,
|
|
@@ -7,7 +7,7 @@ import tempfile
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import TYPE_CHECKING
|
|
9
9
|
|
|
10
|
-
from scaffold_ca_python.models.file_operation import CreateFile, DeleteFile, FileOperation, InsertAfter
|
|
10
|
+
from scaffold_ca_python.models.file_operation import AppendToFile, CreateFile, DeleteFile, FileOperation, InsertAfter
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
13
|
pass
|
|
@@ -60,7 +60,7 @@ class FileWriter:
|
|
|
60
60
|
for op in operations:
|
|
61
61
|
if isinstance(op, CreateFile):
|
|
62
62
|
result.append(op.file.path)
|
|
63
|
-
elif isinstance(op, (DeleteFile, InsertAfter)):
|
|
63
|
+
elif isinstance(op, (DeleteFile, InsertAfter, AppendToFile)):
|
|
64
64
|
result.append(op.path)
|
|
65
65
|
return result
|
|
66
66
|
|
|
@@ -110,6 +110,11 @@ class FileWriter:
|
|
|
110
110
|
for op in inserts:
|
|
111
111
|
self._apply_insert_after(op)
|
|
112
112
|
|
|
113
|
+
# Process append_to_file mutations in declaration order
|
|
114
|
+
appends: list[AppendToFile] = [op for op in operations if isinstance(op, AppendToFile)]
|
|
115
|
+
for op in appends:
|
|
116
|
+
self._apply_append_to_file(op)
|
|
117
|
+
|
|
113
118
|
return committed
|
|
114
119
|
|
|
115
120
|
@staticmethod
|
|
@@ -126,3 +131,23 @@ class FileWriter:
|
|
|
126
131
|
op.path.write_text("".join(lines), encoding="utf-8")
|
|
127
132
|
return
|
|
128
133
|
raise ValueError(f"insert_after anchor {op.anchor!r} not found in {op.path}")
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _apply_append_to_file(op: AppendToFile) -> None:
|
|
137
|
+
"""Append *op.content* to the end of *op.path*, or create the file.
|
|
138
|
+
|
|
139
|
+
When the file exists, trailing blank lines are normalized to a single newline
|
|
140
|
+
before appending. When it doesn't exist, it's created with *op.content* as
|
|
141
|
+
initial contents (leading blank lines stripped).
|
|
142
|
+
"""
|
|
143
|
+
if op.path.exists():
|
|
144
|
+
# File exists - normalize trailing newlines and append
|
|
145
|
+
original = op.path.read_text(encoding="utf-8")
|
|
146
|
+
# Strip all trailing newlines, then ensure exactly one
|
|
147
|
+
normalized = original.rstrip("\n") + "\n"
|
|
148
|
+
op.path.write_text(normalized + op.content, encoding="utf-8")
|
|
149
|
+
else:
|
|
150
|
+
# File doesn't exist - create it with content (strip leading blank lines)
|
|
151
|
+
op.path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
content = op.content.lstrip("\n")
|
|
153
|
+
op.path.write_text(content, encoding="utf-8")
|