nab-python 0.0.2__py3-none-any.whl → 0.0.3__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.
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]:
@@ -12,11 +12,13 @@ from __future__ import annotations
12
12
  from datetime import datetime, timezone
13
13
  from pathlib import Path
14
14
  from typing import TYPE_CHECKING, Protocol, overload
15
+ from urllib.parse import urlsplit, urlunsplit
15
16
 
16
17
  import tomli
17
18
 
18
19
  from nab_index.client import SdistFile, WheelFile
19
20
 
21
+ from .._vendor.packaging.pylock import Pylock, PylockValidationError
20
22
  from .._vendor.packaging.utils import canonicalize_name
21
23
 
22
24
  if TYPE_CHECKING:
@@ -40,6 +42,7 @@ __all__ = [
40
42
  "MissingHashError",
41
43
  "build_lock_input_from_provider",
42
44
  "read_lockfile_anchor",
45
+ "read_lockfile_packages",
43
46
  ]
44
47
 
45
48
 
@@ -132,14 +135,54 @@ def read_lockfile_anchor(path: Path) -> datetime | None:
132
135
  if isinstance(raw, datetime):
133
136
  return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
134
137
  if isinstance(raw, str):
138
+ # Python 3.10's fromisoformat rejects a trailing 'Z'; 3.11+ accept it.
139
+ iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
135
140
  try:
136
- dt = datetime.fromisoformat(raw)
141
+ dt = datetime.fromisoformat(iso)
137
142
  except ValueError:
138
143
  return None
139
144
  return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
140
145
  return None
141
146
 
142
147
 
