nab-python 0.0.7__py3-none-any.whl → 0.0.9__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 (54) hide show
  1. nab_python/_build/env.py +95 -47
  2. nab_python/_build/runner.py +7 -8
  3. nab_python/_conflict_kind.py +2 -2
  4. nab_python/_iso8601.py +34 -0
  5. nab_python/_lockfile/builder.py +153 -54
  6. nab_python/_lockfile/disjointness.py +10 -3
  7. nab_python/_lockfile/pylock.py +242 -129
  8. nab_python/_lockfile/requirements.py +37 -33
  9. nab_python/_packaging_provider.py +24 -2
  10. nab_python/_provider/build_remote.py +1 -2
  11. nab_python/_provider/extras.py +34 -17
  12. nab_python/_provider/listing.py +258 -57
  13. nab_python/_provider/lookahead.py +5 -4
  14. nab_python/_provider/metadata_resolver.py +66 -30
  15. nab_python/_provider/sources.py +78 -59
  16. nab_python/_testing/coordinator_fake.py +34 -80
  17. nab_python/_vcs_admission.py +59 -4
  18. nab_python/_vendor/packaging/PROVENANCE.md +46 -77
  19. nab_python/_vendor/packaging/_manylinux.py +0 -2
  20. nab_python/_vendor/packaging/_musllinux.py +1 -1
  21. nab_python/_vendor/packaging/_ranges.py +17 -17
  22. nab_python/_vendor/packaging/dependency_groups.py +5 -4
  23. nab_python/_vendor/packaging/direct_url.py +17 -8
  24. nab_python/_vendor/packaging/markers.py +43 -4
  25. nab_python/_vendor/packaging/metadata.py +8 -2
  26. nab_python/_vendor/packaging/pylock.py +4 -1
  27. nab_python/_vendor/packaging/ranges.py +251 -57
  28. nab_python/_vendor/packaging/requirements.py +13 -2
  29. nab_python/_vendor/packaging/specifiers.py +6 -6
  30. nab_python/_vendor/packaging/tags.py +14 -5
  31. nab_python/_vendor/packaging/version.py +21 -5
  32. nab_python/build_backend.py +43 -21
  33. nab_python/config.py +890 -101
  34. nab_python/config_sources.py +56 -4
  35. nab_python/download.py +25 -22
  36. nab_python/fetch.py +333 -160
  37. nab_python/lockfile.py +70 -38
  38. nab_python/provider.py +479 -161
  39. nab_python/requirements_file.py +11 -56
  40. nab_python/resolve.py +1065 -753
  41. nab_python/tags.py +813 -0
  42. nab_python/target.py +1116 -0
  43. nab_python/workspace.py +138 -72
  44. {nab_python-0.0.7.dist-info → nab_python-0.0.9.dist-info}/METADATA +3 -3
  45. nab_python-0.0.9.dist-info/RECORD +71 -0
  46. nab_python/universal/__init__.py +0 -1
  47. nab_python/universal/matrix.py +0 -337
  48. nab_python/universal/provider.py +0 -234
  49. nab_python/universal/reresolve.py +0 -340
  50. nab_python/universal/resolve.py +0 -781
  51. nab_python/universal/validate.py +0 -554
  52. nab_python/universal/wheel_selection.py +0 -431
  53. nab_python-0.0.7.dist-info/RECORD +0 -75
  54. {nab_python-0.0.7.dist-info → nab_python-0.0.9.dist-info}/WHEEL +0 -0
nab_python/_build/env.py CHANGED
@@ -12,7 +12,7 @@
12
12
  resolved wheels into a temp directory.
13
13
  * :func:`installer.install` writes each wheel into the venv via
14
14
  ``installer.SchemeDictionaryDestination``, configured from the
15
- venv's own ``sysconfig.get_paths()``.
15
+ venv's own scheme paths.
16
16
 
17
17
  The env is a context manager. Entering it builds the venv and
18
18
  installs the requirements; exiting removes the temp tree. The
@@ -40,6 +40,7 @@ from nab_index.urllib3_async_transport import Urllib3AsyncTransport
40
40
  from .._vcs_admission import UnsupportedVcsError
41
41
  from ..config import NabProjectConfig
42
42
  from ..download import download_lock
43
+ from ..requirements_file import InvalidProjectRequirementError
43
44
 
44
45
  if TYPE_CHECKING:
