nab-python 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_python/__init__.py +1 -0
- nab_python/_build/__init__.py +1 -0
- nab_python/_build/env.py +364 -0
- nab_python/_build/errors.py +17 -0
- nab_python/_build/runner.py +254 -0
- nab_python/_lockfile/__init__.py +1 -0
- nab_python/_lockfile/builder.py +339 -0
- nab_python/_lockfile/disjointness.py +207 -0
- nab_python/_lockfile/pylock.py +323 -0
- nab_python/_lockfile/requirements.py +121 -0
- nab_python/_packaging_provider.py +98 -0
- nab_python/_provider/__init__.py +1 -0
- nab_python/_provider/build_remote.py +95 -0
- nab_python/_provider/extras.py +231 -0
- nab_python/_provider/listing.py +442 -0
- nab_python/_provider/lookahead.py +156 -0
- nab_python/_provider/metadata_resolver.py +450 -0
- nab_python/_provider/priority.py +174 -0
- nab_python/_provider/sources.py +215 -0
- nab_python/_testing/__init__.py +1 -0
- nab_python/_testing/coordinator_fake.py +240 -0
- nab_python/_vcs_admission.py +209 -0
- nab_python/_vendor/__init__.py +6 -0
- nab_python/_vendor/packaging/LICENSE +3 -0
- nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
- nab_python/_vendor/packaging/LICENSE.BSD +23 -0
- nab_python/_vendor/packaging/PROVENANCE.md +73 -0
- nab_python/_vendor/packaging/__init__.py +15 -0
- nab_python/_vendor/packaging/_elffile.py +108 -0
- nab_python/_vendor/packaging/_manylinux.py +265 -0
- nab_python/_vendor/packaging/_musllinux.py +88 -0
- nab_python/_vendor/packaging/_parser.py +394 -0
- nab_python/_vendor/packaging/_structures.py +33 -0
- nab_python/_vendor/packaging/_tokenizer.py +196 -0
- nab_python/_vendor/packaging/dependency_groups.py +302 -0
- nab_python/_vendor/packaging/direct_url.py +325 -0
- nab_python/_vendor/packaging/errors.py +94 -0
- nab_python/_vendor/packaging/licenses/__init__.py +186 -0
- nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
- nab_python/_vendor/packaging/markers.py +506 -0
- nab_python/_vendor/packaging/metadata.py +964 -0
- nab_python/_vendor/packaging/py.typed +0 -0
- nab_python/_vendor/packaging/pylock.py +910 -0
- nab_python/_vendor/packaging/ranges.py +1803 -0
- nab_python/_vendor/packaging/requirements.py +132 -0
- nab_python/_vendor/packaging/specifiers.py +1141 -0
- nab_python/_vendor/packaging/tags.py +929 -0
- nab_python/_vendor/packaging/utils.py +296 -0
- nab_python/_vendor/packaging/version.py +1230 -0
- nab_python/build_backend.py +184 -0
- nab_python/config.py +805 -0
- nab_python/download.py +170 -0
- nab_python/fetch.py +827 -0
- nab_python/lockfile.py +238 -0
- nab_python/metadata.py +145 -0
- nab_python/provider.py +1235 -0
- nab_python/py.typed +0 -0
- nab_python/requirements_file.py +180 -0
- nab_python/resolve.py +497 -0
- nab_python/universal/__init__.py +1 -0
- nab_python/universal/matrix.py +235 -0
- nab_python/universal/provider.py +214 -0
- nab_python/universal/reresolve.py +310 -0
- nab_python/universal/resolve.py +508 -0
- nab_python/universal/validate.py +439 -0
- nab_python/universal/wheel_selection.py +327 -0
- nab_python/workspace.py +214 -0
- nab_python-0.0.1.dist-info/METADATA +49 -0
- nab_python-0.0.1.dist-info/RECORD +71 -0
- nab_python-0.0.1.dist-info/WHEEL +4 -0
- nab_python-0.0.1.dist-info/licenses/LICENSE +21 -0
nab_python/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python package resolver built on the nab resolver core."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PEP 517 build-backend invocation for dynamic metadata."""
|
nab_python/_build/env.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Isolated build env that uses nab itself plus the PyPA ``installer``.
|
|
2
|
+
|
|
3
|
+
``NabBuildEnv`` implements ``build.env.IsolatedEnv`` so it slots into
|
|
4
|
+
``build.ProjectBuilder.from_isolated_env``. The pieces:
|
|
5
|
+
|
|
6
|
+
* ``venv.EnvBuilder`` (stdlib) creates an empty interpreter at a temp
|
|
7
|
+
path, ``with_pip=False``; nab does not need pip in there.
|
|
8
|
+
* nab's own resolver picks versions for ``[build-system].requires``
|
|
9
|
+
using the same indexes / ``exclude-newer`` window as the outer
|
|
10
|
+
resolve.
|
|
11
|
+
* ``download_lock`` from :mod:`nab_python.download` fetches the
|
|
12
|
+
resolved wheels into a temp directory.
|
|
13
|
+
* :func:`installer.install` writes each wheel into the venv via
|
|
14
|
+
``installer.SchemeDictionaryDestination``, configured from the
|
|
15
|
+
venv's own ``sysconfig.get_paths()``.
|
|
16
|
+
|
|
17
|
+
The env is a context manager. Entering it builds the venv and
|
|
18
|
+
installs the requirements; exiting removes the temp tree. The
|
|
19
|
+
``python_executable`` and ``make_extra_environ`` properties come
|
|
20
|
+
from ``build.env.IsolatedEnv``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import venv
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import TYPE_CHECKING
|
|
34
|
+
|
|
35
|
+
from installer import install as installer_install
|
|
36
|
+
from installer.destinations import SchemeDictionaryDestination
|
|
37
|
+
from installer.sources import WheelFile
|
|
38
|
+
from nab_index.urllib3_async_transport import Urllib3AsyncTransport
|
|
39
|
+
|
|
40
|
+
from ..config import NabProjectConfig
|
|
41
|
+
from ..download import download_lock
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from collections.abc import Callable, Mapping
|
|
45
|
+
from typing_extensions import Self
|
|
46
|
+
|
|
47
|
+
from installer.records import RecordEntry
|
|
48
|
+
from installer.utils import Scheme
|
|
49
|
+
from nab_index.transport import AsyncHttpTransport
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"NabBuildEnv",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _FastSchemeDictionaryDestination(SchemeDictionaryDestination):
|
|
57
|
+
"""``SchemeDictionaryDestination`` that skips bytecode-compile work.
|
|
58
|
+
|
|
59
|
+
The stock ``_compile_bytecode`` always resolves the target file's
|
|
60
|
+
real path before checking ``bytecode_optimization_levels``; with
|
|
61
|
+
an empty levels tuple, the loop body never runs but the resolve
|
|
62
|
+
still hits the filesystem twice per record. Skipping the early
|
|
63
|
+
work when the levels tuple is empty is safe because the body
|
|
64
|
+
would have been a no-op.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def _compile_bytecode(self, scheme: Scheme, record: RecordEntry) -> None:
|
|
68
|
+
if not self.bytecode_optimization_levels:
|
|
69
|
+
return
|
|
70
|
+
super()._compile_bytecode(scheme, record)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger(__name__)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_LAUNCHER_KIND = (
|
|
77
|
+
"win-amd64"
|
|
78
|
+
if sys.platform == "win32" and sys.maxsize > 2**32
|
|
79
|
+
else "win-ia32"
|
|
80
|
+
if sys.platform == "win32"
|
|
81
|
+
else "posix"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BuildEnvError(Exception):
|
|
86
|
+
"""The build env could not be set up (resolve, download, or install)."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class NabBuildEnv:
|
|
90
|
+
"""An isolated PEP 518 build environment driven by nab.
|
|
91
|
+
|
|
92
|
+
Implements ``build.env.IsolatedEnv`` so it can be passed to
|
|
93
|
+
``build.ProjectBuilder.from_isolated_env``. The runtime cost
|
|
94
|
+
is one venv creation, one inner resolve over
|
|
95
|
+
``[build-system].requires``, one wheel download per dep, and
|
|
96
|
+
one ``installer.install`` per wheel.
|
|
97
|
+
|
|
98
|
+
``requires`` is the PEP 508 string list from
|
|
99
|
+
``[build-system].requires``. ``config`` carries the indexes,
|
|
100
|
+
``exclude-newer`` window and other nab inputs from the outer
|
|
101
|
+
resolve; it is pruned (no local sources, no workspace, no
|
|
102
|
+
marker overlay) before the inner resolve so the build env is
|
|
103
|
+
computed against PyPI alone.
|
|
104
|
+
|
|
105
|
+
Construction is cheap; the work happens in ``__enter__``.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
requires: list[str],
|
|
111
|
+
*,
|
|
112
|
+
config: NabProjectConfig,
|
|
113
|
+
python_version: str | None = None,
|
|
114
|
+
transport_factory: Callable[[], AsyncHttpTransport] = Urllib3AsyncTransport,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Capture inputs; the venv and inner resolve happen in __enter__."""
|
|
117
|
+
self._requires = list(requires)
|
|
118
|
+
self._config = config
|
|
119
|
+
self._python_version = python_version
|
|
120
|
+
self._transport = transport_factory()
|
|
121
|
+
|
|
122
|
+
self._tmpdir: tempfile.TemporaryDirectory[str] | None = None
|
|
123
|
+
self._venv_path: Path | None = None
|
|
124
|
+
self._python_executable: Path | None = None
|
|
125
|
+
self._scripts_dir: Path | None = None
|
|
126
|
+
|
|
127
|
+
def __enter__(self) -> Self:
|
|
128
|
+
"""Build the venv, run the inner resolve, install build requirements."""
|
|
129
|
+
self._tmpdir = tempfile.TemporaryDirectory(prefix="nab-build-env-")
|
|
130
|
+
try:
|
|
131
|
+
self._provision(Path(self._tmpdir.name))
|
|
132
|
+
except BaseException:
|
|
133
|
+
self._tmpdir.cleanup()
|
|
134
|
+
self._tmpdir = None
|
|
135
|
+
raise
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def _provision(self, root: Path) -> None:
|
|
139
|
+
"""Lay out the venv, install build requirements, populate paths."""
|
|
140
|
+
self._venv_path = root / "venv"
|
|
141
|
+
wheel_dir = root / "wheels"
|
|
142
|
+
wheel_dir.mkdir()
|
|
143
|
+
|
|
144
|
+
logger.debug("creating build venv at %s", self._venv_path)
|
|
145
|
+
builder = venv.EnvBuilder(
|
|
146
|
+
with_pip=False, symlinks=_supports_symlinks(), clear=False
|
|
147
|
+
)
|
|
148
|
+
builder.create(self._venv_path)
|
|
149
|
+
|
|
150
|
+
self._python_executable = _venv_python(self._venv_path)
|
|
151
|
+
self._scripts_dir = self._python_executable.parent
|
|
152
|
+
|
|
153
|
+
scheme_dict = _venv_scheme_paths(self._python_executable)
|
|
154
|
+
|
|
155
|
+
if not self._requires:
|
|
156
|
+
return
|
|
157
|
+
wheel_paths = self._resolve_and_download(wheel_dir)
|
|
158
|
+
destination = _FastSchemeDictionaryDestination(
|
|
159
|
+
scheme_dict=scheme_dict,
|
|
160
|
+
interpreter=str(self._python_executable),
|
|
161
|
+
script_kind=_LAUNCHER_KIND,
|
|
162
|
+
bytecode_optimization_levels=(),
|
|
163
|
+
overwrite_existing=True,
|
|
164
|
+
)
|
|
165
|
+
for wheel_path in wheel_paths:
|
|
166
|
+
logger.debug("installing %s", wheel_path.name)
|
|
167
|
+
with WheelFile.open(wheel_path) as source:
|
|
168
|
+
installer_install(
|
|
169
|
+
source=source,
|
|
170
|
+
destination=destination,
|
|
171
|
+
additional_metadata={"INSTALLER": b"nab\n"},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def __exit__(self, *args: object) -> None:
|
|
175
|
+
"""Remove the temp tree that holds the venv and downloaded wheels."""
|
|
176
|
+
if self._tmpdir is not None:
|
|
177
|
+
self._tmpdir.cleanup()
|
|
178
|
+
self._tmpdir = None
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def python_executable(self) -> str:
|
|
182
|
+
"""Path to the venv's interpreter (str, per ``IsolatedEnv``)."""
|
|
183
|
+
if self._python_executable is None:
|
|
184
|
+
msg = "NabBuildEnv used outside its context-manager scope"
|
|
185
|
+
raise BuildEnvError(msg)
|
|
186
|
+
return str(self._python_executable)
|
|
187
|
+
|
|
188
|
+
def make_extra_environ(self) -> Mapping[str, str]:
|
|
189
|
+
"""PATH / PYTHONPATH overrides for the build subprocess.
|
|
190
|
+
|
|
191
|
+
Prepends the venv's scripts dir so build backends find their
|
|
192
|
+
installed entry points; clears ``PYTHONPATH`` so the host's
|
|
193
|
+
``sys.path`` does not leak in (matches the convention used by
|
|
194
|
+
``build.env.DefaultIsolatedEnv``).
|
|
195
|
+
"""
|
|
196
|
+
if self._scripts_dir is None:
|
|
197
|
+
msg = "NabBuildEnv used outside its context-manager scope"
|
|
198
|
+
raise BuildEnvError(msg)
|
|
199
|
+
host_path = os.environ.get("PATH")
|
|
200
|
+
new_path = (
|
|
201
|
+
f"{self._scripts_dir}{os.pathsep}{host_path}"
|
|
202
|
+
if host_path
|
|
203
|
+
else str(self._scripts_dir)
|
|
204
|
+
)
|
|
205
|
+
return {"PATH": new_path, "PYTHONPATH": ""}
|
|
206
|
+
|
|
207
|
+
def install(self, requirements: list[str]) -> None:
|
|
208
|
+
"""Install additional requirements into the live env.
|
|
209
|
+
|
|
210
|
+
Used for ``get_requires_for_build_wheel`` follow-up requests
|
|
211
|
+
(the backend asks for additional deps after the env is
|
|
212
|
+
already up). Same code path as the constructor's install,
|
|
213
|
+
targeting the same venv.
|
|
214
|
+
"""
|
|
215
|
+
if self._venv_path is None or self._python_executable is None:
|
|
216
|
+
msg = "NabBuildEnv used outside its context-manager scope"
|
|
217
|
+
raise BuildEnvError(msg)
|
|
218
|
+
if not requirements:
|
|
219
|
+
return
|
|
220
|
+
wheel_dir = self._venv_path.parent / "wheels"
|
|
221
|
+
# Append a fresh subdir so a re-install does not re-download
|
|
222
|
+
# the same wheel into the same path under a different version.
|
|
223
|
+
sub = wheel_dir / f"_extra_{len(list(wheel_dir.iterdir()))}"
|
|
224
|
+
sub.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
wheel_paths = self._resolve_and_download(sub, extra=requirements)
|
|
226
|
+
scheme_dict = _venv_scheme_paths(self._python_executable)
|
|
227
|
+
destination = SchemeDictionaryDestination(
|
|
228
|
+
scheme_dict=scheme_dict,
|
|
229
|
+
interpreter=str(self._python_executable),
|
|
230
|
+
script_kind=_LAUNCHER_KIND,
|
|
231
|
+
bytecode_optimization_levels=(),
|
|
232
|
+
overwrite_existing=True,
|
|
233
|
+
)
|
|
234
|
+
for wheel_path in wheel_paths:
|
|
235
|
+
with WheelFile.open(wheel_path) as source:
|
|
236
|
+
installer_install(
|
|
237
|
+
source=source,
|
|
238
|
+
destination=destination,
|
|
239
|
+
additional_metadata={"INSTALLER": b"nab\n"},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _resolve_and_download(
|
|
243
|
+
self,
|
|
244
|
+
wheel_dir: Path,
|
|
245
|
+
*,
|
|
246
|
+
extra: list[str] | None = None,
|
|
247
|
+
) -> list[Path]:
|
|
248
|
+
"""Resolve ``requires`` (+ ``extra``) and write wheels under ``wheel_dir``.
|
|
249
|
+
|
|
250
|
+
The inner resolve runs against a synthetic pyproject so it
|
|
251
|
+
can reuse :func:`nab_python.resolve.resolve_pyproject` and
|
|
252
|
+
:func:`nab_python.download.download_lock` end-to-end. No
|
|
253
|
+
local sources / workspace / marker overlay; build deps
|
|
254
|
+
come from the configured indexes only.
|
|
255
|
+
"""
|
|
256
|
+
# Late import: avoids a cycle through ``resolve.py`` which
|
|
257
|
+
# itself imports ``pypi.py`` which imports ``build_backend``
|
|
258
|
+
# which imports this module.
|
|
259
|
+
from ..resolve import resolve_pyproject
|
|
260
|
+
|
|
261
|
+
requires = list(self._requires)
|
|
262
|
+
if extra:
|
|
263
|
+
requires.extend(extra)
|
|
264
|
+
|
|
265
|
+
synthetic_dir = wheel_dir.parent / "_inner_project"
|
|
266
|
+
synthetic_dir.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
synthetic = synthetic_dir / "pyproject.toml"
|
|
268
|
+
synthetic.write_text(_render_synthetic_pyproject(requires), encoding="utf-8")
|
|
269
|
+
|
|
270
|
+
inner_config = NabProjectConfig(
|
|
271
|
+
indexes=self._config.indexes,
|
|
272
|
+
index_overrides=self._config.index_overrides,
|
|
273
|
+
uploaded_prior_to=self._config.uploaded_prior_to,
|
|
274
|
+
uploaded_prior_to_overrides=self._config.uploaded_prior_to_overrides,
|
|
275
|
+
)
|
|
276
|
+
result = resolve_pyproject(
|
|
277
|
+
synthetic,
|
|
278
|
+
self._transport,
|
|
279
|
+
config=inner_config,
|
|
280
|
+
python_version=self._python_version,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Reject sdist-only pins early: build deps that ship only an
|
|
284
|
+
# sdist trigger a recursive backend invocation that this
|
|
285
|
+
# builder does not handle. Most build tools (hatchling,
|
|
286
|
+
# setuptools, flit, pdm-backend) publish wheels.
|
|
287
|
+
from ..lockfile import IndexPin
|
|
288
|
+
|
|
289
|
+
sdist_only: list[str] = []
|
|
290
|
+
for canonical, pin in result.lock_input.pins.items():
|
|
291
|
+
if isinstance(pin, IndexPin) and not pin.wheels:
|
|
292
|
+
sdist_only.append(f"{canonical}=={pin.version}")
|
|
293
|
+
if sdist_only:
|
|
294
|
+
msg = (
|
|
295
|
+
"build env requires sdist-only packages which nab cannot"
|
|
296
|
+
" install without recursing through the build path: "
|
|
297
|
+
+ ", ".join(sdist_only)
|
|
298
|
+
)
|
|
299
|
+
raise BuildEnvError(msg)
|
|
300
|
+
|
|
301
|
+
download_result = download_lock(result.lock_input, self._transport, wheel_dir)
|
|
302
|
+
# Both wheels and sdists are downloaded; only wheels feed
|
|
303
|
+
# ``installer.install``. The sdists are inert clutter under
|
|
304
|
+
# the temp dir, cleaned up with the env.
|
|
305
|
+
all_paths = list(download_result.written) + list(download_result.skipped)
|
|
306
|
+
return [p for p in all_paths if p.suffix == ".whl"]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _venv_python(venv_path: Path) -> Path:
|
|
310
|
+
r"""Return the venv interpreter path (``Scripts\python.exe`` on Windows)."""
|
|
311
|
+
if sys.platform == "win32":
|
|
312
|
+
return venv_path / "Scripts" / "python.exe" # pragma: no cover
|
|
313
|
+
return venv_path / "bin" / "python"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _venv_scheme_paths(python_executable: Path) -> dict[str, str]:
|
|
317
|
+
"""Ask the venv's interpreter for its own ``sysconfig.get_paths()``.
|
|
318
|
+
|
|
319
|
+
Subprocessing the venv guarantees the returned paths reflect the
|
|
320
|
+
venv's layout (``site-packages`` under the venv root, scripts in
|
|
321
|
+
its ``bin``/``Scripts`` dir, etc.) regardless of how nab itself
|
|
322
|
+
was installed. One subprocess per env construction; negligible.
|
|
323
|
+
"""
|
|
324
|
+
result = subprocess.run( # noqa: S603 - controlled command, no shell
|
|
325
|
+
[
|
|
326
|
+
str(python_executable),
|
|
327
|
+
"-c",
|
|
328
|
+
"import json, sysconfig; print(json.dumps(sysconfig.get_paths()))",
|
|
329
|
+
],
|
|
330
|
+
capture_output=True,
|
|
331
|
+
text=True,
|
|
332
|
+
check=True,
|
|
333
|
+
)
|
|
334
|
+
return dict(json.loads(result.stdout))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _supports_symlinks() -> bool:
|
|
338
|
+
"""``venv.EnvBuilder(symlinks=...)`` heuristic; avoids Windows traps."""
|
|
339
|
+
return sys.platform != "win32"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _render_synthetic_pyproject(requires: list[str]) -> str:
|
|
343
|
+
"""Render a tiny pyproject.toml whose deps are ``requires``.
|
|
344
|
+
|
|
345
|
+
Used as input to the inner resolve; the on-disk file is
|
|
346
|
+
discarded with the rest of the temp tree. The dummy name and
|
|
347
|
+
version make the inner resolver happy without depending on the
|
|
348
|
+
outer project's name/version.
|
|
349
|
+
"""
|
|
350
|
+
deps_block = ",\n ".join(_toml_str(s) for s in requires)
|
|
351
|
+
if deps_block:
|
|
352
|
+
deps_block = f"\n {deps_block},\n"
|
|
353
|
+
return (
|
|
354
|
+
"[project]\n"
|
|
355
|
+
'name = "_nab_build_env"\n'
|
|
356
|
+
'version = "0.0.0"\n'
|
|
357
|
+
f"dependencies = [{deps_block}]\n"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _toml_str(value: str) -> str:
|
|
362
|
+
"""Single-line TOML string literal escape."""
|
|
363
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
364
|
+
return f'"{escaped}"'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Errors shared between the build runner and its callers.
|
|
2
|
+
|
|
3
|
+
Lives in its own module so :mod:`nab_python.build_backend` can
|
|
4
|
+
import the exception without pulling in :mod:`build`,
|
|
5
|
+
:mod:`pyproject_hooks`, and the rest of the runner module at module
|
|
6
|
+
load time. The runner re-exports the class for back-compat.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BuildBackendError",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BuildBackendError(Exception):
|
|
17
|
+
"""A build-backend operation failed or returned unparseable metadata."""
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Orchestrate ``[build-system]`` setup and metadata extraction.
|
|
2
|
+
|
|
3
|
+
The entry point is :func:`run_build_backend`. Given a source
|
|
4
|
+
directory with a ``pyproject.toml`` whose ``project.dynamic``
|
|
5
|
+
contains fields nab cannot read statically, run_build_backend:
|
|
6
|
+
|
|
7
|
+
1. Reads ``[build-system].requires`` and ``[build-system].build-backend``
|
|
8
|
+
(defaulting to ``setuptools.build_meta:__legacy__`` per PEP 517).
|
|
9
|
+
2. Opens a :class:`~nab_python._build.env.NabBuildEnv` populated with
|
|
10
|
+
those requirements.
|
|
11
|
+
3. Hands the env to ``build.ProjectBuilder.from_isolated_env`` and
|
|
12
|
+
asks it for the wheel metadata via ``metadata_path()``, which
|
|
13
|
+
tries ``prepare_metadata_for_build_wheel`` and falls back to a
|
|
14
|
+
full ``build_wheel`` when the backend lacks that hook.
|
|
15
|
+
4. Parses the resulting ``METADATA`` file into
|
|
16
|
+
:class:`~nab_python.metadata.WheelMetadata`.
|
|
17
|
+
|
|
18
|
+
The hatchling-with-dynamic-deps quirk uv documents (the prepare
|
|
19
|
+
hook can return data that does not match the eventual wheel) is
|
|
20
|
+
covered by skipping the prepare step for that combination; see
|
|
21
|
+
:func:`_should_skip_prepare`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import tempfile
|
|
28
|
+
from email import message_from_string
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
import build
|
|
33
|
+
import pyproject_hooks
|
|
34
|
+
import tomli
|
|
35
|
+
|
|
36
|
+
from .._vendor.packaging.requirements import Requirement
|
|
37
|
+
from .._vendor.packaging.specifiers import SpecifierSet
|
|
38
|
+
from .._vendor.packaging.utils import canonicalize_name
|
|
39
|
+
from .._vendor.packaging.version import InvalidVersion, Version
|
|
40
|
+
from ..metadata import WheelMetadata
|
|
41
|
+
from .env import NabBuildEnv
|
|
42
|
+
from .errors import BuildBackendError
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from ..config import NabProjectConfig
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"BuildBackendError",
|
|
49
|
+
"run_build_backend",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_DEFAULT_BACKEND = "setuptools.build_meta:__legacy__"
|
|
56
|
+
_DEFAULT_REQUIRES = ("setuptools >= 40.8.0",)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_build_backend(
|
|
60
|
+
source_dir: Path,
|
|
61
|
+
*,
|
|
62
|
+
config: NabProjectConfig,
|
|
63
|
+
python_version: str | None = None,
|
|
64
|
+
) -> WheelMetadata:
|
|
65
|
+
"""Extract wheel metadata for ``source_dir`` via the build backend.
|
|
66
|
+
|
|
67
|
+
Returns a :class:`~nab_python.metadata.WheelMetadata` parsed from
|
|
68
|
+
the ``METADATA`` file the backend produces. Raises
|
|
69
|
+
:class:`BuildBackendError` on any failure: backend import
|
|
70
|
+
error, hook crash, malformed METADATA, or sdist-only build deps.
|
|
71
|
+
|
|
72
|
+
The build runs in an isolated venv driven by
|
|
73
|
+
:class:`NabBuildEnv`; nothing in the user's main environment is
|
|
74
|
+
perturbed. The build env owns its own HTTP transport (see
|
|
75
|
+
:class:`NabBuildEnv` for why) so callers do not pass one in.
|
|
76
|
+
"""
|
|
77
|
+
pyproject = source_dir / "pyproject.toml"
|
|
78
|
+
if pyproject.is_file():
|
|
79
|
+
try:
|
|
80
|
+
data = tomli.loads(pyproject.read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, tomli.TOMLDecodeError) as exc:
|
|
82
|
+
msg = f"could not read pyproject.toml at {source_dir}: {exc}"
|
|
83
|
+
raise BuildBackendError(msg) from exc
|
|
84
|
+
elif (source_dir / "setup.py").is_file():
|
|
85
|
+
# PEP 517 fallback for legacy setup.py projects: treat the
|
|
86
|
+
# missing ``[build-system]`` as the documented default
|
|
87
|
+
# (setuptools.build_meta:__legacy__).
|
|
88
|
+
data = {}
|
|
89
|
+
else:
|
|
90
|
+
msg = f"no pyproject.toml or setup.py at {source_dir}"
|
|
91
|
+
raise BuildBackendError(msg)
|
|
92
|
+
|
|
93
|
+
backend, requires, _backend_path = _read_build_system(data)
|
|
94
|
+
|
|
95
|
+
skip_prepare = _should_skip_prepare(backend, data)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
with NabBuildEnv(
|
|
99
|
+
requires=list(requires),
|
|
100
|
+
config=config,
|
|
101
|
+
python_version=python_version,
|
|
102
|
+
) as env:
|
|
103
|
+
project = build.ProjectBuilder.from_isolated_env(
|
|
104
|
+
env,
|
|
105
|
+
source_dir=str(source_dir),
|
|
106
|
+
runner=pyproject_hooks.quiet_subprocess_runner,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
extra = list(project.get_requires_for_build("wheel"))
|
|
110
|
+
if extra:
|
|
111
|
+
logger.debug("build backend asked for extras: %s", extra)
|
|
112
|
+
env.install(extra)
|
|
113
|
+
|
|
114
|
+
with tempfile.TemporaryDirectory(prefix="nab-build-meta-") as out_str:
|
|
115
|
+
output_dir = Path(out_str)
|
|
116
|
+
if skip_prepare:
|
|
117
|
+
metadata_dir = _build_wheel_and_extract(project, output_dir)
|
|
118
|
+
else:
|
|
119
|
+
metadata_dir = Path(project.metadata_path(output_dir))
|
|
120
|
+
return _parse_metadata(metadata_dir / "METADATA")
|
|
121
|
+
except build.BuildBackendException as exc:
|
|
122
|
+
msg = f"build backend {backend!r} failed: {exc}"
|
|
123
|
+
raise BuildBackendError(msg) from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_build_system(
|
|
127
|
+
data: dict,
|
|
128
|
+
) -> tuple[str, tuple[str, ...], tuple[str, ...] | None]:
|
|
129
|
+
"""Return ``(backend, requires, backend_path)`` per PEP 517 / 518."""
|
|
130
|
+
table = data.get("build-system")
|
|
131
|
+
if not isinstance(table, dict):
|
|
132
|
+
return _DEFAULT_BACKEND, _DEFAULT_REQUIRES, None
|
|
133
|
+
backend = table.get("build-backend")
|
|
134
|
+
if not isinstance(backend, str):
|
|
135
|
+
backend = _DEFAULT_BACKEND
|
|
136
|
+
raw_requires = table.get("requires")
|
|
137
|
+
requires: tuple[str, ...]
|
|
138
|
+
if isinstance(raw_requires, list) and all(isinstance(r, str) for r in raw_requires):
|
|
139
|
+
requires = tuple(raw_requires)
|
|
140
|
+
else:
|
|
141
|
+
requires = _DEFAULT_REQUIRES
|
|
142
|
+
raw_path = table.get("backend-path")
|
|
143
|
+
backend_path: tuple[str, ...] | None = None
|
|
144
|
+
if isinstance(raw_path, list) and all(isinstance(p, str) for p in raw_path):
|
|
145
|
+
backend_path = tuple(raw_path)
|
|
146
|
+
return backend, requires, backend_path
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _should_skip_prepare(backend: str, data: dict) -> bool:
|
|
150
|
+
"""Skip ``prepare_metadata_for_build_wheel`` when it would lie.
|
|
151
|
+
|
|
152
|
+
uv documents one specific quirk: hatchling's prepare-metadata
|
|
153
|
+
hook can return a metadata that does not match the eventual
|
|
154
|
+
``build_wheel`` output when ``project.dynamic`` includes
|
|
155
|
+
``dependencies`` (or ``optional-dependencies``). Mirror that.
|
|
156
|
+
"""
|
|
157
|
+
if not backend.startswith("hatchling."):
|
|
158
|
+
return False
|
|
159
|
+
project = data.get("project")
|
|
160
|
+
if not isinstance(project, dict):
|
|
161
|
+
return False
|
|
162
|
+
dynamic = project.get("dynamic")
|
|
163
|
+
if not isinstance(dynamic, list):
|
|
164
|
+
return False
|
|
165
|
+
dyn = {d for d in dynamic if isinstance(d, str)}
|
|
166
|
+
return bool(dyn & {"dependencies", "optional-dependencies"})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_wheel_and_extract(
|
|
170
|
+
project: build.ProjectBuilder, output_directory: Path
|
|
171
|
+
) -> Path:
|
|
172
|
+
"""Build a wheel and extract its dist-info directory.
|
|
173
|
+
|
|
174
|
+
The built wheel ends up in ``output_directory``; this helper
|
|
175
|
+
pulls out its ``*.dist-info/`` and returns the path so the
|
|
176
|
+
caller can read ``METADATA``. Mirrors what
|
|
177
|
+
:meth:`build.ProjectBuilder.metadata_path` does internally
|
|
178
|
+
when the prepare hook is missing; we just call it
|
|
179
|
+
unconditionally for the hatchling+dynamic-deps case.
|
|
180
|
+
"""
|
|
181
|
+
wheel = project.build("wheel", str(output_directory))
|
|
182
|
+
wheel_path = Path(wheel)
|
|
183
|
+
# zipfile is only needed for the build path; deferred to keep
|
|
184
|
+
# import-time work minimal.
|
|
185
|
+
import zipfile
|
|
186
|
+
|
|
187
|
+
with zipfile.ZipFile(wheel_path) as zf:
|
|
188
|
+
dist_info_members = [
|
|
189
|
+
n
|
|
190
|
+
for n in zf.namelist()
|
|
191
|
+
if "/" in n and n.split("/")[0].endswith(".dist-info")
|
|
192
|
+
]
|
|
193
|
+
if not dist_info_members:
|
|
194
|
+
msg = f"built wheel {wheel_path.name} has no .dist-info directory"
|
|
195
|
+
raise BuildBackendError(msg)
|
|
196
|
+
distinfo_dir = dist_info_members[0].split("/")[0]
|
|
197
|
+
zf.extractall(
|
|
198
|
+
output_directory,
|
|
199
|
+
(
|
|
200
|
+
m
|
|
201
|
+
for m in zf.namelist()
|
|
202
|
+
if m == distinfo_dir or m.startswith(distinfo_dir + "/")
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
return output_directory / distinfo_dir
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_metadata(metadata_path: Path) -> WheelMetadata:
|
|
209
|
+
"""Parse a ``METADATA`` file into :class:`WheelMetadata`."""
|
|
210
|
+
if not metadata_path.is_file():
|
|
211
|
+
msg = f"backend produced no METADATA file at {metadata_path}"
|
|
212
|
+
raise BuildBackendError(msg)
|
|
213
|
+
text = metadata_path.read_text(encoding="utf-8")
|
|
214
|
+
msg_obj = message_from_string(text)
|
|
215
|
+
|
|
216
|
+
name_raw = msg_obj.get("Name")
|
|
217
|
+
version_raw = msg_obj.get("Version")
|
|
218
|
+
if not name_raw or not version_raw:
|
|
219
|
+
msg = (
|
|
220
|
+
f"backend METADATA at {metadata_path} is missing Name or Version"
|
|
221
|
+
f" (Name={name_raw!r}, Version={version_raw!r})"
|
|
222
|
+
)
|
|
223
|
+
raise BuildBackendError(msg)
|
|
224
|
+
try:
|
|
225
|
+
version = Version(version_raw)
|
|
226
|
+
except InvalidVersion as exc:
|
|
227
|
+
msg = f"backend METADATA has invalid Version {version_raw!r}: {exc}"
|
|
228
|
+
raise BuildBackendError(msg) from exc
|
|
229
|
+
|
|
230
|
+
requires_python_raw = msg_obj.get("Requires-Python")
|
|
231
|
+
requires_python = SpecifierSet(requires_python_raw) if requires_python_raw else None
|
|
232
|
+
|
|
233
|
+
requires_dist: list[Requirement] = []
|
|
234
|
+
for raw in msg_obj.get_all("Requires-Dist") or ():
|
|
235
|
+
try:
|
|
236
|
+
requires_dist.append(Requirement(raw))
|
|
237
|
+
except (ValueError, TypeError): # noqa: PERF203
|
|
238
|
+
logger.warning("skipping unparseable Requires-Dist: %s", raw)
|
|
239
|
+
|
|
240
|
+
provides_extra: list[str] = sorted(
|
|
241
|
+
{
|
|
242
|
+
canonicalize_name(extra)
|
|
243
|
+
for extra in msg_obj.get_all("Provides-Extra") or ()
|
|
244
|
+
if extra
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return WheelMetadata(
|
|
249
|
+
name=canonicalize_name(name_raw),
|
|
250
|
+
version=version,
|
|
251
|
+
requires_python=requires_python,
|
|
252
|
+
requires_dist=requires_dist,
|
|
253
|
+
provides_extra=provides_extra,
|
|
254
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal helpers backing :mod:`nab_python.lockfile`."""
|