nab-python 0.0.7__py3-none-any.whl → 0.0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nab_python/_build/env.py +95 -47
- nab_python/_build/runner.py +7 -8
- nab_python/_conflict_kind.py +2 -2
- nab_python/_iso8601.py +34 -0
- nab_python/_lockfile/builder.py +153 -54
- nab_python/_lockfile/disjointness.py +10 -3
- nab_python/_lockfile/pylock.py +242 -129
- nab_python/_lockfile/requirements.py +37 -33
- nab_python/_packaging_provider.py +24 -2
- nab_python/_provider/build_remote.py +1 -2
- nab_python/_provider/extras.py +34 -17
- nab_python/_provider/listing.py +258 -57
- nab_python/_provider/lookahead.py +5 -4
- nab_python/_provider/metadata_resolver.py +66 -30
- nab_python/_provider/sources.py +78 -59
- nab_python/_testing/coordinator_fake.py +34 -80
- nab_python/_vcs_admission.py +59 -4
- nab_python/_vendor/packaging/PROVENANCE.md +46 -77
- nab_python/_vendor/packaging/_manylinux.py +0 -2
- nab_python/_vendor/packaging/_musllinux.py +1 -1
- nab_python/_vendor/packaging/_ranges.py +17 -17
- nab_python/_vendor/packaging/dependency_groups.py +5 -4
- nab_python/_vendor/packaging/direct_url.py +17 -8
- nab_python/_vendor/packaging/markers.py +43 -4
- nab_python/_vendor/packaging/metadata.py +8 -2
- nab_python/_vendor/packaging/pylock.py +4 -1
- nab_python/_vendor/packaging/ranges.py +251 -57
- nab_python/_vendor/packaging/requirements.py +13 -2
- nab_python/_vendor/packaging/specifiers.py +6 -6
- nab_python/_vendor/packaging/tags.py +14 -5
- nab_python/_vendor/packaging/version.py +21 -5
- nab_python/build_backend.py +43 -21
- nab_python/config.py +890 -101
- nab_python/config_sources.py +56 -4
- nab_python/download.py +25 -22
- nab_python/fetch.py +333 -160
- nab_python/lockfile.py +70 -38
- nab_python/provider.py +479 -161
- nab_python/requirements_file.py +11 -56
- nab_python/resolve.py +1065 -753
- nab_python/tags.py +813 -0
- nab_python/target.py +1116 -0
- nab_python/workspace.py +138 -72
- {nab_python-0.0.7.dist-info → nab_python-0.0.9.dist-info}/METADATA +3 -3
- nab_python-0.0.9.dist-info/RECORD +71 -0
- nab_python/universal/__init__.py +0 -1
- nab_python/universal/matrix.py +0 -337
- nab_python/universal/provider.py +0 -234
- nab_python/universal/reresolve.py +0 -340
- nab_python/universal/resolve.py +0 -781
- nab_python/universal/validate.py +0 -554
- nab_python/universal/wheel_selection.py +0 -431
- nab_python-0.0.7.dist-info/RECORD +0 -75
- {nab_python-0.0.7.dist-info → nab_python-0.0.9.dist-info}/WHEEL +0 -0
nab_python/_build/env.py
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
resolved wheels into a temp directory.
|
|
13
13
|
* :func:`installer.install` writes each wheel into the venv via
|
|
14
14
|
``installer.SchemeDictionaryDestination``, configured from the
|
|
15
|
-
venv's own
|
|
15
|
+
venv's own scheme paths.
|
|
16
16
|
|
|
17
17
|
The env is a context manager. Entering it builds the venv and
|
|
18
18
|
installs the requirements; exiting removes the temp tree. The
|
|
@@ -40,6 +40,7 @@ from nab_index.urllib3_async_transport import Urllib3AsyncTransport
|
|
|
40
40
|
from .._vcs_admission import UnsupportedVcsError
|
|
41
41
|
from ..config import NabProjectConfig
|
|
42
42
|
from ..download import download_lock
|
|
43
|
+
from ..requirements_file import InvalidProjectRequirementError
|
|
43
44
|
|
|
44
45
|
if TYPE_CHECKING:
|
|
45
46
|
from collections.abc import Callable, Mapping
|
|
@@ -82,6 +83,15 @@ _LAUNCHER_KIND = (
|
|
|
82
83
|
else "posix"
|
|
83
84
|
)
|
|
84
85
|
|
|
86
|
+
_SCHEME_PROBE = (
|
|
87
|
+
"import json, sys, sysconfig;"
|
|
88
|
+
"print(json.dumps({"
|
|
89
|
+
"'paths': sysconfig.get_paths(),"
|
|
90
|
+
"'prefix': sys.prefix,"
|
|
91
|
+
"'py_version': '%d.%d' % sys.version_info[:2],"
|
|
92
|
+
"}))"
|
|
93
|
+
)
|
|
94
|
+
|
|
85
95
|
|
|
86
96
|
class BuildEnvError(Exception):
|
|
87
97
|
"""The build env could not be set up (resolve, download, or install)."""
|
|
@@ -103,6 +113,12 @@ class NabBuildEnv:
|
|
|
103
113
|
marker overlay) before the inner resolve so the build env is
|
|
104
114
|
computed against PyPI alone.
|
|
105
115
|
|
|
116
|
+
The venv is created from the host interpreter and the PEP 517
|
|
117
|
+
hooks run in it, so the build requirements resolve for the host
|
|
118
|
+
and not for any ``--python`` retarget: a wheel for another
|
|
119
|
+
Python's ABI would not import, and a build requirement the host
|
|
120
|
+
needs would be dropped by its marker.
|
|
121
|
+
|
|
106
122
|
Construction is cheap; the work happens in ``__enter__``.
|
|
107
123
|
"""
|
|
108
124
|
|
|
@@ -111,13 +127,11 @@ class NabBuildEnv:
|
|
|
111
127
|
requires: list[str],
|
|
112
128
|
*,
|
|
113
129
|
config: NabProjectConfig,
|
|
114
|
-
python_version: str | None = None,
|
|
115
130
|
transport_factory: Callable[[], AsyncHttpTransport] = Urllib3AsyncTransport,
|
|
116
131
|
) -> None:
|
|
117
132
|
"""Capture inputs; the venv and inner resolve happen in __enter__."""
|
|
118
133
|
self._requires = list(requires)
|
|
119
134
|
self._config = config
|
|
120
|
-
self._python_version = python_version
|
|
121
135
|
self._transport_factory = transport_factory
|
|
122
136
|
|
|
123
137
|
self._tmpdir: tempfile.TemporaryDirectory[str] | None = None
|
|
@@ -151,21 +165,27 @@ class NabBuildEnv:
|
|
|
151
165
|
self._python_executable = _venv_python(self._venv_path)
|
|
152
166
|
self._scripts_dir = self._python_executable.parent
|
|
153
167
|
|
|
154
|
-
|
|
168
|
+
scheme_paths = _venv_scheme_paths(self._python_executable)
|
|
155
169
|
|
|
156
170
|
if not self._requires:
|
|
157
171
|
return
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
172
|
+
|
|
173
|
+
self._install_wheels(self._resolve_and_download(wheel_dir), scheme_paths)
|
|
174
|
+
|
|
175
|
+
def _install_wheels(
|
|
176
|
+
self, wheel_paths: list[Path], scheme_paths: dict[str, str]
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Write each wheel into the venv with ``installer``."""
|
|
166
179
|
for wheel_path in wheel_paths:
|
|
167
180
|
logger.debug("installing %s", wheel_path.name)
|
|
168
181
|
with WheelFile.open(wheel_path) as source:
|
|
182
|
+
destination = _FastSchemeDictionaryDestination(
|
|
183
|
+
scheme_dict=_dist_scheme_paths(scheme_paths, source.distribution),
|
|
184
|
+
interpreter=str(self._python_executable),
|
|
185
|
+
script_kind=_LAUNCHER_KIND,
|
|
186
|
+
bytecode_optimization_levels=(),
|
|
187
|
+
overwrite_existing=True,
|
|
188
|
+
)
|
|
169
189
|
installer_install(
|
|
170
190
|
source=source,
|
|
171
191
|
destination=destination,
|
|
@@ -224,21 +244,7 @@ class NabBuildEnv:
|
|
|
224
244
|
sub = wheel_dir / f"_extra_{len(list(wheel_dir.iterdir()))}"
|
|
225
245
|
sub.mkdir(parents=True, exist_ok=True)
|
|
226
246
|
wheel_paths = self._resolve_and_download(sub, extra=requirements)
|
|
227
|
-
|
|
228
|
-
destination = SchemeDictionaryDestination(
|
|
229
|
-
scheme_dict=scheme_dict,
|
|
230
|
-
interpreter=str(self._python_executable),
|
|
231
|
-
script_kind=_LAUNCHER_KIND,
|
|
232
|
-
bytecode_optimization_levels=(),
|
|
233
|
-
overwrite_existing=True,
|
|
234
|
-
)
|
|
235
|
-
for wheel_path in wheel_paths:
|
|
236
|
-
with WheelFile.open(wheel_path) as source:
|
|
237
|
-
installer_install(
|
|
238
|
-
source=source,
|
|
239
|
-
destination=destination,
|
|
240
|
-
additional_metadata={"INSTALLER": b"nab\n"},
|
|
241
|
-
)
|
|
247
|
+
self._install_wheels(wheel_paths, _venv_scheme_paths(self._python_executable))
|
|
242
248
|
|
|
243
249
|
def _resolve_and_download(
|
|
244
250
|
self,
|
|
@@ -249,7 +255,7 @@ class NabBuildEnv:
|
|
|
249
255
|
"""Resolve ``requires`` (+ ``extra``) and write wheels under ``wheel_dir``.
|
|
250
256
|
|
|
251
257
|
The inner resolve runs against a synthetic pyproject so it
|
|
252
|
-
can reuse :func:`nab_python.resolve.
|
|
258
|
+
can reuse :func:`nab_python.resolve.resolve_for_targets` and
|
|
253
259
|
:func:`nab_python.download.download_lock` end-to-end. No
|
|
254
260
|
local sources / workspace / marker overlay; build deps
|
|
255
261
|
come from the configured indexes only.
|
|
@@ -257,7 +263,7 @@ class NabBuildEnv:
|
|
|
257
263
|
# Late import: avoids a cycle through ``resolve.py`` which
|
|
258
264
|
# itself imports ``pypi.py`` which imports ``build_backend``
|
|
259
265
|
# which imports this module.
|
|
260
|
-
from ..resolve import
|
|
266
|
+
from ..resolve import build_lock_input, resolve_for_targets
|
|
261
267
|
|
|
262
268
|
requires = list(self._requires)
|
|
263
269
|
if extra:
|
|
@@ -279,27 +285,40 @@ class NabBuildEnv:
|
|
|
279
285
|
# build a fresh transport each time.
|
|
280
286
|
transport = self._transport_factory()
|
|
281
287
|
try:
|
|
282
|
-
result =
|
|
288
|
+
result = resolve_for_targets(
|
|
283
289
|
synthetic,
|
|
284
290
|
transport,
|
|
285
291
|
config=inner_config,
|
|
286
|
-
python_version=self._python_version,
|
|
287
292
|
)
|
|
288
|
-
|
|
289
|
-
#
|
|
290
|
-
|
|
291
|
-
|
|
293
|
+
# The build env resolves for the host alone, so its one
|
|
294
|
+
# target's failure is the whole resolve's.
|
|
295
|
+
result.raise_for_failure()
|
|
296
|
+
except (
|
|
297
|
+
UnsupportedVcsError,
|
|
298
|
+
NotImplementedError,
|
|
299
|
+
InvalidProjectRequirementError,
|
|
300
|
+
) as exc:
|
|
301
|
+
# A build requirement nab cannot resolve: a direct-URL/VCS pin, or
|
|
302
|
+
# a string that is not valid PEP 508. Wrap it so the outer resolve
|
|
303
|
+
# skips this sdist rather than aborting on the raw error.
|
|
292
304
|
msg = f"build env resolve failed: {exc}"
|
|
293
305
|
raise BuildEnvError(msg) from exc
|
|
294
306
|
|
|
307
|
+
lock_input = build_lock_input(result, config=inner_config)
|
|
308
|
+
|
|
295
309
|
# Reject sdist-only pins early: build deps that ship only an
|
|
296
310
|
# sdist trigger a recursive backend invocation that this
|
|
297
311
|
# builder does not handle. Most build tools (hatchling,
|
|
298
312
|
# setuptools, flit, pdm-backend) publish wheels.
|
|
299
313
|
from ..lockfile import IndexPin
|
|
300
314
|
|
|
315
|
+
pins = {
|
|
316
|
+
name: pin
|
|
317
|
+
for lock in lock_input.targets.values()
|
|
318
|
+
for name, pin in lock.pins.items()
|
|
319
|
+
}
|
|
301
320
|
sdist_only: list[str] = []
|
|
302
|
-
for canonical, pin in
|
|
321
|
+
for canonical, pin in pins.items():
|
|
303
322
|
if isinstance(pin, IndexPin) and not pin.wheels:
|
|
304
323
|
sdist_only.append(f"{canonical}=={pin.version}")
|
|
305
324
|
if sdist_only:
|
|
@@ -310,7 +329,7 @@ class NabBuildEnv:
|
|
|
310
329
|
)
|
|
311
330
|
raise BuildEnvError(msg)
|
|
312
331
|
|
|
313
|
-
download_result = download_lock(
|
|
332
|
+
download_result = download_lock(lock_input, transport, wheel_dir)
|
|
314
333
|
# Both wheels and sdists are downloaded; only wheels feed
|
|
315
334
|
# ``installer.install``. The sdists are inert clutter under
|
|
316
335
|
# the temp dir, cleaned up with the env.
|
|
@@ -326,24 +345,39 @@ def _venv_python(venv_path: Path) -> Path:
|
|
|
326
345
|
|
|
327
346
|
|
|
328
347
|
def _venv_scheme_paths(python_executable: Path) -> dict[str, str]:
|
|
329
|
-
"""Ask the venv's interpreter for
|
|
348
|
+
"""Ask the venv's interpreter for the scheme paths ``installer`` writes to.
|
|
330
349
|
|
|
331
350
|
Subprocessing the venv guarantees the returned paths reflect the
|
|
332
351
|
venv's layout (``site-packages`` under the venv root, scripts in
|
|
333
352
|
its ``bin``/``Scripts`` dir, etc.) regardless of how nab itself
|
|
334
353
|
was installed. One subprocess per env construction; negligible.
|
|
354
|
+
|
|
355
|
+
``sysconfig`` has no ``headers`` scheme, and its ``include`` names
|
|
356
|
+
the base interpreter rather than the venv, so the header root comes
|
|
357
|
+
from the venv's own prefix instead.
|
|
335
358
|
"""
|
|
336
359
|
result = subprocess.run( # noqa: S603 - controlled command, no shell
|
|
337
|
-
[
|
|
338
|
-
str(python_executable),
|
|
339
|
-
"-c",
|
|
340
|
-
"import json, sysconfig; print(json.dumps(sysconfig.get_paths()))",
|
|
341
|
-
],
|
|
360
|
+
[str(python_executable), "-c", _SCHEME_PROBE],
|
|
342
361
|
capture_output=True,
|
|
343
362
|
text=True,
|
|
344
363
|
check=True,
|
|
345
364
|
)
|
|
346
|
-
|
|
365
|
+
probe = json.loads(result.stdout)
|
|
366
|
+
|
|
367
|
+
paths: dict[str, str] = dict(probe["paths"])
|
|
368
|
+
paths["headers"] = str(
|
|
369
|
+
Path(probe["prefix"], "include", "site", f"python{probe['py_version']}")
|
|
370
|
+
)
|
|
371
|
+
return paths
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _dist_scheme_paths(
|
|
375
|
+
scheme_paths: dict[str, str], distribution: str
|
|
376
|
+
) -> dict[str, str]:
|
|
377
|
+
"""Scheme paths for one wheel; its headers go in a directory of its own."""
|
|
378
|
+
paths = dict(scheme_paths)
|
|
379
|
+
paths["headers"] = str(Path(paths["headers"], distribution))
|
|
380
|
+
return paths
|
|
347
381
|
|
|
348
382
|
|
|
349
383
|
def _supports_symlinks() -> bool:
|
|
@@ -371,6 +405,20 @@ def _render_synthetic_pyproject(requires: list[str]) -> str:
|
|
|
371
405
|
|
|
372
406
|
|
|
373
407
|
def _toml_str(value: str) -> str:
|
|
374
|
-
"""
|
|
375
|
-
|
|
376
|
-
|
|
408
|
+
"""Escape a string as a single-line TOML basic-string literal.
|
|
409
|
+
|
|
410
|
+
Backslash, double-quote, and the control characters TOML forbids bare in a
|
|
411
|
+
basic string (below U+0020, plus U+007F) are escaped, so a requirement
|
|
412
|
+
carrying a newline stays valid TOML instead of splitting across a line.
|
|
413
|
+
"""
|
|
414
|
+
out: list[str] = []
|
|
415
|
+
for ch in value:
|
|
416
|
+
if ch == "\\":
|
|
417
|
+
out.append("\\\\")
|
|
418
|
+
elif ch == '"':
|
|
419
|
+
out.append('\\"')
|
|
420
|
+
elif ch < "\x20" or ch == "\x7f":
|
|
421
|
+
out.append(f"\\u{ord(ch):04x}")
|
|
422
|
+
else:
|
|
423
|
+
out.append(ch)
|
|
424
|
+
return '"' + "".join(out) + '"'
|
nab_python/_build/runner.py
CHANGED
|
@@ -62,7 +62,6 @@ def run_build_backend(
|
|
|
62
62
|
source_dir: Path,
|
|
63
63
|
*,
|
|
64
64
|
config: NabProjectConfig,
|
|
65
|
-
python_version: str | None = None,
|
|
66
65
|
) -> WheelMetadata:
|
|
67
66
|
"""Extract wheel metadata for ``source_dir`` via the build backend.
|
|
68
67
|
|
|
@@ -80,7 +79,7 @@ def run_build_backend(
|
|
|
80
79
|
if pyproject.is_file():
|
|
81
80
|
try:
|
|
82
81
|
data = tomli.loads(pyproject.read_text(encoding="utf-8"))
|
|
83
|
-
except (OSError, tomli.TOMLDecodeError) as exc:
|
|
82
|
+
except (OSError, UnicodeDecodeError, tomli.TOMLDecodeError) as exc:
|
|
84
83
|
msg = f"could not read pyproject.toml at {source_dir}: {exc}"
|
|
85
84
|
raise BuildBackendError(msg) from exc
|
|
86
85
|
elif (source_dir / "setup.py").is_file():
|
|
@@ -97,11 +96,7 @@ def run_build_backend(
|
|
|
97
96
|
skip_prepare = _should_skip_prepare(backend, data)
|
|
98
97
|
|
|
99
98
|
try:
|
|
100
|
-
with NabBuildEnv(
|
|
101
|
-
requires=list(requires),
|
|
102
|
-
config=config,
|
|
103
|
-
python_version=python_version,
|
|
104
|
-
) as env:
|
|
99
|
+
with NabBuildEnv(requires=list(requires), config=config) as env:
|
|
105
100
|
project = build.ProjectBuilder.from_isolated_env(
|
|
106
101
|
env,
|
|
107
102
|
source_dir=str(source_dir),
|
|
@@ -120,7 +115,11 @@ def run_build_backend(
|
|
|
120
115
|
else:
|
|
121
116
|
metadata_dir = Path(project.metadata_path(output_dir))
|
|
122
117
|
return _parse_metadata(metadata_dir / "METADATA")
|
|
123
|
-
except
|
|
118
|
+
except (
|
|
119
|
+
build.BuildException,
|
|
120
|
+
build.BuildBackendException,
|
|
121
|
+
build.FailedProcessError,
|
|
122
|
+
) as exc:
|
|
124
123
|
msg = f"build backend {backend!r} failed: {exc}"
|
|
125
124
|
raise BuildBackendError(msg) from exc
|
|
126
125
|
except (BuildEnvError, ResolutionError) as exc:
|
nab_python/_conflict_kind.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Conflict-kind constants and PEP 508 marker-variable mapping.
|
|
2
2
|
|
|
3
|
-
A leaf module that :mod:`nab_python.config`, :mod:`nab_python.
|
|
4
|
-
|
|
3
|
+
A leaf module that :mod:`nab_python.config`, :mod:`nab_python.target`, and
|
|
4
|
+
:mod:`nab_python._lockfile.disjointness` can import without forming a
|
|
5
5
|
cycle. :class:`nab_python.config.ConflictKind` takes its enum values from
|
|
6
6
|
``KIND_EXTRA`` / ``KIND_GROUP`` so a rename here flows to every consumer.
|
|
7
7
|
"""
|
nab_python/_iso8601.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared parsing for the ISO 8601 timestamps that indexes and pylock files carry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
_FRACTIONAL_SECONDS = re.compile(r"\.(\d+)")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_iso_datetime(raw: str) -> datetime:
|
|
12
|
+
"""Parse an ISO 8601 datetime, raising ``ValueError`` when it cannot be read.
|
|
13
|
+
|
|
14
|
+
The offset in ``raw`` is preserved, and the result is naive when there is
|
|
15
|
+
none, so each caller decides what a missing offset means.
|
|
16
|
+
"""
|
|
17
|
+
return datetime.fromisoformat(_to_isoformat(raw))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_isoformat(raw: str) -> str:
|
|
21
|
+
"""Rewrite ``raw`` into the shape ``datetime.isoformat`` emits.
|
|
22
|
+
|
|
23
|
+
Before Python 3.11, ``datetime.fromisoformat`` parses only that shape: an
|
|
24
|
+
explicit ``+HH:MM`` offset rather than ``Z``, and a fraction of exactly 3 or
|
|
25
|
+
6 digits. PEP 700 serves ``Z`` and permits 0 through 6, so those are the two
|
|
26
|
+
parts normalized here; the rest is left for ``fromisoformat`` to judge.
|
|
27
|
+
"""
|
|
28
|
+
iso = f"{raw[:-1]}+00:00" if raw.endswith("Z") else raw
|
|
29
|
+
return _FRACTIONAL_SECONDS.sub(_microseconds, iso)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _microseconds(match: re.Match[str]) -> str:
|
|
33
|
+
# A datetime holds microseconds, so digits past the sixth are dropped.
|
|
34
|
+
return "." + match.group(1)[:6].ljust(6, "0")
|
nab_python/_lockfile/builder.py
CHANGED
|
@@ -19,6 +19,7 @@ import tomli
|
|
|
19
19
|
|
|
20
20
|
from nab_index.client import SdistFile, WheelFile
|
|
21
21
|
|
|
22
|
+
from .._iso8601 import parse_iso_datetime
|
|
22
23
|
from .._toml import tool_nab_section
|
|
23
24
|
from .._vendor.packaging.pylock import Pylock, PylockValidationError
|
|
24
25
|
from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
@@ -36,17 +37,19 @@ if TYPE_CHECKING:
|
|
|
36
37
|
LockInput,
|
|
37
38
|
PinShape,
|
|
38
39
|
SdistArtifact,
|
|
40
|
+
TargetLock,
|
|
39
41
|
VcsPin,
|
|
40
42
|
WheelArtifact,
|
|
41
43
|
)
|
|
42
44
|
from ..provider import ArchiveSource, DistPolicy, LocalSource, VcsSource
|
|
45
|
+
from ..target import ResolveTarget
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
__all__ = [
|
|
46
49
|
"MissingHashError",
|
|
47
50
|
"MissingSdistError",
|
|
48
51
|
"MissingVcsCommitError",
|
|
49
|
-
"
|
|
52
|
+
"build_target_lock",
|
|
50
53
|
"read_lockfile_anchor",
|
|
51
54
|
"read_lockfile_packages",
|
|
52
55
|
"require_artifact_hashes",
|
|
@@ -74,7 +77,7 @@ class LockInputProvider(Protocol):
|
|
|
74
77
|
"""Structural protocol for the provider slice the builder reads.
|
|
75
78
|
|
|
76
79
|
Mirrors the public surface :class:`~nab_python.provider.Provider`
|
|
77
|
-
exposes that :func:`
|
|
80
|
+
exposes that :func:`build_target_lock` consumes; tests
|
|
78
81
|
may supply a stub without inheriting the full Provider class.
|
|
79
82
|
"""
|
|
80
83
|
|
|
@@ -178,17 +181,15 @@ def read_lockfile_anchor(path: Path) -> datetime | None:
|
|
|
178
181
|
try:
|
|
179
182
|
with path.open("rb") as f:
|
|
180
183
|
data = tomli.load(f)
|
|
181
|
-
except (OSError, tomli.TOMLDecodeError):
|
|
184
|
+
except (OSError, UnicodeDecodeError, tomli.TOMLDecodeError):
|
|
182
185
|
return None
|
|
183
186
|
nab = tool_nab_section(data)
|
|
184
187
|
raw = nab.get("created-at") if isinstance(nab, dict) else None
|
|
185
188
|
if isinstance(raw, datetime):
|
|
186
189
|
return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
|
|
187
190
|
if isinstance(raw, str):
|
|
188
|
-
# Python 3.10's fromisoformat rejects a trailing 'Z'; 3.11+ accept it.
|
|
189
|
-
iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
|
190
191
|
try:
|
|
191
|
-
dt =
|
|
192
|
+
dt = parse_iso_datetime(raw)
|
|
192
193
|
except ValueError:
|
|
193
194
|
return None
|
|
194
195
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
@@ -212,7 +213,7 @@ def read_lockfile_packages(path: Path) -> dict[str, Version] | None:
|
|
|
212
213
|
with path.open("rb") as f:
|
|
213
214
|
data = tomli.load(f)
|
|
214
215
|
pylock = Pylock.from_dict(data)
|
|
215
|
-
except (OSError, tomli.TOMLDecodeError, PylockValidationError):
|
|
216
|
+
except (OSError, UnicodeDecodeError, tomli.TOMLDecodeError, PylockValidationError):
|
|
216
217
|
return None
|
|
217
218
|
return {
|
|
218
219
|
str(pkg.name): pkg.version for pkg in pylock.packages if pkg.version is not None
|
|
@@ -239,37 +240,50 @@ def _strip_userinfo(url: str) -> str:
|
|
|
239
240
|
return urlunsplit(parts._replace(netloc=host))
|
|
240
241
|
|
|
241
242
|
|
|
242
|
-
def
|
|
243
|
+
def build_target_lock(
|
|
243
244
|
provider: LockInputProvider,
|
|
245
|
+
target: ResolveTarget,
|
|
244
246
|
pins: Mapping[str, Version],
|
|
245
247
|
*,
|
|
246
|
-
requires_python: str | None = None,
|
|
247
|
-
extras: Sequence[str] = (),
|
|
248
|
-
dependency_groups: Sequence[str] = (),
|
|
249
|
-
default_groups: Sequence[str] = (),
|
|
250
|
-
created_by: str = "nab",
|
|
251
248
|
indexes: Sequence[IndexConfig] = (),
|
|
252
249
|
resolved_keys: Iterable[str] = (),
|
|
253
|
-
|
|
254
|
-
|
|
250
|
+
base_roots: Iterable[str] | None = None,
|
|
251
|
+
selector_roots: Mapping[tuple[str, str], Iterable[str]] | None = None,
|
|
252
|
+
) -> TargetLock:
|
|
253
|
+
"""Build one target's :class:`~nab_python.lockfile.TargetLock`.
|
|
255
254
|
|
|
256
|
-
``provider`` is the :class:`Provider` that drove the resolve
|
|
257
|
-
its caches still hold the listings the resolver
|
|
258
|
-
is the canonical-name -> :class:`Version`
|
|
259
|
-
resolver after extras keys have been
|
|
260
|
-
|
|
261
|
-
``dependency_groups`` lists the PEP 735 groups whose requirements
|
|
262
|
-
were folded into this resolve; ``default_groups`` is the subset
|
|
263
|
-
that a default install (no ``--group`` flag) should apply.
|
|
255
|
+
``provider`` is the :class:`Provider` that drove the resolve for
|
|
256
|
+
``target``; its caches still hold the listings the resolver
|
|
257
|
+
consumed. ``pins`` is the canonical-name -> :class:`Version`
|
|
258
|
+
mapping returned by the resolver after extras keys have been
|
|
259
|
+
stripped.
|
|
264
260
|
|
|
265
261
|
``resolved_keys`` is the full set of resolver result keys, including
|
|
266
262
|
``name[extra]`` proxies; it is read to find which extras activated
|
|
267
263
|
so their edges join the forward dependency graph.
|
|
268
264
|
|
|
269
|
-
|
|
270
|
-
|
|
265
|
+
``base_roots`` and ``selector_roots`` are the resolver keys each
|
|
266
|
+
install context requires directly: the project's own dependencies,
|
|
267
|
+
and those of each selected extra and each selected group, keyed by
|
|
268
|
+
its ``(kind, name)`` member. :func:`_membership_gates` walks the
|
|
269
|
+
resolve from them to find the packages only a selection reaches. An
|
|
270
|
+
empty ``base_roots`` is a project with no dependencies of its own, so
|
|
271
|
+
it does not stand in for ``None``: omitting it while passing selector
|
|
272
|
+
roots raises.
|
|
273
|
+
|
|
274
|
+
Every wheel the target can install, plus the sdist, is recorded for
|
|
275
|
+
each pinned version.
|
|
271
276
|
"""
|
|
272
|
-
from ..lockfile import LocalPin,
|
|
277
|
+
from ..lockfile import LocalPin, TargetLock
|
|
278
|
+
|
|
279
|
+
if base_roots is None:
|
|
280
|
+
if selector_roots:
|
|
281
|
+
msg = (
|
|
282
|
+
"selector_roots need base_roots:"
|
|
283
|
+
" without them every package looks selector-only"
|
|
284
|
+
)
|
|
285
|
+
raise ValueError(msg)
|
|
286
|
+
base_roots = ()
|
|
273
287
|
|
|
274
288
|
lock_pins: dict[str, PinShape] = {}
|
|
275
289
|
for raw_name, version in pins.items():
|
|
@@ -302,30 +316,113 @@ def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a disti
|
|
|
302
316
|
lock_pins[canonical] = _index_pin_from_listing(
|
|
303
317
|
provider, canonical, version, indexes
|
|
304
318
|
)
|
|
305
|
-
|
|
319
|
+
|
|
320
|
+
dependencies, base_dependencies = _forward_dependency_graph(
|
|
321
|
+
provider, pins, resolved_keys
|
|
322
|
+
)
|
|
323
|
+
return TargetLock(
|
|
324
|
+
target=target,
|
|
306
325
|
pins=lock_pins,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
326
|
+
dependencies=dependencies,
|
|
327
|
+
base_dependencies=base_dependencies,
|
|
328
|
+
package_gates=_membership_gates(
|
|
329
|
+
provider,
|
|
330
|
+
pins,
|
|
331
|
+
base_roots=base_roots,
|
|
332
|
+
selector_roots=selector_roots or {},
|
|
333
|
+
),
|
|
313
334
|
)
|
|
314
335
|
|
|
315
336
|
|
|
337
|
+
def _membership_gates(
|
|
338
|
+
provider: LockInputProvider,
|
|
339
|
+
pins: Mapping[str, Version],
|
|
340
|
+
*,
|
|
341
|
+
base_roots: Iterable[str],
|
|
342
|
+
selector_roots: Mapping[tuple[str, str], Iterable[str]],
|
|
343
|
+
) -> dict[str, tuple[tuple[str, str], ...]]:
|
|
344
|
+
"""Name the selections that gate each package no base dependency reaches.
|
|
345
|
+
|
|
346
|
+
A selected extra or group is folded into the resolve that produces
|
|
347
|
+
the lock, so its requirements pin packages a default install must not
|
|
348
|
+
receive. PEP 751 defaults an install to no extras and to
|
|
349
|
+
``default-groups``, and decides per package from ``packages.marker``,
|
|
350
|
+
so a package only a selection reaches has to name every selection
|
|
351
|
+
that reaches it; the writer turns each ``(kind, name)`` member into
|
|
352
|
+
``'name' in extras`` / ``'name' in dependency_groups``. A package
|
|
353
|
+
the project's own dependencies reach is unconditional.
|
|
354
|
+
|
|
355
|
+
Reachability is over this target's resolved graph, so an extras proxy
|
|
356
|
+
(an extra requiring ``pkg[fancy]`` while the project requires plain
|
|
357
|
+
``pkg``) gates what ``fancy`` adds without gating ``pkg``.
|
|
358
|
+
"""
|
|
359
|
+
if not selector_roots:
|
|
360
|
+
return {}
|
|
361
|
+
|
|
362
|
+
pinned = {canonicalize_name(name): version for name, version in pins.items()}
|
|
363
|
+
base_reachable = _reachable_names(provider, pinned, base_roots)
|
|
364
|
+
|
|
365
|
+
gates: defaultdict[str, list[tuple[str, str]]] = defaultdict(list)
|
|
366
|
+
for member in sorted(selector_roots):
|
|
367
|
+
gated = _reachable_names(provider, pinned, selector_roots[member])
|
|
368
|
+
for name in gated - base_reachable:
|
|
369
|
+
gates[name].append(member)
|
|
370
|
+
return {name: tuple(members) for name, members in gates.items()}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _reachable_names(
|
|
374
|
+
provider: LockInputProvider,
|
|
375
|
+
pinned: Mapping[str, Version],
|
|
376
|
+
roots: Iterable[str],
|
|
377
|
+
) -> set[str]:
|
|
378
|
+
"""Return the pinned names reachable from ``roots`` at their pinned versions.
|
|
379
|
+
|
|
380
|
+
The walk is over resolver keys, so a ``name[extra]`` root pulls in
|
|
381
|
+
that extra's dependencies on top of the package's own.
|
|
382
|
+
"""
|
|
383
|
+
from ..provider import split_extra
|
|
384
|
+
|
|
385
|
+
reached: set[str] = set()
|
|
386
|
+
seen: set[str] = set()
|
|
387
|
+
stack = list(roots)
|
|
388
|
+
|
|
389
|
+
while stack:
|
|
390
|
+
key = stack.pop()
|
|
391
|
+
if key in seen:
|
|
392
|
+
continue
|
|
393
|
+
seen.add(key)
|
|
394
|
+
|
|
395
|
+
raw_name, extra = split_extra(key)
|
|
396
|
+
canonical = canonicalize_name(raw_name)
|
|
397
|
+
version = pinned.get(canonical)
|
|
398
|
+
if version is None:
|
|
399
|
+
continue
|
|
400
|
+
reached.add(canonical)
|
|
401
|
+
|
|
402
|
+
cache_key = (canonical, version)
|
|
403
|
+
stack.extend(provider.deps_cache.get(cache_key, {}))
|
|
404
|
+
if extra is not None:
|
|
405
|
+
stack.extend(provider.extra_deps_map.get(cache_key, {}).get(extra, {}))
|
|
406
|
+
|
|
407
|
+
return reached
|
|
408
|
+
|
|
409
|
+
|
|
316
410
|
def _forward_dependency_graph(
|
|
317
411
|
provider: LockInputProvider,
|
|
318
412
|
pins: Mapping[str, Version],
|
|
319
413
|
resolved_keys: Iterable[str],
|
|
320
|
-
) -> dict[str, tuple[str, ...]]:
|
|
414
|
+
) -> tuple[dict[str, tuple[str, ...]], dict[str, tuple[str, ...]]]:
|
|
321
415
|
"""Build the forward dependency graph among the locked packages.
|
|
322
416
|
|
|
323
|
-
|
|
324
|
-
dependencies that are themselves
|
|
325
|
-
|
|
326
|
-
``resolved_keys``) folds that extra's dependencies in
|
|
327
|
-
|
|
328
|
-
|
|
417
|
+
Returns ``(full, base)``. ``full`` maps each pinned package to the
|
|
418
|
+
canonical names of its direct dependencies that are themselves
|
|
419
|
+
pinned; an activated extra (a ``name[extra]`` key in
|
|
420
|
+
``resolved_keys``) folds that extra's dependencies in. ``base`` is
|
|
421
|
+
the subset from each package's own metadata (``deps_cache``), before
|
|
422
|
+
any extra is folded in, so it holds only the edges that fire
|
|
423
|
+
regardless of which extra was activated. Names not in ``pins`` are
|
|
424
|
+
dropped from both so every edge points at a real ``[[packages]]``
|
|
425
|
+
entry.
|
|
329
426
|
"""
|
|
330
427
|
from ..provider import split_extra
|
|
331
428
|
|
|
@@ -337,26 +434,32 @@ def _forward_dependency_graph(
|
|
|
337
434
|
|
|
338
435
|
pinned = {canonicalize_name(name) for name in pins}
|
|
339
436
|
graph: dict[str, tuple[str, ...]] = {}
|
|
437
|
+
base_graph: dict[str, tuple[str, ...]] = {}
|
|
340
438
|
for raw_name, version in pins.items():
|
|
341
439
|
canonical = canonicalize_name(raw_name)
|
|
342
440
|
cache_key = (canonical, version)
|
|
343
|
-
|
|
441
|
+
base_deps = {
|
|
344
442
|
canonicalize_name(split_extra(dep)[0])
|
|
345
443
|
for dep in provider.deps_cache.get(cache_key, {})
|
|
346
444
|
}
|
|
445
|
+
all_deps = set(base_deps)
|
|
347
446
|
extra_map = provider.extra_deps_map.get(cache_key, {})
|
|
348
447
|
for extra in activated_extras.get(canonical, ()):
|
|
349
|
-
|
|
448
|
+
all_deps.update(
|
|
350
449
|
canonicalize_name(split_extra(dep)[0])
|
|
351
450
|
for dep in extra_map.get(extra, {})
|
|
352
451
|
)
|
|
353
|
-
|
|
452
|
+
base_deps &= pinned
|
|
453
|
+
all_deps &= pinned
|
|
354
454
|
# An umbrella extra (pkg[all] pulling pkg[graphviz]) can name its own
|
|
355
455
|
# package; drop it so pkg is never an edge to itself.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
456
|
+
base_deps.discard(canonical)
|
|
457
|
+
all_deps.discard(canonical)
|
|
458
|
+
if all_deps:
|
|
459
|
+
graph[canonical] = tuple(sorted(all_deps))
|
|
460
|
+
if base_deps:
|
|
461
|
+
base_graph[canonical] = tuple(sorted(base_deps))
|
|
462
|
+
return graph, base_graph
|
|
360
463
|
|
|
361
464
|
|
|
362
465
|
def _index_pin_from_listing(
|
|
@@ -478,7 +581,7 @@ def _parse_upload_time(raw: str | None) -> datetime | None:
|
|
|
478
581
|
if raw is None:
|
|
479
582
|
return None
|
|
480
583
|
try:
|
|
481
|
-
parsed =
|
|
584
|
+
parsed = parse_iso_datetime(raw)
|
|
482
585
|
except ValueError:
|
|
483
586
|
return None
|
|
484
587
|
if parsed.tzinfo is None:
|
|
@@ -514,12 +617,8 @@ def require_artifact_hashes(lock_input: LockInput) -> None:
|
|
|
514
617
|
"""
|
|
515
618
|
from ..lockfile import ACCEPTED_HASH_ALGORITHMS, IndexPin
|
|
516
619
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
*lock_input.per_tuple_pins.values(),
|
|
520
|
-
)
|
|
521
|
-
for pins in pin_groups:
|
|
522
|
-
for pin in pins.values():
|
|
620
|
+
for lock in lock_input.targets.values():
|
|
621
|
+
for pin in lock.pins.values():
|
|
523
622
|
if not isinstance(pin, IndexPin):
|
|
524
623
|
continue
|
|
525
624
|
artefacts = (*pin.wheels, *((pin.sdist,) if pin.sdist is not None else ()))
|