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,156 @@
1
+ """Decision-aware look-ahead for :class:`nab_python.provider.Provider`.
2
+
3
+ Owns ``_look_ahead_ok`` and the pending-block tables that record
4
+ "this candidate is incompatible with this decision/positive range"
5
+ rejections. Each rejection becomes a grouped binary
6
+ incompatibility (``{candidate range, decision==w}``) when
7
+ ``flush_pending_blocks`` runs at the end of ``choose_version``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import defaultdict
13
+ from typing import TYPE_CHECKING
14
+
15
+ from nab_resolver.types import Incompatibility, IncompatibilityCause, Term
16
+
17
+ from .._vendor.packaging.ranges import VersionRange
18
+ from .._vendor.packaging.utils import canonicalize_name
19
+
20
+ if TYPE_CHECKING:
21
+ from .._vendor.packaging.version import Version
22
+ from ..provider import Provider
23
+
24
+
25
+ def look_ahead_ok(
26
+ provider: Provider,
27
+ package: str,
28
+ version: Version,
29
+ *,
30
+ check_decisions: bool = True,
31
+ ) -> bool:
32
+ """Check candidate compatibility with root reqs and decisions.
33
+
34
+ With ``check_decisions=False`` only the root-requirement check runs;
35
+ used for "subsequent candidate" iterations to avoid per-candidate clause
36
+ growth on tight version-locks. Extras proxies are skipped (the base's
37
+ look-ahead is sufficient).
38
+
39
+ ``MetadataError`` (including ``UnsupportedSdistError``) is treated as a
40
+ rejection so the resolver moves on; the message is recorded for the
41
+ eventual no-versions diagnostic.
42
+ """
43
+ # Late import: pypi imports this module at module load.
44
+ from ..provider import MetadataError
45
+
46
+ if provider.split_and_normalize(package)[1] is not None:
47
+ return True
48
+
49
+ cache_key = (package, version)
50
+ if cache_key not in provider.deps_cache:
51
+ try:
52
+ provider.get_dependencies(package, version)
53
+ except MetadataError as exc:
54
+ provider.pending_metadata_blocks[canonicalize_name(package)].append(
55
+ (version, str(exc))
56
+ )
57
+ return False
58
+
59
+ deps = provider.deps_cache.get(cache_key, {})
60
+ decisions = provider.solution_decisions if check_decisions else None
61
+
62
+ for dep_name, dep_range in deps.items():
63
+ dep_normalized = canonicalize_name(dep_name)
64
+
65
+ # Root-requirement disagreement: diagnostic-only (the resolver
66
+ # already has the clause via its root_requirements input).
67
+ if dep_normalized in provider.root_requirements:
68
+ root_range = provider.root_requirements[dep_normalized]
69
+ if (dep_range & root_range).is_empty:
70
+ provider.pending_root_blocks[
71
+ (package, dep_normalized, dep_range, root_range)
72
+ ].append(version)
73
+ return False
74
+
75
+ if decisions is not None:
76
+ decided_version = decisions.get(dep_normalized)
77
+ if decided_version is not None and decided_version not in dep_range:
78
+ provider.pending_blocks[
79
+ (package, dep_normalized, decided_version)
80
+ ].append(version)
81
+ return False
82
+
83
+ # Positive-range disagreement: {candidate==v, dep in pos_range}
84
+ # is impossible. Sound across backjumps because the
85
+ # ``dep in pos_range`` term goes UNDETERMINED if the supporting
86
+ # derivation is reverted.
87
+ pos_range = provider.solution_ranges.get(dep_normalized)
88
+ if (
89
+ pos_range is not None
90
+ and decided_version is None
91
+ and (dep_range & pos_range).is_empty
92
+ ):
93
+ provider.pending_range_blocks[
94
+ (package, dep_normalized, pos_range)
95
+ ].append(version)
96
+ return False
97
+
98
+ return True
99
+
100
+
101
+ def flush_pending_blocks(provider: Provider) -> None:
102
+ """Convert queued rejections into grouped binary incompatibilities.
103
+
104
+ For each ``(candidate_pkg, blocker_pkg, blocker_version)`` group we add
105
+ ``{candidate_pkg in {v1,v2,...}, blocker_pkg==w}``. Sound across
106
+ backjumps because the blocker term goes UNDETERMINED when the supporting
107
+ decision is reverted, so the candidate range can be reconsidered.
108
+ """
109
+ # Decision-keyed rejections: pin the blocker to one exact version.
110
+ for (
111
+ candidate_pkg,
112
+ blocker_pkg,
113
+ blocker_version,
114
+ ), versions in provider.pending_blocks.items():
115
+ range_union = VersionRange.empty()
116
+ for v in versions:
117
+ range_union = range_union | VersionRange.singleton(v)
118
+ provider.pending_clauses.append(
119
+ Incompatibility(
120
+ [
121
+ Term(candidate_pkg, range_union, positive=True),
122
+ Term(
123
+ blocker_pkg,
124
+ VersionRange.singleton(blocker_version),
125
+ positive=True,
126
+ ),
127
+ ],
128
+ cause=IncompatibilityCause.DEPENDENCY,
129
+ )
130
+ )
131
+ provider.pending_blocks = defaultdict(list)
132
+
133
+ # Range-keyed rejections: the blocker term uses the positive range directly.
134
+ for (
135
+ candidate_pkg,
136
+ blocker_pkg,
137
+ blocker_range,
138
+ ), versions in provider.pending_range_blocks.items():
139
+ range_union = VersionRange.empty()
140
+ for v in versions:
141
+ range_union = range_union | VersionRange.singleton(v)
142
+ provider.pending_clauses.append(
143
+ Incompatibility(
144
+ [
145
+ Term(candidate_pkg, range_union, positive=True),
146
+ Term(blocker_pkg, blocker_range, positive=True),
147
+ ],
148
+ cause=IncompatibilityCause.DEPENDENCY,
149
+ )
150
+ )
151
+ provider.pending_range_blocks = defaultdict(list)
152
+
153
+ # Root- and metadata-blocks are diagnostic-only; drop them without
154
+ # emitting clauses.
155
+ provider.pending_root_blocks = defaultdict(list)
156
+ provider.pending_metadata_blocks = defaultdict(list)
@@ -0,0 +1,450 @@
1
+ """Metadata fetching, parsing, and dep classification for the provider.
2
+
3
+ Owns the bulk of ``get_dependencies``'s implementation: fetching
4
+ wheel METADATA / sdist PKG-INFO via the coordinator, parsing it
5
+ into a :class:`~nab_python.metadata.WheelMetadata`, and classifying
6
+ each ``Requires-Dist`` entry into base deps vs per-extra deps.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from nab_index.client import SdistFile, WheelFile
14
+
15
+ from .._vendor.packaging.ranges import VersionRange
16
+ from .._vendor.packaging.requirements import InvalidRequirement, Requirement
17
+ from .._vendor.packaging.utils import canonicalize_name
18
+ from ..metadata import DEPENDENCY_FIELDS, parse_metadata
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+ from .._vendor.packaging.markers import Marker
24
+ from .._vendor.packaging.version import Version
25
+ from ..metadata import WheelMetadata
26
+ from ..provider import DistFile, Provider
27
+
28
+
29
+ def resolve_metadata(
30
+ provider: Provider,
31
+ versions: list[tuple[Version, DistFile]],
32
+ package: str,
33
+ version: Version,
34
+ ) -> tuple[str, bool]:
35
+ """Get metadata text and whether it came from an sdist.
36
+
37
+ Returns ``(metadata_text, from_sdist)``. ``from_sdist`` is
38
+ ``True`` when the text was extracted from a source-distribution
39
+ ``PKG-INFO`` rather than a wheel ``METADATA``: needed because
40
+ only sdist values are subject to the :pep:`643` Dynamic
41
+ guarantees and may need a ``pyproject.toml`` fallback.
42
+ """
43
+ # Late import: ``pypi`` imports this module at module load.
44
+ from ..provider import MetadataError
45
+
46
+ _, _, normalized = provider.split_and_normalize(package)
47
+ ver_str = str(version)
48
+
49
+ text = provider.coordinator.index.get_metadata(normalized, ver_str)
50
+ if text is not None:
51
+ # Decide source from listing: a wheel-with-metadata-url at this
52
+ # version means the text was wheel METADATA; otherwise sdist
53
+ # PKG-INFO. ``_metadata`` and ``_sdist`` write to the same
54
+ # slot, so we can't tell from the index alone.
55
+ from_sdist = not has_wheel_metadata_at(versions, version)
56
+ return (text, from_sdist)
57
+
58
+ dist = pick_dist_for_metadata(versions, version)
59
+ if dist is None:
60
+ msg = f"Version {version} of {package} not found in listing"
61
+ raise MetadataError(msg)
62
+
63
+ if isinstance(dist, WheelFile) and dist.metadata_url is not None:
64
+ event = provider.coordinator.request_metadata(
65
+ normalized, ver_str, dist.metadata_url
66
+ )
67
+ event.wait()
68
+ metadata_text = provider.coordinator.index.get_metadata(normalized, ver_str)
69
+ else:
70
+ metadata_text = None
71
+
72
+ if metadata_text is not None:
73
+ return (metadata_text, False)
74
+
75
+ sdist = find_sdist(versions, version)
76
+ if sdist is not None:
77
+ metadata_text = fetch_sdist_metadata(provider, normalized, ver_str, sdist)
78
+ if metadata_text is not None:
79
+ return (metadata_text, True)
80
+
81
+ msg = (
82
+ f"No metadata for {package}=={version}: "
83
+ f"no PEP 658 metadata and no sdist available"
84
+ )
85
+ raise MetadataError(msg)
86
+
87
+
88
+ def has_wheel_metadata_at(
89
+ versions: Sequence[tuple[Version, DistFile]], version: Version
90
+ ) -> bool:
91
+ """Report whether the listing has a wheel with PEP 658 metadata."""
92
+ for v, d in versions:
93
+ if v != version:
94
+ continue
95
+ if isinstance(d, WheelFile) and d.metadata_url is not None:
96
+ return True
97
+ return False
98
+
99
+
100
+ def pick_dist_for_metadata(
101
+ versions: Sequence[tuple[Version, DistFile]], version: Version
102
+ ) -> DistFile | None:
103
+ """Pick the cheapest dist source for ``version``'s metadata.
104
+
105
+ Preference order at the same version:
106
+
107
+ 1. A wheel with a PEP 658 ``metadata_url`` (smallest fetch).
108
+ 2. Any wheel (range-fetch / stream still beats an sdist build).
109
+ 3. The sdist (PKG-INFO; may require a build if Dynamic).
110
+
111
+ The picker is policy-agnostic and applies whatever ``versions``
112
+ holds. :attr:`~nab_python.provider.DistPolicy.SDIST_INSTALL` works
113
+ by keeping both kinds of dists in the listing so this preference
114
+ order naturally chooses the wheel when one exists, falling back
115
+ to the sdist when only the sdist is published.
116
+ """
117
+ wheel_with_meta: DistFile | None = None
118
+ wheel_without_meta: DistFile | None = None
119
+ sdist: DistFile | None = None
120
+ for v, d in versions:
121
+ if v != version:
122
+ continue
123
+ if isinstance(d, WheelFile):
124
+ if d.metadata_url is not None:
125
+ if wheel_with_meta is None:
126
+ wheel_with_meta = d
127
+ elif wheel_without_meta is None:
128
+ wheel_without_meta = d
129
+ elif sdist is None:
130
+ sdist = d
131
+ return wheel_with_meta or wheel_without_meta or sdist
132
+
133
+
134
+ def is_dynamic_deps(metadata: WheelMetadata) -> bool:
135
+ """Return True when dependency fields are :pep:`643` Dynamic."""
136
+ return bool(DEPENDENCY_FIELDS & metadata.dynamic)
137
+
138
+
139
+ def resolve_dynamic_sdist(
140
+ provider: Provider,
141
+ cache_key: tuple[str, Version],
142
+ metadata: WheelMetadata,
143
+ ) -> WheelMetadata:
144
+ """Reconcile a dynamic-deps sdist.
145
+
146
+ First the bundled ``pyproject.toml`` is consulted; when its
147
+ ``[project]`` table statically declares ``dependencies`` and
148
+ ``optional-dependencies``, those replace the dynamic PKG-INFO
149
+ values. When that fallback yields nothing and the effective
150
+ :class:`~nab_python.provider.BuildPolicy` is
151
+ :attr:`~nab_python.provider.BuildPolicy.BUILD_REMOTE`, the sdist is
152
+ fetched, extracted, and handed to a PEP 517 backend by
153
+ :func:`nab_python._provider.build_remote.build_remote_sdist`. Any
154
+ other effective policy raises
155
+ :class:`~nab_python.provider.UnsupportedSdistError`; the resolver
156
+ skips the version via
157
+ :func:`nab_python._provider.lookahead.look_ahead_ok` and surfaces the
158
+ accumulated reasons if no candidate ultimately works.
159
+ """
160
+ # Late import: ``pypi`` imports this module at module load.
161
+ from ..provider import BuildPolicy, UnsupportedSdistError
162
+ from .build_remote import build_remote_sdist
163
+
164
+ package, version = cache_key
165
+ canonical = canonicalize_name(package)
166
+ version_str = str(version)
167
+ index = provider.coordinator.index
168
+
169
+ cached = index.get_resolved_sdist_metadata(canonical, version_str)
170
+ if cached is not None:
171
+ return cached
172
+
173
+ augmented = augment_from_pyproject(provider, package, version, metadata)
174
+ if augmented is not None:
175
+ index.store_resolved_sdist_metadata(canonical, version_str, augmented)
176
+ return augmented
177
+ effective = provider.effective_build_policy(canonical)
178
+ if effective is BuildPolicy.BUILD_REMOTE:
179
+ built = build_remote_sdist(provider, package, version)
180
+ index.store_resolved_sdist_metadata(canonical, version_str, built)
181
+ return built
182
+ provider.stats.excluded_by_build_policy += 1
183
+ msg = (
184
+ f"{package}=={version} sdist has dynamic dependencies and no static"
185
+ f" pyproject.toml fallback; building requires BuildPolicy.BUILD_REMOTE"
186
+ f" but the effective policy is {effective.value}"
187
+ )
188
+ raise UnsupportedSdistError(msg)
189
+
190
+
191
+ def augment_from_pyproject(
192
+ provider: Provider,
193
+ package: str,
194
+ version: Version,
195
+ metadata: WheelMetadata,
196
+ ) -> WheelMetadata | None:
197
+ """Replace dynamic deps with statically-declared pyproject deps.
198
+
199
+ Returns the augmented metadata, or ``None`` if pyproject.toml
200
+ is missing, malformed, or itself marks deps dynamic via
201
+ ``[project].dynamic``.
202
+ """
203
+ # Late import keeps the resolver-time path off ``WheelMetadata``
204
+ # construction unless the dynamic-deps pyproject fallback fires.
205
+ from ..metadata import WheelMetadata as _WheelMetadata
206
+ from ..metadata import load_static_project
207
+
208
+ text = provider.coordinator.index.get_sdist_pyproject(package, str(version))
209
+ project = load_static_project(text) if text is not None else None
210
+ if project is None:
211
+ return None
212
+
213
+ deps_field = project.get("dependencies")
214
+ if deps_field is not None and not isinstance(deps_field, list):
215
+ return None
216
+ optional = project.get("optional-dependencies")
217
+ if optional is not None and not isinstance(optional, dict):
218
+ return None
219
+
220
+ requires_dist = list(parse_pyproject_deps(deps_field or []))
221
+ provides_extra = extend_with_extras(requires_dist, optional or {})
222
+
223
+ provider.stats.sdist_pyproject_fallbacks += 1
224
+ return _WheelMetadata(
225
+ name=metadata.name,
226
+ version=metadata.version,
227
+ requires_python=metadata.requires_python,
228
+ requires_dist=requires_dist,
229
+ provides_extra=provides_extra,
230
+ metadata_version=metadata.metadata_version,
231
+ dynamic=metadata.dynamic,
232
+ )
233
+
234
+
235
+ def extend_with_extras(requires_dist: list[Requirement], optional: dict) -> list[str]:
236
+ """Append extras-gated requirements and return Provides-Extra names."""
237
+ # Late import: ``pypi`` imports this module at module load.
238
+ from ..provider import _add_extra_marker
239
+
240
+ provides_extra: list[str] = []
241
+ for extra_name, extra_deps in optional.items():
242
+ if not isinstance(extra_deps, list):
243
+ continue
244
+ provides_extra.append(extra_name)
245
+ for dep_str in extra_deps:
246
+ if not isinstance(dep_str, str):
247
+ continue
248
+ with_marker = _add_extra_marker(dep_str, extra_name)
249
+ try:
250
+ requires_dist.append(Requirement(with_marker))
251
+ except InvalidRequirement:
252
+ continue
253
+ return provides_extra
254
+
255
+
256
+ def parse_pyproject_deps(deps: list) -> list[Requirement]:
257
+ """Parse a ``project.dependencies`` list, dropping malformed entries."""
258
+ out: list[Requirement] = []
259
+ for dep_str in deps:
260
+ if not isinstance(dep_str, str):
261
+ continue
262
+ try:
263
+ out.append(Requirement(dep_str))
264
+ except InvalidRequirement:
265
+ continue
266
+ return out
267
+
268
+
269
+ def find_sdist(
270
+ versions: list[tuple[Version, DistFile]],
271
+ version: Version,
272
+ ) -> SdistFile | None:
273
+ """Find an sdist for a specific version, or None."""
274
+ for v, d in versions:
275
+ if v == version and isinstance(d, SdistFile):
276
+ return d
277
+ return None
278
+
279
+
280
+ def fetch_sdist_metadata(
281
+ provider: Provider, package: str, version: str, sdist: SdistFile
282
+ ) -> str | None:
283
+ """Block until the coordinator returns sdist PKG-INFO text."""
284
+ event = provider.coordinator.request_sdist(package, version, sdist.url)
285
+ event.wait()
286
+ provider.stats.sdist_pkg_info_fetched += 1
287
+ return provider.coordinator.index.get_metadata(package, version)
288
+
289
+
290
+ def classify_requirement(
291
+ provider: Provider,
292
+ req: Requirement,
293
+ provided_extras: set[str],
294
+ ) -> set[str] | None:
295
+ """Classify a requirement by which extras it belongs to.
296
+
297
+ Returns None if the marker doesn't match the environment.
298
+ Returns an empty set if the requirement is a base dep (no extra gating).
299
+ Returns a set of normalized extra names if extra-gated.
300
+ """
301
+ marker = req.marker
302
+ if marker is None:
303
+ return set()
304
+ marker_id = id(marker)
305
+ if marker_matches_base(provider, marker, marker_id):
306
+ return set()
307
+ if "extra" not in marker_text(provider, marker, marker_id):
308
+ return None
309
+ matched_extras = marker_matched_extras(provider, marker, marker_id, provided_extras)
310
+ return matched_extras or None
311
+
312
+
313
+ def marker_matches_base(provider: Provider, marker: Marker, marker_id: int) -> bool:
314
+ """Evaluate ``marker`` against the env without ``extra`` set, cached."""
315
+ result = provider.marker_base_cache.get(marker_id)
316
+ if result is None:
317
+ result = marker.evaluate(provider.environment)
318
+ provider.marker_base_cache[marker_id] = result
319
+ return result
320
+
321
+
322
+ def marker_text(provider: Provider, marker: Marker, marker_id: int) -> str:
323
+ """Return ``str(marker)``, cached. Walks the AST on big graphs."""
324
+ text = provider.marker_text_cache.get(marker_id)
325
+ if text is None:
326
+ text = str(marker)
327
+ provider.marker_text_cache[marker_id] = text
328
+ return text
329
+
330
+
331
+ def marker_matched_extras(
332
+ provider: Provider,
333
+ marker: Marker,
334
+ marker_id: int,
335
+ provided_extras: set[str],
336
+ ) -> set[str]:
337
+ """Return the extras for which the marker evaluates to True."""
338
+ per_marker = provider.marker_extra_cache.get(marker_id)
339
+ if per_marker is None:
340
+ per_marker = provider.marker_extra_cache[marker_id] = {}
341
+ env = provider.env_with_extra
342
+ matched: set[str] = set()
343
+ for extra_name in provided_extras:
344
+ result = per_marker.get(extra_name)
345
+ if result is None:
346
+ env["extra"] = extra_name
347
+ result = marker.evaluate(env)
348
+ per_marker[extra_name] = result
349
+ if result:
350
+ matched.add(extra_name)
351
+ return matched
352
+
353
+
354
+ def parse_and_cache_metadata(
355
+ provider: Provider,
356
+ cache_key: tuple[str, Version],
357
+ metadata_text: str,
358
+ *,
359
+ from_sdist: bool = False,
360
+ ) -> None:
361
+ """Parse metadata text and pre-compute per-extra deps.
362
+
363
+ Evaluates markers once for all extras, then caches the base
364
+ deps and a per-extra mapping so that get_extra_dependencies
365
+ can do a dict lookup instead of re-iterating requires_dist.
366
+
367
+ When ``from_sdist`` is set and PKG-INFO marks dependency-related
368
+ fields as :pep:`643` Dynamic, attempts the ``pyproject.toml``
369
+ fallback before raising :class:`UnsupportedSdistError` under
370
+ :class:`BuildPolicy.NEVER`.
371
+
372
+ The parsed :class:`WheelMetadata` is shared via the
373
+ :class:`~nab_python.fetch.InMemoryIndex` so that universal-mode
374
+ resolves only run :func:`parse_metadata` once per
375
+ ``(package, version)`` regardless of how many tuples ask for it.
376
+ Per-tuple classification (marker evaluation, extras admission)
377
+ still runs locally in :func:`cache_deps_from_metadata`. The
378
+ sdist-dynamic-deps reconciliation in
379
+ :func:`resolve_dynamic_sdist` returns a *new* dataclass and is
380
+ therefore cached only by the (per-provider) ``metadata_cache``;
381
+ the coordinator-level entry stays the raw parse so subsequent
382
+ tuples can re-apply their own dynamic-resolution rules without
383
+ inheriting this tuple's pyproject fallback.
384
+ """
385
+ package, version = cache_key
386
+ version_str = str(version)
387
+ metadata = provider.coordinator.index.get_parsed_metadata(package, version_str)
388
+ if metadata is None:
389
+ metadata = parse_metadata(metadata_text)
390
+ provider.coordinator.index.store_parsed_metadata(package, version_str, metadata)
391
+ if from_sdist and is_dynamic_deps(metadata):
392
+ metadata = resolve_dynamic_sdist(provider, cache_key, metadata)
393
+ cache_deps_from_metadata(provider, cache_key, metadata)
394
+
395
+
396
+ def cache_deps_from_metadata(
397
+ provider: Provider,
398
+ cache_key: tuple[str, Version],
399
+ metadata: WheelMetadata,
400
+ ) -> None:
401
+ """Populate ``deps_cache`` + ``extra_deps_map`` from a parsed metadata.
402
+
403
+ Shared by the wheel/sdist path (which calls
404
+ :func:`parse_and_cache_metadata` after parsing METADATA text)
405
+ and the local-source path (which already has a
406
+ :class:`WheelMetadata` from
407
+ :func:`nab_python.build_backend.extract_static_metadata`).
408
+ """
409
+ # Late import: ``pypi`` imports this module at module load.
410
+ from ..provider import _normalize_extra
411
+
412
+ provider.metadata_cache[cache_key] = metadata
413
+ provided_extras = {_normalize_extra(e) for e in metadata.provides_extra}
414
+ base_deps: dict[str, VersionRange] = {}
415
+ extra_deps_map: dict[str, dict[str, VersionRange]] = {
416
+ e: {} for e in provided_extras
417
+ }
418
+ for req in metadata.requires_dist:
419
+ req_extras = classify_requirement(provider, req, provided_extras)
420
+ if req_extras is None:
421
+ continue
422
+ add_classified_dep(req, req_extras, base_deps, extra_deps_map)
423
+ provider.deps_cache[cache_key] = base_deps
424
+ provider.extra_deps_map[cache_key] = extra_deps_map
425
+
426
+
427
+ def add_classified_dep(
428
+ req: Requirement,
429
+ req_extras: set[str],
430
+ base_deps: dict[str, VersionRange],
431
+ extra_deps_map: dict[str, dict[str, VersionRange]],
432
+ ) -> None:
433
+ """Add a classified requirement to the appropriate dep set."""
434
+ # Late import: ``pypi`` imports this module at module load.
435
+ from ..provider import join_extra
436
+
437
+ name = canonicalize_name(req.name)
438
+ vi = req.specifier.to_range()
439
+ dep_extras: set[str] = req.extras
440
+
441
+ if not req_extras:
442
+ base_deps[name] = vi
443
+ for re in dep_extras:
444
+ base_deps[join_extra(name, re)] = VersionRange.full()
445
+ else:
446
+ for extra_name in req_extras:
447
+ edeps = extra_deps_map[extra_name]
448
+ edeps[name] = vi
449
+ for re in dep_extras:
450
+ edeps[join_extra(name, re)] = VersionRange.full()