45
46
  from collections.abc import Callable, Mapping
@@ -82,6 +83,15 @@ _LAUNCHER_KIND = (
82
83
  else "posix"
83
84
  )
84
85
 
86
+ _SCHEME_PROBE = (
87
+ "import json, sys, sysconfig;"
88
+ "print(json.dumps({"
89
+ "'paths': sysconfig.get_paths(),"
90
+ "'prefix': sys.prefix,"
91
+ "'py_version': '%d.%d' % sys.version_info[:2],"
92
+ "}))"
93
+ )
94
+
85
95
 
86
96
  class BuildEnvError(Exception):
87
97
  """The build env could not be set up (resolve, download, or install)."""
@@ -103,6 +113,12 @@ class NabBuildEnv:
103
113
  marker overlay) before the inner resolve so the build env is
104
114
  computed against PyPI alone.
105
115
 
116
+ The venv is created from the host interpreter and the PEP 517
117
+ hooks run in it, so the build requirements resolve for the host
118
+ and not for any ``--python`` retarget: a wheel for another
119
+ Python's ABI would not import, and a build requirement the host
120
+ needs would be dropped by its marker.
121
+
106
122
  Construction is cheap; the work happens in ``__enter__``.
107
123
  """
108
124
 
@@ -111,13 +127,11 @@ class NabBuildEnv:
111
127
  requires: list[str],
112
128
  *,
113
129
  config: NabProjectConfig,
114
- python_version: str | None = None,
115
130
  transport_factory: Callable[[], AsyncHttpTransport] = Urllib3AsyncTransport,
116
131
  ) -> None:
117
132
  """Capture inputs; the venv and inner resolve happen in __enter__."""
118
133
  self._requires = list(requires)
119
134
  self._config = config
120
- self._python_version = python_version
121
135
  self._transport_factory = transport_factory
122
136
 
123
137
  self._tmpdir: tempfile.TemporaryDirectory[str] | None = None
@@ -151,21 +165,27 @@ class NabBuildEnv:
151
165
  self._python_executable = _venv_python(self._venv_path)
152
166
  self._scripts_dir = self._python_executable.parent
153
167
 
154
- scheme_dict = _venv_scheme_paths(self._python_executable)
168
+ scheme_paths = _venv_scheme_paths(self._python_executable)
155
169
 
156
170
  if not self._requires:
157
171
  return
158
- wheel_paths = self._resolve_and_download(wheel_dir)
159
- destination = _FastSchemeDictionaryDestination(
160
- scheme_dict=scheme_dict,
161
- interpreter=str(self._python_executable),
162
- script_kind=_LAUNCHER_KIND,
163
- bytecode_optimization_levels=(),
164
- overwrite_existing=True,
165
- )
172
+
173
+ self._install_wheels(self._resolve_and_download(wheel_dir), scheme_paths)
174
+
175
+ def _install_wheels(
176
+ self, wheel_paths: list[Path], scheme_paths: dict[str, str]
177
+ ) -> None:
178
+ """Write each wheel into the venv with ``installer``."""
166
179
  for wheel_path in wheel_paths:
167
180
  logger.debug("installing %s", wheel_path.name)
168
181
  with WheelFile.open(wheel_path) as source:
182
+ destination = _FastSchemeDictionaryDestination(
183
+ scheme_dict=_dist_scheme_paths(scheme_paths, source.distribution),
184
+ interpreter=str(self._python_executable),
185
+ script_kind=_LAUNCHER_KIND,
186
+ bytecode_optimization_levels=(),
187
+ overwrite_existing=True,
188
+ )
169
189
  installer_install(
170
190
  source=source,
171
191
  destination=destination,
@@ -224,21 +244,7 @@ class NabBuildEnv:
224
244
  sub = wheel_dir / f"_extra_{len(list(wheel_dir.iterdir()))}"
225
245
  sub.mkdir(parents=True, exist_ok=True)
226
246
  wheel_paths = self._resolve_and_download(sub, extra=requirements)
227
- scheme_dict = _venv_scheme_paths(self._python_executable)
228
- destination = SchemeDictionaryDestination(
229
- scheme_dict=scheme_dict,
230
- interpreter=str(self._python_executable),
231
- script_kind=_LAUNCHER_KIND,
232
- bytecode_optimization_levels=(),
233
- overwrite_existing=True,
234
- )
235
- for wheel_path in wheel_paths:
236
- with WheelFile.open(wheel_path) as source:
237
- installer_install(
238
- source=source,
239
- destination=destination,
240
- additional_metadata={"INSTALLER": b"nab\n"},
241
- )
247
+ self._install_wheels(wheel_paths, _venv_scheme_paths(self._python_executable))
242
248
 
243
249
  def _resolve_and_download(
244
250
  self,
@@ -249,7 +255,7 @@ class NabBuildEnv:
249
255
  """Resolve ``requires`` (+ ``extra``) and write wheels under ``wheel_dir``.
