nab-python 0.0.4__py3-none-any.whl → 0.0.5__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.
@@ -45,11 +45,26 @@ if TYPE_CHECKING:
45
45
 
46
46
 
47
47
  __all__ = [
48
+ "DivergentBaseDependencyError",
48
49
  "build_pylock",
49
50
  "write_lock",
50
51
  ]
51
52
 
52
53
 
54
+ class DivergentBaseDependencyError(ValueError):
55
+ """An environment's conflict forks disagree on a base dependency's pin.
56
+
57
+ A base dependency present in every fork of an environment drops its
58
+ membership clause so it installs even when no conflicting member is
59
+ selected, which requires the forks to agree on one (version, source).
60
+ When they diverge, every candidate entry keeps a membership clause,
61
+ so nothing would fire in the no-member install context and the
62
+ dependency would silently not install. Surface the divergence with
63
+ the offending package and per-fork pins so the producer can
64
+ reconcile the forks rather than commit an incomplete lock.
65
+ """
66
+
67
+
53
68
  def write_lock(
54
69
  lock_input: LockInput,
55
70
  *,
@@ -317,7 +332,9 @@ def _build_per_tuple_packages(lock_input: LockInput, lock_dir: Path) -> list[Pac
317
332
  not by the base is absent from that set, so it keeps the
318
333
  membership clause and does not install when no member is selected.
319
334
  See :class:`LockInput.env_base_names` for the missing-signature
320
- contract.
335
+ contract. Forks of one environment that disagree on a base
336
+ dependency's pin raise :class:`DivergentBaseDependencyError`
337
+ instead of emitting a lock whose no-member context misses it.
321
338
  """
322
339
  out: list[Package] = []
323
340
  by_name = _group_by_name(lock_input.per_tuple_pins)
@@ -330,6 +347,14 @@ def _build_per_tuple_packages(lock_input: LockInput, lock_dir: Path) -> list[Pac
330
347
  )
331
348
  for canonical_name, per_tuple in by_name.items():
332
349
  groups = _group_pins_by_pin(per_tuple)
350
+ _check_base_fork_agreement(
351
+ canonical_name,
352
+ per_tuple,
353
+ groups,
354
+ env_signatures,
355
+ env_fork_counts,
356
+ lock_input.env_base_names,
357
+ )
333
358
  for pins, tuple_labels in groups:
334
359
  marker = _build_marker(
335
360
  canonical_name,
@@ -452,6 +477,46 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
452
477
  )
453
478
 
454
479
 
480
+ def _check_base_fork_agreement(
481
+ name: str,
482
+ per_tuple: Mapping[str, PinShape],
483
+ groups: list[tuple[list[PinShape], list[str]]],
484
+ env_signatures: Mapping[str, tuple[tuple[str, str], ...]],
485
+ env_fork_counts: Mapping[tuple[tuple[str, str], ...], int],
486
+ env_base_names: Mapping[tuple[tuple[str, str], ...], frozenset[str]],
487
+ ) -> None:
488
+ """Reject a base dep whose forks within one env pin it differently.
489
+
490
+ The env-only collapse in :func:`_build_marker` needs a single
491
+ (version, source) group spanning every fork of the environment.
492
+ Divergent pins split the forks across groups, so every entry would
493
+ keep its membership clause and none would fire when no member is
494
+ selected: the base dependency would silently not install.
495
+ """
496
+ by_env: defaultdict[tuple[tuple[str, str], ...], list[str]] = defaultdict(list)
497
+ for label in sorted(per_tuple.keys() & env_signatures.keys()):
498
+ by_env[env_signatures[label]].append(label)
499
+ for signature, labels in by_env.items():
500
+ if len(labels) < env_fork_counts[signature]:
501
+ continue
502
+ if name not in env_base_names.get(signature, frozenset()):
503
+ continue
504
+ in_env = set(labels)
505
+ widest = max(
506
+ sum(1 for label in group_labels if label in in_env)
507
+ for _, group_labels in groups
508
+ )
509
+ if widest >= env_fork_counts[signature]:
510
+ continue
511
+ forks = ", ".join(f"{label} -> {per_tuple[label].version}" for label in labels)
512
+ msg = (
513
+ f"{name}: the conflict forks of one environment pin this base"
514
+ f" dependency differently ({forks}); no lockfile entry would"
515
+ " install it when no conflicting member is selected"
516
+ )
517
+ raise DivergentBaseDependencyError(msg)
518
+
519
+
455
520
  def _build_marker(
456
521
  name: str,
457
522
  tuple_labels: Sequence[str],
@@ -33,8 +33,10 @@ def write_requirements_with_hashes(
33
33
  Each line is ``name==version`` followed by one ``--hash=sha256:...``
34
34
  per recorded artefact, in the format pip's hash-checking mode
35
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.
36
+ without hashes (pip does not hash-check those forms); an editable
37
+ local pin renders as ``-e <url>`` and a ``subdirectory`` as a
38
+ ``#subdirectory=`` fragment. Returns the text and, when
39
+ ``output_path`` is provided, atomically writes it.
38
40
  """
39
41
  return _render_requirements(lock_input, with_hashes=True, output_path=output_path)
40
42
 
@@ -45,8 +47,8 @@ def write_requirements_without_hashes(
45
47
  """Render ``lock_input`` as a plain ``name==version`` list.
46
48
 
47
49
  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
+ the ``--hash=sha256:...`` lines. Local and VCS pins render the
51
+ same in both variants. Returns the text and, when ``output_path``
50
52
  is provided, atomically writes it.
51
53
  """
52
54
  return _render_requirements(lock_input, with_hashes=False, output_path=output_path)
@@ -97,7 +99,13 @@ def _render_pins(pins: Mapping[str, PinShape], *, with_hashes: bool) -> list[str
97
99
  if isinstance(pin, IndexPin):
98
100
  lines.extend(_render_index_pin(pin, with_hashes=with_hashes))
99
101
  elif isinstance(pin, LocalPin):
100
- lines.append(f"{pin.name} @ {Path(pin.path).resolve().as_uri()}")
102
+ url = Path(pin.path).resolve().as_uri()
103
+ if pin.subdirectory is not None:
104
+ url += f"#subdirectory={pin.subdirectory}"
105
+ if pin.editable:
106
+ lines.append(f"-e {url}")
107
+ else:
108
+ lines.append(f"{pin.name} @ {url}")
101
109
  elif isinstance(pin, VcsPin):
102
110
  lines.append(f"{pin.name} @ {pin.repo_url}")
103
111
  else: # pragma: no cover - exhaustive
@@ -76,17 +76,23 @@ def _pick_in_mode(
76
76
  ) -> Version | None:
77
77
  """Pick a candidate honoring ``ExtrasMode``.
78
78
 
79
- User-requested extras return the first candidate that does not have
80
- known-invalid metadata. Transitive extras additionally validate the
81
- base metadata parses, so a malformed PKG-INFO becomes a candidate
82
- skip instead of a fatal error during the later dependency fetch.
83
- BACKTRACK mode additionally checks ``Provides-Extra``.
84
-
85
- Missing-metadata cases (no PEP 658, no sdist) fall through;
86
- mock test coordinators rely on this.
79
+ Fetches base metadata so an extraction failure (unparseable
80
+ PKG-INFO, or an sdist build the policy disallows) becomes a
81
+ candidate skip instead of a fatal error during the later
82
+ dependency fetch. BACKTRACK mode additionally checks
83
+ ``Provides-Extra`` for transitive extras.
84
+
85
+ Missing-metadata cases (no PEP 658, no sdist) skip transitive
86
+ extras but fall through for user-requested ones; mock test
87
+ coordinators rely on this.
87
88
  """
88
89
  # Late import: ``pypi`` imports this module at module load.
89
- from ..provider import ExtrasMode, MetadataError, _normalize_extra
90
+ from ..provider import (
91
+ ExtrasMode,
92
+ MetadataError,
93
+ UnsupportedSdistError,
94
+ _normalize_extra,
95
+ )
90
96
 
91
97
  _, _, normalized = provider.split_and_normalize(base)
92
98
  is_user = (normalized, extra) in provider.root_extras
@@ -94,17 +100,15 @@ def _pick_in_mode(
94
100
  for version in candidates:
95
101
  if provider.has_invalid_metadata(normalized, version):
96
102
  continue
97
- if is_user:
98
- return version
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
103
  try:
104
104
  provider.get_dependencies(base, version)
105
- except MetadataError:
105
+ except UnsupportedSdistError:
106
106
  continue
107
- if not backtrack:
107
+ except MetadataError:
108
+ if not is_user or provider.has_invalid_metadata(normalized, version):
109
+ continue
110
+ return version
111
+ if is_user or not backtrack:
108
112
  return version
109
113
  metadata = provider.metadata_cache.get((normalized, version))
110
114
  provided = (
@@ -188,7 +192,11 @@ def get_extra_dependencies(
188
192
  return handle_missing_extra(provider, normalized, extra, version, cache_key)
189
193
 
190
194
  deps = dict(extra_map[extra])
191
- deps[normalized] = VersionRange.singleton(version)
195
+ # Pin the base, intersected with any bound the extra itself
196
+ # places on it (``foo>=2; extra == "bar"``).
197
+ deps[normalized] = deps.get(
198
+ normalized, VersionRange.full()
199
+ ) & VersionRange.singleton(version)
192
200
 
193
201
  provider.deps_cache[cache_key] = deps
194
202
  provider.prefetch_new_deps(deps)
@@ -223,9 +231,10 @@ def handle_missing_extra(
223
231
  version,
224
232
  extra,
225
233
  )
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 {}
234
+ # The extra contributes no deps at this version, but the proxy
235
+ # must still pin its base: without the pin the proxy and the base
236
+ # can settle on different versions, and if the base's version does
237
+ # provide the extra its dependencies are silently dropped.
238
+ deps = {normalized: VersionRange.singleton(version)}
239
+ provider.deps_cache[cache_key] = deps
240
+ return deps
@@ -271,10 +271,10 @@ def excluded_by_python(provider: Provider, dist: DistFile) -> bool:
271
271
  try:
272
272
  spec = SpecifierSet(requires_python)
273
273
  cached = Version(provider.python_version) not in spec
274
- except (InvalidSpecifier, InvalidVersion):
275
- # Malformed Requires-Python on the dist or our own
276
- # python_version: treat as not-excluded, let downstream
277
- # logic decide.
274
+ except InvalidSpecifier:
275
+ # Malformed Requires-Python on the dist: treat as
276
+ # not-excluded, let downstream logic decide. Our own
277
+ # python_version is validated at Provider construction.
278
278
  cached = False
279
279
  provider.requires_python_cache[requires_python] = cached
280
280
  if cached:
@@ -425,14 +425,21 @@ def await_metadata_batch(
425
425
  event.wait()
426
426
  text = provider.coordinator.index.get_metadata(package, ver_str)
427
427
  if text is None:
428
- provider.deps_cache[cache_key] = {}
429
- else:
430
- try:
431
- provider.parse_and_cache_metadata(cache_key, text)
432
- except (ValueError, InvalidVersion, InvalidSpecifier):
433
- # Malformed metadata: cache empty deps so the candidate
434
- # acts as if it had no deps rather than bubbling.
435
- provider.deps_cache[cache_key] = {}
428
+ # No PEP 658 text arrived: leave the version un-cached so
429
+ # look-ahead's get_dependencies runs the sdist fallback (or
430
+ # refuses it) rather than pinning it as dependency-free.
431
+ continue
432
+ if provider.coordinator.index.metadata_from_sdist(package, ver_str):
433
+ # The shared slot holds sdist PKG-INFO from an earlier
434
+ # fallback; caching it here would skip the PEP 643 gate
435
+ # that get_dependencies applies on the from_sdist path.
436
+ continue
437
+ try:
438
+ provider.parse_and_cache_metadata(cache_key, text)
439
+ except (ValueError, InvalidVersion, InvalidSpecifier):
440
+ # Malformed metadata: same reason, refuse via get_dependencies
441
+ # (_invalid_metadata) instead of caching empty deps.
442
+ continue
436
443
 
437
444
 
438
445
  def prefetch_new_deps(provider: Provider, deps: Mapping[str, VersionRange]) -> None:
@@ -48,11 +48,11 @@ def resolve_metadata(
48
48
 
49
49
  text = provider.coordinator.index.get_metadata(normalized, ver_str)
50
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)
51
+ # Wheel METADATA and sdist PKG-INFO share the slot; the index
52
+ # records which kind the last write was. Inferring from the
53
+ # listing instead would mislabel the text whenever this
54
+ # provider's view differs from the one that stored it.
55
+ from_sdist = provider.coordinator.index.metadata_from_sdist(normalized, ver_str)
56
56
  return (text, from_sdist)
57
57
 
58
58
  dist = pick_dist_for_metadata(versions, version)
@@ -85,18 +85,6 @@ def resolve_metadata(
85
85
  raise MetadataError(msg)
86
86
 
87
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
88
  def pick_dist_for_metadata(
101
89
  versions: Sequence[tuple[Version, DistFile]], version: Version
102
90
  ) -> DistFile | None:
@@ -93,7 +93,9 @@ def compute_matching(
93
93
  elif has_local_source:
94
94
  matching = 1
95
95
  else:
96
- matching = _NO_LISTING_PRIOR
96
+ # Not cached, so the next call re-checks the index and the
97
+ # listing-arrival side effect above can still fire.
98
+ return _NO_LISTING_PRIOR
97
99
 
98
100
  if per_pkg is None:
99
101
  per_pkg = provider.matching_cache[normalized] = {}
@@ -103,15 +103,16 @@ def split_vcs_scheme(url: str) -> tuple[str | None, str]:
103
103
  def has_full_commit_sha(url: str) -> bool:
104
104
  """Return True if the URL pins to a 40-char hex commit hash.
105
105
 
106
- Looks for ``@<sha>`` after the scheme://host portion; ignores any
107
- ``#`` fragment. Tolerates ``user@host`` syntax by taking the last
108
- ``@`` in the path/ref portion.
106
+ Looks for ``@<sha>`` in the path component (after the authority);
107
+ ignores any ``#`` fragment. A ``user@host`` in the authority is
108
+ left alone, matching the ref parsing in :mod:`nab_index.vcs`.
109
109
  """
110
110
  fragmentless = url.split("#", 1)[0]
111
- after_authority = fragmentless.split("://", 1)[-1]
112
- if "@" not in after_authority:
111
+ after_scheme = fragmentless.split("://", 1)[-1]
112
+ path = after_scheme.partition("/")[2]
113
+ if "@" not in path:
113
114
  return False
114
- ref = after_authority.rsplit("@", 1)[1]
115
+ ref = path.rsplit("@", 1)[1]
115
116
  return bool(FULL_GIT_SHA_RE.match(ref))
116
117
 
117
118
 
nab_python/download.py CHANGED
@@ -104,6 +104,8 @@ def _entries_for_pin(canonical: str, pin: PinShape) -> Iterable[DownloadEntry]:
104
104
 
105
105
 
106
106
  def _iter_index_pin(canonical: str, pin: IndexPin) -> Iterable[DownloadEntry]:
107
+ # Recorded digests are lowercased to match hashlib.hexdigest() output:
108
+ # index-fed flows already lowercase, but a caller-built LockInput may not.
107
109
  if pin.sdist is not None:
108
110
  algo, digest = pin.sdist.primary_digest
109
111
  yield DownloadEntry(
@@ -112,7 +114,7 @@ def _iter_index_pin(canonical: str, pin: IndexPin) -> Iterable[DownloadEntry]:
112
114
  filename=pin.sdist.filename,
113
115
  url=pin.sdist.url,
114
116
  hash_algo=algo,
115
- digest=digest,
117
+ digest=digest.lower(),
116
118
  )
117
119
  for wheel in pin.wheels:
118
120
  algo, digest = wheel.primary_digest
@@ -122,7 +124,7 @@ def _iter_index_pin(canonical: str, pin: IndexPin) -> Iterable[DownloadEntry]:
122
124
  filename=wheel.filename,
123
125
  url=wheel.url,
124
126
  hash_algo=algo,
125
- digest=digest,
127
+ digest=digest.lower(),
126
128
  )
127
129
 
128
130
 
nab_python/fetch.py CHANGED
@@ -141,6 +141,10 @@ class InMemoryIndex:
141
141
  self._listing_errors: dict[str, BaseException] = {}
142
142
  self._listing_indexes: dict[str, str] = {}
143
143
  self._metadata: dict[tuple[str, str], str | None] = {}
144
+ # Keys whose ``_metadata`` slot was last written from an sdist
145
+ # PKG-INFO rather than a wheel METADATA; readers need the
146
+ # origin because only sdist deps go through the PEP 643 gate.
147
+ self._metadata_from_sdist: set[tuple[str, str]] = set()
144
148
  self._sdist_pyproject: dict[tuple[str, str], str | None] = {}
145
149
  self._sdist_archives: dict[tuple[str, str], bytes | None] = {}
146
150
  self._pending: dict[str, _Pending] = {}
@@ -227,6 +231,7 @@ class InMemoryIndex:
227
231
  key = f"metadata:{package}:{version}"
228
232
  with self._lock:
229
233
  self._metadata[(package, version)] = data
234
+ self._metadata_from_sdist.discard((package, version))
230
235
  pending = self._pending.get(key)
231
236
  if pending is not None:
232
237
  pending.result = data
@@ -241,15 +246,27 @@ class InMemoryIndex:
241
246
  because PKG-INFO is core-metadata-equivalent. The pending
242
247
  keys differ so a sdist request can run in parallel with (or
243
248
  after) a failed wheel metadata request.
249
+ :meth:`metadata_from_sdist` reports which kind the slot holds.
244
250
  """
245
251
  key = f"sdist:{package}:{version}"
246
252
  with self._lock:
247
253
  self._metadata[(package, version)] = data
254
+ self._metadata_from_sdist.add((package, version))
248
255
  pending = self._pending.get(key)
249
256
  if pending is not None:
250
257
  pending.result = data
251
258
  pending.event.set()
252
259
 
260
+ def metadata_from_sdist(self, package: str, version: str) -> bool:
261
+ """Return ``True`` when the metadata slot was last written from an sdist.
262
+
263
+ The slot itself cannot distinguish wheel METADATA from sdist
264
+ PKG-INFO; readers that apply the :pep:`643` dynamic-deps gate
265
+ only to sdist values ask here for the current text's origin.
266
+ """
267
+ with self._lock:
268
+ return (package, version) in self._metadata_from_sdist
269
+
253
270
  def store_sdist_pyproject(self, package: str, version: str, data: str) -> None:
254
271
  """Store sdist-derived pyproject.toml text for static-metadata fallback.
255
272
 
@@ -470,6 +487,11 @@ class FetchCoordinator:
470
487
  self._thread.join(timeout=_COORDINATOR_JOIN_TIMEOUT_SECONDS)
471
488
  self._thread = None
472
489
  self._started = False
490
+ # Drop the dead loop so a later start() waits for the fresh one
491
+ # instead of submitting to a closed loop.
492
+ self._loop = None
493
+ self._async_q = None
494
+ self._queue_ready.clear()
473
495
 
474
496
  def _submit(self, item: _QueueItem) -> None:
475
497
  """Schedule ``item`` on the fetcher loop's queue from any thread."""
@@ -711,7 +733,8 @@ class FetchCoordinator:
711
733
 
712
734
  client = self._build_client()
713
735
  try:
714
- while True:
736
+ stopping = False
737
+ while not stopping:
715
738
  item = await queue.get()
716
739
  if item is None:
717
740
  break
@@ -725,10 +748,11 @@ class FetchCoordinator:
725
748
  except asyncio.QueueEmpty:
726
749
  break
727
750
  if extra is None:
728
- for t in tasks:
729
- t.cancel()
730
- await asyncio.gather(*tasks, return_exceptions=True)
731
- return
751
+ # Fall through to the gather below instead of
752
+ # cancelling: a cancelled _handle never records a
753
+ # result, leaving its waiter's event unset forever.
754
+ stopping = True
755
+ break
732
756
  self._dispatch(extra, client, sem, tasks)
733
757
 
734
758
  if tasks:
@@ -856,9 +880,12 @@ class FetchCoordinator:
856
880
  pkg_info, pyproject = await client.get_sdist_files(
857
881
  req.package, req.version, req.url
858
882
  )
859
- self.index.store_sdist_metadata(req.package, req.version, pkg_info)
883
+ # Store pyproject.toml first: store_sdist_metadata fires the
884
+ # pending event, and a released waiter reads the pyproject slot
885
+ # with no further synchronisation.
860
886
  if pyproject is not None:
861
887
  self.index.store_sdist_pyproject(req.package, req.version, pyproject)
888
+ self.index.store_sdist_metadata(req.package, req.version, pkg_info)
862
889
 
863
890
  async def _fetch_sdist_archive(
864
891
  self,
nab_python/lockfile.py CHANGED
@@ -24,7 +24,7 @@ from ._lockfile.builder import (
24
24
  read_lockfile_packages,
25
25
  )
26
26
  from ._lockfile.disjointness import DisjointnessError
27
- from ._lockfile.pylock import build_pylock, write_lock
27
+ from ._lockfile.pylock import DivergentBaseDependencyError, build_pylock, write_lock
28
28
  from ._lockfile.requirements import (
29
29
  write_requirements_with_hashes,
30
30
  write_requirements_without_hashes,
@@ -44,6 +44,7 @@ __all__ = [
44
44
  "ACCEPTED_HASH_ALGORITHMS",
45
45
  "LOCK_VERSION",
46
46
  "DisjointnessError",
47
+ "DivergentBaseDependencyError",
47
48
  "IndexPin",
48
49
  "LocalPin",
49
50
  "LockInput",
nab_python/metadata.py CHANGED
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
  import email.parser
13
13
  from dataclasses import dataclass, field
14
14
  from functools import lru_cache
15
- from typing import Any
15
+ from typing import TYPE_CHECKING, Any
16
16
 
17
17
  import tomli
18
18
 
@@ -20,6 +20,9 @@ from ._vendor.packaging.requirements import Requirement
20
20
  from ._vendor.packaging.specifiers import SpecifierSet
21
21
  from ._vendor.packaging.version import Version
22
22
 
23
+ if TYPE_CHECKING:
24
+ from ._vendor.packaging.markers import Marker
25
+
23
26
  __all__ = [
24
27
  "DEPENDENCY_FIELDS",
25
28
  "WheelMetadata",
@@ -88,6 +91,22 @@ def metadata_deps_are_static(metadata: WheelMetadata) -> bool:
88
91
  return not (DEPENDENCY_FIELDS & metadata.dynamic)
89
92
 
90
93
 
94
+ @lru_cache(maxsize=8192)
95
+ def _intern_marker(marker: Marker) -> Marker:
96
+ """Return a shared :class:`Marker` for an equal marker expression.
97
+
98
+ ``Marker`` hashes and compares by its text, so a single marker like
99
+ ``extra == "test"`` recurs across hundreds of distinct dep strings
100
+ (``pytest; extra == "test"``, ``coverage; extra == "test"``, ...),
101
+ each parsing to its own object. The provider caches marker
102
+ evaluation by ``id(marker)``, so sharing one object per distinct
103
+ expression lets that cache hit across every candidate instead of
104
+ re-evaluating the same expression per dep. Markers are read-only,
105
+ so sharing is safe.
106
+ """
107
+ return marker
108
+
109
+
91
110
  @lru_cache(maxsize=16384)
92
111
  def _parse_requirement_cached(req_str: str) -> Requirement:
93
112
  """Cache ``Requirement(req_str)`` parsing across wheel metadata.
@@ -97,7 +116,10 @@ def _parse_requirement_cached(req_str: str) -> Requirement:
97
116
  only read operations (specifier, marker, extras, name) so sharing
98
117
  parsed objects is safe.
99
118
  """
100
- return Requirement(req_str)
119
+ req = Requirement(req_str)
120
+ if req.marker is not None:
121
+ req.marker = _intern_marker(req.marker)
122
+ return req
101
123
 
102
124
 
103
125
  @lru_cache(maxsize=65536)
nab_python/provider.py CHANGED
@@ -7,7 +7,6 @@ types. Uses a thread pool with a shared HTTP session to overlap I/O.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import contextlib
11
10
  import enum
12
11
  import logging
13
12
  import re
@@ -98,16 +97,17 @@ _PYTHON_FULL_VERSION_PARTS = 3
98
97
  def python_axis_environment(python_version: str) -> dict[str, str]:
99
98
  """Map an explicit Python version to its PEP 508 marker keys.
100
99
 
101
- ``python_full_version`` is padded to three components so patch-precision
102
- markers evaluate the same here as in the universal matrix. Raises
103
- ``InvalidVersion`` if the input is not a version.
100
+ ``python_version`` is padded to two components and
101
+ ``python_full_version`` to three so patch-precision markers evaluate
102
+ the same here as in the universal matrix. Raises ``InvalidVersion``
103
+ if the input is not a version.
104
104
  """
105
- release = Version(python_version).release
106
- minor = (
107
- f"{release[0]}.{release[1]}"
108
- if len(release) >= _PYTHON_VERSION_PARTS
109
- else python_version
110
- )
105
+ try:
106
+ release = Version(python_version).release
107
+ except InvalidVersion:
108
+ msg = f"python_version {python_version!r} is not a valid version"
109
+ raise InvalidVersion(msg) from None
110
+ minor = ".".join(str(part) for part in (*release, 0)[:_PYTHON_VERSION_PARTS])
111
111
  full = (
112
112
  python_version
113
113
  if len(release) >= _PYTHON_FULL_VERSION_PARTS
@@ -494,8 +494,7 @@ class Provider:
494
494
  }
495
495
  self.environment: dict[str, str] = env_init
496
496
  if python_version is not None:
497
- with contextlib.suppress(InvalidVersion):
498
- self.environment.update(python_axis_environment(python_version))
497
+ self.environment.update(python_axis_environment(python_version))
499
498
  if marker_environment:
500
499
  for key, value in marker_environment.items():
501
500
  self.environment[key] = value
nab_python/resolve.py CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import contextlib
6
5
  import itertools
7
6
  import logging
8
7
  import sys
@@ -23,7 +22,7 @@ from ._vendor.packaging.ranges import VersionRange
23
22
  from ._vendor.packaging.requirements import Requirement
24
23
  from ._vendor.packaging.specifiers import SpecifierSet
25
24
  from ._vendor.packaging.utils import canonicalize_name
26
- from ._vendor.packaging.version import InvalidVersion, Version
25
+ from ._vendor.packaging.version import Version
27
26
  from .config import (
28
27
  ConfigError,
29
28
  ConflictFork,
@@ -689,8 +688,7 @@ def _build_marker_environment(
689
688
  for key, value in default_environment().items()
690
689
  if isinstance(value, str)
691
690
  }
692
- with contextlib.suppress(InvalidVersion):
693
- env.update(python_axis_environment(python_version))
691
+ env.update(python_axis_environment(python_version))
694
692
  env.update(overrides)
695
693
  return env
696
694
 
@@ -231,9 +231,9 @@ class Matrix:
231
231
  """Expand the matrix into concrete tuples.
232
232
 
233
233
  Validates inputs eagerly: unknown platform ids, unknown
234
- implementations, an empty python range, or an invalid
235
- ``python_order`` each raise a ``ValueError`` before any work
236
- happens.
234
+ implementations, ``python_patches`` keys that are not known
235
+ minors, an empty python range, or an invalid ``python_order``
236
+ each raise a ``ValueError`` before any work happens.
237
237
 
238
238
  ``platforms`` accepts either bare platform-id strings (use
239
239
  default tag floors) or :class:`PlatformSpec` instances for
@@ -258,13 +258,20 @@ class Matrix:
258
258
  if unknown_impl:
259
259
  msg = f"Unknown implementations: {unknown_impl!r}"
260
260
  raise ValueError(msg)
261
+ patches = self.python_patches or {}
262
+ unknown_patches = [m for m in patches if m not in _KNOWN_PYTHON_MINORS]
263
+ if unknown_patches:
264
+ msg = (
265
+ f"Unknown python_patches minors: {unknown_patches!r};"
266
+ " keys must be major.minor like '3.11'"
267
+ )
268
+ raise ValueError(msg)
261
269
  py_versions = list(_pythons_in_range(self.python))
262
270
  if not py_versions:
263
271
  msg = f"No known Python versions match {self.python!r}"
264
272
  raise ValueError(msg)
265
273
  if self.python_order == "desc":
266
274
  py_versions.reverse()
267
- patches = self.python_patches or {}
268
275
  multi_impl = len(self.implementations) > 1
269
276
  return [
270
277
  MatrixTuple(
@@ -201,9 +201,12 @@ def _resolve_one_tuple_with_overrides( # noqa: PLR0913
201
201
  resolution_strategy: str,
202
202
  ) -> dict[str, str]:
203
203
  """Re-resolve ``tup`` with the given metadata overrides; return pins."""
204
+ # Carry the original tuple's patch release so markers gated on
205
+ # python_full_version evaluate the same in both passes.
204
206
  one_tuple_matrix = _Matrix(
205
207
  python=f"=={tup.python_version}",
206
208
  platforms=(tup.platform_spec,),
209
+ python_patches={tup.python_version: tup.environment["python_full_version"]},
207
210
  implementations=(tup.implementation,),
208
211
  )
209
212
  with _override_metadata(coordinator, wheel_metadata):
@@ -294,17 +297,24 @@ def _override_metadata(
294
297
  overridden raw text rather than serving the prior tuple's view.
295
298
  """
296
299
  text_snapshot: dict[tuple[str, str], str | None] = {}
300
+ from_sdist_snapshot: dict[tuple[str, str], bool] = {}
297
301
  parsed_snapshot: dict[tuple[str, str], object | None] = {}
298
302
  for key in overrides:
299
303
  text_snapshot[key] = coordinator.index.get_metadata(*key)
304
+ from_sdist_snapshot[key] = coordinator.index.metadata_from_sdist(*key)
300
305
  parsed_snapshot[key] = coordinator.index.pop_parsed_metadata(*key)
301
306
  try:
302
307
  for key, text in overrides.items():
303
308
  coordinator.index.store_metadata(*key, text)
304
309
  yield
305
310
  finally:
311
+ # Restore through the matching store call so the slot's sdist
312
+ # provenance survives the override cycle.
306
313
  for key, prior in text_snapshot.items():
307
- coordinator.index.store_metadata(*key, prior)
314
+ if from_sdist_snapshot[key]:
315
+ coordinator.index.store_sdist_metadata(*key, prior)
316
+ else:
317
+ coordinator.index.store_metadata(*key, prior)
308
318
  for key, parsed in parsed_snapshot.items():
309
319
  coordinator.index.pop_parsed_metadata(*key)
310
320
  if parsed is not None:
@@ -26,7 +26,7 @@ from .._vendor.packaging.requirements import (
26
26
  InvalidRequirement,
27
27
  Requirement,
28
28
  )
29
- from .._vendor.packaging.utils import canonicalize_name
29
+ from .._vendor.packaging.utils import canonicalize_name, canonicalize_version
30
30
  from ..metadata import load_static_project, metadata_deps_are_static, parse_metadata
31
31
  from .wheel_selection import select_wheel_for_tuple
32
32
 
@@ -47,7 +47,9 @@ __all__ = [
47
47
 
48
48
  # Statuses that always fail the lock at install time, regardless of
49
49
  # build policy.
50
- _ALWAYS_FATAL_STATUSES = frozenset({"no_compatible_wheel", "no_metadata"})
50
+ _ALWAYS_FATAL_STATUSES = frozenset(
51
+ {"version_not_found", "no_compatible_wheel", "no_metadata"}
52
+ )
51
53
 
52
54
  # Statuses that fail only when the build policy refuses to build
53
55
  # from sdist (BuildPolicy.NEVER). These pins resolve fine if the
@@ -77,8 +79,10 @@ class PinValidation:
77
79
 
78
80
  - ``ok``: chosen wheel's deps (after marker eval) match the resolver's.
79
81
  - ``divergent``: wheel has metadata but deps differ from baseline.
80
- - ``sdist_only``: no wheels at all; the user must build from sdist.
81
- Fatal under ``BuildPolicy.NEVER``.
82
+ - ``sdist_only``: an sdist but no wheels; the user must build from
83
+ sdist. Fatal under ``BuildPolicy.NEVER``.
84
+ - ``version_not_found``: the index has no files at the pinned
85
+ version (or no listing for the package). Always fatal.
82
86
  - ``no_compatible_wheel``: wheels exist but none match the tuple's
83
87
  tags and no buildable sdist exists. Always fatal.
84
88
  - ``no_compatible_wheel_with_sdist``: as above but a sdist is
@@ -112,9 +116,10 @@ class ValidationReport:
112
116
  def fatal_findings(self, *, build_allowed: bool = False) -> list[PinValidation]:
113
117
  """Return findings that would prevent installation.
114
118
 
115
- Always fatal: ``no_compatible_wheel`` (no installable
116
- artifact) and ``no_metadata`` (we can't trust the resolver
117
- ran with the right deps).
119
+ Always fatal: ``version_not_found`` (nothing at the pinned
120
+ version), ``no_compatible_wheel`` (no installable artifact)
121
+ and ``no_metadata`` (we can't trust the resolver ran with
122
+ the right deps).
118
123
 
119
124
  Fatal under BuildPolicy.NEVER: ``sdist_only`` and
120
125
  ``no_compatible_wheel_with_sdist`` (only sdist available).
@@ -165,6 +170,14 @@ def _validate_pin( # noqa: PLR0911 - one return per outcome reads cleaner here
165
170
  files_at_version = [f for f in listing if f.version == str(version)]
166
171
  wheels_at_version = [f for f in files_at_version if isinstance(f, WheelFile)]
167
172
  has_sdist = any(not isinstance(f, WheelFile) for f in files_at_version)
173
+ if not files_at_version:
174
+ return PinValidation(
175
+ tuple_label=tup.label,
176
+ package=package,
177
+ version=str(version),
178
+ status="version_not_found",
179
+ detail="index has no files at this version; nothing to install or build",
180
+ )
168
181
  if not wheels_at_version:
169
182
  return PinValidation(
170
183
  tuple_label=tup.label,
@@ -408,7 +421,9 @@ def _evaluate_metadata_deps_by_extra(
408
421
  references go to the base bucket; markers with ``extra ==
409
422
  "name"`` go to that named bucket. This lets the validator catch
410
423
  per-extra divergence between the resolver's listing baseline and
411
- the chosen wheel, even when base deps match.
424
+ the chosen wheel, even when base deps match. Bucket members are
425
+ :func:`_requirement_key` strings, so two wheels that agree on dep
426
+ names but disagree on specifiers still diverge.
412
427
  """
413
428
  metadata = parse_metadata(metadata_text)
414
429
  extras = {canonicalize_name(e) for e in metadata.provides_extra}
@@ -421,12 +436,35 @@ def _evaluate_metadata_deps_by_extra(
421
436
  req = Requirement(str(req_text))
422
437
  except InvalidRequirement: # pragma: no cover
423
438
  continue
424
- name = canonicalize_name(req.name)
439
+ key = _requirement_key(req)
425
440
  marker = req.marker
426
441
  if marker is None or marker.evaluate(base_env):
427
- out[None].add(name)
442
+ out[None].add(key)
428
443
  continue
429
444
  for e in extras:
430
445
  if marker.evaluate({**environment, "extra": e}):
431
- out[e].add(name)
446
+ out[e].add(key)
432
447
  return out
448
+
449
+
450
+ def _requirement_key(req: Requirement) -> str:
451
+ """Render a requirement's name, extras and constraint canonically.
452
+
453
+ Versions are canonicalized per specifier so equivalent spellings
454
+ (``>=2.0`` vs ``>=2``) compare equal across wheels; the marker is
455
+ omitted because bucketing by extra already accounts for it.
456
+ """
457
+ name: str = canonicalize_name(req.name)
458
+ if req.extras:
459
+ name += "[" + ",".join(sorted(canonicalize_name(e) for e in req.extras)) + "]"
460
+ if req.url is not None:
461
+ return f"{name} @ {req.url}"
462
+ return name + ",".join(
463
+ sorted(
464
+ spec.operator
465
+ + canonicalize_version(
466
+ spec.version, strip_trailing_zero=spec.operator != "~="
467
+ )
468
+ for spec in req.specifier
469
+ )
470
+ )
@@ -108,8 +108,8 @@ class PlatformSpec:
108
108
  ("glibc", _floor_tag(self.manylinux_floor)),
109
109
  ("musl", _floor_tag(self.musllinux_floor)),
110
110
  ("macos", _floor_tag(self.macos_min)),
111
- ("rel", "_".join(self.platform_release.split())),
112
- ("ver", "_".join(self.platform_version.split())),
111
+ ("rel", _escape_label_value(self.platform_release)),
112
+ ("ver", _escape_label_value(self.platform_version)),
113
113
  )
114
114
  return "".join(f"-{tag}{value}" for tag, value in fields if value)
115
115
 
@@ -138,6 +138,26 @@ def _floor_tag(floor: tuple[int, int] | None) -> str:
138
138
  return f"{floor[0]}.{floor[1]}" if floor is not None else ""
139
139
 
140
140
 
141
+ def _escape_label_value(value: str) -> str:
142
+ """Escape a free-form marker value for a label suffix field.
143
+
144
+ Alphanumerics and ``.`` pass through, ``_`` doubles itself, and any
145
+ other character becomes ``_<hex codepoint>_``. This keeps the
146
+ encoding injective and the output free of ``-``, so a value can
147
+ never forge a field boundary and collapse two distinct specs onto
148
+ one label.
149
+ """
150
+ out: list[str] = []
151
+ for ch in value:
152
+ if ch == "_":
153
+ out.append("__")
154
+ elif ch.isalnum() or ch == ".":
155
+ out.append(ch)
156
+ else:
157
+ out.append(f"_{ord(ch):x}_")
158
+ return "".join(out)
159
+
160
+
141
161
  def _linux_platform_tags(
142
162
  arch: str,
143
163
  *,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nab-python
3
- Version: 0.0.4
3
+ Version: 0.0.5
4
4
  Summary: Index-backed provider, lockfile emitter, and downloader for nab
5
5
  Project-URL: Homepage, https://github.com/notatallshaw/nab
6
6
  Project-URL: Documentation, https://nab.readthedocs.io/
@@ -19,8 +19,8 @@ Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
20
  Requires-Dist: build>=1.2
21
21
  Requires-Dist: installer>=0.7
22
- Requires-Dist: nab-index==0.0.4
23
- Requires-Dist: nab-resolver==0.0.4
22
+ Requires-Dist: nab-index==0.0.5
23
+ Requires-Dist: nab-resolver==0.0.5
24
24
  Requires-Dist: pyproject-hooks>=1.2
25
25
  Requires-Dist: tomli-w>=1.2
26
26
  Requires-Dist: tomli>=2.0
@@ -2,17 +2,17 @@ nab_python/__init__.py,sha256=tKRFGIR13xvNEftDBxxdczeKtcrvaqcOPiYbmdyFOOs,62
2
2
  nab_python/_conflict_kind.py,sha256=ZDVR_8WvyNnxvk0v5pqupKWN3CoSi4DpV6mr4yEOxRE,755
3
3
  nab_python/_packaging_provider.py,sha256=-dxbaed8UoPtqxBnrOqxw2N_jPzHaQEMT1RJOoMHdRI,3435
4
4
  nab_python/_toml.py,sha256=r_8CfSxoNHWsnchzmdHEsyzd0iigBp9XWDszUN5mhSM,581
5
- nab_python/_vcs_admission.py,sha256=sjAdqN0HtJI0Dtz6gHHAW24S_ETiyMwNx0Y62_bU7Ao,6360
5
+ nab_python/_vcs_admission.py,sha256=wG9S0naO7nfVI-jJrEyweJUsk9Avv4KBLUA-ga0xjZw,6406
6
6
  nab_python/build_backend.py,sha256=T-KBp-VulIQATUjYvTt_hJronFceDSjvB-wZ3YHWPpw,6519
7
7
  nab_python/config.py,sha256=LplXzroZluUFKtN7WuaBKVEquerrAcfrRA8dkfY7H7M,53288
8
- nab_python/download.py,sha256=EwGpCjLkvcDjZ1_wOGv3xE7NxickXZv1vWXT6ncHodM,6680
9
- nab_python/fetch.py,sha256=V9FSNeRax7-vYGYrloEp-bfpBeJz8SpC9uaon2bRosI,33708
10
- nab_python/lockfile.py,sha256=U7h2B5kcfIP8Ve2oCFd9i8DGEan888HmZkQeLR7p3Kg,11362
11
- nab_python/metadata.py,sha256=eMDyQ4vBg1gZLW1S_aiJ7sMV9onB2t86hLN4fbYwo0c,5727
12
- nab_python/provider.py,sha256=9GnV4qHiyg4bHNjq1p6r1xCBuW41Pli3zoPHIDYvjaQ,53448
8
+ nab_python/download.py,sha256=tPf6bryLbM85hez88nvqmBJr6GdGj1Ux8tNdRmfeqS8,6850
9
+ nab_python/fetch.py,sha256=aZZu-H-3uScF4kNLh45Cy5Ur1TNUF0GxauwX7NWsC3s,35180
10
+ nab_python/lockfile.py,sha256=bAO9WybifQd2v7QSOcTmKcBoxKB7jVhuiAaE0nbWUbE,11428
11
+ nab_python/metadata.py,sha256=_1CvzZ7O8jricjBhPm3AfS3-yrdgL8F_2xpVqBq_ZqY,6586
12
+ nab_python/provider.py,sha256=teuTYZhxAm16qGNsicaLckFDku23VaKAz3oOeoSKZco,53512
13
13
  nab_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  nab_python/requirements_file.py,sha256=YdXc9wdomhEWwxwUwKPa6kCcQ5c5E0IpXqufo8oOV0Y,10708
15
- nab_python/resolve.py,sha256=PynfoS1CRUC6gxvZjmpAt8WU0Se2PJ1oG85weiyhVnU,34738
15
+ nab_python/resolve.py,sha256=5V-9TctP_PowlNYZglRkHqU3STJPY5aM3JtXjwsRwGE,34654
16
16
  nab_python/workspace.py,sha256=WhTUIgFP0IKigmeUvaV1S0j-lK9IHEjhMy57OK0rGpY,8270
17
17
  nab_python/_build/__init__.py,sha256=7qW6Gk8Tr8fc3xon-yZ1zW5Z1oUU-LPRpgyucso1GNc,61
18
18
  nab_python/_build/env.py,sha256=hhst05zByIn0ZblYlsitJTdCWJZMEm1MzUG24xWMaSw,14036
@@ -21,15 +21,15 @@ nab_python/_build/runner.py,sha256=6yV1sz4bxOuOAgPp5vpO1w8X1EifDLcB9vSCOzxsLYM,9
21
21
  nab_python/_lockfile/__init__.py,sha256=gfbgTLr4C66nEZeaI5G5DGGo6_s_qrB2uWAo3DjXC7Q,59
22
22
  nab_python/_lockfile/builder.py,sha256=YPRb35YTWhd68B7-nXTfrbtJnPE3tTGcdWGakqkkWv0,20528
23
23
  nab_python/_lockfile/disjointness.py,sha256=CyEuinsVkIoPjTjE_CFzRi5mvp8X7CVSGQZbNCVup3g,17212
24
- nab_python/_lockfile/pylock.py,sha256=kTgEQ0eFA8jNEIO9T5YybUqMDwJJLASrwkyDYmoBqsU,21765
25
- nab_python/_lockfile/requirements.py,sha256=DXWMtt6QVxbEFNWZiQyOyl5we47uK7gN2OOKIrQ5spU,4467
24
+ nab_python/_lockfile/pylock.py,sha256=m2CRlPaIBr_qufVXFFDYx6YPkekQ8-EDF6tQujzGPTM,24678
25
+ nab_python/_lockfile/requirements.py,sha256=5FRO0ul9stXumRUHt2q5fIO0q1ogW-LYt26ajA6t4hs,4797
26
26
  nab_python/_provider/__init__.py,sha256=FJwoSdUfrfhhXfaeJAIXQEWzY6KV4hKFNnvRkQVccfA,59
27
27
  nab_python/_provider/build_remote.py,sha256=nqQ4rzXNkWxNouAyhhDC-rPZmtSsbPOaREoAPYm4Oz4,3411
28
- nab_python/_provider/extras.py,sha256=DBPqXAFho5dK1KKmbZhdDzVu_NVz4Lxoi9huWIZKPfI,8331
29
- nab_python/_provider/listing.py,sha256=0gO7w-cIiDK4T7Dic2alkRgcj0qnkPusBwCkHLzoQKk,17342
28
+ nab_python/_provider/extras.py,sha256=Jom9EMtEMJc5WI3yhqbbkuYTrn8XTt1iRMdnaHe9iys,8574
29
+ nab_python/_provider/listing.py,sha256=QnM4uGqdsEBTLfLD-STRsSBV6iW40SKNixUuJpfiXJk,17791
30
30
  nab_python/_provider/lookahead.py,sha256=xgX0f4h9dt2rdXZ_HmQmzXKyac2H4pDoaRMpVUqFotQ,5895
31
- nab_python/_provider/metadata_resolver.py,sha256=Bnrl0Ntzsmn2EU__q8EJ8Dpvi7pRzmpSY7lLAuklfvQ,17587
32
- nab_python/_provider/priority.py,sha256=mYJHoMsoi71qTycyloWMmQ8ey00uNgBdIIkfXuar9yM,5975
31
+ nab_python/_provider/metadata_resolver.py,sha256=CVIAUadBSprWAj2Ti_UmsJ2Id7AfkVp_Wc3HFZ-nYsQ,17252
32
+ nab_python/_provider/priority.py,sha256=vdtS08Gf9kAtoXQ2xa5-WCA7r-dCYpOwfzMT4Curfks,6098
33
33
  nab_python/_provider/sources.py,sha256=DeG3oQv3iCrZPgW9VsM_6c5EDiVF3b5jWvdvx7TH9M8,7837
34
34
  nab_python/_testing/__init__.py,sha256=WiMwVVwq6kg2mHFeM7JNA4-2tiXWVOPXq6YJd49vJQ0,44
35
35
  nab_python/_testing/coordinator_fake.py,sha256=4RDaZkEHwEHt1hK-dgUqMZbcrJhbvVSbDihMD710_gE,9311
@@ -63,12 +63,12 @@ nab_python/_vendor/packaging/version.py,sha256=daDMk4YRlKNgSiQkkIlxfmpsj_qGiWhVg
63
63
  nab_python/_vendor/packaging/licenses/__init__.py,sha256=_Jx0XRiD_58palsWnyLrLuh59ZpGCPIPXLKdZo9OJvQ,7293
64
64
  nab_python/_vendor/packaging/licenses/_spdx.py,sha256=WW7DXiyg68up_YND_wpRYlr1SHhiV4FfJLQffghhMxQ,51122
65
65
  nab_python/universal/__init__.py,sha256=eNFMbsK-jBNy1J7wAE702vI-LVXF_aaQQvX7G02ImUw,48
66
- nab_python/universal/matrix.py,sha256=hEGT37F0tx_alYtHUPUO1B_7tPfW3m3PbpOaYi2FPvs,12705
66
+ nab_python/universal/matrix.py,sha256=DSXzmojLGCJQI_Qgy6m2W8P-AziFuCcDzgNcYwsBIk0,13059
67
67
  nab_python/universal/provider.py,sha256=lAHEJ8HQPK1nWvNhKaKoFFRQsxTkliW-QY07W42WK3M,9904
68
- nab_python/universal/reresolve.py,sha256=hSh9hqz0A6BTr-ixfY4h4r6AcRuQWN64-nbtsKc-mvM,11595
68
+ nab_python/universal/reresolve.py,sha256=iA7_QRnLJbdAaN0XS1woLRDVRKBmggIUqLfZZUqZbO0,12195
69
69
  nab_python/universal/resolve.py,sha256=M1r_Tb1kvG3SMlXGKAUWhTGqc3A0Ah1GomMa4CSlGKU,28351
70
- nab_python/universal/validate.py,sha256=ehYPaiDjFmSm9z4D_sMjTqslVuDHG_ozOAIG0CMaqS8,15944
71
- nab_python/universal/wheel_selection.py,sha256=jmukmHs5k3hqEXKFdj0ZTa5z3-v3FmsHwzkriXf0Mn4,13765
72
- nab_python-0.0.4.dist-info/METADATA,sha256=_mx97j9RuTeo89hqyi7kuRxtDsYbRRXDGjTAzdRkJSE,1768
73
- nab_python-0.0.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
74
- nab_python-0.0.4.dist-info/RECORD,,
70
+ nab_python/universal/validate.py,sha256=-T084FTML0joZcfUOyiooLk56fL_0pfXi7lVb7bjLjs,17408
71
+ nab_python/universal/wheel_selection.py,sha256=A7Ozb6jOCDkw5qp1GZ55TmBTrUTpIN7qmI19LXW02JI,14425
72
+ nab_python-0.0.5.dist-info/METADATA,sha256=mkirolKaVgtidr-7EHQNuxp6VDPrfPMYsky0eQjMXvY,1768
73
+ nab_python-0.0.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
74
+ nab_python-0.0.5.dist-info/RECORD,,