nab-python 0.0.2__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 (41) hide show
  1. nab_python/_build/env.py +1 -1
  2. nab_python/_build/runner.py +14 -3
  3. nab_python/_conflict_kind.py +20 -0
  4. nab_python/_lockfile/builder.py +222 -27
  5. nab_python/_lockfile/disjointness.py +251 -51
  6. nab_python/_lockfile/pylock.py +308 -43
  7. nab_python/_lockfile/requirements.py +8 -7
  8. nab_python/_provider/extras.py +7 -7
  9. nab_python/_provider/listing.py +29 -9
  10. nab_python/_provider/metadata_resolver.py +31 -13
  11. nab_python/_provider/sources.py +3 -0
  12. nab_python/_testing/coordinator_fake.py +17 -6
  13. nab_python/_toml.py +16 -0
  14. nab_python/_vendor/packaging/PROVENANCE.md +2 -2
  15. nab_python/_vendor/packaging/_range_utils.py +998 -0
  16. nab_python/_vendor/packaging/_version_utils.py +90 -0
  17. nab_python/_vendor/packaging/metadata.py +19 -4
  18. nab_python/_vendor/packaging/pylock.py +1 -0
  19. nab_python/_vendor/packaging/ranges.py +1489 -1180
  20. nab_python/_vendor/packaging/specifiers.py +436 -121
  21. nab_python/_vendor/packaging/tags.py +8 -6
  22. nab_python/config.py +647 -9
  23. nab_python/download.py +39 -9
  24. nab_python/fetch.py +60 -15
  25. nab_python/lockfile.py +88 -2
  26. nab_python/metadata.py +23 -0
  27. nab_python/provider.py +82 -33
  28. nab_python/requirements_file.py +129 -15
  29. nab_python/resolve.py +462 -33
  30. nab_python/universal/matrix.py +130 -33
  31. nab_python/universal/provider.py +20 -5
  32. nab_python/universal/reresolve.py +1 -0
  33. nab_python/universal/resolve.py +266 -34
  34. nab_python/universal/validate.py +8 -15
  35. nab_python/universal/wheel_selection.py +92 -57
  36. nab_python/workspace.py +8 -2
  37. {nab_python-0.0.2.dist-info → nab_python-0.0.4.dist-info}/METADATA +4 -5
  38. nab_python-0.0.4.dist-info/RECORD +74 -0
  39. nab_python-0.0.2.dist-info/RECORD +0 -71
  40. nab_python-0.0.2.dist-info/licenses/LICENSE +0 -21
  41. {nab_python-0.0.2.dist-info → nab_python-0.0.4.dist-info}/WHEEL +0 -0
nab_python/_build/env.py CHANGED
@@ -314,7 +314,7 @@ def _venv_python(venv_path: Path) -> Path:
314
314
  r"""Return the venv interpreter path (``Scripts\python.exe`` on Windows)."""
315
315
  if sys.platform == "win32":
316
316
  return venv_path / "Scripts" / "python.exe" # pragma: no cover
317
- return venv_path / "bin" / "python"
317
+ return venv_path / "bin" / "python" # pragma: no cover
318
318
 
319
319
 
320
320
  def _venv_scheme_paths(python_executable: Path) -> dict[str, str]:
@@ -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,14 +9,18 @@ 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
16
+ from urllib.parse import urlsplit, urlunsplit
15
17
 
16
18
  import tomli
17
19
 
18
20
  from nab_index.client import SdistFile, WheelFile
19
21
 
22
+ from .._toml import tool_nab_section
23
+ from .._vendor.packaging.pylock import Pylock, PylockValidationError
20
24
  from .._vendor.packaging.utils import canonicalize_name
21
25
 
22
26
  if TYPE_CHECKING:
@@ -38,8 +42,11 @@ if TYPE_CHECKING:
38
42
 
39
43
  __all__ = [
40
44
  "MissingHashError",
45
+ "MissingSdistError",
46
+ "MissingVcsCommitError",
41
47
  "build_lock_input_from_provider",
42
48
  "read_lockfile_anchor",
49
+ "read_lockfile_packages",
43
50
  ]
44
51
 
45
52
 
@@ -68,6 +75,12 @@ class LockInputProvider(Protocol):
68
75
  may supply a stub without inheriting the full Provider class.