250
256
 
251
257
  The inner resolve runs against a synthetic pyproject so it
252
- can reuse :func:`nab_python.resolve.resolve_pyproject` and
258
+ can reuse :func:`nab_python.resolve.resolve_for_targets` and
253
259
  :func:`nab_python.download.download_lock` end-to-end. No
254
260
  local sources / workspace / marker overlay; build deps
255
261
  come from the configured indexes only.
@@ -257,7 +263,7 @@ class NabBuildEnv:
257
263
  # Late import: avoids a cycle through ``resolve.py`` which
258
264
  # itself imports ``pypi.py`` which imports ``build_backend``
259
265
  # which imports this module.
260
- from ..resolve import resolve_pyproject
266
+ from ..resolve import build_lock_input, resolve_for_targets
261
267
 
262
268
  requires = list(self._requires)
263
269
  if extra:
@@ -279,27 +285,40 @@ class NabBuildEnv:
279
285
  # build a fresh transport each time.
280
286
  transport = self._transport_factory()
281
287
  try:
282
- result = resolve_pyproject(
288
+ result = resolve_for_targets(
283
289
  synthetic,
284
290
  transport,
285
291
  config=inner_config,
286
- python_version=self._python_version,
287
292
  )
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.
293
+ # The build env resolves for the host alone, so its one
294
+ # target's failure is the whole resolve's.
295
+ result.raise_for_failure()
296
+ except (
297
+ UnsupportedVcsError,
298
+ NotImplementedError,
299
+ InvalidProjectRequirementError,
300
+ ) as exc:
301
+ # A build requirement nab cannot resolve: a direct-URL/VCS pin, or
302
+ # a string that is not valid PEP 508. Wrap it so the outer resolve
303
+ # skips this sdist rather than aborting on the raw error.
292
304
  msg = f"build env resolve failed: {exc}"
293
305
  raise BuildEnvError(msg) from exc
294
306
 
307
+ lock_input = build_lock_input(result, config=inner_config)
308
+
295
309
  # Reject sdist-only pins early: build deps that ship only an
296
310
  # sdist trigger a recursive backend invocation that this
297
311
  # builder does not handle. Most build tools (hatchling,
298
312
  # setuptools, flit, pdm-backend) publish wheels.
299
313
  from ..lockfile import IndexPin
300
314
 
315
+ pins = {
316
+ name: pin
317
+ for lock in lock_input.targets.values()
318
+ for name, pin in lock.pins.items()
319
+ }
301
320
  sdist_only: list[str] = []
302
- for canonical, pin in result.lock_input.pins.items():
321
+ for canonical, pin in pins.items():
303
322
  if isinstance(pin, IndexPin) and not pin.wheels:
304
323
  sdist_only.append(f"{canonical}=={pin.version}")
305
324
  if sdist_only:
@@ -310,7 +329,7 @@ class NabBuildEnv:
310
329
  )
311
330
  raise BuildEnvError(msg)
312
331
 
313
- download_result = download_lock(result.lock_input, transport, wheel_dir)
332
+ download_result = download_lock(lock_input, transport, wheel_dir)
314
333
  # Both wheels and sdists are downloaded; only wheels feed
315
334
  # ``installer.install``. The sdists are inert clutter under
316
335
  # the temp dir, cleaned up with the env.
@@ -326,24 +345,39 @@ def _venv_python(venv_path: Path) -> Path:
326
345
 
327
346
 
328
347
  def _venv_scheme_paths(python_executable: Path) -> dict[str, str]:
329
- """Ask the venv's interpreter for its own ``sysconfig.get_paths()``.
348
+ """Ask the venv's interpreter for the scheme paths ``installer`` writes to.
330
349
 
331
350
  Subprocessing the venv guarantees the returned paths reflect the
332
351
  venv's layout (``site-packages`` under the venv root, scripts in
