nab-python 0.0.5__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 (55) 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 +17 -4
  7. nab_python/_lockfile/requirements.py +5 -1
  8. nab_python/_provider/build_remote.py +39 -4
  9. nab_python/_provider/extras.py +6 -0
  10. nab_python/_provider/listing.py +170 -48
  11. nab_python/_provider/metadata_resolver.py +121 -19
  12. nab_python/_provider/sources.py +3 -5
  13. nab_python/_testing/coordinator_fake.py +6 -1
  14. nab_python/_testing/overrides.py +18 -0
  15. nab_python/_vcs_admission.py +30 -11
  16. nab_python/_vendor/packaging/PROVENANCE.md +48 -29
  17. nab_python/_vendor/packaging/_elffile.py +5 -5
  18. nab_python/_vendor/packaging/_manylinux.py +30 -15
  19. nab_python/_vendor/packaging/_parser.py +21 -4
  20. nab_python/_vendor/packaging/_ranges.py +836 -0
  21. nab_python/_vendor/packaging/_tokenizer.py +5 -1
  22. nab_python/_vendor/packaging/dependency_groups.py +29 -1
  23. nab_python/_vendor/packaging/direct_url.py +19 -4
  24. nab_python/_vendor/packaging/licenses/__init__.py +18 -6
  25. nab_python/_vendor/packaging/markers.py +29 -6
  26. nab_python/_vendor/packaging/metadata.py +3 -2
  27. nab_python/_vendor/packaging/pylock.py +6 -1
  28. nab_python/_vendor/packaging/ranges.py +707 -1513
  29. nab_python/_vendor/packaging/requirements.py +60 -20
  30. nab_python/_vendor/packaging/specifiers.py +290 -374
  31. nab_python/_vendor/packaging/tags.py +106 -43
  32. nab_python/_vendor/packaging/utils.py +33 -5
  33. nab_python/build_backend.py +11 -3
  34. nab_python/config.py +1142 -385
  35. nab_python/config_sources.py +1799 -0
  36. nab_python/download.py +41 -12
  37. nab_python/fetch.py +152 -72
  38. nab_python/lockfile.py +59 -5
  39. nab_python/metadata.py +5 -4
  40. nab_python/provider.py +459 -82
  41. nab_python/requirements_file.py +143 -18
  42. nab_python/resolve.py +166 -54
  43. nab_python/universal/matrix.py +0 -2
  44. nab_python/universal/provider.py +7 -8
  45. nab_python/universal/reresolve.py +27 -21
  46. nab_python/universal/resolve.py +62 -50
  47. nab_python/universal/validate.py +95 -22
  48. nab_python/universal/wheel_selection.py +67 -19
  49. nab_python/workspace.py +8 -1
  50. {nab_python-0.0.5.dist-info → nab_python-0.0.6.dist-info}/METADATA +3 -3
  51. nab_python-0.0.6.dist-info/RECORD +75 -0
  52. nab_python/_vendor/packaging/_range_utils.py +0 -998
  53. nab_python/_vendor/packaging/_version_utils.py +0 -90
  54. nab_python-0.0.5.dist-info/RECORD +0 -74
  55. {nab_python-0.0.5.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:
@@ -82,14 +83,26 @@ def write_lock(
82
83
  With no ``output_path`` the current directory is the base.
83
84
  """
84
85
  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()))
86
+ text = render_lock(lock_input, lock_dir=lock_dir)
88
87
  if output_path is not None:
89
88
  Path(output_path).write_text(text, encoding="utf-8")
90
89
  return text
91
90
 
92
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
+
93
106
  def build_pylock(lock_input: LockInput, *, lock_dir: Path | None = None) -> Pylock:
94
107
  """Build a :class:`Pylock` from the input shape.
95
108
 
@@ -539,7 +552,7 @@ def _build_marker(
539
552
  ``tuple_markers`` means the caller has not declared a tuple
540
553
  universe and the marker is omitted.
541
554
 
542
- 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
543
556
  by the base is absent from ``env_base_names``, so it keeps the
544
557
  membership OR and does not install when no member is selected.
545
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
@@ -38,6 +41,7 @@ def write_requirements_with_hashes(
38
41
  ``#subdirectory=`` fragment. Returns the text and, when
39
42
  ``output_path`` is provided, atomically writes it.
40
43
  """
44
+ require_artifact_hashes(lock_input)
41
45
  return _render_requirements(lock_input, with_hashes=True, output_path=output_path)
42
46
 
43
47
 
@@ -101,7 +105,7 @@ def _render_pins(pins: Mapping[str, PinShape], *, with_hashes: bool) -> list[str
101
105
  elif isinstance(pin, LocalPin):
102
106
  url = Path(pin.path).resolve().as_uri()
103
107
  if pin.subdirectory is not None:
104
- url += f"#subdirectory={pin.subdirectory}"
108
+ url += f"#subdirectory={quote(pin.subdirectory, safe='/')}"
105
109
  if pin.editable:
106
110
  lines.append(f"-e {url}")
107
111
  else:
@@ -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]],
@@ -13,6 +13,7 @@ import logging
13
13
  from typing import TYPE_CHECKING
14
14
 
15
15
  from .._vendor.packaging.ranges import VersionRange
16
+ from .metadata_resolver import refuse_url_dep
16
17
 
17
18
  if TYPE_CHECKING:
18
19
  from nab_resolver.types import RangeProtocol
@@ -187,6 +188,11 @@ def get_extra_dependencies(
187
188
  msg = f"No metadata cached for {base}=={version}"
188
189
  raise MetadataError(msg)
189
190
 
191
+ deferred = provider.deferred_url_extras.get(base_cache_key, {}).get(extra)
192
+ if deferred:
193
+ req, url = deferred[0]
194
+ refuse_url_dep(provider, req, url)
195
+
190
196
  extra_map = provider.extra_deps_map.get(base_cache_key, {})
191
197
  if extra not in extra_map:
192
198
  return handle_missing_extra(provider, normalized, extra, version, cache_key)