69
76
  """
70
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
+
71
84
  @property
72
85
  def coordinator(self) -> _LockInputCoordinator:
73
86
  """Coordinator used to look up the index that served a listing."""
@@ -108,6 +121,30 @@ class MissingHashError(ValueError):
108
121
  """
109
122
 
110
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
+
111
148
  def read_lockfile_anchor(path: Path) -> datetime | None:
112
149
  """Return the ``[tool.nab].created-at`` timestamp from ``path`` if any.
113
150
 
@@ -128,19 +165,60 @@ def read_lockfile_anchor(path: Path) -> datetime | None:
128
165
  data = tomli.load(f)
129
166
  except (OSError, tomli.TOMLDecodeError):
130
167
  return None
131
- 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
132
170
  if isinstance(raw, datetime):
133
171
  return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
134
172
  if isinstance(raw, str):
173
+ # Python 3.10's fromisoformat rejects a trailing 'Z'; 3.11+ accept it.
174
+ iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
135
175
  try:
136
- dt = datetime.fromisoformat(raw)
176
+ dt = datetime.fromisoformat(iso)
137
177
  except ValueError:
138
178
  return None
139
179
  return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
140
180
  return None
141
181
 
142
182
 
143
- def build_lock_input_from_provider(
183
+ def read_lockfile_packages(path: Path) -> dict[str, Version] | None:
184
+ """Return the ``name -> version`` map from a prior pylock at ``path``.
185
+
186
+ Used by ``nab lock`` to diff a re-lock against the previous result.
187
+ Packages without a recorded version (direct-reference entries that
188
+ omit it) are skipped.
189
+
190
+ Returns ``None`` when ``path`` does not exist, is not valid TOML,
191
+ or is not a spec-compliant PEP 751 lockfile; the caller falls back
192
+ to a no-diff summary line.
193
+ """
194
+ if not path.is_file():
195
+ return None
196
+ try:
197
+ with path.open("rb") as f:
198
+ data = tomli.load(f)
199
+ pylock = Pylock.from_dict(data)
200
+ except (OSError, tomli.TOMLDecodeError, PylockValidationError):
201
+ return None
202
+ return {
203
+ str(pkg.name): pkg.version for pkg in pylock.packages if pkg.version is not None
204
+ }
205
+
206
+
207
+ 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.
215
+ """
216
+ parts = urlsplit(url)
217
+ netloc = parts.netloc.rpartition("@")[2]
218
+ return urlunsplit(parts._replace(netloc=netloc))
219
+
220
+
221
+ def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a distinct lockfile field
144
222
  provider: LockInputProvider,
145
223
  pins: Mapping[str, Version],
146
224
  *,
@@ -150,6 +228,7 @@ def build_lock_input_from_provider(
150
228
  default_groups: Sequence[str] = (),
151
229
  created_by: str = "nab",
152
230
  indexes: Sequence[IndexConfig] = (),
231
+ resolved_keys: Iterable[str] = (),
153
232
  ) -> LockInput:
154
233
  """Build a :class:`LockInput` from a finished resolve.
155
234
 
@@ -162,6 +241,10 @@ def build_lock_input_from_provider(
162
241
  were folded into this resolve; ``default_groups`` is the subset
163
242
  that a default install (no ``--group`` flag) should apply.
164
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
+
165
248
  All wheels and the sdist for each pinned version are recorded so
166
249
  the lockfile is portable across architectures of the same Python.
167
250
  """
@@ -176,6 +259,8 @@ def build_lock_input_from_provider(
176
259
  name=canonical,
177
260
  version=str(version),
178
261
  path=str(Path(local_source.path).resolve()),
262
+ editable=local_source.editable,
263
+ subdirectory=local_source.subdirectory,
179
264
  )
180
265
  continue
181
266
  vcs_source = provider.vcs_source_for(canonical)
@@ -197,9 +282,53 @@ def build_lock_input_from_provider(
197
282
  extras=tuple(extras),
198
283
  dependency_groups=tuple(dependency_groups),
199
284
  default_groups=tuple(default_groups),
285
+ dependencies=_forward_dependency_graph(provider, pins, resolved_keys),
200
286
  )
201
287
 
