nab-python 0.0.4__py3-none-any.whl → 0.0.6__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 +2 -2
  3. nab_python/_conflict_kind.py +42 -0
  4. nab_python/_lockfile/builder.py +81 -38
  5. nab_python/_lockfile/disjointness.py +5 -5
  6. nab_python/_lockfile/pylock.py +83 -5
  7. nab_python/_lockfile/requirements.py +17 -5
  8. nab_python/_provider/build_remote.py +39 -4
  9. nab_python/_provider/extras.py +38 -23
  10. nab_python/_provider/listing.py +189 -60
  11. nab_python/_provider/metadata_resolver.py +126 -36
  12. nab_python/_provider/priority.py +3 -1
  13. nab_python/_provider/sources.py +3 -5
  14. nab_python/_testing/coordinator_fake.py +6 -1
  15. nab_python/_testing/overrides.py +18 -0
  16. nab_python/_vcs_admission.py +37 -17
  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 +29 -6
  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 +11 -3
  35. nab_python/config.py +1142 -385
  36. nab_python/config_sources.py +1799 -0
  37. nab_python/download.py +45 -14
  38. nab_python/fetch.py +185 -78
  39. nab_python/lockfile.py +60 -5
  40. nab_python/metadata.py +29 -6
  41. nab_python/provider.py +469 -93
  42. nab_python/requirements_file.py +143 -18
  43. nab_python/resolve.py +168 -58
  44. nab_python/universal/matrix.py +11 -6
  45. nab_python/universal/provider.py +7 -8
  46. nab_python/universal/reresolve.py +38 -22
  47. nab_python/universal/resolve.py +62 -50
  48. nab_python/universal/validate.py +140 -29
  49. nab_python/universal/wheel_selection.py +89 -21
  50. nab_python/workspace.py +8 -1
  51. {nab_python-0.0.4.dist-info → nab_python-0.0.6.dist-info}/METADATA +3 -3
  52. nab_python-0.0.6.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.4.dist-info/RECORD +0 -74
  56. {nab_python-0.0.4.dist-info → nab_python-0.0.6.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
@@ -250,9 +250,9 @@ def _parse_metadata(metadata_path: Path) -> WheelMetadata:
250
250
 
251
251
  provides_extra: list[str] = sorted(
252
252
  {
253
- canonicalize_name(extra)
253
+ canonicalize_name(stripped)
254
254
  for extra in msg_obj.get_all("Provides-Extra") or ()
255
- if extra
255
+ if (stripped := extra.strip())
256
256
  }
257
257
  )
258
258
 
@@ -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
 
@@ -47,6 +47,7 @@ __all__ = [
47
47
  "build_lock_input_from_provider",
48
48
  "read_lockfile_anchor",
49
49
  "read_lockfile_packages",
50
+ "require_artifact_hashes",
50
51
  ]
51
52
 
52
53
 
@@ -104,8 +105,16 @@ class LockInputProvider(Protocol):
104
105
  """Return the listing slice that matches ``(canonical_name, version)``."""
105
106
  ...
106
107
 
107
- def effective_dist_policy(self, canonical_name: str, /) -> DistPolicy:
108
- """Return the effective :class:`DistPolicy` for ``canonical_name``."""
108
+ def effective_dist_policy(
109
+ self, canonical_name: str, version: Version, index_name: str | None = None, /
110
+ ) -> DistPolicy:
111
+ """Return the effective :class:`DistPolicy` for ``canonical_name==version``."""
112
+ ...
113
+
114
+ def effective_requires_python(
115
+ self, canonical_name: str, version: Version, /
116
+ ) -> str | None:
117
+ """Return the ``requires-python`` override for ``canonical_name==version``."""
109
118
  ...
110
119
 
111
120
 
@@ -205,17 +214,23 @@ def read_lockfile_packages(path: Path) -> dict[str, Version] | None:
205
214
 
206
215
 
207
216
  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.
217
+ """Return ``url`` with credential userinfo removed.
218
+
219
+ Lockfiles are committed to version control, so an index or VCS URL
220
+ carrying an embedded ``user:password`` must not be written verbatim.
221
+ An SSH login such as ``git@`` is the protocol login, not a secret,
222
+ and is required to clone, so it is kept (only an embedded password is
223
+ dropped); host case and port are preserved. A no-op for URLs without
224
+ userinfo.
215
225
  """
216
226
  parts = urlsplit(url)
217
- netloc = parts.netloc.rpartition("@")[2]
218
- return urlunsplit(parts._replace(netloc=netloc))
227
+ userinfo, sep, host = parts.netloc.rpartition("@")
228
+ if not sep:
229
+ return url
230
+ if parts.scheme.endswith("ssh"):
231
+ login = userinfo.split(":", 1)[0]
232
+ host = f"{login}@{host}"
233
+ return urlunsplit(parts._replace(netloc=host))
219
234
 
220
235
 
221
236
  def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a distinct lockfile field
@@ -348,13 +363,23 @@ def _index_pin_from_listing(
348
363
  package's wheels stayed in ``versions_cache`` as a possible
349
364
  metadata source for the resolver; only the sdist is emitted
350
365
  into the lock so installers download and build that archive.
366
+
367
+ A ``requires-python`` metadata override takes precedence over the
368
+ Simple-API value so the pin records the specifier the resolver
369
+ actually admitted against; a conforming :pep:`751` installer would
370
+ otherwise reject a widened pin whose lock still carried the narrow
371
+ artefact value.
351
372
  """
352
373
  from ..fetch import DEFAULT_INDEX_URL
353
374
  from ..lockfile import IndexPin, SdistArtifact, WheelArtifact
354
375
  from ..provider import DistPolicy
355
376
 
356
377
  files = list(provider.dist_files_for(canonical, version))
357
- if provider.effective_dist_policy(canonical) is DistPolicy.SDIST_INSTALL:
378
+ serving = provider.coordinator.index.get_listing_index(canonical)
379
+ if (
380
+ provider.effective_dist_policy(canonical, version, serving)
381
+ is DistPolicy.SDIST_INSTALL
382
+ ):
358
383
  files = [f for f in files if not isinstance(f, WheelFile)]
359
384
  if not any(isinstance(f, SdistFile) for f in files):
360
385
  msg = (
@@ -365,17 +390,18 @@ def _index_pin_from_listing(
365
390
  raise MissingSdistError(msg)
366
391
 
367
392
  wheels = tuple(
368
- _build_artifact(canonical, f, WheelArtifact)
369
- for f in files
370
- if isinstance(f, WheelFile)
393
+ _build_artifact(f, WheelArtifact) for f in files if isinstance(f, WheelFile)
371
394
  )
372
395
  sdist_file = next((f for f in files if isinstance(f, SdistFile)), None)
373
396
  sdist = (
374
- _build_artifact(canonical, sdist_file, SdistArtifact)
375
- if sdist_file is not None
376
- else None
397
+ _build_artifact(sdist_file, SdistArtifact) if sdist_file is not None else None
377
398
  )
378
- requires_python = _common_requires_python(files)
399
+
400
+ override_rp = provider.effective_requires_python(canonical, version)
401
+ requires_python = (
402
+ override_rp if override_rp is not None else _common_requires_python(files)
403
+ )
404
+
379
405
  serving_name = provider.coordinator.index.get_listing_index(canonical)
380
406
  by_name = {ix.name: ix.url for ix in indexes}
381
407
  index_url = by_name.get(serving_name) if serving_name is not None else None
@@ -402,25 +428,22 @@ def _index_pin_from_listing(
402
428
 
403
429
  @overload
404
430
  def _build_artifact(
405
- canonical: str,
406
431
  source: WheelFile | SdistFile,
407
432
  cls: type[WheelArtifact],
408
433
  ) -> WheelArtifact: ...
409
434
  @overload
410
435
  def _build_artifact(
411
- canonical: str,
412
436
  source: WheelFile | SdistFile,
413
437
  cls: type[SdistArtifact],
414
438
  ) -> SdistArtifact: ...
415
439
  def _build_artifact(
416
- canonical: str,
417
440
  source: WheelFile | SdistFile,
418
441
  cls: type[WheelArtifact | SdistArtifact],
419
442
  ) -> WheelArtifact | SdistArtifact:
420
- hashes = _filter_acceptable_hashes(canonical, source.filename, source.hashes)
443
+ hashes = _filter_acceptable_hashes(source.hashes)
421
444
  return cls(
422
445
  filename=source.filename,
423
- url=source.url,
446
+ url=_strip_userinfo(source.url),
424
447
  hashes=hashes,
425
448
  size=source.size,
426
449
  upload_time=_parse_upload_time(source.upload_time),
@@ -449,30 +472,50 @@ def _parse_upload_time(raw: str | None) -> datetime | None:
449
472
 
450
473
 
451
474
  def _filter_acceptable_hashes(
452
- canonical: str, filename: str, hashes: tuple[tuple[str, str], ...]
475
+ hashes: tuple[tuple[str, str], ...],
453
476
  ) -> tuple[tuple[str, str], ...]:
454
477
  """Return the subset of ``hashes`` whose algorithm is consumable.
455
478
 
456
479
  Pip's hash-checking mode and PEP 751 both accept any of sha256,
457
480
  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.
481
+ can pick. Unacceptable algorithms (e.g. md5) are dropped, so the
482
+ result may be empty.
460
483
  """
461
484
  from ..lockfile import ACCEPTED_HASH_ALGORITHMS
462
485
 
463
- accepted = tuple(
486
+ return tuple(
464
487
  (algo, digest)
465
488
  for algo, digest in sorted(hashes)
466
489
  if algo in ACCEPTED_HASH_ALGORITHMS
467
490
  )
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
491
+
492
+
493
+ def require_artifact_hashes(lock_input: LockInput) -> None:
494
+ """Raise :class:`MissingHashError` if a pinned artefact has no hash.
495
+
496
+ PEP 751 and pip's hash-checking mode each need at least one of
497
+ sha256/sha384/sha512 per artefact. The plain ``name==version``
498
+ writer records no hash and does not call this.
499
+ """
500
+ from ..lockfile import ACCEPTED_HASH_ALGORITHMS, IndexPin
501
+
502
+ pin_groups: Iterable[Mapping[str, PinShape]] = (
503
+ lock_input.pins,
504
+ *lock_input.per_tuple_pins.values(),
505
+ )
506
+ for pins in pin_groups:
507
+ for pin in pins.values():
508
+ if not isinstance(pin, IndexPin):
509
+ continue
510
+ artefacts = (*pin.wheels, *((pin.sdist,) if pin.sdist is not None else ()))
511
+ for artefact in artefacts:
512
+ if not artefact.hashes:
513
+ msg = (
514
+ f"{pin.name}: artefact {artefact.filename!r} has no "
515
+ f"acceptable hash (need one of "
516
+ f"{list(ACCEPTED_HASH_ALGORITHMS)!r})"
517
+ )
518
+ raise MissingHashError(msg)
476
519
 
477
520
 
478
521
  def _common_requires_python(files: Iterable[WheelFile | SdistFile]) -> str | None:
@@ -538,7 +581,7 @@ def _vcs_pin_from_source(
538
581
  bare_repo_url = _strip_userinfo(parsed.repo_url)
539
582
  repo_url = f"{parsed.scheme}+{bare_repo_url}@{resolved_sha}"
540
583
  if parsed.subdirectory:
541
- repo_url += f"#subdirectory={parsed.subdirectory}"
584
+ repo_url += f"#subdirectory={quote(parsed.subdirectory, safe='/')}"
542
585
 
543
586
  return VcsPin(
544
587
  name=canonical,
@@ -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"
@@ -31,6 +31,7 @@ from .._vendor.packaging.specifiers import SpecifierSet
31
31
  from .._vendor.packaging.utils import canonicalize_name
32
32
  from .._vendor.packaging.version import Version
33
33
  from ..config import conflict_exclusion_groups, conflict_member_groups
34
+ from .builder import require_artifact_hashes
34
35
  from .disjointness import validate_marker_disjointness
35
36
 
36
37
  if TYPE_CHECKING:
@@ -45,11 +46,26 @@ if TYPE_CHECKING:
45
46
 
46
47
 
47
48
  __all__ = [
49
+ "DivergentBaseDependencyError",
48
50
  "build_pylock",
49
51
  "write_lock",
50
52
  ]
51
53
 
52
54
 
55
+ class DivergentBaseDependencyError(ValueError):
56
+ """An environment's conflict forks disagree on a base dependency's pin.
57
+
58
+ A base dependency present in every fork of an environment drops its
59
+ membership clause so it installs even when no conflicting member is
60
+ selected, which requires the forks to agree on one (version, source).
61
+ When they diverge, every candidate entry keeps a membership clause,
62
+ so nothing would fire in the no-member install context and the
63
+ dependency would silently not install. Surface the divergence with
64
+ the offending package and per-fork pins so the producer can
65
+ reconcile the forks rather than commit an incomplete lock.
66
+ """
67
+
68
+
53
69
  def write_lock(
54
70
  lock_input: LockInput,
55
71
  *,
@@ -67,14 +83,26 @@ def write_lock(
67
83
  With no ``output_path`` the current directory is the base.
68
84
  """
69
85
  lock_dir = Path(output_path).parent if output_path is not None else None
70
- pylock = build_pylock(lock_input, lock_dir=lock_dir)
71
- pylock.validate()
72
- text = tomli_w.dumps(dict(pylock.to_dict()))
86
+ text = render_lock(lock_input, lock_dir=lock_dir)
73
87
  if output_path is not None:
74
88
  Path(output_path).write_text(text, encoding="utf-8")
75
89
  return text
76
90
 
77
91
 
92
+ def render_lock(lock_input: LockInput, *, lock_dir: Path | None = None) -> str:
93
+ """Serialise ``lock_input`` to PEP 751 TOML text without writing a file.
94
+
95
+ ``lock_dir`` is the directory the lock will live in; directory, wheel
96
+ and sdist paths are emitted relative to it so the lock stays portable.
97
+ Defaults to the current directory. Used by ``write_lock`` and by
98
+ ``nab lock --locked`` to render the would-be lock for comparison.
99
+ """
100
+ require_artifact_hashes(lock_input)
101
+ pylock = build_pylock(lock_input, lock_dir=lock_dir)
102
+ pylock.validate()
103
+ return tomli_w.dumps(dict(pylock.to_dict()))
104
+
105
+
78
106
  def build_pylock(lock_input: LockInput, *, lock_dir: Path | None = None) -> Pylock:
79
107
  """Build a :class:`Pylock` from the input shape.
80
108
 
@@ -317,7 +345,9 @@ def _build_per_tuple_packages(lock_input: LockInput, lock_dir: Path) -> list[Pac
317
345
  not by the base is absent from that set, so it keeps the
318
346
  membership clause and does not install when no member is selected.
319
347
  See :class:`LockInput.env_base_names` for the missing-signature
320
- contract.
348
+ contract. Forks of one environment that disagree on a base
349
+ dependency's pin raise :class:`DivergentBaseDependencyError`
350
+ instead of emitting a lock whose no-member context misses it.
321
351
  """
322
352
  out: list[Package] = []
323
353
  by_name = _group_by_name(lock_input.per_tuple_pins)
@@ -330,6 +360,14 @@ def _build_per_tuple_packages(lock_input: LockInput, lock_dir: Path) -> list[Pac
330
360
  )
331
361
  for canonical_name, per_tuple in by_name.items():
332
362
  groups = _group_pins_by_pin(per_tuple)
363
+ _check_base_fork_agreement(
364
+ canonical_name,
365
+ per_tuple,
366
+ groups,
367
+ env_signatures,
368
+ env_fork_counts,
369
+ lock_input.env_base_names,
370
+ )
333
371
  for pins, tuple_labels in groups:
334
372
  marker = _build_marker(
335
373
  canonical_name,
@@ -452,6 +490,46 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
452
490
  )
453
491
 
454
492
 
493
+ def _check_base_fork_agreement(
494
+ name: str,
495
+ per_tuple: Mapping[str, PinShape],
496
+ groups: list[tuple[list[PinShape], list[str]]],
497
+ env_signatures: Mapping[str, tuple[tuple[str, str], ...]],
498
+ env_fork_counts: Mapping[tuple[tuple[str, str], ...], int],
499
+ env_base_names: Mapping[tuple[tuple[str, str], ...], frozenset[str]],
500
+ ) -> None:
501
+ """Reject a base dep whose forks within one env pin it differently.
502
+
503
+ The env-only collapse in :func:`_build_marker` needs a single
504
+ (version, source) group spanning every fork of the environment.
505
+ Divergent pins split the forks across groups, so every entry would
506
+ keep its membership clause and none would fire when no member is
507
+ selected: the base dependency would silently not install.
508
+ """
509
+ by_env: defaultdict[tuple[tuple[str, str], ...], list[str]] = defaultdict(list)
510
+ for label in sorted(per_tuple.keys() & env_signatures.keys()):
511
+ by_env[env_signatures[label]].append(label)
512
+ for signature, labels in by_env.items():
513
+ if len(labels) < env_fork_counts[signature]:
514
+ continue
515
+ if name not in env_base_names.get(signature, frozenset()):
516
+ continue
517
+ in_env = set(labels)
518
+ widest = max(
519
+ sum(1 for label in group_labels if label in in_env)
520
+ for _, group_labels in groups
521
+ )
522
+ if widest >= env_fork_counts[signature]:
523
+ continue
524
+ forks = ", ".join(f"{label} -> {per_tuple[label].version}" for label in labels)
525
+ msg = (
526
+ f"{name}: the conflict forks of one environment pin this base"
527
+ f" dependency differently ({forks}); no lockfile entry would"
528
+ " install it when no conflicting member is selected"
529
+ )
530
+ raise DivergentBaseDependencyError(msg)
531
+
532
+
455
533
  def _build_marker(
456
534
  name: str,
457
535
  tuple_labels: Sequence[str],
@@ -474,7 +552,7 @@ def _build_marker(
474
552
  ``tuple_markers`` means the caller has not declared a tuple
475
553
  universe and the marker is omitted.
476
554
 
477
- A dep required by every member of an ``at_most_one`` set but not
555
+ A dep required by every member of an ``at-most-one`` set but not
478
556
  by the base is absent from ``env_base_names``, so it keeps the
479
557
  membership OR and does not install when no member is selected.
480
558
  An environment with no base-name set (no conflict fork ran) leaves
@@ -11,6 +11,9 @@ from __future__ import annotations
11
11
 
12
12
  from pathlib import Path
13
13
  from typing import TYPE_CHECKING
14
+ from urllib.parse import quote
15
+
16
+ from .builder import require_artifact_hashes
14
17
 
15
18
  if TYPE_CHECKING:
16
19
  import os
@@ -33,9 +36,12 @@ def write_requirements_with_hashes(
33
36
  Each line is ``name==version`` followed by one ``--hash=sha256:...``
34
37
  per recorded artefact, in the format pip's hash-checking mode
35
38
  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.
39
+ without hashes (pip does not hash-check those forms); an editable
40
+ local pin renders as ``-e <url>`` and a ``subdirectory`` as a
41
+ ``#subdirectory=`` fragment. Returns the text and, when
42
+ ``output_path`` is provided, atomically writes it.
38
43
  """
44
+ require_artifact_hashes(lock_input)
39
45
  return _render_requirements(lock_input, with_hashes=True, output_path=output_path)
40
46
 
41
47
 
@@ -45,8 +51,8 @@ def write_requirements_without_hashes(
45
51
  """Render ``lock_input`` as a plain ``name==version`` list.
46
52
 
47
53
  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``
54
+ the ``--hash=sha256:...`` lines. Local and VCS pins render the
55
+ same in both variants. Returns the text and, when ``output_path``
50
56
  is provided, atomically writes it.
51
57
  """
52
58
  return _render_requirements(lock_input, with_hashes=False, output_path=output_path)
@@ -97,7 +103,13 @@ def _render_pins(pins: Mapping[str, PinShape], *, with_hashes: bool) -> list[str
97
103
  if isinstance(pin, IndexPin):
98
104
  lines.extend(_render_index_pin(pin, with_hashes=with_hashes))
99
105
  elif isinstance(pin, LocalPin):
100
- lines.append(f"{pin.name} @ {Path(pin.path).resolve().as_uri()}")
106
+ url = Path(pin.path).resolve().as_uri()
107
+ if pin.subdirectory is not None:
108
+ url += f"#subdirectory={quote(pin.subdirectory, safe='/')}"
109
+ if pin.editable:
110
+ lines.append(f"-e {url}")
111
+ else:
112
+ lines.append(f"{pin.name} @ {url}")
101
113
  elif isinstance(pin, VcsPin):
102
114
  lines.append(f"{pin.name} @ {pin.repo_url}")
103
115
  else: # pragma: no cover - exhaustive
@@ -22,12 +22,13 @@ from typing import TYPE_CHECKING
22
22
 
23
23
  from nab_index.client import SdistFile, WheelFile, extract_sdist_archive
24
24
 
25
+ from .._vendor.packaging.specifiers import SpecifierSet
25
26
  from .._vendor.packaging.utils import canonicalize_name
27
+ from .._vendor.packaging.version import Version
26
28
 
27
29
  if TYPE_CHECKING:
28
30
  from collections.abc import Sequence
29
31
 
30
- from .._vendor.packaging.version import Version
31
32
  from ..metadata import WheelMetadata
32
33
  from ..provider import Provider
33
34
 
@@ -40,7 +41,14 @@ def build_remote_sdist(
40
41
  """Download the sdist for ``(package, version)``, extract, and build.
41
42
 
42
43
  ``package`` is the canonical package name; ``version`` matches an
43
- entry in ``provider.versions_cache``.
44
+ entry in ``provider.versions_cache``. A built sdist whose
45
+ ``Requires-Python`` excludes the resolve target is rejected, since the
46
+ Simple-API listing filter only sees the listing's own (possibly absent)
47
+ Requires-Python, not the value the build produces. A per-package
48
+ ``requires-python`` metadata override substitutes for the built value
49
+ here, matching the listing gate. A built sdist whose declared name or
50
+ version disagrees with the requested candidate is also rejected rather
51
+ than used for the wrong package.
44
52
  """
45
53
  # Late imports: ``provider`` imports this module at module load.
46
54
  from .. import build_backend
@@ -58,8 +66,15 @@ def build_remote_sdist(
58
66
  raise UnsupportedSdistError(msg)
59
67
 
60
68
  ver_str = str(version)
61
- event = provider.coordinator.request_sdist_archive(canonical, ver_str, sdist.url)
69
+ event = provider.coordinator.request_sdist_archive(
70
+ canonical, ver_str, sdist.url, sdist.hashes
71
+ )
62
72
  event.wait()
73
+ integrity_error = provider.coordinator.index.get_sdist_archive_error(
74
+ canonical, ver_str
75
+ )
76
+ if integrity_error is not None:
77
+ raise integrity_error
63
78
  data = provider.coordinator.index.get_sdist_archive(canonical, ver_str)
64
79
  if data is None:
65
80
  msg = (
@@ -75,7 +90,7 @@ def build_remote_sdist(
75
90
  msg = f"{package}=={version} sdist archive could not be extracted: {exc}"
76
91
  raise UnsupportedSdistError(msg) from exc
77
92
  try:
78
- return build_backend.extract_metadata(
93
+ built = build_backend.extract_metadata(
79
94
  source_dir,
80
95
  config=provider.build_config,
81
96
  python_version=provider.python_version,
@@ -84,6 +99,26 @@ def build_remote_sdist(
84
99
  msg = f"{package}=={version} build-remote backend failed: {exc}"
85
100
  raise UnsupportedSdistError(msg) from exc
86
101
 
102
+ target = provider.python_version
103
+ override_rp = provider.effective_requires_python(canonical, version)
104
+ spec = (
105
+ SpecifierSet(override_rp) if override_rp is not None else built.requires_python
106
+ )
107
+ if spec is not None and target and Version(target) not in spec:
108
+ msg = (
109
+ f"{package}=={version} built sdist requires Python {spec} but the"
110
+ f" resolve targets Python {target}"
111
+ )
112
+ raise UnsupportedSdistError(msg)
113
+ if canonicalize_name(built.name) != canonical or built.version != version:
114
+ msg = (
115
+ f"{package}=={version} built sdist declares"
116
+ f" {built.name}=={built.version}, which does not match the"
117
+ " requested candidate"
118
+ )
119
+ raise UnsupportedSdistError(msg)
120
+ return built
121
+
87
122
 
88
123
  def _find_sdist(
89
124
  versions: Sequence[tuple[Version, WheelFile | SdistFile]],