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
@@ -0,0 +1,231 @@
1
+ """Extras-of-extras expansion for the provider.
2
+
3
+ The provider models extras as proxy packages: ``foo[bar]`` is a
4
+ distinct package whose only candidates are the versions of ``foo``
5
+ that declare ``bar`` in their ``Provides-Extra`` field. This
6
+ module owns the per-extra version chooser, the per-extra
7
+ dependency lookup, and the missing-extra fallback.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import TYPE_CHECKING
14
+
15
+ from .._vendor.packaging.ranges import VersionRange
16
+
17
+ if TYPE_CHECKING:
18
+ from nab_resolver.types import RangeProtocol
19
+
20
+ from .._vendor.packaging.version import Version
21
+ from ..provider import Provider
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def choose_extra_version(
28
+ provider: Provider,
29
+ package: str,
30
+ base: str,
31
+ extra: str,
32
+ version_range: VersionRange,
33
+ ) -> Version | None:
34
+ """Pick a version for an extras proxy package.
35
+
36
+ Delegates to the base package's version list. In BACKTRACK mode,
37
+ eagerly checks if the version provides the extra and skips it
38
+ if not. The strategy decision (highest vs lowest) is keyed off
39
+ the *base* canonical name; an extras proxy never gets a different
40
+ answer than its underlying package.
41
+ """
42
+ _, _, normalized = provider.split_and_normalize(base)
43
+ version_list = provider.fetch_versions(base)
44
+ all_versions = provider.versions_only(normalized, version_list)
45
+ candidates = list(version_range.filter(all_versions))
46
+
47
+ # Filter by base's positive range so we don't pick a proxy version
48
+ # that would force base==V into a known-conflicting state.
49
+ base_range = provider.solution_ranges.get(normalized)
50
+ excluded_by_base: list[Version] = []
51
+ if base_range is not None:
52
+ kept: list[Version] = []
53
+ for v in candidates:
54
+ if v in base_range:
55
+ kept.append(v)
56
+ else:
57
+ excluded_by_base.append(v)
58
+ candidates = kept
59
+
60
+ if provider.wants_lowest(normalized):
61
+ candidates = list(reversed(candidates))
62
+
63
+ chosen = _pick_in_mode(provider, base, extra, candidates)
64
+ if chosen is None and excluded_by_base and base_range is not None:
65
+ _record_base_range_blocks(
66
+ provider, package, normalized, base_range, excluded_by_base
67
+ )
68
+ return chosen
69
+
70
+
71
+ def _pick_in_mode(
72
+ provider: Provider,
73
+ base: str,
74
+ extra: str,
75
+ candidates: list[Version],
76
+ ) -> Version | None:
77
+ """Pick a candidate honoring ``ExtrasMode``.
78
+
79
+ User-requested extras short-circuit and return the first
80
+ candidate. Transitive extras validate the base metadata parses
81
+ so a malformed PKG-INFO becomes a candidate skip instead of a
82
+ fatal error during the later dependency fetch. BACKTRACK mode
83
+ additionally checks ``Provides-Extra``.
84
+
85
+ Missing-metadata cases (no PEP 658, no sdist) fall through;
86
+ mock test coordinators rely on this.
87
+ """
88
+ # Late import: ``pypi`` imports this module at module load.
89
+ from ..provider import ExtrasMode, MetadataError, _normalize_extra
90
+
91
+ _, _, normalized = provider.split_and_normalize(base)
92
+ is_user = (normalized, extra) in provider.root_extras
93
+ backtrack = provider.extras_mode == ExtrasMode.BACKTRACK
94
+ for version in candidates:
95
+ if is_user:
96
+ return version
97
+ if provider.has_invalid_metadata(normalized, version):
98
+ continue
99
+ # Fetch base metadata so an unparseable PKG-INFO is caught
100
+ # before the extras proxy decides this version. Any
101
+ # MetadataError (parse failure or no metadata source) is a
102
+ # candidate skip.
103
+ try:
104
+ provider.get_dependencies(base, version)
105
+ except MetadataError:
106
+ continue
107
+ if not backtrack:
108
+ return version
109
+ metadata = provider.metadata_cache.get((normalized, version))
110
+ provided = (
111
+ {_normalize_extra(e) for e in metadata.provides_extra}
112
+ if metadata
113
+ else set()
114
+ )
115
+ if metadata is None or extra in provided:
116
+ return version
117
+ return None
118
+
119
+
120
+ def _record_base_range_blocks(
121
+ provider: Provider,
122
+ proxy_pkg: str,
123
+ base_normalized: str,
124
+ base_range: RangeProtocol[Version],
125
+ excluded: list[Version],
126
+ ) -> None:
127
+ """Push binary clauses for proxy candidates filtered by base's range.
128
+
129
+ Each excluded version V records ``{proxy_pkg == V, base ==
130
+ base_decision}`` (or the range-block analogue) impossible.
131
+ Without these, the resolver only sees a single-term NO_VERSIONS
132
+ clause for the proxy and cannot connect the proxy's
133
+ unsatisfiability to the base decision that caused it; with them,
134
+ conflict resolution can learn to revisit the base decision.
135
+
136
+ The caller guarantees ``excluded`` is non-empty: filtering only
137
+ populates it when ``base_range`` is set, so the range-block path
138
+ always has a target to record against. When the resolver has
139
+ already decided the base, recording against the decision is
140
+ tighter (a singleton blocker) than recording against the range.
141
+ """
142
+ # Late import: ``lookahead`` shares state with this module
143
+ # through ``pypi`` and importing it at module load creates a cycle.
144
+ from .lookahead import flush_pending_blocks
145
+
146
+ base_decision = provider.solution_decisions.get(base_normalized)
147
+ if base_decision is not None:
148
+ for v in excluded:
149
+ provider.pending_blocks[(proxy_pkg, base_normalized, base_decision)].append(
150
+ v
151
+ )
152
+ else:
153
+ for v in excluded:
154
+ provider.pending_range_blocks[
155
+ (proxy_pkg, base_normalized, base_range)
156
+ ].append(v)
157
+ flush_pending_blocks(provider)
158
+
159
+
160
+ def get_extra_dependencies(
161
+ provider: Provider,
162
+ base: str,
163
+ extra: str,
164
+ version: Version,
165
+ ) -> dict[str, VersionRange]:
166
+ """Get dependencies for an extras proxy package."""
167
+ # Late import: ``pypi`` imports this module at module load.
168
+ from ..provider import MetadataError, join_extra
169
+
170
+ _, _, normalized = provider.split_and_normalize(base)
171
+ extra_key = join_extra(normalized, extra)
172
+ cache_key = (extra_key, version)
173
+ if cache_key in provider.deps_cache:
174
+ return provider.deps_cache[cache_key]
175
+
176
+ # Ensure base metadata is fetched and cached.
177
+ provider.get_dependencies(base, version)
178
+ base_cache_key = (normalized, version)
179
+ metadata = provider.metadata_cache.get(base_cache_key)
180
+ if metadata is None: # pragma: no cover
181
+ # get_dependencies(base, version) above always populates
182
+ # metadata_cache on success or raises; this is defensive.
183
+ msg = f"No metadata cached for {base}=={version}"
184
+ raise MetadataError(msg)
185
+
186
+ extra_map = provider.extra_deps_map.get(base_cache_key, {})
187
+ if extra not in extra_map:
188
+ return handle_missing_extra(provider, normalized, extra, version, cache_key)
189
+
190
+ deps = dict(extra_map[extra])
191
+ deps[normalized] = VersionRange.singleton(version)
192
+
193
+ provider.deps_cache[cache_key] = deps
194
+ provider.prefetch_new_deps(deps)
195
+ return deps
196
+
197
+
198
+ def handle_missing_extra(
199
+ provider: Provider,
200
+ normalized: str,
201
+ extra: str,
202
+ version: Version,
203
+ cache_key: tuple[str, Version],
204
+ ) -> dict[str, VersionRange]:
205
+ """Handle a request for an extra not in Provides-Extra.
206
+
207
+ In ERROR_USER and BACKTRACK modes, user-provided extras raise
208
+ immediately. Transitive missing extras always warn and return
209
+ only the base dep (BACKTRACK skips these versions in
210
+ choose_version before we get here).
211
+ """
212
+ # Late import: ``pypi`` imports this module at module load.
213
+ from ..provider import ExtrasMode, MissingExtraError
214
+
215
+ is_user = (normalized, extra) in provider.root_extras
216
+ if is_user and provider.extras_mode != ExtrasMode.WARN:
217
+ msg = f"{normalized}=={version} does not provide extra '{extra}'"
218
+ raise MissingExtraError(msg)
219
+
220
+ logger.warning(
221
+ "%s==%s does not provide extra '%s'",
222
+ normalized,
223
+ version,
224
+ extra,
225
+ )
226
+ # Return empty deps: the extra doesn't exist, so the proxy
227
+ # contributes nothing. Don't pin to the base version, as that
228
+ # creates unnecessary coupling that causes backtracking storms
229
+ # when the resolver tries many base versions.
230
+ provider.deps_cache[cache_key] = {}
231
+ return {}
@@ -0,0 +1,442 @@
1
+ """Listing fetch, filter, and prefetch coordination for the provider.
2
+
3
+ Owns ``fetch_versions`` and the speculative-metadata prefetch
4
+ chain that feeds the resolver's ``choose_version`` look-ahead with
5
+ already-cached metadata where possible.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import TYPE_CHECKING
12
+
13
+ from nab_index.client import SdistFile, WheelFile
14
+
15
+ from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
16
+ from .._vendor.packaging.version import InvalidVersion, Version
17
+ from ..metadata import intern_version as _intern_version
18
+
19
+ if TYPE_CHECKING:
20
+ import threading
21
+ from collections.abc import Mapping, Sequence
22
+
23
+ from nab_resolver.types import RangeProtocol
24
+
25
+ from .._vendor.packaging.ranges import VersionRange
26
+ from ..provider import DistFile, Provider
27
+
28
+
29
+ def fetch_versions(provider: Provider, package: str) -> list[tuple[Version, DistFile]]:
30
+ """Fetch and cache available versions for a package.
31
+
32
+ Checks the in-memory index first; if missing, requests from
33
+ the coordinator and blocks until the listing arrives. Local
34
+ sources short-circuit: a registered :class:`LocalSource`
35
+ becomes the only candidate for the package.
36
+ """
37
+ _, _, normalized = provider.split_and_normalize(package)
38
+ if normalized in provider.versions_cache:
39
+ return provider.versions_cache[normalized]
40
+
41
+ local = provider.local_sources.get(normalized)
42
+ if local is not None:
43
+ result = provider.materialize_local_source(normalized, local)
44
+ provider.versions_cache[normalized] = result
45
+ return result
46
+
47
+ vcs = provider.vcs_sources.get(normalized)
48
+ if vcs is not None:
49
+ result = provider.materialize_vcs_source(normalized, vcs)
50
+ provider.versions_cache[normalized] = result
51
+ return result
52
+
53
+ files = provider.coordinator.index.get_listing(normalized)
54
+ if files is None:
55
+ event = provider.coordinator.request_listing(normalized)
56
+ event.wait()
57
+ files = provider.coordinator.index.get_listing(normalized)
58
+ # request_listing always stores at least an empty list on completion;
59
+ # ``files`` is therefore non-None on the second read.
60
+ assert files is not None
61
+
62
+ # Routed through the method (not the module function) so subclass
63
+ # overrides like UniversalProvider's wheel-tag filter still run.
64
+ result = provider.filter_distributions(normalized, files)
65
+ provider.versions_cache[normalized] = result
66
+ provider.stats.listings_fetched += 1
67
+
68
+ if result:
69
+ speculative_prefetch(provider, normalized, result)
70
+
71
+ return result
72
+
73
+
74
+ def versions_only(
75
+ provider: Provider,
76
+ normalized: str,
77
+ version_list: list[tuple[Version, DistFile]],
78
+ ) -> list[Version]:
79
+ """Return the cached version-only view for ``normalized``."""
80
+ cached = provider.versions_only_cache.get(normalized)
81
+ if cached is None:
82
+ cached = [v for v, _ in version_list]
83
+ provider.versions_only_cache[normalized] = cached
84
+ return cached
85
+
86
+
87
+ def wheel_by_version(
88
+ provider: Provider,
89
+ normalized: str,
90
+ version_list: list[tuple[Version, DistFile]],
91
+ ) -> dict[Version, DistFile]:
92
+ """Return the cached ``Version -> DistFile`` mapping for ``normalized``."""
93
+ cached = provider.wheel_by_version_cache.get(normalized)
94
+ if cached is None:
95
+ cached = dict(version_list)
96
+ provider.wheel_by_version_cache[normalized] = cached
97
+ return cached
98
+
99
+
100
+ def speculative_prefetch(
101
+ provider: Provider,
102
+ normalized: str,
103
+ versions: list[tuple[Version, DistFile]],
104
+ ) -> None:
105
+ """Fire metadata prefetch for likely candidates.
106
+
107
+ Called from fetch_versions and prioritize when a listing
108
+ first becomes available. For constrained root requirements,
109
+ batch-prefetch the first N candidates within the root range
110
+ so choose_version's look-ahead finds them cached. For
111
+ transitive deps, just prefetch the single best candidate.
112
+ """
113
+ root_range = provider.root_requirements.get(normalized)
114
+ if root_range is not None and not (~root_range).is_empty:
115
+ prefetch_root_batch(provider, normalized, versions, root_range)
116
+ else:
117
+ prefetch_transitive_best(provider, normalized, versions)
118
+
119
+
120
+ def prefetch_root_batch(
121
+ provider: Provider,
122
+ normalized: str,
123
+ versions: list[tuple[Version, DistFile]],
124
+ root_range: RangeProtocol[Version],
125
+ ) -> None:
126
+ """Batch metadata fetch for the top candidates inside ``root_range``."""
127
+ # Imported lazily because provider.py imports this module; pulling
128
+ # Provider in at the top would create an import cycle.
129
+ from ..provider import Provider as _Provider
130
+
131
+ items: list[tuple[str, str, str]] = []
132
+ for version, dist in versions:
133
+ if len(items) >= _Provider.PREFETCH_BATCH:
134
+ break
135
+ if version not in root_range:
136
+ continue
137
+ if (normalized, version) in provider.deps_cache:
138
+ continue
139
+ if isinstance(dist, WheelFile) and dist.metadata_url is not None:
140
+ items.append((normalized, dist.version, dist.metadata_url))
141
+ if items:
142
+ provider.coordinator.request_metadata_batch(items)
143
+
144
+
145
+ def prefetch_transitive_best(
146
+ provider: Provider,
147
+ normalized: str,
148
+ versions: list[tuple[Version, DistFile]],
149
+ ) -> None:
150
+ """Fire metadata prefetch for the single best transitive candidate."""
151
+ # Routed through ``provider.pick_best_candidate`` so existing
152
+ # ``patch.object(provider, "pick_best_candidate", ...)`` mocks
153
+ # in the test suite still drive this prefetch path.
154
+ best = provider.pick_best_candidate(normalized, versions)
155
+ if best is None:
156
+ return
157
+ version, dist = best
158
+ cache_key = (normalized, version)
159
+ if (
160
+ cache_key not in provider.deps_cache
161
+ and isinstance(dist, WheelFile)
162
+ and dist.metadata_url is not None
163
+ ):
164
+ provider.coordinator.request_metadata(
165
+ normalized, dist.version, dist.metadata_url
166
+ )
167
+
168
+
169
+ def pick_best_candidate(
170
+ provider: Provider,
171
+ normalized: str,
172
+ versions: list[tuple[Version, DistFile]],
173
+ ) -> tuple[Version, DistFile] | None:
174
+ """Pick the version the resolver will most likely try first."""
175
+ if not versions:
176
+ return None
177
+ if normalized in provider.root_requirements:
178
+ version_range = provider.root_requirements[normalized]
179
+ for version, dist in versions:
180
+ if version in version_range:
181
+ return (version, dist)
182
+ return None
183
+ return versions[0]
184
+
185
+
186
+ def filter_distributions(
187
+ provider: Provider,
188
+ normalized: str,
189
+ files: Sequence[WheelFile | SdistFile],
190
+ ) -> list[tuple[Version, DistFile]]:
191
+ """Filter by requires-python, upload time, and sort.
192
+
193
+ Sorting: newest version first. When the effective ``dist_policy``
194
+ is PREFER_BINARY or SDIST_INSTALL, wheels sort before sdists at
195
+ the same version so the metadata picker hits the cheapest source
196
+ first. ``normalized`` is the canonical package name used to look
197
+ up the per-package ``uploaded-prior-to`` and ``dist-policy``
198
+ overrides.
199
+
200
+ :attr:`~nab_python.provider.DistPolicy.SDIST_INSTALL` is *not*
201
+ filtered here: both wheels and sdists stay in ``versions_cache``
202
+ so the resolver can read whichever source is cheapest at the
203
+ chosen version. The wheels are dropped later, at lock
204
+ construction time, so only the sdist ends up pinned.
205
+ """
206
+ # Late import: ``pypi`` imports this module at module load.
207
+ from ..provider import DistPolicy
208
+
209
+ effective_dist_policy = provider.effective_dist_policy(normalized)
210
+
211
+ # Fast path: skip the time-filter dispatch entirely when no cutoff applies.
212
+ time_filter_active = provider.uploaded_prior_to is not None or bool(
213
+ provider.uploaded_prior_to_overrides
214
+ )
215
+
216
+ result: list[tuple[Version, DistFile]] = []
217
+ for dist in files:
218
+ provider.stats.distributions_seen += 1
219
+ if isinstance(dist, WheelFile):
220
+ provider.stats.wheels_seen += 1
221
+ else:
222
+ provider.stats.sdists_seen += 1
223
+
224
+ if effective_dist_policy == DistPolicy.NO_SDIST and not isinstance(
225
+ dist, WheelFile
226
+ ):
227
+ provider.stats.excluded_by_dist_policy += 1
228
+ continue
229
+ if effective_dist_policy == DistPolicy.SDIST_ONLY and isinstance(
230
+ dist, WheelFile
231
+ ):
232
+ provider.stats.excluded_by_dist_policy += 1
233
+ continue
234
+
235
+ # Cheap string-only filters first so the Version regex parse only
236
+ # runs on dists we might keep.
237
+ if excluded_by_python(provider, dist):
238
+ continue
239
+ if time_filter_active and excluded_by_time(provider, normalized, dist):
240
+ continue
241
+
242
+ try:
243
+ version = _intern_version(dist.version)
244
+ except InvalidVersion:
245
+ continue
246
+
247
+ result.append((version, dist))
248
+
249
+ if effective_dist_policy in (DistPolicy.PREFER_BINARY, DistPolicy.SDIST_INSTALL):
250
+ result.sort(
251
+ key=lambda pair: (pair[0], isinstance(pair[1], WheelFile)),
252
+ reverse=True,
253
+ )
254
+ else:
255
+ result.sort(key=lambda pair: pair[0], reverse=True)
256
+ return result
257
+
258
+
259
+ def excluded_by_python(provider: Provider, dist: DistFile) -> bool:
260
+ """Return True when ``dist``'s Requires-Python excludes the target Python."""
261
+ requires_python = dist.requires_python
262
+ if not requires_python or not provider.python_version:
263
+ return False
264
+ cached = provider.requires_python_cache.get(requires_python)
265
+ if cached is None:
266
+ try:
267
+ spec = SpecifierSet(requires_python)
268
+ cached = Version(provider.python_version) not in spec
269
+ except (InvalidSpecifier, InvalidVersion):
270
+ # Malformed Requires-Python on the dist or our own
271
+ # python_version: treat as not-excluded, let downstream
272
+ # logic decide.
273
+ cached = False
274
+ provider.requires_python_cache[requires_python] = cached
275
+ if cached:
276
+ provider.stats.excluded_by_python += 1
277
+ return cached
278
+
279
+
280
+ def excluded_by_time(provider: Provider, normalized: str, dist: DistFile) -> bool:
281
+ """Return True when ``dist`` was uploaded after the effective cutoff.
282
+
283
+ The effective cutoff is the per-package override at ``normalized``
284
+ if one is set, otherwise the global ``uploaded_prior_to``. An
285
+ override of ``None`` (declared as ``false`` in
286
+ ``[tool.nab.uploaded-prior-to-package]``) disables the cooldown
287
+ for the package even when a global cutoff is set.
288
+ """
289
+ if normalized in provider.uploaded_prior_to_overrides:
290
+ cutoff = provider.uploaded_prior_to_overrides[normalized]
291
+ else:
292
+ cutoff = provider.uploaded_prior_to
293
+ if cutoff is None:
294
+ return False
295
+ if dist.upload_time is None:
296
+ provider.stats.excluded_by_time += 1
297
+ return True
298
+ try:
299
+ upload_dt = datetime.fromisoformat(dist.upload_time.replace("Z", "+00:00"))
300
+ except ValueError:
301
+ provider.stats.excluded_by_time += 1
302
+ return True
303
+ excluded = upload_dt >= cutoff
304
+ if excluded:
305
+ provider.stats.excluded_by_time += 1
306
+ return excluded
307
+
308
+
309
+ def _first_wheel_per_version(
310
+ versions_list: list[tuple[Version, DistFile]],
311
+ ) -> dict[Version, WheelFile]:
312
+ """First wheel-with-PEP-658-metadata per unique version, in list order."""
313
+ out: dict[Version, WheelFile] = {}
314
+ for version, dist in versions_list:
315
+ if isinstance(dist, WheelFile) and dist.metadata_url is not None:
316
+ out.setdefault(version, dist)
317
+ return out
318
+
319
+
320
+ def prefetch_walk_ahead(
321
+ provider: Provider,
322
+ normalized: str,
323
+ deep_count: int,
324
+ ) -> None:
325
+ """Submit metadata for the next ``deep_count`` wheels of ``normalized``.
326
+
327
+ Called when the scan is about to walk past its ``PREFETCH_BATCH``
328
+ window. Front-loading the rest of the walk lets ``_try_abort_skip``
329
+ and any restart hit cache instead of one RTT per visit.
330
+
331
+ Walks ``versions_cache[normalized]`` directly so same-version
332
+ (wheel, sdist) pairs do not lose the wheel the way they would
333
+ via ``wheel_by_version_cache``. Skips already-cached versions,
334
+ sdist-only versions, and versions whose metadata the coordinator
335
+ already holds. Fire-and-forget.
336
+ """
337
+ versions_list = provider.versions_cache.get(normalized)
338
+ if not versions_list:
339
+ return
340
+ wheel_for_v = _first_wheel_per_version(versions_list)
341
+ coordinator_index = provider.coordinator.index
342
+ items: list[tuple[str, str, str]] = []
343
+ seen_versions: set[Version] = set()
344
+ for version, _ in versions_list:
345
+ if version in seen_versions:
346
+ continue
347
+ seen_versions.add(version)
348
+ if len(seen_versions) > deep_count:
349
+ break
350
+ wheel = wheel_for_v.get(version)
351
+ if wheel is None or (normalized, version) in provider.deps_cache:
352
+ continue
353
+ if coordinator_index.has_metadata(normalized, wheel.version):
354
+ continue
355
+ # ``_first_wheel_per_version`` filters out wheels without metadata_url.
356
+ metadata_url = wheel.metadata_url
357
+ assert metadata_url is not None
358
+ items.append((normalized, wheel.version, metadata_url))
359
+ if items:
360
+ provider.coordinator.request_metadata_batch(items)
361
+
362
+
363
+ def prefetch_batch(
364
+ provider: Provider,
365
+ package: str,
366
+ versions: list[Version],
367
+ wheel_by_version_map: dict[Version, DistFile],
368
+ ) -> list[tuple[Version, str, threading.Event]]:
369
+ """Submit metadata fetches for a batch of candidates.
370
+
371
+ Uses request_metadata_batch so all requests reach the fetcher
372
+ as a single queue item and are processed concurrently.
373
+ Returns list of (version, ver_str, event) for submitted requests.
374
+ """
375
+ items: list[tuple[str, str, str]] = []
376
+ version_map: list[tuple[Version, str]] = []
377
+ for v in versions:
378
+ if (package, v) in provider.deps_cache or v not in wheel_by_version_map:
379
+ continue
380
+ wheel = wheel_by_version_map[v]
381
+ if isinstance(wheel, WheelFile) and wheel.metadata_url is not None:
382
+ items.append((package, wheel.version, wheel.metadata_url))
383
+ version_map.append((v, wheel.version))
384
+
385
+ if not items:
386
+ return []
387
+
388
+ raw = provider.coordinator.request_metadata_batch(items)
389
+ submitted = []
390
+ for (_pkg, _ver, ev), (version, ver_str) in zip(raw, version_map, strict=True):
391
+ submitted.append((version, ver_str, ev))
392
+ return submitted
393
+
394
+
395
+ def await_metadata_batch(
396
+ provider: Provider,
397
+ package: str,
398
+ submitted: list[tuple[Version, str, threading.Event]],
399
+ ) -> None:
400
+ """Wait for all submitted metadata to arrive, then parse into cache."""
401
+ for version, ver_str, event in submitted:
402
+ cache_key = (package, version)
403
+ if cache_key in provider.deps_cache:
404
+ continue
405
+ event.wait()
406
+ text = provider.coordinator.index.get_metadata(package, ver_str)
407
+ if text is None:
408
+ provider.deps_cache[cache_key] = {}
409
+ else:
410
+ try:
411
+ provider.parse_and_cache_metadata(cache_key, text)
412
+ except (ValueError, InvalidVersion, InvalidSpecifier):
413
+ # Malformed metadata: cache empty deps so the candidate
414
+ # acts as if it had no deps rather than bubbling.
415
+ provider.deps_cache[cache_key] = {}
416
+
417
+
418
+ def prefetch_new_deps(provider: Provider, deps: Mapping[str, VersionRange]) -> None:
419
+ """Submit listing and metadata fetches for newly discovered deps.
420
+
421
+ For deps whose listings have already arrived (e.g., from a
422
+ prior prefetch), also fire metadata prefetch for their best
423
+ candidate. This deepens the prefetch cascade so metadata is
424
+ ready before the resolver asks for it.
425
+
426
+ Local and VCS sources are skipped; they have no PyPI listing
427
+ and the materialise path in ``fetch_versions`` will surface
428
+ them when the resolver asks.
429
+ """
430
+ for dep in deps:
431
+ _, _, normalized = provider.split_and_normalize(dep)
432
+ if normalized in provider.local_sources or normalized in provider.vcs_sources:
433
+ continue
434
+ if normalized not in provider.versions_cache:
435
+ # Listing not cached: request it. When it arrives,
436
+ # prioritize() will notice and fire metadata prefetch.
437
+ provider.coordinator.request_listing(normalized)
438
+ else:
439
+ # Listing cached: fire speculative metadata prefetch.
440
+ speculative_prefetch(
441
+ provider, normalized, provider.versions_cache[normalized]
442
+ )