nab-python 0.0.5__py3-none-any.whl → 0.0.7__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 (56) hide show
  1. nab_python/_build/env.py +17 -9
  2. nab_python/_build/runner.py +16 -12
  3. nab_python/_conflict_kind.py +42 -0
  4. nab_python/_lockfile/builder.py +143 -42
  5. nab_python/_lockfile/disjointness.py +5 -5
  6. nab_python/_lockfile/pylock.py +46 -10
  7. nab_python/_lockfile/requirements.py +21 -6
  8. nab_python/_provider/build_remote.py +39 -4
  9. nab_python/_provider/extras.py +40 -20
  10. nab_python/_provider/listing.py +186 -52
  11. nab_python/_provider/metadata_resolver.py +164 -51
  12. nab_python/_provider/priority.py +4 -2
  13. nab_python/_provider/sources.py +184 -13
  14. nab_python/_testing/coordinator_fake.py +6 -1
  15. nab_python/_testing/overrides.py +18 -0
  16. nab_python/_vcs_admission.py +67 -11
  17. nab_python/_vendor/packaging/PROVENANCE.md +48 -29
  18. nab_python/_vendor/packaging/_elffile.py +5 -5
  19. nab_python/_vendor/packaging/_manylinux.py +30 -15
  20. nab_python/_vendor/packaging/_parser.py +21 -4
  21. nab_python/_vendor/packaging/_ranges.py +836 -0
  22. nab_python/_vendor/packaging/_tokenizer.py +5 -1
  23. nab_python/_vendor/packaging/dependency_groups.py +29 -1
  24. nab_python/_vendor/packaging/direct_url.py +19 -4
  25. nab_python/_vendor/packaging/licenses/__init__.py +18 -6
  26. nab_python/_vendor/packaging/markers.py +32 -11
  27. nab_python/_vendor/packaging/metadata.py +3 -2
  28. nab_python/_vendor/packaging/pylock.py +6 -1
  29. nab_python/_vendor/packaging/ranges.py +707 -1513
  30. nab_python/_vendor/packaging/requirements.py +60 -20
  31. nab_python/_vendor/packaging/specifiers.py +290 -374
  32. nab_python/_vendor/packaging/tags.py +106 -43
  33. nab_python/_vendor/packaging/utils.py +33 -5
  34. nab_python/build_backend.py +57 -29
  35. nab_python/config.py +1234 -390
  36. nab_python/config_sources.py +1831 -0
  37. nab_python/download.py +85 -17
  38. nab_python/fetch.py +152 -72
  39. nab_python/lockfile.py +91 -6
  40. nab_python/metadata.py +19 -6
  41. nab_python/provider.py +534 -94
  42. nab_python/requirements_file.py +405 -28
  43. nab_python/resolve.py +181 -57
  44. nab_python/universal/matrix.py +0 -2
  45. nab_python/universal/provider.py +20 -15
  46. nab_python/universal/reresolve.py +40 -21
  47. nab_python/universal/resolve.py +88 -55
  48. nab_python/universal/validate.py +114 -30
  49. nab_python/universal/wheel_selection.py +67 -19
  50. nab_python/workspace.py +8 -1
  51. {nab_python-0.0.5.dist-info → nab_python-0.0.7.dist-info}/METADATA +3 -3
  52. nab_python-0.0.7.dist-info/RECORD +75 -0
  53. nab_python/_vendor/packaging/_range_utils.py +0 -998
  54. nab_python/_vendor/packaging/_version_utils.py +0 -90
  55. nab_python-0.0.5.dist-info/RECORD +0 -74
  56. {nab_python-0.0.5.dist-info → nab_python-0.0.7.dist-info}/WHEEL +0 -0
nab_python/_build/env.py CHANGED
@@ -6,7 +6,7 @@
6
6
  * ``venv.EnvBuilder`` (stdlib) creates an empty interpreter at a temp
7
7
  path, ``with_pip=False``; nab does not need pip in there.
8
8
  * nab's own resolver picks versions for ``[build-system].requires``
9
- using the same indexes / ``exclude-newer`` window as the outer
9
+ using the same indexes / ``uploaded-prior-to`` window as the outer
10
10
  resolve.
11
11
  * ``download_lock`` from :mod:`nab_python.download` fetches the
12
12
  resolved wheels into a temp directory.
@@ -37,6 +37,7 @@ from installer.destinations import SchemeDictionaryDestination
37
37
  from installer.sources import WheelFile
