livery-extensions-python 0.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 (28) hide show
  1. livery/extensions/python/__init__.py +51 -0
  2. livery/extensions/python/_answers.py +127 -0
  3. livery/extensions/python/_backend.py +1532 -0
  4. livery/extensions/python/_declared.py +252 -0
  5. livery/extensions/python/_docs/index.md +165 -0
  6. livery/extensions/python/_docs/nav.toml +14 -0
  7. livery/extensions/python/_installed.py +219 -0
  8. livery/extensions/python/_parse.py +324 -0
  9. livery/extensions/python/_reaches.py +165 -0
  10. livery/extensions/python/_steps.py +71 -0
  11. livery/extensions/python/_terminal.py +118 -0
  12. livery/extensions/python/_verify.py +112 -0
  13. livery/extensions/python/content/templates/python-library/LICENSE.jinja +21 -0
  14. livery/extensions/python/content/templates/python-library/README.md.jinja +7 -0
  15. livery/extensions/python/content/templates/python-library/pyproject.toml.jinja +43 -0
  16. livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__init__.py.jinja +15 -0
  17. livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__main__.py.jinja +30 -0
  18. livery/extensions/python/content/templates/python-library/src/{{ source_path }}/py.typed +0 -0
  19. livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_cli.py.jinja +39 -0
  20. livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_package.py.jinja +22 -0
  21. livery/extensions/python/content/templates/python-library/workshop.toml.jinja +10 -0
  22. livery/extensions/python/extension.toml +165 -0
  23. livery/extensions/python/py.typed +0 -0
  24. livery_extensions_python-0.0.0.dist-info/METADATA +31 -0
  25. livery_extensions_python-0.0.0.dist-info/RECORD +28 -0
  26. livery_extensions_python-0.0.0.dist-info/WHEEL +4 -0
  27. livery_extensions_python-0.0.0.dist-info/entry_points.txt +3 -0
  28. livery_extensions_python-0.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1532 @@
