scaffold-ca-python 0.1.4__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/generate_entry_point.py +291 -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 +18 -1
- scaffold_ca_python/models/file_operation.py +19 -1
- 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/project/pyproject_toml.jinja2 +1 -1
- {scaffold_ca_python-0.1.4.dist-info → scaffold_ca_python-0.2.0.dist-info}/METADATA +5 -1
- {scaffold_ca_python-0.1.4.dist-info → scaffold_ca_python-0.2.0.dist-info}/RECORD +13 -10
- {scaffold_ca_python-0.1.4.dist-info → scaffold_ca_python-0.2.0.dist-info}/WHEEL +0 -0
- {scaffold_ca_python-0.1.4.dist-info → scaffold_ca_python-0.2.0.dist-info}/entry_points.txt +0 -0
- {scaffold_ca_python-0.1.4.dist-info → scaffold_ca_python-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
@@ -11,11 +12,13 @@ import typer
|
|
|
11
12
|
import yaml
|
|
12
13
|
from rich.console import Console
|
|
13
14
|
from rich.markup import escape
|
|
15
|
+
from rich.syntax import Syntax
|
|
14
16
|
from rich.tree import Tree
|
|
15
17
|
|
|
18
|
+
from scaffold_ca_python.core import mcp_tool_spec
|
|
16
19
|
from scaffold_ca_python.core.module_builder import ModuleBuilder
|
|
17
20
|
from scaffold_ca_python.core.name_utils import ScaffoldError, to_snake_case
|
|
18
|
-
from scaffold_ca_python.core.project_detector import find_project_root
|
|
21
|
+
from scaffold_ca_python.core.project_detector import find_project_root, resolve_tests_root
|
|
19
22
|
from scaffold_ca_python.factory import ModuleFactory
|
|
20
23
|
from scaffold_ca_python.factory.entry_points.ep_agent import EntryPointAgent
|
|
21
24
|
from scaffold_ca_python.factory.entry_points.ep_generic import EntryPointGeneric
|
|
@@ -45,12 +48,11 @@ _GEP_EPILOG = (
|
|
|
45
48
|
" * mcp MCP server entry point\n\n"
|
|
46
49
|
" * generic Plain entry point\n\n"
|
|
47
50
|
" ==================================================\n\n"
|
|
48
|
-
"Notes: --enable-kafka and --enable-mcp-client are agent type only.\n\n"
|
|
49
|
-
" ==================================================\n\n"
|
|
50
51
|
"Examples:\n\n"
|
|
51
52
|
" * scaffold gep --type restapi\n\n"
|
|
52
53
|
" * scaffold gep --type agent --enable-kafka\n\n"
|
|
53
54
|
" * scaffold gep --type mcp\n\n"
|
|
55
|
+
" * scaffold gep --type mcp --add-tool --name create_file --param path:str --returns str\n\n"
|
|
54
56
|
" * scaffold generate-entry-point --type generic\n"
|
|
55
57
|
)
|
|
56
58
|
|
|
@@ -64,6 +66,235 @@ def _load_project_context(project_root: Path) -> ProjectContext:
|
|
|
64
66
|
return ProjectContext(name=project_name)
|
|
65
67
|
|
|
66
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
|
+
|
|
67
298
|
def _generate_entry_point_impl(
|
|
68
299
|
type_: str,
|
|
69
300
|
swagger: str | None,
|
|
@@ -71,8 +302,30 @@ def _generate_entry_point_impl(
|
|
|
71
302
|
enable_mcp_client: bool,
|
|
72
303
|
with_resources: bool,
|
|
73
304
|
with_prompts: bool,
|
|
305
|
+
add_tool: bool,
|
|
306
|
+
name: str | None,
|
|
307
|
+
param: list[str] | None,
|
|
308
|
+
returns: str,
|
|
309
|
+
description: str | None,
|
|
74
310
|
dry_run: bool,
|
|
75
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
|
+
|
|
76
329
|
# --- Validate type ---
|
|
77
330
|
if type_ not in _REGISTRY:
|
|
78
331
|
console.print(f"[red]Error:[/red] Unknown type '{type_}'. Allowed: {', '.join(_REGISTRY.keys())}.")
|
|
@@ -287,6 +540,24 @@ def register(app: typer.Typer) -> None:
|
|
|
287
540
|
show_default=True,
|
|
288
541
|
),
|
|
289
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,
|
|
290
561
|
dry_run: Annotated[
|
|
291
562
|
bool,
|
|
292
563
|
typer.Option(
|
|
@@ -298,9 +569,25 @@ def register(app: typer.Typer) -> None:
|
|
|
298
569
|
] = False,
|
|
299
570
|
) -> None:
|
|
300
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
|
+
|
|
301
577
|
if type_ is None:
|
|
302
578
|
typer.echo(ctx.get_help())
|
|
303
579
|
raise typer.Exit(0)
|
|
304
580
|
_generate_entry_point_impl(
|
|
305
|
-
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,
|
|
306
593
|
)
|
|
@@ -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")
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""MCP tool specification parsing and validation (T012).
|
|
2
|
+
|
|
3
|
+
This module provides utilities for parsing and validating --add-tool CLI arguments,
|
|
4
|
+
including tool names, parameters, type expressions, and duplicate-name detection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import keyword
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError, validate_module_name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ParamSpec(BaseModel):
|
|
18
|
+
"""A single parsed --param name:type value."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
type: str
|
|
22
|
+
|
|
23
|
+
@field_validator("name")
|
|
24
|
+
@classmethod
|
|
25
|
+
def validate_name(cls, v: str) -> str:
|
|
26
|
+
"""Validate parameter name is snake_case and not a hard keyword."""
|
|
27
|
+
# Reuse validate_module_name for snake_case check
|
|
28
|
+
validate_module_name(v)
|
|
29
|
+
|
|
30
|
+
# Check for hard keywords
|
|
31
|
+
if keyword.iskeyword(v):
|
|
32
|
+
msg = f"'{v}' is a Python keyword and cannot be used as a parameter name."
|
|
33
|
+
raise ValueError(msg)
|
|
34
|
+
|
|
35
|
+
return v
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToolSpec(BaseModel):
|
|
39
|
+
"""Parsed, fully-validated representation of a single --add-tool invocation."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
params: list[ParamSpec]
|
|
43
|
+
returns: str
|
|
44
|
+
docstring: str
|
|
45
|
+
|
|
46
|
+
@field_validator("name")
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate_name(cls, v: str) -> str:
|
|
49
|
+
"""Validate tool name is snake_case and not a hard keyword."""
|
|
50
|
+
validate_module_name(v)
|
|
51
|
+
|
|
52
|
+
if keyword.iskeyword(v):
|
|
53
|
+
msg = f"'{v}' is a Python keyword and cannot be used as a tool name."
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
|
|
56
|
+
return v
|
|
57
|
+
|
|
58
|
+
@field_validator("returns")
|
|
59
|
+
@classmethod
|
|
60
|
+
def validate_returns_type(cls, v: str) -> str:
|
|
61
|
+
"""Validate returns is a valid type expression."""
|
|
62
|
+
if not validate_type_expression(v):
|
|
63
|
+
msg = f"Invalid --returns '{v}'. Not a recognizable type-expression."
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
return v
|
|
66
|
+
|
|
67
|
+
@model_validator(mode="after")
|
|
68
|
+
def check_no_duplicate_param_names(self) -> ToolSpec:
|
|
69
|
+
"""Reject duplicate parameter names."""
|
|
70
|
+
names = [p.name for p in self.params]
|
|
71
|
+
seen = set()
|
|
72
|
+
for name in names:
|
|
73
|
+
if name in seen:
|
|
74
|
+
msg = f"Duplicate --param name '{name}'. Each parameter name must be unique."
|
|
75
|
+
raise ValueError(msg)
|
|
76
|
+
seen.add(name)
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_param(param_str: str) -> tuple[str, str]:
|
|
81
|
+
"""Parse a --param 'name:type' string.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
param_str: String in format 'name:type'
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Tuple of (name, type)
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: If format is invalid, name is not snake_case, name is a hard
|
|
91
|
+
keyword, or type is not a recognizable type-expression
|
|
92
|
+
"""
|
|
93
|
+
if ":" not in param_str:
|
|
94
|
+
msg = f"Invalid --param '{param_str}'. Expected 'name:type' format."
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
|
|
97
|
+
# Split on first colon only
|
|
98
|
+
name, type_str = param_str.split(":", 1)
|
|
99
|
+
|
|
100
|
+
# Validate name (snake_case)
|
|
101
|
+
try:
|
|
102
|
+
validate_module_name(name)
|
|
103
|
+
except (ValueError, ScaffoldError) as e:
|
|
104
|
+
msg = f"Invalid --param '{param_str}'. Parameter name must be snake_case (e.g., 'path:str')."
|
|
105
|
+
raise ValueError(msg) from e
|
|
106
|
+
|
|
107
|
+
# Check for hard keywords
|
|
108
|
+
if keyword.iskeyword(name):
|
|
109
|
+
msg = f"Invalid --param '{param_str}'. '{name}' is a Python keyword and cannot be used as a parameter name."
|
|
110
|
+
raise ValueError(msg)
|
|
111
|
+
|
|
112
|
+
# Validate type expression
|
|
113
|
+
if not validate_type_expression(type_str):
|
|
114
|
+
msg = f"Invalid --param '{param_str}'. '{type_str}' is not a recognizable type-expression."
|
|
115
|
+
raise ValueError(msg)
|
|
116
|
+
|
|
117
|
+
return name, type_str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_type_expression(type_str: str) -> bool:
|
|
121
|
+
"""Check if type_str is a recognizable Python type expression.
|
|
122
|
+
|
|
123
|
+
Uses AST parsing to verify the type string is a valid type expression
|
|
124
|
+
(Name, Subscript, BinOp with |, Attribute, etc.) and not a function call,
|
|
125
|
+
string literal, arithmetic expression, lambda, or comprehension.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
type_str: Type expression string to validate
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if valid type expression, False otherwise
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
node = ast.parse(type_str, mode="eval").body
|
|
135
|
+
except SyntaxError:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
# Allowed: Name (str, int), Subscript (list[str]), BinOp (str | None),
|
|
139
|
+
# Attribute (typing.Optional), Constant (for Literal values inside [...])
|
|
140
|
+
# Not allowed: Call, Str/Constant at top level, Lambda, comprehensions, arithmetic
|
|
141
|
+
return _is_type_expression_node(node)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_type_expression_node(node: ast.expr) -> bool:
|
|
145
|
+
"""Recursively check if AST node represents a valid type expression."""
|
|
146
|
+
if isinstance(node, ast.Name):
|
|
147
|
+
return True
|
|
148
|
+
if isinstance(node, ast.Constant):
|
|
149
|
+
# Allow None and Ellipsis in type expressions
|
|
150
|
+
return node.value is None or node.value is ...
|
|
151
|
+
if isinstance(node, ast.Attribute):
|
|
152
|
+
return True
|
|
153
|
+
if isinstance(node, ast.Subscript):
|
|
154
|
+
# Allow Constant in subscript (for Literal["a", "b"])
|
|
155
|
+
return _is_type_expression_node(node.value) and _is_subscript_arg_valid(node.slice)
|
|
156
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
157
|
+
return _is_type_expression_node(node.left) and _is_type_expression_node(node.right)
|
|
158
|
+
if isinstance(node, ast.Tuple):
|
|
159
|
+
# For tuple[int, ...] syntax
|
|
160
|
+
return all(_is_type_expression_node(elt) if not isinstance(elt, ast.Constant) else True for elt in node.elts)
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_subscript_arg_valid(node: ast.expr) -> bool:
|
|
165
|
+
"""Check if subscript argument is valid (Name, Tuple, or Constant for Literal)."""
|
|
166
|
+
if isinstance(node, ast.Constant):
|
|
167
|
+
return True # Allow string literals inside Literal[...]
|
|
168
|
+
if isinstance(node, ast.Tuple):
|
|
169
|
+
return all(_is_subscript_arg_valid(elt) for elt in node.elts)
|
|
170
|
+
return _is_type_expression_node(node)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def find_existing_tool_names(tools_py_source: str) -> set[str]:
|
|
174
|
+
"""Find all existing tool names in tools.py.
|
|
175
|
+
|
|
176
|
+
Returns the union of:
|
|
177
|
+
- Every @<x>.tool("name") registered name
|
|
178
|
+
- Every function name defined directly inside bind_tools
|
|
179
|
+
- The reserved name "bind_tools"
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
tools_py_source: Source code of tools.py
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Set of existing tool names
|
|
186
|
+
"""
|
|
187
|
+
names: set[str] = {"bind_tools"} # Always reserved
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
module = ast.parse(tools_py_source)
|
|
191
|
+
except SyntaxError:
|
|
192
|
+
return names
|
|
193
|
+
|
|
194
|
+
# Find @<x>.tool("name") decorators
|
|
195
|
+
for node in ast.walk(module):
|
|
196
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
197
|
+
for decorator in node.decorator_list:
|
|
198
|
+
# Match @mcp.tool("name") or @x.tool("name")
|
|
199
|
+
if (
|
|
200
|
+
isinstance(decorator, ast.Call)
|
|
201
|
+
and isinstance(decorator.func, ast.Attribute)
|
|
202
|
+
and decorator.func.attr == "tool"
|
|
203
|
+
and decorator.args
|
|
204
|
+
and isinstance(decorator.args[0], ast.Constant)
|
|
205
|
+
and isinstance(decorator.args[0].value, str)
|
|
206
|
+
):
|
|
207
|
+
names.add(decorator.args[0].value)
|
|
208
|
+
|
|
209
|
+
# Find function names inside bind_tools
|
|
210
|
+
for node in ast.walk(module):
|
|
211
|
+
if isinstance(node, ast.AsyncFunctionDef) and node.name == "bind_tools":
|
|
212
|
+
for child in ast.walk(node):
|
|
213
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child != node:
|
|
214
|
+
names.add(child.name)
|
|
215
|
+
|
|
216
|
+
return names
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def has_bind_tools_function(tools_py_source: str) -> bool:
|
|
220
|
+
"""Check if tools.py has async def bind_tools as the last top-level statement.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
tools_py_source: Source code of tools.py
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if last top-level statement is async def bind_tools, False otherwise
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
module = ast.parse(tools_py_source)
|
|
230
|
+
except SyntaxError:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
if not module.body:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
last_stmt = module.body[-1]
|
|
237
|
+
return isinstance(last_stmt, ast.AsyncFunctionDef) and last_stmt.name == "bind_tools"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def has_test_registration_structure(test_tools_py_source: str) -> bool:
|
|
241
|
+
"""Check if test_tools.py can host an appended registration test.
|
|
242
|
+
|
|
243
|
+
Returns True only if it defines a top-level _make_mock_mcp function and
|
|
244
|
+
imports both bind_tools and asyncio.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
test_tools_py_source: Source code of test_tools.py
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
True if structure is compatible, False otherwise
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
module = ast.parse(test_tools_py_source)
|
|
254
|
+
except SyntaxError:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
# Check for _make_mock_mcp function
|
|
258
|
+
has_helper = any(isinstance(node, ast.FunctionDef) and node.name == "_make_mock_mcp" for node in module.body)
|
|
259
|
+
|
|
260
|
+
# Check for required imports
|
|
261
|
+
imported = set()
|
|
262
|
+
for node in module.body:
|
|
263
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
264
|
+
for alias in node.names:
|
|
265
|
+
imported.add(alias.asname or alias.name)
|
|
266
|
+
|
|
267
|
+
has_required_imports = {"bind_tools", "asyncio"} <= imported
|
|
268
|
+
|
|
269
|
+
return has_helper and has_required_imports
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def validate_description(description: str | None) -> str:
|
|
273
|
+
"""Validate description is safe for docstring embedding.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
description: User-provided description or None
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Validated description or generic placeholder
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ValueError: If description contains embedded triple-quote sequence
|
|
283
|
+
"""
|
|
284
|
+
if not description or not description.strip():
|
|
285
|
+
return "TODO: describe this tool."
|
|
286
|
+
|
|
287
|
+
if '"""' in description or "'''" in description:
|
|
288
|
+
msg = "--description contains an embedded triple-quote sequence and cannot be used as a docstring."
|
|
289
|
+
raise ValueError(msg)
|
|
290
|
+
|
|
291
|
+
return description
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_tool_spec(
|
|
295
|
+
name: str,
|
|
296
|
+
param: list[str] | None,
|
|
297
|
+
returns: str | None,
|
|
298
|
+
description: str | None,
|
|
299
|
+
) -> ToolSpec:
|
|
300
|
+
"""Build a validated ToolSpec from CLI arguments.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
name: Tool name (required)
|
|
304
|
+
param: List of 'name:type' strings or None
|
|
305
|
+
returns: Return type expression or None (defaults to "str")
|
|
306
|
+
description: Tool description or None (defaults to placeholder)
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Validated ToolSpec
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
ValueError: If any validation fails
|
|
313
|
+
"""
|
|
314
|
+
params = []
|
|
315
|
+
if param:
|
|
316
|
+
for p in param:
|
|
317
|
+
param_name, param_type = parse_param(p)
|
|
318
|
+
params.append(ParamSpec(name=param_name, type=param_type))
|
|
319
|
+
|
|
320
|
+
return ToolSpec(
|
|
321
|
+
name=name,
|
|
322
|
+
params=params,
|
|
323
|
+
returns=returns or "str",
|
|
324
|
+
docstring=validate_description(description),
|
|
325
|
+
)
|
|
@@ -17,7 +17,14 @@ from scaffold_ca_python.core.pyproject_writer import (
|
|
|
17
17
|
update_project_scripts,
|
|
18
18
|
)
|
|
19
19
|
from scaffold_ca_python.core.template_renderer import TemplateRenderer
|
|
20
|
-
from scaffold_ca_python.models.file_operation import
|
|
20
|
+
from scaffold_ca_python.models.file_operation import (
|
|
21
|
+
AppendToFile,
|
|
22
|
+
CreateFile,
|
|
23
|
+
DeleteFile,
|
|
24
|
+
FileOperation,
|
|
25
|
+
GeneratedFile,
|
|
26
|
+
InsertAfter,
|
|
27
|
+
)
|
|
21
28
|
|
|
22
29
|
if TYPE_CHECKING:
|
|
23
30
|
from scaffold_ca_python.models.context import ModuleContext, ProjectContext
|
|
@@ -88,6 +95,16 @@ class ModuleBuilder:
|
|
|
88
95
|
"""
|
|
89
96
|
self._operations.append(InsertAfter(path=path, anchor=anchor, content=content))
|
|
90
97
|
|
|
98
|
+
def append_to_file(self, path: Path, content: str) -> None:
|
|
99
|
+
"""Queue an append-to-file operation.
|
|
100
|
+
|
|
101
|
+
*content* will be appended to the end of *path*. If *path* does not exist,
|
|
102
|
+
it will be created. When the file exists, its trailing blank lines are
|
|
103
|
+
normalized to a single newline before appending. In dry-run mode the path
|
|
104
|
+
is included in the preview list without modifying the file.
|
|
105
|
+
"""
|
|
106
|
+
self._operations.append(AppendToFile(path=path, content=content))
|
|
107
|
+
|
|
91
108
|
def render(self, template_name: str, context: BaseModel | dict[str, Any]) -> str:
|
|
92
109
|
"""Render a template through the shared renderer."""
|
|
93
110
|
return self._renderer.render(template_name, context)
|
|
@@ -44,4 +44,22 @@ class InsertAfter(BaseModel):
|
|
|
44
44
|
content: str
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
class AppendToFile(BaseModel):
|
|
48
|
+
"""Append *content* to the end of an existing file, or create the file.
|
|
49
|
+
|
|
50
|
+
When *path* exists, its trailing blank lines are normalized to a single
|
|
51
|
+
newline before *content* is appended. When *path* does not exist, it is
|
|
52
|
+
created (including parent directories) with *content* as initial contents.
|
|
53
|
+
|
|
54
|
+
Raises
|
|
55
|
+
------
|
|
56
|
+
OSError
|
|
57
|
+
When file operations fail (permissions, disk space, etc.)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
kind: Literal["append_to_file"] = "append_to_file"
|
|
61
|
+
path: Path
|
|
62
|
+
content: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
FileOperation = Annotated[CreateFile | DeleteFile | InsertAfter | AppendToFile, "FileOperation"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
def test_bind_tools_registers_{{ name }}() -> None:
|
|
4
|
+
"""Calling bind_tools registers '{{ name }}' on the mcp instance."""
|
|
5
|
+
mock_mcp, registered = _make_mock_mcp()
|
|
6
|
+
fn = getattr(bind_tools, "__wrapped__", bind_tools)
|
|
7
|
+
asyncio.run(fn(mock_mcp))
|
|
8
|
+
assert "{{ name }}" in registered
|
|
@@ -64,7 +64,7 @@ exclude_also = [
|
|
|
64
64
|
]
|
|
65
65
|
|
|
66
66
|
[tool.pytest.ini_options]
|
|
67
|
-
addopts = "--cov={{ python_package }} --cov-fail-under=
|
|
67
|
+
addopts = "--cov={{ python_package }} --cov-fail-under=75 --import-mode=importlib"
|
|
68
68
|
testpaths = ["src/tests"]
|
|
69
69
|
asyncio_default_fixture_loop_scope = "function"
|
|
70
70
|
filterwarnings = [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scaffold-ca-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Scaffolder to generate Clean Architecture apps
|
|
5
5
|
Project-URL: Homepage, https://github.com/bancolombia/scaffold-clean-architecture-py
|
|
6
6
|
Project-URL: Repository, https://github.com/bancolombia/scaffold-clean-architecture-py
|
|
@@ -151,6 +151,10 @@ scaffold gep --type restapi
|
|
|
151
151
|
scaffold gep --type mcp --with-resources --with-prompts --dry-run
|
|
152
152
|
```
|
|
153
153
|
|
|
154
|
+
```bash
|
|
155
|
+
scaffold gep --type mcp --add-tool --name create_file --param path:str --param content:str --returns str --description "Creates a file on disk"
|
|
156
|
+
```
|
|
157
|
+
|
|
154
158
|
### scaffold gh
|
|
155
159
|
|
|
156
160
|
Scaffold a helper utility module and its test stub.
|
|
@@ -3,7 +3,7 @@ scaffold_ca_python/cli.py,sha256=A0h0pkoNPy3EE55lApnjRuevhWb_Aa-IB7jekaaiAcM,971
|
|
|
3
3
|
scaffold_ca_python/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
scaffold_ca_python/commands/delete_module.py,sha256=DZa4rTVhPpt5-HV6dCZY-qWOV18K9qns6gKCsgjDepg,7124
|
|
5
5
|
scaffold_ca_python/commands/generate_driven_adapter.py,sha256=ckHdHVUV5T3SSvLwPp_jQYdp-Av9j2AV836So6tMODk,6938
|
|
6
|
-
scaffold_ca_python/commands/generate_entry_point.py,sha256=
|
|
6
|
+
scaffold_ca_python/commands/generate_entry_point.py,sha256=iGO0sqq9B4XGRCgXLe0D3YfTzgeDBqZVXThSylPN05E,22665
|
|
7
7
|
scaffold_ca_python/commands/generate_helper.py,sha256=i5zaqGvTO6nEvVOGVdOsZoDpJf1cWX7GbGaNcEYLSWY,4470
|
|
8
8
|
scaffold_ca_python/commands/generate_model.py,sha256=I1dMNoVMNytdMZ5mzBAF1WzJDIHMacxwLOmhH29nTIc,4546
|
|
9
9
|
scaffold_ca_python/commands/generate_pipeline.py,sha256=EYPM9csDnrYB3bpnC4YSycqI3bAE8bYy1283OEEC5Ts,5537
|
|
@@ -12,8 +12,9 @@ scaffold_ca_python/commands/generate_use_case.py,sha256=w6BUdF3uXeo3dNsLN76oSYvQ
|
|
|
12
12
|
scaffold_ca_python/commands/update_project.py,sha256=cW9IuAF1yIFkAkzOJdioFv97wr7mRiid08KkqOlFmrM,2845
|
|
13
13
|
scaffold_ca_python/commands/validate_structure.py,sha256=JF6Cb3IQ9d6VcjaReSHHzoXGSmqyoyMWNFplqSCFCOQ,2846
|
|
14
14
|
scaffold_ca_python/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
-
scaffold_ca_python/core/file_writer.py,sha256=
|
|
16
|
-
scaffold_ca_python/core/
|
|
15
|
+
scaffold_ca_python/core/file_writer.py,sha256=ff_5_UEB6vjzYwr6iLiIF3BWbSwNlZmvlnJVmYsRV8Q,6296
|
|
16
|
+
scaffold_ca_python/core/mcp_tool_spec.py,sha256=1APkvaF-PX-wy5sMXtdU9Areha8h-mlQSXtWzPl1wBU,10609
|
|
17
|
+
scaffold_ca_python/core/module_builder.py,sha256=oRUSpmZA3Q_Xno6e3R3e26eoK3REnuLMYJq8kQVyqz8,6518
|
|
17
18
|
scaffold_ca_python/core/name_utils.py,sha256=OAMl4YrgvaZgulemcqYpUp8iHNvR8ieTrccNVc_zQB4,2765
|
|
18
19
|
scaffold_ca_python/core/project_detector.py,sha256=G-n9domRQp65uzUbb7CXJk1nFMCTb347RMvfiVoYAxI,3321
|
|
19
20
|
scaffold_ca_python/core/pyproject_writer.py,sha256=zpxgubt-XSZR5domgL4LG_9AWD9b_cNrDM8fuHmZRzE,7792
|
|
@@ -36,7 +37,7 @@ scaffold_ca_python/factory/simple/model_factory.py,sha256=HFIPO_B8UZco972rnoLYMw
|
|
|
36
37
|
scaffold_ca_python/factory/simple/use_case_factory.py,sha256=-m1oFPbwfEGwiFJTf1RHIvw1WG7tG0IBuVfdpUYCbgQ,1961
|
|
37
38
|
scaffold_ca_python/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
38
39
|
scaffold_ca_python/models/context.py,sha256=H-PuANo8Z8nSCcbnM9L6ATC6Ikuqt_SfYvI5Gsqo3F4,1916
|
|
39
|
-
scaffold_ca_python/models/file_operation.py,sha256=
|
|
40
|
+
scaffold_ca_python/models/file_operation.py,sha256=R5Tu3KBvUnSmH5QZXBQTPoQjvr4YdH1YOKsKS9ICH_Q,1698
|
|
40
41
|
scaffold_ca_python/models/layer.py,sha256=5e11596xVV22HZnRv4r4TvEv-U6UKD08wATAh_UXXQU,1082
|
|
41
42
|
scaffold_ca_python/models/violation.py,sha256=1FCNkfezEhmQ_ANn4KdAVAzLxYj8v4gwNuXH0FQPHmw,546
|
|
42
43
|
scaffold_ca_python/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -66,7 +67,9 @@ scaffold_ca_python/templates/entry_point/mcp/server.py.jinja2,sha256=gVzJm_ugY9E
|
|
|
66
67
|
scaffold_ca_python/templates/entry_point/mcp/test_app.py.jinja2,sha256=MjVDhAXTz35HcCOVTsaWA--qvD_Qyn2SNG99CF6wctk,967
|
|
67
68
|
scaffold_ca_python/templates/entry_point/mcp/test_prompts.py.jinja2,sha256=Y9C7z7jpjoTKANlFB6hNJUUw2abt5qF2C4Nm7MkvDvA,1566
|
|
68
69
|
scaffold_ca_python/templates/entry_point/mcp/test_resources.py.jinja2,sha256=2jHfPqyJ4TJZJupxe8T7gVz2ysaqZHcAog6186ie2CI,1763
|
|
70
|
+
scaffold_ca_python/templates/entry_point/mcp/test_tool_stub.py.jinja2,sha256=Tq4uDcZc77bCC9gCVidmM5HmtjPfACaHBoQW0qEhIWo,295
|
|
69
71
|
scaffold_ca_python/templates/entry_point/mcp/test_tools.py.jinja2,sha256=bd_8nvLuqPApKIYQoBhEokMYZrPYFUn1pnDEIOVxwCo,1550
|
|
72
|
+
scaffold_ca_python/templates/entry_point/mcp/tool_stub.py.jinja2,sha256=VaGWAUF-BFQ7EeOHXUHQ2H-Mzr1UfbfziDsS7EVWUCc,289
|
|
70
73
|
scaffold_ca_python/templates/entry_point/mcp/tools.py.jinja2,sha256=Eqxa-pc_Mf4L0QvWMefKeg43bnjxwYuaNXRg6I9H1pQ,753
|
|
71
74
|
scaffold_ca_python/templates/entry_point/restapi/__init__.py.jinja2,sha256=BrpLExG7nc6cEOYWpJKymUXEvGVc1lU9sb_kBw_HPYg,36
|
|
72
75
|
scaffold_ca_python/templates/entry_point/restapi/app.py.jinja2,sha256=HQQqi8ZiLTaNIt9g9_iJyjxAGt6KXj4lqGcJLI1Qx9s,2626
|
|
@@ -92,7 +95,7 @@ scaffold_ca_python/templates/project/gitignore.jinja2,sha256=ZRf2cRtqbe2d48RfWJW
|
|
|
92
95
|
scaffold_ca_python/templates/project/layer_init.jinja2,sha256=oIlTSrce4ozqEKUU_1TB4473ufiE0SiGG_PJmEkT4FA,25
|
|
93
96
|
scaffold_ca_python/templates/project/main.py.jinja2,sha256=di_ujhkFafXqJUymSdVjEF0tihQ_0xKbKSIyV91rPpk,161
|
|
94
97
|
scaffold_ca_python/templates/project/mypy_ini.jinja2,sha256=SJ_UiLRZ8wBJ7hqSo3QDeMSsempBfaGvqiR_ASyIxR0,93
|
|
95
|
-
scaffold_ca_python/templates/project/pyproject_toml.jinja2,sha256=
|
|
98
|
+
scaffold_ca_python/templates/project/pyproject_toml.jinja2,sha256=7c2fpOc7lHxuItmW1NXdoCkYHp_lnswgx6NtHstk1Vw,1574
|
|
96
99
|
scaffold_ca_python/templates/project/python_version.jinja2,sha256=Auc1s9_hwygz61ULf_j_oX9fK8P6HnuuYaj1o4g845g,5
|
|
97
100
|
scaffold_ca_python/templates/project/application/config/__init__.py.jinja2,sha256=L9xWIW2Gd3VhRdx1RfUf7efBrIuFf3ft54eLQT1tG9g,32
|
|
98
101
|
scaffold_ca_python/templates/project/application/config/config.py.jinja2,sha256=218sZQ1115odJcoI7W6-1kiFjkWjO56d80fqQXk4dyM,323
|
|
@@ -102,8 +105,8 @@ scaffold_ca_python/templates/project/application/config/resource_container.py.ji
|
|
|
102
105
|
scaffold_ca_python/templates/project/application/config/usecases_container.py.jinja2,sha256=3fEHe6mkXkDHlfO5nhLqzLB7ccTpqwtjHZfLyfysbro,527
|
|
103
106
|
scaffold_ca_python/templates/use_case/test_use_case.py.jinja2,sha256=3K1bNKDBQc29rcv8UHUZnROCUmcFSCwJhbm0xP9Uako,364
|
|
104
107
|
scaffold_ca_python/templates/use_case/use_case.py.jinja2,sha256=KPtaK1wvZ-7qHdDZyuVfqXTPYwJdNUMPXQZGuKZFA7Y,217
|
|
105
|
-
scaffold_ca_python-0.
|
|
106
|
-
scaffold_ca_python-0.
|
|
107
|
-
scaffold_ca_python-0.
|
|
108
|
-
scaffold_ca_python-0.
|
|
109
|
-
scaffold_ca_python-0.
|
|
108
|
+
scaffold_ca_python-0.2.0.dist-info/METADATA,sha256=PKj8CVbANvsRzv3Ly7SelKv4krAlFbBRkXnv2NmTOnM,6960
|
|
109
|
+
scaffold_ca_python-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
110
|
+
scaffold_ca_python-0.2.0.dist-info/entry_points.txt,sha256=4PyYg6mNCvAlt2yF_o1-QR2CBXiowYGpNyqcP88tu6I,104
|
|
111
|
+
scaffold_ca_python-0.2.0.dist-info/licenses/LICENSE,sha256=3ZZEP7DK_h3lEjS8r-DvQCec2q7zAqqq7HmSq6cuL0E,1068
|
|
112
|
+
scaffold_ca_python-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|