38
38
  from nab_index.urllib3_async_transport import Urllib3AsyncTransport
39
39
 
40
+ from .._vcs_admission import UnsupportedVcsError
40
41
  from ..config import NabProjectConfig
41
42
  from ..download import download_lock
42
43
 
@@ -97,7 +98,7 @@ class NabBuildEnv:
97
98
 
98
99
  ``requires`` is the PEP 508 string list from
99
100
  ``[build-system].requires``. ``config`` carries the indexes,
100
- ``exclude-newer`` window and other nab inputs from the outer
101
+ ``uploaded-prior-to`` window and other nab inputs from the outer
101
102
  resolve; it is pruned (no local sources, no workspace, no
102
103
  marker overlay) before the inner resolve so the build env is
103
104
  computed against PyPI alone.
@@ -269,20 +270,27 @@ class NabBuildEnv:
269
270
 
270
271
  inner_config = NabProjectConfig(
271
272
  indexes=self._config.indexes,
273
+ package_overrides=self._config.package_overrides,
272
274
  index_overrides=self._config.index_overrides,
273
275
  uploaded_prior_to=self._config.uploaded_prior_to,
274
- uploaded_prior_to_overrides=self._config.uploaded_prior_to_overrides,
275
276
  )
276
277
  # download_lock closes its transport, and ``install`` may call
277
278
  # this again for ``get_requires_for_build_wheel`` follow-ups;
278
279
  # build a fresh transport each time.
279
280
  transport = self._transport_factory()
280
- result = resolve_pyproject(
281
- synthetic,
282
- transport,
283
- config=inner_config,
284
- python_version=self._python_version,
285
- )
281
+ try:
282
+ result = resolve_pyproject(
283
+ synthetic,
284
+ transport,
285
+ config=inner_config,
286
+ python_version=self._python_version,
287
+ )
288
+ except (UnsupportedVcsError, NotImplementedError) as exc:
289
+ # A direct-URL/VCS build requirement means nab cannot build this
290
+ # sdist. Report it as a build-env failure so the outer resolve
291
+ # skips the version instead of aborting on the raw error.
292
+ msg = f"build env resolve failed: {exc}"
293
+ raise BuildEnvError(msg) from exc
286
294
 
287
295
  # Reject sdist-only pins early: build deps that ship only an
288
296
  # sdist trigger a recursive backend invocation that this
@@ -33,12 +33,12 @@ import build
33
33
  import pyproject_hooks
34
34
  import tomli
35
35
 
36
- from .._vendor.packaging.requirements import Requirement
36
+ from nab_resolver.resolver import ResolutionError
37
+
38
+ from .._vendor.packaging.requirements import InvalidRequirement, Requirement
37
39
  from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
38
40
  from .._vendor.packaging.utils import canonicalize_name
39
41
  from .._vendor.packaging.version import InvalidVersion, Version
40
- from nab_resolver.resolver import ResolutionError
41
-
42
42
  from ..metadata import WheelMetadata
43
43
  from .env import BuildEnvError, NabBuildEnv
44
44
  from .errors import BuildBackendError
@@ -238,21 +238,25 @@ def _parse_metadata(metadata_path: Path) -> WheelMetadata:
238
238
  SpecifierSet(requires_python_raw) if requires_python_raw else None
239
239
  )
240
240
  except InvalidSpecifier as exc:
241
- msg = f"backend METADATA has invalid Requires-Python {requires_python_raw!r}: {exc}"
241
+ msg = (
242
+ f"backend METADATA has invalid Requires-Python "
243
+ f"{requires_python_raw!r}: {exc}"
244
+ )
242
245
  raise BuildBackendError(msg) from exc
243
246
 
244
- requires_dist: list[Requirement] = []
245
- for raw in msg_obj.get_all("Requires-Dist") or ():
246
- try:
247
- requires_dist.append(Requirement(raw))
248
- except (ValueError, TypeError): # noqa: PERF203
249
- logger.warning("skipping unparseable Requires-Dist: %s", raw)
247
+ try:
248
+ requires_dist: list[Requirement] = [
249
+ Requirement(raw) for raw in msg_obj.get_all("Requires-Dist") or ()
250
+ ]
251
+ except InvalidRequirement as exc:
252
+ msg = f"backend METADATA has an invalid Requires-Dist: {exc}"
253
+ raise BuildBackendError(msg) from exc
250
254
 
