scaffold-ca-python 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scaffold_ca_python/__init__.py +1 -0
- scaffold_ca_python/cli.py +39 -0
- scaffold_ca_python/commands/__init__.py +0 -0
- scaffold_ca_python/commands/delete_module.py +216 -0
- scaffold_ca_python/commands/generate_driven_adapter.py +182 -0
- scaffold_ca_python/commands/generate_entry_point.py +304 -0
- scaffold_ca_python/commands/generate_helper.py +135 -0
- scaffold_ca_python/commands/generate_model.py +134 -0
- scaffold_ca_python/commands/generate_pipeline.py +158 -0
- scaffold_ca_python/commands/generate_project.py +189 -0
- scaffold_ca_python/commands/generate_use_case.py +136 -0
- scaffold_ca_python/commands/update_project.py +84 -0
- scaffold_ca_python/commands/validate_structure.py +90 -0
- scaffold_ca_python/core/__init__.py +0 -0
- scaffold_ca_python/core/file_writer.py +128 -0
- scaffold_ca_python/core/module_builder.py +127 -0
- scaffold_ca_python/core/name_utils.py +59 -0
- scaffold_ca_python/core/project_detector.py +93 -0
- scaffold_ca_python/core/pyproject_writer.py +169 -0
- scaffold_ca_python/core/structure_validator.py +142 -0
- scaffold_ca_python/core/template_renderer.py +100 -0
- scaffold_ca_python/factory/__init__.py +16 -0
- scaffold_ca_python/factory/driven_adapters/__init__.py +0 -0
- scaffold_ca_python/factory/driven_adapters/da_generic.py +65 -0
- scaffold_ca_python/factory/driven_adapters/da_rest_consumer.py +64 -0
- scaffold_ca_python/factory/driven_adapters/da_secrets.py +64 -0
- scaffold_ca_python/factory/entry_points/__init__.py +0 -0
- scaffold_ca_python/factory/entry_points/ep_agent.py +91 -0
- scaffold_ca_python/factory/entry_points/ep_generic.py +75 -0
- scaffold_ca_python/factory/entry_points/ep_mcp.py +138 -0
- scaffold_ca_python/factory/entry_points/ep_restapi.py +133 -0
- scaffold_ca_python/factory/simple/__init__.py +0 -0
- scaffold_ca_python/factory/simple/delete_module_factory.py +85 -0
- scaffold_ca_python/factory/simple/helper_factory.py +67 -0
- scaffold_ca_python/factory/simple/model_factory.py +57 -0
- scaffold_ca_python/factory/simple/use_case_factory.py +59 -0
- scaffold_ca_python/models/__init__.py +0 -0
- scaffold_ca_python/models/context.py +60 -0
- scaffold_ca_python/models/file_operation.py +47 -0
- scaffold_ca_python/models/layer.py +41 -0
- scaffold_ca_python/models/violation.py +26 -0
- scaffold_ca_python/templates/__init__.py +0 -0
- scaffold_ca_python/templates/driven_adapter/generic/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/driven_adapter/generic/adapter.py.jinja2 +18 -0
- scaffold_ca_python/templates/driven_adapter/generic/test_adapter.py.jinja2 +22 -0
- scaffold_ca_python/templates/driven_adapter/rest_consumer/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/driven_adapter/rest_consumer/rest_consumer.py.jinja2 +27 -0
- scaffold_ca_python/templates/driven_adapter/rest_consumer/test_rest_consumer.py.jinja2 +24 -0
- scaffold_ca_python/templates/driven_adapter/secrets/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/driven_adapter/secrets/secrets_adapter.py.jinja2 +37 -0
- scaffold_ca_python/templates/driven_adapter/secrets/test_secrets_adapter.py.jinja2 +26 -0
- scaffold_ca_python/templates/entry_point/agent/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/agent/agent.py.jinja2 +49 -0
- scaffold_ca_python/templates/entry_point/agent/card.py.jinja2 +15 -0
- scaffold_ca_python/templates/entry_point/agent/entrypoint_main.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/agent/test_agent.py.jinja2 +20 -0
- scaffold_ca_python/templates/entry_point/generic/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/generic/entrypoint_main.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/generic/handler.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/generic/test_handler.py.jinja2 +35 -0
- scaffold_ca_python/templates/entry_point/mcp/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/mcp/app.py.jinja2 +51 -0
- scaffold_ca_python/templates/entry_point/mcp/prompts.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/mcp/resources.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/mcp/server.py.jinja2 +27 -0
- scaffold_ca_python/templates/entry_point/mcp/test_app.py.jinja2 +32 -0
- scaffold_ca_python/templates/entry_point/mcp/test_prompts.py.jinja2 +40 -0
- scaffold_ca_python/templates/entry_point/mcp/test_resources.py.jinja2 +47 -0
- scaffold_ca_python/templates/entry_point/mcp/test_tools.py.jinja2 +40 -0
- scaffold_ca_python/templates/entry_point/mcp/tools.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/restapi/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/entry_point/restapi/app.py.jinja2 +78 -0
- scaffold_ca_python/templates/entry_point/restapi/exception_handler.py.jinja2 +35 -0
- scaffold_ca_python/templates/entry_point/restapi/health.py.jinja2 +13 -0
- scaffold_ca_python/templates/entry_point/restapi/rest_controller.py.jinja2 +26 -0
- scaffold_ca_python/templates/entry_point/restapi/server.py.jinja2 +5 -0
- scaffold_ca_python/templates/entry_point/restapi/test_app.py.jinja2 +22 -0
- scaffold_ca_python/templates/entry_point/restapi/test_exception_handler.py.jinja2 +44 -0
- scaffold_ca_python/templates/entry_point/restapi/test_rest_controller.py.jinja2 +35 -0
- scaffold_ca_python/templates/entry_point/restapi/test_server.py.jinja2 +15 -0
- scaffold_ca_python/templates/helper/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/helper/helper.py.jinja2 +7 -0
- scaffold_ca_python/templates/helper/test_helper.py.jinja2 +8 -0
- scaffold_ca_python/templates/model/model.py.jinja2 +9 -0
- scaffold_ca_python/templates/model/test_model.py.jinja2 +8 -0
- scaffold_ca_python/templates/pipeline/azure/azure_pipelines.yml.jinja2 +28 -0
- scaffold_ca_python/templates/pipeline/github/ci.yml.jinja2 +34 -0
- scaffold_ca_python/templates/project/README.jinja2 +30 -0
- scaffold_ca_python/templates/project/application/config/__init__.py.jinja2 +1 -0
- scaffold_ca_python/templates/project/application/config/config.py.jinja2 +12 -0
- scaffold_ca_python/templates/project/application/config/container.py.jinja2 +17 -0
- scaffold_ca_python/templates/project/application/config/driven_adapters_container.py.jinja2 +14 -0
- scaffold_ca_python/templates/project/application/config/resource_container.py.jinja2 +17 -0
- scaffold_ca_python/templates/project/application/config/usecases_container.py.jinja2 +16 -0
- scaffold_ca_python/templates/project/dockerfile.jinja2 +22 -0
- scaffold_ca_python/templates/project/dockerignore.jinja2 +19 -0
- scaffold_ca_python/templates/project/gitignore.jinja2 +64 -0
- scaffold_ca_python/templates/project/layer_init.jinja2 +1 -0
- scaffold_ca_python/templates/project/main.py.jinja2 +10 -0
- scaffold_ca_python/templates/project/mypy_ini.jinja2 +5 -0
- scaffold_ca_python/templates/project/pyproject_toml.jinja2 +66 -0
- scaffold_ca_python/templates/project/python_version.jinja2 +1 -0
- scaffold_ca_python/templates/use_case/test_use_case.py.jinja2 +12 -0
- scaffold_ca_python/templates/use_case/use_case.py.jinja2 +9 -0
- scaffold_ca_python-0.1.1.dist-info/METADATA +285 -0
- scaffold_ca_python-0.1.1.dist-info/RECORD +109 -0
- scaffold_ca_python-0.1.1.dist-info/WHEEL +4 -0
- scaffold_ca_python-0.1.1.dist-info/entry_points.txt +3 -0
- scaffold_ca_python-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""file_writer: atomic file creation and deletion using tempdir staging (T023)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from scaffold_ca_python.models.file_operation import CreateFile, DeleteFile, FileOperation, InsertAfter
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileWriter:
|
|
17
|
+
"""Execute a list of :class:`FileOperation` objects.
|
|
18
|
+
|
|
19
|
+
Real mode: Uses a temporary directory for staging, then ``os.replace()``
|
|
20
|
+
(atomic on POSIX) to commit every file. If staging fails for
|
|
21
|
+
*any* file the temp directory is discarded and no partial writes
|
|
22
|
+
reach the target tree.
|
|
23
|
+
|
|
24
|
+
Dry-run mode: Returns a list of target :class:`Path` objects that *would*
|
|
25
|
+
be created or deleted, without touching disk.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def execute(
|
|
29
|
+
self,
|
|
30
|
+
operations: list[FileOperation],
|
|
31
|
+
*,
|
|
32
|
+
dry_run: bool = False,
|
|
33
|
+
) -> list[Path]:
|
|
34
|
+
"""Execute *operations*.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
operations:
|
|
39
|
+
Sequence of :class:`CreateFile` or :class:`DeleteFile` operations.
|
|
40
|
+
dry_run:
|
|
41
|
+
When ``True`` no disk writes occur; the method returns the list of
|
|
42
|
+
paths that *would* be affected.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
list[Path]
|
|
47
|
+
In dry-run mode: the paths that would be written/deleted.
|
|
48
|
+
In real mode: the paths that were successfully written.
|
|
49
|
+
"""
|
|
50
|
+
if dry_run:
|
|
51
|
+
return self._dry_run(operations)
|
|
52
|
+
return self._commit(operations)
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Private helpers
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _dry_run(self, operations: list[FileOperation]) -> list[Path]:
|
|
59
|
+
result: list[Path] = []
|
|
60
|
+
for op in operations:
|
|
61
|
+
if isinstance(op, CreateFile):
|
|
62
|
+
result.append(op.file.path)
|
|
63
|
+
elif isinstance(op, (DeleteFile, InsertAfter)):
|
|
64
|
+
result.append(op.path)
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
def _commit(self, operations: list[FileOperation]) -> list[Path]:
|
|
68
|
+
"""Stage all creates in a temp dir, then commit atomically."""
|
|
69
|
+
creates: list[CreateFile] = []
|
|
70
|
+
deletes: list[DeleteFile] = []
|
|
71
|
+
for op in operations:
|
|
72
|
+
if isinstance(op, CreateFile):
|
|
73
|
+
creates.append(op)
|
|
74
|
+
elif isinstance(op, DeleteFile):
|
|
75
|
+
deletes.append(op)
|
|
76
|
+
|
|
77
|
+
committed: list[Path] = []
|
|
78
|
+
|
|
79
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
80
|
+
# Stage all created files — any error here rolls back automatically
|
|
81
|
+
# because the TemporaryDirectory is discarded on __exit__.
|
|
82
|
+
staged: list[tuple[Path, Path]] = []
|
|
83
|
+
for i, op in enumerate(creates):
|
|
84
|
+
tmp_file = Path(tmpdir) / f"staged_{i}"
|
|
85
|
+
tmp_file.write_text(op.file.content, encoding="utf-8")
|
|
86
|
+
staged.append((tmp_file, op.file.path))
|
|
87
|
+
|
|
88
|
+
# Pre-flight: create ALL parent directories before any os.replace().
|
|
89
|
+
# If any mkdir fails, the exception propagates here; the TemporaryDirectory
|
|
90
|
+
# is cleaned up on __exit__ and no target files are committed yet.
|
|
91
|
+
for op, (_, target) in zip(creates, staged):
|
|
92
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
if target.exists() and not op.file.overwrite:
|
|
94
|
+
raise FileExistsError(
|
|
95
|
+
f"File already exists: {target}. Use overwrite=True on GeneratedFile to replace it."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# All parents exist — commit via os.replace (atomic on POSIX)
|
|
99
|
+
for tmp_file, target in staged:
|
|
100
|
+
os.replace(tmp_file, target)
|
|
101
|
+
committed.append(target)
|
|
102
|
+
|
|
103
|
+
# Process deletes after all creates are committed
|
|
104
|
+
for op in deletes:
|
|
105
|
+
if op.path.exists():
|
|
106
|
+
op.path.unlink()
|
|
107
|
+
|
|
108
|
+
# Process insert_after mutations in declaration order
|
|
109
|
+
inserts: list[InsertAfter] = [op for op in operations if isinstance(op, InsertAfter)]
|
|
110
|
+
for op in inserts:
|
|
111
|
+
self._apply_insert_after(op)
|
|
112
|
+
|
|
113
|
+
return committed
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _apply_insert_after(op: InsertAfter) -> None:
|
|
117
|
+
"""Insert *op.content* on a new line after the first line containing *op.anchor*."""
|
|
118
|
+
if not op.path.exists():
|
|
119
|
+
raise FileNotFoundError(f"insert_after target not found: {op.path}")
|
|
120
|
+
original = op.path.read_text(encoding="utf-8")
|
|
121
|
+
lines = original.splitlines(keepends=True)
|
|
122
|
+
for i, line in enumerate(lines):
|
|
123
|
+
if op.anchor in line:
|
|
124
|
+
insertion = op.content if op.content.endswith("\n") else op.content + "\n"
|
|
125
|
+
lines.insert(i + 1, insertion)
|
|
126
|
+
op.path.write_text("".join(lines), encoding="utf-8")
|
|
127
|
+
return
|
|
128
|
+
raise ValueError(f"insert_after anchor {op.anchor!r} not found in {op.path}")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""ModuleBuilder orchestration layer for factory-based module generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from scaffold_ca_python.core.file_writer import FileWriter
|
|
11
|
+
from scaffold_ca_python.core.pyproject_writer import (
|
|
12
|
+
dry_run_inject,
|
|
13
|
+
dry_run_scripts_update,
|
|
14
|
+
inject_dependencies,
|
|
15
|
+
update_project_scripts,
|
|
16
|
+
)
|
|
17
|
+
from scaffold_ca_python.core.template_renderer import TemplateRenderer
|
|
18
|
+
from scaffold_ca_python.models.file_operation import CreateFile, DeleteFile, FileOperation, GeneratedFile, InsertAfter
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from scaffold_ca_python.models.context import ModuleContext, ProjectContext
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ModuleBuilder:
|
|
25
|
+
"""Collect and persist module generation operations.
|
|
26
|
+
|
|
27
|
+
Factory implementations should only interact with this class, not with lower-level
|
|
28
|
+
infrastructure helpers such as FileWriter/TemplateRenderer/pyproject_writer.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
project_root: Path,
|
|
35
|
+
project_ctx: ProjectContext,
|
|
36
|
+
module_ctx: ModuleContext | None = None,
|
|
37
|
+
dry_run: bool = False,
|
|
38
|
+
file_writer: FileWriter | None = None,
|
|
39
|
+
template_renderer: TemplateRenderer | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.project_root = project_root
|
|
42
|
+
self.project_ctx = project_ctx
|
|
43
|
+
self.module_ctx = module_ctx
|
|
44
|
+
self.dry_run = dry_run
|
|
45
|
+
|
|
46
|
+
self._file_writer = file_writer or FileWriter()
|
|
47
|
+
self._renderer = template_renderer or TemplateRenderer()
|
|
48
|
+
|
|
49
|
+
self._operations: list[FileOperation] = []
|
|
50
|
+
self._dependencies: list[str] = []
|
|
51
|
+
self._params: dict[str, object] = {}
|
|
52
|
+
|
|
53
|
+
def add_file(
|
|
54
|
+
self,
|
|
55
|
+
path: Path,
|
|
56
|
+
content: str,
|
|
57
|
+
*,
|
|
58
|
+
template_name: str,
|
|
59
|
+
is_test: bool = False,
|
|
60
|
+
overwrite: bool = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Queue a file creation operation."""
|
|
63
|
+
self._operations.append(
|
|
64
|
+
CreateFile(
|
|
65
|
+
file=GeneratedFile(
|
|
66
|
+
path=path,
|
|
67
|
+
content=content,
|
|
68
|
+
template_name=template_name,
|
|
69
|
+
is_test=is_test,
|
|
70
|
+
overwrite=overwrite,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def delete_file(self, path: Path) -> None:
|
|
76
|
+
"""Queue a file deletion operation."""
|
|
77
|
+
self._operations.append(DeleteFile(path=path))
|
|
78
|
+
|
|
79
|
+
def insert_after(self, path: Path, anchor: str, content: str) -> None:
|
|
80
|
+
"""Queue an insert-after operation on an existing file.
|
|
81
|
+
|
|
82
|
+
*content* will be inserted on a new line immediately after the first
|
|
83
|
+
line in *path* that contains *anchor*. In dry-run mode the path is
|
|
84
|
+
included in the preview list without modifying the file.
|
|
85
|
+
"""
|
|
86
|
+
self._operations.append(InsertAfter(path=path, anchor=anchor, content=content))
|
|
87
|
+
|
|
88
|
+
def render(self, template_name: str, context: BaseModel | dict[str, Any]) -> str:
|
|
89
|
+
"""Render a template through the shared renderer."""
|
|
90
|
+
return self._renderer.render(template_name, context)
|
|
91
|
+
|
|
92
|
+
def add_dependency(self, package: str) -> None:
|
|
93
|
+
"""Queue a package dependency to inject into pyproject.toml."""
|
|
94
|
+
self._dependencies.append(package)
|
|
95
|
+
|
|
96
|
+
def add_param(self, key: str, value: object) -> None:
|
|
97
|
+
"""Store an ad-hoc value used by factories/commands."""
|
|
98
|
+
self._params[key] = value
|
|
99
|
+
|
|
100
|
+
def get_param(self, key: str, default: object | None = None) -> object | None:
|
|
101
|
+
"""Read a previously stored parameter value."""
|
|
102
|
+
return self._params.get(key, default)
|
|
103
|
+
|
|
104
|
+
def persist(self) -> list[Path]:
|
|
105
|
+
"""Persist queued file and dependency operations.
|
|
106
|
+
|
|
107
|
+
In dry-run mode no writes occur; the method returns the paths that would be
|
|
108
|
+
affected by file operations.
|
|
109
|
+
"""
|
|
110
|
+
paths = self._file_writer.execute(self._operations, dry_run=self.dry_run)
|
|
111
|
+
|
|
112
|
+
if self._dependencies:
|
|
113
|
+
if self.dry_run:
|
|
114
|
+
dry_run_inject(self.project_root, self._dependencies)
|
|
115
|
+
else:
|
|
116
|
+
inject_dependencies(self.project_root, self._dependencies)
|
|
117
|
+
|
|
118
|
+
# Optional script update hook used by entry-point factory implementations.
|
|
119
|
+
if bool(self._params.get("scripts_entry", False)):
|
|
120
|
+
pkg = self.project_ctx
|
|
121
|
+
entry_fn: str = str(self._params.get("scripts_entry_fn", "start_server"))
|
|
122
|
+
if self.dry_run:
|
|
123
|
+
dry_run_scripts_update(self.project_root, pkg, entry_fn)
|
|
124
|
+
else:
|
|
125
|
+
update_project_scripts(self.project_root, pkg, entry_fn)
|
|
126
|
+
|
|
127
|
+
return paths
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""name_utils: name validation, snake_case, and PascalCase conversion (T021)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
NAME_RE = re.compile(r"^[a-z][a-z0-9]*([_-][a-z0-9]+)*$")
|
|
8
|
+
|
|
9
|
+
# Matches transitions: lowercase→uppercase and letter→digit boundary runs
|
|
10
|
+
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScaffoldError(Exception):
|
|
14
|
+
"""Raised for user-facing errors (exit code 1)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Public helpers
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_name(name: str) -> str:
|
|
23
|
+
"""Validate *name* against the allowed identifier pattern.
|
|
24
|
+
|
|
25
|
+
Raises :class:`ScaffoldError` with a hint if the name is invalid.
|
|
26
|
+
Returns the original name unchanged when valid.
|
|
27
|
+
"""
|
|
28
|
+
if not NAME_RE.match(name):
|
|
29
|
+
raise ScaffoldError(
|
|
30
|
+
f"Invalid name {name!r}. Use kebab-case (e.g., 'my-project') or snake_case (e.g., 'my_project')."
|
|
31
|
+
)
|
|
32
|
+
return name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_snake_case(name: str) -> str:
|
|
36
|
+
"""Convert *name* (PascalCase, camelCase, kebab-case, or already snake_case) to snake_case."""
|
|
37
|
+
# Normalise hyphens to underscores first (kebab-case support)
|
|
38
|
+
name = name.replace("-", "_")
|
|
39
|
+
# Insert underscores at camelCase / PascalCase boundaries first
|
|
40
|
+
s = _CAMEL_BOUNDARY_RE.sub("_", name)
|
|
41
|
+
return s.lower()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_kebab_case(name: str) -> str:
|
|
45
|
+
"""Convert *name* (PascalCase, snake_case, or already kebab-case) to kebab-case."""
|
|
46
|
+
# Normalise underscores to hyphens first (snake_case support)
|
|
47
|
+
name = name.replace("_", "-")
|
|
48
|
+
# Insert hyphens at camelCase / PascalCase boundaries
|
|
49
|
+
s = _CAMEL_BOUNDARY_RE.sub("-", name)
|
|
50
|
+
return s.lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_pascal_case(name: str) -> str:
|
|
54
|
+
"""Convert *name* (snake_case, kebab-case, PascalCase, or single word) to PascalCase."""
|
|
55
|
+
# Normalise hyphens to underscores first (kebab-case support)
|
|
56
|
+
name = name.replace("-", "_")
|
|
57
|
+
# Use [0].upper() + [1:] instead of capitalize() to preserve inner case
|
|
58
|
+
# e.g. "MyOrder" → "MyOrder" (not "Myorder")
|
|
59
|
+
return "".join(part[0].upper() + part[1:] for part in name.split("_") if part)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""project_detector: locate the Clean Architecture project root (T022)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from scaffold_ca_python.core.name_utils import ScaffoldError
|
|
10
|
+
|
|
11
|
+
_CA_MARKERS: frozenset[str] = frozenset({"domain", "infrastructure", "application"})
|
|
12
|
+
_SCAFFOLD_SECTION = "scaffold-ca-python"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_project_root(cwd: Path | None = None) -> Path:
|
|
16
|
+
"""Walk upward from *cwd* to locate the project root.
|
|
17
|
+
|
|
18
|
+
Strategy (R-10):
|
|
19
|
+
1. Look for a ``pyproject.toml`` that contains ``[tool.scaffold-ca-python]``.
|
|
20
|
+
2. If not found, fall back to a directory whose children include the three
|
|
21
|
+
canonical CA layout directories (``domain/``, ``infrastructure/``,
|
|
22
|
+
``application/``).
|
|
23
|
+
|
|
24
|
+
Raises
|
|
25
|
+
------
|
|
26
|
+
ScaffoldError
|
|
27
|
+
When neither strategy finds a suitable root before reaching the
|
|
28
|
+
filesystem boundary.
|
|
29
|
+
"""
|
|
30
|
+
current = (cwd or Path.cwd()).resolve()
|
|
31
|
+
|
|
32
|
+
# --- Strategy 1: pyproject.toml with [tool.scaffold-ca-python] ----------
|
|
33
|
+
candidate: Path | None = None
|
|
34
|
+
for directory in _iter_parents(current):
|
|
35
|
+
pyproject = directory / "pyproject.toml"
|
|
36
|
+
if pyproject.is_file():
|
|
37
|
+
try:
|
|
38
|
+
with pyproject.open("rb") as fh:
|
|
39
|
+
data = tomllib.load(fh)
|
|
40
|
+
if _SCAFFOLD_SECTION in data.get("tool", {}):
|
|
41
|
+
candidate = directory
|
|
42
|
+
break
|
|
43
|
+
except Exception: # malformed TOML — skip silently
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
if candidate is not None:
|
|
47
|
+
return candidate
|
|
48
|
+
|
|
49
|
+
# --- Strategy 2: canonical CA directory layout --------------------------
|
|
50
|
+
for directory in _iter_parents(current):
|
|
51
|
+
children = {p.name for p in directory.iterdir() if p.is_dir()}
|
|
52
|
+
if _CA_MARKERS.issubset(children):
|
|
53
|
+
return directory
|
|
54
|
+
|
|
55
|
+
raise ScaffoldError(
|
|
56
|
+
"Could not find a scaffold-ca-python project root. "
|
|
57
|
+
"Ensure you are inside a project directory or run 'scaffold generate-project' first. "
|
|
58
|
+
"Hint: a valid project root has either [tool.scaffold-ca-python] in pyproject.toml "
|
|
59
|
+
"or the canonical CA directory layout (domain/, infrastructure/, application/)."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_tests_root(project_root: Path) -> Path:
|
|
64
|
+
"""Return the tests root for a project, probing for src-layout vs root-layout.
|
|
65
|
+
|
|
66
|
+
Resolution order:
|
|
67
|
+
1. ``project_root/src/tests/`` if it already exists (src-layout, post-feature).
|
|
68
|
+
2. ``project_root/tests/`` if it already exists (root-layout, pre-feature).
|
|
69
|
+
3. ``project_root/src/tests/`` as the default for new projects.
|
|
70
|
+
"""
|
|
71
|
+
src_tests = project_root / "src" / "tests"
|
|
72
|
+
if src_tests.exists():
|
|
73
|
+
return src_tests
|
|
74
|
+
root_tests = project_root / "tests"
|
|
75
|
+
if root_tests.exists():
|
|
76
|
+
return root_tests
|
|
77
|
+
return src_tests
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Internal helpers
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _iter_parents(start: Path) -> Iterator[Path]:
|
|
86
|
+
"""Yield *start* and each parent up to (and including) the filesystem root."""
|
|
87
|
+
current = start
|
|
88
|
+
while True:
|
|
89
|
+
yield current
|
|
90
|
+
parent = current.parent
|
|
91
|
+
if parent == current:
|
|
92
|
+
break
|
|
93
|
+
current = parent
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""pyproject_writer: inject dependencies into [project.dependencies] in pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import tomli_w
|
|
9
|
+
|
|
10
|
+
from scaffold_ca_python.models.context import ProjectContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _name_prefix(package: str) -> str:
|
|
14
|
+
"""Extract the bare package name, stripping version specifier and extras.
|
|
15
|
+
|
|
16
|
+
Examples::
|
|
17
|
+
|
|
18
|
+
"fastapi>=0.100" -> "fastapi"
|
|
19
|
+
"uvicorn[standard]>=0.20" -> "uvicorn"
|
|
20
|
+
"a2a-sdk>=0.1" -> "a2a-sdk"
|
|
21
|
+
"""
|
|
22
|
+
name = package.split(">=")[0].split("==")[0].split("<")[0].split("!=")[0]
|
|
23
|
+
name = name.split("[")[0]
|
|
24
|
+
return name.strip().lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _missing(existing: list[str], packages: list[str]) -> list[str]:
|
|
28
|
+
"""Return packages not already present in *existing* (by name prefix)."""
|
|
29
|
+
existing_names = {_name_prefix(d) for d in existing}
|
|
30
|
+
return [p for p in packages if _name_prefix(p) not in existing_names]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def inject_dependencies(project_root: Path, packages: list[str]) -> list[str]:
|
|
34
|
+
"""Add *packages* to ``[project.dependencies]`` in ``pyproject.toml``.
|
|
35
|
+
|
|
36
|
+
Idempotent: a package whose bare name is already present is skipped.
|
|
37
|
+
Does not reformat unrelated sections.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
project_root:
|
|
42
|
+
Directory containing ``pyproject.toml``.
|
|
43
|
+
packages:
|
|
44
|
+
Packages to inject, e.g. ``["fastapi>=0.100", "uvicorn[standard]>=0.20"]``.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
list[str]
|
|
49
|
+
The packages that were actually added (empty when all were already present).
|
|
50
|
+
|
|
51
|
+
Raises
|
|
52
|
+
------
|
|
53
|
+
FileNotFoundError
|
|
54
|
+
If ``pyproject.toml`` does not exist in *project_root*.
|
|
55
|
+
tomllib.TOMLDecodeError
|
|
56
|
+
If ``pyproject.toml`` is malformed.
|
|
57
|
+
"""
|
|
58
|
+
pyproject = project_root / "pyproject.toml"
|
|
59
|
+
with pyproject.open("rb") as fh:
|
|
60
|
+
data = tomllib.load(fh)
|
|
61
|
+
|
|
62
|
+
deps: list[str] = data.setdefault("project", {}).setdefault("dependencies", [])
|
|
63
|
+
to_add = _missing(deps, packages)
|
|
64
|
+
if not to_add:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
deps.extend(to_add)
|
|
68
|
+
|
|
69
|
+
with pyproject.open("wb") as fh:
|
|
70
|
+
tomli_w.dump(data, fh)
|
|
71
|
+
|
|
72
|
+
return to_add
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dry_run_inject(project_root: Path, packages: list[str]) -> list[str]:
|
|
76
|
+
"""Return packages that *would* be added without writing to disk.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
project_root:
|
|
81
|
+
Directory containing ``pyproject.toml``.
|
|
82
|
+
packages:
|
|
83
|
+
Packages to check.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
list[str]
|
|
88
|
+
The packages that would be added if :func:`inject_dependencies` were called.
|
|
89
|
+
|
|
90
|
+
Raises
|
|
91
|
+
------
|
|
92
|
+
FileNotFoundError
|
|
93
|
+
If ``pyproject.toml`` does not exist in *project_root*.
|
|
94
|
+
tomllib.TOMLDecodeError
|
|
95
|
+
If ``pyproject.toml`` is malformed.
|
|
96
|
+
"""
|
|
97
|
+
pyproject = project_root / "pyproject.toml"
|
|
98
|
+
with pyproject.open("rb") as fh:
|
|
99
|
+
data = tomllib.load(fh)
|
|
100
|
+
|
|
101
|
+
deps: list[str] = data.get("project", {}).get("dependencies", [])
|
|
102
|
+
return _missing(deps, packages)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def update_project_scripts(project_root: Path, pkg: ProjectContext, entry_fn: str = "start_server") -> bool:
|
|
106
|
+
"""Rewrite ``[project.scripts]`` so the CLI entry calls *entry_fn*.
|
|
107
|
+
|
|
108
|
+
Sets ``<pkg> = "<pkg>.server:<entry_fn>"`` in ``pyproject.toml``.
|
|
109
|
+
Idempotent: returns ``False`` immediately when the entry already has the
|
|
110
|
+
correct value, leaving the file unchanged.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
project_root:
|
|
115
|
+
Directory containing ``pyproject.toml``.
|
|
116
|
+
pkg:
|
|
117
|
+
The Python package name (e.g. ``"my_app"``).
|
|
118
|
+
entry_fn:
|
|
119
|
+
The function name in ``server.py`` to use as the entry point
|
|
120
|
+
(default ``"start_server"``). Pass ``"main"`` for MCP projects.
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
bool
|
|
125
|
+
``True`` if the file was written, ``False`` if it was already correct.
|
|
126
|
+
"""
|
|
127
|
+
pyproject = project_root / "pyproject.toml"
|
|
128
|
+
with pyproject.open("rb") as fh:
|
|
129
|
+
data = tomllib.load(fh)
|
|
130
|
+
|
|
131
|
+
new_value = f"{pkg.python_package}.server:{entry_fn}"
|
|
132
|
+
scripts: dict[str, str] = data.setdefault("project", {}).setdefault("scripts", {})
|
|
133
|
+
if scripts.get(pkg.python_package_script) == new_value:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
scripts[pkg.python_package_script] = new_value
|
|
137
|
+
with pyproject.open("wb") as fh:
|
|
138
|
+
tomli_w.dump(data, fh)
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def dry_run_scripts_update(project_root: Path, pkg: ProjectContext, entry_fn: str = "start_server") -> bool:
|
|
143
|
+
"""Return whether ``update_project_scripts`` *would* write to disk.
|
|
144
|
+
|
|
145
|
+
Read-only: never modifies ``pyproject.toml``.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
project_root:
|
|
150
|
+
Directory containing ``pyproject.toml``.
|
|
151
|
+
pkg:
|
|
152
|
+
The Python package name (e.g. ``"my_app"``).
|
|
153
|
+
entry_fn:
|
|
154
|
+
The function name in ``server.py`` to use as the entry point
|
|
155
|
+
(default ``"start_server"``). Pass ``"main"`` for MCP projects.
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
bool
|
|
160
|
+
``True`` if the current entry differs from ``<pkg>.server:<entry_fn>``,
|
|
161
|
+
``False`` if no change would be needed.
|
|
162
|
+
"""
|
|
163
|
+
pyproject = project_root / "pyproject.toml"
|
|
164
|
+
with pyproject.open("rb") as fh:
|
|
165
|
+
data = tomllib.load(fh)
|
|
166
|
+
|
|
167
|
+
new_value = f"{pkg.python_package}.server:{entry_fn}"
|
|
168
|
+
scripts: dict[str, str] = data.get("project", {}).get("scripts", {})
|
|
169
|
+
return scripts.get(pkg.python_package_script) != new_value
|