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/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"]
@@ -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
+ )