251
255
  provides_extra: list[str] = sorted(
252
256
  {
253
- canonicalize_name(extra)
257
+ canonicalize_name(stripped)
254
258
  for extra in msg_obj.get_all("Provides-Extra") or ()
255
- if extra
259
+ if (stripped := extra.strip())
256
260
  }
257
261
  )
258
262
 
@@ -8,6 +8,14 @@ cycle. :class:`nab_python.config.ConflictKind` takes its enum values from
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import re
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+ from ._vendor.packaging.markers import Marker
18
+
11
19
  KIND_EXTRA = "extra"
12
20
  KIND_GROUP = "group"
13
21
 
@@ -18,3 +26,37 @@ MARKER_VARIABLE_FOR_KIND = {
18
26
  KIND_EXTRA: "extras",
19
27
  KIND_GROUP: "dependency_groups",
20
28
  }
29
+
30
+ # ``extras`` and ``dependency_groups`` are PEP 685 / PEP 735 set variables that
31
+ # packaging only defines when consuming a lockfile. At resolve time no
32
+ # conflict-fork member is active as a marker-set member (forks fold their
33
+ # members into requirements, not the environment), so both are empty. Seeding
34
+ # them keeps a dependency marker that tests one from raising a raw KeyError at
35
+ # evaluation; the membership simply tests False and the dep is dropped.
36
+ EMPTY_MEMBERSHIP_SETS: dict[str, frozenset[str]] = {
37
+ variable: frozenset() for variable in MARKER_VARIABLE_FOR_KIND.values()
38
+ }
39
+
40
+ _MEMBERSHIP_SET_PATTERN = re.compile(
41
+ r"\b(" + "|".join(MARKER_VARIABLE_FOR_KIND.values()) + r")\b"
42
+ )
43
+
44
+
45
+ def membership_set_in_marker(marker_text: str) -> str | None:
46
+ """Return the lockfile-only set variable a marker tests, or ``None``.
47
+
48
+ A dependency marker that tests ``extras`` or ``dependency_groups`` is a
49
+ mistake (usually meant as ``extra ==``): those variables are defined only
50
+ when consuming a lockfile, so they are empty at resolve time.
51
+ """
52
+ match = _MEMBERSHIP_SET_PATTERN.search(marker_text)
53
+ return match.group(1) if match else None
54
+
55
+
56
+ def dependency_marker_holds(marker: Marker, environment: Mapping[str, str]) -> bool:
57
+ """Evaluate a dependency marker for a resolve-time ``environment``.
58
+
59
+ Seeds the empty lockfile-only set variables so a marker that tests one
60
+ evaluates cleanly to False instead of raising a raw KeyError.
61
+ """
62
+ return bool(marker.evaluate({**environment, **EMPTY_MEMBERSHIP_SETS}))
@@ -13,7 +13,7 @@ from collections import defaultdict
13
13
  from datetime import datetime, timezone
14
14
  from pathlib import Path
15
15
  from typing import TYPE_CHECKING, Protocol, overload
16
- from urllib.parse import urlsplit, urlunsplit
16
+ from urllib.parse import quote, urlsplit, urlunsplit
17
17
 
18
18
  import tomli
19
19
 
@@ -21,6 +21,7 @@ from nab_index.client import SdistFile, WheelFile
21
21
 
22
22
  from .._toml import tool_nab_section
23
23
  from .._vendor.packaging.pylock import Pylock, PylockValidationError
24
+ from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
24
25
  from .._vendor.packaging.utils import canonicalize_name
25
26
 
26
27
  if TYPE_CHECKING:
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
30
31
 
31
32
  from .._vendor.packaging.version import Version
32
33
  from ..lockfile import (
34
+ ArchivePin,
33
35
  IndexPin,
34
36
  LockInput,
35
37
  PinShape,
@@ -37,7 +39,7 @@ if TYPE_CHECKING:
37
39
  VcsPin,
38
40
  WheelArtifact,
39
41
  )
40
- from ..provider import DistPolicy, LocalSource, VcsSource
42
+ from ..provider import ArchiveSource, DistPolicy, LocalSource, VcsSource
41
43
 
42
44
 
