nab 0.0.1__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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """nab: a PubGrub-based dependency resolver for Python packages."""
nab/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m nab ...`` to drive the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
nab/_download.py ADDED
@@ -0,0 +1,84 @@
1
+ """``nab download`` subcommand.
2
+
3
+ Resolves a project once with the single-environment resolver and
4
+ fetches every wheel and sdist into a local directory. Universal
5
+ mode is rejected: the per-tuple lock is the install-time contract,
6
+ so a one-environment download would not represent the resolved
7
+ universe.
8
+
9
+ External callers (the resolver entry point and the download
10
+ helper) are accessed through :mod:`nab.cli` so the test suite's
11
+ ``patch("nab.cli.download_lock")`` style of monkey patches keeps
12
+ working after the per-command split.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from nab_python.config import ResolveMode
21
+ from nab_python.download import DownloadError
22
+
23
+ from . import cli as _cli
24
+ from .cli import (
25
+ HttpBackend,
26
+ PathArg,
27
+ app,
28
+ )
29
+
30
+
31
+ @app.command
32
+ def download(
33
+ path: PathArg = Path("pyproject.toml"),
34
+ *,
35
+ output: Path = Path("wheels"),
36
+ http_backend: HttpBackend = "urllib3",
37
+ cache_dir: Path | None = None,
38
+ no_cache: bool = False,
39
+ offline: bool = False,
40
+ max_concurrency: int = 8,
41
+ no_workspace_discovery: bool = False,
42
+ ) -> None:
43
+ """Resolve and download every wheel/sdist into a local directory.
44
+
45
+ Output files are named after the recorded artefact filename. The
46
+ download is idempotent: files whose sha256 already matches are
47
+ left alone. Local and VCS pins are skipped.
48
+ """
49
+ config = _cli._load_config( # noqa: SLF001
50
+ path, discover_workspace=not no_workspace_discovery
51
+ )
52
+ if config.mode is ResolveMode.UNIVERSAL:
53
+ sys.stderr.write("Error: `nab download` is single-environment only.\n")
54
+ sys.exit(1)
55
+
56
+ effective_cache_dir = _cli._resolve_effective_cache_dir( # noqa: SLF001
57
+ cache_dir, no_cache=no_cache
58
+ )
59
+ transport = _cli._make_transport(http_backend) # noqa: SLF001
60
+ result = _cli._resolve_specific( # noqa: SLF001
61
+ path,
62
+ config=config,
63
+ cache_dir=effective_cache_dir,
64
+ offline=offline,
65
+ transport=transport,
66
+ failure_prefix="Cannot download",
67
+ )
68
+
69
+ download_transport = _cli._make_transport(http_backend) # noqa: SLF001
70
+ try:
71
+ outcome = _cli.download_lock(
72
+ result.lock_input,
73
+ download_transport,
74
+ output,
75
+ max_concurrency=max_concurrency,
76
+ )
77
+ except DownloadError as e:
78
+ sys.stderr.write(f"Download failed: {e}\n")
79
+ sys.exit(1)
80
+
81
+ sys.stderr.write(
82
+ f"Downloaded {len(outcome.written)} files,"
83
+ f" {len(outcome.skipped)} already present, into {output}\n"
84
+ )
nab/_lock.py ADDED
@@ -0,0 +1,476 @@
1
+ """``nab lock`` subcommand and its lockfile-emission helpers.
2
+
3
+ Wires :func:`resolve_pyproject` / :func:`resolve_universal_pyproject`
4
+ to the writers in :mod:`nab_python.lockfile`, plus the universal-mode
5
+ per-tuple emission shapes (single-tuple file, templated per-tuple
6
+ files, multi-block stdout).
7
+
8
+ External callers (the resolver entry points, the lockfile writers,
9
+ the merge helper) are accessed through :mod:`nab.cli` so the test
10
+ suite's ``patch("nab.cli.resolve_pyproject")`` style of monkey
11
+ patches keeps working after the per-command split.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ from nab._version import __version__
22
+ from nab_python.config import (
23
+ NabProjectConfig,
24
+ ResolveMode,
25
+ )
26
+ from nab_python.lockfile import (
27
+ Provenance,
28
+ read_lockfile_anchor,
29
+ )
30
+ from nab_python.provider import ResolutionStrategy
31
+ from nab_python.requirements_file import (
32
+ read_pyproject_groups,
33
+ read_pyproject_optional_dependencies,
34
+ )
35
+
36
+ from . import cli as _cli
37
+ from .cli import (
38
+ HttpBackend,
39
+ LockFormat,
40
+ PathArg,
41
+ ResolutionFlag,
42
+ app,
43
+ )
44
+
45
+ if TYPE_CHECKING:
46
+ from nab_index.transport import AsyncHttpTransport
47
+ from nab_python.resolve import ResolutionResult
48
+ from nab_python.universal.resolve import TupleResult, UniversalResult
49
+
50
+
51
+ @app.command
52
+ def lock( # noqa: PLR0913 - tyro maps each kwarg to a CLI flag so a config object would hide the user-facing surface
53
+ path: PathArg = Path("pyproject.toml"),
54
+ *,
55
+ output: Path | None = None,
56
+ format: LockFormat = "pylock", # noqa: A002 - shadows builtin by convention
57
+ http_backend: HttpBackend = "urllib3",
58
+ cache_dir: Path | None = None,
59
+ no_cache: bool = False,
60
+ offline: bool = False,
61
+ groups: tuple[str, ...] = (),
62
+ all_groups: bool = False,
63
+ extras: tuple[str, ...] = (),
64
+ all_extras: bool = False,
65
+ no_workspace_discovery: bool = False,
66
+ resolution: ResolutionFlag | None = None,
67
+ upgrade: bool = False,
68
+ ) -> None:
69
+ """Resolve dependencies and emit a lockfile or pin list.
70
+
71
+ Formats: ``pylock`` (PEP 751), ``requirements`` (pip-style with
72
+ ``--hash`` lines), ``requirements-without-hashes`` (plain
73
+ ``name==version``). ``--output`` defaults to ``pylock.toml`` or
74
+ ``requirements.txt``; ``--output -`` writes to stdout.
75
+
76
+ ``--groups`` / ``--all-groups`` select PEP 735 dependency groups;
77
+ ``--extras`` / ``--all-extras`` select entries from
78
+ ``[project.optional-dependencies]``. Selected names are folded into
79
+ the resolve and recorded in the lockfile.
80
+
81
+ Universal mode (``[tool.nab].mode = "universal"``) supports all
82
+ three formats. For requirements formats, an ``--output`` template
83
+ containing ``{python_version}`` or ``{platform_id}`` writes one
84
+ file per matrix tuple; a plain path is rejected when multiple
85
+ tuples would collide.
86
+
87
+ ``--resolution`` overrides ``[tool.nab].resolution`` for this run.
88
+ ``--upgrade`` re-anchors the ``P<n>D`` cutoff to ``datetime.now(UTC)``
89
+ instead of reusing the timestamp recorded in any existing lockfile.
90
+ """
91
+ anchor = _determine_lock_anchor(output=output, format=format, upgrade=upgrade)
92
+ config = _cli._load_config( # noqa: SLF001
93
+ path, discover_workspace=not no_workspace_discovery, anchor=anchor
94
+ )
95
+ effective_cache_dir = _cli._resolve_effective_cache_dir( # noqa: SLF001
96
+ cache_dir, no_cache=no_cache
97
+ )
98
+ provenance = _build_provenance(path, config=config, anchor=anchor)
99
+ selected_groups = _resolve_group_selection(
100
+ path, groups=groups, all_groups=all_groups
101
+ )
102
+ selected_extras = _resolve_extra_selection(
103
+ path, extras=extras, all_extras=all_extras
104
+ )
105
+ strategy_override = (
106
+ ResolutionStrategy(resolution) if resolution is not None else None
107
+ )
108
+
109
+ transport = _cli._make_transport(http_backend) # noqa: SLF001
110
+ if config.mode is ResolveMode.UNIVERSAL:
111
+ _emit_universal(
112
+ path,
113
+ config=config,
114
+ cache_dir=effective_cache_dir,
115
+ transport=transport,
116
+ offline=offline,
117
+ output=output,
118
+ format=format,
119
+ provenance=provenance,
120
+ groups=selected_groups,
121
+ extras=selected_extras,
122
+ resolution_strategy=strategy_override,
123
+ )
124
+ return
125
+
126
+ result = _cli._resolve_specific( # noqa: SLF001
127
+ path,
128
+ config=config,
129
+ cache_dir=effective_cache_dir,
130
+ offline=offline,
131
+ transport=transport,
132
+ failure_prefix="Cannot lock",
133
+ groups=selected_groups,
134
+ extras=selected_extras,
135
+ resolution_strategy=strategy_override,
136
+ )
137
+ _emit_specific(result, format=format, output=output, provenance=provenance)
138
+
139
+
140
+ def _emit_specific(
141
+ result: ResolutionResult,
142
+ *,
143
+ format: str, # noqa: A002 - shadows builtin by convention
144
+ output: Path | None,
145
+ provenance: Provenance | None = None,
146
+ ) -> None:
147
+ """Write a single-environment resolution in the requested format."""
148
+ lock_input = result.lock_input
149
+ if provenance is not None:
150
+ lock_input.provenance = provenance
151
+
152
+ if _cli._is_stdout(output): # noqa: SLF001
153
+ if format == "pylock":
154
+ sys.stdout.write(_cli.write_lock(lock_input))
155
+ elif format == "requirements":
156
+ sys.stdout.write(_cli.write_requirements_with_hashes(lock_input))
157
+ else:
158
+ sys.stdout.write(_cli.write_requirements_without_hashes(lock_input))
159
+ return
160
+
161
+ target = output if output is not None else Path(_cli._DEFAULT_OUTPUT[format]) # noqa: SLF001
162
+ if format == "pylock":
163
+ _cli.write_lock(lock_input, output_path=target)
164
+ elif format == "requirements":
165
+ _cli.write_requirements_with_hashes(lock_input, output_path=target)
166
+ else:
167
+ _cli.write_requirements_without_hashes(lock_input, output_path=target)
168
+ sys.stderr.write(f"Wrote {target} ({len(result.pins)} packages)\n")
169
+
170
+
171
+ def _emit_universal( # noqa: PLR0913 - one wrapper per resolve_universal_pyproject kwarg
172
+ path: Path,
173
+ *,
174
+ config: NabProjectConfig,
175
+ cache_dir: Path | None,
176
+ transport: AsyncHttpTransport,
177
+ offline: bool,
178
+ output: Path | None,
179
+ format: str, # noqa: A002 - shadows builtin by convention
180
+ provenance: Provenance | None = None,
181
+ groups: tuple[str, ...] = (),
182
+ extras: tuple[str, ...] = (),
183
+ resolution_strategy: ResolutionStrategy | None = None,
184
+ ) -> None:
185
+ """Run the universal resolver and emit the requested artefact."""
186
+ sys.stderr.write(
187
+ "warning: mode = 'universal' is experimental; output format may"
188
+ " change without notice\n"
189
+ )
190
+
191
+ try:
192
+ result = _cli.resolve_universal_pyproject(
193
+ path,
194
+ config=config,
195
+ cache_dir=cache_dir,
196
+ transport=transport,
197
+ offline=offline,
198
+ groups=groups,
199
+ extras=extras,
200
+ resolution_strategy=resolution_strategy,
201
+ )
202
+ except KeyError:
203
+ sys.stderr.write(f"Error: {path} has no [project].dependencies\n")
204
+ sys.exit(1)
205
+ except LookupError as e:
206
+ sys.stderr.write(f"Error: {e}\n")
207
+ sys.exit(1)
208
+
209
+ if not result.success:
210
+ # Always print per-tuple blocks on failure so the user sees
211
+ # which tuple(s) failed and why; the resolved tuples still
212
+ # appear so partial progress is visible.
213
+ _print_universal_blocks(result)
214
+ sys.exit(1)
215
+
216
+ if format == "pylock":
217
+ _emit_universal_pylock(
218
+ result,
219
+ output=output,
220
+ provenance=provenance,
221
+ groups=groups,
222
+ extras=extras,
223
+ )
224
+ else:
225
+ _emit_universal_requirements(
226
+ result, output=output, with_hashes=format == "requirements"
227
+ )
228
+
229
+
230
+ def _emit_universal_pylock(
231
+ result: UniversalResult,
232
+ *,
233
+ output: Path | None,
234
+ provenance: Provenance | None = None,
235
+ groups: tuple[str, ...] = (),
236
+ extras: tuple[str, ...] = (),
237
+ ) -> None:
238
+ """Merge per-tuple LockInputs into one pylock and write/print it."""
239
+ lock_input = _cli.merge_universal_lock_inputs(
240
+ result,
241
+ extras=extras,
242
+ dependency_groups=groups,
243
+ default_groups=groups,
244
+ )
245
+ if provenance is not None:
246
+ lock_input.provenance = provenance
247
+
248
+ try:
249
+ text = _cli.write_lock(lock_input)
250
+ except _cli.MissingHashError as e:
251
+ sys.stderr.write(f"Cannot lock: {e}\n")
252
+ sys.exit(1)
253
+
254
+ if _cli._is_stdout(output): # noqa: SLF001
255
+ sys.stdout.write(text)
256
+ return
257
+ target = output if output is not None else Path(_cli._DEFAULT_OUTPUT["pylock"]) # noqa: SLF001
258
+ target.write_text(text, encoding="utf-8")
259
+ sys.stderr.write(f"Wrote {target} ({len(result.tuple_results)} tuples)\n")
260
+
261
+
262
+ def _emit_universal_requirements(
263
+ result: UniversalResult, *, output: Path | None, with_hashes: bool
264
+ ) -> None:
265
+ """Emit one requirements file per matrix tuple.
266
+
267
+ Three output shapes:
268
+
269
+ * ``output`` is ``None`` or ``-``: write one stdout dump with
270
+ ``# label`` blocks separating each tuple's pins. Inspection /
271
+ piping shape; pip cannot install a multi-block file directly.
272
+ * ``output`` contains ``{python_version}`` or ``{platform_id}``:
273
+ write one file per successful tuple, substituting the tuple's
274
+ values into the template. This is the constraints-per-
275
+ Python-version shape (e.g. ``constraints-{python_version}.txt``).
276
+ * ``output`` is a plain path AND the matrix has exactly one tuple:
277
+ write that tuple's pins to ``output`` directly.
278
+
279
+ A plain path with multiple tuples errors clearly: there is no
280
+ one-file shape that pip can install from across all tuples.
281
+ """
282
+ if output is None or _cli._is_stdout(output): # noqa: SLF001
283
+ _emit_universal_requirements_stdout(result, with_hashes=with_hashes)
284
+ return
285
+
286
+ template = str(output)
287
+ successful = [tr for tr in result.tuple_results if tr.success]
288
+ if not any(var in template for var in _cli._TUPLE_TEMPLATE_VARS): # noqa: SLF001
289
+ if len(successful) > 1:
290
+ sys.stderr.write(
291
+ "Error: universal mode produced multiple tuples but"
292
+ f" --output {output} has no template variable to"
293
+ " disambiguate. Use {python_version} and/or"
294
+ " {platform_id} in the path, e.g.:\n"
295
+ " --output 'constraints-{python_version}.txt'\n"
296
+ )
297
+ sys.exit(1)
298
+ # Single successful tuple: write directly to the fixed path.
299
+ _write_one_tuple_requirements(successful[0], output, with_hashes=with_hashes)
300
+ return
301
+
302
+ for tr in successful:
303
+ substituted = template.format(
304
+ python_version=tr.tuple_.python_version,
305
+ platform_id=tr.tuple_.platform_id,
306
+ )
307
+ _write_one_tuple_requirements(tr, Path(substituted), with_hashes=with_hashes)
308
+
309
+
310
+ def _emit_universal_requirements_stdout(
311
+ result: UniversalResult, *, with_hashes: bool
312
+ ) -> None:
313
+ """Stdout shape: per-tuple ``# label`` blocks merged into one stream."""
314
+ lock_input = _cli.merge_universal_lock_inputs(result)
315
+ try:
316
+ if with_hashes:
317
+ text = _cli.write_requirements_with_hashes(lock_input)
318
+ else:
319
+ text = _cli.write_requirements_without_hashes(lock_input)
320
+ except _cli.MissingHashError as e:
321
+ sys.stderr.write(f"Cannot lock: {e}\n")
322
+ sys.exit(1)
323
+ sys.stdout.write(text)
324
+
325
+
326
+ def _write_one_tuple_requirements(
327
+ tr: TupleResult, output: Path, *, with_hashes: bool
328
+ ) -> None:
329
+ """Write a single TupleResult's pins to ``output``."""
330
+ if tr.lock_input is None: # pragma: no cover - successful tuples carry one
331
+ return
332
+
333
+ try:
334
+ if with_hashes:
335
+ text = _cli.write_requirements_with_hashes(tr.lock_input)
336
+ else:
337
+ text = _cli.write_requirements_without_hashes(tr.lock_input)
338
+ except _cli.MissingHashError as e:
339
+ sys.stderr.write(f"Cannot lock: {e}\n")
340
+ sys.exit(1)
341
+
342
+ output.write_text(text, encoding="utf-8")
343
+ sys.stderr.write(
344
+ f"Wrote {output} ({len(tr.lock_input.pins)} packages,"
345
+ f" tuple {tr.tuple_.label})\n"
346
+ )
347
+
348
+
349
+ def _resolve_group_selection(
350
+ path: Path,
351
+ *,
352
+ groups: tuple[str, ...],
353
+ all_groups: bool,
354
+ ) -> tuple[str, ...]:
355
+ """Return the canonical, deduplicated group selection for this run.
356
+
357
+ ``groups`` is the user-supplied list (already split by tyro on
358
+ commas). ``all_groups`` overrides it: when set, every group
359
+ defined in the project's ``[dependency-groups]`` table is
360
+ selected. An ``--all-groups`` paired with a non-empty
361
+ ``--groups`` list raises a clean error rather than silently
362
+ preferring one over the other.
363
+ """
364
+ if all_groups and groups:
365
+ sys.stderr.write("Error: --all-groups and --groups are mutually exclusive\n")
366
+ sys.exit(1)
367
+ if not all_groups:
368
+ return tuple(dict.fromkeys(groups))
369
+
370
+ try:
371
+ defined = read_pyproject_groups(path)
372
+ except FileNotFoundError:
373
+ sys.stderr.write(f"Error: {path} not found\n")
374
+ sys.exit(1)
375
+ return tuple(defined.keys())
376
+
377
+
378
+ def _resolve_extra_selection(
379
+ path: Path,
380
+ *,
381
+ extras: tuple[str, ...],
382
+ all_extras: bool,
383
+ ) -> tuple[str, ...]:
384
+ """Return the canonical, deduplicated extras selection for this run."""
385
+ if all_extras and extras:
386
+ sys.stderr.write("Error: --all-extras and --extras are mutually exclusive\n")
387
+ sys.exit(1)
388
+ if not all_extras:
389
+ return tuple(dict.fromkeys(extras))
390
+
391
+ try:
392
+ defined = read_pyproject_optional_dependencies(path)
393
+ except FileNotFoundError:
394
+ sys.stderr.write(f"Error: {path} not found\n")
395
+ sys.exit(1)
396
+ return tuple(defined.keys())
397
+
398
+
399
+ def _build_provenance(
400
+ path: Path, *, config: NabProjectConfig, anchor: datetime
401
+ ) -> Provenance:
402
+ """Capture the inputs that produced this run for the lockfile.
403
+
404
+ The block lands under ``[tool.nab]`` and is informational only.
405
+
406
+ ``anchor`` is the timestamp used as ``now`` when resolving relative
407
+ ``P<n>D`` durations on this run. Recording it as ``created-at``
408
+ lets the next ``nab lock`` reuse the same anchor and reproduce the
409
+ same cutoff.
410
+ """
411
+ python_specifier: str | None
412
+ platforms: tuple[str, ...]
413
+ if config.mode is ResolveMode.UNIVERSAL and config.matrix is not None:
414
+ python_specifier = config.matrix.python
415
+ platforms = tuple(config.matrix.platforms)
416
+ else:
417
+ python_specifier = config.requires_python
418
+ platforms = ()
419
+
420
+ return Provenance(
421
+ nab_version=__version__,
422
+ created_at=anchor,
423
+ command_line=tuple(sys.argv),
424
+ input_path=str(path),
425
+ mode=config.mode.value,
426
+ python_specifier=python_specifier,
427
+ platforms=platforms,
428
+ )
429
+
430
+
431
+ def _determine_lock_anchor(
432
+ *,
433
+ output: Path | None,
434
+ format: str, # noqa: A002 - shadows builtin by convention
435
+ upgrade: bool,
436
+ ) -> datetime:
437
+ """Pick the ``P<n>D`` anchor for ``nab lock``.
438
+
439
+ Returns ``datetime.now(UTC)`` (a fresh anchor) when:
440
+
441
+ - ``--upgrade`` is set: the user is opting into a calendar refresh.
442
+ - Output is stdout (``-``): there is no file to read back later, so
443
+ no point preserving an anchor that nothing will reuse.
444
+ - Format is not ``pylock``: requirements files do not carry the
445
+ ``[tool.nab]`` block we read the anchor from.
446
+ - The expected pylock does not exist or has no recorded anchor:
447
+ first lock or a damaged file.
448
+
449
+ Otherwise returns the ``[tool.nab].created-at`` from the existing
450
+ pylock, so a re-lock against the same project produces the same
451
+ cutoff for ``P<n>D`` durations.
452
+ """
453
+ fresh = datetime.now(timezone.utc)
454
+ if upgrade:
455
+ return fresh
456
+ if _cli._is_stdout(output): # noqa: SLF001
457
+ return fresh
458
+ if format != "pylock":
459
+ return fresh
460
+ target = output if output is not None else Path(_cli._DEFAULT_OUTPUT[format]) # noqa: SLF001
461
+ prior = read_lockfile_anchor(target)
462
+ return prior if prior is not None else fresh
463
+
464
+
465
+ def _print_universal_blocks(result: UniversalResult) -> None:
466
+ """Write per-tuple pin blocks (with FAILED markers) to stdout."""
467
+ blocks: list[str] = []
468
+ for tr in result.tuple_results:
469
+ label = tr.tuple_.label
470
+ if not tr.success:
471
+ blocks.append(f"# {label}: FAILED")
472
+ blocks.extend(f"# {raw}" for raw in (tr.error or "").splitlines())
473
+ continue
474
+ blocks.append(f"# {label}")
475
+ blocks.extend(f"{name}=={tr.pins[name]}" for name in sorted(tr.pins))
476
+ sys.stdout.write("\n".join(blocks) + "\n")
nab/_version.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("nab")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0+unknown"
nab/cli.py ADDED
@@ -0,0 +1,218 @@
1
+ """Entry point for the nab command.
2
+
3
+ Holds the tyro :class:`SubcommandApp` registration plus the helpers
4
+ shared between :mod:`nab._lock` and :mod:`nab._download`: HTTP
5
+ transport selection, cache-directory defaults, config loading, and
6
+ the resolver-error-to-exit-code translation.
7
+
8
+ The two subcommands live in :mod:`nab._lock` and :mod:`nab._download`;
9
+ this module imports them so their ``@app.command`` decorators run
10
+ before :func:`main` calls ``app.cli()``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Annotated, Literal
19
+
20
+ import tyro
21
+ from tyro.extras import SubcommandApp
22
+
23
+ from nab._version import __version__
24
+ from nab_index.urllib3_async_transport import Urllib3AsyncTransport
25
+ from nab_python.config import (
26
+ ConfigError,
27
+ NabProjectConfig,
28
+ read_pyproject_config,
29
+ )
30
+ from nab_python.download import download_lock # noqa: F401 - re-exported for tests
31
+ from nab_python.lockfile import (
32
+ MissingHashError,
33
+ write_lock, # noqa: F401 - re-exported for tests
34
+ write_requirements_with_hashes, # noqa: F401 - re-exported for tests
35
+ write_requirements_without_hashes, # noqa: F401 - re-exported for tests
36
+ )
37
+ from nab_python.resolve import (
38
+ resolve_pyproject,
39
+ resolve_universal_pyproject, # noqa: F401 - re-exported for tests
40
+ )
41
+ from nab_python.universal.resolve import (
42
+ merge_universal_lock_inputs, # noqa: F401 - re-exported for tests
43
+ )
44
+ from nab_python.workspace import WorkspaceDiscoveryError
45
+ from nab_resolver.resolver import ResolutionError
46
+
47
+ if TYPE_CHECKING:
48
+ from datetime import datetime
49
+
50
+ from nab_index.transport import AsyncHttpTransport
51
+ from nab_python.provider import ResolutionStrategy
52
+ from nab_python.resolve import ResolutionResult
53
+
54
+ __all__ = [
55
+ "main",
56
+ ]
57
+
58
+
59
+ # A pyproject.toml positional that may also be omitted to default to ./pyproject.toml.
60
+ PathArg = Annotated[Path, tyro.conf.Positional]
61
+
62
+ # Lowercase Literal types so --http-backend and --format render lowercase
63
+ # choices in --help rather than the uppercase enum names.
64
+ HttpBackend = Literal["urllib3", "httpx", "niquests"]
65
+ LockFormat = Literal["pylock", "requirements", "requirements-without-hashes"]
66
+ ResolutionFlag = Literal["highest", "lowest", "lowest-direct"]
67
+
68
+ _DEFAULT_OUTPUT: dict[str, str] = {
69
+ "pylock": "pylock.toml",
70
+ "requirements": "requirements.txt",
71
+ "requirements-without-hashes": "requirements.txt",
72
+ }
73
+
74
+ _TUPLE_TEMPLATE_VARS = ("{python_version}", "{platform_id}")
75
+
76
+ # Conventional KeyboardInterrupt exit code: 128 + SIGINT(2).
77
+ _SIGINT_EXIT_CODE = 130
78
+
79
+ app = SubcommandApp()
80
+
81
+
82
+ def _make_transport(backend: HttpBackend) -> AsyncHttpTransport:
83
+ # httpx and niquests are optional extras; import lazily so a
84
+ # urllib3-only install doesn't need them.
85
+ if backend == "httpx":
86
+ try:
87
+ from nab_index.httpx_async_transport import ( # noqa: PLC0415
88
+ HttpxAsyncTransport,
89
+ )
90
+ except ImportError:
91
+ sys.stderr.write(
92
+ "Error: httpx is not installed; run `pip install nab[httpx]`\n"
93
+ )
94
+ sys.exit(1)
95
+ return HttpxAsyncTransport()
96
+
97
+ if backend == "niquests":
98
+ try:
99
+ from nab_index.niquests_async_transport import ( # noqa: PLC0415
100
+ NiquestsAsyncTransport,
101
+ )
102
+ except ImportError:
103
+ sys.stderr.write(
104
+ "Error: niquests is not installed; run `pip install nab[niquests]`\n"
105
+ )
106
+ sys.exit(1)
107
+ return NiquestsAsyncTransport()
108
+
109
+ return Urllib3AsyncTransport()
110
+
111
+
112
+ def _default_cache_dir() -> Path:
113
+ """Return the default per-user cache root.
114
+
115
+ Mirrors ``platformdirs.user_cache_path("nab")`` without the
116
+ dependency: ``$XDG_CACHE_HOME/nab`` or ``~/.cache/nab``.
117
+ """
118
+ base = os.environ.get("XDG_CACHE_HOME")
119
+ if base:
120
+ return Path(base) / "nab"
121
+ return Path.home() / ".cache" / "nab"
122
+
123
+
124
+ def _resolve_effective_cache_dir(
125
+ cache_dir: Path | None, *, no_cache: bool
126
+ ) -> Path | None:
127
+ if no_cache:
128
+ return None
129
+ if cache_dir is not None:
130
+ return cache_dir
131
+ return _default_cache_dir()
132
+
133
+
134
+ def _load_config(
135
+ path: Path,
136
+ *,
137
+ discover_workspace: bool = True,
138
+ anchor: datetime | None = None,
139
+ ) -> NabProjectConfig:
140
+ if not path.exists():
141
+ sys.stderr.write(f"Error: {path} not found\n")
142
+ sys.exit(1)
143
+
144
+ try:
145
+ return read_pyproject_config(
146
+ path, discover_workspace=discover_workspace, anchor=anchor
147
+ )
148
+ except ConfigError as exc:
149
+ sys.stderr.write(f"Error in [tool.nab]: {exc}\n")
150
+ sys.exit(1)
151
+ except WorkspaceDiscoveryError as exc:
152
+ sys.stderr.write(f"Workspace discovery error: {exc}\n")
153
+ sys.exit(1)
154
+
155
+
156
+ def _is_stdout(output: Path | None) -> bool:
157
+ return output is not None and str(output) == "-"
158
+
159
+
160
+ def _resolve_specific( # noqa: PLR0913 - one wrapper per resolve_pyproject kwarg
161
+ path: Path,
162
+ *,
163
+ config: NabProjectConfig,
164
+ cache_dir: Path | None,
165
+ offline: bool,
166
+ transport: AsyncHttpTransport,
167
+ failure_prefix: str,
168
+ groups: tuple[str, ...] = (),
169
+ extras: tuple[str, ...] = (),
170
+ resolution_strategy: ResolutionStrategy | None = None,
171
+ ) -> ResolutionResult:
172
+ """Run the single-environment resolver and translate errors to exits."""
173
+ try:
174
+ return resolve_pyproject(
175
+ path,
176
+ transport,
177
+ config=config,
178
+ cache_dir=cache_dir,
179
+ offline=offline,
180
+ groups=groups,
181
+ extras=extras,
182
+ resolution_strategy=resolution_strategy,
183
+ )
184
+ except ResolutionError as e:
185
+ sys.stderr.write(f"Resolution failed: {e}\n")
186
+ sys.exit(1)
187
+ except KeyError:
188
+ sys.stderr.write(f"Error: {path} has no [project].dependencies\n")
189
+ sys.exit(1)
190
+ except LookupError as e:
191
+ sys.stderr.write(f"Error: {e}\n")
192
+ sys.exit(1)
193
+ except MissingHashError as e:
194
+ sys.stderr.write(f"{failure_prefix}: {e}\n")
195
+ sys.exit(1)
196
+
197
+
198
+ # Side-effect imports: each module's @app.command decorators register the
199
+ # subcommand. Placed at the bottom so helpers above bind before
200
+ # nab._lock / nab._download import back from this module.
201
+ from . import _download as _download_module # noqa: E402, F401 - side-effect
202
+ from . import _lock as _lock_module # noqa: E402, F401 - side-effect
203
+
204
+
205
+ def main() -> None:
206
+ """Entry point for the nab command."""
207
+ # Tyro's SubcommandApp does not surface a global ``--version`` flag,
208
+ # so the check runs before ``app.cli()`` parses the sub-command.
209
+ argv = sys.argv[1:]
210
+ if argv and argv[0] in {"--version", "-V"}:
211
+ sys.stdout.write(f"nab {__version__}\n")
212
+ return
213
+
214
+ try:
215
+ app.cli(prog="nab")
216
+ except KeyboardInterrupt:
217
+ sys.stderr.write("Aborted.\n")
218
+ sys.exit(_SIGINT_EXIT_CODE)
nab/py.typed ADDED
File without changes
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: nab
3
+ Version: 0.0.1
4
+ Summary: PubGrub-based dependency resolver for Python packages
5
+ Project-URL: Homepage, https://github.com/notatallshaw/nab
6
+ Project-URL: Documentation, https://nab.readthedocs.io/
7
+ Project-URL: Issues, https://github.com/notatallshaw/nab/issues
8
+ Project-URL: Source, https://github.com/notatallshaw/nab
9
+ Project-URL: Changelog, https://github.com/notatallshaw/nab/blob/main/CHANGELOG.md
10
+ Author-email: Damian Shaw <damian.peter.shaw@gmail.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: nab-index==0.0.1
22
+ Requires-Dist: nab-python==0.0.1
23
+ Requires-Dist: nab-resolver==0.0.1
24
+ Requires-Dist: tyro>=1.0
25
+ Provides-Extra: httpx
26
+ Requires-Dist: nab-index[httpx]==0.0.1; extra == 'httpx'
27
+ Provides-Extra: niquests
28
+ Requires-Dist: nab-index[niquests]==0.0.1; extra == 'niquests'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # nab
32
+
33
+ nab is an experimental Python packaging lock and package download tool,
34
+ aiming to have similar resolver performance to uv, while being written
35
+ in Python.
36
+
37
+ nab reads a `pyproject.toml`, resolves the dependency tree, and
38
+ writes a pinned set of versions or a PEP 751 lockfile. It does not
39
+ install. Hand the lockfile to whatever installer you trust.
40
+
41
+ ## Install
42
+
43
+ For package hygiene, and security reasons, the preference is to install nab itself
44
+ as a tool, e.g.
45
+
46
+ Via pipx:
47
+
48
+ ```bash
49
+ pipx install nab
50
+ ```
51
+
52
+ Or via uv:
53
+
54
+ ```bash
55
+ uv tool install nab
56
+ ```
57
+
58
+
59
+ ## Quick start
60
+
61
+ ```toml
62
+ # pyproject.toml
63
+ [project]
64
+ name = "example"
65
+ version = "0.1.0"
66
+ dependencies = [
67
+ "starlette<=0.36.0",
68
+ "fastapi<=0.115.2",
69
+ ]
70
+ ```
71
+
72
+ ```bash
73
+ nab lock pyproject.toml
74
+ ```
75
+
76
+ Writes `pylock.toml` next to the project. For a sorted
77
+ `name==version` list instead, use
78
+ `nab lock --format requirements-without-hashes --output -`.
79
+
80
+ # Security
81
+
82
+ nab makes some opinionated choices to be secure first
83
+
84
+ ## Build policy
85
+
86
+ By default nab tries to extract static metadata, even from sdists,
87
+ but sometimes that is not possible and you have to build a package
88
+ to extract the dependency metadata. There are three build policies:
89
+
90
+ * never: Never builds a Python package
91
+ * build-local (default): Builds only your local workspace packages
92
+ if they have dynamic versions or dependencies
93
+ * build-remote: Builds packages sourced from indexes or VCS, it is
94
+ recommended that this only be turned on via per-package override
95
+
96
+ ## Indexes
97
+
98
+ nab does not currently support sourcing the same package from
99
+ distinct indexes. Indexes are processed in the order they are given
100
+ to nab, and the first index that has a package is the only index
101
+ that nab will source that package.
102
+
103
+ You can override this behavior by pinning specific packages to
104
+ specific behavior.
105
+
106
+ You can also list different urls as a mirror for the same index.
107
+ When a lockfile is written the primary url will always be used
108
+ so that the lockfile will be stable, even if mirrors are used
109
+ (this feature is a work in progress).
110
+
111
+ ## VCS policy
112
+
113
+ By default nab only allows git URLs that point to a specific
114
+ commit. Using a floating branch as a dependency must be
115
+ enabled in the configuration.
116
+
117
+ # Standards first behavior
118
+
119
+ ## Pre-releases
120
+
121
+ Pre-release versions are selected if there are no stable
122
+ versions to select given the requirements, even for transitive
123
+ dependencies. A user option to force allow or block
124
+ pre-releases per-package is a work in progress.
125
+
126
+ ## Validate per-distribution dependencies
127
+
128
+ By default when a distribution is chosen the dependencies from
129
+ that distribution are used, nab does not assume two different
130
+ distributions for the same package version will have the same
131
+ dependencies.
132
+
133
+ However, sometimes you may want the lock file to produce an
134
+ sdist, that sdist may not have static metadata, and you don't
135
+ want to wait for the sdist to build on every lock, there is
136
+ a distribution policy of "sdist-install", that is the metadata
137
+ will be taken from an appropriate wheel, but the sdist will
138
+ be selected for the install.
139
+
140
+
141
+ # Libraries
142
+
143
+ This project includes multiple libraries that can be used by
144
+ other tools:
145
+
146
+ * `nab-resolver`: An agnostic resolver library based on PubGrub, but with
147
+ extensions that make it compatible with Python packaging standards
148
+ * `nab-python`: A Python packaging provider that drives the nab-resolver
149
+ with lots of specific features and optimizations for the Python packaging
150
+ ecosystem
151
+ * `nab-index`: Provides APIs for nab-python to interact with Python package
152
+ indexes, abstracts HTTP library interface so different HTTP libraries can
153
+ be plugged in
154
+
155
+ All 3 libraries are in experimental mode, I currently recommend pinning them,
156
+ e.g. `nab-resolver==0.0.1`, as APIs may change at any point.
157
+
158
+ Once we reach `0.1.0` we will only break API stability on each minor update,
159
+ so you will be able to pin to `==0.1.*` or `~=0.1.0`.
@@ -0,0 +1,12 @@
1
+ nab/__init__.py,sha256=BBgKg5QyFN3fjhrdGLljSnBfk7DU0InF0oHNWFgeTAI,68
2
+ nab/__main__.py,sha256=Go7j-1mLsitDnmb1k5SMxbLKwyghMSL_4gh08SU7JzQ,114
3
+ nab/_download.py,sha256=arEOhHSYHLr_HFXSPzUJrb78EWKy9XHQv_UFOXAQGIw,2571
4
+ nab/_lock.py,sha256=kw74oG3hOsAymt42deRog7jLp8lAJfLC4HaA4yFK30w,16725
5
+ nab/_version.py,sha256=ZW8jUaeqp9bR89FN_oMeRfrz-pfKQvxMnRM6W2K8sWE,163
6
+ nab/cli.py,sha256=Ekw687lutnH_rYQNyZdqJJp4SUW6HukX3ppg9Ibfxig,6882
7
+ nab/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ nab-0.0.1.dist-info/METADATA,sha256=LwpRXKXYzfikpM6PtPBnr1XDrWd2vWkui-n_X1G-WNs,5096
9
+ nab-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ nab-0.0.1.dist-info/entry_points.txt,sha256=d8gnfTpdbf99HJRYjuO_8nklmE-gD9EStcYcLpM-Y9I,37
11
+ nab-0.0.1.dist-info/licenses/LICENSE,sha256=oec5WE-g9eYDBVwbDbyKHR7_zK67vUXRoikkrsZRuJ8,1068
12
+ nab-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nab = nab.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Damian Shaw
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.