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.
@@ -0,0 +1,231 @@
1
+ """Interactive prompt flow.
2
+
3
+ Prompts are driven entirely by the registry, so surfacing a new question is a
4
+ data change rather than a code change. Anything not prompted here falls through
5
+ to the template's own default in copier.yml.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import TYPE_CHECKING, Protocol
12
+
13
+ import questionary
14
+ from questionary import Choice as QChoice
15
+
16
+ from create_forge.models import PromptKind
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Sequence
20
+
21
+ from create_forge.models import PromptSpec, Template
22
+
23
+
24
+ class ArchetypeChoice(Protocol):
25
+ """The engine's `ComponentDescriptor` shape this module actually needs.
26
+
27
+ Structural, not imported: `prompts.py` is one of the shipped modules
28
+ `tests/test_engine_contract.py::test_shipped_cli_modules_do_not_import_the_engine`
29
+ guards, so it may not import `forge_template` even under
30
+ `TYPE_CHECKING`. `ComponentDescriptor` satisfies this without either
31
+ module knowing about the other (CF-08.02). Read-only properties, not
32
+ plain attributes: `ComponentDescriptor`'s fields are frozen, and a
33
+ read-write Protocol attribute would reject it as non-conforming.
34
+ """
35
+
36
+ @property
37
+ def id(self) -> str:
38
+ """The canonical component identifier."""
39
+ ...
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ """The display name shown in the prompt."""
44
+ ...
45
+
46
+ @property
47
+ def description(self) -> str:
48
+ """The one-line description shown alongside the name."""
49
+ ...
50
+
51
+
52
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
53
+
54
+ _STYLE = questionary.Style(
55
+ [
56
+ ("qmark", "fg:#5f819d bold"),
57
+ ("question", "bold"),
58
+ ("answer", "fg:#85678f"),
59
+ ("pointer", "fg:#5f819d bold"),
60
+ ("highlighted", "fg:#5f819d bold"),
61
+ ("selected", "fg:#5f819d"),
62
+ ("instruction", "fg:#888888"),
63
+ ]
64
+ )
65
+
66
+
67
+ class PromptAbortedError(Exception):
68
+ """The user pressed Ctrl-C or Ctrl-D."""
69
+
70
+
71
+ def slugify(value: str) -> str:
72
+ """Turn a project name into a repository-safe slug."""
73
+ return _SLUG_RE.sub("-", value.lower()).strip("-")
74
+
75
+
76
+ def choose_template(templates: list[Template], default_id: str) -> Template:
77
+ """Ask which archetype to scaffold. Skipped when only one is selectable."""
78
+ if len(templates) == 1:
79
+ return templates[0]
80
+
81
+ choices = [
82
+ QChoice(
83
+ title=f"{t.name} — {t.description}"
84
+ + (" [preview]" if t.status == "preview" else ""),
85
+ value=t,
86
+ )
87
+ for t in templates
88
+ ]
89
+ default = next(
90
+ (c for c in choices if c.value is not None and c.value.id == default_id),
91
+ None,
92
+ )
93
+
94
+ answer: Template | None = questionary.select(
95
+ "What are you building?",
96
+ choices=choices,
97
+ default=default,
98
+ style=_STYLE,
99
+ ).ask()
100
+
101
+ if answer is None:
102
+ raise PromptAbortedError
103
+ return answer
104
+
105
+
106
+ def choose_archetype(archetypes: Sequence[ArchetypeChoice]) -> ArchetypeChoice:
107
+ """Ask which engine archetype to build. Skipped when only one exists.
108
+
109
+ Mirrors `choose_template`'s shape for the engine-preview path (CF-08.02):
110
+ same skip-when-one behaviour, same `PromptAbortedError` on cancel.
111
+ `archetypes` is expected to be non-empty -- callers resolve an empty
112
+ catalogue as a compatibility failure before reaching here.
113
+ """
114
+ if len(archetypes) == 1:
115
+ return archetypes[0]
116
+
117
+ choices = [
118
+ QChoice(title=f"{a.name} — {a.description}", value=a) for a in archetypes
119
+ ]
120
+
121
+ answer: ArchetypeChoice | None = questionary.select(
122
+ "What are you building?",
123
+ choices=choices,
124
+ style=_STYLE,
125
+ ).ask()
126
+
127
+ if answer is None:
128
+ raise PromptAbortedError
129
+ return answer
130
+
131
+
132
+ def ask_all(
133
+ template: Template,
134
+ *,
135
+ preset: dict[str, object] | None = None,
136
+ defaults: dict[str, object] | None = None,
137
+ ) -> dict[str, object]:
138
+ """Run every applicable prompt, returning the collected answers.
139
+
140
+ `preset` holds values supplied on the command line. Preset keys are not
141
+ re-asked, which is what makes `--data` usable alongside interactive mode.
142
+
143
+ `defaults` (e.g. from user config) pre-fills a prompt's answer without
144
+ suppressing it -- unlike `preset`, the question is still asked.
145
+ """
146
+ answers: dict[str, object] = dict(preset or {})
147
+
148
+ for spec in template.prompts:
149
+ if spec.key in answers:
150
+ continue
151
+ if not spec.should_ask(answers):
152
+ continue
153
+
154
+ value = _ask_one(spec, answers, defaults or {})
155
+ if value is None:
156
+ raise PromptAbortedError
157
+ answers[spec.key] = value
158
+
159
+ return answers
160
+
161
+
162
+ def _ask_one(
163
+ spec: PromptSpec, answers: dict[str, object], defaults: dict[str, object]
164
+ ) -> object | None:
165
+ """Render a single prompt."""
166
+ default = _resolve_default(spec, answers, defaults)
167
+
168
+ match spec.kind:
169
+ case PromptKind.CONFIRM:
170
+ confirmed: bool | None = questionary.confirm(
171
+ spec.message,
172
+ default=bool(default),
173
+ style=_STYLE,
174
+ ).ask()
175
+ return confirmed
176
+
177
+ case PromptKind.SELECT:
178
+ choices = [
179
+ QChoice(
180
+ title=c.label + (f" ({c.hint})" if c.hint else ""),
181
+ value=c.value,
182
+ )
183
+ for c in spec.choices
184
+ ]
185
+ chosen_default = next(
186
+ (c for c in choices if c.value == default), choices[0]
187
+ )
188
+ selected: str | None = questionary.select(
189
+ spec.message,
190
+ choices=choices,
191
+ default=chosen_default,
192
+ instruction=spec.help,
193
+ style=_STYLE,
194
+ ).ask()
195
+ return selected
196
+
197
+ case _:
198
+ text: str | None = questionary.text(
199
+ spec.message,
200
+ default=str(default or ""),
201
+ instruction=spec.help,
202
+ validate=_required_if(spec),
203
+ style=_STYLE,
204
+ ).ask()
205
+ return text
206
+
207
+
208
+ def _resolve_default(
209
+ spec: PromptSpec, answers: dict[str, object], defaults: dict[str, object]
210
+ ) -> object | None:
211
+ """Derive a default from earlier answers, then config, then the template."""
212
+ if spec.key == "repo_name" and "project_name" in answers:
213
+ return slugify(str(answers["project_name"]))
214
+ if spec.key in defaults:
215
+ return defaults[spec.key]
216
+ return spec.default
217
+
218
+
219
+ def _required_if(spec: PromptSpec) -> object:
220
+ """Reject empty input for keys the template cannot default."""
221
+ if spec.key != "project_name":
222
+ return lambda _: True
223
+
224
+ def _validate(text: str) -> bool | str:
225
+ if not text.strip():
226
+ return "Project name is required"
227
+ if not slugify(text):
228
+ return "Project name must contain letters or digits"
229
+ return True
230
+
231
+ return _validate
@@ -0,0 +1,36 @@
1
+ """Loads the bundled template registry.
2
+
3
+ The registry is package data, not user configuration. It is read once, validated
4
+ by Pydantic, and cached for the process lifetime. A malformed registry is a
5
+ packaging bug, not a user error, so the failure is loud and unhandled.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tomllib
11
+ from functools import cache
12
+ from importlib import resources
13
+
14
+ from pydantic import ValidationError
15
+
16
+ from create_forge.models import Registry
17
+
18
+ _REGISTRY_FILE = "templates.toml"
19
+
20
+
21
+ @cache
22
+ def load_registry() -> Registry:
23
+ """Read and validate the bundled registry."""
24
+ raw = resources.files("create_forge").joinpath(_REGISTRY_FILE).read_text("utf-8")
25
+
26
+ try:
27
+ data = tomllib.loads(raw)
28
+ except tomllib.TOMLDecodeError as exc: # pragma: no cover - packaging bug
29
+ msg = f"Bundled {_REGISTRY_FILE} is not valid TOML: {exc}"
30
+ raise RuntimeError(msg) from exc
31
+
32
+ try:
33
+ return Registry.model_validate(data)
34
+ except ValidationError as exc: # pragma: no cover - packaging bug
35
+ msg = f"Bundled {_REGISTRY_FILE} failed validation:\n{exc}"
36
+ raise RuntimeError(msg) from exc
create_forge/runner.py ADDED
@@ -0,0 +1,148 @@
1
+ """Thin wrapper over Copier's Python API.
2
+
3
+ Copier's Python API is public but not versioned as strictly as its CLI, so this
4
+ module is the single place that touches it. Pin copier narrowly in
5
+ pyproject.toml (`copier>=9.4,<10`) and this file is the only thing that needs
6
+ attention on a major bump.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from copier import run_copy, run_update
16
+ from copier.errors import CopierError
17
+
18
+ from create_forge import staging
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Mapping
22
+
23
+
24
+ class ScaffoldError(Exception):
25
+ """A failure the user can act on, already phrased for display."""
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ScaffoldRequest:
30
+ """Everything needed to render a template."""
31
+
32
+ src: str
33
+ dst: Path
34
+ data: Mapping[str, object]
35
+ vcs_ref: str | None = None
36
+ """None means Copier resolves the latest PEP440 tag."""
37
+
38
+ dry_run: bool = False
39
+
40
+
41
+ def scaffold(request: ScaffoldRequest) -> None:
42
+ """Render a template into a new directory.
43
+
44
+ `unsafe=True` is the API equivalent of `--trust` and is required because the
45
+ templates declare `_tasks`. This is a deliberate decision: the registry only
46
+ ever points at first-party repositories, so the code being trusted is code
47
+ the same team publishes. Never widen the registry to arbitrary URLs without
48
+ revisiting this.
49
+ """
50
+ try:
51
+ staging.ensure_available(request.dst)
52
+ except staging.DestinationConflictError as exc:
53
+ raise ScaffoldError(str(exc)) from exc
54
+
55
+ # Copier cannot be staged and moved into place the way the engine path
56
+ # is (ADR 0015): its templates declare `_tasks` that run `uv sync` and
57
+ # `pre-commit install`, baking dst's absolute path into `.venv/pyvenv.cfg`,
58
+ # console-script shims, and `.git/hooks/pre-commit`. Renaming a completed
59
+ # output afterward would silently break all three. So this only cleans up
60
+ # a failure at the path Copier already wrote to -- it never stages.
61
+ with staging.discard_on_failure(request.dst):
62
+ try:
63
+ run_copy(
64
+ src_path=request.src,
65
+ dst_path=request.dst,
66
+ data=dict(request.data),
67
+ vcs_ref=request.vcs_ref,
68
+ # Anything not answered by the CLI falls back to the
69
+ # template's own default, so copier.yml stays the source of
70
+ # truth.
71
+ defaults=True,
72
+ unsafe=True,
73
+ quiet=True,
74
+ pretend=request.dry_run,
75
+ )
76
+ except CopierError as exc:
77
+ raise ScaffoldError(_explain(exc)) from exc
78
+
79
+
80
+ def update(project: Path, *, vcs_ref: str | None = None) -> None:
81
+ """Pull template changes into an existing project."""
82
+ answers = project / ".copier-answers.yml"
83
+ if not answers.is_file():
84
+ msg = (
85
+ f"No .copier-answers.yml in {project}. This project was not created "
86
+ "by forge, or the answers file was deleted."
87
+ )
88
+ raise ScaffoldError(msg)
89
+
90
+ try:
91
+ run_update(
92
+ dst_path=project,
93
+ vcs_ref=vcs_ref,
94
+ defaults=True,
95
+ unsafe=True,
96
+ quiet=True,
97
+ # Only ask about questions that did not exist last time.
98
+ skip_answered=True,
99
+ conflict="inline",
100
+ # Copier refuses to update without this. It is not the safety
101
+ # relaxation it looks like: `update` already requires the
102
+ # destination to be a clean git repo (checked above and by
103
+ # Copier itself), so the user reviews a diff before committing
104
+ # regardless. Copier's own CLI hardcodes this for `update` too.
105
+ overwrite=True,
106
+ )
107
+ except CopierError as exc:
108
+ raise ScaffoldError(_explain(exc)) from exc
109
+
110
+
111
+ def _explain(exc: CopierError) -> str:
112
+ """Translate Copier's internal errors into something actionable.
113
+
114
+ Copier's messages assume familiarity with its model. Most users of this CLI
115
+ will not have any, so the common failures get rewritten.
116
+ """
117
+ text = str(exc)
118
+ lowered = text.lower()
119
+
120
+ if "dirty" in lowered or "uncommitted" in lowered:
121
+ return (
122
+ "The project has uncommitted changes. Copier needs a clean working "
123
+ "tree to merge template updates.\n"
124
+ " Commit or stash first: git stash"
125
+ )
126
+ if "only supported in git-tracked subprojects" in lowered:
127
+ return (
128
+ "This project is not tracked by git. `update` needs it to be, so "
129
+ "you can review the merge before committing.\n"
130
+ " Run: git init && git add -A && git commit -m 'initial'"
131
+ )
132
+ if "version from last update not detected" in lowered:
133
+ return (
134
+ "The version recorded in .copier-answers.yml isn't a released "
135
+ "template version, so there is nothing to update from.\n"
136
+ " Check this project was created by create-forge, not hand-edited."
137
+ )
138
+ if "no valid version" in lowered or ("ref" in lowered and "not found" in lowered):
139
+ return (
140
+ "The template has no released version to use.\n"
141
+ " The template repository needs a PEP440 git tag, e.g. v0.1.0"
142
+ )
143
+ if "authentication" in lowered or "permission denied" in lowered:
144
+ return (
145
+ "Could not access the template repository.\n"
146
+ " Check you have read access and that your git credentials are set up."
147
+ )
148
+ return text
create_forge/spec.py ADDED
@@ -0,0 +1,192 @@
1
+ """Pure ProjectSpec wire-payload construction.
2
+
3
+ This module places CLI answers into their canonical ProjectSpec position and
4
+ omits values that are absent. It performs no validation and imports nothing
5
+ from `forge_template` -- `engine.py` is the only module in this package that
6
+ touches the engine, matching invariant 4's rule for `runner.py` and Copier.
7
+ `spec.py` must stay importable and testable without the engine dependency
8
+ installed.
9
+
10
+ The one exception to "map, don't validate" is derivation: ProjectSpec's
11
+ `package_name` and `repository_name` are wire-required fields the engine
12
+ never derives, so create-forge derives them from `project_name` unless a
13
+ `--data` override supplies the matching Copier-style key (`package_name`,
14
+ `repo_name`). See docs/project-spec-construction.md for the full field
15
+ mapping and its rationale.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from typing import TYPE_CHECKING
22
+
23
+ from create_forge.prompts import slugify
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Mapping, Sequence
27
+
28
+ PROJECT_SPEC_PROTOCOL_VERSION = 1
29
+ """Mirrors `forge_template.PROJECT_SPEC_PROTOCOL_VERSION` without importing
30
+ the engine, since this module must stay importable without it installed.
31
+ """
32
+
33
+ DEFAULT_PYTHON_MINIMUM = "3.11"
34
+ DEFAULT_PYTHON_DEVELOPMENT = "3.13"
35
+ """Fallback `python` bounds, mirroring `copier.yml`'s own
36
+ `python_min_version`/`python_version` defaults (CF-08.02).
37
+
38
+ `ProjectSpec.python` is a required field, but `templates.toml` never prompts
39
+ either key -- they're on the deliberately-unasked list in this file's
40
+ sibling `templates.toml` header, same as every other question the Copier
41
+ path lets fall through to its own default. The engine path has no template
42
+ default to fall through to, so create-forge supplies the same values itself
43
+ here rather than leaving `--engine-preview` unusable without `--data`.
44
+ """
45
+
46
+ _PACKAGE_NAME_RE = re.compile(r"[^a-z0-9]+")
47
+
48
+
49
+ def _derive_package_name(project_name: str) -> str:
50
+ """Lower-case, collapse non-alphanumeric runs to one underscore, trim ends.
51
+
52
+ Deliberately not copier.yml's exact
53
+ `{{ project_name | lower | replace(' ', '_') | replace('-', '_') }}` --
54
+ ProjectSpec's `package_name` pattern (`^[a-z][a-z0-9_]*$`) is stricter
55
+ than Copier's own default, and the two systems are allowed to diverge;
56
+ the engine, not this derivation, is authoritative for validity.
57
+ """
58
+ return _PACKAGE_NAME_RE.sub("_", project_name.strip().lower()).strip("_")
59
+
60
+
61
+ def _string_answer(answers: Mapping[str, object], key: str) -> str | None:
62
+ """A non-blank string answer, or None if absent/blank."""
63
+ value = answers.get(key)
64
+ return value if isinstance(value, str) and value.strip() else None
65
+
66
+
67
+ def _authors(answers: Mapping[str, object]) -> list[dict[str, object]]:
68
+ """Zero or one author -- today's registry collects at most one."""
69
+ name = _string_answer(answers, "author_name")
70
+ if name is None:
71
+ return []
72
+ author: dict[str, object] = {"name": name}
73
+ email = _string_answer(answers, "author_email")
74
+ if email is not None:
75
+ author["email"] = email
76
+ return [author]
77
+
78
+
79
+ def _project_metadata(answers: Mapping[str, object]) -> dict[str, object]:
80
+ """Build the `project` sub-object, omitting fields with no source value.
81
+
82
+ A missing required field (`name`, `licence`, ...) is left out rather than
83
+ defaulted -- `engine.build_project_spec` reports it as a structured,
84
+ field-located validation error instead of this module guessing.
85
+ """
86
+ metadata: dict[str, object] = {}
87
+
88
+ project_name = _string_answer(answers, "project_name")
89
+ if project_name is not None:
90
+ metadata["name"] = project_name
91
+
92
+ package_name = _string_answer(answers, "package_name")
93
+ if package_name is None and project_name is not None:
94
+ package_name = _derive_package_name(project_name)
95
+ if package_name is not None:
96
+ metadata["package_name"] = package_name
97
+
98
+ repository_name = _string_answer(answers, "repo_name")
99
+ if repository_name is None and project_name is not None:
100
+ repository_name = slugify(project_name)
101
+ if repository_name is not None:
102
+ metadata["repository_name"] = repository_name
103
+
104
+ description = answers.get("project_description")
105
+ if isinstance(description, str):
106
+ metadata["description"] = description
107
+
108
+ licence = _string_answer(answers, "license")
109
+ if licence is not None:
110
+ metadata["licence"] = licence
111
+
112
+ authors = _authors(answers)
113
+ if authors:
114
+ metadata["authors"] = authors
115
+
116
+ return metadata
117
+
118
+
119
+ def _python_selection(answers: Mapping[str, object]) -> dict[str, object]:
120
+ """Resolve both `python` bounds, falling back per-bound to the defaults.
121
+
122
+ `ProjectSpec.python` is required, so unlike `_project_metadata`'s
123
+ omit-if-absent fields, this always returns a value -- an explicit answer
124
+ for one bound does not require the other, it just leaves the missing one
125
+ at its own default (CF-08.02).
126
+ """
127
+ minimum = _string_answer(answers, "python_min_version") or DEFAULT_PYTHON_MINIMUM
128
+ development = (
129
+ _string_answer(answers, "python_version") or DEFAULT_PYTHON_DEVELOPMENT
130
+ )
131
+ return {"minimum": minimum, "development": development}
132
+
133
+
134
+ def legacy_library_answers(answers: Mapping[str, object]) -> dict[str, str] | None:
135
+ """Resolve the legacy Library answer pair for `map_legacy_library_options`.
136
+
137
+ Returns `None` if `build_backend` was never answered. Mirrors
138
+ `copier.yml`'s own `versioning_resolved` computation: `static`
139
+ when `build_backend` is `uv_build`, else whatever `versioning` says,
140
+ defaulting to `static` when that question was skipped (CF-08.02). This
141
+ stays pure and engine-free -- `engine.map_legacy_library_options` is the
142
+ only caller that hands the result to `forge_template`.
143
+ """
144
+ build_backend = _string_answer(answers, "build_backend")
145
+ if build_backend is None:
146
+ return None
147
+ versioning = _string_answer(answers, "versioning") or "static"
148
+ versioning_resolved = "static" if build_backend == "uv_build" else versioning
149
+ return {
150
+ "build_backend": build_backend,
151
+ "versioning_resolved": versioning_resolved,
152
+ }
153
+
154
+
155
+ def build_spec_payload(
156
+ answers: Mapping[str, object],
157
+ *,
158
+ archetype: str,
159
+ capabilities: Sequence[str] = (),
160
+ platforms: Sequence[str] = (),
161
+ component_options: Mapping[str, Mapping[str, object]] | None = None,
162
+ ) -> dict[str, object]:
163
+ """Build a ProjectSpec wire payload from collected CLI answers.
164
+
165
+ `archetype`/`capabilities`/`platforms`/`component_options` are always
166
+ caller-supplied: create-forge mints no component identifiers of its own
167
+ (ADR 0013). Until CF-06.02 supplies them from `discover_components`,
168
+ callers are responsible for passing values a real manifest will accept.
169
+
170
+ The same `answers` mapping produces the same payload regardless of
171
+ whether it was collected interactively or via `--data`/config, since both
172
+ paths already converge on one `dict[str, object]` before this function
173
+ runs (see `cli._collect_answers`).
174
+ """
175
+ payload: dict[str, object] = {
176
+ "protocol_version": PROJECT_SPEC_PROTOCOL_VERSION,
177
+ "project": _project_metadata(answers),
178
+ "python": _python_selection(answers),
179
+ "components": {
180
+ "archetype": archetype,
181
+ "capabilities": list(capabilities),
182
+ "platforms": list(platforms),
183
+ },
184
+ }
185
+
186
+ if component_options:
187
+ payload["component_options"] = {
188
+ component_id: dict(options)
189
+ for component_id, options in component_options.items()
190
+ }
191
+
192
+ return payload