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,323 @@
1
+ """PEP 751 ``pylock.toml`` emission.
2
+
3
+ Owns ``write_lock`` and ``build_pylock`` plus the
4
+ :class:`LockInput` -> :class:`Pylock` shape conversion. The
5
+ per-tuple expansion path (when a universal resolve produced
6
+ different pins for different environments) collapses into one or
7
+ more ``Package`` entries with markers attached; the emit-time
8
+ disjointness validation lives in
9
+ :mod:`nab_python._lockfile.disjointness`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections import defaultdict
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import tomli_w
19
+
20
+ from .._vendor.packaging.markers import Marker
21
+ from .._vendor.packaging.pylock import (
22
+ Package,
23
+ PackageDirectory,
24
+ PackageSdist,
25
+ PackageVcs,
26
+ PackageWheel,
27
+ Pylock,
28
+ )
29
+ from .._vendor.packaging.specifiers import SpecifierSet
30
+ from .._vendor.packaging.utils import canonicalize_name
31
+ from .._vendor.packaging.version import Version
32
+ from .disjointness import validate_marker_disjointness
33
+
34
+ if TYPE_CHECKING:
35
+ import os
36
+ from collections.abc import Mapping, Sequence
37
+
38
+ from ..lockfile import (
39
+ LockInput,
40
+ PinShape,
41
+ SdistArtifact,
42
+ WheelArtifact,
43
+ )
44
+
45
+
46
+ __all__ = [
47
+ "build_pylock",
48
+ "write_lock",
49
+ ]
50
+
51
+
52
+ def write_lock(
53
+ lock_input: LockInput,
54
+ *,
55
+ output_path: str | os.PathLike[str] | None = None,
56
+ ) -> str:
57
+ """Serialise ``lock_input`` to PEP 751 TOML text.
58
+
59
+ Returns the TOML text. When ``output_path`` is provided, also
60
+ writes it atomically; the caller chooses the path (PEP 751 does
61
+ not mandate one).
62
+ """
63
+ pylock = build_pylock(lock_input)
64
+ pylock.validate()
65
+ text = tomli_w.dumps(dict(pylock.to_dict()))
66
+ if output_path is not None:
67
+ Path(output_path).write_text(text, encoding="utf-8")
68
+ return text
69
+
70
+
71
+ def build_pylock(lock_input: LockInput) -> Pylock:
72
+ """Build a :class:`Pylock` from the input shape.
73
+
74
+ The resolver-side data structures have already been simplified
75
+ when this function runs. The remaining work is shape conversion:
76
+ ``Pin`` -> ``Package``, plus marker attachment from the per-tuple
77
+ map.
78
+ """
79
+ from ..lockfile import LOCK_VERSION
80
+
81
+ if lock_input.per_tuple_pins:
82
+ package_records = _build_per_tuple_packages(lock_input)
83
+ else:
84
+ package_records = [_pin_to_package(pin) for pin in lock_input.pins.values()]
85
+ package_records.sort(key=_package_sort_key)
86
+ validate_marker_disjointness(
87
+ package_records,
88
+ environments=lock_input.tuple_environments,
89
+ extras=lock_input.extras,
90
+ groups=lock_input.dependency_groups,
91
+ )
92
+ tool: dict[str, Any] | None = (
93
+ {"nab": lock_input.provenance.to_block()}
94
+ if lock_input.provenance is not None
95
+ else None
96
+ )
97
+ return Pylock(
98
+ lock_version=Version(LOCK_VERSION),
99
+ environments=tuple(lock_input.environments) or None,
100
+ requires_python=(
101
+ SpecifierSet(lock_input.requires_python)
102
+ if lock_input.requires_python
103
+ else None
104
+ ),
105
+ extras=(
106
+ tuple(canonicalize_name(e) for e in lock_input.extras)
107
+ if lock_input.extras
108
+ else None
109
+ ),
110
+ dependency_groups=(
111
+ tuple(lock_input.dependency_groups)
112
+ if lock_input.dependency_groups
113
+ else None
114
+ ),
115
+ default_groups=(
116
+ tuple(lock_input.default_groups) if lock_input.default_groups else None
117
+ ),
118
+ created_by=lock_input.created_by,
119
+ packages=package_records,
120
+ tool=tool,
121
+ )
122
+
123
+
124
+ def _pin_to_package(pin: PinShape, marker: Marker | None = None) -> Package:
125
+ from ..lockfile import IndexPin, LocalPin, VcsPin
126
+
127
+ if isinstance(pin, IndexPin):
128
+ return Package(
129
+ name=canonicalize_name(pin.name),
130
+ version=Version(pin.version),
131
+ marker=marker,
132
+ requires_python=(
133
+ SpecifierSet(pin.requires_python) if pin.requires_python else None
134
+ ),
135
+ index=pin.index,
136
+ sdist=_sdist_to_package(pin.sdist) if pin.sdist else None,
137
+ wheels=tuple(_wheel_to_package(w) for w in pin.wheels) or None,
138
+ )
139
+ if isinstance(pin, LocalPin):
140
+ return Package(
141
+ name=canonicalize_name(pin.name),
142
+ version=Version(pin.version),
143
+ marker=marker,
144
+ directory=PackageDirectory(path=pin.path, editable=False),
145
+ )
146
+ if isinstance(pin, VcsPin):
147
+ return Package(
148
+ name=canonicalize_name(pin.name),
149
+ version=Version(pin.version),
150
+ marker=marker,
151
+ vcs=PackageVcs(
152
+ type="git",
153
+ url=pin.repo_url,
154
+ commit_id=pin.commit_id,
155
+ subdirectory=pin.subdirectory,
156
+ ),
157
+ )
158
+ msg = f"unknown pin shape: {pin!r}"
159
+ raise TypeError(msg)
160
+
161
+
162
+ def _wheel_to_package(wheel: WheelArtifact) -> PackageWheel:
163
+ return PackageWheel(
164
+ name=wheel.filename,
165
+ url=wheel.url,
166
+ size=wheel.size,
167
+ hashes=dict(wheel.hashes),
168
+ )
169
+
170
+
171
+ def _sdist_to_package(sdist: SdistArtifact) -> PackageSdist:
172
+ return PackageSdist(
173
+ name=sdist.filename,
174
+ url=sdist.url,
175
+ size=sdist.size,
176
+ hashes=dict(sdist.hashes),
177
+ )
178
+
179
+
180
+ def _build_per_tuple_packages(lock_input: LockInput) -> list[Package]:
181
+ """Collapse per-tuple pins into Package entries with markers.
182
+
183
+ For each canonical package name:
184
+ * Group tuples by (version, source-shape).
185
+ * Emit a Package per group; the marker is the OR of the matching
186
+ tuples' markers. When the group's tuples cover the entire
187
+ declared universe (``lock_input.tuple_markers``) the package is
188
+ unconditional and the marker is omitted.
189
+ * Within a group, the artefact sets (wheels and sdist) are
190
+ unioned across the contributing tuples so tuple-specific
191
+ wheels (e.g. cp310-manylinux vs cp311-macos) survive.
192
+
193
+ The emitted marker is the raw OR of the per-tuple marker
194
+ expressions; no Boolean minimisation runs.
195
+ """
196
+ out: list[Package] = []
197
+ by_name = _group_by_name(lock_input.per_tuple_pins)
198
+ total_tuples = len(lock_input.tuple_markers)
199
+ for per_tuple in by_name.values():
200
+ groups = _group_pins_by_pin(per_tuple)
201
+ for pins, tuple_labels in groups:
202
+ marker = _build_marker(tuple_labels, lock_input.tuple_markers, total_tuples)
203
+ out.append(_pin_to_package(_merge_pins_in_group(pins), marker))
204
+ # Pins only present in lock_input.pins (e.g. tuples agreed via the
205
+ # single-source path) emit unconditionally.
206
+ for canonical_name, pin in lock_input.pins.items():
207
+ if canonical_name not in by_name:
208
+ out.append(_pin_to_package(pin))
209
+ return out
210
+
211
+
212
+ def _group_by_name(
213
+ per_tuple_pins: Mapping[str, Mapping[str, PinShape]],
214
+ ) -> dict[str, dict[str, PinShape]]:
215
+ """Pivot ``{tuple_label: {name: pin}}`` to ``{canonical: {label: pin}}``."""
216
+ out: defaultdict[str, dict[str, PinShape]] = defaultdict(dict)
217
+ for tuple_label, per_name in per_tuple_pins.items():
218
+ for raw_name, pin in per_name.items():
219
+ canonical = canonicalize_name(raw_name)
220
+ out[canonical][tuple_label] = pin
221
+ return out
222
+
223
+
224
+ def _group_pins_by_pin(
225
+ per_tuple: dict[str, PinShape],
226
+ ) -> list[tuple[list[PinShape], list[str]]]:
227
+ """Bucket tuples by structural pin discriminator, keeping every pin."""
228
+ by_key: dict[tuple, tuple[list[PinShape], list[str]]] = {}
229
+ for tuple_label, pin in per_tuple.items():
230
+ key = _pin_discriminator(pin)
231
+ if key not in by_key:
232
+ by_key[key] = ([], [])
233
+ by_key[key][0].append(pin)
234
+ by_key[key][1].append(tuple_label)
235
+ return list(by_key.values())
236
+
237
+
238
+ def _pin_discriminator(pin: PinShape) -> tuple:
239
+ """Return a hashable key that identifies the source + version of ``pin``."""
240
+ from ..lockfile import IndexPin, LocalPin, VcsPin
241
+
242
+ if isinstance(pin, IndexPin):
243
+ return ("index", pin.version, pin.index)
244
+ if isinstance(pin, LocalPin):
245
+ return ("local", pin.version, pin.path)
246
+ if isinstance(pin, VcsPin):
247
+ return ("vcs", pin.commit_id, pin.repo_url, pin.subdirectory or "")
248
+ msg = f"unknown pin shape: {pin!r}"
249
+ raise TypeError(msg)
250
+
251
+
252
+ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
253
+ """Combine pins sharing a discriminator into one with unioned artefacts.
254
+
255
+ For :class:`IndexPin`, accumulates every distinct wheel filename
256
+ across the contributing tuples and keeps the first non-``None``
257
+ sdist. ``requires_python`` survives only when every tuple agreed,
258
+ matching :func:`_common_requires_python`'s rule.
259
+ Non-IndexPin shapes are already fully discriminated, so the first
260
+ pin is returned unchanged.
261
+ """
262
+ from ..lockfile import IndexPin
263
+
264
+ head = pins[0]
265
+ if not isinstance(head, IndexPin):
266
+ return head
267
+ seen_wheels: dict[str, WheelArtifact] = {}
268
+ sdist = head.sdist
269
+ requires_python_set: set[str] = set()
270
+ for pin in pins:
271
+ assert isinstance(pin, IndexPin)
272
+ for wheel in pin.wheels:
273
+ seen_wheels.setdefault(wheel.filename, wheel)
274
+ if sdist is None and pin.sdist is not None:
275
+ sdist = pin.sdist
276
+ if pin.requires_python is not None:
277
+ requires_python_set.add(pin.requires_python)
278
+ requires_python = (
279
+ next(iter(requires_python_set)) if len(requires_python_set) == 1 else None
280
+ )
281
+ return IndexPin(
282
+ name=head.name,
283
+ version=head.version,
284
+ index=head.index,
285
+ sdist=sdist,
286
+ wheels=tuple(seen_wheels.values()),
287
+ requires_python=requires_python,
288
+ )
289
+
290
+
291
+ def _build_marker(
292
+ tuple_labels: Sequence[str],
293
+ tuple_markers: Mapping[str, Marker],
294
+ total_tuples: int,
295
+ ) -> Marker | None:
296
+ """Return the marker selecting ``tuple_labels``, or ``None`` if unconditional.
297
+
298
+ The package is unconditional when ``tuple_labels`` covers every
299
+ declared tuple in ``tuple_markers``. Otherwise the marker is the
300
+ OR of the per-tuple markers. When ``tuple_markers`` is empty the
301
+ caller has not declared a tuple universe and we omit the marker.
302
+ """
303
+ if total_tuples == 0 or len(tuple_labels) >= total_tuples:
304
+ return None
305
+ markers = [tuple_markers[label] for label in tuple_labels if label in tuple_markers]
306
+ if not markers:
307
+ return None
308
+ return _or_markers(markers)
309
+
310
+
311
+ def _or_markers(markers: Sequence[Marker]) -> Marker:
312
+ """Return a Marker that evaluates True if any of ``markers`` does."""
313
+ if not markers:
314
+ msg = "_or_markers requires at least one marker"
315
+ raise ValueError(msg)
316
+ if len(markers) == 1:
317
+ return markers[0]
318
+ parts = [f"({m})" for m in markers]
319
+ return Marker(" or ".join(parts))
320
+
321
+
322
+ def _package_sort_key(package: Package) -> tuple:
323
+ return (str(package.name), str(package.version) if package.version else "")
@@ -0,0 +1,121 @@
1
+ """Pip-compatible ``requirements.txt`` rendering for a finished resolve.
2
+
3
+ Produces text that pip's hash-checking mode can install (with
4
+ ``--hash=sha256:...`` lines) or a plain ``name==version`` list when
5
+ hashes are not required. Per-tuple resolves render as commented
6
+ sections; pip cannot install a single requirements.txt across
7
+ multiple ``(python, platform)`` tuples in hash-checking mode.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ import os
17
+ from collections.abc import Mapping
18
+
19
+ from ..lockfile import IndexPin, LockInput, PinShape
20
+
21
+
22
+ __all__ = [
23
+ "write_requirements_with_hashes",
24
+ "write_requirements_without_hashes",
25
+ ]
26
+
27
+
28
+ def write_requirements_with_hashes(
29
+ lock_input: LockInput, *, output_path: str | os.PathLike[str] | None = None
30
+ ) -> str:
31
+ """Render ``lock_input`` as a pip-compatible requirements.txt.
32
+
33
+ Each line is ``name==version`` followed by one ``--hash=sha256:...``
34
+ per recorded artefact, in the format pip's hash-checking mode
35
+ accepts. Local and VCS pins are emitted as ``name @ <url>`` lines
36
+ without hashes (pip does not hash-check those forms). Returns the
37
+ text and, when ``output_path`` is provided, atomically writes it.
38
+ """
39
+ return _render_requirements(lock_input, with_hashes=True, output_path=output_path)
40
+
41
+
42
+ def write_requirements_without_hashes(
43
+ lock_input: LockInput, *, output_path: str | os.PathLike[str] | None = None
44
+ ) -> str:
45
+ """Render ``lock_input`` as a plain ``name==version`` list.
46
+
47
+ Same shape as :func:`write_requirements_with_hashes` but without
48
+ the ``--hash=sha256:...`` lines. Local and VCS pins still render
49
+ as ``name @ <url>``. Returns the text and, when ``output_path``
50
+ is provided, atomically writes it.
51
+ """
52
+ return _render_requirements(lock_input, with_hashes=False, output_path=output_path)
53
+
54
+
55
+ def _render_requirements(
56
+ lock_input: LockInput,
57
+ *,
58
+ with_hashes: bool,
59
+ output_path: str | os.PathLike[str] | None,
60
+ ) -> str:
61
+ if lock_input.per_tuple_pins:
62
+ text = _render_per_tuple_requirements(lock_input, with_hashes=with_hashes)
63
+ else:
64
+ lines = _render_pins(lock_input.pins, with_hashes=with_hashes)
65
+ text = "\n".join(lines) + "\n"
66
+ if output_path is not None:
67
+ Path(output_path).write_text(text, encoding="utf-8")
68
+ return text
69
+
70
+
71
+ def _render_per_tuple_requirements(lock_input: LockInput, *, with_hashes: bool) -> str:
72
+ """Emit one ``# label`` block per tuple followed by that tuple's pins.
73
+
74
+ Pip cannot install a single requirements.txt across multiple
75
+ ``(python, platform)`` tuples in hash-checking mode, so a multi-
76
+ tuple resolve serialises as commented sections that callers are
77
+ expected to extract per environment.
78
+ """
79
+ blocks: list[str] = []
80
+ for label in lock_input.per_tuple_pins:
81
+ pins = lock_input.per_tuple_pins[label]
82
+ block = [f"# {label}"]
83
+ block.extend(_render_pins(pins, with_hashes=with_hashes))
84
+ blocks.append("\n".join(block))
85
+ return "\n\n".join(blocks) + "\n"
86
+
87
+
88
+ def _render_pins(pins: Mapping[str, PinShape], *, with_hashes: bool) -> list[str]:
89
+ """Render a flat ``{name: pin}`` mapping in alphabetical order."""
90
+ from ..lockfile import IndexPin, LocalPin, VcsPin
91
+
92
+ lines: list[str] = []
93
+ for canonical in sorted(pins):
94
+ pin = pins[canonical]
95
+
96
+ if isinstance(pin, IndexPin):
97
+ lines.extend(_render_index_pin(pin, with_hashes=with_hashes))
98
+ elif isinstance(pin, LocalPin):
99
+ lines.append(f"{pin.name} @ {Path(pin.path).resolve().as_uri()}")
100
+ elif isinstance(pin, VcsPin):
101
+ lines.append(f"{pin.name} @ {pin.repo_url}")
102
+ else: # pragma: no cover - exhaustive
103
+ msg = f"unknown pin shape: {pin!r}"
104
+ raise TypeError(msg)
105
+
106
+ return lines
107
+
108
+
109
+ def _render_index_pin(pin: IndexPin, *, with_hashes: bool = True) -> list[str]:
110
+ if not with_hashes:
111
+ return [f"{pin.name}=={pin.version}"]
112
+ digests: list[tuple[str, str]] = []
113
+ if pin.sdist is not None:
114
+ digests.extend(pin.sdist.hashes)
115
+ for wheel in pin.wheels:
116
+ digests.extend(wheel.hashes)
117
+ if not digests:
118
+ return [f"{pin.name}=={pin.version}"]
119
+ parts = [f"{pin.name}=={pin.version}"]
120
+ parts.extend(f"--hash={algo}:{d}" for algo, d in digests)
121
+ return [" \\\n ".join(parts)]
@@ -0,0 +1,98 @@
1
+ """Provider that bridges packaging's PEP 440 types to nab-resolver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Mapping
9
+
10
+ from nab_resolver.types import Incompatibility, RangeProtocol
11
+
12
+ from ._vendor.packaging.ranges import VersionRange
13
+ from ._vendor.packaging.specifiers import SpecifierSet
14
+ from ._vendor.packaging.version import Version
15
+
16
+ __all__ = [
17
+ "PackagingProvider",
18
+ ]
19
+
20
+
21
+ class PackagingProvider:
22
+ """In-memory provider using PEP 440 versions.
23
+
24
+ Packages are strings, versions are :class:`packaging.version.Version`,
25
+ and dependency constraints are :class:`packaging.specifiers.SpecifierSet`,
26
+ converted to :class:`packaging.ranges.VersionRange` for the resolver.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ packages: dict[str, dict[Version, dict[str, SpecifierSet]]],
32
+ ) -> None:
33
+ """Create a provider from a package graph."""
34
+ self._packages = packages
35
+
36
+ def _get_versions(self, package: str) -> list[Version]:
37
+ if package not in self._packages:
38
+ return []
39
+ return sorted(self._packages[package].keys(), reverse=True)
40
+
41
+ def choose_version(
42
+ self, package: str, version_range: RangeProtocol[Version]
43
+ ) -> Version | None:
44
+ """Pick the newest version within the allowed range."""
45
+ for version in self._get_versions(package):
46
+ if version in version_range:
47
+ return version
48
+ return None
49
+
50
+ def get_dependencies(
51
+ self, package: str, version: Version
52
+ ) -> dict[str, VersionRange]:
53
+ """Convert SpecifierSet deps to VersionRange deps."""
54
+ raw = self._packages.get(package, {}).get(version, {})
55
+ return {dep: spec.to_range() for dep, spec in raw.items()}
56
+
57
+ _CONFLICT_THRESHOLD = 5
58
+
59
+ def prioritize(
60
+ self,
61
+ package: str,
62
+ version_range: RangeProtocol[Version],
63
+ conflict_counts: Mapping[str, int],
64
+ culprit_counts: Mapping[str, int] | None = None,
65
+ ) -> tuple[bool, int]:
66
+ """Prioritize packages for resolution order.
67
+
68
+ Returns a tuple compared with min(), so lower = decided first.
69
+ Packages with many conflicts are promoted so the resolver
70
+ discovers incompatibilities before deciding downstream packages.
71
+ ``culprit_counts`` is accepted for protocol compatibility but
72
+ not used by this provider.
73
+ """
74
+ del culprit_counts
75
+ promoted = conflict_counts.get(package, 0) >= self._CONFLICT_THRESHOLD
76
+ versions = self._get_versions(package)
77
+ matching = sum(1 for v in versions if v in version_range)
78
+ return (not promoted, matching)
79
+
80
+ def is_ready(self, package: str) -> bool:
81
+ """All packages are immediately decidable for this in-memory provider."""
82
+ del package
83
+ return True
84
+
85
+ def receive_partial_solution_hint(
86
+ self,
87
+ positive_ranges: Mapping[str, RangeProtocol[Version]],
88
+ decisions: Mapping[str, Version],
89
+ ) -> None:
90
+ """No-op: in-memory provider does not use partial solution state."""
91
+
92
+ def consume_pending_clauses(self) -> list[Incompatibility[str, Version]]:
93
+ """No queued clauses for this in-memory provider."""
94
+ return []
95
+
96
+ def consume_force_backtrack_targets(self) -> list[str]:
97
+ """No force-backtrack signal from this in-memory provider."""
98
+ return []
@@ -0,0 +1 @@
1
+ """Internal helpers backing :mod:`nab_python.provider`."""
@@ -0,0 +1,95 @@
1
+ """BUILD_REMOTE path: fetch, extract, and build a remote sdist.
2
+
3
+ Invoked from :func:`resolve_dynamic_sdist` when neither the
4
+ :pep:`643` static-deps path nor the bundled ``pyproject.toml``
5
+ fallback yields usable dependency metadata, and the effective
6
+ :class:`~nab_python.provider.BuildPolicy` for the package is
7
+ :attr:`~nab_python.provider.BuildPolicy.BUILD_REMOTE`.
8
+
9
+ A failure here raises :class:`~nab_python.provider.UnsupportedSdistError`
10
+ so :func:`nab_python._provider.lookahead.look_ahead_ok` can skip the
11
+ version. If every candidate fails the resolver surfaces the
12
+ accumulated reasons as a no-version-satisfies error: invoking
13
+ ``BUILD_REMOTE`` does not turn a broken sdist into a usable one,
14
+ it just turns silence into a real diagnostic.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import tempfile
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING
22
+
23
+ from nab_index.client import SdistFile, WheelFile, extract_sdist_archive
24
+
25
+ from .._vendor.packaging.utils import canonicalize_name
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Sequence
29
+
30
+ from .._vendor.packaging.version import Version
31
+ from ..metadata import WheelMetadata
32
+ from ..provider import Provider
33
+
34
+
35
+ def build_remote_sdist(
36
+ provider: Provider,
37
+ package: str,
38
+ version: Version,
39
+ ) -> WheelMetadata:
40
+ """Download the sdist for ``(package, version)``, extract, and build.
41
+
42
+ ``package`` is the canonical package name; ``version`` matches an
43
+ entry in ``provider.versions_cache``.
44
+ """
45
+ # Late imports: ``provider`` imports this module at module load.
46
+ from .. import build_backend
47
+ from ..build_backend import BuildBackendError
48
+ from ..provider import UnsupportedSdistError
49
+
50
+ canonical = canonicalize_name(package)
51
+ versions = provider.versions_cache.get(canonical, [])
52
+ sdist = _find_sdist(versions, version)
53
+ if sdist is None:
54
+ msg = (
55
+ f"{package}=={version} build-remote requested but no sdist"
56
+ " is available in the listing"
57
+ )
58
+ raise UnsupportedSdistError(msg)
59
+
60
+ ver_str = str(version)
61
+ event = provider.coordinator.request_sdist_archive(canonical, ver_str, sdist.url)
62
+ event.wait()
63
+ data = provider.coordinator.index.get_sdist_archive(canonical, ver_str)
64
+ if data is None:
65
+ msg = (
66
+ f"{package}=={version} build-remote requested but sdist archive"
67
+ f" fetch from {sdist.url} failed"
68
+ )
69
+ raise UnsupportedSdistError(msg)
70
+
71
+ with tempfile.TemporaryDirectory(prefix="nab-build-remote-") as td:
72
+ try:
73
+ source_dir = extract_sdist_archive(data, Path(td))
74
+ except (OSError, ValueError) as exc:
75
+ msg = f"{package}=={version} sdist archive could not be extracted: {exc}"
76
+ raise UnsupportedSdistError(msg) from exc
77
+ try:
78
+ return build_backend.extract_metadata(
79
+ source_dir,
80
+ config=provider.build_config,
81
+ python_version=provider.python_version,
82
+ )
83
+ except BuildBackendError as exc:
84
+ msg = f"{package}=={version} build-remote backend failed: {exc}"
85
+ raise UnsupportedSdistError(msg) from exc
86
+
87
+
88
+ def _find_sdist(
89
+ versions: Sequence[tuple[Version, WheelFile | SdistFile]],
90
+ version: Version,
91
+ ) -> SdistFile | None:
92
+ for v, d in versions:
93
+ if v == version and isinstance(d, SdistFile):
94
+ return d
95
+ return None