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.
Files changed (109) hide show
  1. scaffold_ca_python/__init__.py +1 -0
  2. scaffold_ca_python/cli.py +39 -0
  3. scaffold_ca_python/commands/__init__.py +0 -0
  4. scaffold_ca_python/commands/delete_module.py +216 -0
  5. scaffold_ca_python/commands/generate_driven_adapter.py +182 -0
  6. scaffold_ca_python/commands/generate_entry_point.py +304 -0
  7. scaffold_ca_python/commands/generate_helper.py +135 -0
  8. scaffold_ca_python/commands/generate_model.py +134 -0
  9. scaffold_ca_python/commands/generate_pipeline.py +158 -0
  10. scaffold_ca_python/commands/generate_project.py +189 -0
  11. scaffold_ca_python/commands/generate_use_case.py +136 -0
  12. scaffold_ca_python/commands/update_project.py +84 -0
  13. scaffold_ca_python/commands/validate_structure.py +90 -0
  14. scaffold_ca_python/core/__init__.py +0 -0
  15. scaffold_ca_python/core/file_writer.py +128 -0
  16. scaffold_ca_python/core/module_builder.py +127 -0
  17. scaffold_ca_python/core/name_utils.py +59 -0
  18. scaffold_ca_python/core/project_detector.py +93 -0
  19. scaffold_ca_python/core/pyproject_writer.py +169 -0
  20. scaffold_ca_python/core/structure_validator.py +142 -0
  21. scaffold_ca_python/core/template_renderer.py +100 -0
  22. scaffold_ca_python/factory/__init__.py +16 -0
  23. scaffold_ca_python/factory/driven_adapters/__init__.py +0 -0
  24. scaffold_ca_python/factory/driven_adapters/da_generic.py +65 -0
  25. scaffold_ca_python/factory/driven_adapters/da_rest_consumer.py +64 -0
  26. scaffold_ca_python/factory/driven_adapters/da_secrets.py +64 -0
  27. scaffold_ca_python/factory/entry_points/__init__.py +0 -0
  28. scaffold_ca_python/factory/entry_points/ep_agent.py +91 -0
  29. scaffold_ca_python/factory/entry_points/ep_generic.py +75 -0
  30. scaffold_ca_python/factory/entry_points/ep_mcp.py +138 -0
  31. scaffold_ca_python/factory/entry_points/ep_restapi.py +133 -0
  32. scaffold_ca_python/factory/simple/__init__.py +0 -0
  33. scaffold_ca_python/factory/simple/delete_module_factory.py +85 -0
  34. scaffold_ca_python/factory/simple/helper_factory.py +67 -0
  35. scaffold_ca_python/factory/simple/model_factory.py +57 -0
  36. scaffold_ca_python/factory/simple/use_case_factory.py +59 -0
  37. scaffold_ca_python/models/__init__.py +0 -0
  38. scaffold_ca_python/models/context.py +60 -0
  39. scaffold_ca_python/models/file_operation.py +47 -0
  40. scaffold_ca_python/models/layer.py +41 -0
  41. scaffold_ca_python/models/violation.py +26 -0
  42. scaffold_ca_python/templates/__init__.py +0 -0
  43. scaffold_ca_python/templates/driven_adapter/generic/__init__.py.jinja2 +1 -0
  44. scaffold_ca_python/templates/driven_adapter/generic/adapter.py.jinja2 +18 -0
  45. scaffold_ca_python/templates/driven_adapter/generic/test_adapter.py.jinja2 +22 -0
  46. scaffold_ca_python/templates/driven_adapter/rest_consumer/__init__.py.jinja2 +1 -0
  47. scaffold_ca_python/templates/driven_adapter/rest_consumer/rest_consumer.py.jinja2 +27 -0
  48. scaffold_ca_python/templates/driven_adapter/rest_consumer/test_rest_consumer.py.jinja2 +24 -0
  49. scaffold_ca_python/templates/driven_adapter/secrets/__init__.py.jinja2 +1 -0
  50. scaffold_ca_python/templates/driven_adapter/secrets/secrets_adapter.py.jinja2 +37 -0
  51. scaffold_ca_python/templates/driven_adapter/secrets/test_secrets_adapter.py.jinja2 +26 -0
  52. scaffold_ca_python/templates/entry_point/agent/__init__.py.jinja2 +1 -0
  53. scaffold_ca_python/templates/entry_point/agent/agent.py.jinja2 +49 -0
  54. scaffold_ca_python/templates/entry_point/agent/card.py.jinja2 +15 -0
  55. scaffold_ca_python/templates/entry_point/agent/entrypoint_main.py.jinja2 +13 -0
  56. scaffold_ca_python/templates/entry_point/agent/test_agent.py.jinja2 +20 -0
  57. scaffold_ca_python/templates/entry_point/generic/__init__.py.jinja2 +1 -0
  58. scaffold_ca_python/templates/entry_point/generic/entrypoint_main.py.jinja2 +13 -0
  59. scaffold_ca_python/templates/entry_point/generic/handler.py.jinja2 +13 -0
  60. scaffold_ca_python/templates/entry_point/generic/test_handler.py.jinja2 +35 -0
  61. scaffold_ca_python/templates/entry_point/mcp/__init__.py.jinja2 +1 -0
  62. scaffold_ca_python/templates/entry_point/mcp/app.py.jinja2 +51 -0
  63. scaffold_ca_python/templates/entry_point/mcp/prompts.py.jinja2 +22 -0
  64. scaffold_ca_python/templates/entry_point/mcp/resources.py.jinja2 +22 -0
  65. scaffold_ca_python/templates/entry_point/mcp/server.py.jinja2 +27 -0
  66. scaffold_ca_python/templates/entry_point/mcp/test_app.py.jinja2 +32 -0
  67. scaffold_ca_python/templates/entry_point/mcp/test_prompts.py.jinja2 +40 -0
  68. scaffold_ca_python/templates/entry_point/mcp/test_resources.py.jinja2 +47 -0
  69. scaffold_ca_python/templates/entry_point/mcp/test_tools.py.jinja2 +40 -0
  70. scaffold_ca_python/templates/entry_point/mcp/tools.py.jinja2 +22 -0
  71. scaffold_ca_python/templates/entry_point/restapi/__init__.py.jinja2 +1 -0
  72. scaffold_ca_python/templates/entry_point/restapi/app.py.jinja2 +78 -0
  73. scaffold_ca_python/templates/entry_point/restapi/exception_handler.py.jinja2 +35 -0
  74. scaffold_ca_python/templates/entry_point/restapi/health.py.jinja2 +13 -0
  75. scaffold_ca_python/templates/entry_point/restapi/rest_controller.py.jinja2 +26 -0
  76. scaffold_ca_python/templates/entry_point/restapi/server.py.jinja2 +5 -0
  77. scaffold_ca_python/templates/entry_point/restapi/test_app.py.jinja2 +22 -0
  78. scaffold_ca_python/templates/entry_point/restapi/test_exception_handler.py.jinja2 +44 -0
  79. scaffold_ca_python/templates/entry_point/restapi/test_rest_controller.py.jinja2 +35 -0
  80. scaffold_ca_python/templates/entry_point/restapi/test_server.py.jinja2 +15 -0
  81. scaffold_ca_python/templates/helper/__init__.py.jinja2 +1 -0
  82. scaffold_ca_python/templates/helper/helper.py.jinja2 +7 -0
  83. scaffold_ca_python/templates/helper/test_helper.py.jinja2 +8 -0
  84. scaffold_ca_python/templates/model/model.py.jinja2 +9 -0
  85. scaffold_ca_python/templates/model/test_model.py.jinja2 +8 -0
  86. scaffold_ca_python/templates/pipeline/azure/azure_pipelines.yml.jinja2 +28 -0
  87. scaffold_ca_python/templates/pipeline/github/ci.yml.jinja2 +34 -0
  88. scaffold_ca_python/templates/project/README.jinja2 +30 -0
  89. scaffold_ca_python/templates/project/application/config/__init__.py.jinja2 +1 -0
  90. scaffold_ca_python/templates/project/application/config/config.py.jinja2 +12 -0
  91. scaffold_ca_python/templates/project/application/config/container.py.jinja2 +17 -0
  92. scaffold_ca_python/templates/project/application/config/driven_adapters_container.py.jinja2 +14 -0
  93. scaffold_ca_python/templates/project/application/config/resource_container.py.jinja2 +17 -0
  94. scaffold_ca_python/templates/project/application/config/usecases_container.py.jinja2 +16 -0
  95. scaffold_ca_python/templates/project/dockerfile.jinja2 +22 -0
  96. scaffold_ca_python/templates/project/dockerignore.jinja2 +19 -0
  97. scaffold_ca_python/templates/project/gitignore.jinja2 +64 -0
  98. scaffold_ca_python/templates/project/layer_init.jinja2 +1 -0
  99. scaffold_ca_python/templates/project/main.py.jinja2 +10 -0
  100. scaffold_ca_python/templates/project/mypy_ini.jinja2 +5 -0
  101. scaffold_ca_python/templates/project/pyproject_toml.jinja2 +66 -0
  102. scaffold_ca_python/templates/project/python_version.jinja2 +1 -0
  103. scaffold_ca_python/templates/use_case/test_use_case.py.jinja2 +12 -0
  104. scaffold_ca_python/templates/use_case/use_case.py.jinja2 +9 -0
  105. scaffold_ca_python-0.1.1.dist-info/METADATA +285 -0
  106. scaffold_ca_python-0.1.1.dist-info/RECORD +109 -0
  107. scaffold_ca_python-0.1.1.dist-info/WHEEL +4 -0
  108. scaffold_ca_python-0.1.1.dist-info/entry_points.txt +3 -0
  109. 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