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
nab_python/provider.py
ADDED
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
"""Index-backed provider for nab-resolver.
|
|
2
|
+
|
|
3
|
+
Fetches package metadata from package indexes on demand using
|
|
4
|
+
nab-index, converting PEP 440/508 types into nab-resolver Range
|
|
5
|
+
types. Uses a thread pool with a shared HTTP session to overlap I/O.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import enum
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from collections import defaultdict, deque
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from nab_index.client import SdistFile, WheelFile
|
|
18
|
+
|
|
19
|
+
from ._provider import extras as _extras
|
|
20
|
+
from ._provider import listing as _listing
|
|
21
|
+
from ._provider import lookahead as _lookahead
|
|
22
|
+
from ._provider import metadata_resolver as _metadata_resolver
|
|
23
|
+
from ._provider import priority as _priority
|
|
24
|
+
from ._provider import sources as _sources
|
|
25
|
+
from ._vcs_admission import (
|
|
26
|
+
UnsupportedVcsError,
|
|
27
|
+
VcsConfig,
|
|
28
|
+
VcsPolicy,
|
|
29
|
+
)
|
|
30
|
+
from ._vendor.packaging.markers import default_environment
|
|
31
|
+
from ._vendor.packaging.ranges import VersionRange
|
|
32
|
+
from ._vendor.packaging.utils import canonicalize_name
|
|
33
|
+
from ._vendor.packaging.version import InvalidVersion, Version
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
import threading
|
|
37
|
+
from collections.abc import Mapping, Sequence
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from nab_resolver.types import Incompatibility, RangeProtocol
|
|
42
|
+
|
|
43
|
+
from .config import NabProjectConfig
|
|
44
|
+
from .fetch import FetchCoordinator
|
|
45
|
+
from .metadata import WheelMetadata
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"BuildPolicy",
|
|
49
|
+
"DistFile",
|
|
50
|
+
"DistPolicy",
|
|
51
|
+
"ExtrasMode",
|
|
52
|
+
"LocalSource",
|
|
53
|
+
"MetadataError",
|
|
54
|
+
"MissingExtraError",
|
|
55
|
+
"Provider",
|
|
56
|
+
"ProviderStats",
|
|
57
|
+
"ResolutionStrategy",
|
|
58
|
+
"UnsupportedSdistError",
|
|
59
|
+
"UnsupportedVcsError",
|
|
60
|
+
"VcsConfig",
|
|
61
|
+
"VcsPolicy",
|
|
62
|
+
"VcsSource",
|
|
63
|
+
"join_extra",
|
|
64
|
+
"split_extra",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
_EXTRA_RE = re.compile(r"^(?P<base>[^\[]+)\[(?P<extra>[^\]]+)\]$")
|
|
71
|
+
|
|
72
|
+
# A PEP 508 ``python_version`` value is the ``major.minor`` pair.
|
|
73
|
+
_PYTHON_VERSION_PARTS = 2
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalize_extra(extra: str) -> str:
|
|
77
|
+
"""Normalize an extra name per PEP 685 (same rules as package names)."""
|
|
78
|
+
return canonicalize_name(extra)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def split_extra(package: str) -> tuple[str, str | None]:
|
|
82
|
+
"""Split 'name[extra]' into ('name', 'extra'), or ('name', None).
|
|
83
|
+
|
|
84
|
+
The extra name is normalized per PEP 685.
|
|
85
|
+
"""
|
|
86
|
+
m = _EXTRA_RE.match(package)
|
|
87
|
+
if m is None:
|
|
88
|
+
return (package, None)
|
|
89
|
+
return (m.group("base"), _normalize_extra(m.group("extra")))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def join_extra(base: str, extra: str) -> str:
|
|
93
|
+
"""Join a base name and extra into 'name[extra]'.
|
|
94
|
+
|
|
95
|
+
The extra name is normalized per PEP 685.
|
|
96
|
+
"""
|
|
97
|
+
return f"{base}[{_normalize_extra(extra)}]"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MissingExtraError(Exception):
|
|
101
|
+
"""Raised when a user-requested extra is not provided by the package."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ExtrasMode(enum.Enum):
|
|
105
|
+
"""How to handle missing extras (not in Provides-Extra)."""
|
|
106
|
+
|
|
107
|
+
WARN = "warn"
|
|
108
|
+
"""Log warning, drop the extra, resolution continues (pip's behavior)."""
|
|
109
|
+
|
|
110
|
+
ERROR_USER = "error_user"
|
|
111
|
+
"""Error for user-provided extras, warn for transitive."""
|
|
112
|
+
|
|
113
|
+
BACKTRACK = "backtrack"
|
|
114
|
+
"""Error for user-provided, backtrack for transitive."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class DistPolicy(enum.Enum):
|
|
118
|
+
"""How to admit wheels and sdists during resolution."""
|
|
119
|
+
|
|
120
|
+
NO_SDIST = "no-sdist"
|
|
121
|
+
"""Ignore sdists entirely. Only use wheels with PEP 658 metadata."""
|
|
122
|
+
|
|
123
|
+
PREFER_BINARY = "prefer-binary"
|
|
124
|
+
"""Try wheels first, fall back to sdists for versions without wheels."""
|
|
125
|
+
|
|
126
|
+
WHEEL_OR_SDIST = "wheel-or-sdist"
|
|
127
|
+
"""Admit both. Newest version wins regardless of artifact kind."""
|
|
128
|
+
|
|
129
|
+
SDIST_ONLY = "sdist-only"
|
|
130
|
+
"""Reject wheels; sdists only. Mirrors pip's ``--no-binary <pkg>``."""
|
|
131
|
+
|
|
132
|
+
SDIST_INSTALL = "sdist-install"
|
|
133
|
+
"""Lock the sdist; resolve from whichever artifact is cheapest.
|
|
134
|
+
|
|
135
|
+
Same lockfile shape as :attr:`SDIST_ONLY` (only the sdist is pinned, so
|
|
136
|
+
installers download and build that archive), but the resolver is free to
|
|
137
|
+
consult either the wheel's METADATA (via PEP 658 or a range fetch) or
|
|
138
|
+
the sdist's PKG-INFO when extracting dependency facts. In practice it
|
|
139
|
+
reads the wheel when one exists at the chosen version because that is
|
|
140
|
+
the cheapest source; when only the sdist is published it falls back to
|
|
141
|
+
PKG-INFO with the usual :pep:`643` and pyproject.toml fallbacks.
|
|
142
|
+
Mirrors a pip install with ``--no-binary <pkg>`` while keeping the
|
|
143
|
+
resolver-time fast paths intact.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class BuildPolicy(enum.Enum):
|
|
148
|
+
"""How permissive the resolver is about invoking PEP 517 backends.
|
|
149
|
+
|
|
150
|
+
Three levels, strictest to most permissive. Each level reads static
|
|
151
|
+
metadata from every source it admits; the difference is what is
|
|
152
|
+
permitted to fall through to a backend invocation when the static
|
|
153
|
+
read returns nothing usable.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
NEVER = "never"
|
|
157
|
+
"""Static metadata only, from any source.
|
|
158
|
+
|
|
159
|
+
Wheels, PEP 643 sdists, sdists with a static ``pyproject.toml`` fallback,
|
|
160
|
+
local checkouts via ``[[tool.nab.local-sources]]``, and VCS clones via
|
|
161
|
+
``[[tool.nab.vcs-sources]]`` are all read statically. Sources whose
|
|
162
|
+
metadata is dynamic without a static fallback are skipped.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
BUILD_LOCAL = "build-local"
|
|
166
|
+
"""Static metadata everywhere, plus PEP 517 builds on local checkouts.
|
|
167
|
+
|
|
168
|
+
Adds backend invocation for ``[[tool.nab.local-sources]]`` and
|
|
169
|
+
workspace members when their ``pyproject.toml`` cannot be read
|
|
170
|
+
statically. VCS clones and remote PyPI sdists remain static-only.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
BUILD_REMOTE = "build-remote"
|
|
174
|
+
"""Builds extend to VCS clones and remote PyPI sdists.
|
|
175
|
+
|
|
176
|
+
On top of :attr:`BUILD_LOCAL`, invokes the backend on VCS-cloned
|
|
177
|
+
trees and on fetched sdists when their metadata is dynamic and has
|
|
178
|
+
no static fallback.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ResolutionStrategy(enum.Enum):
|
|
183
|
+
"""Which version the resolver picks within an allowed range.
|
|
184
|
+
|
|
185
|
+
Mirrors uv's ``--resolution`` flag. ``LOWEST_DIRECT`` catches missing
|
|
186
|
+
``>=`` bounds without dragging the whole transitive graph to its floor.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
HIGHEST = "highest"
|
|
190
|
+
"""Newest compatible version (default)."""
|
|
191
|
+
|
|
192
|
+
LOWEST = "lowest"
|
|
193
|
+
"""Oldest compatible version, transitively."""
|
|
194
|
+
|
|
195
|
+
LOWEST_DIRECT = "lowest-direct"
|
|
196
|
+
"""Oldest for direct deps; newest for transitive deps."""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True)
|
|
200
|
+
class LocalSource:
|
|
201
|
+
"""A source tree on disk used as the only candidate for a package.
|
|
202
|
+
|
|
203
|
+
``name`` is the package name; the resolver pins the package to a
|
|
204
|
+
single synthetic version derived from the directory's
|
|
205
|
+
``[project].version`` field (or ``"0.0.0+local"`` if absent).
|
|
206
|
+
``path`` is the absolute filesystem path to the source tree.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
name: str
|
|
210
|
+
path: str
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True, slots=True)
|
|
214
|
+
class VcsSource:
|
|
215
|
+
"""A VCS reference used as the only candidate for a package.
|
|
216
|
+
|
|
217
|
+
``name`` is the package name; ``url`` is the pip-style VCS URL
|
|
218
|
+
(e.g. ``git+https://github.com/x/y.git@<sha>#subdirectory=pkg``).
|
|
219
|
+
The provider clones the repo to its cache and treats the
|
|
220
|
+
checked-out source as a :class:`LocalSource` for metadata
|
|
221
|
+
extraction.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
name: str
|
|
225
|
+
url: str
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class MetadataError(Exception):
|
|
229
|
+
"""Raised when dependency metadata cannot be extracted."""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class UnsupportedSdistError(MetadataError):
|
|
233
|
+
"""Sdist or source tree needs a backend invocation the policy disallows.
|
|
234
|
+
|
|
235
|
+
Raised when extraction would require a build the current
|
|
236
|
+
:class:`BuildPolicy` (or its per-package override) does not permit:
|
|
237
|
+
dynamic metadata under :attr:`BuildPolicy.NEVER`, a VCS clone under
|
|
238
|
+
:attr:`BuildPolicy.BUILD_LOCAL`, or a remote sdist build failure
|
|
239
|
+
under :attr:`BuildPolicy.BUILD_REMOTE`. Caught by
|
|
240
|
+
:meth:`Provider._look_ahead_ok` so the resolver skips the
|
|
241
|
+
version instead of failing.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _add_extra_marker(dep_str: str, extra_name: str) -> str:
|
|
246
|
+
"""Append ``extra == "name"`` to a :pep:`508` dep string.
|
|
247
|
+
|
|
248
|
+
Combines an existing marker with ``and`` so a dep like
|
|
249
|
+
``numpy ; python_version >= '3.10'`` becomes
|
|
250
|
+
``numpy ; (python_version >= '3.10') and extra == "foo"``.
|
|
251
|
+
"""
|
|
252
|
+
base, sep, marker = dep_str.partition(";")
|
|
253
|
+
if sep:
|
|
254
|
+
return f'{base.strip()} ; ({marker.strip()}) and extra == "{extra_name}"'
|
|
255
|
+
return f'{dep_str} ; extra == "{extra_name}"'
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass
|
|
259
|
+
class ProviderStats:
|
|
260
|
+
"""Counters describing what the provider did during a resolve.
|
|
261
|
+
|
|
262
|
+
Complements :class:`nab_resolver.ResolverStats` by tracking the PyPI/wheel
|
|
263
|
+
layer (listing fetches, metadata reads, filter rejections). Used by
|
|
264
|
+
benchmarks to measure prefetch and look-ahead wins.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
listings_fetched: int = 0
|
|
268
|
+
metadata_fetched: int = 0
|
|
269
|
+
sdist_pkg_info_fetched: int = 0
|
|
270
|
+
distributions_seen: int = 0
|
|
271
|
+
wheels_seen: int = 0
|
|
272
|
+
sdists_seen: int = 0
|
|
273
|
+
excluded_by_python: int = 0
|
|
274
|
+
excluded_by_time: int = 0
|
|
275
|
+
excluded_by_dist_policy: int = 0
|
|
276
|
+
excluded_by_build_policy: int = 0
|
|
277
|
+
sdist_pyproject_fallbacks: int = 0
|
|
278
|
+
get_dependencies_calls: int = 0
|
|
279
|
+
choose_version_calls: int = 0
|
|
280
|
+
prioritize_calls: int = 0
|
|
281
|
+
look_ahead_rejections: int = 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
DistFile = WheelFile | SdistFile
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class Provider:
|
|
288
|
+
"""Lazy index-backed provider for nab-resolver.
|
|
289
|
+
|
|
290
|
+
Fetches version lists and .metadata from PyPI via nab-index.
|
|
291
|
+
A thread pool submits listing fetches in the background so
|
|
292
|
+
transitive deps are fetched concurrently with resolution.
|
|
293
|
+
The niquests session is shared across threads (thread-safe
|
|
294
|
+
with HTTP/2 connection reuse).
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
# Drives two prefetch paths: the speculative root-batch prefetch
|
|
298
|
+
# fired when a listing first arrives, and the scan batch in
|
|
299
|
+
# ``_scan_candidates_pipelined``. Matched to the abort threshold
|
|
300
|
+
# below: prefetching 8 versions covers the worst-case abort scan
|
|
301
|
+
# without overshooting. Larger batches waste bandwidth and
|
|
302
|
+
# in-flight HTTP slots on metadata the resolver never decides;
|
|
303
|
+
# smaller batches starve the look-ahead pipeline.
|
|
304
|
+
PREFETCH_BATCH: int = 8
|
|
305
|
+
|
|
306
|
+
# Batches kept fetching during the previous batch's await in
|
|
307
|
+
# ``choose_version``. Depth>=2 reordered listing arrivals and
|
|
308
|
+
# destabilised hard scenarios.
|
|
309
|
+
PREFETCH_DEPTH: int = 1
|
|
310
|
+
|
|
311
|
+
# Once the first look-ahead candidate fails, the resolver walks
|
|
312
|
+
# versions one at a time via the abort-skip path. Front-load
|
|
313
|
+
# metadata for the next K so the walk hits cache instead of one
|
|
314
|
+
# RTT per visit. Only fires from _scan_candidates_pipelined, so
|
|
315
|
+
# scenarios that accept the first candidate pay nothing.
|
|
316
|
+
DEEP_PREFETCH_COUNT: int = 64
|
|
317
|
+
|
|
318
|
+
# Per-call cap on decision-aware look-ahead rejections so tight version
|
|
319
|
+
# clusters do not emit a flood of redundant grouped binary clauses.
|
|
320
|
+
_BROAD_LA_REJECT_CAP: int = 64
|
|
321
|
+
|
|
322
|
+
# When the scan accumulates this many rejections, all sharing the same
|
|
323
|
+
# ``(blocker_pkg, blocker_version)`` (no range/root/metadata blocks),
|
|
324
|
+
# abandon look-ahead for this scan: drop its pending clauses, return
|
|
325
|
+
# the first candidate unchecked, and let the resolver decide it.
|
|
326
|
+
# ``get_dependencies`` will then add the real dep-range clause, which
|
|
327
|
+
# pubgrub's conflict resolution can use to back-jump the offending
|
|
328
|
+
# blocker decision. Look-ahead's grouped binary clauses are too narrow
|
|
329
|
+
# to drive that back-jump on their own
|
|
330
|
+
# (``docs/analysis/nab_lookahead_monolithic_backjump.md``).
|
|
331
|
+
#
|
|
332
|
+
# Set low because the trigger is conservative: a unique
|
|
333
|
+
# ``(blocker_pkg, blocker_version)`` repeating across every rejection
|
|
334
|
+
# is already a strong signal. Combined with the per-package skip
|
|
335
|
+
# below, subsequent calls for the same package skip look-ahead while
|
|
336
|
+
# the blocker decision is unchanged.
|
|
337
|
+
_LOOKAHEAD_ABORT_THRESHOLD = 8
|
|
338
|
+
|
|
339
|
+
# Max force-backtracks one blocker can drive per resolution.
|
|
340
|
+
# One-shot misses sustained culprits; unlimited oscillates on
|
|
341
|
+
# blockers that are also the right pin.
|
|
342
|
+
_MAX_FORCE_BACKTRACKS_PER_PKG = 3
|
|
343
|
+
|
|
344
|
+
# Re-exported from _provider.priority so test references keep resolving.
|
|
345
|
+
TIER_AFFECTED = _priority.TIER_AFFECTED
|
|
346
|
+
TIER_NORMAL = _priority.TIER_NORMAL
|
|
347
|
+
TIER_CULPRIT = _priority.TIER_CULPRIT
|
|
348
|
+
CONFLICT_THRESHOLD = _priority.CONFLICT_THRESHOLD
|
|
349
|
+
CULPRIT_DEMOTE_THRESHOLD = _priority.CULPRIT_DEMOTE_THRESHOLD
|
|
350
|
+
|
|
351
|
+
def __init__( # noqa: PLR0913, PLR0915, PLR0912, C901 - resolver config is wide; bundling all flags into one bag is worse for callers
|
|
352
|
+
self,
|
|
353
|
+
coordinator: FetchCoordinator,
|
|
354
|
+
python_version: str | None = None,
|
|
355
|
+
root_requirements: dict[str, VersionRange] | None = None,
|
|
356
|
+
uploaded_prior_to: datetime | None = None,
|
|
357
|
+
uploaded_prior_to_overrides: Mapping[str, datetime | None] | None = None,
|
|
358
|
+
extras_mode: ExtrasMode = ExtrasMode.ERROR_USER,
|
|
359
|
+
root_extras: set[tuple[str, str]] | None = None,
|
|
360
|
+
dist_policy: DistPolicy = DistPolicy.WHEEL_OR_SDIST,
|
|
361
|
+
dist_policy_overrides: Mapping[str, DistPolicy] | None = None,
|
|
362
|
+
build_policy: BuildPolicy = BuildPolicy.BUILD_LOCAL,
|
|
363
|
+
build_policy_overrides: Mapping[str, BuildPolicy] | None = None,
|
|
364
|
+
vcs_config: VcsConfig | None = None,
|
|
365
|
+
marker_environment: dict[str, str] | None = None,
|
|
366
|
+
local_sources: list[LocalSource] | None = None,
|
|
367
|
+
vcs_sources: list[VcsSource] | None = None,
|
|
368
|
+
vcs_cache_dir: Path | None = None,
|
|
369
|
+
build_config: NabProjectConfig | None = None,
|
|
370
|
+
resolution_strategy: ResolutionStrategy = ResolutionStrategy.HIGHEST,
|
|
371
|
+
direct_packages: frozenset[str] | None = None,
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Construct the provider; see the class docstring for parameters."""
|
|
374
|
+
self.coordinator = coordinator
|
|
375
|
+
self.python_version = python_version
|
|
376
|
+
self.uploaded_prior_to = uploaded_prior_to
|
|
377
|
+
self.uploaded_prior_to_overrides: dict[str, datetime | None] = dict(
|
|
378
|
+
uploaded_prior_to_overrides or {}
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Passed through to the build env when extract_source_metadata
|
|
382
|
+
# falls through to a PEP 517 backend; static-only callers leave None.
|
|
383
|
+
self.build_config = build_config
|
|
384
|
+
self.extras_mode = extras_mode
|
|
385
|
+
self.root_extras = root_extras or set()
|
|
386
|
+
self._dist_policy = dist_policy
|
|
387
|
+
self._dist_policy_overrides: dict[str, DistPolicy] = dict(
|
|
388
|
+
dist_policy_overrides or {}
|
|
389
|
+
)
|
|
390
|
+
self.build_policy = build_policy
|
|
391
|
+
self._resolution_strategy = resolution_strategy
|
|
392
|
+
self._direct_packages: frozenset[str] = direct_packages or frozenset()
|
|
393
|
+
|
|
394
|
+
self._build_policy_overrides: dict[str, BuildPolicy] = {}
|
|
395
|
+
for raw_name, policy in (build_policy_overrides or {}).items():
|
|
396
|
+
canonical = canonicalize_name(raw_name)
|
|
397
|
+
if canonical in self._build_policy_overrides:
|
|
398
|
+
msg = f"duplicate build-policy override for {raw_name!r}"
|
|
399
|
+
raise ValueError(msg)
|
|
400
|
+
self._build_policy_overrides[canonical] = policy
|
|
401
|
+
|
|
402
|
+
# Backends run on the host, so invoking one under a marker overlay
|
|
403
|
+
# would produce metadata that does not match the impersonated target.
|
|
404
|
+
# The guard inspects overrides as well as the global so a single
|
|
405
|
+
# build-* entry cannot quietly opt out of the soundness check.
|
|
406
|
+
if marker_environment:
|
|
407
|
+
offending: list[tuple[str, BuildPolicy]] = []
|
|
408
|
+
if build_policy is not BuildPolicy.NEVER:
|
|
409
|
+
offending.append(("<global>", build_policy))
|
|
410
|
+
for canonical, policy in self._build_policy_overrides.items():
|
|
411
|
+
if policy is not BuildPolicy.NEVER:
|
|
412
|
+
offending.append((canonical, policy))
|
|
413
|
+
if offending:
|
|
414
|
+
rendered = ", ".join(
|
|
415
|
+
f"{name}={policy.value}" for name, policy in offending
|
|
416
|
+
)
|
|
417
|
+
msg = (
|
|
418
|
+
"marker_environment overlay requires BuildPolicy.NEVER"
|
|
419
|
+
" globally and in every per-package override; got"
|
|
420
|
+
f" {rendered}. Backends run on the host and report"
|
|
421
|
+
" metadata for the host, not the impersonated target."
|
|
422
|
+
)
|
|
423
|
+
raise ValueError(msg)
|
|
424
|
+
|
|
425
|
+
self.vcs_config = vcs_config or VcsConfig()
|
|
426
|
+
self.local_sources = _sources.index_local_sources(self, local_sources or [])
|
|
427
|
+
self.vcs_cache_dir = vcs_cache_dir
|
|
428
|
+
self.vcs_sources = _sources.index_vcs_sources(self, vcs_sources or [])
|
|
429
|
+
|
|
430
|
+
# default_environment() returns a TypedDict whose ``.items()`` view
|
|
431
|
+
# widens values to ``object``; rebuild as a concrete ``dict[str, str]``
|
|
432
|
+
# so mutations and the env_with_extra copy below stay typed.
|
|
433
|
+
env_init: dict[str, str] = {
|
|
434
|
+
key: value
|
|
435
|
+
for key, value in default_environment().items()
|
|
436
|
+
if isinstance(value, str)
|
|
437
|
+
}
|
|
438
|
+
self.environment: dict[str, str] = env_init
|
|
439
|
+
if python_version is not None:
|
|
440
|
+
try:
|
|
441
|
+
release = Version(python_version).release
|
|
442
|
+
except InvalidVersion:
|
|
443
|
+
pass
|
|
444
|
+
else:
|
|
445
|
+
# python_version is major.minor (PEP 508);
|
|
446
|
+
# python_full_version is the full release.
|
|
447
|
+
self.environment["python_version"] = (
|
|
448
|
+
f"{release[0]}.{release[1]}"
|
|
449
|
+
if len(release) >= _PYTHON_VERSION_PARTS
|
|
450
|
+
else python_version
|
|
451
|
+
)
|
|
452
|
+
self.environment["python_full_version"] = python_version
|
|
453
|
+
if marker_environment:
|
|
454
|
+
for key, value in marker_environment.items():
|
|
455
|
+
self.environment[key] = value
|
|
456
|
+
|
|
457
|
+
self.root_requirements = root_requirements or {}
|
|
458
|
+
self.versions_cache: dict[str, list[tuple[Version, DistFile]]] = {}
|
|
459
|
+
self.deps_cache: dict[tuple[str, Version], dict[str, VersionRange]] = {}
|
|
460
|
+
self.metadata_cache: dict[tuple[str, Version], WheelMetadata] = {}
|
|
461
|
+
self.extra_deps_map: dict[
|
|
462
|
+
tuple[str, Version], dict[str, dict[str, VersionRange]]
|
|
463
|
+
] = {}
|
|
464
|
+
|
|
465
|
+
# Memoised sdist-rejections so re-tries do not re-parse PKG-INFO.
|
|
466
|
+
self._unsupported_sdists: set[tuple[str, Version]] = set()
|
|
467
|
+
|
|
468
|
+
# Memoised metadata-parse failures (malformed Requires-Dist, etc.)
|
|
469
|
+
# keyed by (canonical_name, Version). Value is the cached error
|
|
470
|
+
# string so the look-ahead diagnostic stays consistent across
|
|
471
|
+
# repeated lookups without re-parsing the broken text.
|
|
472
|
+
self._invalid_metadata: dict[tuple[str, Version], str] = {}
|
|
473
|
+
|
|
474
|
+
# Nested matching cache: prioritize is called many times per resolve
|
|
475
|
+
# so the per-call (normalized, range) tuple alloc is worth avoiding.
|
|
476
|
+
self.matching_cache: dict[str, dict[RangeProtocol[Version], int]] = {}
|
|
477
|
+
|
|
478
|
+
# Requires-Python compatibility, keyed by the raw specifier string.
|
|
479
|
+
self.requires_python_cache: dict[str, bool] = {}
|
|
480
|
+
|
|
481
|
+
# Marker evaluation caches keyed by id(marker); requirement parsing is
|
|
482
|
+
# cached upstream so each distinct marker text shares one Marker.
|
|
483
|
+
self.marker_base_cache: dict[int, bool] = {}
|
|
484
|
+
self.marker_extra_cache: dict[int, dict[str, bool]] = {}
|
|
485
|
+
# Memoised str(marker) for the cheap "extra" in marker_text gate.
|
|
486
|
+
self.marker_text_cache: dict[int, str] = {}
|
|
487
|
+
# Reused per-evaluation environment dict (avoids a copy per requirement).
|
|
488
|
+
self.env_with_extra: dict[str, str] = dict(self.environment)
|
|
489
|
+
|
|
490
|
+
# (base, extra, normalized_name) per input package string.
|
|
491
|
+
self._package_parts: dict[str, tuple[str, str | None, str]] = {}
|
|
492
|
+
|
|
493
|
+
# Fast-path priority cache, keyed by package + Range identity +
|
|
494
|
+
# affected count. Range identity is sound because solution.get
|
|
495
|
+
# returns the same object until it changes.
|
|
496
|
+
self.priority_cache: dict[
|
|
497
|
+
str, tuple[RangeProtocol[Version], int, tuple[int, int, bool]]
|
|
498
|
+
] = {}
|
|
499
|
+
|
|
500
|
+
# Derived views of versions_cache, built lazily alongside the listing.
|
|
501
|
+
self.versions_only_cache: dict[str, list[Version]] = {}
|
|
502
|
+
self.wheel_by_version_cache: dict[str, dict[Version, DistFile]] = {}
|
|
503
|
+
|
|
504
|
+
self.solution_ranges: dict[str, RangeProtocol[Version]] = {}
|
|
505
|
+
self.solution_decisions: dict[str, Version] = {}
|
|
506
|
+
self.pending_clauses: list[Incompatibility[str, Version]] = []
|
|
507
|
+
self.pending_blocks: defaultdict[tuple[str, str, Version], list[Version]] = (
|
|
508
|
+
defaultdict(list)
|
|
509
|
+
)
|
|
510
|
+
self.pending_range_blocks: defaultdict[
|
|
511
|
+
tuple[str, str, RangeProtocol[Version]], list[Version]
|
|
512
|
+
] = defaultdict(list)
|
|
513
|
+
|
|
514
|
+
# Diagnostic-only: root_requirements feed PubGrub directly, so these
|
|
515
|
+
# blockers never need flushing as incompatibilities; they exist purely
|
|
516
|
+
# so the failure message can name the excluding root requirement.
|
|
517
|
+
self.pending_root_blocks: defaultdict[
|
|
518
|
+
tuple[str, str, RangeProtocol[Version], RangeProtocol[Version]],
|
|
519
|
+
list[Version],
|
|
520
|
+
] = defaultdict(list)
|
|
521
|
+
|
|
522
|
+
# Diagnostic-only: metadata-error rejections so the failure message
|
|
523
|
+
# can name the real cause (sdist build needed, malformed PKG-INFO, etc).
|
|
524
|
+
self.pending_metadata_blocks: defaultdict[str, list[tuple[Version, str]]] = (
|
|
525
|
+
defaultdict(list)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
# Last NO_VERSIONS reason per package; consumed by resolve.py to
|
|
529
|
+
# enrich ResolutionError messages.
|
|
530
|
+
self._no_versions_reasons: dict[str, str] = {}
|
|
531
|
+
|
|
532
|
+
# Per-package record of "look-ahead aborted at this blocker decision".
|
|
533
|
+
# While the blocker is still decided to the recorded version, the next
|
|
534
|
+
# ``choose_version`` for this package skips look-ahead entirely and
|
|
535
|
+
# returns the first candidate; re-running the scan would just hit the
|
|
536
|
+
# same monolithic-rejection pattern and abort again. Cleared per
|
|
537
|
+
# package when the blocker's decision changes (back-jump unblocks it).
|
|
538
|
+
self._lookahead_aborted: dict[str, tuple[str, Version]] = {}
|
|
539
|
+
|
|
540
|
+
# Blocker packages queued for force back-track by the resolver after
|
|
541
|
+
# the next ``choose_version`` returns. Populated by the look-ahead
|
|
542
|
+
# abort path; drained by ``consume_force_backtrack_targets``. uv-style
|
|
543
|
+
# signal: when the scan rejects many candidates all blamed on the same
|
|
544
|
+
# blocker decision, we have strong evidence the blocker is the culprit
|
|
545
|
+
# and ask the resolver to back-jump it now rather than burn a full
|
|
546
|
+
# natural-path conflict cycle.
|
|
547
|
+
self._force_backtrack_targets: list[str] = []
|
|
548
|
+
|
|
549
|
+
# Per-blocker fire count for force-backtrack. Each abort that
|
|
550
|
+
# names the blocker bumps the count. The abort path stops
|
|
551
|
+
# queueing once ``_MAX_FORCE_BACKTRACKS_PER_PKG`` is reached.
|
|
552
|
+
self._force_backtrack_counts: dict[str, int] = {}
|
|
553
|
+
|
|
554
|
+
self.stats = ProviderStats()
|
|
555
|
+
|
|
556
|
+
if self.root_requirements:
|
|
557
|
+
for pkg in self.root_requirements:
|
|
558
|
+
_, _, normalized = self.split_and_normalize(pkg)
|
|
559
|
+
if normalized in self.local_sources or normalized in self.vcs_sources:
|
|
560
|
+
continue
|
|
561
|
+
self.coordinator.request_listing(normalized)
|
|
562
|
+
|
|
563
|
+
def fetch_versions(self, package: str) -> list[tuple[Version, DistFile]]:
|
|
564
|
+
"""See :func:`nab_python._provider.listing.fetch_versions`."""
|
|
565
|
+
return _listing.fetch_versions(self, package)
|
|
566
|
+
|
|
567
|
+
def effective_build_policy(self, canonical_name: str) -> BuildPolicy:
|
|
568
|
+
"""Return the build policy for ``canonical_name``.
|
|
569
|
+
|
|
570
|
+
Caller must canonicalise the name first. Overrides may be looser or
|
|
571
|
+
stricter than the global policy.
|
|
572
|
+
"""
|
|
573
|
+
return self._build_policy_overrides.get(canonical_name, self.build_policy)
|
|
574
|
+
|
|
575
|
+
def effective_dist_policy(self, canonical_name: str) -> DistPolicy:
|
|
576
|
+
"""Return the dist policy for ``canonical_name``."""
|
|
577
|
+
return self._dist_policy_overrides.get(canonical_name, self._dist_policy)
|
|
578
|
+
|
|
579
|
+
def force_backtrack_count(self, canonical_name: str) -> int:
|
|
580
|
+
"""How many times this package has triggered force-backtrack."""
|
|
581
|
+
return self._force_backtrack_counts.get(canonical_name, 0)
|
|
582
|
+
|
|
583
|
+
def has_invalid_metadata(self, canonical_name: str, version: Version) -> bool:
|
|
584
|
+
"""Return True if metadata parsing previously failed for this pin."""
|
|
585
|
+
return (canonical_name, version) in self._invalid_metadata
|
|
586
|
+
|
|
587
|
+
def materialize_local_source(
|
|
588
|
+
self,
|
|
589
|
+
normalized: str,
|
|
590
|
+
source: LocalSource,
|
|
591
|
+
) -> list[tuple[Version, DistFile]]:
|
|
592
|
+
"""See :func:`nab_python._provider.sources.materialize_local_source`."""
|
|
593
|
+
result: list[tuple[Version, DistFile]] = []
|
|
594
|
+
for version, sdist in _sources.materialize_local_source(
|
|
595
|
+
self, normalized, source
|
|
596
|
+
):
|
|
597
|
+
result.append((version, sdist))
|
|
598
|
+
return result
|
|
599
|
+
|
|
600
|
+
def materialize_vcs_source(
|
|
601
|
+
self,
|
|
602
|
+
normalized: str,
|
|
603
|
+
source: VcsSource,
|
|
604
|
+
) -> list[tuple[Version, DistFile]]:
|
|
605
|
+
"""See :func:`nab_python._provider.sources.materialize_vcs_source`."""
|
|
606
|
+
result: list[tuple[Version, DistFile]] = []
|
|
607
|
+
for version, sdist in _sources.materialize_vcs_source(self, normalized, source):
|
|
608
|
+
result.append((version, sdist))
|
|
609
|
+
return result
|
|
610
|
+
|
|
611
|
+
def versions_only(
|
|
612
|
+
self,
|
|
613
|
+
normalized: str,
|
|
614
|
+
version_list: list[tuple[Version, DistFile]],
|
|
615
|
+
) -> list[Version]:
|
|
616
|
+
"""See :func:`nab_python._provider.listing.versions_only`."""
|
|
617
|
+
return _listing.versions_only(self, normalized, version_list)
|
|
618
|
+
|
|
619
|
+
def _wheel_by_version(
|
|
620
|
+
self,
|
|
621
|
+
normalized: str,
|
|
622
|
+
version_list: list[tuple[Version, DistFile]],
|
|
623
|
+
) -> dict[Version, DistFile]:
|
|
624
|
+
"""See :func:`nab_python._provider.listing.wheel_by_version`."""
|
|
625
|
+
return _listing.wheel_by_version(self, normalized, version_list)
|
|
626
|
+
|
|
627
|
+
def speculative_prefetch(
|
|
628
|
+
self,
|
|
629
|
+
normalized: str,
|
|
630
|
+
versions: list[tuple[Version, DistFile]],
|
|
631
|
+
) -> None:
|
|
632
|
+
"""See :func:`nab_python._provider.listing.speculative_prefetch`."""
|
|
633
|
+
_listing.speculative_prefetch(self, normalized, versions)
|
|
634
|
+
|
|
635
|
+
def prefetch_walk_ahead(self, normalized: str) -> None:
|
|
636
|
+
"""See :func:`nab_python._provider.listing.prefetch_walk_ahead`."""
|
|
637
|
+
_listing.prefetch_walk_ahead(self, normalized, self.DEEP_PREFETCH_COUNT)
|
|
638
|
+
|
|
639
|
+
def filter_distributions(
|
|
640
|
+
self, normalized: str, files: Sequence[WheelFile | SdistFile]
|
|
641
|
+
) -> list[tuple[Version, DistFile]]:
|
|
642
|
+
"""See :func:`nab_python._provider.listing.filter_distributions`."""
|
|
643
|
+
return _listing.filter_distributions(self, normalized, files)
|
|
644
|
+
|
|
645
|
+
def pick_best_candidate(
|
|
646
|
+
self,
|
|
647
|
+
normalized: str,
|
|
648
|
+
versions: list[tuple[Version, DistFile]],
|
|
649
|
+
) -> tuple[Version, DistFile] | None:
|
|
650
|
+
"""See :func:`nab_python._provider.listing.pick_best_candidate`."""
|
|
651
|
+
return _listing.pick_best_candidate(self, normalized, versions)
|
|
652
|
+
|
|
653
|
+
def choose_version(
|
|
654
|
+
self, package: str, version_range: RangeProtocol[Version]
|
|
655
|
+
) -> Version | None:
|
|
656
|
+
"""Pick a version within the allowed range, respecting the strategy."""
|
|
657
|
+
assert isinstance(version_range, VersionRange)
|
|
658
|
+
self.stats.choose_version_calls += 1
|
|
659
|
+
|
|
660
|
+
base, extra, normalized = self.split_and_normalize(package)
|
|
661
|
+
if extra is not None:
|
|
662
|
+
return self._choose_extra_version(package, base, extra, version_range)
|
|
663
|
+
|
|
664
|
+
version_list = self.fetch_versions(package)
|
|
665
|
+
all_versions = self.versions_only(normalized, version_list)
|
|
666
|
+
candidates = list(version_range.filter(all_versions))
|
|
667
|
+
|
|
668
|
+
# VersionRange.filter yields newest-first; reverse for LOWEST so
|
|
669
|
+
# look-ahead walks oldest -> newest.
|
|
670
|
+
if self.wants_lowest(normalized):
|
|
671
|
+
candidates.reverse()
|
|
672
|
+
|
|
673
|
+
no_lookahead = not self.root_requirements and not self.solution_decisions
|
|
674
|
+
if no_lookahead or not candidates:
|
|
675
|
+
if not candidates:
|
|
676
|
+
self._record_no_versions_reason(package, all_versions)
|
|
677
|
+
return candidates[0] if candidates else None
|
|
678
|
+
|
|
679
|
+
skip = self._try_abort_skip(normalized, candidates[0])
|
|
680
|
+
if skip is not None:
|
|
681
|
+
return skip
|
|
682
|
+
|
|
683
|
+
wheel_by_version = self._wheel_by_version(normalized, version_list)
|
|
684
|
+
return self._run_full_scan(
|
|
685
|
+
normalized, candidates, wheel_by_version, package, all_versions
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
def _try_abort_skip(self, normalized: str, first: Version) -> Version | None:
|
|
689
|
+
"""Return the first candidate when a prior abort is still valid.
|
|
690
|
+
|
|
691
|
+
While the recorded blocker decision is unchanged, a re-run of
|
|
692
|
+
the full scan would just trip the abort again. A warm cache
|
|
693
|
+
hit returns directly; otherwise a non-decision look-ahead
|
|
694
|
+
guards against unreadable wheels. Returns None when no
|
|
695
|
+
recorded abort applies, or when the candidate fails the gate.
|
|
696
|
+
"""
|
|
697
|
+
aborted = self._lookahead_aborted.get(normalized)
|
|
698
|
+
if aborted is None:
|
|
699
|
+
return None
|
|
700
|
+
blocker_pkg, blocker_version = aborted
|
|
701
|
+
if self.solution_decisions.get(blocker_pkg) != blocker_version:
|
|
702
|
+
del self._lookahead_aborted[normalized]
|
|
703
|
+
return None
|
|
704
|
+
if (normalized, first) in self.deps_cache:
|
|
705
|
+
return first
|
|
706
|
+
if self._look_ahead_ok(normalized, first, check_decisions=False):
|
|
707
|
+
self._flush_pending_blocks()
|
|
708
|
+
return first
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
def _run_full_scan(
|
|
712
|
+
self,
|
|
713
|
+
normalized: str,
|
|
714
|
+
candidates: list[Version],
|
|
715
|
+
wheel_by_version: dict[Version, DistFile],
|
|
716
|
+
package: str,
|
|
717
|
+
all_versions: list[Version],
|
|
718
|
+
) -> Version | None:
|
|
719
|
+
"""Run the decision-aware look-ahead scan over candidates."""
|
|
720
|
+
broad_rejections = 0
|
|
721
|
+
if self._look_ahead_ok(normalized, candidates[0], check_decisions=True):
|
|
722
|
+
self._flush_pending_blocks()
|
|
723
|
+
return candidates[0]
|
|
724
|
+
self.stats.look_ahead_rejections += 1
|
|
725
|
+
broad_rejections += 1
|
|
726
|
+
|
|
727
|
+
found = self._scan_candidates_pipelined(
|
|
728
|
+
normalized,
|
|
729
|
+
candidates[1:],
|
|
730
|
+
wheel_by_version,
|
|
731
|
+
broad_rejections,
|
|
732
|
+
first_candidate=candidates[0],
|
|
733
|
+
)
|
|
734
|
+
if found is not None:
|
|
735
|
+
self._flush_pending_blocks()
|
|
736
|
+
return found
|
|
737
|
+
|
|
738
|
+
# Every candidate rejected. Flush so the resolver replaces the default
|
|
739
|
+
# NO_VERSIONS clause with the grouped binary incompatibilities.
|
|
740
|
+
blockers = self._capture_lookahead_blockers(normalized)
|
|
741
|
+
self._flush_pending_blocks()
|
|
742
|
+
self._record_no_versions_reason(package, all_versions, blockers=blockers)
|
|
743
|
+
return None
|
|
744
|
+
|
|
745
|
+
def _choose_extra_version(
|
|
746
|
+
self, package: str, base: str, extra: str, version_range: VersionRange
|
|
747
|
+
) -> Version | None:
|
|
748
|
+
"""See :func:`nab_python._provider.extras.choose_extra_version`."""
|
|
749
|
+
return _extras.choose_extra_version(self, package, base, extra, version_range)
|
|
750
|
+
|
|
751
|
+
def _scan_candidates_pipelined(
|
|
752
|
+
self,
|
|
753
|
+
normalized: str,
|
|
754
|
+
remaining: list[Version],
|
|
755
|
+
wheel_by_version: dict[Version, DistFile],
|
|
756
|
+
broad_rejections: int,
|
|
757
|
+
*,
|
|
758
|
+
first_candidate: Version | None = None,
|
|
759
|
+
) -> Version | None:
|
|
760
|
+
"""Scan ``remaining`` with ``PREFETCH_DEPTH`` batches in flight.
|
|
761
|
+
|
|
762
|
+
Returns the first ``_look_ahead_ok`` version, or ``first_candidate``
|
|
763
|
+
if the scan trips the monolithic-rejection abort, or ``None`` when
|
|
764
|
+
every candidate was rejected. Caller flushes pending blocks on
|
|
765
|
+
every return path.
|
|
766
|
+
|
|
767
|
+
Abort semantics: when ``broad_rejections`` crosses
|
|
768
|
+
``_LOOKAHEAD_ABORT_THRESHOLD`` and every queued rejection for
|
|
769
|
+
``normalized`` shares one ``(blocker_pkg, blocker_version)``,
|
|
770
|
+
discard the misleading singleton-blocker pending blocks for
|
|
771
|
+
``normalized`` and return ``first_candidate``. The resolver then
|
|
772
|
+
decides that candidate tentatively, ``get_dependencies`` emits the
|
|
773
|
+
actual dep-range clause, and pubgrub back-jumps the offending
|
|
774
|
+
blocker on its own. Sound because no clause is emitted by the
|
|
775
|
+
abort path.
|
|
776
|
+
"""
|
|
777
|
+
# Front-load deep metadata before the scan: by the time the
|
|
778
|
+
# 8-batch trips the abort, the rest of the walk is in flight.
|
|
779
|
+
self.prefetch_walk_ahead(normalized)
|
|
780
|
+
|
|
781
|
+
starts_iter = iter(range(0, len(remaining), self.PREFETCH_BATCH))
|
|
782
|
+
in_flight: deque[
|
|
783
|
+
tuple[list[Version], list[tuple[Version, str, threading.Event]]]
|
|
784
|
+
] = deque()
|
|
785
|
+
for _ in range(self.PREFETCH_DEPTH):
|
|
786
|
+
start = next(starts_iter, None)
|
|
787
|
+
if start is None:
|
|
788
|
+
break
|
|
789
|
+
batch = remaining[start : start + self.PREFETCH_BATCH]
|
|
790
|
+
submitted = self._prefetch_batch(normalized, batch, wheel_by_version)
|
|
791
|
+
in_flight.append((batch, submitted))
|
|
792
|
+
|
|
793
|
+
while in_flight:
|
|
794
|
+
batch, submitted = in_flight.popleft()
|
|
795
|
+
# Refill before awaiting so the next fetch overlaps the wait.
|
|
796
|
+
next_start = next(starts_iter, None)
|
|
797
|
+
if next_start is not None:
|
|
798
|
+
next_batch = remaining[next_start : next_start + self.PREFETCH_BATCH]
|
|
799
|
+
next_submitted = self._prefetch_batch(
|
|
800
|
+
normalized, next_batch, wheel_by_version
|
|
801
|
+
)
|
|
802
|
+
in_flight.append((next_batch, next_submitted))
|
|
803
|
+
self._await_metadata_batch(normalized, submitted)
|
|
804
|
+
outcome, broad_rejections = self._scan_batch(
|
|
805
|
+
normalized, batch, broad_rejections, first_candidate
|
|
806
|
+
)
|
|
807
|
+
if outcome is not None:
|
|
808
|
+
return outcome
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
def _scan_batch(
|
|
812
|
+
self,
|
|
813
|
+
normalized: str,
|
|
814
|
+
batch: list[Version],
|
|
815
|
+
broad_rejections: int,
|
|
816
|
+
first_candidate: Version | None,
|
|
817
|
+
) -> tuple[Version | None, int]:
|
|
818
|
+
"""Look-ahead-check each version. Return (winner, new_rejection_count).
|
|
819
|
+
|
|
820
|
+
Winner is the first compatible candidate, or ``first_candidate``
|
|
821
|
+
when the abort fires, or None to keep scanning further batches.
|
|
822
|
+
"""
|
|
823
|
+
for version in batch:
|
|
824
|
+
check_decisions = broad_rejections < self._BROAD_LA_REJECT_CAP
|
|
825
|
+
if self._look_ahead_ok(
|
|
826
|
+
normalized, version, check_decisions=check_decisions
|
|
827
|
+
):
|
|
828
|
+
return version, broad_rejections
|
|
829
|
+
self.stats.look_ahead_rejections += 1
|
|
830
|
+
if not check_decisions:
|
|
831
|
+
continue
|
|
832
|
+
broad_rejections += 1
|
|
833
|
+
if first_candidate is None:
|
|
834
|
+
continue
|
|
835
|
+
if broad_rejections < self._LOOKAHEAD_ABORT_THRESHOLD:
|
|
836
|
+
continue
|
|
837
|
+
if self._try_abort_lookahead(normalized):
|
|
838
|
+
return first_candidate, broad_rejections
|
|
839
|
+
return None, broad_rejections
|
|
840
|
+
|
|
841
|
+
def _try_abort_lookahead(self, normalized: str) -> bool:
|
|
842
|
+
"""Run the monolithic-rejection abort. Return True when fired.
|
|
843
|
+
|
|
844
|
+
Records the abort state, queues the blocker for force-backtrack
|
|
845
|
+
(up to the per-blocker cap), and returns True so the caller
|
|
846
|
+
falls back to its first candidate.
|
|
847
|
+
"""
|
|
848
|
+
blocker = self._should_abort_lookahead(normalized)
|
|
849
|
+
if blocker is None:
|
|
850
|
+
return False
|
|
851
|
+
self._discard_pending_decision_blocks(normalized)
|
|
852
|
+
self._lookahead_aborted[normalized] = blocker
|
|
853
|
+
blocker_pkg, _ = blocker
|
|
854
|
+
prior_fires = self._force_backtrack_counts.get(blocker_pkg, 0)
|
|
855
|
+
if (
|
|
856
|
+
blocker_pkg not in self._force_backtrack_targets
|
|
857
|
+
and prior_fires < self._MAX_FORCE_BACKTRACKS_PER_PKG
|
|
858
|
+
):
|
|
859
|
+
self._force_backtrack_targets.append(blocker_pkg)
|
|
860
|
+
self._force_backtrack_counts[blocker_pkg] = prior_fires + 1
|
|
861
|
+
return True
|
|
862
|
+
|
|
863
|
+
def _should_abort_lookahead(self, normalized: str) -> tuple[str, Version] | None:
|
|
864
|
+
"""Return the single shared blocker if every rejection blames it.
|
|
865
|
+
|
|
866
|
+
The trigger is intentionally narrow: only when *every* rejection
|
|
867
|
+
for ``normalized`` is a decision block with the same
|
|
868
|
+
``(blocker_pkg, blocker_version)`` key and there are no
|
|
869
|
+
range / root / metadata blocks. Returns ``(blocker_pkg,
|
|
870
|
+
blocker_version)`` when the condition holds, else ``None``.
|
|
871
|
+
Mixed-cause scans keep the per-version clauses because at least
|
|
872
|
+
one rejection cause is a real constraint the resolver still
|
|
873
|
+
needs to learn.
|
|
874
|
+
"""
|
|
875
|
+
seen: set[tuple[str, Version]] = set()
|
|
876
|
+
for cand, blocker_pkg, blocker_version in self.pending_blocks:
|
|
877
|
+
if cand == normalized:
|
|
878
|
+
seen.add((blocker_pkg, blocker_version))
|
|
879
|
+
if len(seen) > 1:
|
|
880
|
+
return None
|
|
881
|
+
if len(seen) != 1:
|
|
882
|
+
return None
|
|
883
|
+
if any(cand == normalized for cand, *_ in self.pending_range_blocks):
|
|
884
|
+
return None
|
|
885
|
+
if any(cand == normalized for cand, *_ in self.pending_root_blocks):
|
|
886
|
+
return None
|
|
887
|
+
if normalized in self.pending_metadata_blocks:
|
|
888
|
+
return None
|
|
889
|
+
return next(iter(seen))
|
|
890
|
+
|
|
891
|
+
def _discard_pending_decision_blocks(self, normalized: str) -> None:
|
|
892
|
+
"""Drop decision-block entries for ``normalized`` without emitting clauses.
|
|
893
|
+
|
|
894
|
+
Used by the look-ahead abort path: the singleton-blocker clauses
|
|
895
|
+
the queue would otherwise produce are exactly the ones that
|
|
896
|
+
mislead the resolver into picking a deep candidate. Range / root
|
|
897
|
+
/ metadata blocks are left in place because the abort path only
|
|
898
|
+
fires when none exist for this candidate; this helper still
|
|
899
|
+
scopes its delete to the matching candidate name for safety.
|
|
900
|
+
"""
|
|
901
|
+
self.pending_blocks = defaultdict(
|
|
902
|
+
list,
|
|
903
|
+
{k: v for k, v in self.pending_blocks.items() if k[0] != normalized},
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
def wants_lowest(self, normalized: str) -> bool:
|
|
907
|
+
"""Whether the resolver should pick the minimum version for ``normalized``.
|
|
908
|
+
|
|
909
|
+
Lookup keys are canonical names; extras-proxy callers must
|
|
910
|
+
pass the *base* name (the strategy decision is keyed off the
|
|
911
|
+
underlying package, not the proxy).
|
|
912
|
+
"""
|
|
913
|
+
if self._resolution_strategy is ResolutionStrategy.LOWEST:
|
|
914
|
+
return True
|
|
915
|
+
if self._resolution_strategy is ResolutionStrategy.LOWEST_DIRECT:
|
|
916
|
+
return normalized in self._direct_packages
|
|
917
|
+
return False
|
|
918
|
+
|
|
919
|
+
def _record_no_versions_reason(
|
|
920
|
+
self,
|
|
921
|
+
package: str,
|
|
922
|
+
all_versions: list[Version],
|
|
923
|
+
*,
|
|
924
|
+
blockers: list[str] | None = None,
|
|
925
|
+
) -> None:
|
|
926
|
+
"""Record why ``choose_version`` returned ``None`` for ``package``.
|
|
927
|
+
|
|
928
|
+
``blockers`` carries the look-ahead rejection causes when
|
|
929
|
+
every candidate that fell in ``version_range`` was rejected:
|
|
930
|
+
either because of an already-decided package, a positive-range
|
|
931
|
+
constraint, a root-requirement disagreement, or because the
|
|
932
|
+
candidate's metadata could not be read under the current
|
|
933
|
+
build policy. When supplied, the recorded reason names those
|
|
934
|
+
causes so the user does not see a bare "no version matches
|
|
935
|
+
the requirement", which would suggest the package is
|
|
936
|
+
missing from the index when in fact it is the resolver's
|
|
937
|
+
transitive constraints (or a too-strict build policy) that
|
|
938
|
+
excluded every candidate.
|
|
939
|
+
"""
|
|
940
|
+
if not all_versions:
|
|
941
|
+
reason = "package not found on any configured index"
|
|
942
|
+
elif blockers:
|
|
943
|
+
# Look-ahead rejection: candidates DID match the range but
|
|
944
|
+
# were rejected. Naming the blocker is more useful than
|
|
945
|
+
# a generic "no version matches" line, which would
|
|
946
|
+
# otherwise fire because ``all_versions`` contains
|
|
947
|
+
# versions inside ``version_range``.
|
|
948
|
+
joined = "; ".join(blockers)
|
|
949
|
+
reason = f"every version in range was rejected: {joined}"
|
|
950
|
+
else:
|
|
951
|
+
reason = "no version matches the requirement"
|
|
952
|
+
self._no_versions_reasons[package] = reason
|
|
953
|
+
|
|
954
|
+
def _capture_lookahead_blockers(self, normalized: str) -> list[str]:
|
|
955
|
+
"""Summarise pending look-ahead rejections for ``normalized``.
|
|
956
|
+
|
|
957
|
+
Returns one human-readable string per blocker source
|
|
958
|
+
(decisions, positive ranges, root disagreements, metadata errors).
|
|
959
|
+
"""
|
|
960
|
+
out: list[str] = []
|
|
961
|
+
|
|
962
|
+
for cand, blocker_pkg, blocker_version in self.pending_blocks:
|
|
963
|
+
if cand != normalized:
|
|
964
|
+
continue
|
|
965
|
+
out.append(f"requires {blocker_pkg} != {blocker_version}")
|
|
966
|
+
|
|
967
|
+
for cand, blocker_pkg, blocker_range in self.pending_range_blocks:
|
|
968
|
+
if cand != normalized:
|
|
969
|
+
continue
|
|
970
|
+
out.append(
|
|
971
|
+
f"requires {blocker_pkg} in {blocker_range}"
|
|
972
|
+
" (disjoint with current solution range)"
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
for (
|
|
976
|
+
cand,
|
|
977
|
+
blocker_pkg,
|
|
978
|
+
dep_range,
|
|
979
|
+
root_range,
|
|
980
|
+
) in self.pending_root_blocks:
|
|
981
|
+
if cand != normalized:
|
|
982
|
+
continue
|
|
983
|
+
out.append(
|
|
984
|
+
f"requires {blocker_pkg} in {dep_range} but root has it in {root_range}"
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
# Collapse repeated metadata-error blockers (one per version) into
|
|
988
|
+
# a single "N versions failed (first: <msg>)" line.
|
|
989
|
+
meta = self.pending_metadata_blocks.get(normalized)
|
|
990
|
+
if meta:
|
|
991
|
+
count = len(meta)
|
|
992
|
+
first_msg = meta[0][1]
|
|
993
|
+
if count == 1:
|
|
994
|
+
out.append(first_msg)
|
|
995
|
+
else:
|
|
996
|
+
out.append(
|
|
997
|
+
f"{count} versions failed metadata extraction (first: {first_msg})"
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
return out
|
|
1001
|
+
|
|
1002
|
+
def get_no_versions_reason(self, package: str) -> str | None:
|
|
1003
|
+
"""Return the recorded reason for ``package``'s NO_VERSIONS clause.
|
|
1004
|
+
|
|
1005
|
+
Returns ``None`` if no diagnostic was captured (e.g. the
|
|
1006
|
+
package was decided successfully or failed for a non-listing
|
|
1007
|
+
reason such as a metadata parse error).
|
|
1008
|
+
"""
|
|
1009
|
+
return self._no_versions_reasons.get(package)
|
|
1010
|
+
|
|
1011
|
+
def _prefetch_batch(
|
|
1012
|
+
self,
|
|
1013
|
+
package: str,
|
|
1014
|
+
versions: list[Version],
|
|
1015
|
+
wheel_by_version: dict[Version, DistFile],
|
|
1016
|
+
) -> list[tuple[Version, str, threading.Event]]:
|
|
1017
|
+
"""See :func:`nab_python._provider.listing.prefetch_batch`."""
|
|
1018
|
+
return _listing.prefetch_batch(self, package, versions, wheel_by_version)
|
|
1019
|
+
|
|
1020
|
+
def _await_metadata_batch(
|
|
1021
|
+
self,
|
|
1022
|
+
package: str,
|
|
1023
|
+
submitted: list[tuple[Version, str, threading.Event]],
|
|
1024
|
+
) -> None:
|
|
1025
|
+
"""See :func:`nab_python._provider.listing.await_metadata_batch`."""
|
|
1026
|
+
_listing.await_metadata_batch(self, package, submitted)
|
|
1027
|
+
|
|
1028
|
+
def receive_partial_solution_hint(
|
|
1029
|
+
self,
|
|
1030
|
+
positive_ranges: Mapping[str, RangeProtocol[Version]],
|
|
1031
|
+
decisions: Mapping[str, Version],
|
|
1032
|
+
) -> None:
|
|
1033
|
+
"""Accept a snapshot of the resolver's positive-range assignments.
|
|
1034
|
+
|
|
1035
|
+
Decision-only forward checking is safer than reasoning over
|
|
1036
|
+
derivations because backjumping a decision also undoes its derivations.
|
|
1037
|
+
"""
|
|
1038
|
+
self.solution_ranges = dict(positive_ranges)
|
|
1039
|
+
self.solution_decisions = dict(decisions)
|
|
1040
|
+
|
|
1041
|
+
def _look_ahead_ok(
|
|
1042
|
+
self, package: str, version: Version, *, check_decisions: bool = True
|
|
1043
|
+
) -> bool:
|
|
1044
|
+
"""See :func:`nab_python._provider.lookahead.look_ahead_ok`."""
|
|
1045
|
+
return _lookahead.look_ahead_ok(
|
|
1046
|
+
self, package, version, check_decisions=check_decisions
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
def _flush_pending_blocks(self) -> None:
|
|
1050
|
+
"""See :func:`nab_python._provider.lookahead.flush_pending_blocks`."""
|
|
1051
|
+
_lookahead.flush_pending_blocks(self)
|
|
1052
|
+
|
|
1053
|
+
def consume_pending_clauses(self) -> list[Incompatibility[str, Version]]:
|
|
1054
|
+
"""Drain queued binary clauses for the resolver to absorb."""
|
|
1055
|
+
clauses = self.pending_clauses
|
|
1056
|
+
self.pending_clauses = []
|
|
1057
|
+
return clauses
|
|
1058
|
+
|
|
1059
|
+
def consume_force_backtrack_targets(self) -> list[str]:
|
|
1060
|
+
"""Drain blocker packages queued by the look-ahead abort path.
|
|
1061
|
+
|
|
1062
|
+
See ``ResolverProvider.consume_force_backtrack_targets`` for the
|
|
1063
|
+
contract. Returning a non-empty list asks the resolver to skip
|
|
1064
|
+
deciding the candidate just returned by ``choose_version`` and
|
|
1065
|
+
instead targeted-back-track these packages immediately.
|
|
1066
|
+
"""
|
|
1067
|
+
targets = self._force_backtrack_targets
|
|
1068
|
+
self._force_backtrack_targets = []
|
|
1069
|
+
return targets
|
|
1070
|
+
|
|
1071
|
+
def get_dependencies(
|
|
1072
|
+
self, package: str, version: Version
|
|
1073
|
+
) -> dict[str, VersionRange]:
|
|
1074
|
+
"""Fetch .metadata and return dependencies as VersionRange."""
|
|
1075
|
+
self.stats.get_dependencies_calls += 1
|
|
1076
|
+
|
|
1077
|
+
base, extra, normalized = self.split_and_normalize(package)
|
|
1078
|
+
if extra is not None:
|
|
1079
|
+
return self._get_extra_dependencies(base, extra, version)
|
|
1080
|
+
|
|
1081
|
+
cache_key = (normalized, version)
|
|
1082
|
+
if cache_key in self.deps_cache:
|
|
1083
|
+
return self.deps_cache[cache_key]
|
|
1084
|
+
if cache_key in self._unsupported_sdists:
|
|
1085
|
+
effective = self.effective_build_policy(normalized)
|
|
1086
|
+
msg = (
|
|
1087
|
+
f"{normalized}=={version} sdist metadata could not be extracted"
|
|
1088
|
+
f" under BuildPolicy.{effective.name} (cached prior failure)"
|
|
1089
|
+
)
|
|
1090
|
+
raise UnsupportedSdistError(msg)
|
|
1091
|
+
cached_invalid = self._invalid_metadata.get(cache_key)
|
|
1092
|
+
if cached_invalid is not None:
|
|
1093
|
+
raise MetadataError(cached_invalid)
|
|
1094
|
+
|
|
1095
|
+
versions = self.fetch_versions(package)
|
|
1096
|
+
|
|
1097
|
+
# Local + VCS sources pre-populate metadata during fetch_versions.
|
|
1098
|
+
if cache_key in self.metadata_cache and (
|
|
1099
|
+
normalized in self.local_sources or normalized in self.vcs_sources
|
|
1100
|
+
):
|
|
1101
|
+
self._cache_deps_from_metadata(cache_key, self.metadata_cache[cache_key])
|
|
1102
|
+
return self.deps_cache[cache_key]
|
|
1103
|
+
|
|
1104
|
+
metadata_text, from_sdist = self._resolve_metadata(versions, package, version)
|
|
1105
|
+
|
|
1106
|
+
try:
|
|
1107
|
+
self.parse_and_cache_metadata(
|
|
1108
|
+
cache_key, metadata_text, from_sdist=from_sdist
|
|
1109
|
+
)
|
|
1110
|
+
except UnsupportedSdistError:
|
|
1111
|
+
self._unsupported_sdists.add(cache_key)
|
|
1112
|
+
raise
|
|
1113
|
+
except Exception as exc:
|
|
1114
|
+
logger.warning(
|
|
1115
|
+
"Skipping %s==%s: metadata cannot be parsed (%s)."
|
|
1116
|
+
" Subsequent lookups for this version reuse the cached"
|
|
1117
|
+
" failure and do not re-emit this warning.",
|
|
1118
|
+
package,
|
|
1119
|
+
version,
|
|
1120
|
+
exc,
|
|
1121
|
+
)
|
|
1122
|
+
msg = f"Invalid metadata for {package}=={version}: {exc}"
|
|
1123
|
+
self._invalid_metadata[cache_key] = msg
|
|
1124
|
+
raise MetadataError(msg) from exc
|
|
1125
|
+
|
|
1126
|
+
self.stats.metadata_fetched += 1
|
|
1127
|
+
self.prefetch_new_deps(self.deps_cache[cache_key])
|
|
1128
|
+
|
|
1129
|
+
return self.deps_cache[cache_key]
|
|
1130
|
+
|
|
1131
|
+
def prefetch_new_deps(self, deps: dict[str, VersionRange]) -> None:
|
|
1132
|
+
"""See :func:`nab_python._provider.listing.prefetch_new_deps`."""
|
|
1133
|
+
_listing.prefetch_new_deps(self, deps)
|
|
1134
|
+
|
|
1135
|
+
def _resolve_metadata(
|
|
1136
|
+
self,
|
|
1137
|
+
versions: list[tuple[Version, DistFile]],
|
|
1138
|
+
package: str,
|
|
1139
|
+
version: Version,
|
|
1140
|
+
) -> tuple[str, bool]:
|
|
1141
|
+
"""See :func:`nab_python._provider.metadata_resolver.resolve_metadata`."""
|
|
1142
|
+
return _metadata_resolver.resolve_metadata(self, versions, package, version)
|
|
1143
|
+
|
|
1144
|
+
def parse_and_cache_metadata(
|
|
1145
|
+
self,
|
|
1146
|
+
cache_key: tuple[str, Version],
|
|
1147
|
+
metadata_text: str,
|
|
1148
|
+
*,
|
|
1149
|
+
from_sdist: bool = False,
|
|
1150
|
+
) -> None:
|
|
1151
|
+
"""See :func:`._provider.metadata_resolver.parse_and_cache_metadata`."""
|
|
1152
|
+
_metadata_resolver.parse_and_cache_metadata(
|
|
1153
|
+
self, cache_key, metadata_text, from_sdist=from_sdist
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
def _cache_deps_from_metadata(
|
|
1157
|
+
self,
|
|
1158
|
+
cache_key: tuple[str, Version],
|
|
1159
|
+
metadata: WheelMetadata,
|
|
1160
|
+
) -> None:
|
|
1161
|
+
"""See :func:`._provider.metadata_resolver.cache_deps_from_metadata`."""
|
|
1162
|
+
_metadata_resolver.cache_deps_from_metadata(self, cache_key, metadata)
|
|
1163
|
+
|
|
1164
|
+
def _get_extra_dependencies(
|
|
1165
|
+
self,
|
|
1166
|
+
base: str,
|
|
1167
|
+
extra: str,
|
|
1168
|
+
version: Version,
|
|
1169
|
+
) -> dict[str, VersionRange]:
|
|
1170
|
+
"""See :func:`nab_python._provider.extras.get_extra_dependencies`."""
|
|
1171
|
+
return _extras.get_extra_dependencies(self, base, extra, version)
|
|
1172
|
+
|
|
1173
|
+
def split_and_normalize(self, package: str) -> tuple[str, str | None, str]:
|
|
1174
|
+
"""Return ``(base, extra, normalized_base)`` for ``package``, cached."""
|
|
1175
|
+
cached = self._package_parts.get(package)
|
|
1176
|
+
if cached is not None:
|
|
1177
|
+
return cached
|
|
1178
|
+
base, extra = split_extra(package)
|
|
1179
|
+
normalized = canonicalize_name(base)
|
|
1180
|
+
result = (base, extra, normalized)
|
|
1181
|
+
self._package_parts[package] = result
|
|
1182
|
+
return result
|
|
1183
|
+
|
|
1184
|
+
def is_ready(self, package: str) -> bool:
|
|
1185
|
+
"""Check if a package's listing is available without blocking.
|
|
1186
|
+
|
|
1187
|
+
Used by the resolver to prefer packages with cached data,
|
|
1188
|
+
letting it make progress while other listings are in flight.
|
|
1189
|
+
"""
|
|
1190
|
+
_, extra, normalized = self.split_and_normalize(package)
|
|
1191
|
+
if extra is not None:
|
|
1192
|
+
return normalized in self.versions_cache
|
|
1193
|
+
if normalized in self.versions_cache:
|
|
1194
|
+
return True
|
|
1195
|
+
return self.coordinator.index.get_listing(normalized) is not None
|
|
1196
|
+
|
|
1197
|
+
def prioritize(
|
|
1198
|
+
self,
|
|
1199
|
+
package: str,
|
|
1200
|
+
version_range: RangeProtocol[Version],
|
|
1201
|
+
conflict_counts: Mapping[str, int],
|
|
1202
|
+
culprit_counts: Mapping[str, int] | None = None,
|
|
1203
|
+
) -> tuple[int, int, bool]:
|
|
1204
|
+
"""Prioritize packages for resolution order.
|
|
1205
|
+
|
|
1206
|
+
Returns ``(tier, matching_count, is_base)``. Affected packages
|
|
1207
|
+
with high ``conflict_counts`` are promoted to tier 0 so they
|
|
1208
|
+
decide first inside a conflict cluster; runaway culprits with
|
|
1209
|
+
high ``culprit_counts`` are demoted to tier 2 (uv's
|
|
1210
|
+
deprioritise-on-conflict). Everything else is tier 1.
|
|
1211
|
+
|
|
1212
|
+
See :mod:`nab_python._provider.priority` for the implementation.
|
|
1213
|
+
"""
|
|
1214
|
+
return _priority.prioritize(
|
|
1215
|
+
self, package, version_range, conflict_counts, culprit_counts
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
def local_source_for(self, canonical_name: str) -> LocalSource | None:
|
|
1219
|
+
"""Return the local source registered under ``canonical_name`` or None."""
|
|
1220
|
+
return self.local_sources.get(canonicalize_name(canonical_name))
|
|
1221
|
+
|
|
1222
|
+
def vcs_source_for(self, canonical_name: str) -> VcsSource | None:
|
|
1223
|
+
"""Return the VCS source registered under ``canonical_name`` or None."""
|
|
1224
|
+
return self.vcs_sources.get(canonicalize_name(canonical_name))
|
|
1225
|
+
|
|
1226
|
+
def dist_files_for(self, canonical_name: str, version: Version) -> list[DistFile]:
|
|
1227
|
+
"""Return every distribution file the resolver saw at ``version``.
|
|
1228
|
+
|
|
1229
|
+
Drawn from the cached listing populated during the resolve, so
|
|
1230
|
+
callers do not pay another fetch. When the package was never
|
|
1231
|
+
listed (synthetic / not asked for), returns an empty list.
|
|
1232
|
+
"""
|
|
1233
|
+
normalized = canonicalize_name(canonical_name)
|
|
1234
|
+
listing = self.versions_cache.get(normalized, [])
|
|
1235
|
+
return [dist for v, dist in listing if v == version]
|