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.
Files changed (99) hide show
  1. code_standards-7.0.0.dist-info/METADATA +53 -0
  2. code_standards-7.0.0.dist-info/RECORD +99 -0
  3. code_standards-7.0.0.dist-info/WHEEL +4 -0
  4. code_standards-7.0.0.dist-info/entry_points.txt +3 -0
  5. code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
  6. sarj_standards/__init__.py +30 -0
  7. sarj_standards/__main__.py +5 -0
  8. sarj_standards/_meta.py +22 -0
  9. sarj_standards/api.py +890 -0
  10. sarj_standards/cli/__init__.py +0 -0
  11. sarj_standards/cli/main.py +2466 -0
  12. sarj_standards/configs/cli-reference.v1.json +1 -0
  13. sarj_standards/configs/doctor.config.json +22 -0
  14. sarj_standards/configs/eslint.application.mjs +1366 -0
  15. sarj_standards/configs/eslint.peers.json +44 -0
  16. sarj_standards/configs/eslint.strict.mjs +1060 -0
  17. sarj_standards/configs/markdownlint.strict.yaml +12 -0
  18. sarj_standards/configs/pyright.strict.json +96 -0
  19. sarj_standards/configs/ruff.application.toml +363 -0
  20. sarj_standards/configs/ruff.strict.toml +338 -0
  21. sarj_standards/configs/rule-inventory.v1.json +1 -0
  22. sarj_standards/configs/rule-ledger.json +846 -0
  23. sarj_standards/configs/rule-warning-levels.v1.json +1 -0
  24. sarj_standards/configs/taplo.strict.toml +14 -0
  25. sarj_standards/configs/yamllint.strict.yaml +25 -0
  26. sarj_standards/libs/__init__.py +0 -0
  27. sarj_standards/libs/adoption/__init__.py +0 -0
  28. sarj_standards/libs/adoption/configs.py +36 -0
  29. sarj_standards/libs/adoption/doctor.py +1346 -0
  30. sarj_standards/libs/adoption/exclusions.py +66 -0
  31. sarj_standards/libs/adoption/hooks.py +423 -0
  32. sarj_standards/libs/adoption/launcher.py +240 -0
  33. sarj_standards/libs/adoption/lifecycle.py +493 -0
  34. sarj_standards/libs/adoption/manifest.py +550 -0
  35. sarj_standards/libs/adoption/packagemanager.py +285 -0
  36. sarj_standards/libs/adoption/retired_suppressions.py +371 -0
  37. sarj_standards/libs/adoption/scaffold.py +1660 -0
  38. sarj_standards/libs/adoption/service.py +441 -0
  39. sarj_standards/libs/adoption/transaction.py +274 -0
  40. sarj_standards/libs/adoption/upgrade.py +516 -0
  41. sarj_standards/libs/adoption/uvtool.py +62 -0
  42. sarj_standards/libs/catalogs/__init__.py +9 -0
  43. sarj_standards/libs/catalogs/slack_automations.py +627 -0
  44. sarj_standards/libs/corpus/__init__.py +25 -0
  45. sarj_standards/libs/corpus/manifest.py +211 -0
  46. sarj_standards/libs/corpus/snapshot.py +222 -0
  47. sarj_standards/libs/diagnostics/__init__.py +65 -0
  48. sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
  49. sarj_standards/libs/diagnostics/baseline.py +131 -0
  50. sarj_standards/libs/diagnostics/models.py +574 -0
  51. sarj_standards/libs/diagnostics/serialize.py +290 -0
  52. sarj_standards/libs/diagnostics/source.py +172 -0
  53. sarj_standards/libs/filesystem.py +11 -0
  54. sarj_standards/libs/linting/__init__.py +0 -0
  55. sarj_standards/libs/linting/analysis.py +422 -0
  56. sarj_standards/libs/linting/external.py +1454 -0
  57. sarj_standards/libs/linting/library_policy.py +688 -0
  58. sarj_standards/libs/linting/policy.py +152 -0
  59. sarj_standards/libs/linting/runner.py +442 -0
  60. sarj_standards/libs/linting/textlint.py +1605 -0
  61. sarj_standards/libs/release/__init__.py +98 -0
  62. sarj_standards/libs/release/_values.py +24 -0
  63. sarj_standards/libs/release/artifacts.py +191 -0
  64. sarj_standards/libs/release/causality.py +80 -0
  65. sarj_standards/libs/release/changes.py +48 -0
  66. sarj_standards/libs/release/process.py +128 -0
  67. sarj_standards/libs/release/publish.py +85 -0
  68. sarj_standards/libs/release/registry.py +271 -0
  69. sarj_standards/libs/release/release_age.py +218 -0
  70. sarj_standards/libs/release/rollout.py +1163 -0
  71. sarj_standards/libs/release/tags.py +373 -0
  72. sarj_standards/libs/release/typescript.py +191 -0
  73. sarj_standards/libs/repository/__init__.py +0 -0
  74. sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
  75. sarj_standards/libs/repository/comment_corpus.py +536 -0
  76. sarj_standards/libs/repository/config_generation.py +146 -0
  77. sarj_standards/libs/repository/docs.py +347 -0
  78. sarj_standards/libs/repository/hooks.py +118 -0
  79. sarj_standards/libs/repository/ledger.py +99 -0
  80. sarj_standards/libs/repository/repository.py +744 -0
  81. sarj_standards/libs/repository/rule_authoring.py +246 -0
  82. sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
  83. sarj_standards/libs/repository/rule_changes.py +318 -0
  84. sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
  85. sarj_standards/libs/repository/rule_lifecycle.py +167 -0
  86. sarj_standards/libs/repository/rule_maintenance.py +225 -0
  87. sarj_standards/libs/rules/__init__.py +74 -0
  88. sarj_standards/libs/rules/catalog.py +145 -0
  89. sarj_standards/libs/rules/contracts.py +382 -0
  90. sarj_standards/libs/rules/corpus_runner.py +365 -0
  91. sarj_standards/libs/rules/evaluation.py +177 -0
  92. sarj_standards/libs/setup/__init__.py +4 -0
  93. sarj_standards/libs/setup/repository.py +40 -0
  94. sarj_standards/py.typed +0 -0
  95. sarj_standards/schemas/__init__.py +4 -0
  96. sarj_standards/schemas/_paths.py +7 -0
  97. sarj_standards/schemas/rule-catalog.v1.json +1 -0
  98. sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
  99. 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")
@@ -0,0 +1,9 @@
1
+ from sarj_standards.libs.catalogs.slack_automations import (
2
+ CONVENTIONAL_PATH,
3
+ CatalogFinding,
4
+ render_schema,
5
+ validate_catalog,
6
+ )
7
+
8
+
9
+ __all__ = ("CONVENTIONAL_PATH", "CatalogFinding", "render_schema", "validate_catalog")