nab-python 0.0.6__py3-none-any.whl → 0.0.8__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/_build/runner.py +14 -10
- nab_python/_lockfile/builder.py +62 -4
- nab_python/_lockfile/pylock.py +29 -6
- nab_python/_lockfile/requirements.py +16 -5
- nab_python/_packaging_provider.py +24 -2
- nab_python/_provider/extras.py +64 -33
- nab_python/_provider/listing.py +16 -4
- nab_python/_provider/metadata_resolver.py +59 -38
- nab_python/_provider/priority.py +4 -2
- nab_python/_provider/sources.py +181 -8
- nab_python/_vcs_admission.py +47 -1
- nab_python/_vendor/packaging/PROVENANCE.md +46 -77
- nab_python/_vendor/packaging/_manylinux.py +0 -2
- nab_python/_vendor/packaging/_musllinux.py +1 -1
- nab_python/_vendor/packaging/_ranges.py +17 -17
- nab_python/_vendor/packaging/dependency_groups.py +5 -4
- nab_python/_vendor/packaging/direct_url.py +17 -8
- nab_python/_vendor/packaging/markers.py +46 -9
- nab_python/_vendor/packaging/metadata.py +8 -2
- nab_python/_vendor/packaging/pylock.py +4 -1
- nab_python/_vendor/packaging/ranges.py +251 -57
- nab_python/_vendor/packaging/requirements.py +13 -2
- nab_python/_vendor/packaging/specifiers.py +6 -6
- nab_python/_vendor/packaging/tags.py +14 -5
- nab_python/_vendor/packaging/version.py +21 -5
- nab_python/build_backend.py +82 -38
- nab_python/config.py +99 -5
- nab_python/config_sources.py +34 -2
- nab_python/download.py +48 -9
- nab_python/fetch.py +50 -7
- nab_python/lockfile.py +32 -1
- nab_python/metadata.py +14 -2
- nab_python/provider.py +231 -48
- nab_python/requirements_file.py +273 -24
- nab_python/resolve.py +47 -16
- nab_python/universal/provider.py +25 -8
- nab_python/universal/reresolve.py +13 -0
- nab_python/universal/resolve.py +33 -7
- nab_python/universal/validate.py +19 -8
- nab_python/workspace.py +18 -5
- {nab_python-0.0.6.dist-info → nab_python-0.0.8.dist-info}/METADATA +3 -3
- nab_python-0.0.8.dist-info/RECORD +75 -0
- nab_python-0.0.6.dist-info/RECORD +0 -75
- {nab_python-0.0.6.dist-info → nab_python-0.0.8.dist-info}/WHEEL +0 -0
nab_python/_build/runner.py
CHANGED
|
@@ -33,12 +33,12 @@ import build
|
|
|
33
33
|
import pyproject_hooks
|
|
34
34
|
import tomli
|
|
35
35
|
|
|
36
|
-
from
|
|
36
|
+
from nab_resolver.resolver import ResolutionError
|
|
37
|
+
|
|
38
|
+
from .._vendor.packaging.requirements import InvalidRequirement, Requirement
|
|
37
39
|
from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
38
40
|
from .._vendor.packaging.utils import canonicalize_name
|
|
39
41
|
from .._vendor.packaging.version import InvalidVersion, Version
|
|
40
|
-
from nab_resolver.resolver import ResolutionError
|
|
41
|
-
|
|
42
42
|
from ..metadata import WheelMetadata
|
|
43
43
|
from .env import BuildEnvError, NabBuildEnv
|
|
44
44
|
from .errors import BuildBackendError
|
|
@@ -238,15 +238,19 @@ def _parse_metadata(metadata_path: Path) -> WheelMetadata:
|
|
|
238
238
|
SpecifierSet(requires_python_raw) if requires_python_raw else None
|
|
239
239
|
)
|
|
240
240
|
except InvalidSpecifier as exc:
|
|
241
|
-
msg =
|
|
241
|
+
msg = (
|
|
242
|
+
f"backend METADATA has invalid Requires-Python "
|
|
243
|
+
f"{requires_python_raw!r}: {exc}"
|
|
244
|
+
)
|
|
242
245
|
raise BuildBackendError(msg) from exc
|
|
243
246
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
247
|
+
try:
|
|
248
|
+
requires_dist: list[Requirement] = [
|
|
249
|
+
Requirement(raw) for raw in msg_obj.get_all("Requires-Dist") or ()
|
|
250
|
+
]
|
|
251
|
+
except InvalidRequirement as exc:
|
|
252
|
+
msg = f"backend METADATA has an invalid Requires-Dist: {exc}"
|
|
253
|
+
raise BuildBackendError(msg) from exc
|
|
250
254
|
|
|
251
255
|
provides_extra: list[str] = sorted(
|
|
252
256
|
{
|
nab_python/_lockfile/builder.py
CHANGED
|
@@ -21,6 +21,7 @@ from nab_index.client import SdistFile, WheelFile
|
|
|
21
21
|
|
|
22
22
|
from .._toml import tool_nab_section
|
|
23
23
|
from .._vendor.packaging.pylock import Pylock, PylockValidationError
|
|
24
|
+
from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
24
25
|
from .._vendor.packaging.utils import canonicalize_name
|
|
25
26
|
|
|
26
27
|
if TYPE_CHECKING:
|
|
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
|
|
|
30
31
|
|
|
31
32
|
from .._vendor.packaging.version import Version
|
|
32
33
|
from ..lockfile import (
|
|
34
|
+
ArchivePin,
|
|
33
35
|
IndexPin,
|
|
34
36
|
LockInput,
|
|
35
37
|
PinShape,
|
|
@@ -37,7 +39,7 @@ if TYPE_CHECKING:
|
|
|
37
39
|
VcsPin,
|
|
38
40
|
WheelArtifact,
|
|
39
41
|
)
|
|
40
|
-
from ..provider import DistPolicy, LocalSource, VcsSource
|
|
42
|
+
from ..provider import ArchiveSource, DistPolicy, LocalSource, VcsSource
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
__all__ = [
|
|
@@ -95,6 +97,10 @@ class LockInputProvider(Protocol):
|
|
|
95
97
|
"""Return the configured VcsSource for ``canonical_name`` or None."""
|
|
96
98
|
...
|
|
97
99
|
|
|
100
|
+
def archive_source_for(self, canonical_name: str, /) -> ArchiveSource | None:
|
|
101
|
+
"""Return the configured ArchiveSource for ``canonical_name`` or None."""
|
|
102
|
+
...
|
|
103
|
+
|
|
98
104
|
def vcs_pin_for(self, canonical_name: str, /) -> str | None:
|
|
99
105
|
"""Return the resolved 40-char SHA captured during materialisation."""
|
|
100
106
|
...
|
|
@@ -287,6 +293,12 @@ def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a disti
|
|
|
287
293
|
resolved_sha=provider.vcs_pin_for(canonical),
|
|
288
294
|
)
|
|
289
295
|
continue
|
|
296
|
+
archive_source = provider.archive_source_for(canonical)
|
|
297
|
+
if archive_source is not None:
|
|
298
|
+
lock_pins[canonical] = _archive_pin_from_source(
|
|
299
|
+
canonical, version, archive_source
|
|
300
|
+
)
|
|
301
|
+
continue
|
|
290
302
|
lock_pins[canonical] = _index_pin_from_listing(
|
|
291
303
|
provider, canonical, version, indexes
|
|
292
304
|
)
|
|
@@ -339,6 +351,9 @@ def _forward_dependency_graph(
|
|
|
339
351
|
for dep in extra_map.get(extra, {})
|
|
340
352
|
)
|
|
341
353
|
dep_names &= pinned
|
|
354
|
+
# An umbrella extra (pkg[all] pulling pkg[graphviz]) can name its own
|
|
355
|
+
# package; drop it so pkg is never an edge to itself.
|
|
356
|
+
dep_names.discard(canonical)
|
|
342
357
|
if dep_names:
|
|
343
358
|
graph[canonical] = tuple(sorted(dep_names))
|
|
344
359
|
return graph
|
|
@@ -519,11 +534,27 @@ def require_artifact_hashes(lock_input: LockInput) -> None:
|
|
|
519
534
|
|
|
520
535
|
|
|
521
536
|
def _common_requires_python(files: Iterable[WheelFile | SdistFile]) -> str | None:
|
|
522
|
-
"""Return
|
|
537
|
+
"""Return the package-level Requires-Python value, or ``None``.
|
|
538
|
+
|
|
539
|
+
An artefact with no Requires-Python is unconstrained, so a single
|
|
540
|
+
such artefact leaves the whole package unconstrained. Otherwise the
|
|
541
|
+
value survives only when every artefact agrees.
|
|
542
|
+
|
|
543
|
+
A malformed value counts as unconstrained too, matching
|
|
544
|
+
``excluded_by_python``, which admits a dist whose Requires-Python is
|
|
545
|
+
an unparseable specifier on any Python. So the lock writer always
|
|
546
|
+
parses a valid specifier or ``None``, and the pin is never
|
|
547
|
+
over-constrained by an artefact whose floor nab could not read.
|
|
548
|
+
"""
|
|
523
549
|
seen: set[str] = set()
|
|
524
550
|
for f in files:
|
|
525
|
-
if f.requires_python is
|
|
526
|
-
|
|
551
|
+
if f.requires_python is None:
|
|
552
|
+
return None
|
|
553
|
+
try:
|
|
554
|
+
SpecifierSet(f.requires_python)
|
|
555
|
+
except InvalidSpecifier:
|
|
556
|
+
return None
|
|
557
|
+
seen.add(f.requires_python)
|
|
527
558
|
if len(seen) == 1:
|
|
528
559
|
return next(iter(seen))
|
|
529
560
|
return None
|
|
@@ -593,3 +624,30 @@ def _vcs_pin_from_source(
|
|
|
593
624
|
requested_revision=requested_revision,
|
|
594
625
|
vcs_type=parsed.scheme,
|
|
595
626
|
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _archive_pin_from_source(
|
|
630
|
+
canonical: str,
|
|
631
|
+
version: Version,
|
|
632
|
+
source: ArchiveSource,
|
|
633
|
+
) -> ArchivePin:
|
|
634
|
+
"""Build an :class:`ArchivePin` from an :class:`ArchiveSource`.
|
|
635
|
+
|
|
636
|
+
The URL, hashes, and subdirectory come from the source declaration,
|
|
637
|
+
which config parse validated (a hash is required), so the pin records
|
|
638
|
+
the exact archive the resolve used. The URL is stripped of any
|
|
639
|
+
credential userinfo, like every other pin, so a committed lockfile
|
|
640
|
+
never carries a token.
|
|
641
|
+
"""
|
|
642
|
+
from nab_index.archive import ArchiveRequest
|
|
643
|
+
|
|
644
|
+
from ..lockfile import ArchivePin
|
|
645
|
+
|
|
646
|
+
request = ArchiveRequest.parse(source.url)
|
|
647
|
+
return ArchivePin(
|
|
648
|
+
name=canonical,
|
|
649
|
+
version=str(version),
|
|
650
|
+
url=_strip_userinfo(request.url),
|
|
651
|
+
hashes=request.hashes,
|
|
652
|
+
subdirectory=request.subdirectory or None,
|
|
653
|
+
)
|
nab_python/_lockfile/pylock.py
CHANGED
|
@@ -21,6 +21,7 @@ import tomli_w
|
|
|
21
21
|
from .._vendor.packaging.markers import Marker
|
|
22
22
|
from .._vendor.packaging.pylock import (
|
|
23
23
|
Package,
|
|
24
|
+
PackageArchive,
|
|
24
25
|
PackageDirectory,
|
|
25
26
|
PackageSdist,
|
|
26
27
|
PackageVcs,
|
|
@@ -214,7 +215,7 @@ def _pin_to_package(
|
|
|
214
215
|
lock_dir: Path,
|
|
215
216
|
dependencies: list[dict[str, str]] | None = None,
|
|
216
217
|
) -> Package:
|
|
217
|
-
from ..lockfile import IndexPin, LocalPin, VcsPin
|
|
218
|
+
from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
|
|
218
219
|
|
|
219
220
|
if isinstance(pin, IndexPin):
|
|
220
221
|
return Package(
|
|
@@ -267,6 +268,20 @@ def _pin_to_package(
|
|
|
267
268
|
requested_revision=pin.requested_revision,
|
|
268
269
|
),
|
|
269
270
|
)
|
|
271
|
+
if isinstance(pin, ArchivePin):
|
|
272
|
+
# An archive is content-pinned by its hash, so the version is
|
|
273
|
+
# stable and recorded (unlike the directory/VCS cases above).
|
|
274
|
+
return Package(
|
|
275
|
+
name=canonicalize_name(pin.name),
|
|
276
|
+
version=Version(pin.version),
|
|
277
|
+
marker=marker,
|
|
278
|
+
dependencies=dependencies,
|
|
279
|
+
archive=PackageArchive(
|
|
280
|
+
url=pin.url,
|
|
281
|
+
hashes=dict(pin.hashes),
|
|
282
|
+
subdirectory=pin.subdirectory,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
270
285
|
msg = f"unknown pin shape: {pin!r}"
|
|
271
286
|
raise TypeError(msg)
|
|
272
287
|
|
|
@@ -423,7 +438,7 @@ def _group_pins_by_pin(
|
|
|
423
438
|
|
|
424
439
|
def _pin_discriminator(pin: PinShape) -> tuple:
|
|
425
440
|
"""Return a hashable key that identifies the source + version of ``pin``."""
|
|
426
|
-
from ..lockfile import IndexPin, LocalPin, VcsPin
|
|
441
|
+
from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
|
|
427
442
|
|
|
428
443
|
if isinstance(pin, IndexPin):
|
|
429
444
|
return ("index", pin.version, pin.index)
|
|
@@ -447,6 +462,8 @@ def _pin_discriminator(pin: PinShape) -> tuple:
|
|
|
447
462
|
pin.repo_url,
|
|
448
463
|
pin.subdirectory or "",
|
|
449
464
|
)
|
|
465
|
+
if isinstance(pin, ArchivePin):
|
|
466
|
+
return ("archive", pin.version, pin.url, pin.hashes, pin.subdirectory or "")
|
|
450
467
|
msg = f"unknown pin shape: {pin!r}"
|
|
451
468
|
raise TypeError(msg)
|
|
452
469
|
|
|
@@ -456,8 +473,9 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
|
|
|
456
473
|
|
|
457
474
|
For :class:`IndexPin`, accumulates every distinct wheel filename
|
|
458
475
|
across the contributing tuples and keeps the first non-``None``
|
|
459
|
-
sdist. ``requires_python`` survives only when every tuple
|
|
460
|
-
|
|
476
|
+
sdist. ``requires_python`` survives only when every tuple carried
|
|
477
|
+
the same value and none was unconstrained, matching
|
|
478
|
+
:func:`_common_requires_python`'s rule.
|
|
461
479
|
Non-IndexPin shapes are already fully discriminated, so the first
|
|
462
480
|
pin is returned unchanged.
|
|
463
481
|
"""
|
|
@@ -469,16 +487,21 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
|
|
|
469
487
|
seen_wheels: dict[str, WheelArtifact] = {}
|
|
470
488
|
sdist = head.sdist
|
|
471
489
|
requires_python_set: set[str] = set()
|
|
490
|
+
any_unconstrained = False
|
|
472
491
|
for pin in pins:
|
|
473
492
|
assert isinstance(pin, IndexPin)
|
|
474
493
|
for wheel in pin.wheels:
|
|
475
494
|
seen_wheels.setdefault(wheel.filename, wheel)
|
|
476
495
|
if sdist is None and pin.sdist is not None:
|
|
477
496
|
sdist = pin.sdist
|
|
478
|
-
if pin.requires_python is
|
|
497
|
+
if pin.requires_python is None:
|
|
498
|
+
any_unconstrained = True
|
|
499
|
+
else:
|
|
479
500
|
requires_python_set.add(pin.requires_python)
|
|
480
501
|
requires_python = (
|
|
481
|
-
next(iter(requires_python_set))
|
|
502
|
+
next(iter(requires_python_set))
|
|
503
|
+
if not any_unconstrained and len(requires_python_set) == 1
|
|
504
|
+
else None
|
|
482
505
|
)
|
|
483
506
|
return IndexPin(
|
|
484
507
|
name=head.name,
|
|
@@ -38,8 +38,11 @@ def write_requirements_with_hashes(
|
|
|
38
38
|
accepts. Local and VCS pins are emitted as ``name @ <url>`` lines
|
|
39
39
|
without hashes (pip does not hash-check those forms); an editable
|
|
40
40
|
local pin renders as ``-e <url>`` and a ``subdirectory`` as a
|
|
41
|
-
``#subdirectory=`` fragment.
|
|
42
|
-
|
|
41
|
+
``#subdirectory=`` fragment. An archive pin is a third form, ``name @
|
|
42
|
+
<url>#sha256=...``, carrying its hash in the fragment;
|
|
43
|
+
:func:`require_artifact_hashes` skips it because that hash is guaranteed
|
|
44
|
+
at config parse. Returns the text and, when ``output_path`` is provided,
|
|
45
|
+
atomically writes it.
|
|
43
46
|
"""
|
|
44
47
|
require_artifact_hashes(lock_input)
|
|
45
48
|
return _render_requirements(lock_input, with_hashes=True, output_path=output_path)
|
|
@@ -51,8 +54,8 @@ def write_requirements_without_hashes(
|
|
|
51
54
|
"""Render ``lock_input`` as a plain ``name==version`` list.
|
|
52
55
|
|
|
53
56
|
Same shape as :func:`write_requirements_with_hashes` but without
|
|
54
|
-
the ``--hash=sha256:...`` lines. Local and
|
|
55
|
-
same in both variants. Returns the text and, when ``output_path``
|
|
57
|
+
the ``--hash=sha256:...`` lines. Local, VCS, and archive pins render
|
|
58
|
+
the same in both variants. Returns the text and, when ``output_path``
|
|
56
59
|
is provided, atomically writes it.
|
|
57
60
|
"""
|
|
58
61
|
return _render_requirements(lock_input, with_hashes=False, output_path=output_path)
|
|
@@ -94,7 +97,7 @@ def _render_per_tuple_requirements(lock_input: LockInput, *, with_hashes: bool)
|
|
|
94
97
|
|
|
95
98
|
def _render_pins(pins: Mapping[str, PinShape], *, with_hashes: bool) -> list[str]:
|
|
96
99
|
"""Render a flat ``{name: pin}`` mapping in alphabetical order."""
|
|
97
|
-
from ..lockfile import IndexPin, LocalPin, VcsPin
|
|
100
|
+
from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
|
|
98
101
|
|
|
99
102
|
lines: list[str] = []
|
|
100
103
|
for canonical in sorted(pins):
|
|
@@ -112,6 +115,14 @@ def _render_pins(pins: Mapping[str, PinShape], *, with_hashes: bool) -> list[str
|
|
|
112
115
|
lines.append(f"{pin.name} @ {url}")
|
|
113
116
|
elif isinstance(pin, VcsPin):
|
|
114
117
|
lines.append(f"{pin.name} @ {pin.repo_url}")
|
|
118
|
+
elif isinstance(pin, ArchivePin):
|
|
119
|
+
# The hash is the archive's identity, so carry it (and any
|
|
120
|
+
# subdirectory) in the fragment for a reproducible, hash-checkable
|
|
121
|
+
# install line, mirroring how VcsPin pins its commit in the URL.
|
|
122
|
+
fragment = "&".join(f"{algo}={digest}" for algo, digest in pin.hashes)
|
|
123
|
+
if pin.subdirectory is not None:
|
|
124
|
+
fragment += f"&subdirectory={quote(pin.subdirectory, safe='/')}"
|
|
125
|
+
lines.append(f"{pin.name} @ {pin.url}#{fragment}")
|
|
115
126
|
else: # pragma: no cover - exhaustive
|
|
116
127
|
msg = f"unknown pin shape: {pin!r}"
|
|
117
128
|
raise TypeError(msg)
|
|
@@ -4,12 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
+
from ._vendor.packaging.ranges import VersionRange
|
|
8
|
+
|
|
7
9
|
if TYPE_CHECKING:
|
|
8
10
|
from collections.abc import Mapping
|
|
9
11
|
|
|
10
12
|
from nab_resolver.types import Incompatibility, RangeProtocol
|
|
11
13
|
|
|
12
|
-
from ._vendor.packaging.ranges import VersionRange
|
|
13
14
|
from ._vendor.packaging.specifiers import SpecifierSet
|
|
14
15
|
from ._vendor.packaging.version import Version
|
|
15
16
|
|
|
@@ -47,12 +48,21 @@ class PackagingProvider:
|
|
|
47
48
|
return version
|
|
48
49
|
return None
|
|
49
50
|
|
|
51
|
+
def has_satisfying_version(
|
|
52
|
+
self, package: str, version_range: RangeProtocol[Version]
|
|
53
|
+
) -> bool:
|
|
54
|
+
"""Report whether any available version falls in the range."""
|
|
55
|
+
return any(v in version_range for v in self._get_versions(package))
|
|
56
|
+
|
|
50
57
|
def get_dependencies(
|
|
51
58
|
self, package: str, version: Version
|
|
52
59
|
) -> dict[str, VersionRange]:
|
|
53
60
|
"""Convert SpecifierSet deps to VersionRange deps."""
|
|
54
61
|
raw = self._packages.get(package, {}).get(version, {})
|
|
55
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
dep: (spec.to_range() if spec else VersionRange.full(admit_arbitrary=False))
|
|
64
|
+
for dep, spec in raw.items()
|
|
65
|
+
}
|
|
56
66
|
|
|
57
67
|
_CONFLICT_THRESHOLD = 5
|
|
58
68
|
|
|
@@ -96,3 +106,15 @@ class PackagingProvider:
|
|
|
96
106
|
def consume_force_backtrack_targets(self) -> list[str]:
|
|
97
107
|
"""No force-backtrack signal from this in-memory provider."""
|
|
98
108
|
return []
|
|
109
|
+
|
|
110
|
+
def widen_decision(self, package: str, version: Version) -> VersionRange | None:
|
|
111
|
+
"""No widening: dependency clauses keep the exact decided version."""
|
|
112
|
+
del package, version
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def narrow_for_display(
|
|
116
|
+
self, package: str, constraint: RangeProtocol[Version]
|
|
117
|
+
) -> RangeProtocol[Version]:
|
|
118
|
+
"""Identity: constraints render as stored."""
|
|
119
|
+
del package
|
|
120
|
+
return constraint
|
nab_python/_provider/extras.py
CHANGED
|
@@ -43,26 +43,43 @@ def choose_extra_version(
|
|
|
43
43
|
_, _, normalized = provider.split_and_normalize(base)
|
|
44
44
|
version_list = provider.fetch_versions(base)
|
|
45
45
|
all_versions = provider.versions_only(normalized, version_list)
|
|
46
|
-
candidates = list(version_range.filter(all_versions))
|
|
47
46
|
|
|
48
|
-
# Filter by base's positive range so we don't pick a proxy version
|
|
49
|
-
# that would force base==V into a known-conflicting state.
|
|
47
|
+
# Filter by the base's positive range so we don't pick a proxy version
|
|
48
|
+
# that would force base==V into a known-conflicting state. Intersect
|
|
49
|
+
# rather than test membership: the base's range carries the pre-release
|
|
50
|
+
# admission granted by the requirement that named the extra, while the
|
|
51
|
+
# proxy's own range is built full.
|
|
50
52
|
base_range = provider.solution_ranges.get(normalized)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
candidates =
|
|
53
|
+
if base_range is None:
|
|
54
|
+
logger.debug(
|
|
55
|
+
"no base range for %s; base admission cannot be applied to %s",
|
|
56
|
+
normalized,
|
|
57
|
+
package,
|
|
58
|
+
)
|
|
59
|
+
candidates = list(version_range.filter(all_versions))
|
|
60
|
+
else:
|
|
61
|
+
candidates = list((version_range & base_range).filter(all_versions))
|
|
60
62
|
|
|
61
63
|
if provider.wants_lowest(normalized):
|
|
62
64
|
candidates = list(reversed(candidates))
|
|
63
65
|
|
|
64
66
|
chosen = _pick_in_mode(provider, base, extra, candidates)
|
|
65
|
-
|
|
67
|
+
# Enumerate pre-releases too: default filtering buffers a pre-release
|
|
68
|
+
# behind any matching final and would drop one that the base's bounds
|
|
69
|
+
# exclude, so it would never be recorded and the proxy would keep a
|
|
70
|
+
# permanent NO_VERSIONS clause past the backjump lifting the base
|
|
71
|
+
# decision. Membership below is bounds-only, so the blocks stay sound.
|
|
72
|
+
if (
|
|
73
|
+
chosen is None
|
|
74
|
+
and base_range is not None
|
|
75
|
+
and (
|
|
76
|
+
excluded_by_base := [
|
|
77
|
+
v
|
|
78
|
+
for v in version_range.filter(all_versions, prereleases=True)
|
|
79
|
+
if v not in base_range
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
):
|
|
66
83
|
_record_base_range_blocks(
|
|
67
84
|
provider, package, normalized, base_range, excluded_by_base
|
|
68
85
|
)
|
|
@@ -77,23 +94,15 @@ def _pick_in_mode(
|
|
|
77
94
|
) -> Version | None:
|
|
78
95
|
"""Pick a candidate honoring ``ExtrasMode``.
|
|
79
96
|
|
|
80
|
-
Fetches base metadata so an extraction failure (unparseable
|
|
81
|
-
|
|
82
|
-
candidate
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
Missing-metadata cases (no PEP 658, no sdist) skip transitive
|
|
87
|
-
extras but fall through for user-requested ones; mock test
|
|
88
|
-
coordinators rely on this.
|
|
97
|
+
Fetches base metadata so an extraction failure (unparseable PKG-INFO,
|
|
98
|
+
a disallowed sdist build, or no metadata source at all) skips the
|
|
99
|
+
candidate rather than raising later, when the proxy refetches the base
|
|
100
|
+
to expand the extra. This applies to user-requested extras too, since
|
|
101
|
+
the proxy always needs the base metadata. BACKTRACK mode additionally
|
|
102
|
+
checks ``Provides-Extra`` for transitive extras.
|
|
89
103
|
"""
|
|
90
104
|
# Late import: ``pypi`` imports this module at module load.
|
|
91
|
-
from ..provider import
|
|
92
|
-
ExtrasMode,
|
|
93
|
-
MetadataError,
|
|
94
|
-
UnsupportedSdistError,
|
|
95
|
-
_normalize_extra,
|
|
96
|
-
)
|
|
105
|
+
from ..provider import ExtrasMode, MetadataError, _normalize_extra
|
|
97
106
|
|
|
98
107
|
_, _, normalized = provider.split_and_normalize(base)
|
|
99
108
|
is_user = (normalized, extra) in provider.root_extras
|
|
@@ -103,12 +112,8 @@ def _pick_in_mode(
|
|
|
103
112
|
continue
|
|
104
113
|
try:
|
|
105
114
|
provider.get_dependencies(base, version)
|
|
106
|
-
except UnsupportedSdistError:
|
|
107
|
-
continue
|
|
108
115
|
except MetadataError:
|
|
109
|
-
|
|
110
|
-
continue
|
|
111
|
-
return version
|
|
116
|
+
continue
|
|
112
117
|
if is_user or not backtrack:
|
|
113
118
|
return version
|
|
114
119
|
metadata = provider.metadata_cache.get((normalized, version))
|
|
@@ -122,6 +127,32 @@ def _pick_in_mode(
|
|
|
122
127
|
return None
|
|
123
128
|
|
|
124
129
|
|
|
130
|
+
def version_provides_extra(
|
|
131
|
+
provider: Provider,
|
|
132
|
+
base: str,
|
|
133
|
+
extra: str,
|
|
134
|
+
version: Version,
|
|
135
|
+
) -> bool:
|
|
136
|
+
"""Whether ``version`` of ``base`` declares ``extra`` and yields metadata here.
|
|
137
|
+
|
|
138
|
+
Honoring a cross-tuple preference for a ``base[extra]`` proxy is only
|
|
139
|
+
safe when the preferred version both provides the extra and has
|
|
140
|
+
extractable metadata in this tuple.
|
|
141
|
+
"""
|
|
142
|
+
# Late import: provider imports this module at module load.
|
|
143
|
+
from ..provider import MetadataError, _normalize_extra
|
|
144
|
+
|
|
145
|
+
_, _, normalized = provider.split_and_normalize(base)
|
|
146
|
+
try:
|
|
147
|
+
provider.get_dependencies(base, version)
|
|
148
|
+
except MetadataError:
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
metadata = provider.metadata_cache[(normalized, version)]
|
|
152
|
+
provided = {_normalize_extra(e) for e in metadata.provides_extra}
|
|
153
|
+
return extra in provided
|
|
154
|
+
|
|
155
|
+
|
|
125
156
|
def _record_base_range_blocks(
|
|
126
157
|
provider: Provider,
|
|
127
158
|
proxy_pkg: str,
|
nab_python/_provider/listing.py
CHANGED
|
@@ -50,6 +50,14 @@ def fetch_versions(provider: Provider, package: str) -> list[tuple[Version, Dist
|
|
|
50
50
|
provider.versions_cache[normalized] = result
|
|
51
51
|
return result
|
|
52
52
|
|
|
53
|
+
archive = provider.archive_sources.get(normalized)
|
|
54
|
+
if archive is not None:
|
|
55
|
+
# The download-and-verify guards are gated everywhere; only the
|
|
56
|
+
# post-extraction success tail needs the tar data filter (see sources.py).
|
|
57
|
+
result = provider.materialize_archive_source(normalized, archive)
|
|
58
|
+
provider.versions_cache[normalized] = result # pragma: no cover
|
|
59
|
+
return result # pragma: no cover
|
|
60
|
+
|
|
53
61
|
files = provider.coordinator.index.get_listing(normalized)
|
|
54
62
|
if files is None:
|
|
55
63
|
event = provider.coordinator.request_listing(normalized)
|
|
@@ -572,13 +580,17 @@ def prefetch_new_deps(provider: Provider, deps: Mapping[str, VersionRange]) -> N
|
|
|
572
580
|
candidate. This deepens the prefetch cascade so metadata is
|
|
573
581
|
ready before the resolver asks for it.
|
|
574
582
|
|
|
575
|
-
Local and
|
|
576
|
-
and the materialise path in ``fetch_versions`` will
|
|
577
|
-
them when the resolver asks.
|
|
583
|
+
Local, VCS, and archive sources are skipped; they have no PyPI
|
|
584
|
+
listing and the materialise path in ``fetch_versions`` will
|
|
585
|
+
surface them when the resolver asks.
|
|
578
586
|
"""
|
|
579
587
|
for dep in deps:
|
|
580
588
|
_, _, normalized = provider.split_and_normalize(dep)
|
|
581
|
-
if
|
|
589
|
+
if (
|
|
590
|
+
normalized in provider.local_sources
|
|
591
|
+
or normalized in provider.vcs_sources
|
|
592
|
+
or normalized in provider.archive_sources
|
|
593
|
+
):
|
|
582
594
|
continue
|
|
583
595
|
if normalized not in provider.versions_cache:
|
|
584
596
|
# Listing not cached: request it. When it arrives,
|