148
+ def read_lockfile_packages(path: Path) -> dict[str, Version] | None:
149
+ """Return the ``name -> version`` map from a prior pylock at ``path``.
150
+
151
+ Used by ``nab lock`` to diff a re-lock against the previous result.
152
+ Packages without a recorded version (direct-reference entries that
153
+ omit it) are skipped.
154
+
155
+ Returns ``None`` when ``path`` does not exist, is not valid TOML,
156
+ or is not a spec-compliant PEP 751 lockfile; the caller falls back
157
+ to a no-diff summary line.
158
+ """
159
+ if not path.is_file():
160
+ return None
161
+ try:
162
+ with path.open("rb") as f:
163
+ data = tomli.load(f)
164
+ pylock = Pylock.from_dict(data)
165
+ except (OSError, tomli.TOMLDecodeError, PylockValidationError):
166
+ return None
167
+ return {
168
+ str(pkg.name): pkg.version for pkg in pylock.packages if pkg.version is not None
169
+ }
170
+
171
+
172
+ def _strip_userinfo(url: str) -> str:
173
+ """Return ``url`` with any ``user:password@`` userinfo removed.
174
+
175
+ Lockfiles are committed to version control, so an index or VCS
176
+ URL carrying embedded credentials must not be written verbatim.
177
+ Only the userinfo is dropped: host case and port are preserved
178
+ and a ``git+`` scheme prefix is left intact. A no-op for URLs
179
+ without credentials.
180
+ """
181
+ parts = urlsplit(url)
182
+ netloc = parts.netloc.rpartition("@")[2]
183
+ return urlunsplit(parts._replace(netloc=netloc))
184
+
185
+
143
186
  def build_lock_input_from_provider(
144
187
  provider: LockInputProvider,
145
188
  pins: Mapping[str, Version],
@@ -176,6 +219,8 @@ def build_lock_input_from_provider(
176
219
  name=canonical,
177
220
  version=str(version),
178
221
  path=str(Path(local_source.path).resolve()),
222
+ editable=local_source.editable,
223
+ subdirectory=local_source.subdirectory,
179
224
  )
180
225
  continue
181
226
  vcs_source = provider.vcs_source_for(canonical)
@@ -252,7 +297,7 @@ def _index_pin_from_listing(
252
297
  return IndexPin(
253
298
  name=canonical,
254
299
  version=str(version),
255
- index=index_url,
300
+ index=_strip_userinfo(index_url),
256
301
  sdist=sdist,
257
302
  wheels=wheels,
258
303
  requires_python=requires_python,
@@ -282,9 +327,27 @@ def _build_artifact(
282
327
  url=source.url,
283
328
  hashes=hashes,
284
329
  size=source.size,
330
+ upload_time=_parse_upload_time(source.upload_time),
331
+ local_path=source.local_path,
285
332
  )
286
333
 
287
334
 
335
+ def _parse_upload_time(raw: str | None) -> datetime | None:
336
+ """Parse an index ``upload-time`` string to a ``datetime``.
337
+
338
+ 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.
342
+ """
343
+ if raw is None:
344
+ return None
345
+ try:
346
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
347
+ except ValueError:
348
+ return None
349
+
350
+
288
351
  def _filter_acceptable_hashes(
289
352
  canonical: str, filename: str, hashes: tuple[tuple[str, str], ...]
290
353
  ) -> tuple[tuple[str, str], ...]:
@@ -336,22 +399,26 @@ def _vcs_pin_from_source(
336
399
  provider) over the URL's ``@<ref>``: annotated tags and floating
337
400
  refs only resolve to a commit after the clone runs. Fall back to
338
401
  the URL ref when the source was never materialised.
402
+
403
+ ``requested_revision`` is the URL's ``@<ref>``, kept only when it
404
+ 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``.
339
407
  """
340
408
  from nab_index.vcs import VcsCloneError, VcsRequest
341
409
 
342
410
  from ..lockfile import VcsPin
343
411
 
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 = ""
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
352
418
  return VcsPin(
353
419
  name=canonical,
354
420
  version=str(version),
355
- repo_url=source.url,
421
+ repo_url=_strip_userinfo(source.url),
356
422
  commit_id=commit_id,
423
+ requested_revision=requested_revision,
357
424
  )
@@ -11,6 +11,7 @@ disjointness validation lives in
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import os
14
15
  from collections import defaultdict
15
16
  from pathlib import Path
16
17
  from typing import TYPE_CHECKING, Any
@@ -32,7 +33,6 @@ from .._vendor.packaging.version import Version
32
33
  from .disjointness import validate_marker_disjointness
33
34
 
34
35
  if TYPE_CHECKING:
35
- import os
36
36
  from collections.abc import Mapping, Sequence
37
37
 
38
38
  from ..lockfile import (
@@ -57,10 +57,16 @@ def write_lock(
57
57
  """Serialise ``lock_input`` to PEP 751 TOML text.
58
58
 
59
59
  Returns the TOML text. When ``output_path`` is provided, also
60
- writes it atomically; the caller chooses the path (PEP 751 does
61
- not mandate one).
60
+ writes it there; the caller chooses the path (PEP 751 does not
61
+ mandate one).
62
+
63
+ Directory, wheel and sdist paths are written relative to
64
+ ``output_path``'s parent so the lockfile stays portable between
65
+ machines (PEP 751 records those paths relative to the lock file).
66
+ With no ``output_path`` the current directory is the base.
62
67
  """
63
- pylock = build_pylock(lock_input)
68
+ lock_dir = Path(output_path).parent if output_path is not None else None
69
+ pylock = build_pylock(lock_input, lock_dir=lock_dir)
64
70
  pylock.validate()
65
71
  text = tomli_w.dumps(dict(pylock.to_dict()))
66
72
  if output_path is not None:
@@ -68,20 +74,28 @@ def write_lock(
68
74
  return text
69
75
 
70
76
 
71
- def build_pylock(lock_input: LockInput) -> Pylock:
77
+ def build_pylock(lock_input: LockInput, *, lock_dir: Path | None = None) -> Pylock:
72
78
  """Build a :class:`Pylock` from the input shape.
73
79
 
74
80
  The resolver-side data structures have already been simplified
75
81
  when this function runs. The remaining work is shape conversion:
76
82
  ``Pin`` -> ``Package``, plus marker attachment from the per-tuple
77
83
  map.
84
+
85
+ ``lock_dir`` is the directory the lockfile will be written to;
86
+ local-directory, wheel and sdist paths are emitted relative to it
87
+ so the lockfile is portable. It defaults to the current working
88
+ directory when the caller has no path in mind (e.g. stdout).
78
89
  """
79
90
  from ..lockfile import LOCK_VERSION
80
91
 
92
+ base = (lock_dir if lock_dir is not None else Path.cwd()).resolve()
81
93
  if lock_input.per_tuple_pins:
82
- package_records = _build_per_tuple_packages(lock_input)
94
+ package_records = _build_per_tuple_packages(lock_input, base)
83
95
  else:
84
- package_records = [_pin_to_package(pin) for pin in lock_input.pins.values()]
96
+ package_records = [
97
+ _pin_to_package(pin, lock_dir=base) for pin in lock_input.pins.values()
98
+ ]
85
99
  package_records.sort(key=_package_sort_key)
86
100
  validate_marker_disjointness(
87
101
  package_records,
@@ -121,7 +135,29 @@ def build_pylock(lock_input: LockInput) -> Pylock:
121
135
  )
122
136
 
123
137
 
124
- def _pin_to_package(pin: PinShape, marker: Marker | None = None) -> Package:
138
+ def _relativize_path(target: str | os.PathLike[str], lock_dir: Path) -> str:
139
+ """Return ``target`` as a POSIX path relative to ``lock_dir``.
140
+
141
+ PEP 751 records ``packages.directory.path`` and the wheel/sdist
142
+ ``path`` fields relative to the lock file so the lockfile stays
143
+ portable between machines. :func:`os.path.relpath` is used
144
+ rather than :meth:`pathlib.Path.relative_to` so a ``target``
145
+ outside ``lock_dir`` still resolves, to a ``../``-prefixed path.
146
+ The result uses POSIX separators, which the spec recommends for
147
+ portable relative paths.
148
+
149
+ A Windows cross-drive ValueError falls back to the absolute path.
150
+ """
151
+ try:
152
+ rel = os.path.relpath(target, lock_dir)
153
+ except ValueError:
154
+ rel = os.fspath(target)
155
+ return Path(rel).as_posix()
156
+
157
+
158
+ def _pin_to_package(
159
+ pin: PinShape, marker: Marker | None = None, *, lock_dir: Path
160
+ ) -> Package:
125
161
  from ..lockfile import IndexPin, LocalPin, VcsPin
126
162
 
127
163
  if isinstance(pin, IndexPin):
@@ -133,51 +169,92 @@ def _pin_to_package(pin: PinShape, marker: Marker | None = None) -> Package:
133
169
  SpecifierSet(pin.requires_python) if pin.requires_python else None
134
170
  ),
135
171
  index=pin.index,
136
- sdist=_sdist_to_package(pin.sdist) if pin.sdist else None,
137
- wheels=tuple(_wheel_to_package(w) for w in pin.wheels) or None,
172
+ sdist=(
173
+ _sdist_to_package(pin.sdist, lock_dir=lock_dir) if pin.sdist else None
174
+ ),
175
+ wheels=tuple(_wheel_to_package(w, lock_dir=lock_dir) for w in pin.wheels)
176
+ or None,
138
177
  )
139
178
  if isinstance(pin, LocalPin):
179
+ # PEP 751: omit version for directory sources; it is not
180
+ # deterministic (the source tree may change at install time).
181
+ # The path is recorded relative to the lock file for portability.
140
182
  return Package(
141
183
  name=canonicalize_name(pin.name),
142
- version=Version(pin.version),
184
+ version=None,
143
185
  marker=marker,
144
- directory=PackageDirectory(path=pin.path, editable=False),
186
+ directory=PackageDirectory(
187
+ path=_relativize_path(pin.path, lock_dir),
188
+ editable=pin.editable,
189
+ subdirectory=pin.subdirectory,
190
+ ),
145
191
  )
146
192
  if isinstance(pin, VcsPin):
193
+ # PEP 751: omit version for VCS sources for the same reason.
147
194
  return Package(
148
195
  name=canonicalize_name(pin.name),
149
- version=Version(pin.version),
196
+ version=None,
150
197
  marker=marker,
151
198
  vcs=PackageVcs(
152
199
  type="git",
153
200
  url=pin.repo_url,
154
201
  commit_id=pin.commit_id,
155
202
  subdirectory=pin.subdirectory,
203
+ requested_revision=pin.requested_revision,
156
204
  ),
157
205
  )
158
206
  msg = f"unknown pin shape: {pin!r}"
159
207
  raise TypeError(msg)
160
208
 
161
209
 
162
- def _wheel_to_package(wheel: WheelArtifact) -> PackageWheel:
210
+ def _wheel_to_package(wheel: WheelArtifact, *, lock_dir: Path) -> PackageWheel:
211
+ """Convert a wheel artefact to its PEP 751 ``packages.wheels`` entry.
212
+
213
+ A wheel from a local find-links directory carries its on-disk
214
+ ``local_path``; it is written as a ``path`` relative to the lock
215
+ file so the lockfile stays portable. A remote wheel records its
216
+ ``url`` verbatim.
217
+ """
218
+ if wheel.local_path is not None:
219
+ return PackageWheel(
220
+ name=wheel.filename,
221
+ path=_relativize_path(wheel.local_path.resolve(), lock_dir),
222
+ size=wheel.size,
223
+ hashes=dict(wheel.hashes),
224
+ upload_time=wheel.upload_time,
225
+ )
163
226
  return PackageWheel(
164
227
  name=wheel.filename,
165
228
  url=wheel.url,
166
229
  size=wheel.size,
167
230
  hashes=dict(wheel.hashes),
231
+ upload_time=wheel.upload_time,
168
232
  )
169
233
 
170
234
 
171
- def _sdist_to_package(sdist: SdistArtifact) -> PackageSdist:
235
+ def _sdist_to_package(sdist: SdistArtifact, *, lock_dir: Path) -> PackageSdist:
236
+ """Convert an sdist artefact to its PEP 751 ``packages.sdist`` entry.
237
+
238
+ See :func:`_wheel_to_package` for the ``local_path`` handling.
239
+ """
240
+ if sdist.local_path is not None:
241
+ return PackageSdist(
242
+ name=sdist.filename,
243
+ path=_relativize_path(sdist.local_path.resolve(), lock_dir),
244
+ size=sdist.size,
245
+ hashes=dict(sdist.hashes),
246
+ upload_time=sdist.upload_time,
247
+ )
172
248
  return PackageSdist(
173
249
  name=sdist.filename,
174
250
  url=sdist.url,
175
251
  size=sdist.size,
176
252
  hashes=dict(sdist.hashes),
253
+ upload_time=sdist.upload_time,
177
254
  )
178
255
 
179
256
 
180
- def _build_per_tuple_packages(lock_input: LockInput) -> list[Package]:
257
+ def _build_per_tuple_packages(lock_input: LockInput, lock_dir: Path) -> list[Package]:
181
258
  """Collapse per-tuple pins into Package entries with markers.
182
259
 
183
260
  For each canonical package name:
@@ -200,12 +277,14 @@ def _build_per_tuple_packages(lock_input: LockInput) -> list[Package]:
200
277
  groups = _group_pins_by_pin(per_tuple)
201
278
  for pins, tuple_labels in groups:
202
279
  marker = _build_marker(tuple_labels, lock_input.tuple_markers, total_tuples)
203
- out.append(_pin_to_package(_merge_pins_in_group(pins), marker))
280
+ out.append(
281
+ _pin_to_package(_merge_pins_in_group(pins), marker, lock_dir=lock_dir)
282
+ )
204
283
  # Pins only present in lock_input.pins (e.g. tuples agreed via the
205
284
  # single-source path) emit unconditionally.
206
285
  for canonical_name, pin in lock_input.pins.items():
207
286
  if canonical_name not in by_name:
208
- out.append(_pin_to_package(pin))
287
+ out.append(_pin_to_package(pin, lock_dir=lock_dir))
209
288
  return out
210
289
 
211
290
 
@@ -242,8 +321,18 @@ def _pin_discriminator(pin: PinShape) -> tuple:
242
321
  if isinstance(pin, IndexPin):
243
322
  return ("index", pin.version, pin.index)
244
323
  if isinstance(pin, LocalPin):
245
- return ("local", pin.version, pin.path)
324
+ # editable and subdirectory change install behaviour, so two
325
+ # otherwise-identical local pins differing only here are distinct.
326
+ return (
327
+ "local",
328
+ pin.version,
329
+ pin.path,
330
+ pin.editable,
331
+ pin.subdirectory or "",
332
+ )
246
333
  if isinstance(pin, VcsPin):
334
+ # requested_revision is informational; it does not affect the
335
+ # checkout, so it is intentionally left out of the discriminator.
247
336
  return ("vcs", pin.commit_id, pin.repo_url, pin.subdirectory or "")
248
337
  msg = f"unknown pin shape: {pin!r}"
249
338
  raise TypeError(msg)
@@ -430,7 +430,11 @@ def add_classified_dep(
430
430
  base_deps: dict[str, VersionRange],
431
431
  extra_deps_map: dict[str, dict[str, VersionRange]],
432
432
  ) -> None:
433
- """Add a classified requirement to the appropriate dep set."""
433
+ """Add a classified requirement to the appropriate dep set.
434
+
435
+ A name appearing on several ``Requires-Dist`` lines is intersected
436
+ into one range.
437
+ """
434
438
  # Late import: ``pypi`` imports this module at module load.
435
439
  from ..provider import join_extra
436
440
 
@@ -439,12 +443,12 @@ def add_classified_dep(
439
443
  dep_extras: set[str] = req.extras
440
444
 
441
445
  if not req_extras:
442
- base_deps[name] = vi
446
+ base_deps[name] = base_deps.get(name, VersionRange.full()) & vi
443
447
  for re in dep_extras:
444
448
  base_deps[join_extra(name, re)] = VersionRange.full()
445
449
  else:
446
450
  for extra_name in req_extras:
447
451
  edeps = extra_deps_map[extra_name]
448
- edeps[name] = vi
452
+ edeps[name] = edeps.get(name, VersionRange.full()) & vi
449
453
  for re in dep_extras:
450
454
  edeps[join_extra(name, re)] = VersionRange.full()
@@ -57,6 +57,8 @@ def materialize_local_source(
57
57
  or looser; raises :class:`UnsupportedSdistError` otherwise.
58
58
  """
59
59
  path = Path(source.path)
60
+ if source.subdirectory:
61
+ path = path / source.subdirectory
60
62
  metadata = extract_source_metadata(
61
63
  provider,
62
64
  path,
@@ -134,6 +136,7 @@ def seed_synthetic_listing(
134
136
  else None
135
137
  ),
136
138
  upload_time=None,
139
+ local_path=path,
137
140
  )
138
141
  version = metadata.version
139
142
  provider.metadata_cache[(normalized, version)] = metadata
@@ -13,7 +13,8 @@ import threading
13
13
  from typing import TYPE_CHECKING
14
14
  from unittest.mock import MagicMock
15
15
 
16
- from nab_python.fetch import InMemoryIndex
16
+ from nab_index.multi_index import IndexConfig
17
+ from nab_python.fetch import DEFAULT_INDEX_NAME, DEFAULT_INDEX_URL, InMemoryIndex
17
18
 
18
19
  if TYPE_CHECKING:
19
20
  from collections.abc import Callable, Mapping, Sequence
@@ -207,7 +208,8 @@ def make_coordinator( # noqa: PLR0913
207
208
  Call sites that need request side effects beyond what this helper
208
209
  wires up (for example ``request_sdist_archive``) can reassign
209
210
  ``.side_effect`` on the returned mock; the index is exposed at
210
- ``coordinator.index`` for direct manipulation.
211
+ ``coordinator.index`` for direct manipulation. ``coordinator.indexes``
212
+ defaults to the single default-PyPI :class:`IndexConfig` list.
211
213
  """
212
214
  index = InMemoryIndex()
213
215
  failures = fetch_failures if fetch_failures is not None else set()
@@ -223,6 +225,7 @@ def make_coordinator( # noqa: PLR0913
223
225
 
224
226
  coordinator = MagicMock()
225
227
  coordinator.index = index
228
+ coordinator.indexes = [IndexConfig(DEFAULT_INDEX_NAME, DEFAULT_INDEX_URL)]
226
229
 
227
230
  resolve_metadata = _make_metadata_resolver(
228
231
  metadata_text=metadata_text,
@@ -9,8 +9,8 @@ from an in-flight pull request, vendored so nab can use the public
9
9
  - Upstream repository: https://github.com/pypa/packaging
10
10
  - Pull request: https://github.com/pypa/packaging/pull/1182
11
11
  - Source branch: `notatallshaw/packaging:public-pep440-version-range`
12
- - Pinned commit: `536a8a7ba0c552b4573522292d1ea63ccb4ad34c`
13
- - Snapshot date: 2026-05-03
12
+ - Pinned commit: `82799d02ffec2815769d5889062e54686e7c6863`
13
+ - Snapshot date: 2026-05-23
14
14
 
15
15
  The snapshot is the full `src/packaging/` tree at that commit, plus
16
16
  `LICENSE`, `LICENSE.APACHE`, and `LICENSE.BSD` from the repository