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.
Files changed (71) hide show
  1. nab_python/__init__.py +1 -0
  2. nab_python/_build/__init__.py +1 -0
  3. nab_python/_build/env.py +364 -0
  4. nab_python/_build/errors.py +17 -0
  5. nab_python/_build/runner.py +254 -0
  6. nab_python/_lockfile/__init__.py +1 -0
  7. nab_python/_lockfile/builder.py +339 -0
  8. nab_python/_lockfile/disjointness.py +207 -0
  9. nab_python/_lockfile/pylock.py +323 -0
  10. nab_python/_lockfile/requirements.py +121 -0
  11. nab_python/_packaging_provider.py +98 -0
  12. nab_python/_provider/__init__.py +1 -0
  13. nab_python/_provider/build_remote.py +95 -0
  14. nab_python/_provider/extras.py +231 -0
  15. nab_python/_provider/listing.py +442 -0
  16. nab_python/_provider/lookahead.py +156 -0
  17. nab_python/_provider/metadata_resolver.py +450 -0
  18. nab_python/_provider/priority.py +174 -0
  19. nab_python/_provider/sources.py +215 -0
  20. nab_python/_testing/__init__.py +1 -0
  21. nab_python/_testing/coordinator_fake.py +240 -0
  22. nab_python/_vcs_admission.py +209 -0
  23. nab_python/_vendor/__init__.py +6 -0
  24. nab_python/_vendor/packaging/LICENSE +3 -0
  25. nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
  26. nab_python/_vendor/packaging/LICENSE.BSD +23 -0
  27. nab_python/_vendor/packaging/PROVENANCE.md +73 -0
  28. nab_python/_vendor/packaging/__init__.py +15 -0
  29. nab_python/_vendor/packaging/_elffile.py +108 -0
  30. nab_python/_vendor/packaging/_manylinux.py +265 -0
  31. nab_python/_vendor/packaging/_musllinux.py +88 -0
  32. nab_python/_vendor/packaging/_parser.py +394 -0
  33. nab_python/_vendor/packaging/_structures.py +33 -0
  34. nab_python/_vendor/packaging/_tokenizer.py +196 -0
  35. nab_python/_vendor/packaging/dependency_groups.py +302 -0
  36. nab_python/_vendor/packaging/direct_url.py +325 -0
  37. nab_python/_vendor/packaging/errors.py +94 -0
  38. nab_python/_vendor/packaging/licenses/__init__.py +186 -0
  39. nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
  40. nab_python/_vendor/packaging/markers.py +506 -0
  41. nab_python/_vendor/packaging/metadata.py +964 -0
  42. nab_python/_vendor/packaging/py.typed +0 -0
  43. nab_python/_vendor/packaging/pylock.py +910 -0
  44. nab_python/_vendor/packaging/ranges.py +1803 -0
  45. nab_python/_vendor/packaging/requirements.py +132 -0
  46. nab_python/_vendor/packaging/specifiers.py +1141 -0
  47. nab_python/_vendor/packaging/tags.py +929 -0
  48. nab_python/_vendor/packaging/utils.py +296 -0
  49. nab_python/_vendor/packaging/version.py +1230 -0
  50. nab_python/build_backend.py +184 -0
  51. nab_python/config.py +805 -0
  52. nab_python/download.py +170 -0
  53. nab_python/fetch.py +827 -0
  54. nab_python/lockfile.py +238 -0
  55. nab_python/metadata.py +145 -0
  56. nab_python/provider.py +1235 -0
  57. nab_python/py.typed +0 -0
  58. nab_python/requirements_file.py +180 -0
  59. nab_python/resolve.py +497 -0
  60. nab_python/universal/__init__.py +1 -0
  61. nab_python/universal/matrix.py +235 -0
  62. nab_python/universal/provider.py +214 -0
  63. nab_python/universal/reresolve.py +310 -0
  64. nab_python/universal/resolve.py +508 -0
  65. nab_python/universal/validate.py +439 -0
  66. nab_python/universal/wheel_selection.py +327 -0
  67. nab_python/workspace.py +214 -0
  68. nab_python-0.0.1.dist-info/METADATA +49 -0
  69. nab_python-0.0.1.dist-info/RECORD +71 -0
  70. nab_python-0.0.1.dist-info/WHEEL +4 -0
  71. 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."""
@@ -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`."""