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,252 @@
|
|
|
1
|
+
"""The declared-imports rule: a package's source imports what its manifest declares.
|
|
2
|
+
|
|
3
|
+
At import time, a module under a package's ``src`` imports only the
|
|
4
|
+
standard library, the workspace's own members, and the distributions
|
|
5
|
+
the package's ``pyproject.toml`` declares in ``[project.dependencies]``.
|
|
6
|
+
Inside a function or a method it may also import a distribution an
|
|
7
|
+
optional extra declares, since importing the module does not run that
|
|
8
|
+
import. An import under ``TYPE_CHECKING``, or in a ``try`` that handles
|
|
9
|
+
``ImportError``, is not judged: no run makes the first, and the source
|
|
10
|
+
runs without the second.
|
|
11
|
+
|
|
12
|
+
A module a program loads as a plugin, named in one of the program's
|
|
13
|
+
entry-point groups (``footman.tasks`` and ``footman.builtin`` for the
|
|
14
|
+
runner, ``pytest11`` for pytest), may also import what that program
|
|
15
|
+
brings: its distribution and the distributions it requires. The
|
|
16
|
+
program is present whenever such a module runs. A name another member
|
|
17
|
+
owns is left to the sibling-references step, which judges the edges
|
|
18
|
+
between members.
|
|
19
|
+
|
|
20
|
+
Which distribution provides a module is read from the metadata of the
|
|
21
|
+
distributions the workspace's environment installed, so a module that
|
|
22
|
+
no installed distribution provides refuses, naming it.
|
|
23
|
+
|
|
24
|
+
The rule is a step of the layering phase,
|
|
25
|
+
``[phases.layering.declared-imports]``, over each python package's
|
|
26
|
+
``references`` answer: the sources a narrowed run reaches, every one
|
|
27
|
+
otherwise.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import sys
|
|
33
|
+
import tomllib
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
36
|
+
|
|
37
|
+
from livery.footman import fail, prog
|
|
38
|
+
from livery.workshop import selected_files
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Mapping
|
|
42
|
+
|
|
43
|
+
from livery.extensions.python._parse import RuleContext
|
|
44
|
+
from livery.workshop import (
|
|
45
|
+
Neighbours,
|
|
46
|
+
Package,
|
|
47
|
+
PhaseContext,
|
|
48
|
+
ReferencedName,
|
|
49
|
+
SourceReferences,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
#: Where a package declares the distributions its sources import at
|
|
53
|
+
#: import time.
|
|
54
|
+
DEPENDENCIES = "[project.dependencies]"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def declared_imports(ctx: PhaseContext) -> None:
|
|
58
|
+
"""The step: refuse each import that no declaration of its package accounts for.
|
|
59
|
+
|
|
60
|
+
The sources the run reaches ([livery.workshop.selected_files][])
|
|
61
|
+
are judged; every source when the run knows nothing of what changed.
|
|
62
|
+
"""
|
|
63
|
+
from livery.extensions.python._parse import RuleContext
|
|
64
|
+
|
|
65
|
+
context = RuleContext(
|
|
66
|
+
root=ctx.root, packages=ctx.packages, files=selected_files(ctx)
|
|
67
|
+
)
|
|
68
|
+
found = problems(context)
|
|
69
|
+
if found:
|
|
70
|
+
fail("the sources break the declared-imports rule:\n " + "\n ".join(found))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Manifest:
|
|
75
|
+
"""What a package's ``pyproject.toml`` declares, as the rule reads it.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
path: The file, relative to the workspace root, posix.
|
|
79
|
+
dependencies: The distributions ``[project.dependencies]``
|
|
80
|
+
names, normalized.
|
|
81
|
+
extras: Each optional extra, to the distributions it names,
|
|
82
|
+
normalized.
|
|
83
|
+
plugins: Each module the package names in a plugin entry-point
|
|
84
|
+
group, to the distributions of the programs that load it.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
path: str
|
|
88
|
+
dependencies: frozenset[str]
|
|
89
|
+
extras: Mapping[str, frozenset[str]]
|
|
90
|
+
plugins: Mapping[str, frozenset[str]]
|
|
91
|
+
|
|
92
|
+
def loaded_by(self, module: str) -> frozenset[str]:
|
|
93
|
+
"""The programs that load *module* as a plugin, or a package above it."""
|
|
94
|
+
found: set[str] = set()
|
|
95
|
+
for plugin, programs in self.plugins.items():
|
|
96
|
+
if module == plugin or module.startswith(plugin + "."):
|
|
97
|
+
found |= programs
|
|
98
|
+
return frozenset(found)
|
|
99
|
+
|
|
100
|
+
def extra_naming(self, distributions: frozenset[str]) -> str:
|
|
101
|
+
"""The first optional extra naming one of *distributions*; empty for none."""
|
|
102
|
+
return next(
|
|
103
|
+
(extra for extra, names in self.extras.items() if names & distributions),
|
|
104
|
+
"",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def manifest(package: Package) -> Manifest:
|
|
109
|
+
"""What *package*'s ``pyproject.toml`` declares that the rule reads."""
|
|
110
|
+
from livery.extensions.python._backend import plugin_hosts
|
|
111
|
+
from livery.extensions.python._installed import requirement_name
|
|
112
|
+
|
|
113
|
+
pyproject = package.directory / "pyproject.toml"
|
|
114
|
+
data = tomllib.loads(pyproject.read_text("utf-8")) if pyproject.is_file() else {}
|
|
115
|
+
project = cast("dict[str, Any]", data.get("project", {}))
|
|
116
|
+
dependencies = cast("list[str]", project.get("dependencies", []))
|
|
117
|
+
extras = cast("dict[str, list[str]]", project.get("optional-dependencies", {}))
|
|
118
|
+
return Manifest(
|
|
119
|
+
path=f"{package.path}/pyproject.toml",
|
|
120
|
+
dependencies=frozenset(map(requirement_name, dependencies)),
|
|
121
|
+
extras={
|
|
122
|
+
extra: frozenset(map(requirement_name, requirements))
|
|
123
|
+
for extra, requirements in extras.items()
|
|
124
|
+
},
|
|
125
|
+
plugins=plugin_hosts(package),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def problems(context: RuleContext) -> list[str]:
|
|
130
|
+
"""Each import no declaration accounts for, in package and source order.
|
|
131
|
+
|
|
132
|
+
A package that holds the python extension is judged, each of its
|
|
133
|
+
sources under ``src`` that the run reaches, as this extension's
|
|
134
|
+
``references`` answer records them.
|
|
135
|
+
"""
|
|
136
|
+
from livery.extensions.python._backend import references
|
|
137
|
+
from livery.workshop import extension_set, neighbours
|
|
138
|
+
|
|
139
|
+
around = neighbours(context.packages)
|
|
140
|
+
found: list[str] = []
|
|
141
|
+
for package in context.packages_in_scope():
|
|
142
|
+
if "python" not in extension_set(package):
|
|
143
|
+
continue
|
|
144
|
+
declared = manifest(package)
|
|
145
|
+
shipped = f"{package.path}/src/"
|
|
146
|
+
for source in references(package):
|
|
147
|
+
if not source.path.startswith(shipped):
|
|
148
|
+
continue
|
|
149
|
+
if context.files is not None and source.path not in context.files:
|
|
150
|
+
continue
|
|
151
|
+
found += judged(source, declared, around)
|
|
152
|
+
return found
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def judged(
|
|
156
|
+
source: SourceReferences, declared: Manifest, around: Neighbours
|
|
157
|
+
) -> list[str]:
|
|
158
|
+
"""What *source* imports that *declared* does not account for, one line each.
|
|
159
|
+
|
|
160
|
+
Import-time references are judged before lazy ones, and each
|
|
161
|
+
distribution is named once per source, at the first reference that
|
|
162
|
+
needs it. A namespace several distributions share is judged through
|
|
163
|
+
the names under it that the source references, since those decide
|
|
164
|
+
which distribution the source needs.
|
|
165
|
+
"""
|
|
166
|
+
from livery.extensions.python._installed import installed
|
|
167
|
+
|
|
168
|
+
judgeable = [
|
|
169
|
+
reference
|
|
170
|
+
for reference in source.references
|
|
171
|
+
if not reference.type_checking and not reference.optional
|
|
172
|
+
]
|
|
173
|
+
ordered = [
|
|
174
|
+
*(reference for reference in judgeable if not reference.lazy),
|
|
175
|
+
*(reference for reference in judgeable if reference.lazy),
|
|
176
|
+
]
|
|
177
|
+
names = {reference.name for reference in judgeable}
|
|
178
|
+
programs = declared.loaded_by(source.module)
|
|
179
|
+
reported: set[object] = set()
|
|
180
|
+
found: list[str] = []
|
|
181
|
+
for reference in ordered:
|
|
182
|
+
name = reference.name
|
|
183
|
+
if _standard(name) or _member(name, around):
|
|
184
|
+
continue
|
|
185
|
+
environment = installed()
|
|
186
|
+
providers = environment.providers(name)
|
|
187
|
+
if not providers:
|
|
188
|
+
top = name.partition(".")[0]
|
|
189
|
+
if top not in reported:
|
|
190
|
+
reported.add(top)
|
|
191
|
+
found.append(
|
|
192
|
+
f"{source.path} imports {name}, and no distribution installed"
|
|
193
|
+
" in the workspace's environment provides it: declare the"
|
|
194
|
+
f" distribution that does in {declared.path}, then"
|
|
195
|
+
f" `{prog()} sync` installs it; one an optional extra declares"
|
|
196
|
+
" installs once the package's dev-extras lists that extra"
|
|
197
|
+
)
|
|
198
|
+
continue
|
|
199
|
+
if len(providers) > 1 and any(other.startswith(name + ".") for other in names):
|
|
200
|
+
continue
|
|
201
|
+
allowed = set(declared.dependencies)
|
|
202
|
+
for program in programs:
|
|
203
|
+
allowed |= environment.brings(program)
|
|
204
|
+
if reference.lazy:
|
|
205
|
+
for extra in declared.extras.values():
|
|
206
|
+
allowed |= extra
|
|
207
|
+
if providers & allowed or providers in reported:
|
|
208
|
+
continue
|
|
209
|
+
reported.add(providers)
|
|
210
|
+
found.append(_refusal(source, reference, providers, declared))
|
|
211
|
+
return found
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _refusal(
|
|
215
|
+
source: SourceReferences,
|
|
216
|
+
reference: ReferencedName,
|
|
217
|
+
providers: frozenset[str],
|
|
218
|
+
declared: Manifest,
|
|
219
|
+
) -> str:
|
|
220
|
+
"""The line that names *reference*'s module, its distribution and the manifest."""
|
|
221
|
+
named = " or ".join(sorted(providers))
|
|
222
|
+
if reference.lazy:
|
|
223
|
+
return (
|
|
224
|
+
f"{source.path} imports {reference.name} inside a function, and"
|
|
225
|
+
f" {named} provides it, which {declared.path} declares neither in"
|
|
226
|
+
f" {DEPENDENCIES} nor in an optional extra: declare it in one of them"
|
|
227
|
+
)
|
|
228
|
+
extra = declared.extra_naming(providers)
|
|
229
|
+
if extra:
|
|
230
|
+
return (
|
|
231
|
+
f"{source.path} imports {reference.name} at import time, and {named}"
|
|
232
|
+
f" provides it, which {declared.path} declares in its optional extra"
|
|
233
|
+
f" {extra!r} alone: import it inside the function that uses it, or"
|
|
234
|
+
f" declare it in {DEPENDENCIES}"
|
|
235
|
+
)
|
|
236
|
+
return (
|
|
237
|
+
f"{source.path} imports {reference.name} at import time, and {named}"
|
|
238
|
+
f" provides it, which {declared.path} does not declare in {DEPENDENCIES}:"
|
|
239
|
+
" declare it there"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _standard(name: str) -> bool:
|
|
244
|
+
"""Whether *name* is in the standard library of the running interpreter."""
|
|
245
|
+
return name.partition(".")[0] in sys.stdlib_module_names
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _member(name: str, around: Neighbours) -> bool:
|
|
249
|
+
"""Whether a workspace member owns *name*, or *name* is a namespace above one."""
|
|
250
|
+
return bool(around.owner_of(name)) or any(
|
|
251
|
+
root.startswith(name + ".") for root in around.owners
|
|
252
|
+
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# livery-extensions-python
|
|
2
|
+
|
|
3
|
+
Python distributions: each package's version stamped, its wheel and
|
|
4
|
+
sdist built, published to a python index and verified, its suite run
|
|
5
|
+
against the installed wheel for a release, and the layering rules over
|
|
6
|
+
the python sources. A package turns it on by listing the extension in
|
|
7
|
+
its own `workshop.toml`:
|
|
8
|
+
|
|
9
|
+
```toml
|
|
10
|
+
extensions = ["python"]
|
|
11
|
+
name = "acme-core"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The package's `pyproject.toml` is its manifest: it declares the
|
|
15
|
+
version, the requirements and the scripts. `fm new.project` lists
|
|
16
|
+
python at the root, since the workspace's own tests and `tasks.py` are
|
|
17
|
+
python. A workspace that does not list it has no python steps, no
|
|
18
|
+
`python-library` template, and no way to create a `workshop-extension`
|
|
19
|
+
package, whose template extends `python-library`. The nanobind
|
|
20
|
+
extension requires python, since its packages are python
|
|
21
|
+
distributions, and its distribution requires this one.
|
|
22
|
+
|
|
23
|
+
## A new package
|
|
24
|
+
|
|
25
|
+
`fm new.package.python-library <name>` creates a python package while
|
|
26
|
+
the workspace lists python: a typed module under `src/`, its tests, and
|
|
27
|
+
a `pyproject.toml` uv builds. `--command=<script>` adds the script
|
|
28
|
+
`<script>` to `[project.scripts]`, which runs `main()` in the package's
|
|
29
|
+
`__main__.py`, and a test of it; without it the package has no script.
|
|
30
|
+
A workspace that does not list python has no such verb: list the
|
|
31
|
+
extension in `[workspace] extensions` and run `fm sync`, which installs
|
|
32
|
+
`livery-extensions-python`, and the verb exists. Until then
|
|
33
|
+
`fm new.package.workshop-extension` refuses, naming python and its
|
|
34
|
+
distribution.
|
|
35
|
+
|
|
36
|
+
## A package's files
|
|
37
|
+
|
|
38
|
+
python gives the paths of each package whose set holds it these
|
|
39
|
+
categories:
|
|
40
|
+
|
|
41
|
+
| Category | Patterns |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `test` | `tests/**/test_*.py`, `tests/**/*_test.py` |
|
|
44
|
+
| `test-support` | `tests/**` |
|
|
45
|
+
| `source` | `src/**` |
|
|
46
|
+
|
|
47
|
+
The most specific pattern wins, so a test module under `tests/` is a
|
|
48
|
+
test and every other file there, `conftest.py` among them, supports
|
|
49
|
+
the tests.
|
|
50
|
+
|
|
51
|
+
## A release
|
|
52
|
+
|
|
53
|
+
python adds a step to each phase of a python package's release:
|
|
54
|
+
|
|
55
|
+
- `stamp.python` writes the version into `pyproject.toml` and into
|
|
56
|
+
each `__init__.py` under `src/` that carries `__version__`.
|
|
57
|
+
- `build.python` builds the wheel and the sdist with `uv build` into
|
|
58
|
+
the package's `dist/`, from an empty `dist/` each time. The wheel
|
|
59
|
+
carries the package's `docs/` as the module's `_docs`, copied for
|
|
60
|
+
the build and removed after it. The build refuses a wheel tagged for
|
|
61
|
+
a platform: python's build ships a pure wheel, tagged `none-any`, so
|
|
62
|
+
a package with compiled code lists the extension that builds its
|
|
63
|
+
platform wheels, such as nanobind.
|
|
64
|
+
- `test.python` installs the built wheel into a fresh environment and
|
|
65
|
+
runs the package's tests against the installed copy, once per
|
|
66
|
+
resolution the release proves: `floor` installs every direct
|
|
67
|
+
dependency at its declared floor, `latest` the newest, and `head` the
|
|
68
|
+
versions the workspace lock pins. The wheel resolves from the
|
|
69
|
+
co-released set's `dist/` directories and the workspace's indexes
|
|
70
|
+
alone, and installs with its `test` extra when the package declares
|
|
71
|
+
one. The test toolchain installs after the wheel, at the lock's
|
|
72
|
+
pins, and a toolchain install that moves a direct dependency the leg
|
|
73
|
+
resolved refuses, naming it.
|
|
74
|
+
- `publish.python` uploads the wheel and the sdist with `uv publish` to
|
|
75
|
+
the registry target the release resolves. An empty upload address
|
|
76
|
+
refuses, since uv's own default is PyPI. An empty
|
|
77
|
+
`UV_PUBLISH_TOKEN` never reaches uv, so trusted publishing still
|
|
78
|
+
runs, and a version the index already holds is walked past.
|
|
79
|
+
- `verify.python`, which `fm release.verify` runs, installs the
|
|
80
|
+
released version alone into a plain environment and runs the
|
|
81
|
+
package's tests at the released tree against it.
|
|
82
|
+
|
|
83
|
+
The release train probes the python index the registry ladder resolves
|
|
84
|
+
for the package, through the simple API, before it uploads.
|
|
85
|
+
|
|
86
|
+
## fm run
|
|
87
|
+
|
|
88
|
+
python answers `executables` with the scripts a package's
|
|
89
|
+
`pyproject.toml` names under `[project.scripts]`. `fm run` starts the
|
|
90
|
+
one it names from the workspace's environment, which `fm sync`
|
|
91
|
+
installs, with the words after the dashes, and exits with its code. A
|
|
92
|
+
script the environment lacks refuses, naming `fm sync`.
|
|
93
|
+
|
|
94
|
+
## The layering rules
|
|
95
|
+
|
|
96
|
+
python answers the `references` query for python sources from one
|
|
97
|
+
parse per gate, memoised by the file's path, size and modification
|
|
98
|
+
time. Its three steps of the layering phase read the same parse, over
|
|
99
|
+
the files a narrowed run reaches or every source:
|
|
100
|
+
|
|
101
|
+
- `private-reaches`: no package's source reaches another
|
|
102
|
+
distribution's private name, apart from the reaches the root
|
|
103
|
+
contract's `[[python.private-reaches]]` allows, each with its reason.
|
|
104
|
+
An entry no source uses any more refuses.
|
|
105
|
+
- `runner-terminal`: a module that imports the runner asks
|
|
106
|
+
`livery.footman.attended()` whether a person is there, never the
|
|
107
|
+
terminal (`sys.stdin.isatty()` and its relatives), since the
|
|
108
|
+
terminal knows nothing of `--no-input` or `--dry-run`. The runner's
|
|
109
|
+
own sources, and a module that never imports the runner, are exempt.
|
|
110
|
+
The step judges the root's `tasks.py` with the packages' sources.
|
|
111
|
+
- `declared-imports`: at import time, a module under a package's `src`
|
|
112
|
+
imports the standard library, the workspace's own members, and the
|
|
113
|
+
distributions its `pyproject.toml` declares in
|
|
114
|
+
`[project.dependencies]`. Inside a function it may also import a
|
|
115
|
+
distribution an optional extra declares. An import under
|
|
116
|
+
`TYPE_CHECKING`, or in a `try` that handles `ImportError`, is not
|
|
117
|
+
judged. A module a program loads as a plugin, named under the
|
|
118
|
+
`footman.tasks` or `footman.builtin` entry points for the runner or
|
|
119
|
+
`pytest11` for pytest, may also import what that program brings: its
|
|
120
|
+
distribution and the distributions it requires. A refusal names the
|
|
121
|
+
source, the module, the distribution and the `pyproject.toml` to
|
|
122
|
+
declare it in.
|
|
123
|
+
|
|
124
|
+
An allowed reach is a table of the root contract:
|
|
125
|
+
|
|
126
|
+
```toml
|
|
127
|
+
[[python.private-reaches]]
|
|
128
|
+
from = "acme-tools"
|
|
129
|
+
reaches = "acme.core._registry.purge"
|
|
130
|
+
reason = "the loop deletes published versions until the admin protocol exists"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Requirements
|
|
134
|
+
|
|
135
|
+
python's requirement writers take a sibling that holds python alone. A
|
|
136
|
+
build or runtime edge writes the sibling's requirement into the
|
|
137
|
+
package's `[project.dependencies]` at the edge's floor; a test edge
|
|
138
|
+
writes it into the `test` extra of `[project.optional-dependencies]`,
|
|
139
|
+
which is python's answer to `test-requirements`, and which the
|
|
140
|
+
release's isolated test installs the wheel with. A manifest with no
|
|
141
|
+
`[project]` table refuses, and so does a test extra in a form the
|
|
142
|
+
edit does not take, naming the line to write by hand.
|
|
143
|
+
|
|
144
|
+
## The queries it declares
|
|
145
|
+
|
|
146
|
+
- `python.test.runner` and `python.examples.runner`: who runs the
|
|
147
|
+
python packages' suites, several in one call, and one package's
|
|
148
|
+
documentation examples. python answers both while pytest is listed,
|
|
149
|
+
and pytest's test and examples steps ask them.
|
|
150
|
+
- `python.test.preview`: who prints a machine's coverage preview after
|
|
151
|
+
its suites ran. The coverage extension answers it while python is
|
|
152
|
+
listed.
|
|
153
|
+
|
|
154
|
+
## Its public names
|
|
155
|
+
|
|
156
|
+
`livery.extensions.python` serves the names an extension that builds a
|
|
157
|
+
python package's wheels with a tool of its own reads, as nanobind
|
|
158
|
+
does:
|
|
159
|
+
|
|
160
|
+
- `module_docs(package)` copies the package's `docs/` into its source
|
|
161
|
+
tree as the wheel's embedded `_docs` for one build, and removes the
|
|
162
|
+
copy after it.
|
|
163
|
+
- `run_isolated_test(package, root, ...)` installs a built wheel into a
|
|
164
|
+
fresh environment and runs the package's tests against the installed
|
|
165
|
+
copy.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# This package's section of the workspace site, hand-authored.
|
|
2
|
+
# Labels map to pages relative to this docs/ tree, or to nested
|
|
3
|
+
# lists. Every authored page must appear here, and every entry must
|
|
4
|
+
# name a real page. A generator owns a block between `# nav:begin
|
|
5
|
+
# <name>` and `# nav:end <name>` markers (tasks, tools); the emitter's
|
|
6
|
+
# own sections, changelog, coverage and api, are blocks too: place a
|
|
7
|
+
# pair where one belongs, and a section without one lands after the
|
|
8
|
+
# authored entries, changelog and coverage ahead of the tasks block,
|
|
9
|
+
# api last.
|
|
10
|
+
nav = [
|
|
11
|
+
{ "Index" = "index.md" },
|
|
12
|
+
# nav:begin tasks
|
|
13
|
+
# nav:end tasks
|
|
14
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Read what the environment's installed distributions ship and require.
|
|
2
|
+
|
|
3
|
+
The declared-imports rule asks the environment two questions: which
|
|
4
|
+
installed distribution ships the module a source imports, and what a
|
|
5
|
+
distribution brings with it. Both answers come from the distributions'
|
|
6
|
+
own metadata (``importlib.metadata``) beside the running interpreter,
|
|
7
|
+
which is the workspace's environment when the gate runs, read once per
|
|
8
|
+
process. The answer reads the same files on every platform: a regular
|
|
9
|
+
install's ``RECORD``, and the path an editable install's ``.pth`` file
|
|
10
|
+
adds, so a workspace member installed for development and the same
|
|
11
|
+
distribution installed from its wheel ship the same modules.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import csv
|
|
17
|
+
import functools
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from importlib import machinery, metadata
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
#: The name at the start of a requirement, before its extras, version,
|
|
25
|
+
#: marker or URL.
|
|
26
|
+
_NAME = re.compile(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
27
|
+
|
|
28
|
+
#: A marker that makes a requirement part of an optional extra.
|
|
29
|
+
_EXTRA = re.compile(r"\bextra\b")
|
|
30
|
+
|
|
31
|
+
#: The suffixes a module's file ends with on this interpreter, longest
|
|
32
|
+
#: first, so ``.cpython-314-darwin.so`` wins over ``.so``.
|
|
33
|
+
_SUFFIXES = tuple(sorted(machinery.all_suffixes(), key=len, reverse=True))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalized(name: str) -> str:
|
|
37
|
+
"""*name* as distributions are compared: lower case, each run of ``-_.`` a dash."""
|
|
38
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def requirement_name(requirement: str) -> str:
|
|
42
|
+
"""The normalized distribution name a requirement string names; empty for none."""
|
|
43
|
+
match = _NAME.match(requirement)
|
|
44
|
+
return normalized(match.group(1)) if match else ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Installed:
|
|
49
|
+
"""What the environment's distributions ship, and what each requires.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
modules: Each module path a distribution ships, and each package
|
|
53
|
+
path above it, to the distributions shipping it, by
|
|
54
|
+
normalized name.
|
|
55
|
+
requires: Each installed distribution, by normalized name, to
|
|
56
|
+
the distributions it requires outside its optional extras.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
modules: Mapping[str, frozenset[str]]
|
|
60
|
+
requires: Mapping[str, frozenset[str]]
|
|
61
|
+
|
|
62
|
+
def providers(self, name: str) -> frozenset[str]:
|
|
63
|
+
"""The distributions shipping the module that *name* names; empty for none.
|
|
64
|
+
|
|
65
|
+
The longest dotted prefix of *name* that is a module decides,
|
|
66
|
+
so ``yaml.safe_load`` resolves to the distribution that ships
|
|
67
|
+
``yaml``, and a namespace several distributions share to all
|
|
68
|
+
of them.
|
|
69
|
+
"""
|
|
70
|
+
parts = name.split(".")
|
|
71
|
+
for end in range(len(parts), 0, -1):
|
|
72
|
+
found = self.modules.get(".".join(parts[:end]))
|
|
73
|
+
if found:
|
|
74
|
+
return found
|
|
75
|
+
return frozenset()
|
|
76
|
+
|
|
77
|
+
def brings(self, distribution: str) -> frozenset[str]:
|
|
78
|
+
"""*distribution* and every distribution it requires, transitively.
|
|
79
|
+
|
|
80
|
+
A requirement under any marker but an extra's counts, whatever
|
|
81
|
+
the platform, so the answer is the same on every machine.
|
|
82
|
+
"""
|
|
83
|
+
found: set[str] = set()
|
|
84
|
+
pending = [normalized(distribution)]
|
|
85
|
+
while pending:
|
|
86
|
+
name = pending.pop()
|
|
87
|
+
if name not in found:
|
|
88
|
+
found.add(name)
|
|
89
|
+
pending.extend(self.requires.get(name, ()))
|
|
90
|
+
return frozenset(found)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@functools.cache
|
|
94
|
+
def installed() -> Installed:
|
|
95
|
+
"""The environment's distributions, read from their metadata once per process."""
|
|
96
|
+
return read_installed(metadata.distributions())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_installed(distributions: Iterable[metadata.Distribution]) -> Installed:
|
|
100
|
+
"""What *distributions* ship and require, read from their metadata."""
|
|
101
|
+
modules: dict[str, set[str]] = {}
|
|
102
|
+
requires: dict[str, frozenset[str]] = {}
|
|
103
|
+
for distribution in distributions:
|
|
104
|
+
about = distribution.metadata
|
|
105
|
+
name = normalized(about["Name"]) if "Name" in about else ""
|
|
106
|
+
if not name:
|
|
107
|
+
continue
|
|
108
|
+
requires[name] = requires.get(name, frozenset()) | _required(distribution)
|
|
109
|
+
for module in _shipped(distribution):
|
|
110
|
+
for end in range(1, len(module) + 1):
|
|
111
|
+
modules.setdefault(".".join(module[:end]), set()).add(name)
|
|
112
|
+
return Installed(
|
|
113
|
+
modules={module: frozenset(names) for module, names in modules.items()},
|
|
114
|
+
requires=requires,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _required(distribution: metadata.Distribution) -> frozenset[str]:
|
|
119
|
+
"""The distributions *distribution* requires outside its optional extras."""
|
|
120
|
+
found: set[str] = set()
|
|
121
|
+
for requirement in distribution.requires or ():
|
|
122
|
+
spec, _, marker = requirement.partition(";")
|
|
123
|
+
if _EXTRA.search(marker):
|
|
124
|
+
continue
|
|
125
|
+
name = requirement_name(spec)
|
|
126
|
+
if name:
|
|
127
|
+
found.add(name)
|
|
128
|
+
return frozenset(found)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _shipped(distribution: metadata.Distribution) -> Iterator[tuple[str, ...]]:
|
|
132
|
+
"""Each module *distribution* ships, as its dotted parts.
|
|
133
|
+
|
|
134
|
+
A regular install lists every file in its ``RECORD``. An editable
|
|
135
|
+
install lists a ``.pth`` file at the top of the environment instead,
|
|
136
|
+
whose lines add directories to the import path: what those hold is
|
|
137
|
+
the distribution's.
|
|
138
|
+
"""
|
|
139
|
+
for file in _files(distribution):
|
|
140
|
+
parts = file.split("/")
|
|
141
|
+
if len(parts) == 1 and file.endswith(".pth"):
|
|
142
|
+
pth = Path(str(distribution.locate_file(file)))
|
|
143
|
+
for directory in _pth_directories(pth):
|
|
144
|
+
yield from _modules_under(directory, ())
|
|
145
|
+
continue
|
|
146
|
+
module = _module_name(parts[-1])
|
|
147
|
+
if module is None or not all(part.isidentifier() for part in parts[:-1]):
|
|
148
|
+
continue
|
|
149
|
+
yield tuple(parts[:-1] if module == "__init__" else [*parts[:-1], module])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _files(distribution: metadata.Distribution) -> list[str]:
|
|
153
|
+
"""The files *distribution* installed, each relative to its environment, posix.
|
|
154
|
+
|
|
155
|
+
Read from ``RECORD`` as text, which costs a fraction of building
|
|
156
|
+
a path object for each of the tens of thousands of lines an
|
|
157
|
+
environment lists; an install without one answers through
|
|
158
|
+
``importlib.metadata``.
|
|
159
|
+
"""
|
|
160
|
+
record = distribution.read_text("RECORD")
|
|
161
|
+
if record is None:
|
|
162
|
+
return [str(file) for file in distribution.files or ()]
|
|
163
|
+
return [row[0] for row in csv.reader(record.splitlines()) if row and row[0]]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pth_directories(pth: Path) -> Iterator[Path]:
|
|
167
|
+
"""The directories the ``.pth`` file *pth* adds to the import path.
|
|
168
|
+
|
|
169
|
+
A line that is empty, a comment, or code (``import ...``) adds
|
|
170
|
+
none; a relative line is read against the file's own directory, as
|
|
171
|
+
the interpreter reads it.
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
lines = pth.read_text("utf-8").splitlines()
|
|
175
|
+
except (OSError, UnicodeDecodeError):
|
|
176
|
+
return
|
|
177
|
+
for line in lines:
|
|
178
|
+
entry = line.strip()
|
|
179
|
+
if not entry or entry.startswith(("#", "import ", "import\t")):
|
|
180
|
+
continue
|
|
181
|
+
directory = pth.parent / entry
|
|
182
|
+
if directory.is_dir():
|
|
183
|
+
yield directory
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _modules_under(
|
|
187
|
+
directory: Path, prefix: tuple[str, ...]
|
|
188
|
+
) -> Iterator[tuple[str, ...]]:
|
|
189
|
+
"""What *directory* on the import path makes importable, under *prefix*.
|
|
190
|
+
|
|
191
|
+
A regular package (a directory holding ``__init__.py``) is named
|
|
192
|
+
whole, since everything under it is the package's. A directory
|
|
193
|
+
without one is a namespace portion, read through to what it holds.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
entries = sorted(directory.iterdir())
|
|
197
|
+
except OSError:
|
|
198
|
+
return
|
|
199
|
+
for entry in entries:
|
|
200
|
+
if entry.is_dir():
|
|
201
|
+
if entry.name == "__pycache__" or not entry.name.isidentifier():
|
|
202
|
+
continue
|
|
203
|
+
if (entry / "__init__.py").is_file():
|
|
204
|
+
yield (*prefix, entry.name)
|
|
205
|
+
else:
|
|
206
|
+
yield from _modules_under(entry, (*prefix, entry.name))
|
|
207
|
+
continue
|
|
208
|
+
module = _module_name(entry.name)
|
|
209
|
+
if module is not None and module != "__init__":
|
|
210
|
+
yield (*prefix, module)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _module_name(file: str) -> str | None:
|
|
214
|
+
"""The module a file named *file* is, by its suffix; None for no module."""
|
|
215
|
+
for suffix in _SUFFIXES:
|
|
216
|
+
if file.endswith(suffix):
|
|
217
|
+
module = file[: -len(suffix)]
|
|
218
|
+
return module if module.isidentifier() else None
|
|
219
|
+
return None
|