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/prompts.py
ADDED
|
@@ -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
|
create_forge/registry.py
ADDED
|
@@ -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
|