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.
Files changed (71) hide show
  1. nab_python/__init__.py +1 -0
  2. nab_python/_build/__init__.py +1 -0
  3. nab_python/_build/env.py +364 -0
  4. nab_python/_build/errors.py +17 -0
  5. nab_python/_build/runner.py +254 -0
  6. nab_python/_lockfile/__init__.py +1 -0
  7. nab_python/_lockfile/builder.py +339 -0
  8. nab_python/_lockfile/disjointness.py +207 -0
  9. nab_python/_lockfile/pylock.py +323 -0
  10. nab_python/_lockfile/requirements.py +121 -0
  11. nab_python/_packaging_provider.py +98 -0
  12. nab_python/_provider/__init__.py +1 -0
  13. nab_python/_provider/build_remote.py +95 -0
  14. nab_python/_provider/extras.py +231 -0
  15. nab_python/_provider/listing.py +442 -0
  16. nab_python/_provider/lookahead.py +156 -0
  17. nab_python/_provider/metadata_resolver.py +450 -0
  18. nab_python/_provider/priority.py +174 -0
  19. nab_python/_provider/sources.py +215 -0
  20. nab_python/_testing/__init__.py +1 -0
  21. nab_python/_testing/coordinator_fake.py +240 -0
  22. nab_python/_vcs_admission.py +209 -0
  23. nab_python/_vendor/__init__.py +6 -0
  24. nab_python/_vendor/packaging/LICENSE +3 -0
  25. nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
  26. nab_python/_vendor/packaging/LICENSE.BSD +23 -0
  27. nab_python/_vendor/packaging/PROVENANCE.md +73 -0
  28. nab_python/_vendor/packaging/__init__.py +15 -0
  29. nab_python/_vendor/packaging/_elffile.py +108 -0
  30. nab_python/_vendor/packaging/_manylinux.py +265 -0
  31. nab_python/_vendor/packaging/_musllinux.py +88 -0
  32. nab_python/_vendor/packaging/_parser.py +394 -0
  33. nab_python/_vendor/packaging/_structures.py +33 -0
  34. nab_python/_vendor/packaging/_tokenizer.py +196 -0
  35. nab_python/_vendor/packaging/dependency_groups.py +302 -0
  36. nab_python/_vendor/packaging/direct_url.py +325 -0
  37. nab_python/_vendor/packaging/errors.py +94 -0
  38. nab_python/_vendor/packaging/licenses/__init__.py +186 -0
  39. nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
  40. nab_python/_vendor/packaging/markers.py +506 -0
  41. nab_python/_vendor/packaging/metadata.py +964 -0
  42. nab_python/_vendor/packaging/py.typed +0 -0
  43. nab_python/_vendor/packaging/pylock.py +910 -0
  44. nab_python/_vendor/packaging/ranges.py +1803 -0
  45. nab_python/_vendor/packaging/requirements.py +132 -0
  46. nab_python/_vendor/packaging/specifiers.py +1141 -0
  47. nab_python/_vendor/packaging/tags.py +929 -0
  48. nab_python/_vendor/packaging/utils.py +296 -0
  49. nab_python/_vendor/packaging/version.py +1230 -0
  50. nab_python/build_backend.py +184 -0
  51. nab_python/config.py +805 -0
  52. nab_python/download.py +170 -0
  53. nab_python/fetch.py +827 -0
  54. nab_python/lockfile.py +238 -0
  55. nab_python/metadata.py +145 -0
  56. nab_python/provider.py +1235 -0
  57. nab_python/py.typed +0 -0
  58. nab_python/requirements_file.py +180 -0
  59. nab_python/resolve.py +497 -0
  60. nab_python/universal/__init__.py +1 -0
  61. nab_python/universal/matrix.py +235 -0
  62. nab_python/universal/provider.py +214 -0
  63. nab_python/universal/reresolve.py +310 -0
  64. nab_python/universal/resolve.py +508 -0
  65. nab_python/universal/validate.py +439 -0
  66. nab_python/universal/wheel_selection.py +327 -0
  67. nab_python/workspace.py +214 -0
  68. nab_python-0.0.1.dist-info/METADATA +49 -0
  69. nab_python-0.0.1.dist-info/RECORD +71 -0
  70. nab_python-0.0.1.dist-info/WHEEL +4 -0
  71. 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]