202
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
+
203
332
  def _index_pin_from_listing(
204
333
  provider: LockInputProvider,
205
334
  canonical: str,
@@ -212,8 +341,8 @@ def _index_pin_from_listing(
212
341
  served the package's listing during the resolve, looked up from
213
342
  the coordinator's :class:`InMemoryIndex` (which records the
214
343
  serving index by name) and resolved against ``indexes`` for the
215
- URL. When ``indexes`` is empty or the route is unknown the URL
216
- 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.
217
346
 
218
347
  Under :attr:`~nab_python.provider.DistPolicy.SDIST_INSTALL` the
219
348
  package's wheels stayed in ``versions_cache`` as a possible
@@ -227,6 +356,13 @@ def _index_pin_from_listing(
227
356
  files = list(provider.dist_files_for(canonical, version))
228
357
  if provider.effective_dist_policy(canonical) is DistPolicy.SDIST_INSTALL:
229
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)
230
366
 
231
367
  wheels = tuple(
232
368
  _build_artifact(canonical, f, WheelArtifact)
@@ -242,17 +378,22 @@ def _index_pin_from_listing(
242
378
  requires_python = _common_requires_python(files)
243
379
  serving_name = provider.coordinator.index.get_listing_index(canonical)
244
380
  by_name = {ix.name: ix.url for ix in indexes}
245
-
246
- if serving_name is not None and serving_name in by_name:
247
- index_url = by_name[serving_name]
248
- elif indexes:
249
- index_url = indexes[0].url
250
- 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.
251
392
  index_url = DEFAULT_INDEX_URL
252
393
  return IndexPin(
253
394
  name=canonical,
254
395
  version=str(version),
255
- index=index_url,
396
+ index=_strip_userinfo(index_url),
256
397
  sdist=sdist,
257
398
  wheels=wheels,
258
399
  requires_python=requires_python,
@@ -282,9 +423,31 @@ def _build_artifact(
282
423
  url=source.url,
283
424
  hashes=hashes,
284
425
  size=source.size,
426
+ upload_time=_parse_upload_time(source.upload_time),
427
+ local_path=source.local_path,
285
428
  )
286
429
 
287
430
 
431
+ def _parse_upload_time(raw: str | None) -> datetime | None:
432
+ """Parse an index ``upload-time`` string to a UTC ``datetime``.
433
+
434
+ Accepts the RFC 3339 form the Simple/JSON API serves (``Z`` or an
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.
439
+ """
440
+ if raw is None:
441
+ return None
442
+ try:
443
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
444
+ except ValueError:
445
+ return None
446
+ if parsed.tzinfo is None:
447
+ return None
448
+ return parsed.astimezone(timezone.utc)
449
+
450
+
288
451
  def _filter_acceptable_hashes(
289
452
  canonical: str, filename: str, hashes: tuple[tuple[str, str], ...]
290
453
  ) -> tuple[tuple[str, str], ...]:
@@ -332,26 +495,58 @@ def _vcs_pin_from_source(
332
495
  ) -> VcsPin:
333
496
  """Build a :class:`VcsPin` from a :class:`VcsSource`.
334
497
 
335
- Prefer ``resolved_sha`` (the post-clone SHA recorded on the
336
- provider) over the URL's ``@<ref>``: annotated tags and floating
337
- refs only resolve to a commit after the clone runs. Fall back to
338
- 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``.
504
+
505
+ ``requested_revision`` is the URL's ``@<ref>``, kept only when it
506
+ is a named ref that differs from ``commit_id`` (i.e. the user did
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.
339
517
  """
340
- from nab_index.vcs import VcsCloneError, VcsRequest
518
+ from nab_index.vcs import VcsRequest
341
519
 
342
520
  from ..lockfile import VcsPin
343
521
 
344
- if resolved_sha is not None:
345
- commit_id = resolved_sha
346
- else:
347
- try:
348
- parsed = VcsRequest.parse(source.url)
349
- commit_id = parsed.ref
350
- except VcsCloneError:
351
- commit_id = ""
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
+
352
543
  return VcsPin(
353
544
  name=canonical,
354
545
  version=str(version),
355
- repo_url=source.url,
356
- 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,
550
+ requested_revision=requested_revision,
551
+ vcs_type=parsed.scheme,
357
552
  )