nab-python 0.0.1__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.
- nab_python/__init__.py +1 -0
- nab_python/_build/__init__.py +1 -0
- nab_python/_build/env.py +364 -0
- nab_python/_build/errors.py +17 -0
- nab_python/_build/runner.py +254 -0
- nab_python/_lockfile/__init__.py +1 -0
- nab_python/_lockfile/builder.py +339 -0
- nab_python/_lockfile/disjointness.py +207 -0
- nab_python/_lockfile/pylock.py +323 -0
- nab_python/_lockfile/requirements.py +121 -0
- nab_python/_packaging_provider.py +98 -0
- nab_python/_provider/__init__.py +1 -0
- nab_python/_provider/build_remote.py +95 -0
- nab_python/_provider/extras.py +231 -0
- nab_python/_provider/listing.py +442 -0
- nab_python/_provider/lookahead.py +156 -0
- nab_python/_provider/metadata_resolver.py +450 -0
- nab_python/_provider/priority.py +174 -0
- nab_python/_provider/sources.py +215 -0
- nab_python/_testing/__init__.py +1 -0
- nab_python/_testing/coordinator_fake.py +240 -0
- nab_python/_vcs_admission.py +209 -0
- nab_python/_vendor/__init__.py +6 -0
- nab_python/_vendor/packaging/LICENSE +3 -0
- nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
- nab_python/_vendor/packaging/LICENSE.BSD +23 -0
- nab_python/_vendor/packaging/PROVENANCE.md +73 -0
- nab_python/_vendor/packaging/__init__.py +15 -0
- nab_python/_vendor/packaging/_elffile.py +108 -0
- nab_python/_vendor/packaging/_manylinux.py +265 -0
- nab_python/_vendor/packaging/_musllinux.py +88 -0
- nab_python/_vendor/packaging/_parser.py +394 -0
- nab_python/_vendor/packaging/_structures.py +33 -0
- nab_python/_vendor/packaging/_tokenizer.py +196 -0
- nab_python/_vendor/packaging/dependency_groups.py +302 -0
- nab_python/_vendor/packaging/direct_url.py +325 -0
- nab_python/_vendor/packaging/errors.py +94 -0
- nab_python/_vendor/packaging/licenses/__init__.py +186 -0
- nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
- nab_python/_vendor/packaging/markers.py +506 -0
- nab_python/_vendor/packaging/metadata.py +964 -0
- nab_python/_vendor/packaging/py.typed +0 -0
- nab_python/_vendor/packaging/pylock.py +910 -0
- nab_python/_vendor/packaging/ranges.py +1803 -0
- nab_python/_vendor/packaging/requirements.py +132 -0
- nab_python/_vendor/packaging/specifiers.py +1141 -0
- nab_python/_vendor/packaging/tags.py +929 -0
- nab_python/_vendor/packaging/utils.py +296 -0
- nab_python/_vendor/packaging/version.py +1230 -0
- nab_python/build_backend.py +184 -0
- nab_python/config.py +805 -0
- nab_python/download.py +170 -0
- nab_python/fetch.py +827 -0
- nab_python/lockfile.py +238 -0
- nab_python/metadata.py +145 -0
- nab_python/provider.py +1235 -0
- nab_python/py.typed +0 -0
- nab_python/requirements_file.py +180 -0
- nab_python/resolve.py +497 -0
- nab_python/universal/__init__.py +1 -0
- nab_python/universal/matrix.py +235 -0
- nab_python/universal/provider.py +214 -0
- nab_python/universal/reresolve.py +310 -0
- nab_python/universal/resolve.py +508 -0
- nab_python/universal/validate.py +439 -0
- nab_python/universal/wheel_selection.py +327 -0
- nab_python/workspace.py +214 -0
- nab_python-0.0.1.dist-info/METADATA +49 -0
- nab_python-0.0.1.dist-info/RECORD +71 -0
- nab_python-0.0.1.dist-info/WHEEL +4 -0
- nab_python-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Build a :class:`LockInput` from a finished resolve.
|
|
2
|
+
|
|
3
|
+
The provider's caches still hold the listings the resolver consumed
|
|
4
|
+
when this runs, so artefact hashes and per-file Requires-Python can
|
|
5
|
+
be read directly without a second fetch. This module also owns the
|
|
6
|
+
``read_lockfile_anchor`` helper used by ``nab lock`` to keep
|
|
7
|
+
``P<n>D`` durations stable across re-locks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Protocol, overload
|
|
15
|
+
|
|
16
|
+
import tomli
|
|
17
|
+
|
|
18
|
+
from nab_index.client import SdistFile, WheelFile
|
|
19
|
+
|
|
20
|
+
from .._vendor.packaging.utils import canonicalize_name
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
24
|
+
|
|
25
|
+
from nab_index.multi_index import IndexConfig
|
|
26
|
+
|
|
27
|
+
from .._vendor.packaging.version import Version
|
|
28
|
+
from ..lockfile import (
|
|
29
|
+
IndexPin,
|
|
30
|
+
LockInput,
|
|
31
|
+
PinShape,
|
|
32
|
+
SdistArtifact,
|
|
33
|
+
VcsPin,
|
|
34
|
+
WheelArtifact,
|
|
35
|
+
)
|
|
36
|
+
from ..provider import DistPolicy, LocalSource, VcsSource
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"MissingHashError",
|
|
41
|
+
"build_lock_input_from_provider",
|
|
42
|
+
"read_lockfile_anchor",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _LockInputIndex(Protocol):
|
|
47
|
+
"""Protocol for the InMemoryIndex slice the builder reads."""
|
|
48
|
+
|
|
49
|
+
def get_listing_index(self, package: str) -> str | None:
|
|
50
|
+
"""Return the configured index name that served ``package``."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _LockInputCoordinator(Protocol):
|
|
55
|
+
"""Protocol for the FetchCoordinator slice the builder reads."""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def index(self) -> _LockInputIndex:
|
|
59
|
+
"""The underlying index used to look up serving-index labels."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LockInputProvider(Protocol):
|
|
64
|
+
"""Structural protocol for the provider slice the builder reads.
|
|
65
|
+
|
|
66
|
+
Mirrors the public surface :class:`~nab_python.provider.Provider`
|
|
67
|
+
exposes that :func:`build_lock_input_from_provider` consumes; tests
|
|
68
|
+
may supply a stub without inheriting the full Provider class.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def coordinator(self) -> _LockInputCoordinator:
|
|
73
|
+
"""Coordinator used to look up the index that served a listing."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def local_source_for(self, canonical_name: str, /) -> LocalSource | None:
|
|
77
|
+
"""Return the configured LocalSource for ``canonical_name`` or None."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
def vcs_source_for(self, canonical_name: str, /) -> VcsSource | None:
|
|
81
|
+
"""Return the configured VcsSource for ``canonical_name`` or None."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
def dist_files_for(
|
|
85
|
+
self, canonical_name: str, version: Version, /
|
|
86
|
+
) -> list[WheelFile | SdistFile]:
|
|
87
|
+
"""Return the listing slice that matches ``(canonical_name, version)``."""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
def effective_dist_policy(self, canonical_name: str, /) -> DistPolicy:
|
|
91
|
+
"""Return the effective :class:`DistPolicy` for ``canonical_name``."""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MissingHashError(ValueError):
|
|
96
|
+
"""A distribution chosen by the resolver has no usable hash.
|
|
97
|
+
|
|
98
|
+
PEP 751 requires at least one hash per artefact. When an index
|
|
99
|
+
serves a wheel or sdist without a ``hashes`` map (rare on PyPI,
|
|
100
|
+
common on file:// indexes), the lock writer cannot emit a
|
|
101
|
+
spec-compliant entry. Surface the failure with the offending
|
|
102
|
+
package and filename so the user can either add a hash to their
|
|
103
|
+
local index or exclude the package.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_lockfile_anchor(path: Path) -> datetime | None:
|
|
108
|
+
"""Return the ``[tool.nab].created-at`` timestamp from ``path`` if any.
|
|
109
|
+
|
|
110
|
+
Used by ``nab lock`` to keep ``P<n>D`` durations stable across
|
|
111
|
+
re-locks: the anchor used for the previous resolve is read back
|
|
112
|
+
and reused unless the user passes ``--upgrade``.
|
|
113
|
+
|
|
114
|
+
Returns ``None`` when ``path`` does not exist, is not valid TOML,
|
|
115
|
+
is not a PEP 751-shaped pylock, or is missing the ``[tool.nab]``
|
|
116
|
+
block. Naive timestamps (no timezone offset) are coerced to UTC
|
|
117
|
+
for symmetry with the writer; this is informational provenance, so
|
|
118
|
+
a missing offset is recoverable rather than fatal.
|
|
119
|
+
"""
|
|
120
|
+
if not path.is_file():
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
with path.open("rb") as f:
|
|
124
|
+
data = tomli.load(f)
|
|
125
|
+
except (OSError, tomli.TOMLDecodeError):
|
|
126
|
+
return None
|
|
127
|
+
raw = data.get("tool", {}).get("nab", {}).get("created-at")
|
|
128
|
+
if isinstance(raw, datetime):
|
|
129
|
+
return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
|
|
130
|
+
if isinstance(raw, str):
|
|
131
|
+
try:
|
|
132
|
+
dt = datetime.fromisoformat(raw)
|
|
133
|
+
except ValueError:
|
|
134
|
+
return None
|
|
135
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_lock_input_from_provider(
|
|
140
|
+
provider: LockInputProvider,
|
|
141
|
+
pins: Mapping[str, Version],
|
|
142
|
+
*,
|
|
143
|
+
requires_python: str | None = None,
|
|
144
|
+
extras: Sequence[str] = (),
|
|
145
|
+
dependency_groups: Sequence[str] = (),
|
|
146
|
+
default_groups: Sequence[str] = (),
|
|
147
|
+
created_by: str = "nab",
|
|
148
|
+
indexes: Sequence[IndexConfig] = (),
|
|
149
|
+
) -> LockInput:
|
|
150
|
+
"""Build a :class:`LockInput` from a finished resolve.
|
|
151
|
+
|
|
152
|
+
``provider`` is the :class:`Provider` that drove the resolve;
|
|
153
|
+
its caches still hold the listings the resolver consumed. ``pins``
|
|
154
|
+
is the canonical-name -> :class:`Version` mapping returned by the
|
|
155
|
+
resolver after extras keys have been stripped.
|
|
156
|
+
|
|
157
|
+
``dependency_groups`` lists the PEP 735 groups whose requirements
|
|
158
|
+
were folded into this resolve; ``default_groups`` is the subset
|
|
159
|
+
that a default install (no ``--group`` flag) should apply.
|
|
160
|
+
|
|
161
|
+
All wheels and the sdist for each pinned version are recorded so
|
|
162
|
+
the lockfile is portable across architectures of the same Python.
|
|
163
|
+
"""
|
|
164
|
+
from ..lockfile import LocalPin, LockInput
|
|
165
|
+
|
|
166
|
+
lock_pins: dict[str, PinShape] = {}
|
|
167
|
+
for raw_name, version in pins.items():
|
|
168
|
+
canonical = canonicalize_name(raw_name)
|
|
169
|
+
local_source = provider.local_source_for(canonical)
|
|
170
|
+
if local_source is not None:
|
|
171
|
+
lock_pins[canonical] = LocalPin(
|
|
172
|
+
name=canonical,
|
|
173
|
+
version=str(version),
|
|
174
|
+
path=str(Path(local_source.path).resolve()),
|
|
175
|
+
)
|
|
176
|
+
continue
|
|
177
|
+
vcs_source = provider.vcs_source_for(canonical)
|
|
178
|
+
if vcs_source is not None:
|
|
179
|
+
lock_pins[canonical] = _vcs_pin_from_source(canonical, version, vcs_source)
|
|
180
|
+
continue
|
|
181
|
+
lock_pins[canonical] = _index_pin_from_listing(
|
|
182
|
+
provider, canonical, version, indexes
|
|
183
|
+
)
|
|
184
|
+
return LockInput(
|
|
185
|
+
pins=lock_pins,
|
|
186
|
+
requires_python=requires_python,
|
|
187
|
+
created_by=created_by,
|
|
188
|
+
extras=tuple(extras),
|
|
189
|
+
dependency_groups=tuple(dependency_groups),
|
|
190
|
+
default_groups=tuple(default_groups),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _index_pin_from_listing(
|
|
195
|
+
provider: LockInputProvider,
|
|
196
|
+
canonical: str,
|
|
197
|
+
version: Version,
|
|
198
|
+
indexes: Sequence[IndexConfig],
|
|
199
|
+
) -> IndexPin:
|
|
200
|
+
"""Construct an :class:`IndexPin` for an index-served package.
|
|
201
|
+
|
|
202
|
+
The recorded ``index`` is the URL of the configured index that
|
|
203
|
+
served the package's listing during the resolve, looked up from
|
|
204
|
+
the coordinator's :class:`InMemoryIndex` (which records the
|
|
205
|
+
serving index by name) and resolved against ``indexes`` for the
|
|
206
|
+
URL. When ``indexes`` is empty or the route is unknown the URL
|
|
207
|
+
falls back to the default Simple-API root.
|
|
208
|
+
|
|
209
|
+
Under :attr:`~nab_python.provider.DistPolicy.SDIST_INSTALL` the
|
|
210
|
+
package's wheels stayed in ``versions_cache`` as a possible
|
|
211
|
+
metadata source for the resolver; only the sdist is emitted
|
|
212
|
+
into the lock so installers download and build that archive.
|
|
213
|
+
"""
|
|
214
|
+
from ..fetch import DEFAULT_INDEX_URL
|
|
215
|
+
from ..lockfile import IndexPin, SdistArtifact, WheelArtifact
|
|
216
|
+
from ..provider import DistPolicy
|
|
217
|
+
|
|
218
|
+
files = list(provider.dist_files_for(canonical, version))
|
|
219
|
+
if provider.effective_dist_policy(canonical) is DistPolicy.SDIST_INSTALL:
|
|
220
|
+
files = [f for f in files if not isinstance(f, WheelFile)]
|
|
221
|
+
|
|
222
|
+
wheels = tuple(
|
|
223
|
+
_build_artifact(canonical, f, WheelArtifact)
|
|
224
|
+
for f in files
|
|
225
|
+
if isinstance(f, WheelFile)
|
|
226
|
+
)
|
|
227
|
+
sdist_file = next((f for f in files if isinstance(f, SdistFile)), None)
|
|
228
|
+
sdist = (
|
|
229
|
+
_build_artifact(canonical, sdist_file, SdistArtifact)
|
|
230
|
+
if sdist_file is not None
|
|
231
|
+
else None
|
|
232
|
+
)
|
|
233
|
+
requires_python = _common_requires_python(files)
|
|
234
|
+
serving_name = provider.coordinator.index.get_listing_index(canonical)
|
|
235
|
+
by_name = {ix.name: ix.url for ix in indexes}
|
|
236
|
+
|
|
237
|
+
if serving_name is not None and serving_name in by_name:
|
|
238
|
+
index_url = by_name[serving_name]
|
|
239
|
+
elif indexes:
|
|
240
|
+
index_url = indexes[0].url
|
|
241
|
+
else:
|
|
242
|
+
index_url = DEFAULT_INDEX_URL
|
|
243
|
+
return IndexPin(
|
|
244
|
+
name=canonical,
|
|
245
|
+
version=str(version),
|
|
246
|
+
index=index_url,
|
|
247
|
+
sdist=sdist,
|
|
248
|
+
wheels=wheels,
|
|
249
|
+
requires_python=requires_python,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@overload
|
|
254
|
+
def _build_artifact(
|
|
255
|
+
canonical: str,
|
|
256
|
+
source: WheelFile | SdistFile,
|
|
257
|
+
cls: type[WheelArtifact],
|
|
258
|
+
) -> WheelArtifact: ...
|
|
259
|
+
@overload
|
|
260
|
+
def _build_artifact(
|
|
261
|
+
canonical: str,
|
|
262
|
+
source: WheelFile | SdistFile,
|
|
263
|
+
cls: type[SdistArtifact],
|
|
264
|
+
) -> SdistArtifact: ...
|
|
265
|
+
def _build_artifact(
|
|
266
|
+
canonical: str,
|
|
267
|
+
source: WheelFile | SdistFile,
|
|
268
|
+
cls: type[WheelArtifact | SdistArtifact],
|
|
269
|
+
) -> WheelArtifact | SdistArtifact:
|
|
270
|
+
hashes = _filter_acceptable_hashes(canonical, source.filename, source.hashes)
|
|
271
|
+
return cls(
|
|
272
|
+
filename=source.filename,
|
|
273
|
+
url=source.url,
|
|
274
|
+
hashes=hashes,
|
|
275
|
+
size=source.size,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _filter_acceptable_hashes(
|
|
280
|
+
canonical: str, filename: str, hashes: tuple[tuple[str, str], ...]
|
|
281
|
+
) -> tuple[tuple[str, str], ...]:
|
|
282
|
+
"""Return the subset of ``hashes`` whose algorithm is consumable.
|
|
283
|
+
|
|
284
|
+
Pip's hash-checking mode and PEP 751 both accept any of sha256,
|
|
285
|
+
sha384, or sha512; nab forwards every recorded entry so consumers
|
|
286
|
+
can pick. Raise :class:`MissingHashError` when none of the
|
|
287
|
+
acceptable algorithms are present.
|
|
288
|
+
"""
|
|
289
|
+
from ..lockfile import ACCEPTED_HASH_ALGORITHMS
|
|
290
|
+
|
|
291
|
+
accepted = tuple(
|
|
292
|
+
(algo, digest)
|
|
293
|
+
for algo, digest in sorted(hashes)
|
|
294
|
+
if algo in ACCEPTED_HASH_ALGORITHMS
|
|
295
|
+
)
|
|
296
|
+
if not accepted:
|
|
297
|
+
algos = sorted({algo for algo, _ in hashes})
|
|
298
|
+
msg = (
|
|
299
|
+
f"{canonical}: artefact {filename!r} has no acceptable hash"
|
|
300
|
+
f" (need one of {list(ACCEPTED_HASH_ALGORITHMS)!r}, got {algos!r})"
|
|
301
|
+
)
|
|
302
|
+
raise MissingHashError(msg)
|
|
303
|
+
return accepted
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _common_requires_python(files: Iterable[WheelFile | SdistFile]) -> str | None:
|
|
307
|
+
"""Return a single Requires-Python value if all files agree, else ``None``."""
|
|
308
|
+
seen: set[str] = set()
|
|
309
|
+
for f in files:
|
|
310
|
+
if f.requires_python is not None:
|
|
311
|
+
seen.add(f.requires_python)
|
|
312
|
+
if len(seen) == 1:
|
|
313
|
+
return next(iter(seen))
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _vcs_pin_from_source(canonical: str, version: Version, source: VcsSource) -> VcsPin:
|
|
318
|
+
"""Build a :class:`VcsPin` from a :class:`VcsSource`.
|
|
319
|
+
|
|
320
|
+
The commit id is the ``@<ref>`` portion of the source URL when
|
|
321
|
+
present, parsed via :class:`nab_index.vcs.VcsRequest`. Unparseable
|
|
322
|
+
URLs fall through to an empty commit id; the source URL itself is
|
|
323
|
+
recorded verbatim.
|
|
324
|
+
"""
|
|
325
|
+
from nab_index.vcs import VcsCloneError, VcsRequest
|
|
326
|
+
|
|
327
|
+
from ..lockfile import VcsPin
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
parsed = VcsRequest.parse(source.url)
|
|
331
|
+
commit_id = parsed.ref
|
|
332
|
+
except VcsCloneError:
|
|
333
|
+
commit_id = ""
|
|
334
|
+
return VcsPin(
|
|
335
|
+
name=canonical,
|
|
336
|
+
version=str(version),
|
|
337
|
+
repo_url=source.url,
|
|
338
|
+
commit_id=commit_id,
|
|
339
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Per-name marker disjointness validation for the PEP 751 emitter.
|
|
2
|
+
|
|
3
|
+
PEP 751 forbids two ``[[packages]]`` entries with the same name from
|
|
4
|
+
firing under one install context. This module owns the validator
|
|
5
|
+
that enumerates the install-context universe (environments x extras
|
|
6
|
+
powerset x dependency-groups powerset) and reports any pair that
|
|
7
|
+
collide, plus the bookkeeping helpers that prune the powerset axes
|
|
8
|
+
to the marker variables a same-name candidate actually references.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import functools
|
|
14
|
+
import itertools
|
|
15
|
+
import re
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
21
|
+
from collections.abc import Set as AbstractSet
|
|
22
|
+
|
|
23
|
+
from .._vendor.packaging.markers import Marker
|
|
24
|
+
from .._vendor.packaging.pylock import Package
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DisjointnessError",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DisjointnessError(ValueError):
|
|
33
|
+
"""Two same-name ``[[packages]]`` entries can fire under one context.
|
|
34
|
+
|
|
35
|
+
PEP 751 forbids ambiguous installer matches. When more than one
|
|
36
|
+
same-name entry has a marker that holds for the same install
|
|
37
|
+
context, the consumer cannot pick deterministically. The
|
|
38
|
+
validator surfaces the colliding name, the witness context
|
|
39
|
+
(environment + extras + groups), and the colliding versions so
|
|
40
|
+
the producer can either change the resolution or declare a
|
|
41
|
+
conflict.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_marker_disjointness(
|
|
46
|
+
packages: Sequence[Package],
|
|
47
|
+
*,
|
|
48
|
+
environments: Mapping[str, Mapping[str, str]],
|
|
49
|
+
extras: Sequence[str],
|
|
50
|
+
groups: Sequence[str],
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Confirm same-name ``[[packages]]`` entries are pairwise disjoint.
|
|
53
|
+
|
|
54
|
+
Builds the universe of install contexts as the cartesian product
|
|
55
|
+
of declared environments, the powerset of declared ``extras``,
|
|
56
|
+
and the powerset of declared ``dependency_groups``. For every
|
|
57
|
+
point in that universe and every package name, evaluates each
|
|
58
|
+
candidate entry's marker. When two or more entries hold for the
|
|
59
|
+
same point, raises :class:`DisjointnessError` with the witness.
|
|
60
|
+
|
|
61
|
+
The empty-environments path skips validation: a producer that
|
|
62
|
+
does not declare a universe cannot specify what "all envs" means
|
|
63
|
+
and the validator would over-report when entries have ``marker
|
|
64
|
+
is None``. Callers that emit per-tuple markers (universal
|
|
65
|
+
mode) populate ``LockInput.tuple_environments`` from the
|
|
66
|
+
matrix.
|
|
67
|
+
|
|
68
|
+
Powerset pruning: ``extras`` and ``dependency_groups`` are PEP
|
|
69
|
+
685 / PEP 735 marker variables that markers may or may not
|
|
70
|
+
reference. Materialising the full ``2**N`` powerset is
|
|
71
|
+
intractable for projects that declare many extras. Inspect each
|
|
72
|
+
marker's string form via :func:`_referenced_membership_names`
|
|
73
|
+
and only iterate the powerset over names that any marker
|
|
74
|
+
actually mentions. When no marker references a variable, that
|
|
75
|
+
powerset collapses to ``{()}`` and the cartesian explosion
|
|
76
|
+
disappears.
|
|
77
|
+
"""
|
|
78
|
+
if not environments:
|
|
79
|
+
return
|
|
80
|
+
by_name: defaultdict[str, list[Package]] = defaultdict(list)
|
|
81
|
+
for pkg in packages:
|
|
82
|
+
by_name[str(pkg.name)].append(pkg)
|
|
83
|
+
same_name_entries = [entries for entries in by_name.values() if len(entries) > 1]
|
|
84
|
+
if not same_name_entries:
|
|
85
|
+
return
|
|
86
|
+
candidate_markers = [pkg.marker for entries in same_name_entries for pkg in entries]
|
|
87
|
+
relevant_extras = _restrict_to_referenced(extras, candidate_markers, "extras")
|
|
88
|
+
relevant_groups = _restrict_to_referenced(
|
|
89
|
+
groups, candidate_markers, "dependency_groups"
|
|
90
|
+
)
|
|
91
|
+
extras_subsets = list(_powerset(relevant_extras))
|
|
92
|
+
group_subsets = list(_powerset(relevant_groups))
|
|
93
|
+
for entries in same_name_entries:
|
|
94
|
+
name = str(entries[0].name)
|
|
95
|
+
for env_label, env_dict in environments.items():
|
|
96
|
+
for extra_subset in extras_subsets:
|
|
97
|
+
for group_subset in group_subsets:
|
|
98
|
+
context: dict[str, str | AbstractSet[str]] = dict(env_dict)
|
|
99
|
+
context["extras"] = frozenset(extra_subset)
|
|
100
|
+
context["dependency_groups"] = frozenset(group_subset)
|
|
101
|
+
matching = [
|
|
102
|
+
pkg for pkg in entries if _marker_holds(pkg.marker, context)
|
|
103
|
+
]
|
|
104
|
+
if len(matching) <= 1:
|
|
105
|
+
continue
|
|
106
|
+
versions = sorted(
|
|
107
|
+
str(p.version) if p.version else "" for p in matching
|
|
108
|
+
)
|
|
109
|
+
msg = (
|
|
110
|
+
f"{name}: {len(matching)} entries fire under"
|
|
111
|
+
f" env={env_label!r} extras={sorted(extra_subset)!r}"
|
|
112
|
+
f" groups={sorted(group_subset)!r}: versions={versions}"
|
|
113
|
+
)
|
|
114
|
+
raise DisjointnessError(msg)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@functools.cache
|
|
118
|
+
def _membership_name_pattern(variable: str) -> re.Pattern[str]:
|
|
119
|
+
"""Compile (and cache) the regex matching ``"NAME" [not] in <variable>``.
|
|
120
|
+
|
|
121
|
+
Used to detect which extras / dependency-group names a marker
|
|
122
|
+
references. PEP 508 reserves ``extras`` (PEP 685) and
|
|
123
|
+
``dependency_groups`` (PEP 735) as bare-token marker variables,
|
|
124
|
+
so a literal-vs-variable membership test always serialises as
|
|
125
|
+
``"<lit>" [not] in <var>`` after :func:`Marker.__str__`
|
|
126
|
+
normalisation. Rejected alternatives: walking
|
|
127
|
+
``Marker._markers`` (private packaging API) or vendoring
|
|
128
|
+
``Marker.as_ast()`` from packaging PR #1145 (still open; loses
|
|
129
|
+
operand-vs-variable distinction in the proposed shape).
|
|
130
|
+
"""
|
|
131
|
+
return re.compile(
|
|
132
|
+
r"""(['"])([^'"]*)\1\s+(?:not\s+)?in\s+""" + re.escape(variable) + r"\b",
|
|
133
|
+
re.IGNORECASE,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _referenced_membership_names(
|
|
138
|
+
markers: Iterable[Marker | None], variable: str
|
|
139
|
+
) -> tuple[frozenset[str], bool]:
|
|
140
|
+
"""Return the literals any marker tests for membership in ``variable``.
|
|
141
|
+
|
|
142
|
+
``variable`` is one of ``"extras"`` or ``"dependency_groups"``;
|
|
143
|
+
a literal ``"foo"`` referenced as ``"foo" in extras`` (or its
|
|
144
|
+
``not in`` form) lands in the result. The regex matches
|
|
145
|
+
``str(marker)`` because :class:`Marker` re-emits a canonical
|
|
146
|
+
form where the literal is always quoted and the variable is
|
|
147
|
+
always a bare token.
|
|
148
|
+
|
|
149
|
+
Also returns a flag set when *any* marker contains the bare
|
|
150
|
+
``variable`` token; callers can use it to detect unusual marker
|
|
151
|
+
shapes that mention the variable but did not match the regex
|
|
152
|
+
(a future PEP form, a comparison flipped to put the variable
|
|
153
|
+
on the LHS, etc.) and fall back to a safe over-approximation.
|
|
154
|
+
"""
|
|
155
|
+
pattern = _membership_name_pattern(variable)
|
|
156
|
+
bare_token = re.compile(r"\b" + re.escape(variable) + r"\b")
|
|
157
|
+
found: set[str] = set()
|
|
158
|
+
has_bare_reference = False
|
|
159
|
+
for marker in markers:
|
|
160
|
+
if marker is None:
|
|
161
|
+
continue
|
|
162
|
+
text = str(marker)
|
|
163
|
+
if bare_token.search(text):
|
|
164
|
+
has_bare_reference = True
|
|
165
|
+
for match in pattern.finditer(text):
|
|
166
|
+
found.add(match.group(2))
|
|
167
|
+
return frozenset(found), has_bare_reference
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _restrict_to_referenced(
|
|
171
|
+
declared: Sequence[str],
|
|
172
|
+
markers: Sequence[Marker | None],
|
|
173
|
+
variable: str,
|
|
174
|
+
) -> tuple[str, ...]:
|
|
175
|
+
"""Restrict ``declared`` to the subset that any marker references.
|
|
176
|
+
|
|
177
|
+
``variable`` is the marker token (``"extras"`` or
|
|
178
|
+
``"dependency_groups"``). The intersection of declared names
|
|
179
|
+
and regex-matched literals shrinks the powerset axis to what
|
|
180
|
+
the markers actually depend on. When the bare token appears
|
|
181
|
+
in some marker but no literals were extracted (an unusual form
|
|
182
|
+
the regex did not anticipate), fall back to the full declared
|
|
183
|
+
list so the validator over-approximates rather than silently
|
|
184
|
+
misses a collision.
|
|
185
|
+
"""
|
|
186
|
+
referenced, has_bare = _referenced_membership_names(markers, variable)
|
|
187
|
+
if not has_bare:
|
|
188
|
+
return ()
|
|
189
|
+
if not referenced:
|
|
190
|
+
return tuple(declared)
|
|
191
|
+
return tuple(name for name in declared if name in referenced)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _marker_holds(
|
|
195
|
+
marker: Marker | None, context: Mapping[str, str | AbstractSet[str]]
|
|
196
|
+
) -> bool:
|
|
197
|
+
"""Return True when ``marker`` is absent or evaluates True under ``context``."""
|
|
198
|
+
if marker is None:
|
|
199
|
+
return True
|
|
200
|
+
return bool(marker.evaluate(dict(context)))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _powerset(items: Sequence[str]) -> Iterable[tuple[str, ...]]:
|
|
204
|
+
"""Yield every subset of ``items`` as a sorted tuple, including ``()``."""
|
|
205
|
+
seen = sorted(set(items))
|
|
206
|
+
for r in range(len(seen) + 1):
|
|
207
|
+
yield from itertools.combinations(seen, r)
|