create-forge 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.
- create_forge/__init__.py +1 -0
- create_forge/cli.py +824 -0
- create_forge/compat.py +47 -0
- create_forge/config.py +126 -0
- create_forge/engine.py +228 -0
- create_forge/models.py +142 -0
- create_forge/pipeline.py +141 -0
- create_forge/prompts.py +231 -0
- create_forge/registry.py +36 -0
- create_forge/runner.py +148 -0
- create_forge/spec.py +192 -0
- create_forge/staging.py +180 -0
- create_forge/templates.toml +121 -0
- create_forge-0.2.0.dist-info/METADATA +207 -0
- create_forge-0.2.0.dist-info/RECORD +18 -0
- create_forge-0.2.0.dist-info/WHEEL +4 -0
- create_forge-0.2.0.dist-info/entry_points.txt +2 -0
- create_forge-0.2.0.dist-info/licenses/LICENSE +21 -0
create_forge/compat.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Engine compatibility constants shared by shipped and engine-only modules.
|
|
2
|
+
|
|
3
|
+
Deliberately engine-free: nothing here imports `forge_template`, not even
|
|
4
|
+
under `TYPE_CHECKING`. That is what lets `cli.py`'s `doctor` command report
|
|
5
|
+
the declared engine range and supported protocols unconditionally --
|
|
6
|
+
independent of whether the `engine` extra is installed, and without
|
|
7
|
+
importing `engine.py` itself, which would break ADR 0014's rule that no
|
|
8
|
+
module reachable from `create-forge`'s shipped entry point may depend on
|
|
9
|
+
`forge_template` at its own import time.
|
|
10
|
+
`tests/test_engine_contract.py`'s `_SHIPPED_MODULES` AST guard covers this
|
|
11
|
+
module for exactly that reason -- mirroring the role `staging.py` already
|
|
12
|
+
plays for the same rule (ADR 0015). See
|
|
13
|
+
[ADR 0018](../../docs/adr/0018-pypi-distribution-and-the-first-engine-range.md)
|
|
14
|
+
and the canonical [engine resolution contract](../../docs/engine-resolution.md).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
ENGINE_DISTRIBUTION = "forge-template"
|
|
20
|
+
"""The PyPI distribution name `create-forge[engine]` declares."""
|
|
21
|
+
|
|
22
|
+
SUPPORTED_ENGINE_RANGE = ">=0.3.1,<0.4"
|
|
23
|
+
"""The first assigned, released compatibility range (ADR 0018).
|
|
24
|
+
|
|
25
|
+
Pre-1.0, a supported range stays within one minor line -- see the
|
|
26
|
+
[integration contract](../../docs/integration-contract.md)'s
|
|
27
|
+
version-and-protocol-compatibility rule. `0.3.1` is the first PyPI release of
|
|
28
|
+
`forge-template` (forge-template ADR 0036); `engine.py` checks an installed
|
|
29
|
+
package against this range with `packaging.specifiers.SpecifierSet`,
|
|
30
|
+
replacing the prior exact-pin development check.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
SUPPORTED_PROJECTSPEC_PROTOCOLS: tuple[int, ...] = (1,)
|
|
34
|
+
"""ProjectSpec wire protocols this create-forge release has implemented
|
|
35
|
+
against.
|
|
36
|
+
|
|
37
|
+
Deliberately not read from the installed engine's own advertised protocols
|
|
38
|
+
-- negotiation in `engine.py` compares the two sides rather than assuming
|
|
39
|
+
they agree.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
SUPPORTED_COMPONENT_MANIFEST_PROTOCOLS: tuple[int, ...] = (1, 2)
|
|
43
|
+
"""Component-manifest protocols this create-forge release understands.
|
|
44
|
+
|
|
45
|
+
Independent from the installed engine's advertised protocols for the same
|
|
46
|
+
reason as :data:`SUPPORTED_PROJECTSPEC_PROTOCOLS`.
|
|
47
|
+
"""
|
create_forge/config.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""User configuration.
|
|
2
|
+
|
|
3
|
+
Purely a convenience layer: it remembers answers you would otherwise retype on
|
|
4
|
+
every scaffold. It deliberately cannot change which template is cloned — that
|
|
5
|
+
address is bundled with the release so the code being trusted is the code that
|
|
6
|
+
was reviewed. Use `--template-url` for a one-off override.
|
|
7
|
+
|
|
8
|
+
Resolution order, lowest to highest precedence:
|
|
9
|
+
|
|
10
|
+
1. built-in defaults
|
|
11
|
+
2. $XDG_CONFIG_HOME/create-forge/config.toml (or ~/.config/...)
|
|
12
|
+
3. FORGE_* environment variables
|
|
13
|
+
4. command line flags (applied by cli.py, not here)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import tomllib
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Self
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
|
|
24
|
+
|
|
25
|
+
_ENV_PREFIX = "FORGE_"
|
|
26
|
+
_FILENAME = "config.toml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UserConfig(BaseModel):
|
|
30
|
+
"""Remembered answers, all optional."""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
33
|
+
|
|
34
|
+
author_name: str | None = None
|
|
35
|
+
author_email: str | None = None
|
|
36
|
+
github_org: str | None = None
|
|
37
|
+
default_template: str | None = None
|
|
38
|
+
|
|
39
|
+
@model_validator(mode="after")
|
|
40
|
+
def _blank_is_unset(self) -> Self:
|
|
41
|
+
# A key present but empty in TOML should behave as absent rather than
|
|
42
|
+
# pre-filling a prompt with "".
|
|
43
|
+
cleaned = {
|
|
44
|
+
k: (v.strip() or None) if isinstance(v, str) else v
|
|
45
|
+
for k, v in self.__dict__.items()
|
|
46
|
+
}
|
|
47
|
+
if cleaned != self.__dict__:
|
|
48
|
+
return self.model_copy(update=cleaned)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def as_answers(self) -> dict[str, object]:
|
|
52
|
+
"""The subset usable as template answers."""
|
|
53
|
+
return {
|
|
54
|
+
key: value
|
|
55
|
+
for key, value in (
|
|
56
|
+
("author_name", self.author_name),
|
|
57
|
+
("author_email", self.author_email),
|
|
58
|
+
("github_org", self.github_org),
|
|
59
|
+
)
|
|
60
|
+
if value is not None
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def config_path() -> Path:
|
|
65
|
+
"""Where the config file is expected to live."""
|
|
66
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
67
|
+
root = Path(base) if base else Path.home() / ".config"
|
|
68
|
+
return root / "create-forge" / _FILENAME
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_config(path: Path | None = None) -> UserConfig:
|
|
72
|
+
"""Read config from disk and environment.
|
|
73
|
+
|
|
74
|
+
A malformed config is the user's to fix, so it raises with the path rather
|
|
75
|
+
than being silently ignored — silently ignoring it produces the far more
|
|
76
|
+
confusing failure of settings that appear to do nothing.
|
|
77
|
+
"""
|
|
78
|
+
target = path or config_path()
|
|
79
|
+
data: dict[str, object] = {}
|
|
80
|
+
|
|
81
|
+
if target.is_file():
|
|
82
|
+
try:
|
|
83
|
+
data = tomllib.loads(target.read_text("utf-8"))
|
|
84
|
+
except tomllib.TOMLDecodeError as exc:
|
|
85
|
+
msg = f"{target} is not valid TOML: {exc}"
|
|
86
|
+
raise ValueError(msg) from exc
|
|
87
|
+
except OSError as exc:
|
|
88
|
+
msg = f"Could not read {target}: {exc}"
|
|
89
|
+
raise ValueError(msg) from exc
|
|
90
|
+
|
|
91
|
+
data |= env_overrides()
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
return UserConfig.model_validate(data)
|
|
95
|
+
except ValidationError as exc:
|
|
96
|
+
msg = f"Invalid configuration in {target}:\n{exc}"
|
|
97
|
+
raise ValueError(msg) from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def env_overrides() -> dict[str, object]:
|
|
101
|
+
"""Which `FORGE_*` environment variables are set, e.g. `FORGE_GITHUB_ORG`."""
|
|
102
|
+
return {
|
|
103
|
+
field: os.environ[key]
|
|
104
|
+
for field in UserConfig.model_fields
|
|
105
|
+
if (key := f"{_ENV_PREFIX}{field.upper()}") in os.environ
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def write_example(path: Path | None = None) -> Path:
|
|
110
|
+
"""Write a commented starter config. Never overwrites."""
|
|
111
|
+
target = path or config_path()
|
|
112
|
+
if target.exists():
|
|
113
|
+
return target
|
|
114
|
+
|
|
115
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
target.write_text(
|
|
117
|
+
"# create-forge configuration.\n"
|
|
118
|
+
"# These pre-fill prompts; every value is optional.\n"
|
|
119
|
+
"\n"
|
|
120
|
+
'# author_name = "Your Name"\n'
|
|
121
|
+
'# author_email = "you@example.com"\n'
|
|
122
|
+
'# github_org = "your-org"\n'
|
|
123
|
+
'# default_template = "library"\n',
|
|
124
|
+
encoding="utf-8",
|
|
125
|
+
)
|
|
126
|
+
return target
|
create_forge/engine.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""The single module that touches the `forge_template` engine.
|
|
2
|
+
|
|
3
|
+
Mirrors `runner.py`'s role for Copier's Python API (invariant 4): the engine
|
|
4
|
+
is imported in exactly one place, so it evolves without every module needing
|
|
5
|
+
attention. Importing this module requires the `engine` extra
|
|
6
|
+
(`uv sync --all-extras`, or `pip install 'create-forge[engine]'`) -- since
|
|
7
|
+
[ADR 0018](../../docs/adr/0018-pypi-distribution-and-the-first-engine-range.md),
|
|
8
|
+
`forge-template` is a real, PyPI-installable, range-bounded optional
|
|
9
|
+
dependency rather than a `[tool.uv.sources]`-pinned development-only one. No
|
|
10
|
+
module reachable from create-forge's shipped CLI entry point may import this
|
|
11
|
+
module;
|
|
12
|
+
`tests/test_engine_contract.py::test_shipped_cli_modules_do_not_import_the_engine`
|
|
13
|
+
enforces that. `compat.py` holds the range and protocol constants this
|
|
14
|
+
module checks against -- it is engine-free, so `cli.py`'s `doctor` command
|
|
15
|
+
can report them without importing this module at all.
|
|
16
|
+
|
|
17
|
+
`spec.py` builds the wire payload this module parses and validates, while this
|
|
18
|
+
module also exposes the discovery adapter `pipeline.py` uses, reachable today
|
|
19
|
+
via the hidden `new --engine-preview` flag -- see ADR 0013,
|
|
20
|
+
docs/project-spec-construction.md, and docs/component-discovery.md for the
|
|
21
|
+
full contracts.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from forge_template import (
|
|
29
|
+
ComponentDescriptor,
|
|
30
|
+
EngineInfo,
|
|
31
|
+
ProjectSpec,
|
|
32
|
+
RenderedProject,
|
|
33
|
+
get_engine_info,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Explicit self-reexport: mypy strict's no_implicit_reexport otherwise blocks
|
|
37
|
+
# `cli.py`'s lazy `except engine.ForgeEngineError` (a direct import of this
|
|
38
|
+
# module, not merely an attribute chain) from typing against a name this
|
|
39
|
+
# module only imported rather than defined.
|
|
40
|
+
from forge_template import ForgeEngineError as ForgeEngineError # noqa: PLC0414
|
|
41
|
+
from forge_template import discover_components as _discover_components
|
|
42
|
+
from forge_template import map_legacy_library_answers as _map_legacy_library_answers
|
|
43
|
+
from forge_template import parse_project_spec as _parse_project_spec
|
|
44
|
+
from forge_template import render_project as _render_project
|
|
45
|
+
from forge_template import validate_project_spec as _validate_project_spec
|
|
46
|
+
from packaging.specifiers import SpecifierSet
|
|
47
|
+
from packaging.version import Version
|
|
48
|
+
|
|
49
|
+
from create_forge.compat import (
|
|
50
|
+
ENGINE_DISTRIBUTION,
|
|
51
|
+
SUPPORTED_COMPONENT_MANIFEST_PROTOCOLS,
|
|
52
|
+
SUPPORTED_ENGINE_RANGE,
|
|
53
|
+
SUPPORTED_PROJECTSPEC_PROTOCOLS,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from collections.abc import Mapping
|
|
58
|
+
|
|
59
|
+
_SUPPORTED_ENGINE_SPECIFIER = SpecifierSet(SUPPORTED_ENGINE_RANGE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EngineCompatibilityError(Exception):
|
|
63
|
+
"""An installed engine is outside the supported package/protocol range.
|
|
64
|
+
|
|
65
|
+
Carries exit status `3`'s meaning (docs/cli-conventions.md), reserved by
|
|
66
|
+
ADR 0011 for exactly this failure class. Reachable today only via the
|
|
67
|
+
hidden `new --engine-preview` flag (ADR 0014); the default `new` path
|
|
68
|
+
still cannot produce it.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _require_supported_package(info: EngineInfo) -> None:
|
|
73
|
+
"""Reject an engine package outside the declared, released range."""
|
|
74
|
+
if Version(info.package_version) in _SUPPORTED_ENGINE_SPECIFIER:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
msg = (
|
|
78
|
+
f"Detected forge-template {info.package_version}, but this "
|
|
79
|
+
f"create-forge release supports {ENGINE_DISTRIBUTION}"
|
|
80
|
+
f"{SUPPORTED_ENGINE_RANGE}. Run "
|
|
81
|
+
f"`pip install '{ENGINE_DISTRIBUTION}{SUPPORTED_ENGINE_RANGE}'` "
|
|
82
|
+
"(or the equivalent `uv add`/`uv sync` invocation) to install a "
|
|
83
|
+
"compatible version."
|
|
84
|
+
)
|
|
85
|
+
raise EngineCompatibilityError(msg)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _require_protocol_overlap(
|
|
89
|
+
info: EngineInfo,
|
|
90
|
+
*,
|
|
91
|
+
protocol_name: str,
|
|
92
|
+
supported: tuple[int, ...],
|
|
93
|
+
detected: tuple[int, ...],
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Reject an engine with no protocol version in common with this CLI."""
|
|
96
|
+
supported_set = set(supported)
|
|
97
|
+
detected_set = set(detected)
|
|
98
|
+
if supported_set & detected_set:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
msg = (
|
|
102
|
+
f"forge-template {info.package_version} supports {protocol_name} "
|
|
103
|
+
f"protocol(s) {sorted(detected_set)}, but this create-forge release "
|
|
104
|
+
f"supports {sorted(supported_set)}."
|
|
105
|
+
)
|
|
106
|
+
raise EngineCompatibilityError(msg)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _require_projectspec_protocol(info: EngineInfo) -> None:
|
|
110
|
+
"""Require a shared ProjectSpec protocol for every engine operation."""
|
|
111
|
+
_require_protocol_overlap(
|
|
112
|
+
info,
|
|
113
|
+
protocol_name="ProjectSpec",
|
|
114
|
+
supported=SUPPORTED_PROJECTSPEC_PROTOCOLS,
|
|
115
|
+
detected=info.projectspec_protocols,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _require_component_manifest_protocol(info: EngineInfo) -> None:
|
|
120
|
+
"""Require a shared component-manifest protocol before discovery."""
|
|
121
|
+
_require_protocol_overlap(
|
|
122
|
+
info,
|
|
123
|
+
protocol_name="component manifest",
|
|
124
|
+
supported=SUPPORTED_COMPONENT_MANIFEST_PROTOCOLS,
|
|
125
|
+
detected=info.component_manifest_protocols,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def negotiate_protocol() -> None:
|
|
130
|
+
"""Confirm the engine matches the supported package/ProjectSpec range.
|
|
131
|
+
|
|
132
|
+
Runs before any payload is parsed, validated, or rendered.
|
|
133
|
+
"""
|
|
134
|
+
info = get_engine_info()
|
|
135
|
+
_require_supported_package(info)
|
|
136
|
+
_require_projectspec_protocol(info)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def discover() -> tuple[ComponentDescriptor, ...]:
|
|
140
|
+
"""Return engine-owned component descriptors after protocol negotiation.
|
|
141
|
+
|
|
142
|
+
ProjectSpec and component-manifest compatibility are checked before the
|
|
143
|
+
engine scans its installed catalogue. The descriptors are returned
|
|
144
|
+
unchanged: their identifiers, presentation metadata, compatibility,
|
|
145
|
+
relationships, and options remain owned and validated by `forge-template`.
|
|
146
|
+
"""
|
|
147
|
+
info = get_engine_info()
|
|
148
|
+
_require_supported_package(info)
|
|
149
|
+
_require_projectspec_protocol(info)
|
|
150
|
+
_require_component_manifest_protocol(info)
|
|
151
|
+
return _discover_components()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_project_spec(payload: Mapping[str, object]) -> ProjectSpec:
|
|
155
|
+
"""Negotiate the protocol, then strictly parse a ProjectSpec payload.
|
|
156
|
+
|
|
157
|
+
Negotiation runs before `parse_project_spec` ever inspects `payload`,
|
|
158
|
+
satisfying #46's "negotiate the supported ProjectSpec protocol before any
|
|
159
|
+
side effect" criterion independent of what the payload itself contains.
|
|
160
|
+
"""
|
|
161
|
+
negotiate_protocol()
|
|
162
|
+
return _parse_project_spec(payload)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def validate(spec: ProjectSpec) -> ProjectSpec:
|
|
166
|
+
"""Validate a parsed ProjectSpec against the installed component catalogue.
|
|
167
|
+
|
|
168
|
+
The installed `forge-template` catalogue is production: `library` and
|
|
169
|
+
`cli` are both real, validated archetypes.
|
|
170
|
+
"""
|
|
171
|
+
info = get_engine_info()
|
|
172
|
+
_require_supported_package(info)
|
|
173
|
+
_require_projectspec_protocol(info)
|
|
174
|
+
_require_component_manifest_protocol(info)
|
|
175
|
+
return _validate_project_spec(spec)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def render(spec: ProjectSpec) -> RenderedProject:
|
|
179
|
+
"""Render one spec to immutable in-memory files after compatibility checks.
|
|
180
|
+
|
|
181
|
+
The public engine owns validation, composition, rendering, and
|
|
182
|
+
generated-project validation -- the `RenderedProject` returned here has
|
|
183
|
+
already passed `forge_template.validate_rendered_project`. This adapter
|
|
184
|
+
deliberately accepts no destination path and performs no filesystem
|
|
185
|
+
writes; `pipeline.finalise_generation_request` (ADR 0015) owns staging and
|
|
186
|
+
finalisation around the returned files.
|
|
187
|
+
"""
|
|
188
|
+
info = get_engine_info()
|
|
189
|
+
_require_supported_package(info)
|
|
190
|
+
_require_projectspec_protocol(info)
|
|
191
|
+
_require_component_manifest_protocol(info)
|
|
192
|
+
return _render_project(spec)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def map_legacy_library_options(
|
|
196
|
+
legacy_answers: Mapping[str, str],
|
|
197
|
+
) -> Mapping[str, object]:
|
|
198
|
+
"""Translate legacy Library answers into the `library` component option.
|
|
199
|
+
|
|
200
|
+
Thin wrapper around the public `map_legacy_library_answers` facade after
|
|
201
|
+
the same compatibility checks every other operation here runs, so this
|
|
202
|
+
stays the only module that touches the mapping's implementation. The
|
|
203
|
+
mapping itself -- `build_backend`/`versioning_resolved` to
|
|
204
|
+
`packaging_mode` -- is engine-owned; see
|
|
205
|
+
docs/library-archetype.md#legacy-copier-answer-mapping in forge-template.
|
|
206
|
+
`pipeline.build_generation_request` is the only caller, and only for the
|
|
207
|
+
`library` archetype (CF-08.02).
|
|
208
|
+
"""
|
|
209
|
+
info = get_engine_info()
|
|
210
|
+
_require_supported_package(info)
|
|
211
|
+
_require_projectspec_protocol(info)
|
|
212
|
+
_require_component_manifest_protocol(info)
|
|
213
|
+
return _map_legacy_library_answers(legacy_answers)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def explain(exc: ForgeEngineError) -> str:
|
|
217
|
+
"""Translate a structured `ForgeEngineError` into terminal-ready text.
|
|
218
|
+
|
|
219
|
+
Mirrors `runner._explain()`'s job for Copier's freeform messages, but
|
|
220
|
+
from a structured source: `ForgeEngineError` already carries a stable
|
|
221
|
+
code and located details, so this formats them rather than pattern
|
|
222
|
+
matching on message text.
|
|
223
|
+
"""
|
|
224
|
+
lines = [f"{exc.message} ({exc.code.value})"]
|
|
225
|
+
for detail in exc.details:
|
|
226
|
+
location = ".".join(str(part) for part in detail.path) or exc.operation
|
|
227
|
+
lines.append(f" {location}: {detail.message}")
|
|
228
|
+
return "\n".join(lines)
|
create_forge/models.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Registry data models.
|
|
2
|
+
|
|
3
|
+
The registry is bundled with the package, so adding a template means cutting a
|
|
4
|
+
new CLI release. That is a deliberate MVP trade-off: no network call on start,
|
|
5
|
+
no partial-failure states, and the registry is validated at build time by the
|
|
6
|
+
test suite rather than at runtime by the user.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Annotated, Literal, Self
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PromptKind(StrEnum):
|
|
18
|
+
"""How a prompt is rendered."""
|
|
19
|
+
|
|
20
|
+
TEXT = "text"
|
|
21
|
+
SELECT = "select"
|
|
22
|
+
CONFIRM = "confirm"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Choice(BaseModel):
|
|
26
|
+
"""One option in a select prompt."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
29
|
+
|
|
30
|
+
value: str
|
|
31
|
+
label: str
|
|
32
|
+
hint: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PromptSpec(BaseModel):
|
|
36
|
+
"""A question the CLI asks on behalf of a template.
|
|
37
|
+
|
|
38
|
+
This is UX metadata only. The authoritative declaration of every variable —
|
|
39
|
+
including its type, default and validation — lives in the template's own
|
|
40
|
+
`copier.yml`. Anything not listed here is left to the template default.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
44
|
+
|
|
45
|
+
key: Annotated[str, Field(pattern=r"^[a-z][a-z0-9_]*$")]
|
|
46
|
+
"""Must match a question key in the template's copier.yml."""
|
|
47
|
+
|
|
48
|
+
kind: PromptKind
|
|
49
|
+
message: str
|
|
50
|
+
help: str | None = None
|
|
51
|
+
choices: list[Choice] = Field(default_factory=list)
|
|
52
|
+
default: str | bool | None = None
|
|
53
|
+
depends_on: dict[str, str] = Field(default_factory=dict)
|
|
54
|
+
"""Only ask when these previously-answered keys hold these values."""
|
|
55
|
+
|
|
56
|
+
@model_validator(mode="after")
|
|
57
|
+
def _choices_match_kind(self) -> Self:
|
|
58
|
+
if self.kind is PromptKind.SELECT and not self.choices:
|
|
59
|
+
msg = f"prompt {self.key!r} is a select but declares no choices"
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
if self.kind is not PromptKind.SELECT and self.choices:
|
|
62
|
+
msg = f"prompt {self.key!r} declares choices but is not a select"
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def should_ask(self, answers: dict[str, object]) -> bool:
|
|
67
|
+
"""Whether this prompt applies given what has been answered so far."""
|
|
68
|
+
return all(
|
|
69
|
+
str(answers.get(key)) == expected
|
|
70
|
+
for key, expected in self.depends_on.items()
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Template(BaseModel):
|
|
75
|
+
"""One archetype available to scaffold."""
|
|
76
|
+
|
|
77
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
78
|
+
|
|
79
|
+
id: Annotated[str, Field(pattern=r"^[a-z][a-z0-9-]*$")]
|
|
80
|
+
name: str
|
|
81
|
+
description: str
|
|
82
|
+
url: HttpUrl
|
|
83
|
+
status: Literal["stable", "preview", "deprecated"] = "stable"
|
|
84
|
+
deprecated_in_favour_of: str | None = None
|
|
85
|
+
prompts: list[PromptSpec] = Field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
@model_validator(mode="after")
|
|
88
|
+
def _deprecation_has_successor(self) -> Self:
|
|
89
|
+
if self.status == "deprecated" and not self.deprecated_in_favour_of:
|
|
90
|
+
msg = f"template {self.id!r} is deprecated but names no successor"
|
|
91
|
+
raise ValueError(msg)
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
@model_validator(mode="after")
|
|
95
|
+
def _prompt_keys_unique(self) -> Self:
|
|
96
|
+
keys = [p.key for p in self.prompts]
|
|
97
|
+
if len(keys) != len(set(keys)):
|
|
98
|
+
msg = f"template {self.id!r} has duplicate prompt keys"
|
|
99
|
+
raise ValueError(msg)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Registry(BaseModel):
|
|
104
|
+
"""The full set of templates this CLI release knows about."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
107
|
+
|
|
108
|
+
default_template: str
|
|
109
|
+
templates: list[Template]
|
|
110
|
+
|
|
111
|
+
@model_validator(mode="after")
|
|
112
|
+
def _default_exists_and_is_usable(self) -> Self:
|
|
113
|
+
match = next((t for t in self.templates if t.id == self.default_template), None)
|
|
114
|
+
if match is None:
|
|
115
|
+
msg = f"default_template {self.default_template!r} is not in the registry"
|
|
116
|
+
raise ValueError(msg)
|
|
117
|
+
if match.status == "deprecated":
|
|
118
|
+
msg = f"default_template {self.default_template!r} is deprecated"
|
|
119
|
+
raise ValueError(msg)
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
@model_validator(mode="after")
|
|
123
|
+
def _ids_unique(self) -> Self:
|
|
124
|
+
ids = [t.id for t in self.templates]
|
|
125
|
+
if len(ids) != len(set(ids)):
|
|
126
|
+
msg = "duplicate template ids in registry"
|
|
127
|
+
raise ValueError(msg)
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def get(self, template_id: str) -> Template:
|
|
131
|
+
"""Look up a template, raising a useful error when absent."""
|
|
132
|
+
for template in self.templates:
|
|
133
|
+
if template.id == template_id:
|
|
134
|
+
return template
|
|
135
|
+
available = ", ".join(sorted(t.id for t in self.templates))
|
|
136
|
+
msg = f"unknown template {template_id!r}. Available: {available}"
|
|
137
|
+
raise KeyError(msg)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def selectable(self) -> list[Template]:
|
|
141
|
+
"""Templates offered interactively (deprecated ones stay addressable)."""
|
|
142
|
+
return [t for t in self.templates if t.status != "deprecated"]
|
create_forge/pipeline.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The shared create pipeline: discover, build, validate, render -- in memory.
|
|
2
|
+
|
|
3
|
+
This is the one internal generation path CF-07.01 introduces (ADR 0014).
|
|
4
|
+
It depends on `create_forge.engine` -- and therefore, transitively, on the
|
|
5
|
+
development-only `forge-template` dependency -- but its own source never
|
|
6
|
+
imports `forge_template` directly: type annotations that need engine-owned
|
|
7
|
+
types import them only under `TYPE_CHECKING`, so this module's runtime
|
|
8
|
+
behaviour never requires the engine to be *type-checkable*, only to be
|
|
9
|
+
*installed* when one of its functions is actually called. `engine.py` remains
|
|
10
|
+
the only module whose source touches `forge_template` at runtime, per ADR
|
|
11
|
+
0013 and invariant 4.
|
|
12
|
+
|
|
13
|
+
`create_forge.cli` imports this module lazily, inside `--engine-preview`'s
|
|
14
|
+
branch only, guarded by `try/except ImportError` -- see ADR 0014 for why:
|
|
15
|
+
`forge-template` is not a runtime dependency of the released CLI, so no
|
|
16
|
+
module reachable at `cli.py`'s own import time may depend on it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from create_forge import engine, staging
|
|
25
|
+
from create_forge.spec import build_spec_payload, legacy_library_answers
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Mapping, Sequence
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from forge_template import ComponentDescriptor, ProjectSpec, RenderedProject
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class GenerationRequest:
|
|
36
|
+
"""One in-memory result of the shared create pipeline.
|
|
37
|
+
|
|
38
|
+
Ready for CF-07.04 to stage and finalise. No filesystem write happens
|
|
39
|
+
here or in anything this wraps -- `engine.render()` is in-memory only.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
spec: ProjectSpec
|
|
43
|
+
rendered: RenderedProject
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def discover_archetypes() -> tuple[ComponentDescriptor, ...]:
|
|
47
|
+
"""Engine-owned archetype descriptors, for `--engine-preview` selection.
|
|
48
|
+
|
|
49
|
+
Filters `engine.discover()` to `kind == "archetype"` so `cli.py` never
|
|
50
|
+
branches on engine-defined `kind` values itself (CF-08.02) -- discovery
|
|
51
|
+
stays the one place that interprets descriptor shape.
|
|
52
|
+
"""
|
|
53
|
+
return tuple(d for d in engine.discover() if d.kind == "archetype")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolved_component_options(
|
|
57
|
+
answers: Mapping[str, object],
|
|
58
|
+
archetype: str,
|
|
59
|
+
component_options: Mapping[str, Mapping[str, object]] | None,
|
|
60
|
+
) -> Mapping[str, Mapping[str, object]] | None:
|
|
61
|
+
"""Derive `component_options` when the caller supplied none.
|
|
62
|
+
|
|
63
|
+
The one archetype-specific branch in this codebase (CF-08.02): `library`
|
|
64
|
+
predates the engine, so its legacy `build_backend`/`versioning`
|
|
65
|
+
answers need translating into the production `packaging_mode` option or
|
|
66
|
+
a user's choice silently reverts to the engine's own default. `cli` has
|
|
67
|
+
no options and needs no translation -- every other archetype passes
|
|
68
|
+
through unchanged, keyed on the engine's own
|
|
69
|
+
`map_legacy_library_answers` naming rather than on a local archetype
|
|
70
|
+
list, so this does not grow into a per-archetype registry here.
|
|
71
|
+
"""
|
|
72
|
+
if component_options is not None or archetype != "library":
|
|
73
|
+
return component_options
|
|
74
|
+
legacy = legacy_library_answers(answers)
|
|
75
|
+
if legacy is None:
|
|
76
|
+
return None
|
|
77
|
+
return {"library": engine.map_legacy_library_options(legacy)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_generation_request(
|
|
81
|
+
answers: Mapping[str, object],
|
|
82
|
+
*,
|
|
83
|
+
archetype: str,
|
|
84
|
+
capabilities: Sequence[str] = (),
|
|
85
|
+
platforms: Sequence[str] = (),
|
|
86
|
+
component_options: Mapping[str, Mapping[str, object]] | None = None,
|
|
87
|
+
) -> GenerationRequest:
|
|
88
|
+
"""Run the shared pipeline: discover -> build -> validate -> render.
|
|
89
|
+
|
|
90
|
+
Interactive and non-interactive `new` invocations both converge here once
|
|
91
|
+
they've collected the same `answers` mapping `cli._collect_answers`
|
|
92
|
+
already produces today -- nothing about answer collection changes.
|
|
93
|
+
|
|
94
|
+
`archetype`/`capabilities`/`platforms` stay caller-supplied (ADR 0013):
|
|
95
|
+
this pipeline mints no component identifiers of its own. An explicit
|
|
96
|
+
`component_options` is likewise passed through unchanged; when the
|
|
97
|
+
caller supplies none, `_resolved_component_options` derives the one
|
|
98
|
+
legacy mapping this repository still owns. `discover()` runs for its own
|
|
99
|
+
compatibility-ladder effect and to surface real descriptors to callers;
|
|
100
|
+
`discover_archetypes()` is what actually drives selection, from
|
|
101
|
+
`cli.py`.
|
|
102
|
+
|
|
103
|
+
Every downstream call (`build_project_spec`, `validate`, `render`)
|
|
104
|
+
independently re-checks package/protocol compatibility before doing its
|
|
105
|
+
own work, so there is no side effect -- in-memory or otherwise -- before
|
|
106
|
+
every check has passed.
|
|
107
|
+
"""
|
|
108
|
+
engine.discover()
|
|
109
|
+
resolved_options = _resolved_component_options(
|
|
110
|
+
answers, archetype, component_options
|
|
111
|
+
)
|
|
112
|
+
payload = build_spec_payload(
|
|
113
|
+
answers,
|
|
114
|
+
archetype=archetype,
|
|
115
|
+
capabilities=capabilities,
|
|
116
|
+
platforms=platforms,
|
|
117
|
+
component_options=resolved_options,
|
|
118
|
+
)
|
|
119
|
+
spec = engine.build_project_spec(payload)
|
|
120
|
+
validated = engine.validate(spec)
|
|
121
|
+
rendered = engine.render(validated)
|
|
122
|
+
return GenerationRequest(spec=validated, rendered=rendered)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def finalise_generation_request(request: GenerationRequest, destination: Path) -> None:
|
|
126
|
+
"""Stage and finalise `request`'s rendered files (ADR 0015).
|
|
127
|
+
|
|
128
|
+
Renders them into a directory adjacent to `destination`, then moves that
|
|
129
|
+
directory into place atomically.
|
|
130
|
+
|
|
131
|
+
`create-forge` does not call `forge_template.validate_rendered_project`
|
|
132
|
+
itself -- `engine.render()` already did, as the last step inside
|
|
133
|
+
`build_generation_request`. Reaching this function at all means that
|
|
134
|
+
validation already passed; this function's only job is the filesystem
|
|
135
|
+
half create-forge owns: staging, target-safety, and an atomic rename.
|
|
136
|
+
"""
|
|
137
|
+
with staging.staged(destination) as staging_dir:
|
|
138
|
+
staging.write_files(
|
|
139
|
+
staging_dir,
|
|
140
|
+
((file.target, file.content) for file in request.rendered.files),
|
|
141
|
+
)
|