nab-python 0.0.3__py3-none-any.whl → 0.0.4__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 (37) hide show
  1. nab_python/_build/runner.py +14 -3
  2. nab_python/_conflict_kind.py +20 -0
  3. nab_python/_lockfile/builder.py +158 -30
  4. nab_python/_lockfile/disjointness.py +251 -51
  5. nab_python/_lockfile/pylock.py +205 -29
  6. nab_python/_lockfile/requirements.py +8 -7
  7. nab_python/_provider/extras.py +7 -7
  8. nab_python/_provider/listing.py +29 -9
  9. nab_python/_provider/metadata_resolver.py +24 -10
  10. nab_python/_testing/coordinator_fake.py +12 -4
  11. nab_python/_toml.py +16 -0
  12. nab_python/_vendor/packaging/PROVENANCE.md +2 -2
  13. nab_python/_vendor/packaging/_range_utils.py +337 -112
  14. nab_python/_vendor/packaging/_version_utils.py +66 -13
  15. nab_python/_vendor/packaging/metadata.py +19 -4
  16. nab_python/_vendor/packaging/pylock.py +1 -0
  17. nab_python/_vendor/packaging/ranges.py +1412 -443
  18. nab_python/_vendor/packaging/specifiers.py +291 -159
  19. nab_python/_vendor/packaging/tags.py +8 -6
  20. nab_python/config.py +609 -11
  21. nab_python/download.py +39 -9
  22. nab_python/fetch.py +60 -15
  23. nab_python/lockfile.py +56 -0
  24. nab_python/metadata.py +23 -0
  25. nab_python/provider.py +77 -33
  26. nab_python/requirements_file.py +100 -15
  27. nab_python/resolve.py +444 -30
  28. nab_python/universal/matrix.py +130 -33
  29. nab_python/universal/provider.py +20 -5
  30. nab_python/universal/reresolve.py +1 -0
  31. nab_python/universal/resolve.py +230 -32
  32. nab_python/universal/validate.py +8 -15
  33. nab_python/universal/wheel_selection.py +92 -57
  34. nab_python/workspace.py +8 -2
  35. {nab_python-0.0.3.dist-info → nab_python-0.0.4.dist-info}/METADATA +3 -3
  36. {nab_python-0.0.3.dist-info → nab_python-0.0.4.dist-info}/RECORD +37 -35
  37. {nab_python-0.0.3.dist-info → nab_python-0.0.4.dist-info}/WHEEL +0 -0
@@ -34,11 +34,13 @@ import pyproject_hooks
34
34
  import tomli
35
35
 
36
36
  from .._vendor.packaging.requirements import Requirement
37
- from .._vendor.packaging.specifiers import SpecifierSet
37
+ from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
38
38
  from .._vendor.packaging.utils import canonicalize_name
39
39
  from .._vendor.packaging.version import InvalidVersion, Version
40
+ from nab_resolver.resolver import ResolutionError
41
+
40
42
  from ..metadata import WheelMetadata
41
- from .env import NabBuildEnv
43
+ from .env import BuildEnvError, NabBuildEnv
42
44
  from .errors import BuildBackendError
43
45
 
44
46
  if TYPE_CHECKING:
