nab-python 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nab_python/__init__.py +1 -0
- nab_python/_build/__init__.py +1 -0
- nab_python/_build/env.py +364 -0
- nab_python/_build/errors.py +17 -0
- nab_python/_build/runner.py +254 -0
- nab_python/_lockfile/__init__.py +1 -0
- nab_python/_lockfile/builder.py +339 -0
- nab_python/_lockfile/disjointness.py +207 -0
- nab_python/_lockfile/pylock.py +323 -0
- nab_python/_lockfile/requirements.py +121 -0
- nab_python/_packaging_provider.py +98 -0
- nab_python/_provider/__init__.py +1 -0
- nab_python/_provider/build_remote.py +95 -0
- nab_python/_provider/extras.py +231 -0
- nab_python/_provider/listing.py +442 -0
- nab_python/_provider/lookahead.py +156 -0
- nab_python/_provider/metadata_resolver.py +450 -0
- nab_python/_provider/priority.py +174 -0
- nab_python/_provider/sources.py +215 -0
- nab_python/_testing/__init__.py +1 -0
- nab_python/_testing/coordinator_fake.py +240 -0
- nab_python/_vcs_admission.py +209 -0
- nab_python/_vendor/__init__.py +6 -0
- nab_python/_vendor/packaging/LICENSE +3 -0
- nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
- nab_python/_vendor/packaging/LICENSE.BSD +23 -0
- nab_python/_vendor/packaging/PROVENANCE.md +73 -0
- nab_python/_vendor/packaging/__init__.py +15 -0
- nab_python/_vendor/packaging/_elffile.py +108 -0
- nab_python/_vendor/packaging/_manylinux.py +265 -0
- nab_python/_vendor/packaging/_musllinux.py +88 -0
- nab_python/_vendor/packaging/_parser.py +394 -0
- nab_python/_vendor/packaging/_structures.py +33 -0
- nab_python/_vendor/packaging/_tokenizer.py +196 -0
- nab_python/_vendor/packaging/dependency_groups.py +302 -0
- nab_python/_vendor/packaging/direct_url.py +325 -0
- nab_python/_vendor/packaging/errors.py +94 -0
- nab_python/_vendor/packaging/licenses/__init__.py +186 -0
- nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
- nab_python/_vendor/packaging/markers.py +506 -0
- nab_python/_vendor/packaging/metadata.py +964 -0
- nab_python/_vendor/packaging/py.typed +0 -0
- nab_python/_vendor/packaging/pylock.py +910 -0
- nab_python/_vendor/packaging/ranges.py +1803 -0
- nab_python/_vendor/packaging/requirements.py +132 -0
- nab_python/_vendor/packaging/specifiers.py +1141 -0
- nab_python/_vendor/packaging/tags.py +929 -0
- nab_python/_vendor/packaging/utils.py +296 -0
- nab_python/_vendor/packaging/version.py +1230 -0
- nab_python/build_backend.py +184 -0
- nab_python/config.py +805 -0
- nab_python/download.py +170 -0
- nab_python/fetch.py +827 -0
- nab_python/lockfile.py +238 -0
- nab_python/metadata.py +145 -0
- nab_python/provider.py +1235 -0
- nab_python/py.typed +0 -0
- nab_python/requirements_file.py +180 -0
- nab_python/resolve.py +497 -0
- nab_python/universal/__init__.py +1 -0
- nab_python/universal/matrix.py +235 -0
- nab_python/universal/provider.py +214 -0
- nab_python/universal/reresolve.py +310 -0
- nab_python/universal/resolve.py +508 -0
- nab_python/universal/validate.py +439 -0
- nab_python/universal/wheel_selection.py +327 -0
- nab_python/workspace.py +214 -0
- nab_python-0.0.1.dist-info/METADATA +49 -0
- nab_python-0.0.1.dist-info/RECORD +71 -0
- nab_python-0.0.1.dist-info/WHEEL +4 -0
- nab_python-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
)
|