nab 0.0.1__tar.gz
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-0.0.1/LICENSE +21 -0
- nab-0.0.1/PKG-INFO +159 -0
- nab-0.0.1/README.md +129 -0
- nab-0.0.1/pyproject.toml +222 -0
- nab-0.0.1/src/nab/__init__.py +1 -0
- nab-0.0.1/src/nab/__main__.py +6 -0
- nab-0.0.1/src/nab/_download.py +84 -0
- nab-0.0.1/src/nab/_lock.py +476 -0
- nab-0.0.1/src/nab/_version.py +6 -0
- nab-0.0.1/src/nab/cli.py +218 -0
- nab-0.0.1/src/nab/py.typed +0 -0
nab-0.0.1/LICENSE
ADDED
|
@@ -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.
|
nab-0.0.1/PKG-INFO
ADDED
|
@@ -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`.
|
nab-0.0.1/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# nab
|
|
2
|
+
|
|
3
|
+
nab is an experimental Python packaging lock and package download tool,
|
|
4
|
+
aiming to have similar resolver performance to uv, while being written
|
|
5
|
+
in Python.
|
|
6
|
+
|
|
7
|
+
nab reads a `pyproject.toml`, resolves the dependency tree, and
|
|
8
|
+
writes a pinned set of versions or a PEP 751 lockfile. It does not
|
|
9
|
+
install. Hand the lockfile to whatever installer you trust.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
For package hygiene, and security reasons, the preference is to install nab itself
|
|
14
|
+
as a tool, e.g.
|
|
15
|
+
|
|
16
|
+
Via pipx:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pipx install nab
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or via uv:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv tool install nab
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```toml
|
|
32
|
+
# pyproject.toml
|
|
33
|
+
[project]
|
|
34
|
+
name = "example"
|
|
35
|
+
version = "0.1.0"
|
|
36
|
+
dependencies = [
|
|
37
|
+
"starlette<=0.36.0",
|
|
38
|
+
"fastapi<=0.115.2",
|
|
39
|
+
]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
nab lock pyproject.toml
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Writes `pylock.toml` next to the project. For a sorted
|
|
47
|
+
`name==version` list instead, use
|
|
48
|
+
`nab lock --format requirements-without-hashes --output -`.
|
|
49
|
+
|
|
50
|
+
# Security
|
|
51
|
+
|
|
52
|
+
nab makes some opinionated choices to be secure first
|
|
53
|
+
|
|
54
|
+
## Build policy
|
|
55
|
+
|
|
56
|
+
By default nab tries to extract static metadata, even from sdists,
|
|
57
|
+
but sometimes that is not possible and you have to build a package
|
|
58
|
+
to extract the dependency metadata. There are three build policies:
|
|
59
|
+
|
|
60
|
+
* never: Never builds a Python package
|
|
61
|
+
* build-local (default): Builds only your local workspace packages
|
|
62
|
+
if they have dynamic versions or dependencies
|
|
63
|
+
* build-remote: Builds packages sourced from indexes or VCS, it is
|
|
64
|
+
recommended that this only be turned on via per-package override
|
|
65
|
+
|
|
66
|
+
## Indexes
|
|
67
|
+
|
|
68
|
+
nab does not currently support sourcing the same package from
|
|
69
|
+
distinct indexes. Indexes are processed in the order they are given
|
|
70
|
+
to nab, and the first index that has a package is the only index
|
|
71
|
+
that nab will source that package.
|
|
72
|
+
|
|
73
|
+
You can override this behavior by pinning specific packages to
|
|
74
|
+
specific behavior.
|
|
75
|
+
|
|
76
|
+
You can also list different urls as a mirror for the same index.
|
|
77
|
+
When a lockfile is written the primary url will always be used
|
|
78
|
+
so that the lockfile will be stable, even if mirrors are used
|
|
79
|
+
(this feature is a work in progress).
|
|
80
|
+
|
|
81
|
+
## VCS policy
|
|
82
|
+
|
|
83
|
+
By default nab only allows git URLs that point to a specific
|
|
84
|
+
commit. Using a floating branch as a dependency must be
|
|
85
|
+
enabled in the configuration.
|
|
86
|
+
|
|
87
|
+
# Standards first behavior
|
|
88
|
+
|
|
89
|
+
## Pre-releases
|
|
90
|
+
|
|
91
|
+
Pre-release versions are selected if there are no stable
|
|
92
|
+
versions to select given the requirements, even for transitive
|
|
93
|
+
dependencies. A user option to force allow or block
|
|
94
|
+
pre-releases per-package is a work in progress.
|
|
95
|
+
|
|
96
|
+
## Validate per-distribution dependencies
|
|
97
|
+
|
|
98
|
+
By default when a distribution is chosen the dependencies from
|
|
99
|
+
that distribution are used, nab does not assume two different
|
|
100
|
+
distributions for the same package version will have the same
|
|
101
|
+
dependencies.
|
|
102
|
+
|
|
103
|
+
However, sometimes you may want the lock file to produce an
|
|
104
|
+
sdist, that sdist may not have static metadata, and you don't
|
|
105
|
+
want to wait for the sdist to build on every lock, there is
|
|
106
|
+
a distribution policy of "sdist-install", that is the metadata
|
|
107
|
+
will be taken from an appropriate wheel, but the sdist will
|
|
108
|
+
be selected for the install.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Libraries
|
|
112
|
+
|
|
113
|
+
This project includes multiple libraries that can be used by
|
|
114
|
+
other tools:
|
|
115
|
+
|
|
116
|
+
* `nab-resolver`: An agnostic resolver library based on PubGrub, but with
|
|
117
|
+
extensions that make it compatible with Python packaging standards
|
|
118
|
+
* `nab-python`: A Python packaging provider that drives the nab-resolver
|
|
119
|
+
with lots of specific features and optimizations for the Python packaging
|
|
120
|
+
ecosystem
|
|
121
|
+
* `nab-index`: Provides APIs for nab-python to interact with Python package
|
|
122
|
+
indexes, abstracts HTTP library interface so different HTTP libraries can
|
|
123
|
+
be plugged in
|
|
124
|
+
|
|
125
|
+
All 3 libraries are in experimental mode, I currently recommend pinning them,
|
|
126
|
+
e.g. `nab-resolver==0.0.1`, as APIs may change at any point.
|
|
127
|
+
|
|
128
|
+
Once we reach `0.1.0` we will only break API stability on each minor update,
|
|
129
|
+
so you will be able to pin to `==0.1.*` or `~=0.1.0`.
|
nab-0.0.1/pyproject.toml
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nab"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "PubGrub-based dependency resolver for Python packages"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Damian Shaw", email = "damian.peter.shaw@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"nab-resolver==0.0.1",
|
|
23
|
+
"nab-python==0.0.1",
|
|
24
|
+
"nab-index==0.0.1",
|
|
25
|
+
"tyro>=1.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
httpx = ["nab-index[httpx]==0.0.1"]
|
|
30
|
+
niquests = ["nab-index[niquests]==0.0.1"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/notatallshaw/nab"
|
|
34
|
+
Documentation = "https://nab.readthedocs.io/"
|
|
35
|
+
Issues = "https://github.com/notatallshaw/nab/issues"
|
|
36
|
+
Source = "https://github.com/notatallshaw/nab"
|
|
37
|
+
Changelog = "https://github.com/notatallshaw/nab/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
nab = "nab.cli:main"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["hatchling"]
|
|
44
|
+
build-backend = "hatchling.build"
|
|
45
|
+
|
|
46
|
+
# https://docs.pytest.org/en/stable/reference/reference.html#confval-addopts
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
minversion = "9.0"
|
|
49
|
+
addopts = [
|
|
50
|
+
"-ra",
|
|
51
|
+
"--strict-markers",
|
|
52
|
+
"--strict-config",
|
|
53
|
+
"--import-mode=importlib",
|
|
54
|
+
"--showlocals",
|
|
55
|
+
"-m",
|
|
56
|
+
"not property and not crosshair and not network",
|
|
57
|
+
]
|
|
58
|
+
testpaths = [
|
|
59
|
+
"nab-resolver/tests",
|
|
60
|
+
"nab-python/tests",
|
|
61
|
+
"tests",
|
|
62
|
+
]
|
|
63
|
+
xfail_strict = true
|
|
64
|
+
filterwarnings = ["error"]
|
|
65
|
+
markers = [
|
|
66
|
+
"slow: scenarios that take more than a second",
|
|
67
|
+
"network: hits a real network endpoint (skipped by default in CI)",
|
|
68
|
+
"property: Hypothesis property tests (opt-in; run with -m property)",
|
|
69
|
+
"crosshair: CrossHair-backed property tests (opt-in; very slow; -m crosshair)",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# https://coverage.readthedocs.io/en/latest/config.html#run
|
|
73
|
+
[tool.coverage.run]
|
|
74
|
+
branch = true
|
|
75
|
+
relative_files = true
|
|
76
|
+
parallel = true
|
|
77
|
+
concurrency = ["thread", "multiprocessing"]
|
|
78
|
+
sigterm = true
|
|
79
|
+
source_pkgs = [
|
|
80
|
+
"nab_resolver",
|
|
81
|
+
"nab_python",
|
|
82
|
+
"nab",
|
|
83
|
+
]
|
|
84
|
+
source = []
|
|
85
|
+
omit = [
|
|
86
|
+
"*/tests/property/*",
|
|
87
|
+
"*/tests/property_python/*",
|
|
88
|
+
"*/tests/universal/property_universal/*",
|
|
89
|
+
"*/test_crosshair_*.py",
|
|
90
|
+
"*/_vendor/*",
|
|
91
|
+
"*/nab_python/_testing/*",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
# https://coverage.readthedocs.io/en/latest/config.html#report
|
|
95
|
+
[tool.coverage.report]
|
|
96
|
+
fail_under = 100
|
|
97
|
+
show_missing = true
|
|
98
|
+
skip_covered = true
|
|
99
|
+
skip_empty = true
|
|
100
|
+
exclude_also = [
|
|
101
|
+
"def __repr__",
|
|
102
|
+
"raise NotImplementedError",
|
|
103
|
+
"raise RuntimeError.*unreachable",
|
|
104
|
+
"if __name__ == .__main__.:",
|
|
105
|
+
"class .*\\bProtocol\\):",
|
|
106
|
+
"@(abc\\.)?abstractmethod",
|
|
107
|
+
"^\\s+pass$",
|
|
108
|
+
"if TYPE_CHECKING:",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
# https://coverage.readthedocs.io/en/latest/config.html#paths
|
|
112
|
+
[tool.coverage.paths]
|
|
113
|
+
nab_resolver = [
|
|
114
|
+
"nab-resolver/src/nab_resolver",
|
|
115
|
+
"*/nab_resolver",
|
|
116
|
+
]
|
|
117
|
+
nab_python = [
|
|
118
|
+
"nab-python/src/nab_python",
|
|
119
|
+
"*/nab_python",
|
|
120
|
+
]
|
|
121
|
+
nab = [
|
|
122
|
+
"src/nab",
|
|
123
|
+
"*/nab",
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# https://docs.astral.sh/ruff/settings/
|
|
127
|
+
[tool.ruff]
|
|
128
|
+
target-version = "py310"
|
|
129
|
+
extend-exclude = ["**/_vendor/**"]
|
|
130
|
+
|
|
131
|
+
# https://docs.astral.sh/ruff/settings/#lint_per-file-ignores
|
|
132
|
+
[tool.ruff.lint.per-file-ignores]
|
|
133
|
+
|
|
134
|
+
# Benchmarks intentionally print, lack docstrings, run subprocesses, and
|
|
135
|
+
# carry many scenario knobs.
|
|
136
|
+
"**/benchmarks/**" = [
|
|
137
|
+
"T201", # Print statements (benchmark output)
|
|
138
|
+
"D103", # Missing docstring in public function
|
|
139
|
+
"INP001", # Implicit namespace package (scripts live outside packages)
|
|
140
|
+
"S607", # subprocess with partial executable path
|
|
141
|
+
"BLE001", # Blind exception catch (surface every failure mode)
|
|
142
|
+
"C901", # mccabe complexity (scenario knobs add branches)
|
|
143
|
+
"PLR0912", # Too many branches (same reason as C901)
|
|
144
|
+
"PLR0915", # Too many statements (long main()s)
|
|
145
|
+
"PLR2004", # Magic value in comparison (benchmark thresholds)
|
|
146
|
+
"SLF001", # Private member access (sibling scripts share helpers)
|
|
147
|
+
"N818", # Exception name not Error-suffixed (private signal types)
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
# Tests use their own per-directory ruff.toml that extends this config.
|
|
151
|
+
# See tests/ruff.toml and the per-package tests/ruff.toml files.
|
|
152
|
+
|
|
153
|
+
# docs/conf.py sits outside any Python package by Sphinx convention.
|
|
154
|
+
"docs/conf.py" = ["INP001"]
|
|
155
|
+
|
|
156
|
+
# Subpackages defer imports to break cycles with their parent modules.
|
|
157
|
+
"nab-python/src/nab_python/_provider/*.py" = ["PLC0415"]
|
|
158
|
+
"nab-python/src/nab_python/_build/*.py" = ["PLC0415"]
|
|
159
|
+
"nab-python/src/nab_python/_lockfile/*.py" = ["PLC0415"]
|
|
160
|
+
|
|
161
|
+
# https://docs.astral.sh/ruff/settings/#lint
|
|
162
|
+
[tool.ruff.lint]
|
|
163
|
+
select = ["ALL"]
|
|
164
|
+
ignore = [
|
|
165
|
+
# Frameworks we don't use.
|
|
166
|
+
"AIR", # Airflow
|
|
167
|
+
"DJ", # Django
|
|
168
|
+
"FAST", # FastAPI
|
|
169
|
+
"NPY", # NumPy
|
|
170
|
+
"PD", # Pandas
|
|
171
|
+
|
|
172
|
+
# Conflicts with the formatter.
|
|
173
|
+
"COM812", # Trailing comma
|
|
174
|
+
"COM819", # Prohibited trailing comma
|
|
175
|
+
"ISC001", # Implicit string concatenation (single line)
|
|
176
|
+
"ISC002", # Implicit string concatenation (multi-line)
|
|
177
|
+
|
|
178
|
+
# Mutually-exclusive pairs; pick one of each.
|
|
179
|
+
"D203", # one-blank-line-before-class (we use D211)
|
|
180
|
+
"D212", # multi-line-summary-first-line (we use D213)
|
|
181
|
+
|
|
182
|
+
# Individual rules.
|
|
183
|
+
"ANN401", # Disallows Any (needed for generic library)
|
|
184
|
+
"S101", # Assert usage (needed for type narrowing and tests)
|
|
185
|
+
"TID252", # Bans relative imports; the project prefers them.
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
# https://docs.astral.sh/ruff/settings/#lintpylint
|
|
189
|
+
[tool.ruff.lint.pylint]
|
|
190
|
+
max-args = 8
|
|
191
|
+
|
|
192
|
+
# https://docs.astral.sh/ruff/settings/#lintpydocstyle
|
|
193
|
+
[tool.ruff.lint.pydocstyle]
|
|
194
|
+
convention = "pep257"
|
|
195
|
+
|
|
196
|
+
# https://docs.astral.sh/ruff/settings/#lintisort
|
|
197
|
+
[tool.ruff.lint.isort]
|
|
198
|
+
known-first-party = ["nab", "nab_index", "nab_python", "nab_resolver"]
|
|
199
|
+
|
|
200
|
+
# https://microsoft.github.io/pyright/#/configuration?id=main-pyright-settings
|
|
201
|
+
[tool.pyright]
|
|
202
|
+
pythonVersion = "3.10"
|
|
203
|
+
typeCheckingMode = "standard"
|
|
204
|
+
include = [
|
|
205
|
+
"nab-resolver/src",
|
|
206
|
+
"nab-resolver/tests",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
# https://mypy.readthedocs.io/en/stable/config_file.html#using-a-pyproject-toml-file
|
|
210
|
+
[tool.mypy]
|
|
211
|
+
python_version = "3.10"
|
|
212
|
+
strict = true
|
|
213
|
+
namespace_packages = true
|
|
214
|
+
explicit_package_bases = true
|
|
215
|
+
mypy_path = ["nab-resolver/src"]
|
|
216
|
+
enable_error_code = [
|
|
217
|
+
"redundant-expr",
|
|
218
|
+
"truthy-bool",
|
|
219
|
+
"possibly-undefined",
|
|
220
|
+
"explicit-override",
|
|
221
|
+
"mutable-override",
|
|
222
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""nab: a PubGrub-based dependency resolver for Python packages."""
|
|
@@ -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
|
+
)
|
|
@@ -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-0.0.1/src/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)
|
|
File without changes
|