333
352
  its ``bin``/``Scripts`` dir, etc.) regardless of how nab itself
334
353
  was installed. One subprocess per env construction; negligible.
354
+
355
+ ``sysconfig`` has no ``headers`` scheme, and its ``include`` names
356
+ the base interpreter rather than the venv, so the header root comes
357
+ from the venv's own prefix instead.
335
358
  """
336
359
  result = subprocess.run( # noqa: S603 - controlled command, no shell
337
- [
338
- str(python_executable),
339
- "-c",
340
- "import json, sysconfig; print(json.dumps(sysconfig.get_paths()))",
341
- ],
360
+ [str(python_executable), "-c", _SCHEME_PROBE],
342
361
  capture_output=True,
343
362
  text=True,
344
363
  check=True,
345
364
  )
346
- return dict(json.loads(result.stdout))
365
+ probe = json.loads(result.stdout)
366
+
367
+ paths: dict[str, str] = dict(probe["paths"])
368
+ paths["headers"] = str(
369
+ Path(probe["prefix"], "include", "site", f"python{probe['py_version']}")
370
+ )
371
+ return paths
372
+
373
+
374
+ def _dist_scheme_paths(
375
+ scheme_paths: dict[str, str], distribution: str
376
+ ) -> dict[str, str]:
377
+ """Scheme paths for one wheel; its headers go in a directory of its own."""
378
+ paths = dict(scheme_paths)
379
+ paths["headers"] = str(Path(paths["headers"], distribution))
380
+ return paths
347
381
 
348
382
 
349
383
  def _supports_symlinks() -> bool:
@@ -371,6 +405,20 @@ def _render_synthetic_pyproject(requires: list[str]) -> str:
371
405
 
372
406
 
373
407
  def _toml_str(value: str) -> str:
374
- """Single-line TOML string literal escape."""
375
- escaped = value.replace("\\", "\\\\").replace('"', '\\"')
376
- return f'"{escaped}"'
408
+ """Escape a string as a single-line TOML basic-string literal.
409
+
410
+ Backslash, double-quote, and the control characters TOML forbids bare in a
411
+ basic string (below U+0020, plus U+007F) are escaped, so a requirement
412
+ carrying a newline stays valid TOML instead of splitting across a line.
413
+ """
414
+ out: list[str] = []
415
+ for ch in value:
416
+ if ch == "\\":
417
+ out.append("\\\\")
418
+ elif ch == '"':
419
+ out.append('\\"')
420
+ elif ch < "\x20" or ch == "\x7f":
421
+ out.append(f"\\u{ord(ch):04x}")
422
+ else:
423
+ out.append(ch)
424
+ return '"' + "".join(out) + '"'
@@ -62,7 +62,6 @@ def run_build_backend(
62
62
  source_dir: Path,
63
63
  *,
64
64
  config: NabProjectConfig,
65
- python_version: str | None = None,
66
65
  ) -> WheelMetadata:
67
66
  """Extract wheel metadata for ``source_dir`` via the build backend.
68
67
 
