create-forge 0.3.1__tar.gz → 0.3.2__tar.gz
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-0.3.1 → create_forge-0.3.2}/CHANGELOG.md +6 -1
- {create_forge-0.3.1 → create_forge-0.3.2}/PKG-INFO +3 -2
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/adr/README.md +1 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/pyproject.toml +13 -6
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/cli.py +142 -7
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/runner.py +152 -2
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/installed_client.py +1 -1
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_cli.py +53 -0
- create_forge-0.3.2/tests/test_copier_cache.py +130 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_e2e_installed_data_science.py +3 -3
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_engine_contract.py +15 -13
- create_forge-0.3.2/tests/test_runner.py +325 -0
- create_forge-0.3.1/tests/test_runner.py +0 -164
- {create_forge-0.3.1 → create_forge-0.3.2}/.gitignore +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/LICENSE +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/00-governance-and-principles/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/01-python-core/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/02-developer-experience/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/03-quality-and-ci/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/04-runtime-and-configuration/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/05-security-and-supply-chain/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/06-extension-and-composition-contract/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/07-forge-cli-integration/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/08-reference-archetype-validation/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v1/roadmap/09-blueprint-compatibility/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/10-data-science-architecture-contract/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/11-reusable-data-science-capabilities/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/12-data-science-archetype/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/13-data-science-cli-integration/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/docs/roadmap-v2/roadmap/14-data-science-validation-and-rollout/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/examples/README.md +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/__init__.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/compat.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/config.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/engine.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/models.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/pipeline.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/prompts.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/registry.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/sources.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/spec.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/staging.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/src/create_forge/templates.toml +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/__init__.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/conftest.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_adr.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_archetype_parity.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_component_selection.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_config.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_data_science_pipeline.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_downstream_reference.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_drift.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_e2e_engine_generation.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_e2e_generation.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_e2e_installed_rollout.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_engine_adapter.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_engine_cross_repository.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_labels.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_models.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_pipeline.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_policy_hook.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_prompts.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_reference_client_boundary.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_registry.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_sources.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_spec.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_staging.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_update.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_update_network.py +0 -0
- {create_forge-0.3.1 → create_forge-0.3.2}/tests/test_workflows.py +0 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
Generated by git-cliff from Conventional Commits.
|
|
4
|
+
## [0.3.2] - 2026-09-08
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- Diagnose the Copier template cache
|
|
4
9
|
## [0.3.1] - 2026-09-08
|
|
5
10
|
|
|
6
11
|
### Bug Fixes
|
|
7
12
|
|
|
8
13
|
- Prevent template source credentials from reaching CLI output (#144)
|
|
9
|
-
- Raise the Copier floor past the destination-escape advisories
|
|
14
|
+
- Raise the Copier floor past the destination-escape advisories (#146)
|
|
10
15
|
|
|
11
16
|
### Documentation
|
|
12
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: create-forge
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Scaffold modern Python projects from maintained templates.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Sandsy09/create-forge
|
|
6
6
|
Project-URL: Repository, https://github.com/Sandsy09/create-forge
|
|
@@ -20,7 +20,8 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Code Generators
|
|
21
21
|
Classifier: Typing :: Typed
|
|
22
22
|
Requires-Python: >=3.11
|
|
23
|
-
Requires-Dist: copier<10,>=9.
|
|
23
|
+
Requires-Dist: copier<10,>=9.16
|
|
24
|
+
Requires-Dist: platformdirs>=4.3.6
|
|
24
25
|
Requires-Dist: pydantic>=2.10
|
|
25
26
|
Requires-Dist: pyyaml>=6.0
|
|
26
27
|
Requires-Dist: questionary>=2.0
|
|
@@ -41,6 +41,7 @@ format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions
|
|
|
41
41
|
- [0036 — Keep credentials out of template sources and CLI diagnostics](0036-template-source-credentials.md)
|
|
42
42
|
- [0037 — Pin external Actions to reviewed commits and scope permissions per job](0037-immutable-workflow-actions.md)
|
|
43
43
|
- [0038 — Raise the Copier floor past the destination-escape advisories and hold uv](0038-dependency-floor-review.md)
|
|
44
|
+
- [0039 — Diagnose the Copier template cache](0039-copier-cache-diagnostics.md)
|
|
44
45
|
|
|
45
46
|
Add a new record by copying the most recent one and incrementing the number.
|
|
46
47
|
Records are immutable: supersede them rather than editing.
|
|
@@ -7,7 +7,7 @@ build-backend = "hatchling.build"
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "create-forge"
|
|
10
|
-
version = "0.3.
|
|
10
|
+
version = "0.3.2"
|
|
11
11
|
description = "Scaffold modern Python projects from maintained templates."
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -29,11 +29,13 @@ classifiers = [
|
|
|
29
29
|
|
|
30
30
|
dependencies = [
|
|
31
31
|
# Pinned to a major: the Python API is public but evolves faster than the
|
|
32
|
-
# CLI. runner.py is the only module that touches it. The lower bound
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
|
|
32
|
+
# CLI. runner.py is the only module that touches it. The lower bound was
|
|
33
|
+
# raised past the destination-escape advisories to 9.15.2 (ADR 0038), then
|
|
34
|
+
# to 9.16 (ADR 0039): 9.16 is the first release with the git-mirror cache
|
|
35
|
+
# and COPIER_CACHE_DIR that runner.py models and `doctor` reports -- a
|
|
36
|
+
# required-behaviour move, not an advisory one. The whole >=9.16 range is
|
|
37
|
+
# advisory-free. See docs/engine-updates.md.
|
|
38
|
+
"copier>=9.16,<10",
|
|
37
39
|
# >=0.16: typer 0.15.0-0.15.3 declare an uncapped click and break against
|
|
38
40
|
# click >=8.2 (TyperArgument.make_metavar), and 0.15.4 pins click <8.2.
|
|
39
41
|
# 0.16.0 is the first release that works with current click. The CI
|
|
@@ -43,6 +45,11 @@ dependencies = [
|
|
|
43
45
|
"pydantic>=2.10",
|
|
44
46
|
"rich>=13.9",
|
|
45
47
|
"pyyaml>=6.0",
|
|
48
|
+
# runner.py resolves Copier's cache directory by Copier's own documented
|
|
49
|
+
# rule (COPIER_CACHE_DIR, else the platformdirs user cache). Pinned to the
|
|
50
|
+
# bound Copier itself declares so it adds no resolution pressure and the
|
|
51
|
+
# CI `floor` job sees the version Copier's own floor would (ADR 0039).
|
|
52
|
+
"platformdirs>=4.3.6",
|
|
46
53
|
]
|
|
47
54
|
|
|
48
55
|
[project.optional-dependencies]
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import re
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
8
9
|
import sys
|
|
@@ -42,7 +43,14 @@ from create_forge.prompts import (
|
|
|
42
43
|
slugify,
|
|
43
44
|
)
|
|
44
45
|
from create_forge.registry import load_registry
|
|
45
|
-
from create_forge.runner import
|
|
46
|
+
from create_forge.runner import (
|
|
47
|
+
ScaffoldError,
|
|
48
|
+
ScaffoldRequest,
|
|
49
|
+
cache_probe,
|
|
50
|
+
copier_cache_location,
|
|
51
|
+
scaffold,
|
|
52
|
+
update,
|
|
53
|
+
)
|
|
46
54
|
from create_forge.sources import SourceError, display_source, validate_source
|
|
47
55
|
from create_forge.spec import (
|
|
48
56
|
DESCRIPTOR_KIND,
|
|
@@ -1050,6 +1058,30 @@ class ConfigSummary:
|
|
|
1050
1058
|
keys: list[str]
|
|
1051
1059
|
|
|
1052
1060
|
|
|
1061
|
+
@dataclass(frozen=True, slots=True)
|
|
1062
|
+
class CopierCache:
|
|
1063
|
+
"""Copier's git-mirror cache location and whether create-forge could use
|
|
1064
|
+
it -- see docs/engine-resolution.md's diagnostics contract. `writable` is
|
|
1065
|
+
the only field that can fail a `doctor` check; the rest are facts.
|
|
1066
|
+
""" # noqa: D205
|
|
1067
|
+
|
|
1068
|
+
path: str
|
|
1069
|
+
override: bool
|
|
1070
|
+
exists: bool
|
|
1071
|
+
writable: bool
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
@dataclass(frozen=True, slots=True)
|
|
1075
|
+
class UvStatus:
|
|
1076
|
+
"""The `uv` create-forge would actually run, and the one the `engine`
|
|
1077
|
+
extra declares -- distinct facts that can legitimately differ.
|
|
1078
|
+
""" # noqa: D205
|
|
1079
|
+
|
|
1080
|
+
path: str | None
|
|
1081
|
+
version: str | None
|
|
1082
|
+
package: str | None
|
|
1083
|
+
|
|
1084
|
+
|
|
1053
1085
|
@dataclass(frozen=True, slots=True)
|
|
1054
1086
|
class Diagnostics:
|
|
1055
1087
|
"""Everything `doctor` reports, gathered once so the table and `--json`
|
|
@@ -1061,6 +1093,8 @@ class Diagnostics:
|
|
|
1061
1093
|
platform: str
|
|
1062
1094
|
integration: Integration
|
|
1063
1095
|
config: ConfigSummary
|
|
1096
|
+
copier_cache: CopierCache
|
|
1097
|
+
uv: UvStatus
|
|
1064
1098
|
checks: list[Check]
|
|
1065
1099
|
|
|
1066
1100
|
@property
|
|
@@ -1069,6 +1103,73 @@ class Diagnostics:
|
|
|
1069
1103
|
return all(check.passed for check in self.checks if not check.informational)
|
|
1070
1104
|
|
|
1071
1105
|
|
|
1106
|
+
def _tooling_diagnostics(checks: list[Check]) -> tuple[CopierCache, UvStatus]:
|
|
1107
|
+
"""Append the tooling rows and return the structured facts.
|
|
1108
|
+
|
|
1109
|
+
The git / uv / Copier-cache rows land in `checks`; the cache and uv facts
|
|
1110
|
+
`doctor --json` reports alongside them come back as dataclasses.
|
|
1111
|
+
"""
|
|
1112
|
+
git_found = shutil.which("git")
|
|
1113
|
+
checks.append(
|
|
1114
|
+
Check(
|
|
1115
|
+
"git",
|
|
1116
|
+
bool(git_found),
|
|
1117
|
+
git_found or "not on PATH — required to clone templates",
|
|
1118
|
+
)
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
uv_found = shutil.which("uv")
|
|
1122
|
+
uv_version = _uv_version(uv_found)
|
|
1123
|
+
checks.append(
|
|
1124
|
+
Check(
|
|
1125
|
+
"uv",
|
|
1126
|
+
bool(uv_found),
|
|
1127
|
+
f"{uv_version} ({uv_found})"
|
|
1128
|
+
if uv_found and uv_version
|
|
1129
|
+
else uv_found or "not on PATH — required by generated projects",
|
|
1130
|
+
)
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
cache = copier_cache_location()
|
|
1134
|
+
probe = cache_probe(cache)
|
|
1135
|
+
checks.append(
|
|
1136
|
+
Check(
|
|
1137
|
+
"copier cache",
|
|
1138
|
+
True,
|
|
1139
|
+
f"{cache.path} "
|
|
1140
|
+
+ (
|
|
1141
|
+
"(COPIER_CACHE_DIR override)"
|
|
1142
|
+
if cache.overridden
|
|
1143
|
+
else "(default location)"
|
|
1144
|
+
),
|
|
1145
|
+
informational=True,
|
|
1146
|
+
)
|
|
1147
|
+
)
|
|
1148
|
+
checks.append(
|
|
1149
|
+
Check(
|
|
1150
|
+
"copier cache writable",
|
|
1151
|
+
probe.writable,
|
|
1152
|
+
"writable"
|
|
1153
|
+
if probe.writable
|
|
1154
|
+
else "not writable — set COPIER_CACHE_DIR to a writable directory",
|
|
1155
|
+
)
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
return (
|
|
1159
|
+
CopierCache(
|
|
1160
|
+
path=str(cache.path),
|
|
1161
|
+
override=cache.overridden,
|
|
1162
|
+
exists=probe.exists,
|
|
1163
|
+
writable=probe.writable,
|
|
1164
|
+
),
|
|
1165
|
+
UvStatus(
|
|
1166
|
+
path=uv_found,
|
|
1167
|
+
version=uv_version,
|
|
1168
|
+
package=_optional_dist_version("uv"),
|
|
1169
|
+
),
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
|
|
1072
1173
|
def _gather_diagnostics() -> Diagnostics:
|
|
1073
1174
|
"""Run every doctor check and collect every reportable fact.
|
|
1074
1175
|
|
|
@@ -1091,12 +1192,7 @@ def _gather_diagnostics() -> Diagnostics:
|
|
|
1091
1192
|
python_version = f"{py.major}.{py.minor}.{py.micro}"
|
|
1092
1193
|
check(py >= (3, 11), "Python 3.11+", python_version)
|
|
1093
1194
|
|
|
1094
|
-
|
|
1095
|
-
("git", "required to clone templates"),
|
|
1096
|
-
("uv", "required by generated projects"),
|
|
1097
|
-
):
|
|
1098
|
-
found = shutil.which(tool)
|
|
1099
|
-
check(bool(found), tool, found or f"not on PATH — {why}")
|
|
1195
|
+
copier_cache, uv_status = _tooling_diagnostics(checks)
|
|
1100
1196
|
|
|
1101
1197
|
if shutil.which("git"):
|
|
1102
1198
|
name = _git_config("user.name")
|
|
@@ -1171,10 +1267,38 @@ def _gather_diagnostics() -> Diagnostics:
|
|
|
1171
1267
|
template_ref=None,
|
|
1172
1268
|
),
|
|
1173
1269
|
config=ConfigSummary(path=str(config_path()), keys=config_keys),
|
|
1270
|
+
copier_cache=copier_cache,
|
|
1271
|
+
uv=uv_status,
|
|
1174
1272
|
checks=checks,
|
|
1175
1273
|
)
|
|
1176
1274
|
|
|
1177
1275
|
|
|
1276
|
+
def _uv_version(uv_path: str | None) -> str | None:
|
|
1277
|
+
"""The version `uv --version` reports, or None when it can't be trusted.
|
|
1278
|
+
|
|
1279
|
+
`uv --version` prints e.g. `uv 0.12.10`. Only a token that looks like a
|
|
1280
|
+
version is returned, so nothing arbitrary from the subprocess can reach
|
|
1281
|
+
`doctor`'s output.
|
|
1282
|
+
"""
|
|
1283
|
+
if not uv_path:
|
|
1284
|
+
return None
|
|
1285
|
+
try:
|
|
1286
|
+
result = subprocess.run( # noqa: S603
|
|
1287
|
+
[uv_path, "--version"],
|
|
1288
|
+
capture_output=True,
|
|
1289
|
+
text=True,
|
|
1290
|
+
check=False,
|
|
1291
|
+
timeout=5,
|
|
1292
|
+
)
|
|
1293
|
+
except (OSError, subprocess.TimeoutExpired): # pragma: no cover
|
|
1294
|
+
return None
|
|
1295
|
+
match result.stdout.split():
|
|
1296
|
+
case [_, token, *_] if re.fullmatch(r"[0-9][0-9A-Za-z.+-]*", token):
|
|
1297
|
+
return token
|
|
1298
|
+
case _:
|
|
1299
|
+
return None
|
|
1300
|
+
|
|
1301
|
+
|
|
1178
1302
|
def _render_diagnostics_table(diagnostics: Diagnostics, target: Console) -> None:
|
|
1179
1303
|
"""Render `doctor`'s checks as the human-facing Rich table."""
|
|
1180
1304
|
passed_marker, failed_marker = _markers(target)
|
|
@@ -1219,6 +1343,17 @@ def _diagnostics_payload(diagnostics: Diagnostics) -> dict[str, object]:
|
|
|
1219
1343
|
"template_ref": integration.template_ref,
|
|
1220
1344
|
},
|
|
1221
1345
|
"config": {"path": diagnostics.config.path, "keys": diagnostics.config.keys},
|
|
1346
|
+
"copier_cache": {
|
|
1347
|
+
"path": diagnostics.copier_cache.path,
|
|
1348
|
+
"override": diagnostics.copier_cache.override,
|
|
1349
|
+
"exists": diagnostics.copier_cache.exists,
|
|
1350
|
+
"writable": diagnostics.copier_cache.writable,
|
|
1351
|
+
},
|
|
1352
|
+
"uv": {
|
|
1353
|
+
"path": diagnostics.uv.path,
|
|
1354
|
+
"version": diagnostics.uv.version,
|
|
1355
|
+
"package": diagnostics.uv.package,
|
|
1356
|
+
},
|
|
1222
1357
|
"checks": [
|
|
1223
1358
|
{"name": c.name, "ok": c.passed, "detail": c.detail}
|
|
1224
1359
|
for c in diagnostics.checks
|
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
Copier's Python API is public but not versioned as strictly as its CLI, so this
|
|
4
4
|
module is the single place that touches it. Pin copier narrowly in
|
|
5
|
-
pyproject.toml (`copier>=9.
|
|
5
|
+
pyproject.toml (`copier>=9.16,<10`) and this file is the only thing that
|
|
6
6
|
needs attention on a major bump.
|
|
7
|
+
|
|
8
|
+
`copier_cache_location` also models one piece of documented Copier behaviour --
|
|
9
|
+
where the git-mirror cache lives and how `COPIER_CACHE_DIR` overrides it, added
|
|
10
|
+
in Copier 9.16 -- so `doctor` can report it and a cache failure can be
|
|
11
|
+
explained instead of misattributed to the network. `tests/test_copier_cache.py`
|
|
12
|
+
pins that model against Copier's own private resolver so a drift fails CI.
|
|
7
13
|
"""
|
|
8
14
|
|
|
9
15
|
from __future__ import annotations
|
|
10
16
|
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
11
19
|
from dataclasses import dataclass
|
|
12
20
|
from pathlib import Path
|
|
13
21
|
from typing import TYPE_CHECKING
|
|
@@ -15,6 +23,7 @@ from typing import TYPE_CHECKING
|
|
|
15
23
|
import yaml
|
|
16
24
|
from copier import run_copy, run_update
|
|
17
25
|
from copier.errors import CopierError
|
|
26
|
+
from platformdirs import user_cache_dir
|
|
18
27
|
from plumbum.commands.processes import ProcessExecutionError
|
|
19
28
|
|
|
20
29
|
from create_forge import staging
|
|
@@ -34,6 +43,76 @@ _PROCESS_FAILURE_MESSAGE = (
|
|
|
34
43
|
"access, and Git credentials, then retry."
|
|
35
44
|
)
|
|
36
45
|
|
|
46
|
+
COPIER_CACHE_ENV_VAR = "COPIER_CACHE_DIR"
|
|
47
|
+
|
|
48
|
+
_CACHE_FAILURE_SIGNAL = "not a git repository"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class CacheLocation:
|
|
53
|
+
"""Where Copier stores its git-mirror cache, and how it was chosen."""
|
|
54
|
+
|
|
55
|
+
path: Path
|
|
56
|
+
overridden: bool
|
|
57
|
+
"""True when COPIER_CACHE_DIR selected the path, not the default."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class CacheProbe:
|
|
62
|
+
"""Whether Copier's cache directory exists and accepts writes."""
|
|
63
|
+
|
|
64
|
+
exists: bool
|
|
65
|
+
writable: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def copier_cache_location() -> CacheLocation:
|
|
69
|
+
"""Resolve Copier's cache directory by its own documented rule.
|
|
70
|
+
|
|
71
|
+
Copier uses `COPIER_CACHE_DIR` verbatim when it is set and non-empty,
|
|
72
|
+
otherwise `<platformdirs user cache for "copier">/git`. This mirrors
|
|
73
|
+
`copier._vcs._get_cache_dir`, which is private and has moved between
|
|
74
|
+
supported releases; `tests/test_copier_cache.py` pins the two together so
|
|
75
|
+
a drift fails CI rather than silently misreporting in `doctor`.
|
|
76
|
+
"""
|
|
77
|
+
override = os.environ.get(COPIER_CACHE_ENV_VAR)
|
|
78
|
+
if override:
|
|
79
|
+
return CacheLocation(path=Path(override), overridden=True)
|
|
80
|
+
default = Path(user_cache_dir("copier", appauthor=False)) / "git"
|
|
81
|
+
return CacheLocation(path=default, overridden=False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _write_probe(directory: Path) -> None:
|
|
85
|
+
"""Create and immediately remove a uniquely named file in `directory`."""
|
|
86
|
+
fd, name = tempfile.mkstemp(dir=directory, prefix=".create-forge-probe-")
|
|
87
|
+
os.close(fd)
|
|
88
|
+
Path(name).unlink(missing_ok=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cache_probe(location: CacheLocation) -> CacheProbe:
|
|
92
|
+
"""Report whether Copier could use `location` without changing it.
|
|
93
|
+
|
|
94
|
+
Never creates the cache directory: when it is absent, the deepest existing
|
|
95
|
+
ancestor is probed instead, because that is what Copier's own
|
|
96
|
+
`mirror.parent.mkdir(parents=True)` writes into. The probe creates a
|
|
97
|
+
uniquely named file and deletes it immediately -- it never reads, writes,
|
|
98
|
+
or lists a `<sha>.git` mirror, and leaves nothing persistent behind.
|
|
99
|
+
|
|
100
|
+
`os.access` is deliberately not used: on Windows it ignores ACLs and
|
|
101
|
+
reports a locked-down corporate cache directory as writable, which is the
|
|
102
|
+
exact condition this probe exists to catch.
|
|
103
|
+
"""
|
|
104
|
+
exists = location.path.is_dir()
|
|
105
|
+
probe_dir = location.path
|
|
106
|
+
while not probe_dir.is_dir():
|
|
107
|
+
if probe_dir.parent == probe_dir:
|
|
108
|
+
return CacheProbe(exists=exists, writable=False)
|
|
109
|
+
probe_dir = probe_dir.parent
|
|
110
|
+
try:
|
|
111
|
+
_write_probe(probe_dir)
|
|
112
|
+
except OSError:
|
|
113
|
+
return CacheProbe(exists=exists, writable=False)
|
|
114
|
+
return CacheProbe(exists=exists, writable=True)
|
|
115
|
+
|
|
37
116
|
|
|
38
117
|
@dataclass(frozen=True, slots=True)
|
|
39
118
|
class ScaffoldRequest:
|
|
@@ -89,6 +168,16 @@ def scaffold(request: ScaffoldRequest) -> None:
|
|
|
89
168
|
)
|
|
90
169
|
except (CopierError, ProcessExecutionError) as exc:
|
|
91
170
|
raise ScaffoldError(_explain(exc)) from exc
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
# ProcessExecutionError is itself an OSError subclass, so it stays
|
|
173
|
+
# matched by the clause above. This one is Copier failing to
|
|
174
|
+
# create or use its cache directory directly (Copier >=9.16) -- a
|
|
175
|
+
# redirected or ACL-restricted cache on a managed machine surfaces
|
|
176
|
+
# here, before git runs. Errors outside the cache path are not
|
|
177
|
+
# ours to translate.
|
|
178
|
+
if (message := _explain_cache_os_error(exc)) is None:
|
|
179
|
+
raise
|
|
180
|
+
raise ScaffoldError(message) from exc
|
|
92
181
|
|
|
93
182
|
|
|
94
183
|
def update(project: Path, *, vcs_ref: str | None = None, dry_run: bool = False) -> None:
|
|
@@ -143,6 +232,60 @@ def update(project: Path, *, vcs_ref: str | None = None, dry_run: bool = False)
|
|
|
143
232
|
)
|
|
144
233
|
except (CopierError, ProcessExecutionError) as exc:
|
|
145
234
|
raise ScaffoldError(_explain(exc)) from exc
|
|
235
|
+
except OSError as exc:
|
|
236
|
+
# See scaffold(): a cache-directory failure Copier raises directly,
|
|
237
|
+
# not through git. Everything else re-raises untouched.
|
|
238
|
+
if (message := _explain_cache_os_error(exc)) is None:
|
|
239
|
+
raise
|
|
240
|
+
raise ScaffoldError(message) from exc
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _cache_failure_message(cache_path: Path) -> str:
|
|
244
|
+
"""Recovery guidance for an unusable Copier cache, naming only our own
|
|
245
|
+
resolved path -- never any part of the process output that revealed it.
|
|
246
|
+
""" # noqa: D205
|
|
247
|
+
return (
|
|
248
|
+
f"Copier's template cache at {cache_path} is not usable.\n"
|
|
249
|
+
" This is common on managed machines where the cache location is "
|
|
250
|
+
"redirected or restricted.\n"
|
|
251
|
+
" Point Copier at a fresh, writable directory and retry -- do not "
|
|
252
|
+
"delete the existing one:\n"
|
|
253
|
+
' PowerShell: $env:COPIER_CACHE_DIR = "C:\\forge-cache"\n'
|
|
254
|
+
' bash/zsh: export COPIER_CACHE_DIR="$HOME/.cache/forge-copier"'
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _under_cache_dir(candidate: object, cache_path: Path) -> bool:
|
|
259
|
+
"""True when `candidate` is a string path at or below `cache_path`."""
|
|
260
|
+
if not isinstance(candidate, str):
|
|
261
|
+
return False
|
|
262
|
+
return os.path.normcase(candidate).startswith(os.path.normcase(str(cache_path)))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _looks_like_cache_git_failure(exc: ProcessExecutionError, cache_path: Path) -> bool:
|
|
266
|
+
"""True only for a git failure that reports a missing repository *and*
|
|
267
|
+
names the cache directory -- not for auth, network, or missing-source
|
|
268
|
+
failures that merely happen to run against a cache path in argv.
|
|
269
|
+
""" # noqa: D205
|
|
270
|
+
if _CACHE_FAILURE_SIGNAL not in (exc.stderr or "").lower():
|
|
271
|
+
return False
|
|
272
|
+
if os.path.normcase(str(cache_path)) in os.path.normcase(exc.stderr or ""):
|
|
273
|
+
return True
|
|
274
|
+
return any(_under_cache_dir(arg, cache_path) for arg in exc.argv or [])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _explain_cache_os_error(exc: OSError) -> str | None:
|
|
278
|
+
"""Explain an OS error Copier raised while preparing its cache directory.
|
|
279
|
+
|
|
280
|
+
Returns None for any error whose filename is not under the resolved cache
|
|
281
|
+
directory, so the caller re-raises it untouched -- create-forge only owns
|
|
282
|
+
a failure at the cache path it can name.
|
|
283
|
+
"""
|
|
284
|
+
cache_path = copier_cache_location().path
|
|
285
|
+
names = (exc.filename, getattr(exc, "filename2", None))
|
|
286
|
+
if any(_under_cache_dir(name, cache_path) for name in names):
|
|
287
|
+
return _cache_failure_message(cache_path)
|
|
288
|
+
return None
|
|
146
289
|
|
|
147
290
|
|
|
148
291
|
def _explain(exc: CopierError | ProcessExecutionError) -> str:
|
|
@@ -155,7 +298,14 @@ def _explain(exc: CopierError | ProcessExecutionError) -> str:
|
|
|
155
298
|
# The raw exception contains argv, stdout and stderr. In addition to
|
|
156
299
|
# being too implementation-specific for users, argv may contain
|
|
157
300
|
# credentials embedded in a template URL, so none of it is displayed.
|
|
158
|
-
|
|
301
|
+
# Only fixed signals are read out of it, and only create-forge's own
|
|
302
|
+
# resolved cache path is ever shown.
|
|
303
|
+
cache = copier_cache_location()
|
|
304
|
+
return (
|
|
305
|
+
_cache_failure_message(cache.path)
|
|
306
|
+
if _looks_like_cache_git_failure(exc, cache.path)
|
|
307
|
+
else _PROCESS_FAILURE_MESSAGE
|
|
308
|
+
)
|
|
159
309
|
|
|
160
310
|
text = str(exc)
|
|
161
311
|
lowered = text.lower()
|
|
@@ -66,6 +66,9 @@ def _isolated_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
|
66
66
|
_clean_forge_env, extended to also isolate the file path.
|
|
67
67
|
"""
|
|
68
68
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
69
|
+
# doctor now probes Copier's cache directory; keep that off the real
|
|
70
|
+
# user cache the same way config is kept off the real config file.
|
|
71
|
+
monkeypatch.setenv("COPIER_CACHE_DIR", str(tmp_path / "copier-cache"))
|
|
69
72
|
for field in UserConfig.model_fields:
|
|
70
73
|
monkeypatch.delenv(f"FORGE_{field.upper()}", raising=False)
|
|
71
74
|
return config_path()
|
|
@@ -235,6 +238,56 @@ def test_doctor_json_exits_1_when_a_check_fails(
|
|
|
235
238
|
assert registry_check["ok"] is False
|
|
236
239
|
|
|
237
240
|
|
|
241
|
+
def test_doctor_reports_the_copier_cache_and_uv(
|
|
242
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
243
|
+
) -> None:
|
|
244
|
+
"""ADR 0039 / docs/engine-resolution.md diagnostics contract: `doctor`
|
|
245
|
+
names Copier's cache directory, whether COPIER_CACHE_DIR overrides it, and
|
|
246
|
+
whether it is writable, plus the `uv` binary it would actually run.
|
|
247
|
+
"""
|
|
248
|
+
monkeypatch.setattr(cli_module, "_git_config", lambda _key: "test")
|
|
249
|
+
override = tmp_path / "cache dir"
|
|
250
|
+
override.mkdir()
|
|
251
|
+
monkeypatch.setenv("COPIER_CACHE_DIR", str(override))
|
|
252
|
+
|
|
253
|
+
result = runner.invoke(app, ["doctor", "--json"])
|
|
254
|
+
|
|
255
|
+
assert result.exit_code == 0, result.output
|
|
256
|
+
payload = json.loads(result.output)
|
|
257
|
+
assert payload["copier_cache"] == {
|
|
258
|
+
"path": str(override),
|
|
259
|
+
"override": True,
|
|
260
|
+
"exists": True,
|
|
261
|
+
"writable": True,
|
|
262
|
+
}
|
|
263
|
+
assert set(payload["uv"]) == {"path", "version", "package"}
|
|
264
|
+
assert "copier cache" in result.output # informational row in the table too
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_doctor_fails_when_the_copier_cache_is_unwritable(
|
|
268
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""An unwritable cache means the environment genuinely cannot scaffold, so
|
|
271
|
+
the check fails and `doctor` exits 1 -- the schema stays additive, only
|
|
272
|
+
the boolean flips.
|
|
273
|
+
"""
|
|
274
|
+
monkeypatch.setattr(cli_module, "_git_config", lambda _key: "test")
|
|
275
|
+
monkeypatch.setattr(
|
|
276
|
+
cli_module,
|
|
277
|
+
"cache_probe",
|
|
278
|
+
lambda _location: runner_module.CacheProbe(exists=True, writable=False),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
result = runner.invoke(app, ["doctor", "--json"])
|
|
282
|
+
|
|
283
|
+
assert result.exit_code == 1
|
|
284
|
+
payload = json.loads(result.output)
|
|
285
|
+
assert payload["ok"] is False
|
|
286
|
+
assert payload["copier_cache"]["writable"] is False
|
|
287
|
+
check = next(c for c in payload["checks"] if c["name"] == "copier cache writable")
|
|
288
|
+
assert check["ok"] is False
|
|
289
|
+
|
|
290
|
+
|
|
238
291
|
def test_doctor_survives_a_console_that_cannot_encode_check_marks(
|
|
239
292
|
monkeypatch: pytest.MonkeyPatch,
|
|
240
293
|
) -> None:
|