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.
- livery/extensions/python/__init__.py +51 -0
- livery/extensions/python/_answers.py +127 -0
- livery/extensions/python/_backend.py +1532 -0
- livery/extensions/python/_declared.py +252 -0
- livery/extensions/python/_docs/index.md +165 -0
- livery/extensions/python/_docs/nav.toml +14 -0
- livery/extensions/python/_installed.py +219 -0
- livery/extensions/python/_parse.py +324 -0
- livery/extensions/python/_reaches.py +165 -0
- livery/extensions/python/_steps.py +71 -0
- livery/extensions/python/_terminal.py +118 -0
- livery/extensions/python/_verify.py +112 -0
- livery/extensions/python/content/templates/python-library/LICENSE.jinja +21 -0
- livery/extensions/python/content/templates/python-library/README.md.jinja +7 -0
- livery/extensions/python/content/templates/python-library/pyproject.toml.jinja +43 -0
- livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__init__.py.jinja +15 -0
- livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__main__.py.jinja +30 -0
- livery/extensions/python/content/templates/python-library/src/{{ source_path }}/py.typed +0 -0
- livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_cli.py.jinja +39 -0
- livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_package.py.jinja +22 -0
- livery/extensions/python/content/templates/python-library/workshop.toml.jinja +10 -0
- livery/extensions/python/extension.toml +165 -0
- livery/extensions/python/py.typed +0 -0
- livery_extensions_python-0.0.0.dist-info/METADATA +31 -0
- livery_extensions_python-0.0.0.dist-info/RECORD +28 -0
- livery_extensions_python-0.0.0.dist-info/WHEEL +4 -0
- livery_extensions_python-0.0.0.dist-info/entry_points.txt +3 -0
- 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()
|