code-standards 7.0.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.
- code_standards-7.0.0.dist-info/METADATA +53 -0
- code_standards-7.0.0.dist-info/RECORD +99 -0
- code_standards-7.0.0.dist-info/WHEEL +4 -0
- code_standards-7.0.0.dist-info/entry_points.txt +3 -0
- code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
- sarj_standards/__init__.py +30 -0
- sarj_standards/__main__.py +5 -0
- sarj_standards/_meta.py +22 -0
- sarj_standards/api.py +890 -0
- sarj_standards/cli/__init__.py +0 -0
- sarj_standards/cli/main.py +2466 -0
- sarj_standards/configs/cli-reference.v1.json +1 -0
- sarj_standards/configs/doctor.config.json +22 -0
- sarj_standards/configs/eslint.application.mjs +1366 -0
- sarj_standards/configs/eslint.peers.json +44 -0
- sarj_standards/configs/eslint.strict.mjs +1060 -0
- sarj_standards/configs/markdownlint.strict.yaml +12 -0
- sarj_standards/configs/pyright.strict.json +96 -0
- sarj_standards/configs/ruff.application.toml +363 -0
- sarj_standards/configs/ruff.strict.toml +338 -0
- sarj_standards/configs/rule-inventory.v1.json +1 -0
- sarj_standards/configs/rule-ledger.json +846 -0
- sarj_standards/configs/rule-warning-levels.v1.json +1 -0
- sarj_standards/configs/taplo.strict.toml +14 -0
- sarj_standards/configs/yamllint.strict.yaml +25 -0
- sarj_standards/libs/__init__.py +0 -0
- sarj_standards/libs/adoption/__init__.py +0 -0
- sarj_standards/libs/adoption/configs.py +36 -0
- sarj_standards/libs/adoption/doctor.py +1346 -0
- sarj_standards/libs/adoption/exclusions.py +66 -0
- sarj_standards/libs/adoption/hooks.py +423 -0
- sarj_standards/libs/adoption/launcher.py +240 -0
- sarj_standards/libs/adoption/lifecycle.py +493 -0
- sarj_standards/libs/adoption/manifest.py +550 -0
- sarj_standards/libs/adoption/packagemanager.py +285 -0
- sarj_standards/libs/adoption/retired_suppressions.py +371 -0
- sarj_standards/libs/adoption/scaffold.py +1660 -0
- sarj_standards/libs/adoption/service.py +441 -0
- sarj_standards/libs/adoption/transaction.py +274 -0
- sarj_standards/libs/adoption/upgrade.py +516 -0
- sarj_standards/libs/adoption/uvtool.py +62 -0
- sarj_standards/libs/catalogs/__init__.py +9 -0
- sarj_standards/libs/catalogs/slack_automations.py +627 -0
- sarj_standards/libs/corpus/__init__.py +25 -0
- sarj_standards/libs/corpus/manifest.py +211 -0
- sarj_standards/libs/corpus/snapshot.py +222 -0
- sarj_standards/libs/diagnostics/__init__.py +65 -0
- sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
- sarj_standards/libs/diagnostics/baseline.py +131 -0
- sarj_standards/libs/diagnostics/models.py +574 -0
- sarj_standards/libs/diagnostics/serialize.py +290 -0
- sarj_standards/libs/diagnostics/source.py +172 -0
- sarj_standards/libs/filesystem.py +11 -0
- sarj_standards/libs/linting/__init__.py +0 -0
- sarj_standards/libs/linting/analysis.py +422 -0
- sarj_standards/libs/linting/external.py +1454 -0
- sarj_standards/libs/linting/library_policy.py +688 -0
- sarj_standards/libs/linting/policy.py +152 -0
- sarj_standards/libs/linting/runner.py +442 -0
- sarj_standards/libs/linting/textlint.py +1605 -0
- sarj_standards/libs/release/__init__.py +98 -0
- sarj_standards/libs/release/_values.py +24 -0
- sarj_standards/libs/release/artifacts.py +191 -0
- sarj_standards/libs/release/causality.py +80 -0
- sarj_standards/libs/release/changes.py +48 -0
- sarj_standards/libs/release/process.py +128 -0
- sarj_standards/libs/release/publish.py +85 -0
- sarj_standards/libs/release/registry.py +271 -0
- sarj_standards/libs/release/release_age.py +218 -0
- sarj_standards/libs/release/rollout.py +1163 -0
- sarj_standards/libs/release/tags.py +373 -0
- sarj_standards/libs/release/typescript.py +191 -0
- sarj_standards/libs/repository/__init__.py +0 -0
- sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
- sarj_standards/libs/repository/comment_corpus.py +536 -0
- sarj_standards/libs/repository/config_generation.py +146 -0
- sarj_standards/libs/repository/docs.py +347 -0
- sarj_standards/libs/repository/hooks.py +118 -0
- sarj_standards/libs/repository/ledger.py +99 -0
- sarj_standards/libs/repository/repository.py +744 -0
- sarj_standards/libs/repository/rule_authoring.py +246 -0
- sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
- sarj_standards/libs/repository/rule_changes.py +318 -0
- sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
- sarj_standards/libs/repository/rule_lifecycle.py +167 -0
- sarj_standards/libs/repository/rule_maintenance.py +225 -0
- sarj_standards/libs/rules/__init__.py +74 -0
- sarj_standards/libs/rules/catalog.py +145 -0
- sarj_standards/libs/rules/contracts.py +382 -0
- sarj_standards/libs/rules/corpus_runner.py +365 -0
- sarj_standards/libs/rules/evaluation.py +177 -0
- sarj_standards/libs/setup/__init__.py +4 -0
- sarj_standards/libs/setup/repository.py +40 -0
- sarj_standards/py.typed +0 -0
- sarj_standards/schemas/__init__.py +4 -0
- sarj_standards/schemas/_paths.py +7 -0
- sarj_standards/schemas/rule-catalog.v1.json +1 -0
- sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
- sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import tomllib
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import TYPE_CHECKING, Final
|
|
10
|
+
|
|
11
|
+
from packaging.version import Version
|
|
12
|
+
|
|
13
|
+
from sarj_standards._meta import CONFIGS_DIR
|
|
14
|
+
from sarj_standards.libs.filesystem import is_link_like
|
|
15
|
+
|
|
16
|
+
from . import doctor, hooks, lifecycle, manifest, packagemanager, retired_suppressions, scaffold, transaction, uvtool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_BUNDLE_LINE = re.compile(r'(?m)^bundle\s*=\s*"[^"]*"\s*$')
|
|
24
|
+
_INSTALL_REMEDIABLE_FINDING_IDS = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"doctor.eslint.override",
|
|
27
|
+
"doctor.eslint.peer",
|
|
28
|
+
"doctor.python.legacy-in-project-tool",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
_MANUAL_POSTFLIGHT_FINDING_IDS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"doctor.eslint.shadowed-config",
|
|
34
|
+
"doctor.ci.gate",
|
|
35
|
+
"doctor.precommit.rev",
|
|
36
|
+
"doctor.pyright.deprecated",
|
|
37
|
+
"doctor.ruff.authority",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_INSTALL_MUTATED_NAMES: Final = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"bun.lock",
|
|
45
|
+
"bun.lockb",
|
|
46
|
+
"package-lock.json",
|
|
47
|
+
"package.json",
|
|
48
|
+
"pnpm-lock.yaml",
|
|
49
|
+
"pyproject.toml",
|
|
50
|
+
"uv.lock",
|
|
51
|
+
"yarn.lock",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
_CONFIG_SOURCES = MappingProxyType(
|
|
55
|
+
{
|
|
56
|
+
"ruff": ("ruff.strict.toml", "ruff.application.toml", ".ruff-strict.toml", "python"),
|
|
57
|
+
"pyright": ("pyright.strict.json", "pyright.strict.json", ".pyright-strict.json", "python"),
|
|
58
|
+
"eslint": ("eslint.strict.mjs", "eslint.application.mjs", "eslint.strict.mjs", "typescript"),
|
|
59
|
+
"markdownlint": ("markdownlint.strict.yaml", "markdownlint.strict.yaml", ".markdownlint.yaml", "root"),
|
|
60
|
+
"taplo": ("taplo.strict.toml", "taplo.strict.toml", ".taplo.toml", "root"),
|
|
61
|
+
"yamllint": ("yamllint.strict.yaml", "yamllint.strict.yaml", ".yamllint.yaml", "root"),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
_MIRROR_EXCLUDED_PARTS: Final = frozenset({"example", "examples", "fixture", "fixtures", "test", "tests"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Change:
|
|
69
|
+
path: Path
|
|
70
|
+
reason: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class UpgradePlan:
|
|
75
|
+
root: Path
|
|
76
|
+
adopted: manifest.Manifest
|
|
77
|
+
ecosystems: scaffold.Ecosystems
|
|
78
|
+
scaffold_plan: scaffold.Plan
|
|
79
|
+
changes: list[Change]
|
|
80
|
+
config_writes: list[tuple[Path, Path]]
|
|
81
|
+
pin_writes: list[tuple[Path, str]]
|
|
82
|
+
lockfiles: tuple[Path, ...]
|
|
83
|
+
javascript_install_roots: tuple[Path, ...]
|
|
84
|
+
javascript_lockfiles: tuple[Path, ...]
|
|
85
|
+
suppression_writes: list[tuple[Path, str]]
|
|
86
|
+
manifest_text: str
|
|
87
|
+
preconditions: dict[Path, bytes | None]
|
|
88
|
+
preexisting_drift: frozenset[tuple[str, str]]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_plan(root: Path) -> UpgradePlan: # ruff: ignore[too-many-locals] -- one plan resolves every owned site once
|
|
92
|
+
root = root.resolve()
|
|
93
|
+
if not root.is_dir():
|
|
94
|
+
msg = f"repository root {root} is not a directory"
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
adopted = manifest.load(root)
|
|
97
|
+
if adopted is None:
|
|
98
|
+
msg = "repository is not adopted; run `code-standards setup` first"
|
|
99
|
+
raise ValueError(msg)
|
|
100
|
+
executing_version = Version(manifest.adopted_version())
|
|
101
|
+
declared_version = Version(adopted.version)
|
|
102
|
+
if declared_version > executing_version:
|
|
103
|
+
msg = (
|
|
104
|
+
f"repository uses newer standards {adopted.version}; executing bundle is "
|
|
105
|
+
f"{manifest.adopted_version()}. Install the newer code-standards release and rerun update"
|
|
106
|
+
)
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
path = manifest.manifest_path(root)
|
|
109
|
+
current_text = path.read_text(encoding="utf-8")
|
|
110
|
+
parsed: object = tomllib.loads(current_text)
|
|
111
|
+
hooks_table = manifest.table_field(manifest.as_table(parsed), "hooks")
|
|
112
|
+
hook_manager = adopted.hook_manager if "manager" in hooks_table else hooks.detect_manager(root)
|
|
113
|
+
adopted = replace(adopted, hook_manager=hook_manager)
|
|
114
|
+
detected_ecosystems = scaffold.detect(
|
|
115
|
+
root,
|
|
116
|
+
python_dest=adopted.python_dest if any(name in adopted.configs for name in manifest.PYTHON_CONFIGS) else None,
|
|
117
|
+
typescript_dest=(
|
|
118
|
+
adopted.typescript_dest if any(name in adopted.configs for name in manifest.TYPESCRIPT_CONFIGS) else None
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
scaffold_plan = scaffold.build_plan(
|
|
122
|
+
root,
|
|
123
|
+
force=False,
|
|
124
|
+
configs=adopted.configs,
|
|
125
|
+
python_dest=adopted.python_dest if detected_ecosystems.python else None,
|
|
126
|
+
typescript_dest=adopted.typescript_dest if detected_ecosystems.typescript else None,
|
|
127
|
+
profile=adopted.profile,
|
|
128
|
+
hook_manager=adopted.hook_manager,
|
|
129
|
+
allow_existing_nested_eslint=True,
|
|
130
|
+
)
|
|
131
|
+
if scaffold_plan.errors:
|
|
132
|
+
raise ValueError("; ".join(scaffold_plan.errors))
|
|
133
|
+
manifest_target = manifest.manifest_path(root)
|
|
134
|
+
scaffold_plan.writes = [(path, contents) for path, contents in scaffold_plan.writes if path != manifest_target]
|
|
135
|
+
ecosystems = _install_ecosystems(detected_ecosystems, adopted.configs)
|
|
136
|
+
|
|
137
|
+
installed = manifest.installed_versions()
|
|
138
|
+
pin_updates = doctor.plan_version_pin_updates(root, installed)
|
|
139
|
+
# Compose pin migrations into scaffold rewrites of the same file.
|
|
140
|
+
scaffold_plan.writes = [
|
|
141
|
+
(path, doctor.rewrite_version_pins(contents, installed)[0]) for path, contents in scaffold_plan.writes
|
|
142
|
+
]
|
|
143
|
+
scaffold_write_paths = {path for path, _contents in scaffold_plan.writes}
|
|
144
|
+
pin_writes = [(update.path, update.contents) for update in pin_updates if update.path not in scaffold_write_paths]
|
|
145
|
+
lockfile_candidates: set[Path] = set()
|
|
146
|
+
for update in pin_updates:
|
|
147
|
+
sibling_lock = update.path.with_name("uv.lock")
|
|
148
|
+
if update.path.name == "pyproject.toml" and sibling_lock.is_file():
|
|
149
|
+
lockfile_candidates.add(sibling_lock)
|
|
150
|
+
lockfiles = tuple(sorted(lockfile_candidates))
|
|
151
|
+
primary_javascript_root = ecosystems.typescript_install_root or ecosystems.typescript_root
|
|
152
|
+
javascript_install_roots = tuple(
|
|
153
|
+
sorted(
|
|
154
|
+
{
|
|
155
|
+
install_root
|
|
156
|
+
for update in pin_updates
|
|
157
|
+
if update.path.name == "package.json"
|
|
158
|
+
and (install_root := packagemanager.workspace_root(update.path.parent, root)) != primary_javascript_root
|
|
159
|
+
and any((install_root / name).is_file() for name, _manager in packagemanager.LOCKFILES)
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
javascript_lockfiles = tuple(
|
|
164
|
+
sorted(
|
|
165
|
+
lockfile
|
|
166
|
+
for install_root in javascript_install_roots
|
|
167
|
+
for name, _manager in packagemanager.LOCKFILES
|
|
168
|
+
if (lockfile := install_root / name).is_file()
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if not _BUNDLE_LINE.search(current_text):
|
|
173
|
+
msg = f"{path} has no replaceable top-level bundle field"
|
|
174
|
+
raise ValueError(msg)
|
|
175
|
+
manifest_text = _BUNDLE_LINE.sub(f'bundle = "{manifest.adopted_version()}"', current_text, count=1)
|
|
176
|
+
if "manager" not in hooks_table:
|
|
177
|
+
separator = "" if manifest_text.endswith("\n\n") else "\n"
|
|
178
|
+
manifest_text += f'{separator}[hooks]\nmanager = "{hook_manager}"\n'
|
|
179
|
+
changes: list[Change] = []
|
|
180
|
+
if manifest_text != current_text:
|
|
181
|
+
changes.append(Change(path, f"adopt standards {manifest.adopted_version()}"))
|
|
182
|
+
|
|
183
|
+
destinations = {
|
|
184
|
+
"root": root,
|
|
185
|
+
"python": (root / adopted.python_dest).resolve(),
|
|
186
|
+
"typescript": (root / adopted.typescript_dest).resolve(),
|
|
187
|
+
}
|
|
188
|
+
config_writes: list[tuple[Path, Path]] = []
|
|
189
|
+
for name in adopted.configs:
|
|
190
|
+
spec = _CONFIG_SOURCES.get(name)
|
|
191
|
+
if spec is None:
|
|
192
|
+
msg = f"manifest declares unknown config {name!r}"
|
|
193
|
+
raise ValueError(msg)
|
|
194
|
+
standard, application, target_name, kind = spec
|
|
195
|
+
destination = destinations[kind]
|
|
196
|
+
try:
|
|
197
|
+
destination.relative_to(root.resolve())
|
|
198
|
+
except ValueError as exc:
|
|
199
|
+
msg = f"manifest destination for {name} escapes repository root"
|
|
200
|
+
raise ValueError(msg) from exc
|
|
201
|
+
source = CONFIGS_DIR / (application if adopted.profile == "application" else standard)
|
|
202
|
+
target = destination / target_name
|
|
203
|
+
if not target.is_file() or target.read_bytes() != source.read_bytes():
|
|
204
|
+
targets = (target, *_identical_config_mirrors(root, target))
|
|
205
|
+
for config_target in targets:
|
|
206
|
+
changes.append(Change(config_target, f"sync {name} config"))
|
|
207
|
+
config_writes.append((source, config_target))
|
|
208
|
+
|
|
209
|
+
reserved_paths = {
|
|
210
|
+
path,
|
|
211
|
+
*(target for _source, target in config_writes),
|
|
212
|
+
*(target for target, _contents in pin_writes),
|
|
213
|
+
*(target for target, _contents in (*scaffold_plan.writes, *scaffold_plan.edits)),
|
|
214
|
+
*scaffold_plan.deletes,
|
|
215
|
+
}
|
|
216
|
+
suppression_writes = [
|
|
217
|
+
(rewrite.path, rewrite.contents)
|
|
218
|
+
for rewrite in retired_suppressions.plan(doctor.authored_files(root))
|
|
219
|
+
if rewrite.path not in reserved_paths
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
for target, _contents in (*scaffold_plan.writes, *scaffold_plan.edits):
|
|
223
|
+
if target != path:
|
|
224
|
+
changes.append(Change(target, "repair adoption wiring"))
|
|
225
|
+
changes.extend(Change(target, "remove retired repository launcher") for target in scaffold_plan.deletes)
|
|
226
|
+
changes.extend(Change(update.path, f"refresh {'/'.join(update.packages)} version pin") for update in pin_updates)
|
|
227
|
+
changes.extend(Change(lockfile, "refresh Python lockfile") for lockfile in lockfiles)
|
|
228
|
+
changes.extend(Change(lockfile, "refresh JavaScript lockfile") for lockfile in javascript_lockfiles)
|
|
229
|
+
changes.extend(Change(path, "migrate retired source suppression") for path, _contents in suppression_writes)
|
|
230
|
+
planned_paths = tuple(
|
|
231
|
+
dict.fromkeys(
|
|
232
|
+
[path]
|
|
233
|
+
+ [target for _source, target in config_writes]
|
|
234
|
+
+ [target for target, _contents in pin_writes]
|
|
235
|
+
+ list(lockfiles)
|
|
236
|
+
+ list(javascript_lockfiles)
|
|
237
|
+
+ [target for target, _contents in suppression_writes]
|
|
238
|
+
+ [target for target, _contents in (*scaffold_plan.writes, *scaffold_plan.edits)]
|
|
239
|
+
+ list(scaffold_plan.deletes)
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
transaction.validate_targets(root, planned_paths)
|
|
243
|
+
preconditions = {target: target.read_bytes() if target.is_file() else None for target in planned_paths}
|
|
244
|
+
preexisting_drift = frozenset(
|
|
245
|
+
(finding.id, finding.where) for finding in doctor.diagnose(root) if finding.level is doctor.Level.DRIFT
|
|
246
|
+
)
|
|
247
|
+
return UpgradePlan(
|
|
248
|
+
root,
|
|
249
|
+
adopted,
|
|
250
|
+
ecosystems,
|
|
251
|
+
scaffold_plan,
|
|
252
|
+
changes,
|
|
253
|
+
config_writes,
|
|
254
|
+
pin_writes,
|
|
255
|
+
lockfiles,
|
|
256
|
+
javascript_install_roots,
|
|
257
|
+
javascript_lockfiles,
|
|
258
|
+
suppression_writes,
|
|
259
|
+
manifest_text,
|
|
260
|
+
preconditions,
|
|
261
|
+
preexisting_drift,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _identical_config_mirrors(root: Path, target: Path) -> tuple[Path, ...]:
|
|
266
|
+
if not target.is_file() or is_link_like(target):
|
|
267
|
+
return ()
|
|
268
|
+
expected = target.read_bytes()
|
|
269
|
+
return tuple(
|
|
270
|
+
path
|
|
271
|
+
for path in doctor.authored_files(root)
|
|
272
|
+
if path != target
|
|
273
|
+
and path.name == target.name
|
|
274
|
+
and not any(part.lower() in _MIRROR_EXCLUDED_PARTS for part in path.relative_to(root).parts)
|
|
275
|
+
and path.read_bytes() == expected
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _install_ecosystems(ecosystems: scaffold.Ecosystems, configs: Sequence[str]) -> scaffold.Ecosystems:
|
|
280
|
+
python = ecosystems.python and any(name in manifest.PYTHON_CONFIGS for name in configs)
|
|
281
|
+
typescript = ecosystems.typescript and any(name in manifest.TYPESCRIPT_CONFIGS for name in configs)
|
|
282
|
+
return scaffold.Ecosystems(
|
|
283
|
+
python=python,
|
|
284
|
+
typescript=typescript,
|
|
285
|
+
python_root=ecosystems.python_root if python else None,
|
|
286
|
+
typescript_root=ecosystems.typescript_root if typescript else None,
|
|
287
|
+
typescript_install_root=ecosystems.typescript_install_root if typescript else None,
|
|
288
|
+
client=ecosystems.client,
|
|
289
|
+
yarn=ecosystems.yarn,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def unsafe_retired_findings(plan: UpgradePlan) -> list[doctor.Finding]:
|
|
294
|
+
replaced = [target for _source, target in plan.config_writes]
|
|
295
|
+
owned = {target.relative_to(plan.root).as_posix() for target in replaced}
|
|
296
|
+
planned = {path.relative_to(plan.root).as_posix(): (path, contents) for path, contents in plan.suppression_writes}
|
|
297
|
+
projected = {
|
|
298
|
+
path.relative_to(plan.root).as_posix(): (path, contents) for path, contents in plan.scaffold_plan.writes
|
|
299
|
+
}
|
|
300
|
+
blockers: list[doctor.Finding] = []
|
|
301
|
+
for finding in doctor.diagnose(plan.root):
|
|
302
|
+
if finding.id != "doctor.rule.retired" or finding.level is not doctor.Level.DRIFT:
|
|
303
|
+
continue
|
|
304
|
+
relative, _, reference = finding.where.partition(": ")
|
|
305
|
+
if relative in owned:
|
|
306
|
+
continue
|
|
307
|
+
rewrite = planned.get(relative)
|
|
308
|
+
retired_id = reference.rsplit(" x", maxsplit=1)[0]
|
|
309
|
+
if rewrite is not None and retired_id not in doctor.retired_rule_references(*rewrite):
|
|
310
|
+
continue
|
|
311
|
+
scaffold_write = projected.get(relative)
|
|
312
|
+
if scaffold_write is not None and retired_id not in doctor.retired_rule_references(*scaffold_write):
|
|
313
|
+
continue
|
|
314
|
+
blockers.append(finding)
|
|
315
|
+
return blockers
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def apply(
|
|
319
|
+
plan: UpgradePlan,
|
|
320
|
+
*,
|
|
321
|
+
install: bool = True,
|
|
322
|
+
allow_retired_debt: bool = False,
|
|
323
|
+
) -> int:
|
|
324
|
+
if Version(plan.adopted.version) > Version(manifest.adopted_version()):
|
|
325
|
+
return 2
|
|
326
|
+
blockers = unsafe_retired_findings(plan)
|
|
327
|
+
if blockers and not allow_retired_debt:
|
|
328
|
+
return 2
|
|
329
|
+
try:
|
|
330
|
+
transaction.validate_targets(plan.root, tuple(plan.preconditions))
|
|
331
|
+
stale = any(
|
|
332
|
+
(path.read_bytes() if path.is_file() else None) != expected for path, expected in plan.preconditions.items()
|
|
333
|
+
)
|
|
334
|
+
except OSError:
|
|
335
|
+
return 2
|
|
336
|
+
if stale:
|
|
337
|
+
return 2
|
|
338
|
+
|
|
339
|
+
paths = tuple(
|
|
340
|
+
[manifest.manifest_path(plan.root)]
|
|
341
|
+
+ [target for _source, target in plan.config_writes]
|
|
342
|
+
+ [path for path, _contents in plan.pin_writes]
|
|
343
|
+
+ list(plan.lockfiles)
|
|
344
|
+
+ list(plan.javascript_lockfiles)
|
|
345
|
+
+ [path for path, _contents in plan.suppression_writes]
|
|
346
|
+
+ [path for path, _contents in (*plan.scaffold_plan.writes, *plan.scaffold_plan.edits)]
|
|
347
|
+
+ list(plan.scaffold_plan.deletes)
|
|
348
|
+
)
|
|
349
|
+
file_transaction = transaction.FileTransaction.capture(plan.root, paths)
|
|
350
|
+
environment = None if plan.ecosystems.python_root is None else plan.ecosystems.python_root / ".venv"
|
|
351
|
+
environment_existed = environment is not None and environment.exists()
|
|
352
|
+
try:
|
|
353
|
+
status = _apply_and_validate(
|
|
354
|
+
plan,
|
|
355
|
+
file_transaction,
|
|
356
|
+
install=install,
|
|
357
|
+
allow_retired_debt=allow_retired_debt,
|
|
358
|
+
)
|
|
359
|
+
except KeyboardInterrupt:
|
|
360
|
+
_recover_or_raise(file_transaction, environment, environment_existed=environment_existed)
|
|
361
|
+
return 130
|
|
362
|
+
except OSError, TypeError, ValueError:
|
|
363
|
+
_recover_or_raise(file_transaction, environment, environment_existed=environment_existed)
|
|
364
|
+
return 2
|
|
365
|
+
if status:
|
|
366
|
+
_recover_or_raise(file_transaction, environment, environment_existed=environment_existed)
|
|
367
|
+
return status
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _recover_or_raise(
|
|
371
|
+
file_transaction: transaction.FileTransaction,
|
|
372
|
+
environment: Path | None,
|
|
373
|
+
*,
|
|
374
|
+
environment_existed: bool,
|
|
375
|
+
) -> None:
|
|
376
|
+
errors = tuple(
|
|
377
|
+
detail
|
|
378
|
+
for detail in (
|
|
379
|
+
file_transaction.rollback().render(),
|
|
380
|
+
None
|
|
381
|
+
if _cleanup_new_environment(environment, existed=environment_existed)
|
|
382
|
+
else "could not remove new environment",
|
|
383
|
+
)
|
|
384
|
+
if detail
|
|
385
|
+
)
|
|
386
|
+
if errors:
|
|
387
|
+
raise OSError("upgrade recovery incomplete: " + "; ".join(errors))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _cleanup_new_environment(environment: Path | None, *, existed: bool) -> bool:
|
|
391
|
+
if existed or environment is None or not environment.is_dir():
|
|
392
|
+
return True
|
|
393
|
+
try:
|
|
394
|
+
shutil.rmtree(environment)
|
|
395
|
+
except OSError:
|
|
396
|
+
return False
|
|
397
|
+
return True
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _apply_and_validate(
|
|
401
|
+
plan: UpgradePlan,
|
|
402
|
+
file_transaction: transaction.FileTransaction,
|
|
403
|
+
*,
|
|
404
|
+
install: bool,
|
|
405
|
+
allow_retired_debt: bool,
|
|
406
|
+
) -> int:
|
|
407
|
+
_write_plan(plan, file_transaction)
|
|
408
|
+
if install:
|
|
409
|
+
status = lifecycle.execute(
|
|
410
|
+
[
|
|
411
|
+
*(
|
|
412
|
+
lifecycle.Command("Python lockfile", uvtool.lock_argv(lockfile.parent), lockfile.parent)
|
|
413
|
+
for lockfile in plan.lockfiles
|
|
414
|
+
),
|
|
415
|
+
*(
|
|
416
|
+
lifecycle.Command(
|
|
417
|
+
"JavaScript lockfile",
|
|
418
|
+
packagemanager.install_argv(
|
|
419
|
+
(manager := packagemanager.detect(install_root)),
|
|
420
|
+
workspace=(
|
|
421
|
+
manager is packagemanager.PackageManager.PNPM
|
|
422
|
+
or (install_root / "pnpm-workspace.yaml").is_file()
|
|
423
|
+
),
|
|
424
|
+
yarn=packagemanager.yarn_variant(install_root),
|
|
425
|
+
),
|
|
426
|
+
install_root,
|
|
427
|
+
)
|
|
428
|
+
for install_root in plan.javascript_install_roots
|
|
429
|
+
),
|
|
430
|
+
*lifecycle.install_commands(
|
|
431
|
+
plan.root,
|
|
432
|
+
plan.ecosystems,
|
|
433
|
+
hook_manager=plan.adopted.hook_manager,
|
|
434
|
+
),
|
|
435
|
+
]
|
|
436
|
+
)
|
|
437
|
+
_mark_installer_writes(file_transaction)
|
|
438
|
+
if status:
|
|
439
|
+
return status
|
|
440
|
+
findings = doctor.diagnose(plan.root)
|
|
441
|
+
drifted = [finding for finding in findings if finding.level is doctor.Level.DRIFT]
|
|
442
|
+
if not install:
|
|
443
|
+
drifted = [finding for finding in drifted if not is_install_remediable(finding)]
|
|
444
|
+
if not allow_retired_debt and any(finding.id == "doctor.rule.retired" for finding in drifted):
|
|
445
|
+
return 1
|
|
446
|
+
if allow_retired_debt:
|
|
447
|
+
drifted = [finding for finding in drifted if finding.id != "doctor.rule.retired"]
|
|
448
|
+
drifted = [finding for finding in drifted if (finding.id, finding.where) not in plan.preexisting_drift]
|
|
449
|
+
drifted = [finding for finding in drifted if finding.id not in _MANUAL_POSTFLIGHT_FINDING_IDS]
|
|
450
|
+
return 1 if drifted else 0
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def changes_bundle_version(plan: UpgradePlan) -> bool:
|
|
454
|
+
return Version(plan.adopted.version) < Version(manifest.adopted_version())
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def is_install_remediable(finding: doctor.Finding) -> bool:
|
|
458
|
+
return finding.id in _INSTALL_REMEDIABLE_FINDING_IDS
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def pending_install_findings(root: Path) -> list[doctor.Finding]:
|
|
462
|
+
return [
|
|
463
|
+
finding
|
|
464
|
+
for finding in doctor.diagnose(root)
|
|
465
|
+
if finding.level is doctor.Level.DRIFT and is_install_remediable(finding)
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _write_plan(plan: UpgradePlan, file_transaction: transaction.FileTransaction) -> None:
|
|
470
|
+
transaction.validate_targets(
|
|
471
|
+
plan.root,
|
|
472
|
+
tuple(
|
|
473
|
+
[manifest.manifest_path(plan.root)]
|
|
474
|
+
+ [target for _source, target in plan.config_writes]
|
|
475
|
+
+ [path for path, _contents in plan.pin_writes]
|
|
476
|
+
+ [path for path, _contents in plan.suppression_writes]
|
|
477
|
+
+ [path for path, _contents in (*plan.scaffold_plan.writes, *plan.scaffold_plan.edits)]
|
|
478
|
+
+ list(plan.scaffold_plan.deletes)
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
manifest_target = manifest.manifest_path(plan.root)
|
|
482
|
+
transaction.assert_expected(plan.root, manifest_target, plan.preconditions[manifest_target])
|
|
483
|
+
transaction.atomic_write_text(plan.root, manifest_target, plan.manifest_text)
|
|
484
|
+
_mark_direct_write(file_transaction, manifest_target)
|
|
485
|
+
for source, target in plan.config_writes:
|
|
486
|
+
if is_link_like(target) or (target.exists() and not target.is_file()):
|
|
487
|
+
msg = f"refusing unsafe generated-config target {target}"
|
|
488
|
+
raise OSError(msg)
|
|
489
|
+
transaction.assert_expected(plan.root, target, plan.preconditions[target])
|
|
490
|
+
transaction.atomic_write_bytes(plan.root, target, source.read_bytes())
|
|
491
|
+
_mark_direct_write(file_transaction, target)
|
|
492
|
+
for target, contents in plan.pin_writes:
|
|
493
|
+
transaction.assert_expected(plan.root, target, plan.preconditions[target])
|
|
494
|
+
transaction.atomic_write_text(plan.root, target, contents)
|
|
495
|
+
_mark_direct_write(file_transaction, target)
|
|
496
|
+
for target, contents in plan.suppression_writes:
|
|
497
|
+
transaction.assert_expected(plan.root, target, plan.preconditions[target])
|
|
498
|
+
transaction.atomic_write_text(plan.root, target, contents)
|
|
499
|
+
_mark_direct_write(file_transaction, target)
|
|
500
|
+
scaffold.apply(plan.scaffold_plan, preconditions=plan.preconditions)
|
|
501
|
+
for target, _contents in (*plan.scaffold_plan.writes, *plan.scaffold_plan.edits):
|
|
502
|
+
_mark_direct_write(file_transaction, target)
|
|
503
|
+
for target in plan.scaffold_plan.deletes:
|
|
504
|
+
_mark_direct_write(file_transaction, target)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _mark_direct_write(file_transaction: transaction.FileTransaction, path: Path) -> None:
|
|
508
|
+
file_transaction.mark_written(path)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _mark_installer_writes(file_transaction: transaction.FileTransaction) -> None:
|
|
512
|
+
file_transaction.mark_written(*(path for path in file_transaction.before if path.name in _INSTALL_MUTATED_NAMES))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def render(changes: Sequence[Change]) -> str:
|
|
516
|
+
return "\n".join(f"update: {change.path} -- {change.reason}" for change in changes)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from typing import TYPE_CHECKING, Final
|
|
5
|
+
|
|
6
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_UV_CONFIG: Final = "uv.toml"
|
|
14
|
+
_PYPROJECT: Final = "pyproject.toml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def version_file(project: Path | None) -> Path | None:
|
|
18
|
+
if project is None:
|
|
19
|
+
return None
|
|
20
|
+
uv_config = project / _UV_CONFIG
|
|
21
|
+
if required_version(uv_config) is not None:
|
|
22
|
+
return uv_config
|
|
23
|
+
pyproject = project / _PYPROJECT
|
|
24
|
+
return pyproject if required_version(pyproject) is not None else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def required_version(path: Path) -> str | None:
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
parsed: object = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
32
|
+
except OSError, tomllib.TOMLDecodeError:
|
|
33
|
+
return None
|
|
34
|
+
table = _table(parsed)
|
|
35
|
+
if path.name == _PYPROJECT:
|
|
36
|
+
table = _table(_table(table.get("tool")).get("uv"))
|
|
37
|
+
value = table.get("required-version")
|
|
38
|
+
if not isinstance(value, str) or not value:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
_ = SpecifierSet(value)
|
|
42
|
+
except InvalidSpecifier:
|
|
43
|
+
return None
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _table(value: object) -> dict[str, object]:
|
|
48
|
+
if not isinstance(value, dict):
|
|
49
|
+
return {}
|
|
50
|
+
return {key: item for key, item in value.items() if isinstance(key, str)} # pyright: ignore[reportUnknownVariableType]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def argv(project: Path, *arguments: str) -> tuple[str, ...]:
|
|
54
|
+
source = version_file(project)
|
|
55
|
+
required = None if source is None else required_version(source)
|
|
56
|
+
if required is None:
|
|
57
|
+
return ("uv", *arguments)
|
|
58
|
+
return ("uvx", "--no-config", "--isolated", "--from", f"uv{required}", "uv", *arguments)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def lock_argv(project: Path) -> tuple[str, ...]:
|
|
62
|
+
return argv(project, "lock")
|