livery-extensions-python 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. livery/extensions/python/__init__.py +51 -0
  2. livery/extensions/python/_answers.py +127 -0
  3. livery/extensions/python/_backend.py +1532 -0
  4. livery/extensions/python/_declared.py +252 -0
  5. livery/extensions/python/_docs/index.md +165 -0
  6. livery/extensions/python/_docs/nav.toml +14 -0
  7. livery/extensions/python/_installed.py +219 -0
  8. livery/extensions/python/_parse.py +324 -0
  9. livery/extensions/python/_reaches.py +165 -0
  10. livery/extensions/python/_steps.py +71 -0
  11. livery/extensions/python/_terminal.py +118 -0
  12. livery/extensions/python/_verify.py +112 -0
  13. livery/extensions/python/content/templates/python-library/LICENSE.jinja +21 -0
  14. livery/extensions/python/content/templates/python-library/README.md.jinja +7 -0
  15. livery/extensions/python/content/templates/python-library/pyproject.toml.jinja +43 -0
  16. livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__init__.py.jinja +15 -0
  17. livery/extensions/python/content/templates/python-library/src/{{ source_path }}/__main__.py.jinja +30 -0
  18. livery/extensions/python/content/templates/python-library/src/{{ source_path }}/py.typed +0 -0
  19. livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_cli.py.jinja +39 -0
  20. livery/extensions/python/content/templates/python-library/tests/test_{{ project_slug }}_package.py.jinja +22 -0
  21. livery/extensions/python/content/templates/python-library/workshop.toml.jinja +10 -0
  22. livery/extensions/python/extension.toml +165 -0
  23. livery/extensions/python/py.typed +0 -0
  24. livery_extensions_python-0.0.0.dist-info/METADATA +31 -0
  25. livery_extensions_python-0.0.0.dist-info/RECORD +28 -0
  26. livery_extensions_python-0.0.0.dist-info/WHEEL +4 -0
  27. livery_extensions_python-0.0.0.dist-info/entry_points.txt +3 -0
  28. livery_extensions_python-0.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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