43
45
  __all__ = [
@@ -47,6 +49,7 @@ __all__ = [
47
49
  "build_lock_input_from_provider",
48
50
  "read_lockfile_anchor",
49
51
  "read_lockfile_packages",
52
+ "require_artifact_hashes",
50
53
  ]
51
54
 
52
55
 
@@ -94,6 +97,10 @@ class LockInputProvider(Protocol):
94
97
  """Return the configured VcsSource for ``canonical_name`` or None."""
95
98
  ...
96
99
 
100
+ def archive_source_for(self, canonical_name: str, /) -> ArchiveSource | None:
101
+ """Return the configured ArchiveSource for ``canonical_name`` or None."""
102
+ ...
103
+
97
104
  def vcs_pin_for(self, canonical_name: str, /) -> str | None:
98
105
  """Return the resolved 40-char SHA captured during materialisation."""
99
106
  ...
@@ -104,8 +111,16 @@ class LockInputProvider(Protocol):
104
111
  """Return the listing slice that matches ``(canonical_name, version)``."""
105
112
  ...
106
113
 
107
- def effective_dist_policy(self, canonical_name: str, /) -> DistPolicy:
108
- """Return the effective :class:`DistPolicy` for ``canonical_name``."""
114
+ def effective_dist_policy(
115
+ self, canonical_name: str, version: Version, index_name: str | None = None, /
116
+ ) -> DistPolicy:
117
+ """Return the effective :class:`DistPolicy` for ``canonical_name==version``."""
118
+ ...
119
+
120
+ def effective_requires_python(
121
+ self, canonical_name: str, version: Version, /
122
+ ) -> str | None:
123
+ """Return the ``requires-python`` override for ``canonical_name==version``."""
109
124
  ...
110
125
 
111
126
 
@@ -205,17 +220,23 @@ def read_lockfile_packages(path: Path) -> dict[str, Version] | None:
205
220
 
206
221
 
207
222
  def _strip_userinfo(url: str) -> str:
208
- """Return ``url`` with any ``user:password@`` userinfo removed.
209
-
210
- Lockfiles are committed to version control, so an index or VCS
211
- URL carrying embedded credentials must not be written verbatim.
212
- Only the userinfo is dropped: host case and port are preserved
213
- and a ``git+`` scheme prefix is left intact. A no-op for URLs
214
- without credentials.
223
+ """Return ``url`` with credential userinfo removed.
224
+
225
+ Lockfiles are committed to version control, so an index or VCS URL
226
+ carrying an embedded ``user:password`` must not be written verbatim.
227
+ An SSH login such as ``git@`` is the protocol login, not a secret,
228
+ and is required to clone, so it is kept (only an embedded password is
229
+ dropped); host case and port are preserved. A no-op for URLs without
230
+ userinfo.
215
231
  """
216
232
  parts = urlsplit(url)
217
- netloc = parts.netloc.rpartition("@")[2]
218
- return urlunsplit(parts._replace(netloc=netloc))
233
+ userinfo, sep, host = parts.netloc.rpartition("@")
234
+ if not sep:
235
+ return url
236
+ if parts.scheme.endswith("ssh"):
237
+ login = userinfo.split(":", 1)[0]
238
+ host = f"{login}@{host}"
239
+ return urlunsplit(parts._replace(netloc=host))
219
240
 
220
241
 
221
242
  def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a distinct lockfile field
@@ -272,6 +293,12 @@ def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a disti
272
293
  resolved_sha=provider.vcs_pin_for(canonical),
273
294
  )
274
295
  continue
296
+ archive_source = provider.archive_source_for(canonical)
297
+ if archive_source is not None:
298
+ lock_pins[canonical] = _archive_pin_from_source(
299
+ canonical, version, archive_source
300
+ )
301
+ continue
275
302
  lock_pins[canonical] = _index_pin_from_listing(
276
303
  provider, canonical, version, indexes
277
304
  )
@@ -324,6 +351,9 @@ def _forward_dependency_graph(
324
351
  for dep in extra_map.get(extra, {})
325
352
  )
326
353
  dep_names &= pinned
354
+ # An umbrella extra (pkg[all] pulling pkg[graphviz]) can name its own
355
+ # package; drop it so pkg is never an edge to itself.
356
+ dep_names.discard(canonical)
327
357
  if dep_names:
328
358
  graph[canonical] = tuple(sorted(dep_names))
329
359
  return graph
@@ -348,13 +378,23 @@ def _index_pin_from_listing(
348
378
  package's wheels stayed in ``versions_cache`` as a possible
349
379
  metadata source for the resolver; only the sdist is emitted
350
380
  into the lock so installers download and build that archive.
381
+
382
+ A ``requires-python`` metadata override takes precedence over the
383
+ Simple-API value so the pin records the specifier the resolver
384
+ actually admitted against; a conforming :pep:`751` installer would
385
+ otherwise reject a widened pin whose lock still carried the narrow
386
+ artefact value.
351
387
  """
352
388
  from ..fetch import DEFAULT_INDEX_URL
353
389
  from ..lockfile import IndexPin, SdistArtifact, WheelArtifact
354
390
  from ..provider import DistPolicy
355
391
 
356
392
  files = list(provider.dist_files_for(canonical, version))
357
- if provider.effective_dist_policy(canonical) is DistPolicy.SDIST_INSTALL:
393
+ serving = provider.coordinator.index.get_listing_index(canonical)
394
+ if (
395
+ provider.effective_dist_policy(canonical, version, serving)
396
+ is DistPolicy.SDIST_INSTALL
397
+ ):
358
398
  files = [f for f in files if not isinstance(f, WheelFile)]
359
399
  if not any(isinstance(f, SdistFile) for f in files):
360
400
  msg = (
@@ -365,17 +405,18 @@ def _index_pin_from_listing(
365
405
  raise MissingSdistError(msg)
366
406
 
367
407
  wheels = tuple(
368
- _build_artifact(canonical, f, WheelArtifact)
369
- for f in files
370
- if isinstance(f, WheelFile)
408
+ _build_artifact(f, WheelArtifact) for f in files if isinstance(f, WheelFile)
371
409
  )
372
410
  sdist_file = next((f for f in files if isinstance(f, SdistFile)), None)
373
411
  sdist = (
374
- _build_artifact(canonical, sdist_file, SdistArtifact)
375
- if sdist_file is not None
376
- else None
412
+ _build_artifact(sdist_file, SdistArtifact) if sdist_file is not None else None
413
+ )
414
+
415
+ override_rp = provider.effective_requires_python(canonical, version)
416
+ requires_python = (
417
+ override_rp if override_rp is not None else _common_requires_python(files)
377
418
  )
378
- requires_python = _common_requires_python(files)
419
+
379
420
  serving_name = provider.coordinator.index.get_listing_index(canonical)
380
421
  by_name = {ix.name: ix.url for ix in indexes}
381
422
  index_url = by_name.get(serving_name) if serving_name is not None else None
@@ -402,25 +443,22 @@ def _index_pin_from_listing(
402
443
 
403
444
  @overload
404
445
  def _build_artifact(
405
- canonical: str,
406
446
  source: WheelFile | SdistFile,
407
447
  cls: type[WheelArtifact],
408
448
  ) -> WheelArtifact: ...
409
449
  @overload
410
450
  def _build_artifact(
411
- canonical: str,
412
451
  source: WheelFile | SdistFile,
413
452
  cls: type[SdistArtifact],
414
453
  ) -> SdistArtifact: ...
415
454
  def _build_artifact(
416
- canonical: str,
417
455
  source: WheelFile | SdistFile,
418
456
  cls: type[WheelArtifact | SdistArtifact],
419
457
  ) -> WheelArtifact | SdistArtifact:
420
- hashes = _filter_acceptable_hashes(canonical, source.filename, source.hashes)
458
+ hashes = _filter_acceptable_hashes(source.hashes)
421
459
  return cls(
422
460
  filename=source.filename,
423
- url=source.url,
461
+ url=_strip_userinfo(source.url),
424
462
  hashes=hashes,
425
463
  size=source.size,
426
464
  upload_time=_parse_upload_time(source.upload_time),
@@ -449,38 +487,74 @@ def _parse_upload_time(raw: str | None) -> datetime | None:
449
487
 
450
488
 
451
489
  def _filter_acceptable_hashes(
452
- canonical: str, filename: str, hashes: tuple[tuple[str, str], ...]
490
+ hashes: tuple[tuple[str, str], ...],
453
491
  ) -> tuple[tuple[str, str], ...]:
454
492
  """Return the subset of ``hashes`` whose algorithm is consumable.
455
493
 
456
494
  Pip's hash-checking mode and PEP 751 both accept any of sha256,
457
495
  sha384, or sha512; nab forwards every recorded entry so consumers
458
- can pick. Raise :class:`MissingHashError` when none of the
459
- acceptable algorithms are present.
496
+ can pick. Unacceptable algorithms (e.g. md5) are dropped, so the
497
+ result may be empty.
460
498
  """
461
499
  from ..lockfile import ACCEPTED_HASH_ALGORITHMS
462
500
 
463
- accepted = tuple(
501
+ return tuple(
464
502
  (algo, digest)
465
503
  for algo, digest in sorted(hashes)
466
504
  if algo in ACCEPTED_HASH_ALGORITHMS
467
505
  )
468
- if not accepted:
469
- algos = sorted({algo for algo, _ in hashes})
470
- msg = (
471
- f"{canonical}: artefact {filename!r} has no acceptable hash"
472
- f" (need one of {list(ACCEPTED_HASH_ALGORITHMS)!r}, got {algos!r})"
473
- )
474
- raise MissingHashError(msg)
475
- return accepted
506
+
507
+
508
+ def require_artifact_hashes(lock_input: LockInput) -> None:
509
+ """Raise :class:`MissingHashError` if a pinned artefact has no hash.
510
+
511
+ PEP 751 and pip's hash-checking mode each need at least one of
512
+ sha256/sha384/sha512 per artefact. The plain ``name==version``
513
+ writer records no hash and does not call this.
514
+ """
515
+ from ..lockfile import ACCEPTED_HASH_ALGORITHMS, IndexPin
516
+
517
+ pin_groups: Iterable[Mapping[str, PinShape]] = (
518
+ lock_input.pins,
519
+ *lock_input.per_tuple_pins.values(),
520
+ )
521
+ for pins in pin_groups:
522
+ for pin in pins.values():
523
+ if not isinstance(pin, IndexPin):
524
+ continue
525
+ artefacts = (*pin.wheels, *((pin.sdist,) if pin.sdist is not None else ()))
526
+ for artefact in artefacts:
527
+ if not artefact.hashes:
528
+ msg = (
529
+ f"{pin.name}: artefact {artefact.filename!r} has no "
530
+ f"acceptable hash (need one of "
531
+ f"{list(ACCEPTED_HASH_ALGORITHMS)!r})"
532
+ )
533
+ raise MissingHashError(msg)
476
534
 
477
535
 
478
536
  def _common_requires_python(files: Iterable[WheelFile | SdistFile]) -> str | None:
479
- """Return a single Requires-Python value if all files agree, else ``None``."""
537
+ """Return the package-level Requires-Python value, or ``None``.
538
+
539
+ An artefact with no Requires-Python is unconstrained, so a single
540
+ such artefact leaves the whole package unconstrained. Otherwise the
541
+ value survives only when every artefact agrees.
542
+
543
+ A malformed value counts as unconstrained too, matching
544
+ ``excluded_by_python``, which admits a dist whose Requires-Python is
545
+ an unparseable specifier on any Python. So the lock writer always
546
+ parses a valid specifier or ``None``, and the pin is never
547
+ over-constrained by an artefact whose floor nab could not read.
548
+ """
480
549
  seen: set[str] = set()
481
550
  for f in files:
482
- if f.requires_python is not None:
483
- seen.add(f.requires_python)
551
+ if f.requires_python is None:
552
+ return None
553
+ try:
554
+ SpecifierSet(f.requires_python)
555
+ except InvalidSpecifier:
556
+ return None
557
+ seen.add(f.requires_python)
484
558
  if len(seen) == 1:
485
559
  return next(iter(seen))
486
560
  return None
@@ -538,7 +612,7 @@ def _vcs_pin_from_source(
538
612
  bare_repo_url = _strip_userinfo(parsed.repo_url)
539
613
  repo_url = f"{parsed.scheme}+{bare_repo_url}@{resolved_sha}"
540
614
  if parsed.subdirectory:
541
- repo_url += f"#subdirectory={parsed.subdirectory}"
615
+ repo_url += f"#subdirectory={quote(parsed.subdirectory, safe='/')}"
542
616
 
543
617
  return VcsPin(
544
618
  name=canonical,
@@ -550,3 +624,30 @@ def _vcs_pin_from_source(
550
624
  requested_revision=requested_revision,
551
625
  vcs_type=parsed.scheme,
552
626
  )
627
+
628
+
629
+ def _archive_pin_from_source(
630
+ canonical: str,
631
+ version: Version,
632
+ source: ArchiveSource,
633
+ ) -> ArchivePin:
634
+ """Build an :class:`ArchivePin` from an :class:`ArchiveSource`.
635
+
636
+ The URL, hashes, and subdirectory come from the source declaration,
637
+ which config parse validated (a hash is required), so the pin records
638
+ the exact archive the resolve used. The URL is stripped of any
639
+ credential userinfo, like every other pin, so a committed lockfile
640
+ never carries a token.
641
+ """
642
+ from nab_index.archive import ArchiveRequest
643
+
644
+ from ..lockfile import ArchivePin
645
+
646
+ request = ArchiveRequest.parse(source.url)
647
+ return ArchivePin(
648
+ name=canonical,
649
+ version=str(version),
650
+ url=_strip_userinfo(request.url),
651
+ hashes=request.hashes,
652
+ subdirectory=request.subdirectory or None,
653
+ )
@@ -99,8 +99,8 @@ def validate_marker_disjointness(
99
99
 
100
100
  ``declared_groups`` carries every conflict set regardless of policy
101
101
  so the hint can distinguish an undeclared collision (suggest adding
102
- a declaration) from one already declared under ``at_least_one``
103
- (suggest tightening to ``at_most_one`` or ``exactly_one``).
102
+ a declaration) from one already declared under ``at-least-one``
103
+ (suggest tightening to ``at-most-one`` or ``exactly-one``).
104
104
  """
105
105
  if not environments:
106
106
  return
@@ -265,7 +265,7 @@ def _conflict_hint(
265
265
  because a conflict declaration would not help.
266
266
 
267
267
  When ``declared_groups`` already covers the active members, the
268
- hint instead suggests tightening the policy: an ``at_least_one``
268
+ hint instead suggests tightening the policy: an ``at-least-one``
269
269
  declaration permits co-selection, so the validator still raises and
270
270
  the user has to switch to an exclusive policy to prune the point.
271
271
  """
@@ -288,8 +288,8 @@ def _conflict_hint(
288
288
  if already_declared:
289
289
  return (
290
290
  ". These members are declared in [tool.nab].conflicts under a"
291
- " policy that permits co-selection; switch to at_most_one or"
292
- " exactly_one to prune the colliding context"
291
+ " policy that permits co-selection; switch to at-most-one or"
292
+ " exactly-one to prune the colliding context"
293
293
  )
294
294
  return (
295
295
  ". If these are intentionally mutually exclusive, declare them in"
@@ -21,6 +21,7 @@ import tomli_w
21
21
  from .._vendor.packaging.markers import Marker
22
22
  from .._vendor.packaging.pylock import (
23
23
  Package,
24
+ PackageArchive,
24
25
  PackageDirectory,
25
26
  PackageSdist,
26
27
  PackageVcs,
@@ -31,6 +32,7 @@ from .._vendor.packaging.specifiers import SpecifierSet
31
32
  from .._vendor.packaging.utils import canonicalize_name
32
33
  from .._vendor.packaging.version import Version
33
34
  from ..config import conflict_exclusion_groups, conflict_member_groups
35
+ from .builder import require_artifact_hashes
34
36
  from .disjointness import validate_marker_disjointness
35
37
 
36
38
  if TYPE_CHECKING:
@@ -82,14 +84,26 @@ def write_lock(
82
84
  With no ``output_path`` the current directory is the base.
83
85
  """
84
86
  lock_dir = Path(output_path).parent if output_path is not None else None
85
- pylock = build_pylock(lock_input, lock_dir=lock_dir)
86
- pylock.validate()
87
- text = tomli_w.dumps(dict(pylock.to_dict()))
87
+ text = render_lock(lock_input, lock_dir=lock_dir)
88
88
  if output_path is not None:
89
89
  Path(output_path).write_text(text, encoding="utf-8")
90
90
  return text
91
91
 
92
92
 
93
+ def render_lock(lock_input: LockInput, *, lock_dir: Path | None = None) -> str:
94
+ """Serialise ``lock_input`` to PEP 751 TOML text without writing a file.
95
+
96
+ ``lock_dir`` is the directory the lock will live in; directory, wheel
97
+ and sdist paths are emitted relative to it so the lock stays portable.
98
+ Defaults to the current directory. Used by ``write_lock`` and by
99
+ ``nab lock --locked`` to render the would-be lock for comparison.
100
+ """
101
+ require_artifact_hashes(lock_input)
102
+ pylock = build_pylock(lock_input, lock_dir=lock_dir)
103
+ pylock.validate()
104
+ return tomli_w.dumps(dict(pylock.to_dict()))
105
+
106
+
93
107
  def build_pylock(lock_input: LockInput, *, lock_dir: Path | None = None) -> Pylock:
94
108
  """Build a :class:`Pylock` from the input shape.
95
109
 
@@ -201,7 +215,7 @@ def _pin_to_package(
201
215
  lock_dir: Path,
202
216
  dependencies: list[dict[str, str]] | None = None,
203
217
  ) -> Package:
204
- from ..lockfile import IndexPin, LocalPin, VcsPin
218
+ from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
205
219
 
206
220
  if isinstance(pin, IndexPin):
207
221
  return Package(
@@ -254,6 +268,20 @@ def _pin_to_package(
254
268
  requested_revision=pin.requested_revision,
255
269
  ),
256
270
  )
271
+ if isinstance(pin, ArchivePin):
272
+ # An archive is content-pinned by its hash, so the version is
273
+ # stable and recorded (unlike the directory/VCS cases above).
274
+ return Package(
275
+ name=canonicalize_name(pin.name),
276
+ version=Version(pin.version),
277
+ marker=marker,
278
+ dependencies=dependencies,
279
+ archive=PackageArchive(
280
+ url=pin.url,
281
+ hashes=dict(pin.hashes),
282
+ subdirectory=pin.subdirectory,
283
+ ),
284
+ )
257
285
  msg = f"unknown pin shape: {pin!r}"
258
286
  raise TypeError(msg)
259
287
 
@@ -410,7 +438,7 @@ def _group_pins_by_pin(
410
438
 
411
439
  def _pin_discriminator(pin: PinShape) -> tuple:
412
440
  """Return a hashable key that identifies the source + version of ``pin``."""
413
- from ..lockfile import IndexPin, LocalPin, VcsPin
441
+ from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
414
442
 
415
443
  if isinstance(pin, IndexPin):
416
444
  return ("index", pin.version, pin.index)
@@ -434,6 +462,8 @@ def _pin_discriminator(pin: PinShape) -> tuple:
434
462
  pin.repo_url,
435
463
  pin.subdirectory or "",
436
464
  )
465
+ if isinstance(pin, ArchivePin):
466
+ return ("archive", pin.version, pin.url, pin.hashes, pin.subdirectory or "")
437
467
  msg = f"unknown pin shape: {pin!r}"
438
468
  raise TypeError(msg)
439
469
 
@@ -443,8 +473,9 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
443
473
 
444
474
  For :class:`IndexPin`, accumulates every distinct wheel filename
445
475
  across the contributing tuples and keeps the first non-``None``
446
- sdist. ``requires_python`` survives only when every tuple agreed,
447
- matching :func:`_common_requires_python`'s rule.
476
+ sdist. ``requires_python`` survives only when every tuple carried
477
+ the same value and none was unconstrained, matching
478
+ :func:`_common_requires_python`'s rule.
448
479
  Non-IndexPin shapes are already fully discriminated, so the first
449
480
  pin is returned unchanged.
450
481
  """
@@ -456,16 +487,21 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
456
487
  seen_wheels: dict[str, WheelArtifact] = {}
457
488
  sdist = head.sdist
458
489
  requires_python_set: set[str] = set()
490
+ any_unconstrained = False
459
491
  for pin in pins:
460
492
  assert isinstance(pin, IndexPin)
461
493
  for wheel in pin.wheels:
462
494
  seen_wheels.setdefault(wheel.filename, wheel)
463
495
  if sdist is None and pin.sdist is not None:
464
496
  sdist = pin.sdist
465
- if pin.requires_python is not None:
497
+ if pin.requires_python is None:
498
+ any_unconstrained = True
499
+ else:
466
500
  requires_python_set.add(pin.requires_python)
467
501
  requires_python = (
468
- next(iter(requires_python_set)) if len(requires_python_set) == 1 else None
502
+ next(iter(requires_python_set))
503
+ if not any_unconstrained and len(requires_python_set) == 1
504
+ else None
469
505
  )
470
506
  return IndexPin(
471
507
  name=head.name,
@@ -539,7 +575,7 @@ def _build_marker(
539
575
  ``tuple_markers`` means the caller has not declared a tuple
540
576
  universe and the marker is omitted.
541
577
 
542
- A dep required by every member of an ``at_most_one`` set but not
578
+ A dep required by every member of an ``at-most-one`` set but not
543
579
  by the base is absent from ``env_base_names``, so it keeps the
544
580
  membership OR and does not install when no member is selected.
545
581
  An environment with no base-name set (no conflict fork ran) leaves