1
+ """Build, version, publish and test a python distribution: the python extension's code.
2
+
3
+ The python extension's answers and steps call the functions here, so a
4
+ package whose set holds python builds, stamps, publishes and runs its
5
+ tests through them. The suites of every package a run judges run in
6
+ one pytest call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ import tomllib
18
+ from collections.abc import Callable, Generator, Iterable, Mapping
19
+ from contextlib import contextmanager
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any, cast
22
+
23
+ import livery.footman as footman
24
+ import livery.toolroom.tools as tools
25
+ from livery.footman import fail
26
+ from livery.toolroom.tools import pytest
27
+ from livery.workshop import Neighbours, Package, Query, SourceReferences
28
+
29
+ if TYPE_CHECKING:
30
+ from livery.workshop import RegistryTarget, SpeedMark
31
+
32
+ #: The name a package lists this extension by.
33
+ EXTENSION = "python"
34
+
35
+ #: How a pure wheel's file name ends: no ABI, any platform.
36
+ PURE_TAG = "-none-any.whl"
37
+
38
+
39
+ def public_modules(package: Package) -> tuple[str, ...]:
40
+ """The modules that declare *package*'s public API.
41
+
42
+ A root that is a regular package, or a single module, is its own
43
+ declaration. A public package beneath a root keeps its own import
44
+ path and is declared in the root's ``__init__`` as well, imported
45
+ for the checkers alone and listed in ``__all__``: the verifier
46
+ follows the declaration into it, reading the ``py.typed`` at the
47
+ distribution's root. A namespace root, one with no ``__init__``,
48
+ declares nothing; a package with no src tree is its distribution
49
+ name's module.
50
+ """
51
+ src = package.directory / "src"
52
+ if not src.is_dir():
53
+ return (module_for(package),)
54
+ modules: list[str] = []
55
+ for root in module_roots(package) or (module_for(package),):
56
+ directory = src.joinpath(*root.split("."))
57
+ if (directory / "__init__.py").is_file() or directory.with_suffix(
58
+ ".py"
59
+ ).is_file():
60
+ modules.append(root)
61
+ return tuple(modules)
62
+
63
+
64
+ def module_for(package: Package) -> str:
65
+ """The package's importable module, read from its src tree.
66
+
67
+ The single-directory chain under ``src/`` down to the first
68
+ directory carrying python files is the module
69
+ (``src/livery/forge`` is ``livery.forge``). Deriving it from the
70
+ distribution name guessed wrong the moment a member's own name
71
+ carried a hyphen: ``loop-echo`` under a workspace prefix became
72
+ ``loop.echo``, a module that does not exist. A package without a
73
+ src tree falls back to the dist-name spelling, underscores for
74
+ hyphens beyond the namespace dot.
75
+ """
76
+ src = package.directory / "src"
77
+ if src.is_dir():
78
+ parts: list[str] = []
79
+ node = src
80
+ while True:
81
+ dirs = [d for d in node.iterdir() if d.is_dir() and d.name.isidentifier()]
82
+ has_py = any(f.suffix == ".py" for f in node.iterdir() if f.is_file())
83
+ if parts and (has_py or len(dirs) != 1):
84
+ return ".".join(parts)
85
+ if len(dirs) != 1:
86
+ break
87
+ node = dirs[0]
88
+ parts.append(node.name)
89
+ if parts:
90
+ return ".".join(parts)
91
+ head, _, tail = package.name.partition("-")
92
+ if not tail:
93
+ return head
94
+ return head + "." + tail.replace("-", "_")
95
+
96
+
97
+ def current_version(package: Package) -> str:
98
+ """The version the package's ``pyproject.toml`` declares."""
99
+ data = tomllib.loads((package.directory / "pyproject.toml").read_text("utf-8"))
100
+ return str(data.get("project", {}).get("version", "0.0.0"))
101
+
102
+
103
+ def stamp_version(package: Package) -> _Stamper:
104
+ """Where a Python package's version lives, ready to stamp."""
105
+ return _Stamper(package)
106
+
107
+
108
+ class _Stamper:
109
+ """Stamp a version into a Python package's places, idempotently."""
110
+
111
+ def __init__(self, package: Package) -> None:
112
+ self._package = package
113
+
114
+ def homes(self) -> list[Path]:
115
+ """``pyproject.toml``, and every module that may carry ``__version__``.
116
+
117
+ That is a package's ``__init__.py``.
118
+ """
119
+ src = self._package.directory / "src"
120
+ modules = [*src.rglob("__init__.py")] if src.is_dir() else []
121
+ return [self._package.directory / "pyproject.toml", *sorted(modules)]
122
+
123
+ def stamp(self, version: str) -> list[str]:
124
+ """Write *version* into pyproject and ``__version__``; what changed."""
125
+ import re as _re
126
+
127
+ changed = []
128
+ pyproject = self._package.directory / "pyproject.toml"
129
+ text = pyproject.read_text("utf-8")
130
+ stamped, count = _re.subn(
131
+ r'^version = "[^"]+"$',
132
+ f'version = "{version}"',
133
+ text,
134
+ count=1,
135
+ flags=_re.M,
136
+ )
137
+ if count != 1:
138
+ fail(f"{pyproject} has no version line to stamp")
139
+ if stamped != text:
140
+ pyproject.write_text(stamped, encoding="utf-8")
141
+ changed.append("pyproject.toml")
142
+ for init in self.homes()[1:]:
143
+ text = init.read_text("utf-8")
144
+ stamped, count = _re.subn(
145
+ r'^__version__ = "[^"]+"$',
146
+ f'__version__ = "{version}"',
147
+ text,
148
+ count=1,
149
+ flags=_re.M,
150
+ )
151
+ if count and stamped != text:
152
+ init.write_text(stamped, encoding="utf-8")
153
+ changed.append(str(init.relative_to(self._package.directory)))
154
+ return changed
155
+
156
+
157
+ def test(
158
+ package: Package,
159
+ root: Path,
160
+ *,
161
+ selection: tuple[str, ...] = (),
162
+ arguments: tuple[str, ...] = (),
163
+ ) -> None:
164
+ """Run *package*'s tests: its suite, or the files in *selection* alone.
165
+
166
+ *arguments* go to pytest after the workshop's own.
167
+ """
168
+ files = tuple(f"{package.path}/{path}" for path in selection)
169
+ run_test(
170
+ *arguments,
171
+ packages=(package,),
172
+ root=root,
173
+ selection={package.path: files} if files else None,
174
+ )
175
+
176
+
177
+ def examples_of(package: Package) -> list[Path]:
178
+ """The example files *package* ships under ``docs/examples/``, sorted.
179
+
180
+ A ``conftest.py`` there is the package's setup around them, never
181
+ an example.
182
+ """
183
+ base = package.directory / "docs" / "examples"
184
+ if not base.is_dir():
185
+ return []
186
+ return sorted(path for path in base.rglob("*.py") if path.name != "conftest.py")
187
+
188
+
189
+ def run_examples(
190
+ package: Package,
191
+ root: Path,
192
+ files: tuple[str, ...] = (),
193
+ arguments: tuple[str, ...] = (),
194
+ ) -> None:
195
+ """Run *package*'s documentation examples, one test per file; *arguments* to pytest.
196
+
197
+ Pytest over the package's ``docs/examples/`` directory, or over
198
+ *files* alone when every one of them is an example, whose files
199
+ the workshop's examples plugin collects
200
+ ([livery.workshop.is_example][]), from the workspace root so
201
+ the workspace's pytest configuration applies. A named file that is
202
+ no example, the examples' ``conftest.py``, is every example's
203
+ setup, so the whole directory runs. Captured and printed on a red
204
+ exit, so a runner's log carries the failing example's file and
205
+ line; a package without examples says so and passes.
206
+ """
207
+ from livery.workshop import is_example
208
+
209
+ if not examples_of(package):
210
+ print(f" examples: {package.path} has none")
211
+ return
212
+ named = tuple(name for name in files if is_example(Path(name)))
213
+ targets = (
214
+ named
215
+ if files and len(named) == len(files)
216
+ else (f"{package.path}/docs/examples",)
217
+ )
218
+ result = pytest.opts(in_process=False, cwd=root, nofail=True)(*targets, *arguments)
219
+ if result.code != 0:
220
+ print(result.stdout, end="")
221
+ print(result.stderr, end="")
222
+ fail(f"examples of {package.path}: pytest exited {result.code}")
223
+
224
+
225
+ #: The variables that tell a test it runs on a forge's runner. A
226
+ #: machine's gate sets them, so a test that reads them fails here the
227
+ #: way it would fail on the runner, instead of one push later.
228
+ RUNNER_VARIABLES = ("CI", "GITHUB_ACTIONS")
229
+
230
+
231
+ def runner_shaped(env: Mapping[str, str]) -> dict[str, str]:
232
+ """*env* with the runner's variables set, as the check legs see the suite."""
233
+ return {**env, **dict.fromkeys(RUNNER_VARIABLES, "true")}
234
+
235
+
236
+ #: coverage.py's own configuration file, which the test step's
237
+ #: extension writes at the root while the suites are measured: the
238
+ #: meter armed in every process the tests start reads it, and the
239
+ #: coverage CLI finds it first.
240
+ COVERAGE_CONFIG = ".coveragerc"
241
+
242
+ #: coverage.py's own variable: set in pytest's environment to the
243
+ #: configuration's path, it starts the meter in every python the tests
244
+ #: start, from interpreter start.
245
+ METER = "COVERAGE_PROCESS_START"
246
+
247
+ #: Who prints a machine's coverage preview after its suites ran: the
248
+ #: function a listed extension answers with, given the workspace root
249
+ #: and the packages the run tested; None when no extension answers.
250
+ PREVIEW: Query[Callable[[Path, tuple[Package, ...]], None] | None] = Query(
251
+ "python.test.preview"
252
+ )
253
+
254
+
255
+ def run_test(
256
+ *pytest_args: str,
257
+ packages: tuple[Package, ...],
258
+ root: Path,
259
+ selection: Mapping[str, tuple[str, ...]] | None = None,
260
+ point: str = "",
261
+ measured: bool = False,
262
+ ) -> None:
263
+ """Run the suites of *packages* in one pytest call, *pytest_args* unchanged.
264
+
265
+ The call collects each package's own test directory, the
266
+ workspace's own tests when their unit is among *packages*, and
267
+ nothing else, so a package a run leaves out is never collected;
268
+ *selection* names, per package path, the test files that stand for
269
+ the package's directory. *point* selects that CI point's tests,
270
+ through the workshop's points plugin. A *measured* run measures
271
+ coverage over the namespace the configuration names: inside CI the
272
+ tests run metered for the gate job's union, and on a machine the
273
+ run prints the preview a listed extension answers
274
+ ``python.test.preview`` with
275
+ ([livery.extensions.python._backend.PREVIEW][]), the coverage
276
+ extension's figure beside each floor, or none. A run that is not
277
+ measured runs no meter and prints no preview. A machine's run sets
278
+ the runner's variables (`RUNNER_VARIABLES`), so the suite is judged
279
+ the way the legs judge it. With no test directory among the
280
+ packages no pytest starts, since one handed no path would collect
281
+ the whole configuration.
282
+ """
283
+ from livery.workshop import WORKSPACE_TESTS
284
+
285
+ if point:
286
+ pytest_args = (*pytest_args, f"--workshop-point={point}")
287
+ chosen = selection or {}
288
+ picked: list[str] = []
289
+ for package in packages:
290
+ # The workspace's own tests are a unit whose directory is its
291
+ # own: the leg stores them keyed by the whole tree.
292
+ unit = package.path == WORKSPACE_TESTS
293
+ tests = package.directory if unit else package.directory / "tests"
294
+ if tests.is_dir():
295
+ own = package.path if unit else f"{package.path}/tests"
296
+ picked.extend(chosen.get(package.path) or (own,))
297
+ dirs = tuple(picked)
298
+ if not dirs:
299
+ names = ", ".join(package.path for package in packages)
300
+ print(f" tests: no test directory in {names}; nothing to collect")
301
+ return
302
+ from livery.workshop import SPEED_FILE_VARIABLE, ci_run
303
+
304
+ run = ci_run()
305
+ with tempfile.TemporaryDirectory() as scratch:
306
+ # The workshop's speed plugin sums each package's test time
307
+ # into this file; the sums print beside the marks afterwards,
308
+ # on a red run too.
309
+ sums = Path(scratch) / "speed.json"
310
+ env = {**os.environ, SPEED_FILE_VARIABLE: str(sums)}
311
+ try:
312
+ if run is not None:
313
+ # Inside CI a measured run meters the tests, and only
314
+ # them. Coverage's own subprocess variable is armed in
315
+ # pytest's environment, never in the gate's own, so
316
+ # every python the tests start inherits it (the xdist
317
+ # workers, the runner's children a test spawns in a
318
+ # fixture workspace) and the driver that decides what
319
+ # to run records nothing: a line counts only when a
320
+ # test reached it, whatever the gate ran around the
321
+ # tests. The test extension's pytest plugin names each
322
+ # test's context in that run, the leg splits the one
323
+ # run's data per suite, and the floors are judged
324
+ # once, on the union, in the gate job. Captured, and
325
+ # printed on a red exit: the job's log is the run's
326
+ # evidence and nothing else in a runner shows a red
327
+ # test's words. Captured rather than streamed because a
328
+ # captured child gets its own hidden console on Windows,
329
+ # where a streamed one shares the runner's and a control
330
+ # event a worker raises would interrupt the runner too.
331
+ armed = {**env, METER: str(root / COVERAGE_CONFIG)} if measured else env
332
+ result = pytest.opts(in_process=False, env=armed, nofail=True)(
333
+ *dirs, *pytest_args
334
+ )
335
+ if result.code != 0:
336
+ print(result.stdout, end="")
337
+ print(result.stderr, end="")
338
+ fail(f"pytest exited {result.code}")
339
+ else:
340
+ # Bare --cov on a measured run: the measured source is
341
+ # the configuration's `[run] source`, the namespace the
342
+ # render derived, never a spelled module. The runner's
343
+ # variables are set, so a test that reads them is
344
+ # judged here as on the leg.
345
+ metered = ("--cov", "--cov-report=") if measured else ()
346
+ pytest.opts(in_process=False, env=runner_shaped(env))(
347
+ *dirs, *metered, *pytest_args
348
+ )
349
+ finally:
350
+ for line in speed_lines(root, sums, leg=run.leg if run else ""):
351
+ print(line)
352
+ if run is None and measured:
353
+ from livery.workshop import answer
354
+
355
+ preview = answer(root, PREVIEW)
356
+ if preview is not None:
357
+ preview(root, packages)
358
+
359
+
360
+ def speed_lines(root: Path, sums: Path, *, leg: str = "") -> list[str]:
361
+ """Each package's summed test time; on a check leg, beside its mark there.
362
+
363
+ *sums* is the file the speed plugin wrote; no file means the
364
+ plugin did not run. With *leg* the run is a check leg's, and each
365
+ line carries the package's mark on that leg or says there is
366
+ none; an unreadable store says so once and the times still print.
367
+ Without *leg* the run is a machine's: the times print alone,
368
+ since the marks are judged on the CI legs and a machine's clock
369
+ is not one of them, and nothing here reads the store.
370
+ """
371
+ from livery.workshop import speed_marks
372
+
373
+ if not sums.is_file():
374
+ return []
375
+ try:
376
+ loaded = json.loads(sums.read_text("utf-8"))
377
+ except (OSError, ValueError):
378
+ return []
379
+ if not isinstance(loaded, dict) or not loaded:
380
+ return []
381
+ current: dict[tuple[str, str], SpeedMark] | None = None
382
+ lines: list[str] = []
383
+ if leg:
384
+ current, why = speed_marks(root)
385
+ if current is None:
386
+ lines.append(f" speed marks: not read ({why})")
387
+ for package, data in sorted(loaded.items()):
388
+ if not isinstance(data, dict):
389
+ continue
390
+ seconds = float(data.get("seconds", 0.0))
391
+ tests = int(data.get("tests", 0))
392
+ if not leg:
393
+ beside = "judged on the CI legs"
394
+ elif current is None:
395
+ beside = "mark unknown"
396
+ else:
397
+ mark = current.get((str(package), leg))
398
+ beside = (
399
+ f"mark {mark.seconds:.1f}s on {leg}"
400
+ if mark is not None
401
+ else f"no mark on {leg}"
402
+ )
403
+ lines.append(f" speed {package}: {seconds:.1f}s over {tests} tests ({beside})")
404
+ return lines
405
+
406
+
407
+ def module_roots(package: Package) -> tuple[str, ...]:
408
+ """The import prefixes *package* owns.
409
+
410
+ The build backend's ``module-name`` where the manifest declares
411
+ one, since that is what the wheel ships. Otherwise each topmost
412
+ directory under ``src`` holding an ``__init__.py`` on its branch,
413
+ which finds nothing under a namespace root, one with no public
414
+ names. Never derived
415
+ from the distribution name: three distributions share the
416
+ ``livery.toolroom`` namespace, so a transformed name would
417
+ attribute two of them to the third. A package with no python
418
+ source owns nothing.
419
+ """
420
+ src = package.directory / "src"
421
+ if not src.is_dir():
422
+ return ()
423
+ declared = _declared_modules(package)
424
+ if declared:
425
+ return declared
426
+ roots: list[str] = []
427
+ for init in root_marks(src):
428
+ dotted = ".".join(init.relative_to(src).parts[:-1])
429
+ if not dotted:
430
+ continue
431
+ if any(dotted == root or dotted.startswith(root + ".") for root in roots):
432
+ continue
433
+ roots.append(dotted)
434
+ return tuple(sorted(roots))
435
+
436
+
437
+ def root_marks(src: Path) -> list[Path]:
438
+ """The files that mark an importable root under *src*, shallowest first.
439
+
440
+ A root carries an ``__init__.py``; the topmost on a branch is that
441
+ branch's root. A namespace, one with no ``__init__.py``, marks
442
+ nothing.
443
+ """
444
+ return sorted(src.rglob("__init__.py"), key=lambda path: (len(path.parts), path))
445
+
446
+
447
+ def _declared_modules(package: Package) -> tuple[str, ...]:
448
+ """The modules uv's build backend ships for *package*, sorted; empty if none."""
449
+ pyproject = package.directory / "pyproject.toml"
450
+ if not pyproject.is_file():
451
+ return ()
452
+ data = tomllib.loads(pyproject.read_text("utf-8"))
453
+ backend = data.get("tool", {}).get("uv", {}).get("build-backend", {})
454
+ declared = backend.get("module-name") if isinstance(backend, dict) else None
455
+ if isinstance(declared, str):
456
+ return (declared,)
457
+ if isinstance(declared, list):
458
+ return tuple(sorted(name for name in declared if isinstance(name, str)))
459
+ return ()
460
+
461
+
462
+ #: The runner's distribution. Its own sources implement the answer to
463
+ #: whether a person is there, and it loads the modules a package names
464
+ #: under its entry-point groups.
465
+ RUNNER_DISTRIBUTION = "livery-footman"
466
+
467
+ #: The entry-point groups whose modules a program loads as plugins, each
468
+ #: to that program's distribution. A module named in one runs inside the
469
+ #: program alone, so what the program brings is present whenever the
470
+ #: module runs.
471
+ PLUGIN_GROUPS: Mapping[str, str] = {
472
+ "footman.tasks": RUNNER_DISTRIBUTION,
473
+ "footman.builtin": RUNNER_DISTRIBUTION,
474
+ "pytest11": "pytest",
475
+ }
476
+
477
+
478
+ def scripts(package: Package) -> tuple[str, ...]:
479
+ """The scripts *package*'s ``pyproject.toml`` names under ``[project.scripts]``.
480
+
481
+ Each by the name its installation puts on PATH, in the file's
482
+ order; empty for a package without the file or the table.
483
+ """
484
+ pyproject = package.directory / "pyproject.toml"
485
+ if not pyproject.is_file():
486
+ return ()
487
+ data = tomllib.loads(pyproject.read_text("utf-8"))
488
+ return tuple(str(name) for name in data.get("project", {}).get("scripts", {}))
489
+
490
+
491
+ def run_script(
492
+ package: Package, root: Path, name: str, arguments: tuple[str, ...]
493
+ ) -> int:
494
+ """Start the script *name* from the workspace's environment; its exit code.
495
+
496
+ The script is the one ``fm sync`` installs into the ``.venv`` at
497
+ *root*, started with *arguments* and the console shared.
498
+
499
+ Raises:
500
+ Failed: when the environment holds no script of that name,
501
+ naming the directory and the verb that installs it.
502
+ """
503
+ from livery.workshop import scripts_dir
504
+
505
+ directory = scripts_dir(root / ".venv")
506
+ script = directory / (f"{name}.exe" if sys.platform == "win32" else name)
507
+ if not script.is_file():
508
+ fail(
509
+ f"{package.path}: {directory} holds no script {name};"
510
+ f" `{footman.prog()} sync` installs the workspace's packages there"
511
+ )
512
+ return footman.run([str(script), *arguments], capture=False, nofail=True).code
513
+
514
+
515
+ def plugin_hosts(package: Package) -> dict[str, frozenset[str]]:
516
+ """Each module *package* names in a plugin group, to the programs that load it.
517
+
518
+ Read from the ``[project.entry-points]`` tables of the package's
519
+ ``pyproject.toml``, for the groups ``PLUGIN_GROUPS`` names, each
520
+ program by its distribution. A module under one of these modules
521
+ is loaded with it. Empty for a package that names none.
522
+ """
523
+ pyproject = package.directory / "pyproject.toml"
524
+ if not pyproject.is_file():
525
+ return {}
526
+ data = tomllib.loads(pyproject.read_text("utf-8"))
527
+ groups = data.get("project", {}).get("entry-points", {})
528
+ hosts: dict[str, set[str]] = {}
529
+ for group, program in PLUGIN_GROUPS.items():
530
+ for target in (groups.get(group) or {}).values():
531
+ module = str(target).partition(":")[0].strip()
532
+ hosts.setdefault(module, set()).add(program)
533
+ return {module: frozenset(programs) for module, programs in hosts.items()}
534
+
535
+
536
+ def plugin_modules(package: Package) -> tuple[str, ...]:
537
+ """The modules *package* declares as footman task entry points.
538
+
539
+ A module the runner loads as a plugin has the runner present by
540
+ construction, and whatever the extension stack mounts with it, so its
541
+ imports of either are not dependencies of the distribution. The
542
+ fact lives in the package's own metadata; nothing here needs to
543
+ be told a second time.
544
+ """
545
+ return tuple(
546
+ module
547
+ for module, programs in plugin_hosts(package).items()
548
+ if RUNNER_DISTRIBUTION in programs
549
+ )
550
+
551
+
552
+ def _extra_distributions(package: Package) -> frozenset[str]:
553
+ """The distribution names *package* declares in its optional extras.
554
+
555
+ An extra is a declaration: the integration needs that package,
556
+ and nothing installs it by default. A reference it covers is
557
+ accounted for, and no ``[[depends]]`` edge follows, since the
558
+ reverse check reads ``[project.dependencies]`` alone.
559
+ """
560
+ pyproject = package.directory / "pyproject.toml"
561
+ if not pyproject.is_file():
562
+ return frozenset()
563
+ data = tomllib.loads(pyproject.read_text("utf-8"))
564
+ found = set()
565
+ for requirements in (
566
+ data.get("project", {}).get("optional-dependencies") or {}
567
+ ).values():
568
+ for requirement in requirements:
569
+ name = str(requirement)
570
+ for cut in "[>=<!~; ":
571
+ name = name.partition(cut)[0]
572
+ found.add(name)
573
+ return frozenset(found)
574
+
575
+
576
+ def references(package: Package) -> tuple[SourceReferences, ...]:
577
+ """What each python source of *package* references, one record per file.
578
+
579
+ Every ``.py`` file under ``src``, then under ``tests``, in path
580
+ order, read through the layering phase's one parse, so a file
581
+ another rule also reads is parsed once per gate; a file that does
582
+ not parse is left out, since the syntax gate names it. A source
583
+ under ``src`` is referenced by its dotted path, and a test by
584
+ nothing. Two of python's conventions account for a name: each
585
+ name a module the package declares as a footman task entry point
586
+ references, since the runner that loads the module brings what it
587
+ imports, and each name of a sibling one of its optional extras
588
+ names, since an extra is a declaration of its own.
589
+ """
590
+ from livery.extensions.python._parse import dotted_name, parsed_source
591
+
592
+ plugins = plugin_modules(package)
593
+ around, named = _extra_siblings(package)
594
+ records: list[SourceReferences] = []
595
+ for area in ("src", "tests"):
596
+ base = package.directory / area
597
+ if not base.is_dir():
598
+ continue
599
+ for source in sorted(base.rglob("*.py")):
600
+ parsed = parsed_source(source)
601
+ if parsed is None:
602
+ continue
603
+ module = dotted_name(source, base) if area == "src" else ""
604
+ names = dict.fromkeys(reference.name for reference in parsed.references)
605
+ loaded = bool(module) and any(
606
+ module == plugin or module.startswith(plugin + ".")
607
+ for plugin in plugins
608
+ )
609
+ accounted = tuple(
610
+ name for name in names if loaded or around.owner_of(name) in named
611
+ )
612
+ relative = source.relative_to(package.directory).as_posix()
613
+ records.append(
614
+ SourceReferences(
615
+ path=f"{package.path}/{relative}",
616
+ module=module,
617
+ references=parsed.references,
618
+ accounted=accounted,
619
+ )
620
+ )
621
+ return tuple(records)
622
+
623
+
624
+ def _extra_siblings(package: Package) -> tuple[Neighbours, frozenset[str]]:
625
+ """Who owns each name in the workspace, and the siblings *package*'s extras name.
626
+
627
+ The siblings are the other members whose distribution an optional
628
+ extra of the package names, by path; a name is theirs when the
629
+ longest member prefix it matches is one of their roots. Empty for a
630
+ package that declares no extra, which then reads no other member.
631
+ """
632
+ from livery.workshop import discover_packages, neighbours
633
+
634
+ extras = _extra_distributions(package)
635
+ if not extras:
636
+ return Neighbours(owners={}, by_path={}), frozenset()
637
+ around = neighbours(discover_packages(package.root))
638
+ named = frozenset(
639
+ path
640
+ for path, member in around.by_path.items()
641
+ if member.name in extras and path != package.path
642
+ )
643
+ return around, named
644
+
645
+
646
+ def declared_requirements(package: Package) -> dict[str, str]:
647
+ """The package's declared dependencies, name to constraint text.
648
+
649
+ Read from ``[project.dependencies]``; the layering lint compares
650
+ these against the contract's build and runtime edges.
651
+ """
652
+ project = _project_table(package)
653
+ return _constraints(project.get("dependencies", []))
654
+
655
+
656
+ #: The optional extra that holds what a package's tests require beyond
657
+ #: its dependencies: one line for each test edge of its contract.
658
+ TEST_EXTRA = "test"
659
+
660
+
661
+ def declared_test_requirements(package: Package) -> dict[str, str]:
662
+ """The package's ``test`` extra, name to constraint text.
663
+
664
+ Read from ``[project.optional-dependencies] test``; the layering
665
+ lint compares these against the contract's test edges. Empty for a
666
+ package that declares no such extra.
667
+ """
668
+ extras = _project_table(package).get("optional-dependencies") or {}
669
+ return _constraints(extras.get(TEST_EXTRA) or [])
670
+
671
+
672
+ def _project_table(package: Package) -> dict[str, Any]:
673
+ """The ``[project]`` table of *package*'s ``pyproject.toml``; empty without one."""
674
+ pyproject = package.directory / "pyproject.toml"
675
+ if not pyproject.is_file():
676
+ return {}
677
+ data = tomllib.loads(pyproject.read_text("utf-8"))
678
+ return cast("dict[str, Any]", data.get("project", {}))
679
+
680
+
681
+ def _constraints(requirements: Iterable[object]) -> dict[str, str]:
682
+ """Each requirement of *requirements* by its name, to its constraint text."""
683
+ entries: dict[str, str] = {}
684
+ for requirement in requirements:
685
+ text = str(requirement)
686
+ name = _named(text)
687
+ entries[name] = text[len(name) :].split(";")[0].replace("]", "")
688
+ return entries
689
+
690
+
691
+ def _named(requirement: str) -> str:
692
+ """The distribution name *requirement* starts with, as it spells it."""
693
+ name = requirement
694
+ for cut in "[>=<!~; ":
695
+ name = name.partition(cut)[0]
696
+ return name
697
+
698
+
699
+ def declare_requirement(
700
+ package: Package, dependency: Package, floor: str
701
+ ) -> list[str] | None:
702
+ """Add ``<dependency>>=<floor>`` to the project's dependencies; the files changed.
703
+
704
+ The python extension's requirement writer, its answer to the
705
+ ``requirement-writers`` query. It takes a sibling whose set holds
706
+ python, which the pyproject names as a distribution, and returns
707
+ None for any other sibling, which it leaves to another writer of
708
+ the package's set. A text edit that keeps the manifest's
709
+ formatting: the entry joins the existing list, one per line, or
710
+ opens the list when the project declares none. Already declared,
711
+ whatever the constraint, means nothing to write. A manifest without
712
+ a ``[project]`` table refuses naming it.
713
+ """
714
+ import re as _re
715
+
716
+ from livery.workshop import extension_set
717
+
718
+ if EXTENSION not in extension_set(dependency):
719
+ return None
720
+ pyproject = package.directory / "pyproject.toml"
721
+ if dependency.name in declared_requirements(package):
722
+ return []
723
+ text = pyproject.read_text("utf-8")
724
+ entry = f' "{dependency.name}>={floor}",\n'
725
+ listed = _re.search(r"^dependencies = \[(.*?)^\]", text, flags=_re.M | _re.S)
726
+ inline = _re.search(r"^dependencies = \[(.*)\]$", text, flags=_re.M)
727
+ if listed is not None:
728
+ head, tail = text[: listed.end() - 1], text[listed.end() - 1 :]
729
+ text = head + entry + tail
730
+ elif inline is not None:
731
+ items = [item.strip() for item in inline.group(1).split(",") if item.strip()]
732
+ lines = "".join(f" {item},\n" for item in items) + entry
733
+ text = (
734
+ text[: inline.start()]
735
+ + f"dependencies = [\n{lines}]"
736
+ + text[inline.end() :]
737
+ )
738
+ else:
739
+ header = _re.search(r"^\[project\]\n", text, flags=_re.M)
740
+ if header is None:
741
+ fail(f"{pyproject} has no [project] table to declare a dependency in")
742
+ text = (
743
+ text[: header.end()]
744
+ + f"dependencies = [\n{entry}]\n"
745
+ + text[header.end() :]
746
+ )
747
+ pyproject.write_text(text, encoding="utf-8")
748
+ return ["pyproject.toml"]
749
+
750
+
751
+ #: A table header in a TOML file: where the table before it ends.
752
+ _HEADER = re.compile(r"^\[", flags=re.M)
753
+
754
+ #: The comment a test extra opens with when the writer adds it.
755
+ _TEST_COMMENT = (
756
+ "# The members this package's tests need beyond its dependencies, one\n"
757
+ "# for each test edge of its workshop.toml, at the edge's floor.\n"
758
+ )
759
+
760
+ #: The column a one-line test extra fits in; a longer one spreads one
761
+ #: requirement to a line.
762
+ _WIDTH = 88
763
+
764
+
765
+ def declare_test_requirement(
766
+ package: Package, dependency: Package, floor: str
767
+ ) -> list[str] | None:
768
+ """Write ``<dependency>>=<floor>`` into the ``test`` extra; the files changed.
769
+
770
+ The python extension's writer of a test requirement, its answer to
771
+ the ``test-requirement-writers`` query. It takes a sibling whose set
772
+ holds python, and returns None for any other sibling. The line
773
+ carries the edge's floor: one that names the sibling at another
774
+ constraint is replaced, and one that carries the floor already
775
+ means nothing to write. An empty *floor* writes the name alone. A
776
+ text edit that keeps the manifest's formatting and comments: the
777
+ line joins the extra, kept on one line while it fits in 88 columns
778
+ and one requirement to a line beyond; the extra opens, after a
779
+ comment saying what it holds, at the end of
780
+ ``[project.optional-dependencies]``; and that table opens after the
781
+ ``[project]`` table when the project declares none.
782
+
783
+ Raises:
784
+ Failed: when the manifest has no ``[project]`` table, and when
785
+ its test extra is spelled in a form the edit does not take,
786
+ naming the manifest.
787
+ """
788
+ from livery.workshop import extension_set
789
+
790
+ if EXTENSION not in extension_set(dependency):
791
+ return None
792
+ held = declared_test_requirements(package).get(dependency.name)
793
+ if held is not None and (not floor or f">={floor}" in held):
794
+ return []
795
+ pyproject = package.directory / "pyproject.toml"
796
+ line = f"{dependency.name}>={floor}" if floor else dependency.name
797
+ text = _with_test_line(pyproject, dependency.name, line)
798
+ pyproject.write_text(text, encoding="utf-8")
799
+ return ["pyproject.toml"]
800
+
801
+
802
+ def _with_test_line(pyproject: Path, name: str, line: str) -> str:
803
+ """*pyproject*'s text with *line* in its test extra, in place of each naming *name*.
804
+
805
+ Raises:
806
+ Failed: when the manifest has no ``[project]`` table, and when
807
+ its test extra is spelled in a form the edit does not take.
808
+ """
809
+ text = pyproject.read_text("utf-8")
810
+ table = re.search(r"^\[project\.optional-dependencies\]\n", text, flags=re.M)
811
+ if table is None:
812
+ project = re.search(r"^\[project\]\n", text, flags=re.M)
813
+ if project is None:
814
+ fail(f"{pyproject} has no [project] table to declare a test requirement in")
815
+ following = _HEADER.search(text, project.end())
816
+ opened = (
817
+ f"[project.optional-dependencies]\n{_TEST_COMMENT}"
818
+ f'{TEST_EXTRA} = ["{line}"]\n'
819
+ )
820
+ if following is None:
821
+ return text.rstrip("\n") + "\n\n" + opened
822
+ start = following.start()
823
+ return text[:start] + opened + "\n" + text[start:]
824
+ following = _HEADER.search(text, table.end())
825
+ end = following.start() if following else len(text)
826
+ body = text[table.end() : end]
827
+ edited = _with_line_in(body, name, line)
828
+ if edited is None:
829
+ fail(
830
+ f"{pyproject} spells its {TEST_EXTRA} extra in a form this edit does not"
831
+ f' take; write "{line}" into it by hand'
832
+ )
833
+ return text[: table.end()] + edited + text[end:]
834
+
835
+
836
+ def _with_line_in(body: str, name: str, line: str) -> str | None:
837
+ """The body of ``[project.optional-dependencies]`` with *line* in its test extra.
838
+
839
+ The extra is one line, or one requirement to a line with the
840
+ bracket closing on a line of its own; None for any other spelling.
841
+ """
842
+ inline = re.search(rf"^{TEST_EXTRA} = \[([^\n]*)\]([^\n]*)\n", body, flags=re.M)
843
+ if inline is not None:
844
+ listed = cast("list[str]", tomllib.loads(f"x = [{inline.group(1)}]")["x"])
845
+ items = _placed(listed, name, line)
846
+ spelled = ", ".join(f'"{item}"' for item in items)
847
+ rendered = f"{TEST_EXTRA} = [{spelled}]{inline.group(2)}\n"
848
+ if len(rendered) > _WIDTH + 1:
849
+ each = "".join(f' "{item}",\n' for item in items)
850
+ rendered = f"{TEST_EXTRA} = [{inline.group(2)}\n{each}]\n"
851
+ return body[: inline.start()] + rendered + body[inline.end() :]
852
+ spread = re.search(
853
+ rf"^{TEST_EXTRA} = \[[ \t]*(?:#[^\n]*)?\n(.*?)^\]", body, flags=re.M | re.S
854
+ )
855
+ if spread is not None:
856
+ lines = spread.group(1).splitlines(keepends=True)
857
+ naming = [
858
+ index
859
+ for index, held in enumerate(lines)
860
+ if (quoted := re.match(r'\s*"([^"]*)"', held)) is not None
861
+ and _named(quoted.group(1)) == name
862
+ ]
863
+ entry = f' "{line}",\n'
864
+ for index in reversed(naming[1:]):
865
+ del lines[index]
866
+ if naming:
867
+ lines[naming[0]] = entry
868
+ else:
869
+ lines.append(entry)
870
+ return body[: spread.start(1)] + "".join(lines) + body[spread.end(1) :]
871
+ if re.search(rf"^{TEST_EXTRA}\s*=", body, flags=re.M):
872
+ return None
873
+ kept = body.rstrip("\n")
874
+ # The blank lines that end the table stay after the extra it gains.
875
+ tail = body[len(kept) + 1 :] if kept else body
876
+ lead = f"{kept}\n" if kept else ""
877
+ return f'{lead}{_TEST_COMMENT}{TEST_EXTRA} = ["{line}"]\n{tail}'
878
+
879
+
880
+ def _placed(items: list[str], name: str, line: str) -> list[str]:
881
+ """*items* with *line* in place of the first naming *name*, or after the last."""
882
+ placed: list[str] = []
883
+ for item in items:
884
+ if _named(item) != name:
885
+ placed.append(item)
886
+ elif line not in placed:
887
+ placed.append(line)
888
+ if line not in placed:
889
+ placed.append(line)
890
+ return placed
891
+
892
+
893
+ def module_docs_dir(package: Package) -> Path | None:
894
+ """Where *package*'s wheel-embedded ``_docs`` lives; None without a module.
895
+
896
+ In the shallowest of the package's module roots (`module_roots`)
897
+ whose directory exists under ``src``, the importable package the
898
+ wheel ships. Of two roots with as many parts, the one answered
899
+ first.
900
+ """
901
+ src = package.directory / "src"
902
+ directories = [src.joinpath(*root.split(".")) for root in module_roots(package)]
903
+ present = [directory for directory in directories if directory.is_dir()]
904
+ if not present:
905
+ return None
906
+ return min(present, key=lambda directory: len(directory.parts)) / "_docs"
907
+
908
+
909
+ def materialise_module_docs(package: Package) -> Path | None:
910
+ """Copy the package's ``docs/`` into its wheel-embedded ``_docs``, whole.
911
+
912
+ The copy is rebuilt from scratch, so the wheel never carries docs
913
+ older than the tree it is built from, and a package without
914
+ ``docs/`` loses its old copy instead of shipping it.
915
+
916
+ Returns:
917
+ The copy's path; None when the package has no module to carry
918
+ it, or no ``docs/`` to copy.
919
+ """
920
+ target = module_docs_dir(package)
921
+ if target is None:
922
+ return None
923
+ shutil.rmtree(target, ignore_errors=True)
924
+ docs = package.directory / "docs"
925
+ if not docs.is_dir():
926
+ return None
927
+ shutil.copytree(docs, target)
928
+ return target
929
+
930
+
931
+ @contextmanager
932
+ def module_docs(package: Package) -> Generator[Path | None]:
933
+ """The wheel-embedded ``_docs``, in the source tree for one build alone.
934
+
935
+ On entry the package's ``docs/`` is copied whole to ``_docs`` in
936
+ the shallowest of its module roots, the importable package its
937
+ wheel ships, replacing any older copy. On exit the copy is
938
+ removed, after a failed build too. A copy left behind puts the
939
+ docs' example files under the package's source, where every
940
+ checker that walks the tree judges them as the package's own code.
941
+
942
+ Yields:
943
+ The copy's path; None when the package has no module to carry
944
+ it, or no ``docs/`` to copy.
945
+ """
946
+ target = materialise_module_docs(package)
947
+ try:
948
+ yield target
949
+ finally:
950
+ if target is not None:
951
+ shutil.rmtree(target, ignore_errors=True)
952
+
953
+
954
+ def build(package: Package, root: Path, *, epoch: int = 0) -> Path:
955
+ """Build *package*'s wheel and sdist into its ``dist/``; the dist dir.
956
+
957
+ Always from a clean ``dist/``: reusing an artifact from an
958
+ earlier build installs stale code under current tests, a silent
959
+ footgun bought off for seconds of build. *epoch* (a commit's
960
+ timestamp) rides ``SOURCE_DATE_EPOCH`` so a pure-Python rebuild
961
+ at the same ref is byte-identical.
962
+
963
+ Raises:
964
+ Failed: when ``uv build`` exits non-zero, when it writes no
965
+ wheel, and when it writes a wheel tagged for a platform.
966
+ Python's build ships a pure wheel, tagged ``none-any``; a
967
+ platform tag means compiled code built for this machine
968
+ alone, and a package with compiled code lists the extension
969
+ that builds a wheel for each platform.
970
+ """
971
+ dist = package.directory / "dist"
972
+ shutil.rmtree(dist, ignore_errors=True)
973
+ env = dict(os.environ)
974
+ if epoch:
975
+ env["SOURCE_DATE_EPOCH"] = str(epoch)
976
+ # The wheel embeds the docs copied whole from packages/<name>/docs
977
+ # for this build, so it never carries docs older than its tree.
978
+ with module_docs(package):
979
+ result = tools.uv.opts(
980
+ cwd=package.directory, nofail=True, recorded=False, env=env
981
+ )("build", "--out-dir", str(dist))
982
+ if result.code != 0:
983
+ fail(
984
+ f"uv build ({package.name}) exited {result.code}:\n"
985
+ f"{result.stdout}{result.stderr}"
986
+ )
987
+ wheels = sorted(dist.glob("*.whl"))
988
+ if not wheels:
989
+ fail(f"{package.name}: the build produced no wheel in {dist}")
990
+ tagged = [wheel.name for wheel in wheels if not wheel.name.endswith(PURE_TAG)]
991
+ if tagged:
992
+ fail(
993
+ f"{package.name}: the build produced a wheel tagged for a platform,"
994
+ f" {', '.join(tagged)}; python's build ships a pure wheel, tagged"
995
+ " none-any, so a package with compiled code lists the extension that"
996
+ " builds its platform wheels"
997
+ )
998
+ return dist
999
+
1000
+
1001
+ def publish_artifact(
1002
+ package: Package, root: Path, *, version: str, target: RegistryTarget
1003
+ ) -> bool:
1004
+ """Upload ``dist/*`` to the python index: python's publish step calls it.
1005
+
1006
+ ``uv publish``, to the target's upload endpoint with its
1007
+ credential. *version* and *root* are what another ecosystem's
1008
+ publisher reads: the wheels in ``dist/`` already carry their
1009
+ versions, and the index needs nothing from the workspace.
1010
+ """
1011
+ del root, version
1012
+ return publish_wheels(package, index_url=target.publish_url, token=target.token)
1013
+
1014
+
1015
+ def publish_wheels(package: Package, *, index_url: str = "", token: str = "") -> bool:
1016
+ """Upload ``dist/*``; False when everything was already published.
1017
+
1018
+ ``uv publish``, with the already-published duplicate the one
1019
+ tolerated failure: a re-run must walk past what an earlier
1020
+ attempt landed. Anything else, a rejected credential, an
1021
+ unreachable index, surfaces verbatim.
1022
+
1023
+ The upload address is required: without ``--publish-url`` uv
1024
+ silently defaults to PyPI, and an undeclared endpoint must
1025
+ refuse rather than surprise. A deliberate PyPI publish arrives
1026
+ here as the ecosystem rung's explicit URL.
1027
+ """
1028
+ if not index_url:
1029
+ fail(
1030
+ f"{package.name}: no publish address given, and an upload"
1031
+ " endpoint is never defaulted (uv's silent default is"
1032
+ " PyPI). Declare [registries.python] publish in"
1033
+ " workshop.toml, or set PYTHON_PUBLISH_INDEX."
1034
+ )
1035
+ command = ["publish", "--publish-url", index_url]
1036
+ if token:
1037
+ # A Secret, so a profile trace and every other place the
1038
+ # command line is shown carry *** in its place and uv alone
1039
+ # receives the value; recorded=False keeps the call out of
1040
+ # receipts, --json, and recordings as well.
1041
+ command += ["--token", footman.Secret(token)]
1042
+ command += [str(path) for path in sorted((package.directory / "dist").glob("*"))]
1043
+ # CI renders UV_PUBLISH_TOKEN from a secret that may be absent, and
1044
+ # an absent secret arrives as an empty string. uv treats a set but
1045
+ # empty token as a credential rather than as none: trusted
1046
+ # publishing is skipped and the index refuses the empty token. The
1047
+ # child sees the variable only when it carries a value.
1048
+ env = {k: v for k, v in os.environ.items() if k != "UV_PUBLISH_TOKEN" or v}
1049
+ result = tools.uv.opts(cwd=package.directory, nofail=True, recorded=False, env=env)(
1050
+ *command
1051
+ )
1052
+ if result.code == 0:
1053
+ return True
1054
+ output = f"{result.stdout}{result.stderr}"
1055
+ if _is_duplicate(output):
1056
+ print(f" {package.name}: already published; walking past")
1057
+ return False
1058
+ fail(f"uv publish ({package.name}) exited {result.code}:\n{output}")
1059
+ return False # unreachable; fail raises
1060
+
1061
+
1062
+ #: How each index refuses a file it already serves. Gitea and PyPI
1063
+ #: say the version already exists, twine-style indexes say duplicate,
1064
+ #: GitLab's package registry says the file name is taken.
1065
+ _DUPLICATE_PHRASES = ("already exists", "duplicate", "file name has already been taken")
1066
+
1067
+
1068
+ def _is_duplicate(output: str) -> bool:
1069
+ """Whether *output*, an upload's refusal, means the file is already served."""
1070
+ lowered = output.lower()
1071
+ return any(phrase in lowered for phrase in _DUPLICATE_PHRASES)
1072
+
1073
+
1074
+ def _index_args(root: Path) -> tuple[str, ...]:
1075
+ """The repo's ``[[tool.uv.index]]`` entries as install flags.
1076
+
1077
+ The isolated venv is bare and reads no project config, so without
1078
+ this it resolves only from PyPI plus the local wheels, and a
1079
+ custom index's already-published packages cannot resolve the way
1080
+ a real consumer's do.
1081
+ """
1082
+ import tomllib
1083
+
1084
+ pyproject = root / "pyproject.toml"
1085
+ if not pyproject.is_file():
1086
+ return ()
1087
+ data = tomllib.loads(pyproject.read_text("utf-8"))
1088
+ args: list[str] = []
1089
+ for entry in (data.get("tool", {}).get("uv", {}).get("index", [])) or []:
1090
+ url = str(entry.get("url") or "")
1091
+ if not url:
1092
+ continue
1093
+ args.append("--default-index" if entry.get("default") else "--index")
1094
+ args.append(url)
1095
+ return tuple(args)
1096
+
1097
+
1098
+ def _exported(root: Path, pins: Path, *selection: str) -> Path | None:
1099
+ """The workspace lock's *selection* as exact pins in *pins*; None without a lock.
1100
+
1101
+ ``uv export`` reads the lock offline. Workspace members are left
1102
+ out: they export as workspace-relative paths a scratch venv cannot
1103
+ resolve, and the leg installs the built wheel and finds a sibling's
1104
+ in the co-released set's ``dist/`` directories alone. A workspace
1105
+ without a lock (a bare rig, a consumer checkout) answers None.
1106
+ """
1107
+ result = tools.uv.opts(cwd=root, nofail=True, recorded=False)(
1108
+ "export",
1109
+ "--format",
1110
+ "requirements-txt",
1111
+ *selection,
1112
+ "--no-emit-workspace",
1113
+ "--no-hashes",
1114
+ "-o",
1115
+ str(pins),
1116
+ )
1117
+ if result.code != 0 or not pins.is_file():
1118
+ return None
1119
+ # A path-sourced entry (a checkout standing in for a wheel)
1120
+ # exports as a local reference the scratch venv can neither
1121
+ # reach nor parse; the pins we want are the index-resolvable
1122
+ # lines, so the local ones are dropped.
1123
+ lines = [
1124
+ line
1125
+ for line in pins.read_text("utf-8").splitlines()
1126
+ if "file://" not in line and not line.startswith(("-e ", "./", "/"))
1127
+ ]
1128
+ pins.write_text("\n".join(lines) + "\n", encoding="utf-8")
1129
+ return pins
1130
+
1131
+
1132
+ def _dev_pins(root: Path, scratch: Path) -> Path | None:
1133
+ """The lock's dev-group resolution as exact pins; None without one.
1134
+
1135
+ The isolated venv's toolchain arrives at the versions the gate
1136
+ itself tested with. Workspace members stay out: the leg already
1137
+ installed the built wheel at its resolution, and reinstalling them
1138
+ would clobber the starved resolution the movement guard protects.
1139
+ A workspace without a lock or a dev group answers None and the leg
1140
+ falls back to a bare pytest install.
1141
+ """
1142
+ return _exported(
1143
+ root, scratch / "dev-pins.txt", "--only-group", "dev", "--no-emit-project"
1144
+ )
1145
+
1146
+
1147
+ def _lock_pins(root: Path, scratch: Path) -> Path | None:
1148
+ """Every version the workspace lock pins, as it stands; None without a lock.
1149
+
1150
+ The head leg's constraints: every package, group and extra the
1151
+ lock resolves, read with ``--frozen``, so the export never locks
1152
+ again and the leg installs at the pins the gate tested.
1153
+ """
1154
+ return _exported(
1155
+ root,
1156
+ scratch / "lock-pins.txt",
1157
+ "--frozen",
1158
+ "--all-packages",
1159
+ "--all-groups",
1160
+ "--all-extras",
1161
+ )
1162
+
1163
+
1164
+ def _pins_without(pins: Path, resolved: dict[str, str]) -> Path:
1165
+ """Rewrite *pins* without the distributions the leg already resolved.
1166
+
1167
+ The dev group's export carries every member's dependencies at the
1168
+ locked version; installed over a leg, those would move the floor
1169
+ or the latest the leg resolved and the two legs would prove one
1170
+ set. The toolchain the tests need is what remains: pytest, its
1171
+ plugins, and whatever else the leg did not resolve, each pinned
1172
+ from the lock. The movement guard stays behind this as the
1173
+ backstop for a tool that genuinely shares a dependency with the
1174
+ member and needs another version of it.
1175
+ """
1176
+ taken = {name.lower().replace("_", "-") for name in resolved}
1177
+ kept: list[str] = []
1178
+ for line in pins.read_text("utf-8").splitlines():
1179
+ head = line.strip().split(";", 1)[0]
1180
+ name = re.split(r"[\s=<>!~\[]", head, maxsplit=1)[0].lower().replace("_", "-")
1181
+ if name and name in taken:
1182
+ continue
1183
+ kept.append(line)
1184
+ pins.write_text("\n".join(kept) + "\n", encoding="utf-8")
1185
+ return pins
1186
+
1187
+
1188
+ def _direct_requirements(package: Package) -> tuple[str, ...]:
1189
+ """The requirement strings *package*'s ``[project]`` declares.
1190
+
1191
+ The isolated install lists them explicitly beside the wheel: to
1192
+ the resolver a wheel file's own dependencies are transitive, so
1193
+ ``--resolution=lowest-direct`` would leave them at highest and
1194
+ the floor leg would starve nothing. Named on the command line
1195
+ they are direct, and the starvation lands where the leg aims it.
1196
+ """
1197
+ import tomllib
1198
+
1199
+ pyproject = package.directory / "pyproject.toml"
1200
+ if not pyproject.is_file():
1201
+ return ()
1202
+ data = tomllib.loads(pyproject.read_text("utf-8"))
1203
+ return tuple(
1204
+ str(requirement)
1205
+ for requirement in data.get("project", {}).get("dependencies", []) or []
1206
+ )
1207
+
1208
+
1209
+ def _leg_env(root: Path, venv: Path) -> dict[str, str]:
1210
+ """The isolated leg's process environment, scrubbed of the workspace.
1211
+
1212
+ The leg's own venv leads PATH and the workspace venv's entries
1213
+ drop out, so a tool probe answers for what the leg installed
1214
+ rather than what the workspace happens to carry (a click tool
1215
+ found on the workspace PATH but absent from the leg's python
1216
+ extracts a different spec). VIRTUAL_ENV points at the leg, and
1217
+ the ambient coverage variables go, as every read of measured data
1218
+ scrubs them: a metered gate must not re-point the leg's children.
1219
+ """
1220
+ workspace_venv = str(root / ".venv")
1221
+ env = {
1222
+ key: value
1223
+ for key, value in os.environ.items()
1224
+ if not key.startswith(("COVERAGE_", "COV_CORE_"))
1225
+ }
1226
+ kept = [
1227
+ entry
1228
+ for entry in env.get("PATH", "").split(os.pathsep)
1229
+ if entry
1230
+ and entry != workspace_venv
1231
+ and not entry.startswith(workspace_venv + os.sep)
1232
+ ]
1233
+ from livery.workshop import scripts_dir
1234
+
1235
+ env["PATH"] = os.pathsep.join([str(scripts_dir(venv)), *kept])
1236
+ env["VIRTUAL_ENV"] = str(venv)
1237
+ return env
1238
+
1239
+
1240
+ def _install_target(package: Package, wheel: Path) -> str:
1241
+ """The leg's install spelling for the wheel under test.
1242
+
1243
+ A package may declare a ``test`` extra naming what its suite needs
1244
+ beyond its runtime dependencies (an optional host it drives, the
1245
+ editor-intelligence libraries a doc probe imports). The leg runs
1246
+ that suite, so it installs the wheel with the extra; without one
1247
+ the wheel installs plain.
1248
+ """
1249
+ import tomllib
1250
+
1251
+ pyproject = package.directory / "pyproject.toml"
1252
+ if pyproject.is_file():
1253
+ data = tomllib.loads(pyproject.read_text("utf-8"))
1254
+ extras = data.get("project", {}).get("optional-dependencies", {}) or {}
1255
+ if "test" in extras:
1256
+ return f"{package.name}[test] @ {wheel.resolve().as_uri()}"
1257
+ return str(wheel)
1258
+
1259
+
1260
+ def _direct_versions(package: Package, resolved: dict[str, str]) -> dict[str, str]:
1261
+ """The resolved versions of *package*'s own direct dependencies."""
1262
+ import re
1263
+
1264
+ names = []
1265
+ for requirement in _direct_requirements(package):
1266
+ match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement)
1267
+ if match:
1268
+ names.append(match.group(1).lower().replace("_", "-"))
1269
+ return {
1270
+ name: version
1271
+ for name, version in resolved.items()
1272
+ if name.lower().replace("_", "-") in names
1273
+ }
1274
+
1275
+
1276
+ def _refresh_args(release_dirs: tuple[Path, ...]) -> list[str]:
1277
+ """The ``--refresh-package`` flags for every wheel in *release_dirs*.
1278
+
1279
+ uv keys its wheel cache on the distribution's filename, and a
1280
+ co-released member rebuilt at the same version keeps the same
1281
+ filename: without a refresh the leg installs the first build the
1282
+ cache saw, and every later source fix tests as if it did nothing.
1283
+ One flag per distribution named by a wheel in the set's ``dist/``
1284
+ directories, sorted; empty without any.
1285
+ """
1286
+ names = {
1287
+ wheel.name.split("-", 1)[0].replace("_", "-").lower()
1288
+ for directory in release_dirs
1289
+ for wheel in directory.glob("*.whl")
1290
+ }
1291
+ return [f"--refresh-package={name}" for name in sorted(names)]
1292
+
1293
+
1294
+ #: The resolutions the isolated test takes, each to uv's resolution
1295
+ #: strategy and whether the workspace lock's pins constrain the install.
1296
+ RESOLUTIONS: dict[str, tuple[str, bool]] = {
1297
+ "floor": ("lowest-direct", False),
1298
+ "latest": ("highest", False),
1299
+ "head": ("highest", True),
1300
+ }
1301
+
1302
+
1303
+ def isolated_test(
1304
+ package: Package, root: Path, *, release_dirs: tuple[Path, ...], resolution: str
1305
+ ) -> dict[str, str]:
1306
+ """Test *package*'s built wheel in a fresh venv at *resolution*; what it got.
1307
+
1308
+ [livery.extensions.python.run_isolated_test][] runs it.
1309
+ """
1310
+ return run_isolated_test(
1311
+ package, root, release_dirs=release_dirs, resolution=resolution
1312
+ )
1313
+
1314
+
1315
+ def verify(
1316
+ package: Package, *, tree: Path, version: str, python: str, index: str, extras: str
1317
+ ) -> int:
1318
+ """Install *package*'s released *version* alone and run its tests at *tree*.
1319
+
1320
+ The exit code; [livery.extensions.python._verify.verify][] runs it.
1321
+ """
1322
+ from livery.extensions.python import _verify
1323
+
1324
+ return _verify.verify(
1325
+ package, tree=tree, version=version, python=python, index=index, extras=extras
1326
+ )
1327
+
1328
+
1329
+ def run_isolated_test(
1330
+ package: Package,
1331
+ root: Path,
1332
+ *,
1333
+ release_dirs: tuple[Path, ...] = (),
1334
+ resolution: str = "latest",
1335
+ wheels_dir: Path | None = None,
1336
+ ) -> dict[str, str]:
1337
+ """Install the built wheel into a fresh venv and test the installed copy.
1338
+
1339
+ One leg of the release's isolated test, at *resolution*: ``floor``
1340
+ puts every direct dependency at its declared floor, so a floor
1341
+ lying about compatibility fails here; ``latest`` resolves the
1342
+ newest, the world a fresh consumer gets; ``head`` installs at the
1343
+ versions the workspace lock pins, read as it stands, and refuses
1344
+ in a workspace without a lock.
1345
+
1346
+ ``--find-links`` is limited to *release_dirs*, the co-released
1347
+ set's ``dist/`` directories: a leftover wheel from a non-released
1348
+ package must never mask that the release needs an unpublished
1349
+ dependency version. Everything else resolves from the repo's
1350
+ configured indexes, like a real consumer.
1351
+ Each distribution those directories carry is refreshed in uv's
1352
+ cache, so a member rebuilt at the same version installs the wheel
1353
+ just built and never an earlier build of the same filename.
1354
+
1355
+ The toolchain installs after the wheel, at the lock's dev-group
1356
+ pins where a lock exists (bare pytest otherwise), and a probe
1357
+ then re-reads the package's own direct dependencies: the second
1358
+ install is pip-shaped and moves versions without erroring, so a
1359
+ toolchain pin that overlaps a floored dependency would silently
1360
+ undo the starvation. Movement is a taught refusal.
1361
+
1362
+ Args:
1363
+ package: The member whose wheel is installed and tested.
1364
+ root: The workspace root, whose lock and indexes the install
1365
+ reads.
1366
+ release_dirs: The co-released set's ``dist/`` directories, the
1367
+ only places a wheel resolves from beside the indexes.
1368
+ resolution: ``floor``, ``latest`` or ``head``.
1369
+ wheels_dir: Where the wheel to install is, for a leg that built
1370
+ one outside the collected ``dist/``; None takes the
1371
+ package's own ``dist/``.
1372
+
1373
+ Returns:
1374
+ The resolved version per distribution, the report's raw
1375
+ material ("floor leg: livery-forge 0.1.0").
1376
+
1377
+ Raises:
1378
+ Failed: when *resolution* is none of the three, when no wheel
1379
+ is there to install, when the head leg finds no lock, when
1380
+ an install fails, when the toolchain install moves a direct
1381
+ dependency the leg resolved, and when the tests fail.
1382
+ """
1383
+ import json
1384
+ import tempfile
1385
+
1386
+ if resolution not in RESOLUTIONS:
1387
+ fail(
1388
+ f"{package.name}: the isolated test resolves at"
1389
+ f" {', '.join(RESOLUTIONS)}, not {resolution!r}"
1390
+ )
1391
+ strategy, locked = RESOLUTIONS[resolution]
1392
+
1393
+ # Sorted for determinism: with several wheels in dist a glob's
1394
+ # filesystem order once handed the leg a musllinux wheel the
1395
+ # venv could not install.
1396
+ built = wheels_dir if wheels_dir is not None else package.directory / "dist"
1397
+ wheels = sorted(built.glob("*.whl"))
1398
+ if not wheels:
1399
+ fail(f"{package.name}: no wheel in {built} to validate; build first")
1400
+ with tempfile.TemporaryDirectory() as scratch:
1401
+ from livery.workshop import venv_python
1402
+
1403
+ venv = Path(scratch) / "venv"
1404
+ python = venv_python(venv)
1405
+
1406
+ def _run_install(*args: str) -> None:
1407
+ result = tools.uv.opts(cwd=scratch, nofail=True, recorded=False)(*args)
1408
+ if result.code != 0:
1409
+ fail(
1410
+ f"{package.name} isolated install ({resolution}) exited"
1411
+ f" {result.code}:\n{result.stdout}{result.stderr}"
1412
+ )
1413
+
1414
+ def _listing() -> dict[str, str]:
1415
+ listing = tools.uv.opts(cwd=scratch, nofail=True, recorded=False)(
1416
+ "pip", "list", "--python", str(python), "--format", "json"
1417
+ )
1418
+ versions: dict[str, str] = {}
1419
+ if listing.code == 0:
1420
+ for row in json.loads(listing.stdout or "[]"):
1421
+ versions[str(row.get("name", ""))] = str(row.get("version", ""))
1422
+ return versions
1423
+
1424
+ constraints: tuple[str, ...] = ()
1425
+ if locked:
1426
+ pins = _lock_pins(root, Path(scratch))
1427
+ if pins is None:
1428
+ fail(
1429
+ f"{package.name}: the head leg installs at the versions the"
1430
+ f" workspace lock pins, and uv export read no lock in {root};"
1431
+ " lock the workspace, or list floor or latest in"
1432
+ " [release] prove"
1433
+ )
1434
+ constraints = ("--constraint", str(pins))
1435
+ # The leg's own interpreter version, explicitly: bare `uv
1436
+ # venv` takes uv's default python, and a platform wheel
1437
+ # built for the running interpreter (cp311) cannot install
1438
+ # into a venv of another (cp314). Pure wheels never noticed.
1439
+ _run_install(
1440
+ "venv",
1441
+ "--python",
1442
+ f"{sys.version_info.major}.{sys.version_info.minor}",
1443
+ str(venv),
1444
+ )
1445
+ _run_install(
1446
+ "pip",
1447
+ "install",
1448
+ "--python",
1449
+ str(python),
1450
+ f"--resolution={strategy}",
1451
+ *[f"--find-links={d}" for d in release_dirs],
1452
+ *_refresh_args(release_dirs),
1453
+ *_index_args(root),
1454
+ *constraints,
1455
+ _install_target(package, wheels[0]),
1456
+ # The declared dependencies ride the command line so
1457
+ # the resolution strategy treats them as direct; see
1458
+ # _direct_requirements.
1459
+ *_direct_requirements(package),
1460
+ )
1461
+ resolved = _listing()
1462
+ before = _direct_versions(package, resolved)
1463
+ # The toolchain never rides the starved install: lowest-direct
1464
+ # aimed at it once dragged pytest back a decade. Locked pins
1465
+ # where the workspace has them, minus what the leg resolved,
1466
+ # so the leg keeps its floor or its latest; pytest is a no-op
1467
+ # re-request when the pins already hold it.
1468
+ # The toolchain resolves from the same indexes as the wheel:
1469
+ # the lock's pins name whatever versions the workspace's own
1470
+ # index serves, and a bare-PyPI install cannot see those.
1471
+ pins = _dev_pins(root, Path(scratch))
1472
+ if pins is not None:
1473
+ pins = _pins_without(pins, resolved)
1474
+ _run_install(
1475
+ "pip",
1476
+ "install",
1477
+ "--python",
1478
+ str(python),
1479
+ *_index_args(root),
1480
+ "-r",
1481
+ str(pins),
1482
+ )
1483
+ _run_install(
1484
+ "pip", "install", "--python", str(python), *_index_args(root), "pytest"
1485
+ )
1486
+ after = _direct_versions(package, _listing())
1487
+ moved = {
1488
+ name: (before[name], version)
1489
+ for name, version in after.items()
1490
+ if name in before and before[name] != version
1491
+ }
1492
+ if moved:
1493
+ listed = ", ".join(
1494
+ f"{name} {was} -> {now}" for name, (was, now) in sorted(moved.items())
1495
+ )
1496
+ fail(
1497
+ f"{package.name}: the toolchain install moved direct"
1498
+ f" dependencies the {resolution} leg had resolved: {listed}."
1499
+ " The starvation must stay honest: a toolchain tool needs"
1500
+ " another version of a dependency the member declares, so"
1501
+ " pin that tool where the two agree, or widen the member's"
1502
+ " floor."
1503
+ )
1504
+ tests = package.directory / "tests"
1505
+ if tests.is_dir():
1506
+ result = footman.run(
1507
+ [
1508
+ str(python),
1509
+ "-m",
1510
+ "pytest",
1511
+ str(tests),
1512
+ "-q",
1513
+ "-p",
1514
+ "no:cacheprovider",
1515
+ # pytest derives its rootdir from the test paths and
1516
+ # finds the workspace's configuration, whose addopts
1517
+ # would hand the leg xdist workers and coverage flags;
1518
+ # the leg is serial and unmetered by design.
1519
+ "-o",
1520
+ "addopts=",
1521
+ ],
1522
+ cwd=scratch,
1523
+ env=_leg_env(root, venv),
1524
+ nofail=True,
1525
+ recorded=False,
1526
+ )
1527
+ if result.code != 0:
1528
+ fail(
1529
+ f"{package.name} isolated tests ({resolution}) failed:\n"
1530
+ f"{result.stdout[-4000:]}{result.stderr[-2000:]}"
1531
+ )
1532
+ return _listing()