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/cli.py
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
"""Command line interface for create-forge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from create_forge.compat import (
|
|
20
|
+
ENGINE_DISTRIBUTION,
|
|
21
|
+
SUPPORTED_ENGINE_RANGE,
|
|
22
|
+
SUPPORTED_PROJECTSPEC_PROTOCOLS,
|
|
23
|
+
)
|
|
24
|
+
from create_forge.config import (
|
|
25
|
+
UserConfig,
|
|
26
|
+
config_path,
|
|
27
|
+
env_overrides,
|
|
28
|
+
load_config,
|
|
29
|
+
write_example,
|
|
30
|
+
)
|
|
31
|
+
from create_forge.prompts import (
|
|
32
|
+
ArchetypeChoice,
|
|
33
|
+
PromptAbortedError,
|
|
34
|
+
ask_all,
|
|
35
|
+
choose_archetype,
|
|
36
|
+
choose_template,
|
|
37
|
+
slugify,
|
|
38
|
+
)
|
|
39
|
+
from create_forge.registry import load_registry
|
|
40
|
+
from create_forge.runner import ScaffoldError, ScaffoldRequest, scaffold, update
|
|
41
|
+
from create_forge.staging import (
|
|
42
|
+
DestinationConflictError,
|
|
43
|
+
StagingError,
|
|
44
|
+
ensure_available,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from collections.abc import Sequence
|
|
49
|
+
|
|
50
|
+
from create_forge.models import Registry, Template
|
|
51
|
+
|
|
52
|
+
app = typer.Typer(
|
|
53
|
+
name="create-forge",
|
|
54
|
+
help="Scaffold modern Python projects from maintained templates.",
|
|
55
|
+
no_args_is_help=True,
|
|
56
|
+
add_completion=False,
|
|
57
|
+
)
|
|
58
|
+
console = Console()
|
|
59
|
+
err = Console(stderr=True)
|
|
60
|
+
|
|
61
|
+
config_app = typer.Typer(
|
|
62
|
+
name="config",
|
|
63
|
+
help="Inspect or initialise your create-forge configuration.",
|
|
64
|
+
no_args_is_help=True,
|
|
65
|
+
)
|
|
66
|
+
app.add_typer(config_app, name="config")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _dist_version(name: str) -> str:
|
|
70
|
+
"""Installed version of a distribution, or "unknown" if it can't be found.
|
|
71
|
+
|
|
72
|
+
Editable installs of `create-forge` itself hit the fallback in normal
|
|
73
|
+
development; a distribution genuinely not being installed (e.g. `copier`
|
|
74
|
+
in some hypothetical stripped environment) hits it too.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
return version(name)
|
|
78
|
+
except PackageNotFoundError: # pragma: no cover - editable installs
|
|
79
|
+
return "unknown"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _optional_dist_version(name: str) -> str | None:
|
|
83
|
+
"""Installed version of an optional distribution, or `None` if absent.
|
|
84
|
+
|
|
85
|
+
Distinct from `_dist_version`: `create-forge` always depends on `name`
|
|
86
|
+
there, so "unknown" signals a broken environment. Here `name` is the
|
|
87
|
+
`engine` extra's `forge-template` -- not installed is the normal,
|
|
88
|
+
expected v0.2.x default, and docs/engine-resolution.md's diagnostics
|
|
89
|
+
contract documents `integration.engine_package` as `null` for it, not
|
|
90
|
+
the string "unknown".
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
return version(name)
|
|
94
|
+
except PackageNotFoundError:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _version() -> str:
|
|
99
|
+
return _dist_version("create-forge")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_data(pairs: list[str]) -> dict[str, object]:
|
|
103
|
+
"""Turn `--data key=value` into answers, coercing obvious booleans."""
|
|
104
|
+
parsed: dict[str, object] = {}
|
|
105
|
+
for pair in pairs:
|
|
106
|
+
key, sep, value = pair.partition("=")
|
|
107
|
+
if not sep:
|
|
108
|
+
msg = f"--data expects key=value, got {pair!r}"
|
|
109
|
+
raise typer.BadParameter(msg)
|
|
110
|
+
lowered = value.lower()
|
|
111
|
+
if lowered in {"true", "false"}:
|
|
112
|
+
parsed[key] = lowered == "true"
|
|
113
|
+
else:
|
|
114
|
+
parsed[key] = value
|
|
115
|
+
return parsed
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _version_callback(show: bool) -> None:
|
|
119
|
+
"""Print the version and exit, if `--version` was passed."""
|
|
120
|
+
if show:
|
|
121
|
+
console.print(_version())
|
|
122
|
+
raise typer.Exit
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.callback()
|
|
126
|
+
def main(
|
|
127
|
+
_version_flag: Annotated[
|
|
128
|
+
bool,
|
|
129
|
+
typer.Option(
|
|
130
|
+
"--version",
|
|
131
|
+
callback=_version_callback,
|
|
132
|
+
is_eager=True,
|
|
133
|
+
help="Show the version and exit.",
|
|
134
|
+
),
|
|
135
|
+
] = False,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""create-forge."""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _load_config_or_exit() -> UserConfig:
|
|
141
|
+
"""Load user config, exiting with a plain-language error if it is malformed."""
|
|
142
|
+
try:
|
|
143
|
+
return load_config()
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
err.print(f"[red]{exc}[/red]")
|
|
146
|
+
raise typer.Exit(1) from exc
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _select_template(
|
|
150
|
+
registry: Registry, config: UserConfig, template_id: str | None, *, yes: bool
|
|
151
|
+
) -> Template:
|
|
152
|
+
"""Resolve which template to scaffold from --template, config, or a prompt.
|
|
153
|
+
|
|
154
|
+
Also emits the deprecation warning, since it depends on the same
|
|
155
|
+
resolution the caller needs the return value for.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
preferred_id = (
|
|
159
|
+
registry.get(config.default_template).id
|
|
160
|
+
if config.default_template
|
|
161
|
+
else registry.default_template
|
|
162
|
+
)
|
|
163
|
+
except KeyError as exc:
|
|
164
|
+
err.print(f"[red]{config_path()} sets default_template: {exc.args[0]}[/red]")
|
|
165
|
+
raise typer.Exit(1) from exc
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
if template_id:
|
|
169
|
+
template = registry.get(template_id)
|
|
170
|
+
elif yes:
|
|
171
|
+
template = registry.get(preferred_id)
|
|
172
|
+
else:
|
|
173
|
+
template = choose_template(registry.selectable, preferred_id)
|
|
174
|
+
except KeyError as exc:
|
|
175
|
+
err.print(f"[red]{exc.args[0]}[/red]")
|
|
176
|
+
raise typer.Exit(1) from exc
|
|
177
|
+
except PromptAbortedError:
|
|
178
|
+
raise typer.Exit(130) from None
|
|
179
|
+
|
|
180
|
+
if template.status == "deprecated":
|
|
181
|
+
err.print(
|
|
182
|
+
f"[yellow]{template.id} is deprecated. "
|
|
183
|
+
f"Use {template.deprecated_in_favour_of} instead.[/yellow]"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return template
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _collect_answers(
|
|
190
|
+
template: Template,
|
|
191
|
+
preset: dict[str, object],
|
|
192
|
+
cfg_answers: dict[str, object],
|
|
193
|
+
*,
|
|
194
|
+
yes: bool,
|
|
195
|
+
) -> dict[str, object]:
|
|
196
|
+
"""Gather answers from --yes/--data, or by prompting for anything missing."""
|
|
197
|
+
if yes:
|
|
198
|
+
if "project_name" not in preset:
|
|
199
|
+
err.print("[red]--yes requires a project name.[/red]")
|
|
200
|
+
raise typer.Exit(1)
|
|
201
|
+
return {**cfg_answers, **preset}
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
return {
|
|
205
|
+
**cfg_answers,
|
|
206
|
+
**ask_all(template, preset=preset, defaults=cfg_answers),
|
|
207
|
+
}
|
|
208
|
+
except PromptAbortedError:
|
|
209
|
+
err.print("\n[dim]Cancelled.[/dim]")
|
|
210
|
+
raise typer.Exit(130) from None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _confirm_third_party(template_url: str | None, *, yes: bool) -> None:
|
|
214
|
+
"""Warn and, unless --yes, ask for confirmation before running foreign code."""
|
|
215
|
+
if not template_url:
|
|
216
|
+
return
|
|
217
|
+
err.print(
|
|
218
|
+
Panel(
|
|
219
|
+
f"Scaffolding from [bold]{template_url}[/bold]\n"
|
|
220
|
+
"Template code will be executed. Only continue if you trust it.",
|
|
221
|
+
title="[yellow]Third-party template[/yellow]",
|
|
222
|
+
border_style="yellow",
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
if not yes and not typer.confirm("Continue?", default=False):
|
|
226
|
+
raise typer.Exit(130)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _run_scaffold(request: ScaffoldRequest, slug: str) -> None:
|
|
230
|
+
"""Scaffold, translating a ScaffoldError into a clean exit."""
|
|
231
|
+
try:
|
|
232
|
+
with console.status(f"Scaffolding {slug}…"):
|
|
233
|
+
scaffold(request)
|
|
234
|
+
except ScaffoldError as exc:
|
|
235
|
+
err.print(f"[red]{exc}[/red]")
|
|
236
|
+
raise typer.Exit(1) from exc
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _select_archetype(
|
|
240
|
+
archetypes: Sequence[ArchetypeChoice], archetype: str | None, *, yes: bool
|
|
241
|
+
) -> str:
|
|
242
|
+
"""Resolve which archetype to build: explicit, --yes, then a prompt.
|
|
243
|
+
|
|
244
|
+
CF-08.02. Mirrors `_select_template`'s resolution shape for the Copier
|
|
245
|
+
path, but
|
|
246
|
+
with no config- or registry-supplied default: the engine declares no
|
|
247
|
+
default archetype, and `templates.toml`'s `default_template` is a
|
|
248
|
+
Copier-path concept the engine path deliberately does not inherit.
|
|
249
|
+
"""
|
|
250
|
+
available = {a.id for a in archetypes}
|
|
251
|
+
|
|
252
|
+
if archetype is not None:
|
|
253
|
+
if archetype not in available:
|
|
254
|
+
err.print(
|
|
255
|
+
f"[red]Unknown archetype {archetype!r}. Available: "
|
|
256
|
+
f"{', '.join(sorted(available))}[/red]"
|
|
257
|
+
)
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
return archetype
|
|
260
|
+
|
|
261
|
+
if yes:
|
|
262
|
+
err.print(
|
|
263
|
+
"[red]--engine-preview with --yes requires --archetype. "
|
|
264
|
+
f"Available: {', '.join(sorted(available))}[/red]"
|
|
265
|
+
)
|
|
266
|
+
raise typer.Exit(1)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
return choose_archetype(archetypes).id
|
|
270
|
+
except PromptAbortedError:
|
|
271
|
+
err.print("\n[dim]Cancelled.[/dim]")
|
|
272
|
+
raise typer.Exit(130) from None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _run_engine_preview(
|
|
276
|
+
answers: dict[str, object],
|
|
277
|
+
archetype: str | None,
|
|
278
|
+
dst: Path,
|
|
279
|
+
*,
|
|
280
|
+
dry_run: bool,
|
|
281
|
+
yes: bool,
|
|
282
|
+
) -> None:
|
|
283
|
+
"""The --engine-preview path: discover, select, build, validate, render.
|
|
284
|
+
|
|
285
|
+
Stages and finalises into `dst` exactly like the Copier path, just
|
|
286
|
+
through the engine (ADR 0015). `forge-template` is the optional `engine`
|
|
287
|
+
extra (ADR 0018) -- not installed by a plain `pip install create-forge`
|
|
288
|
+
-- so the import is lazy and guarded: every other command, and `new`
|
|
289
|
+
without this flag, must keep working with the dependency absent.
|
|
290
|
+
"""
|
|
291
|
+
err.print("[dim]--engine-preview is a hidden preview path (ADR 0014).[/dim]")
|
|
292
|
+
try:
|
|
293
|
+
ensure_available(dst)
|
|
294
|
+
except DestinationConflictError as exc:
|
|
295
|
+
err.print(f"[red]{exc}[/red]")
|
|
296
|
+
raise typer.Exit(1) from exc
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
# Lazy by necessity, not style: forge-template is the optional
|
|
300
|
+
# `engine` extra (ADR 0018), not installed by default, so this
|
|
301
|
+
# import must not run unless this branch is actually reached.
|
|
302
|
+
# `engine` is imported directly here (rather than accessed as
|
|
303
|
+
# `pipeline.engine`) so mypy's strict implicit-reexport check has a
|
|
304
|
+
# real, direct import to type against.
|
|
305
|
+
from create_forge import engine, pipeline # noqa: PLC0415
|
|
306
|
+
except ImportError:
|
|
307
|
+
err.print(
|
|
308
|
+
"[red]The engine extra isn't installed.[/red] Run "
|
|
309
|
+
"`pip install 'create-forge[engine]'` (or `uv sync --all-extras` "
|
|
310
|
+
"in a create-forge checkout) to use it."
|
|
311
|
+
)
|
|
312
|
+
raise typer.Exit(1) from None
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
archetypes = pipeline.discover_archetypes()
|
|
316
|
+
except engine.EngineCompatibilityError as exc:
|
|
317
|
+
err.print(f"[red]{exc}[/red]")
|
|
318
|
+
raise typer.Exit(3) from exc
|
|
319
|
+
except engine.ForgeEngineError as exc:
|
|
320
|
+
err.print(f"[red]{engine.explain(exc)}[/red]")
|
|
321
|
+
raise typer.Exit(1) from exc
|
|
322
|
+
|
|
323
|
+
resolved_archetype = _select_archetype(archetypes, archetype, yes=yes)
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
request = pipeline.build_generation_request(
|
|
327
|
+
answers, archetype=resolved_archetype
|
|
328
|
+
)
|
|
329
|
+
except engine.EngineCompatibilityError as exc:
|
|
330
|
+
err.print(f"[red]{exc}[/red]")
|
|
331
|
+
raise typer.Exit(3) from exc
|
|
332
|
+
except engine.ForgeEngineError as exc:
|
|
333
|
+
err.print(f"[red]{engine.explain(exc)}[/red]")
|
|
334
|
+
raise typer.Exit(1) from exc
|
|
335
|
+
|
|
336
|
+
if dry_run:
|
|
337
|
+
for file in request.rendered.files:
|
|
338
|
+
console.print(f"[dim]would write[/dim] {file.target}")
|
|
339
|
+
console.print("[dim]Dry run — nothing written.[/dim]")
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
pipeline.finalise_generation_request(request, dst)
|
|
344
|
+
except StagingError as exc:
|
|
345
|
+
err.print(f"[red]{exc}[/red]")
|
|
346
|
+
raise typer.Exit(1) from exc
|
|
347
|
+
|
|
348
|
+
_report_created(answers["project_name"], dst, updatable=False)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _report_created(project_name: object, dst: Path, *, updatable: bool = True) -> None:
|
|
352
|
+
"""Print the success panel once a project has actually been written."""
|
|
353
|
+
update_line = (
|
|
354
|
+
"[dim]Pull later template changes with: uvx create-forge update[/dim]"
|
|
355
|
+
if updatable
|
|
356
|
+
else "[dim]Built via --engine-preview -- create-forge update does not "
|
|
357
|
+
"apply.[/dim]"
|
|
358
|
+
)
|
|
359
|
+
console.print(
|
|
360
|
+
Panel(
|
|
361
|
+
f"[bold]{project_name}[/bold] created at [dim]{dst}[/dim]\n\n"
|
|
362
|
+
f" cd {dst.name}\n"
|
|
363
|
+
" uv run poe check\n\n"
|
|
364
|
+
f"{update_line}",
|
|
365
|
+
border_style="green",
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@app.command("new")
|
|
371
|
+
def new( # noqa: PLR0913, PLR0917 - a CLI entry point's options are its public surface; one parameter per --flag is unavoidable
|
|
372
|
+
name: Annotated[
|
|
373
|
+
str | None,
|
|
374
|
+
typer.Argument(help="Project name. Prompted for when omitted."),
|
|
375
|
+
] = None,
|
|
376
|
+
template_id: Annotated[
|
|
377
|
+
str | None,
|
|
378
|
+
typer.Option("--template", "-t", help="Template to use."),
|
|
379
|
+
] = None,
|
|
380
|
+
path: Annotated[
|
|
381
|
+
Path | None,
|
|
382
|
+
typer.Option("--path", "-p", help="Where to create the project."),
|
|
383
|
+
] = None,
|
|
384
|
+
data: Annotated[
|
|
385
|
+
list[str] | None,
|
|
386
|
+
typer.Option("--data", "-d", help="Preset an answer: key=value. Repeatable."),
|
|
387
|
+
] = None,
|
|
388
|
+
yes: Annotated[
|
|
389
|
+
bool,
|
|
390
|
+
typer.Option("--yes", "-y", help="Skip prompts; use template defaults."),
|
|
391
|
+
] = False,
|
|
392
|
+
template_url: Annotated[
|
|
393
|
+
str | None,
|
|
394
|
+
typer.Option(
|
|
395
|
+
"--template-url",
|
|
396
|
+
help="Clone from a different template. Runs its code — only use "
|
|
397
|
+
"sources you trust.",
|
|
398
|
+
),
|
|
399
|
+
] = None,
|
|
400
|
+
ref: Annotated[
|
|
401
|
+
str | None,
|
|
402
|
+
typer.Option("--ref", help="Template version. Defaults to the latest tag."),
|
|
403
|
+
] = None,
|
|
404
|
+
dry_run: Annotated[
|
|
405
|
+
bool,
|
|
406
|
+
typer.Option("--dry-run", help="Show what would be written, write nothing."),
|
|
407
|
+
] = False,
|
|
408
|
+
engine_preview: Annotated[
|
|
409
|
+
bool,
|
|
410
|
+
typer.Option(
|
|
411
|
+
"--engine-preview",
|
|
412
|
+
hidden=True,
|
|
413
|
+
help="Development-only: build via the public forge-template engine "
|
|
414
|
+
"instead of Copier. Combine with --archetype to pick a "
|
|
415
|
+
"component; omit it to be prompted.",
|
|
416
|
+
),
|
|
417
|
+
] = False,
|
|
418
|
+
archetype: Annotated[
|
|
419
|
+
str | None,
|
|
420
|
+
typer.Option(
|
|
421
|
+
"--archetype",
|
|
422
|
+
hidden=True,
|
|
423
|
+
help="Development-only: the engine archetype to build. Requires "
|
|
424
|
+
"--engine-preview.",
|
|
425
|
+
),
|
|
426
|
+
] = None,
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Create a new project."""
|
|
429
|
+
if archetype is not None and not engine_preview:
|
|
430
|
+
err.print("[red]--archetype requires --engine-preview.[/red]")
|
|
431
|
+
raise typer.Exit(1)
|
|
432
|
+
|
|
433
|
+
registry = load_registry()
|
|
434
|
+
preset = _parse_data(data or [])
|
|
435
|
+
if name:
|
|
436
|
+
preset.setdefault("project_name", name)
|
|
437
|
+
|
|
438
|
+
config = _load_config_or_exit()
|
|
439
|
+
cfg_answers = config.as_answers()
|
|
440
|
+
|
|
441
|
+
template = _select_template(registry, config, template_id, yes=yes)
|
|
442
|
+
answers = _collect_answers(template, preset, cfg_answers, yes=yes)
|
|
443
|
+
|
|
444
|
+
slug = slugify(str(answers["project_name"]))
|
|
445
|
+
dst = (path or Path.cwd() / slug).resolve()
|
|
446
|
+
|
|
447
|
+
if engine_preview:
|
|
448
|
+
_run_engine_preview(answers, archetype, dst, dry_run=dry_run, yes=yes)
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
src = template_url or str(template.url)
|
|
452
|
+
|
|
453
|
+
_confirm_third_party(template_url, yes=yes)
|
|
454
|
+
_run_scaffold(
|
|
455
|
+
ScaffoldRequest(src=src, dst=dst, data=answers, vcs_ref=ref, dry_run=dry_run),
|
|
456
|
+
slug,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
if dry_run:
|
|
460
|
+
console.print("[dim]Dry run — nothing written.[/dim]")
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
_report_created(answers["project_name"], dst)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@app.command("list")
|
|
467
|
+
def list_templates() -> None:
|
|
468
|
+
"""Show the available templates."""
|
|
469
|
+
registry = load_registry()
|
|
470
|
+
table = Table(box=None, pad_edge=False)
|
|
471
|
+
table.add_column("ID", style="bold")
|
|
472
|
+
table.add_column("Name")
|
|
473
|
+
table.add_column("Description", style="dim")
|
|
474
|
+
table.add_column("Status")
|
|
475
|
+
|
|
476
|
+
for template in registry.templates:
|
|
477
|
+
marker = "" if template.status == "stable" else f"[yellow]{template.status}[/]"
|
|
478
|
+
default = (
|
|
479
|
+
" [dim](default)[/dim]" if template.id == registry.default_template else ""
|
|
480
|
+
)
|
|
481
|
+
table.add_row(
|
|
482
|
+
template.id + default, template.name, template.description, marker
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
console.print(table)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@app.command("update")
|
|
489
|
+
def update_project(
|
|
490
|
+
project: Annotated[Path, typer.Argument(help="Project directory.")] = Path(),
|
|
491
|
+
ref: Annotated[
|
|
492
|
+
str | None, typer.Option("--ref", help="Target version. Defaults to latest.")
|
|
493
|
+
] = None,
|
|
494
|
+
) -> None:
|
|
495
|
+
"""Pull template changes into an existing project."""
|
|
496
|
+
try:
|
|
497
|
+
with console.status("Updating…"):
|
|
498
|
+
update(project.resolve(), vcs_ref=ref)
|
|
499
|
+
except ScaffoldError as exc:
|
|
500
|
+
err.print(f"[red]{exc}[/red]")
|
|
501
|
+
raise typer.Exit(1) from exc
|
|
502
|
+
|
|
503
|
+
console.print(
|
|
504
|
+
"[green]Updated.[/green] Review the diff before committing — "
|
|
505
|
+
"conflicts are marked inline."
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _markers(target: Console) -> tuple[str, str]:
|
|
510
|
+
"""Return (pass, fail) markers the console's encoding can actually render.
|
|
511
|
+
|
|
512
|
+
A Windows console on the cp1252 codepage -- the default outside Windows
|
|
513
|
+
Terminal -- cannot encode the check-mark glyphs, and Rich lets the
|
|
514
|
+
resulting UnicodeEncodeError propagate rather than degrading. `doctor`
|
|
515
|
+
needs markers it knows will survive before it ever tries to print them.
|
|
516
|
+
"""
|
|
517
|
+
try:
|
|
518
|
+
"✓✗".encode(target.encoding)
|
|
519
|
+
except (UnicodeEncodeError, LookupError):
|
|
520
|
+
return "OK", "FAIL"
|
|
521
|
+
return "✓", "✗"
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@dataclass(frozen=True, slots=True)
|
|
525
|
+
class Check:
|
|
526
|
+
"""One row of `doctor` output.
|
|
527
|
+
|
|
528
|
+
`informational` rows report a fact rather than a pass/fail condition (the
|
|
529
|
+
installed Copier version, say) and never affect `doctor`'s exit status —
|
|
530
|
+
only `passed=False` on a non-informational row does.
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
name: str
|
|
534
|
+
passed: bool
|
|
535
|
+
detail: str
|
|
536
|
+
informational: bool = False
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@dataclass(frozen=True, slots=True)
|
|
540
|
+
class Integration:
|
|
541
|
+
"""The active create-forge/forge-template integration line and its
|
|
542
|
+
versions -- see docs/engine-resolution.md for what each field means and
|
|
543
|
+
when it is populated. Since ADR 0018, `engine_range` and
|
|
544
|
+
`projectspec_supported` are always populated: a released range and
|
|
545
|
+
supported protocol are now declared regardless of whether the `engine`
|
|
546
|
+
extra is installed. `engine_package` is `None` until it is;
|
|
547
|
+
`projectspec_detected` stays `None` until a command actually imports and
|
|
548
|
+
negotiates with the engine (`doctor` never does -- see its own
|
|
549
|
+
docstring).
|
|
550
|
+
""" # noqa: D205
|
|
551
|
+
|
|
552
|
+
line: str
|
|
553
|
+
copier: str
|
|
554
|
+
engine_package: str | None
|
|
555
|
+
engine_range: str | None
|
|
556
|
+
projectspec_supported: str | None
|
|
557
|
+
projectspec_detected: str | None
|
|
558
|
+
template_source: str | None
|
|
559
|
+
template_ref: str | None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@dataclass(frozen=True, slots=True)
|
|
563
|
+
class ConfigSummary:
|
|
564
|
+
"""Where config was read from and which keys it set."""
|
|
565
|
+
|
|
566
|
+
path: str
|
|
567
|
+
keys: list[str]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@dataclass(frozen=True, slots=True)
|
|
571
|
+
class Diagnostics:
|
|
572
|
+
"""Everything `doctor` reports, gathered once so the table and `--json`
|
|
573
|
+
output can never disagree.
|
|
574
|
+
""" # noqa: D205
|
|
575
|
+
|
|
576
|
+
create_forge: str
|
|
577
|
+
python: str
|
|
578
|
+
platform: str
|
|
579
|
+
integration: Integration
|
|
580
|
+
config: ConfigSummary
|
|
581
|
+
checks: list[Check]
|
|
582
|
+
|
|
583
|
+
@property
|
|
584
|
+
def ok(self) -> bool:
|
|
585
|
+
"""Whether every non-informational check passed."""
|
|
586
|
+
return all(check.passed for check in self.checks if not check.informational)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _gather_diagnostics() -> Diagnostics:
|
|
590
|
+
"""Run every doctor check and collect every reportable fact.
|
|
591
|
+
|
|
592
|
+
`doctor` stays offline: it reports the registry's bundled template source
|
|
593
|
+
but never resolves a ref, since that would mean a network call for what
|
|
594
|
+
is meant to be a fast local health check. For the same reason, engine
|
|
595
|
+
presence is read via `importlib.metadata` only -- never imported -- so
|
|
596
|
+
`projectspec_detected` (which needs a real `get_engine_info()` call) stays
|
|
597
|
+
`None` here regardless of whether the `engine` extra is installed.
|
|
598
|
+
"""
|
|
599
|
+
checks: list[Check] = []
|
|
600
|
+
|
|
601
|
+
def check(passed: bool, name: str, detail: str) -> None:
|
|
602
|
+
checks.append(Check(name, passed, detail))
|
|
603
|
+
|
|
604
|
+
def info(name: str, detail: str) -> None:
|
|
605
|
+
checks.append(Check(name, True, detail, informational=True))
|
|
606
|
+
|
|
607
|
+
py = sys.version_info
|
|
608
|
+
python_version = f"{py.major}.{py.minor}.{py.micro}"
|
|
609
|
+
check(py >= (3, 11), "Python 3.11+", python_version)
|
|
610
|
+
|
|
611
|
+
for tool, why in (
|
|
612
|
+
("git", "required to clone templates"),
|
|
613
|
+
("uv", "required by generated projects"),
|
|
614
|
+
):
|
|
615
|
+
found = shutil.which(tool)
|
|
616
|
+
check(bool(found), tool, found or f"not on PATH — {why}")
|
|
617
|
+
|
|
618
|
+
if shutil.which("git"):
|
|
619
|
+
name = _git_config("user.name")
|
|
620
|
+
email = _git_config("user.email")
|
|
621
|
+
check(
|
|
622
|
+
bool(name and email),
|
|
623
|
+
"git identity",
|
|
624
|
+
f"{name} <{email}>"
|
|
625
|
+
if name and email
|
|
626
|
+
else "unset — scaffolding cannot commit",
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
registry: Registry | None = None
|
|
630
|
+
try:
|
|
631
|
+
registry = load_registry()
|
|
632
|
+
check(True, "registry", f"{len(registry.templates)} template(s)")
|
|
633
|
+
except RuntimeError as exc:
|
|
634
|
+
check(False, "registry", str(exc).splitlines()[0])
|
|
635
|
+
|
|
636
|
+
template_source: str | None = None
|
|
637
|
+
if registry is not None:
|
|
638
|
+
default = registry.get(registry.default_template)
|
|
639
|
+
template_source = str(default.url)
|
|
640
|
+
source_detail = (
|
|
641
|
+
f"{template_source} (default: {registry.default_template}, "
|
|
642
|
+
"ref: latest PEP 440 tag — resolved at scaffold time)"
|
|
643
|
+
)
|
|
644
|
+
else:
|
|
645
|
+
source_detail = "unavailable — registry did not load"
|
|
646
|
+
|
|
647
|
+
config_keys: list[str] = []
|
|
648
|
+
try:
|
|
649
|
+
config = load_config()
|
|
650
|
+
config_keys = sorted(config.model_dump(exclude_none=True))
|
|
651
|
+
keys_detail = ", ".join(config_keys) or "no values set"
|
|
652
|
+
check(True, "config", f"{config_path()} — {keys_detail}")
|
|
653
|
+
except ValueError as exc:
|
|
654
|
+
check(False, "config", str(exc).splitlines()[0])
|
|
655
|
+
|
|
656
|
+
engine_range = f"{ENGINE_DISTRIBUTION}{SUPPORTED_ENGINE_RANGE}"
|
|
657
|
+
engine_package = _optional_dist_version(ENGINE_DISTRIBUTION)
|
|
658
|
+
projectspec_supported = ",".join(str(p) for p in SUPPORTED_PROJECTSPEC_PROTOCOLS)
|
|
659
|
+
|
|
660
|
+
info("create-forge", _dist_version("create-forge"))
|
|
661
|
+
info("copier", _dist_version("copier"))
|
|
662
|
+
info("template source", source_detail)
|
|
663
|
+
info(
|
|
664
|
+
"engine",
|
|
665
|
+
f"{ENGINE_DISTRIBUTION} {engine_package} installed (supports {engine_range})"
|
|
666
|
+
if engine_package is not None
|
|
667
|
+
else f"not installed (supports {engine_range}) — "
|
|
668
|
+
"install with pip install 'create-forge[engine]'",
|
|
669
|
+
)
|
|
670
|
+
info(
|
|
671
|
+
"ProjectSpec protocol",
|
|
672
|
+
f"supported: {projectspec_supported} (detected: requires the "
|
|
673
|
+
"engine extra and a real negotiation, not performed by doctor)",
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
return Diagnostics(
|
|
677
|
+
create_forge=_dist_version("create-forge"),
|
|
678
|
+
python=python_version,
|
|
679
|
+
platform=sys.platform,
|
|
680
|
+
integration=Integration(
|
|
681
|
+
line="v0.2.x-copier",
|
|
682
|
+
copier=_dist_version("copier"),
|
|
683
|
+
engine_package=engine_package,
|
|
684
|
+
engine_range=engine_range,
|
|
685
|
+
projectspec_supported=projectspec_supported,
|
|
686
|
+
projectspec_detected=None,
|
|
687
|
+
template_source=template_source,
|
|
688
|
+
template_ref=None,
|
|
689
|
+
),
|
|
690
|
+
config=ConfigSummary(path=str(config_path()), keys=config_keys),
|
|
691
|
+
checks=checks,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _render_diagnostics_table(diagnostics: Diagnostics, target: Console) -> None:
|
|
696
|
+
"""Render `doctor`'s checks as the human-facing Rich table."""
|
|
697
|
+
passed_marker, failed_marker = _markers(target)
|
|
698
|
+
|
|
699
|
+
table = Table(box=None, pad_edge=False)
|
|
700
|
+
table.add_column("")
|
|
701
|
+
table.add_column("Check")
|
|
702
|
+
table.add_column("Detail", style="dim")
|
|
703
|
+
|
|
704
|
+
for entry in diagnostics.checks:
|
|
705
|
+
if entry.informational:
|
|
706
|
+
table.add_row("[dim]-[/]", entry.name, entry.detail)
|
|
707
|
+
continue
|
|
708
|
+
marker = passed_marker if entry.passed else failed_marker
|
|
709
|
+
style = "green" if entry.passed else "red"
|
|
710
|
+
table.add_row(f"[{style}]{marker}[/]", entry.name, entry.detail)
|
|
711
|
+
|
|
712
|
+
target.print(table)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _diagnostics_payload(diagnostics: Diagnostics) -> dict[str, object]:
|
|
716
|
+
"""The stable JSON shape `doctor --json` emits.
|
|
717
|
+
|
|
718
|
+
Documented field-by-field in docs/engine-resolution.md's diagnostics
|
|
719
|
+
contract -- new fields may be added, but existing ones keep their meaning.
|
|
720
|
+
"""
|
|
721
|
+
integration = diagnostics.integration
|
|
722
|
+
return {
|
|
723
|
+
"create_forge": diagnostics.create_forge,
|
|
724
|
+
"python": diagnostics.python,
|
|
725
|
+
"platform": diagnostics.platform,
|
|
726
|
+
"integration": {
|
|
727
|
+
"line": integration.line,
|
|
728
|
+
"copier": integration.copier,
|
|
729
|
+
"engine_package": integration.engine_package,
|
|
730
|
+
"engine_range": integration.engine_range,
|
|
731
|
+
"projectspec_protocol": {
|
|
732
|
+
"supported": integration.projectspec_supported,
|
|
733
|
+
"detected": integration.projectspec_detected,
|
|
734
|
+
},
|
|
735
|
+
"template_source": integration.template_source,
|
|
736
|
+
"template_ref": integration.template_ref,
|
|
737
|
+
},
|
|
738
|
+
"config": {"path": diagnostics.config.path, "keys": diagnostics.config.keys},
|
|
739
|
+
"checks": [
|
|
740
|
+
{"name": c.name, "ok": c.passed, "detail": c.detail}
|
|
741
|
+
for c in diagnostics.checks
|
|
742
|
+
if not c.informational
|
|
743
|
+
],
|
|
744
|
+
"ok": diagnostics.ok,
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@app.command("doctor")
|
|
749
|
+
def doctor(
|
|
750
|
+
as_json: Annotated[
|
|
751
|
+
bool,
|
|
752
|
+
typer.Option("--json", help="Print machine-readable diagnostics."),
|
|
753
|
+
] = False,
|
|
754
|
+
) -> None:
|
|
755
|
+
"""Check that the environment can scaffold and update projects."""
|
|
756
|
+
diagnostics = _gather_diagnostics()
|
|
757
|
+
|
|
758
|
+
if as_json:
|
|
759
|
+
typer.echo(json.dumps(_diagnostics_payload(diagnostics), indent=2))
|
|
760
|
+
else:
|
|
761
|
+
_render_diagnostics_table(diagnostics, console)
|
|
762
|
+
|
|
763
|
+
if not diagnostics.ok:
|
|
764
|
+
raise typer.Exit(1)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@config_app.command("init")
|
|
768
|
+
def config_init() -> None:
|
|
769
|
+
"""Write a commented starter config file. Never overwrites an existing one."""
|
|
770
|
+
target = config_path()
|
|
771
|
+
existed = target.exists()
|
|
772
|
+
write_example(target)
|
|
773
|
+
if existed:
|
|
774
|
+
console.print(f"[dim]{target} already exists — left untouched.[/dim]")
|
|
775
|
+
else:
|
|
776
|
+
console.print(f"[green]Wrote {target}.[/green] Edit it, then run `new` again.")
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@config_app.command("show")
|
|
780
|
+
def config_show() -> None:
|
|
781
|
+
"""Print resolved configuration and where each value came from."""
|
|
782
|
+
target = config_path()
|
|
783
|
+
if not target.is_file():
|
|
784
|
+
console.print(
|
|
785
|
+
f"[dim]{target} does not exist.[/dim] Run `create-forge config init`."
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
try:
|
|
789
|
+
config = load_config(target)
|
|
790
|
+
except ValueError as exc:
|
|
791
|
+
err.print(f"[red]{exc}[/red]")
|
|
792
|
+
raise typer.Exit(1) from exc
|
|
793
|
+
|
|
794
|
+
overridden = env_overrides()
|
|
795
|
+
|
|
796
|
+
table = Table(box=None, pad_edge=False)
|
|
797
|
+
table.add_column("Key")
|
|
798
|
+
table.add_column("Value")
|
|
799
|
+
table.add_column("Source", style="dim")
|
|
800
|
+
|
|
801
|
+
for field, value in config.model_dump().items():
|
|
802
|
+
if field in overridden:
|
|
803
|
+
source = "environment"
|
|
804
|
+
elif value is not None:
|
|
805
|
+
source = "config file"
|
|
806
|
+
else:
|
|
807
|
+
source = "unset"
|
|
808
|
+
table.add_row(field, str(value) if value is not None else "—", source)
|
|
809
|
+
|
|
810
|
+
console.print(table)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _git_config(key: str) -> str | None:
|
|
814
|
+
try:
|
|
815
|
+
result = subprocess.run( # noqa: S603
|
|
816
|
+
["git", "config", "--get", key], # noqa: S607
|
|
817
|
+
capture_output=True,
|
|
818
|
+
text=True,
|
|
819
|
+
check=False,
|
|
820
|
+
timeout=5,
|
|
821
|
+
)
|
|
822
|
+
except (OSError, subprocess.TimeoutExpired): # pragma: no cover
|
|
823
|
+
return None
|
|
824
|
+
return result.stdout.strip() or None
|