@@ -121,6 +123,9 @@ def run_build_backend(
121
123
  except build.BuildBackendException as exc:
122
124
  msg = f"build backend {backend!r} failed: {exc}"
123
125
  raise BuildBackendError(msg) from exc
126
+ except (BuildEnvError, ResolutionError) as exc:
127
+ msg = f"build env setup for {backend!r} failed: {exc}"
128
+ raise BuildBackendError(msg) from exc
124
129
 
125
130
 
126
131
  def _read_build_system(
@@ -228,7 +233,13 @@ def _parse_metadata(metadata_path: Path) -> WheelMetadata:
228
233
  raise BuildBackendError(msg) from exc
229
234
 
230
235
  requires_python_raw = msg_obj.get("Requires-Python")
231
- requires_python = SpecifierSet(requires_python_raw) if requires_python_raw else None
236
+ try:
237
+ requires_python = (
238
+ SpecifierSet(requires_python_raw) if requires_python_raw else None
239
+ )
240
+ except InvalidSpecifier as exc:
241
+ msg = f"backend METADATA has invalid Requires-Python {requires_python_raw!r}: {exc}"
242
+ raise BuildBackendError(msg) from exc
232
243
 
233
244
  requires_dist: list[Requirement] = []
234
245
  for raw in msg_obj.get_all("Requires-Dist") or ():
@@ -0,0 +1,20 @@
1
+ """Conflict-kind constants and PEP 508 marker-variable mapping.
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
5
+ cycle. :class:`nab_python.config.ConflictKind` takes its enum values from
6
+ ``KIND_EXTRA`` / ``KIND_GROUP`` so a rename here flows to every consumer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ KIND_EXTRA = "extra"
12
+ KIND_GROUP = "group"
13
+
14
+ # Membership of a conflict-fork member emits ``'name' in <variable>`` on
15
+ # the per-package marker; this mapping is the (kind -> variable) contract
16
+ # the universal matrix and the disjointness validator share.
17
+ MARKER_VARIABLE_FOR_KIND = {
18
+ KIND_EXTRA: "extras",
19
+ KIND_GROUP: "dependency_groups",
20
+ }
@@ -9,6 +9,7 @@ be read directly without a second fetch. This module also owns the
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from collections import defaultdict
12
13
  from datetime import datetime, timezone
13
14
  from pathlib import Path
14
15
  from typing import TYPE_CHECKING, Protocol, overload
@@ -18,6 +19,7 @@ import tomli
18
19
 
19
20
  from nab_index.client import SdistFile, WheelFile
20
21
 
22
+ from .._toml import tool_nab_section
21
23
  from .._vendor.packaging.pylock import Pylock, PylockValidationError
22
24
  from .._vendor.packaging.utils import canonicalize_name
23
25
 
@@ -40,6 +42,8 @@ if TYPE_CHECKING:
40
42
 
41
43
  __all__ = [
42
44
  "MissingHashError",
45
+ "MissingSdistError",
46
+ "MissingVcsCommitError",
43
47
  "build_lock_input_from_provider",
44
48
  "read_lockfile_anchor",
45
49
  "read_lockfile_packages",
@@ -71,6 +75,12 @@ class LockInputProvider(Protocol):
71
75
  may supply a stub without inheriting the full Provider class.
72
76
  """
73
77
 
78
+ deps_cache: Mapping[tuple[str, Version], Mapping[str, object]]
79
+ """Direct dependencies per ``(canonical name, version)``."""
80
+
81
+ extra_deps_map: Mapping[tuple[str, Version], Mapping[str, Mapping[str, object]]]
82
+ """Per-extra dependencies per ``(canonical name, version)``."""
83
+
74
84
  @property
75
85
  def coordinator(self) -> _LockInputCoordinator:
76
86
  """Coordinator used to look up the index that served a listing."""
@@ -111,6 +121,30 @@ class MissingHashError(ValueError):
111
121
  """
112
122
 
113
123
 
124
+ class MissingSdistError(ValueError):
125
+ """A ``sdist-install`` package's pinned version has no sdist.
126
+
127
+ Under :attr:`~nab_python.provider.DistPolicy.SDIST_INSTALL` the
128
+ resolver may read a wheel's metadata but the lock must pin only the
129
+ sdist. When the pinned version publishes wheels but no sdist, the
130
+ wheels are dropped and nothing is left to pin. Surface the package
131
+ and version so the user can pick a version with an sdist or relax
132
+ the policy, rather than emitting an empty package the spec rejects.
133
+ """
134
+
135
+
136
+ class MissingVcsCommitError(ValueError):
137
+ """A VCS source reached the lock writer without a resolved commit SHA.
138
+
139
+ PEP 751 requires ``packages.vcs.commit-id`` to be an immutable
140
+ identifier. nab records the post-clone SHA on the provider during
141
+ materialisation, before any version can be pinned, so a missing SHA
142
+ here means a VCS source was pinned without being cloned. Surface it
143
+ loudly rather than emit a branch name or empty string as the commit
144
+ id, which would silently produce a non-reproducible lock.
145
+ """
146
+
147
+
114
148
  def read_lockfile_anchor(path: Path) -> datetime | None:
115
149
  """Return the ``[tool.nab].created-at`` timestamp from ``path`` if any.
116
150
 
@@ -131,7 +165,8 @@ def read_lockfile_anchor(path: Path) -> datetime | None:
131
165
  data = tomli.load(f)
132
166
  except (OSError, tomli.TOMLDecodeError):
133
167
  return None
134
- raw = data.get("tool", {}).get("nab", {}).get("created-at")
168
+ nab = tool_nab_section(data)
169
+ raw = nab.get("created-at") if isinstance(nab, dict) else None
135
170
  if isinstance(raw, datetime):
136
171
  return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
137
172
  if isinstance(raw, str):
@@ -183,7 +218,7 @@ def _strip_userinfo(url: str) -> str:
183
218
  return urlunsplit(parts._replace(netloc=netloc))
184
219
 
185
220
 
186
- def build_lock_input_from_provider(
221
+ def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a distinct lockfile field
187
222
  provider: LockInputProvider,
188
223
  pins: Mapping[str, Version],
189
224
  *,
@@ -193,6 +228,7 @@ def build_lock_input_from_provider(
193
228
  default_groups: Sequence[str] = (),
194
229
  created_by: str = "nab",
195
230
  indexes: Sequence[IndexConfig] = (),
231
+ resolved_keys: Iterable[str] = (),
196
232
  ) -> LockInput:
197
233
  """Build a :class:`LockInput` from a finished resolve.
198
234
 
@@ -205,6 +241,10 @@ def build_lock_input_from_provider(
205
241
  were folded into this resolve; ``default_groups`` is the subset
206
242
  that a default install (no ``--group`` flag) should apply.
207
243
 
244
+ ``resolved_keys`` is the full set of resolver result keys, including
245
+ ``name[extra]`` proxies; it is read to find which extras activated
246
+ so their edges join the forward dependency graph.
247
+
208
248
  All wheels and the sdist for each pinned version are recorded so
209
249
  the lockfile is portable across architectures of the same Python.
210
250
  """
@@ -242,9 +282,53 @@ def build_lock_input_from_provider(
242
282
  extras=tuple(extras),
243
283
  dependency_groups=tuple(dependency_groups),
244
284
  default_groups=tuple(default_groups),
285
+ dependencies=_forward_dependency_graph(provider, pins, resolved_keys),
245
286
  )
246
287
 
247
288
 
289
+ def _forward_dependency_graph(
290
+ provider: LockInputProvider,
291
+ pins: Mapping[str, Version],
292
+ resolved_keys: Iterable[str],
293
+ ) -> dict[str, tuple[str, ...]]:
294
+ """Build the forward dependency graph among the locked packages.
295
+
296
+ Each pinned package maps to the canonical names of its direct
297
+ dependencies that are themselves pinned. Base dependencies come
298
+ from ``deps_cache``; an activated extra (a ``name[extra]`` key in
299
+ ``resolved_keys``) folds that extra's dependencies in too. Names
300
+ not in ``pins`` are dropped so every edge points at a real
301
+ ``[[packages]]`` entry.
302
+ """
303
+ from ..provider import split_extra
304
+
305
+ activated_extras: defaultdict[str, set[str]] = defaultdict(set)
306
+ for key in resolved_keys:
307
+ base, extra = split_extra(key)
308
+ if extra is not None:
309
+ activated_extras[canonicalize_name(base)].add(extra)
310
+
311
+ pinned = {canonicalize_name(name) for name in pins}
312
+ graph: dict[str, tuple[str, ...]] = {}
313
+ for raw_name, version in pins.items():
314
+ canonical = canonicalize_name(raw_name)
315
+ cache_key = (canonical, version)
316
+ dep_names = {
317
+ canonicalize_name(split_extra(dep)[0])
318
+ for dep in provider.deps_cache.get(cache_key, {})
319
+ }
320
+ extra_map = provider.extra_deps_map.get(cache_key, {})
321
+ for extra in activated_extras.get(canonical, ()):
322
+ dep_names.update(
323
+ canonicalize_name(split_extra(dep)[0])
324
+ for dep in extra_map.get(extra, {})
325
+ )
326
+ dep_names &= pinned
327
+ if dep_names:
328
+ graph[canonical] = tuple(sorted(dep_names))
329
+ return graph
330
+
331
+
248
332
  def _index_pin_from_listing(
249
333
  provider: LockInputProvider,
250
334
  canonical: str,
@@ -257,8 +341,8 @@ def _index_pin_from_listing(
257
341
  served the package's listing during the resolve, looked up from
258
342
  the coordinator's :class:`InMemoryIndex` (which records the
259
343
  serving index by name) and resolved against ``indexes`` for the
260
- URL. When ``indexes`` is empty or the route is unknown the URL
261
- falls back to the default Simple-API root.
344
+ URL. A pinned package's serving index is always recorded and is
345
+ one of ``indexes``, so the URL is always known.
262
346
 
263
347
  Under :attr:`~nab_python.provider.DistPolicy.SDIST_INSTALL` the
264
348
  package's wheels stayed in ``versions_cache`` as a possible
@@ -272,6 +356,13 @@ def _index_pin_from_listing(
272
356
  files = list(provider.dist_files_for(canonical, version))
273
357
  if provider.effective_dist_policy(canonical) is DistPolicy.SDIST_INSTALL:
274
358
  files = [f for f in files if not isinstance(f, WheelFile)]
359
+ if not any(isinstance(f, SdistFile) for f in files):
360
+ msg = (
361
+ f"{canonical}=={version} has no sdist, but its dist-policy is "
362
+ f"'sdist-install'; pick a version that publishes an sdist or "
363
+ f"change dist-policy for {canonical}"
364
+ )
365
+ raise MissingSdistError(msg)
275
366
 
276
367
  wheels = tuple(
277
368
  _build_artifact(canonical, f, WheelArtifact)
@@ -287,12 +378,17 @@ def _index_pin_from_listing(
287
378
  requires_python = _common_requires_python(files)
288
379
  serving_name = provider.coordinator.index.get_listing_index(canonical)
289
380
  by_name = {ix.name: ix.url for ix in indexes}
290
-
291
- if serving_name is not None and serving_name in by_name:
292
- index_url = by_name[serving_name]
293
- elif indexes:
294
- index_url = indexes[0].url
295
- else:
381
+ index_url = by_name.get(serving_name) if serving_name is not None else None
382
+ if index_url is None:
383
+ if by_name:
384
+ # Can't happen for a real pin (the serving index is always
385
+ # recorded and configured); raise, don't guess.
386
+ msg = (
387
+ f"{canonical}: recorded serving index {serving_name!r} is "
388
+ "not one of the configured indexes"
389
+ )
390
+ raise AssertionError(msg)
391
+ # No indexes configured (unit tests only); use the default root.
296
392
  index_url = DEFAULT_INDEX_URL
297
393
  return IndexPin(
298
394
  name=canonical,
@@ -333,19 +429,23 @@ def _build_artifact(
333
429
 
334
430
 
335
431
  def _parse_upload_time(raw: str | None) -> datetime | None:
336
- """Parse an index ``upload-time`` string to a ``datetime``.
432
+ """Parse an index ``upload-time`` string to a UTC ``datetime``.
337
433
 
338
434
  Accepts the RFC 3339 form the Simple/JSON API serves (``Z`` or an
339
- explicit offset). Returns ``None`` when the field is absent or
340
- unparseable; the timestamp is informational, so a bad value is
341
- dropped rather than fatal.
435
+ explicit offset) and normalizes it to UTC (PEP 751 requires UTC for
436
+ the emitted field). Returns ``None`` when the field is absent,
437
+ unparseable, or has no timezone; the timestamp is informational, so
438
+ a bad value is dropped rather than fatal.
342
439
  """
343
440
  if raw is None:
344
441
  return None
345
442
  try:
346
- return datetime.fromisoformat(raw.replace("Z", "+00:00"))
443
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
347
444
  except ValueError:
348
445
  return None
446
+ if parsed.tzinfo is None:
447
+ return None
448
+ return parsed.astimezone(timezone.utc)
349
449
 
350
450
 
351
451
  def _filter_acceptable_hashes(
@@ -395,30 +495,58 @@ def _vcs_pin_from_source(
395
495
  ) -> VcsPin:
396
496
  """Build a :class:`VcsPin` from a :class:`VcsSource`.
397
497
 
398
- Prefer ``resolved_sha`` (the post-clone SHA recorded on the
399
- provider) over the URL's ``@<ref>``: annotated tags and floating
400
- refs only resolve to a commit after the clone runs. Fall back to
401
- the URL ref when the source was never materialised.
498
+ ``resolved_sha`` is the post-clone SHA recorded on the provider by
499
+ :func:`~nab_python._provider.sources.materialize_vcs_source`. A VCS
500
+ source cannot be pinned without first being materialised, so a
501
+ ``None`` here is an internal invariant violation: raise
502
+ :class:`MissingVcsCommitError` rather than emit a branch name or
503
+ empty string as ``commit_id``.
402
504
 
403
505
  ``requested_revision`` is the URL's ``@<ref>``, kept only when it
404
506
  is a named ref that differs from ``commit_id`` (i.e. the user did
405
- not pin the bare SHA). An absent or unparseable ref leaves it
406
- ``None``.
507
+ not pin the bare SHA). ``subdirectory`` carries the
508
+ ``#subdirectory=`` fragment so an installer can locate the project
509
+ inside the repo.
510
+
511
+ ``bare_repo_url`` comes from ``parsed.repo_url``, which
512
+ :meth:`VcsRequest.parse` has already separated from the ref and the
513
+ fragment. ``repo_url`` re-pins that bare URL to ``commit_id`` (the
514
+ ``git+`` prefix, ``@<sha>``, and any ``#subdirectory=`` fragment) so
515
+ the requirements.txt line installs the locked commit, not the ref
516
+ the user supplied.
407
517
  """
408
- from nab_index.vcs import VcsCloneError, VcsRequest
518
+ from nab_index.vcs import VcsRequest
409
519
 
410
520
  from ..lockfile import VcsPin
411
521
 
412
- try:
413
- url_ref = VcsRequest.parse(source.url).ref
414
- except VcsCloneError:
415
- url_ref = ""
416
- commit_id = resolved_sha if resolved_sha is not None else url_ref
417
- requested_revision = url_ref if url_ref and url_ref != commit_id else None
522
+ if resolved_sha is None:
523
+ msg = (
524
+ f"{canonical}: VCS source pinned without a resolved commit SHA;"
525
+ " materialize_vcs_source records the post-clone SHA before any"
526
+ " version can be pinned, so this is an internal invariant"
527
+ " violation"
528
+ )
529
+ raise MissingVcsCommitError(msg)
530
+ parsed = VcsRequest.parse(source.url)
531
+ # Keep the named ref only when it differs from the SHA (tag or branch case).
532
+ requested_revision = (
533
+ parsed.ref if parsed.ref and parsed.ref != resolved_sha else None
534
+ )
535
+
536
+ # Compose a pinned installable URL from the parsed pieces, not from source.url,
537
+ # so credentials are stripped and the sha replaces any floating ref.
538
+ bare_repo_url = _strip_userinfo(parsed.repo_url)
539
+ repo_url = f"{parsed.scheme}+{bare_repo_url}@{resolved_sha}"
540
+ if parsed.subdirectory:
541
+ repo_url += f"#subdirectory={parsed.subdirectory}"
542
+
418
543
  return VcsPin(
419
544
  name=canonical,
420
545
  version=str(version),
421
- repo_url=_strip_userinfo(source.url),
422
- commit_id=commit_id,
546
+ repo_url=repo_url,
547
+ bare_repo_url=bare_repo_url,
548
+ commit_id=resolved_sha,
549
+ subdirectory=parsed.subdirectory or None,
423
550
  requested_revision=requested_revision,
551
+ vcs_type=parsed.scheme,
424
552
  )