@@ -80,7 +79,7 @@ def run_build_backend(
80
79
  if pyproject.is_file():
81
80
  try:
82
81
  data = tomli.loads(pyproject.read_text(encoding="utf-8"))
83
- except (OSError, tomli.TOMLDecodeError) as exc:
82
+ except (OSError, UnicodeDecodeError, tomli.TOMLDecodeError) as exc:
84
83
  msg = f"could not read pyproject.toml at {source_dir}: {exc}"
85
84
  raise BuildBackendError(msg) from exc
86
85
  elif (source_dir / "setup.py").is_file():
@@ -97,11 +96,7 @@ def run_build_backend(
97
96
  skip_prepare = _should_skip_prepare(backend, data)
98
97
 
99
98
  try:
100
- with NabBuildEnv(
101
- requires=list(requires),
102
- config=config,
103
- python_version=python_version,
104
- ) as env:
99
+ with NabBuildEnv(requires=list(requires), config=config) as env:
105
100
  project = build.ProjectBuilder.from_isolated_env(
106
101
  env,
107
102
  source_dir=str(source_dir),
@@ -120,7 +115,11 @@ def run_build_backend(
120
115
  else:
121
116
  metadata_dir = Path(project.metadata_path(output_dir))
122
117
  return _parse_metadata(metadata_dir / "METADATA")
123
- except build.BuildBackendException as exc:
118
+ except (
119
+ build.BuildException,
120
+ build.BuildBackendException,
121
+ build.FailedProcessError,
122
+ ) as exc:
124
123
  msg = f"build backend {backend!r} failed: {exc}"
125
124
  raise BuildBackendError(msg) from exc
126
125
  except (BuildEnvError, ResolutionError) as exc:
@@ -1,7 +1,7 @@
1
1
  """Conflict-kind constants and PEP 508 marker-variable mapping.
2
2
 
3
- A leaf module that :mod:`nab_python.config`, :mod:`nab_python.universal.matrix`,
4
- and :mod:`nab_python._lockfile.disjointness` can import without forming a
3
+ A leaf module that :mod:`nab_python.config`, :mod:`nab_python.target`, and
4
+ :mod:`nab_python._lockfile.disjointness` can import without forming a
5
5
  cycle. :class:`nab_python.config.ConflictKind` takes its enum values from
6
6
  ``KIND_EXTRA`` / ``KIND_GROUP`` so a rename here flows to every consumer.
7
7
  """
nab_python/_iso8601.py ADDED
@@ -0,0 +1,34 @@
1
+ """Shared parsing for the ISO 8601 timestamps that indexes and pylock files carry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime
7
+
8
+ _FRACTIONAL_SECONDS = re.compile(r"\.(\d+)")
9
+
10
+
11
+ def parse_iso_datetime(raw: str) -> datetime:
12
+ """Parse an ISO 8601 datetime, raising ``ValueError`` when it cannot be read.
13
+
14
+ The offset in ``raw`` is preserved, and the result is naive when there is
15
+ none, so each caller decides what a missing offset means.
16
+ """
17
+ return datetime.fromisoformat(_to_isoformat(raw))
18
+
19
+
20
+ def _to_isoformat(raw: str) -> str:
21
+ """Rewrite ``raw`` into the shape ``datetime.isoformat`` emits.
22
+
23
+ Before Python 3.11, ``datetime.fromisoformat`` parses only that shape: an
24
+ explicit ``+HH:MM`` offset rather than ``Z``, and a fraction of exactly 3 or
25
+ 6 digits. PEP 700 serves ``Z`` and permits 0 through 6, so those are the two
26
+ parts normalized here; the rest is left for ``fromisoformat`` to judge.
27
+ """
28
+ iso = f"{raw[:-1]}+00:00" if raw.endswith("Z") else raw
29
+ return _FRACTIONAL_SECONDS.sub(_microseconds, iso)
30
+
31
+
32
+ def _microseconds(match: re.Match[str]) -> str:
33
+ # A datetime holds microseconds, so digits past the sixth are dropped.
34
+ return "." + match.group(1)[:6].ljust(6, "0")
@@ -19,6 +19,7 @@ import tomli
19
19
 
20
20
  from nab_index.client import SdistFile, WheelFile
21
21
 
22
+ from .._iso8601 import parse_iso_datetime
22
23
  from .._toml import tool_nab_section
23
24
  from .._vendor.packaging.pylock import Pylock, PylockValidationError
24
25
  from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
@@ -36,17 +37,19 @@ if TYPE_CHECKING:
36
37
  LockInput,
37
38
  PinShape,
38
39
  SdistArtifact,
40
+ TargetLock,
39
41
  VcsPin,
40
42
  WheelArtifact,
41
43
  )
42
44
  from ..provider import ArchiveSource, DistPolicy, LocalSource, VcsSource
45
+ from ..target import ResolveTarget
43
46
 
44
47
 
45
48
  __all__ = [
46
49
  "MissingHashError",
47
50
  "MissingSdistError",
48
51
  "MissingVcsCommitError",
49
- "build_lock_input_from_provider",
52
+ "build_target_lock",
50
53
  "read_lockfile_anchor",
51
54
  "read_lockfile_packages",
52
55
  "require_artifact_hashes",
@@ -74,7 +77,7 @@ class LockInputProvider(Protocol):
74
77
  """Structural protocol for the provider slice the builder reads.
75
78
 
76
79
  Mirrors the public surface :class:`~nab_python.provider.Provider`
77
- exposes that :func:`build_lock_input_from_provider` consumes; tests
80
+ exposes that :func:`build_target_lock` consumes; tests
78
81
  may supply a stub without inheriting the full Provider class.
79
82
  """
80
83
 
@@ -178,17 +181,15 @@ def read_lockfile_anchor(path: Path) -> datetime | None:
178
181
  try:
179
182
  with path.open("rb") as f:
180
183
  data = tomli.load(f)
181
- except (OSError, tomli.TOMLDecodeError):
184
+ except (OSError, UnicodeDecodeError, tomli.TOMLDecodeError):
182
185
  return None
183
186
  nab = tool_nab_section(data)
184
187
  raw = nab.get("created-at") if isinstance(nab, dict) else None
185
188
  if isinstance(raw, datetime):
186
189
  return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
187
190
  if isinstance(raw, str):
188
- # Python 3.10's fromisoformat rejects a trailing 'Z'; 3.11+ accept it.
189
- iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
190
191
  try:
191
- dt = datetime.fromisoformat(iso)
192
+ dt = parse_iso_datetime(raw)
192
193
  except ValueError:
193
194
  return None
194
195
  return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
@@ -212,7 +213,7 @@ def read_lockfile_packages(path: Path) -> dict[str, Version] | None:
212
213
  with path.open("rb") as f:
213
214
  data = tomli.load(f)
214
215
  pylock = Pylock.from_dict(data)
215
- except (OSError, tomli.TOMLDecodeError, PylockValidationError):
216
+ except (OSError, UnicodeDecodeError, tomli.TOMLDecodeError, PylockValidationError):
216
217
  return None
217
218
  return {
218
219
  str(pkg.name): pkg.version for pkg in pylock.packages if pkg.version is not None
@@ -239,37 +240,50 @@ def _strip_userinfo(url: str) -> str:
239
240
  return urlunsplit(parts._replace(netloc=host))
240
241
 
241
242
 
242
- def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a distinct lockfile field
243
+ def build_target_lock(
243
244
  provider: LockInputProvider,
245
+ target: ResolveTarget,
244
246
  pins: Mapping[str, Version],
245
247
  *,
246
- requires_python: str | None = None,
247
- extras: Sequence[str] = (),
248
- dependency_groups: Sequence[str] = (),
249
- default_groups: Sequence[str] = (),
250
- created_by: str = "nab",
251
248
  indexes: Sequence[IndexConfig] = (),
252
249
  resolved_keys: Iterable[str] = (),
253
- ) -> LockInput:
254
- """Build a :class:`LockInput` from a finished resolve.
250
+ base_roots: Iterable[str] | None = None,
251
+ selector_roots: Mapping[tuple[str, str], Iterable[str]] | None = None,
252
+ ) -> TargetLock:
253
+ """Build one target's :class:`~nab_python.lockfile.TargetLock`.
255
254
 
256
- ``provider`` is the :class:`Provider` that drove the resolve;
257
- its caches still hold the listings the resolver consumed. ``pins``
258
- is the canonical-name -> :class:`Version` mapping returned by the
259
- resolver after extras keys have been stripped.
260
-
261
- ``dependency_groups`` lists the PEP 735 groups whose requirements
262
- were folded into this resolve; ``default_groups`` is the subset
263
- that a default install (no ``--group`` flag) should apply.
255
+ ``provider`` is the :class:`Provider` that drove the resolve for
256
+ ``target``; its caches still hold the listings the resolver
257
+ consumed. ``pins`` is the canonical-name -> :class:`Version`
258
+ mapping returned by the resolver after extras keys have been
259
+ stripped.
264
260
 
265
261
  ``resolved_keys`` is the full set of resolver result keys, including
266
262
  ``name[extra]`` proxies; it is read to find which extras activated
267
263
  so their edges join the forward dependency graph.
268
264
 
269
- All wheels and the sdist for each pinned version are recorded so
270
- the lockfile is portable across architectures of the same Python.
265
+ ``base_roots`` and ``selector_roots`` are the resolver keys each
266
+ install context requires directly: the project's own dependencies,
267
+ and those of each selected extra and each selected group, keyed by
268
+ its ``(kind, name)`` member. :func:`_membership_gates` walks the
269
+ resolve from them to find the packages only a selection reaches. An
270
+ empty ``base_roots`` is a project with no dependencies of its own, so
271
+ it does not stand in for ``None``: omitting it while passing selector
272
+ roots raises.
273
+
274
+ Every wheel the target can install, plus the sdist, is recorded for
275
+ each pinned version.
271
276
  """
272
- from ..lockfile import LocalPin, LockInput
277
+ from ..lockfile import LocalPin, TargetLock
278
+
279
+ if base_roots is None:
280
+ if selector_roots:
281
+ msg = (
282
+ "selector_roots need base_roots:"
283
+ " without them every package looks selector-only"
284
+ )
285
+ raise ValueError(msg)
286
+ base_roots = ()
273
287
 
274
288
  lock_pins: dict[str, PinShape] = {}
275
289
  for raw_name, version in pins.items():
@@ -302,30 +316,113 @@ def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a disti
302
316
  lock_pins[canonical] = _index_pin_from_listing(
303
317
  provider, canonical, version, indexes
304
318
  )
305
- return LockInput(
319
+
320
+ dependencies, base_dependencies = _forward_dependency_graph(
321
+ provider, pins, resolved_keys
322
+ )
323
+ return TargetLock(
324
+ target=target,
306
325
  pins=lock_pins,
307
- requires_python=requires_python,
308
- created_by=created_by,
309
- extras=tuple(extras),
310
- dependency_groups=tuple(dependency_groups),
311
- default_groups=tuple(default_groups),
312
- dependencies=_forward_dependency_graph(provider, pins, resolved_keys),
326
+ dependencies=dependencies,
327
+ base_dependencies=base_dependencies,
328
+ package_gates=_membership_gates(
329
+ provider,
330
+ pins,
331
+ base_roots=base_roots,
332
+ selector_roots=selector_roots or {},
333
+ ),
313
334
  )
314
335
 
315
336
 
337
+ def _membership_gates(
338
+ provider: LockInputProvider,
339
+ pins: Mapping[str, Version],
340
+ *,
341
+ base_roots: Iterable[str],
342
+ selector_roots: Mapping[tuple[str, str], Iterable[str]],
343
+ ) -> dict[str, tuple[tuple[str, str], ...]]:
344
+ """Name the selections that gate each package no base dependency reaches.
345
+
346
+ A selected extra or group is folded into the resolve that produces
347
+ the lock, so its requirements pin packages a default install must not
348
+ receive. PEP 751 defaults an install to no extras and to
349
+ ``default-groups``, and decides per package from ``packages.marker``,
350
+ so a package only a selection reaches has to name every selection
351
+ that reaches it; the writer turns each ``(kind, name)`` member into
352
+ ``'name' in extras`` / ``'name' in dependency_groups``. A package
353
+ the project's own dependencies reach is unconditional.
354
+
355
+ Reachability is over this target's resolved graph, so an extras proxy
356
+ (an extra requiring ``pkg[fancy]`` while the project requires plain
357
+ ``pkg``) gates what ``fancy`` adds without gating ``pkg``.
358
+ """
359
+ if not selector_roots:
360
+ return {}
361
+
362
+ pinned = {canonicalize_name(name): version for name, version in pins.items()}
363
+ base_reachable = _reachable_names(provider, pinned, base_roots)
364
+
365
+ gates: defaultdict[str, list[tuple[str, str]]] = defaultdict(list)
366
+ for member in sorted(selector_roots):
367
+ gated = _reachable_names(provider, pinned, selector_roots[member])
368
+ for name in gated - base_reachable:
369
+ gates[name].append(member)
370
+ return {name: tuple(members) for name, members in gates.items()}
371
+
372
+
373
+ def _reachable_names(
374
+ provider: LockInputProvider,
375
+ pinned: Mapping[str, Version],
376
+ roots: Iterable[str],
377
+ ) -> set[str]:
378
+ """Return the pinned names reachable from ``roots`` at their pinned versions.
379
+
380
+ The walk is over resolver keys, so a ``name[extra]`` root pulls in
381
+ that extra's dependencies on top of the package's own.
382
+ """
383
+ from ..provider import split_extra
384
+
385
+ reached: set[str] = set()
386
+ seen: set[str] = set()
387
+ stack = list(roots)
388
+
389
+ while stack:
390
+ key = stack.pop()
391
+ if key in seen:
392
+ continue
393
+ seen.add(key)
394
+
395
+ raw_name, extra = split_extra(key)
396
+ canonical = canonicalize_name(raw_name)
397
+ version = pinned.get(canonical)
398
+ if version is None:
399
+ continue
400
+ reached.add(canonical)
401
+
402
+ cache_key = (canonical, version)
403
+ stack.extend(provider.deps_cache.get(cache_key, {}))
404
+ if extra is not None:
405
+ stack.extend(provider.extra_deps_map.get(cache_key, {}).get(extra, {}))
406
+
407
+ return reached
408
+
409
+
316
410
  def _forward_dependency_graph(
317
411
  provider: LockInputProvider,
318
412
  pins: Mapping[str, Version],
319
413
  resolved_keys: Iterable[str],
320
- ) -> dict[str, tuple[str, ...]]:
414
+ ) -> tuple[dict[str, tuple[str, ...]], dict[str, tuple[str, ...]]]:
321
415
  """Build the forward dependency graph among the locked packages.
322
416
 
323
- Each pinned package maps to the canonical names of its direct
324
- dependencies that are themselves pinned. Base dependencies come
325
- from ``deps_cache``; an activated extra (a ``name[extra]`` key in
326
- ``resolved_keys``) folds that extra's dependencies in too. Names
327
- not in ``pins`` are dropped so every edge points at a real
328
- ``[[packages]]`` entry.
417
+ Returns ``(full, base)``. ``full`` maps each pinned package to the
418
+ canonical names of its direct dependencies that are themselves
419
+ pinned; an activated extra (a ``name[extra]`` key in
420
+ ``resolved_keys``) folds that extra's dependencies in. ``base`` is
421
+ the subset from each package's own metadata (``deps_cache``), before
422
+ any extra is folded in, so it holds only the edges that fire
423
+ regardless of which extra was activated. Names not in ``pins`` are
424
+ dropped from both so every edge points at a real ``[[packages]]``
425
+ entry.
329
426
  """
330
427
  from ..provider import split_extra
331
428
 
@@ -337,26 +434,32 @@ def _forward_dependency_graph(
337
434
 
338
435
  pinned = {canonicalize_name(name) for name in pins}
339
436
  graph: dict[str, tuple[str, ...]] = {}
437
+ base_graph: dict[str, tuple[str, ...]] = {}
340
438
  for raw_name, version in pins.items():
341
439
  canonical = canonicalize_name(raw_name)
342
440
  cache_key = (canonical, version)
343
- dep_names = {
441
+ base_deps = {
344
442
  canonicalize_name(split_extra(dep)[0])
345
443
  for dep in provider.deps_cache.get(cache_key, {})
346
444
  }
445
+ all_deps = set(base_deps)
347
446
  extra_map = provider.extra_deps_map.get(cache_key, {})
348
447
  for extra in activated_extras.get(canonical, ()):
349
- dep_names.update(
448
+ all_deps.update(
350
449
  canonicalize_name(split_extra(dep)[0])
351
450
  for dep in extra_map.get(extra, {})
352
451
  )
353
- dep_names &= pinned
452
+ base_deps &= pinned
453
+ all_deps &= pinned
354
454
  # An umbrella extra (pkg[all] pulling pkg[graphviz]) can name its own
355
455
  # package; drop it so pkg is never an edge to itself.
356
- dep_names.discard(canonical)
357
- if dep_names:
358
- graph[canonical] = tuple(sorted(dep_names))
359
- return graph
456
+ base_deps.discard(canonical)
457
+ all_deps.discard(canonical)
458
+ if all_deps:
459
+ graph[canonical] = tuple(sorted(all_deps))
460
+ if base_deps:
461
+ base_graph[canonical] = tuple(sorted(base_deps))
462
+ return graph, base_graph
360
463
 
361
464
 
362
465
  def _index_pin_from_listing(
@@ -478,7 +581,7 @@ def _parse_upload_time(raw: str | None) -> datetime | None:
478
581
  if raw is None:
479
582
  return None
480
583
  try:
481
- parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
584
+ parsed = parse_iso_datetime(raw)
482
585
  except ValueError:
483
586
  return None
484
587
  if parsed.tzinfo is None:
@@ -514,12 +617,8 @@ def require_artifact_hashes(lock_input: LockInput) -> None:
514
617
  """
515
618
  from ..lockfile import ACCEPTED_HASH_ALGORITHMS, IndexPin
516
619
 
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():
620
+ for lock in lock_input.targets.values():
621
+ for pin in lock.pins.values():
523
622
  if not isinstance(pin, IndexPin):
524
623
  continue
525
624
  artefacts = (*pin.wheels, *((pin.sdist,) if pin.sdist is not None else ()))