nab-python 0.0.5__py3-none-any.whl → 0.0.7__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 +17 -9
- nab_python/_build/runner.py +16 -12
- nab_python/_conflict_kind.py +42 -0
- nab_python/_lockfile/builder.py +143 -42
- nab_python/_lockfile/disjointness.py +5 -5
- nab_python/_lockfile/pylock.py +46 -10
- nab_python/_lockfile/requirements.py +21 -6
- nab_python/_provider/build_remote.py +39 -4
- nab_python/_provider/extras.py +40 -20
- nab_python/_provider/listing.py +186 -52
- nab_python/_provider/metadata_resolver.py +164 -51
- nab_python/_provider/priority.py +4 -2
- nab_python/_provider/sources.py +184 -13
- nab_python/_testing/coordinator_fake.py +6 -1
- nab_python/_testing/overrides.py +18 -0
- nab_python/_vcs_admission.py +67 -11
- nab_python/_vendor/packaging/PROVENANCE.md +48 -29
- nab_python/_vendor/packaging/_elffile.py +5 -5
- nab_python/_vendor/packaging/_manylinux.py +30 -15
- nab_python/_vendor/packaging/_parser.py +21 -4
- nab_python/_vendor/packaging/_ranges.py +836 -0
- nab_python/_vendor/packaging/_tokenizer.py +5 -1
- nab_python/_vendor/packaging/dependency_groups.py +29 -1
- nab_python/_vendor/packaging/direct_url.py +19 -4
- nab_python/_vendor/packaging/licenses/__init__.py +18 -6
- nab_python/_vendor/packaging/markers.py +32 -11
- nab_python/_vendor/packaging/metadata.py +3 -2
- nab_python/_vendor/packaging/pylock.py +6 -1
- nab_python/_vendor/packaging/ranges.py +707 -1513
- nab_python/_vendor/packaging/requirements.py +60 -20
- nab_python/_vendor/packaging/specifiers.py +290 -374
- nab_python/_vendor/packaging/tags.py +106 -43
- nab_python/_vendor/packaging/utils.py +33 -5
- nab_python/build_backend.py +57 -29
- nab_python/config.py +1234 -390
- nab_python/config_sources.py +1831 -0
- nab_python/download.py +85 -17
- nab_python/fetch.py +152 -72
- nab_python/lockfile.py +91 -6
- nab_python/metadata.py +19 -6
- nab_python/provider.py +534 -94
- nab_python/requirements_file.py +405 -28
- nab_python/resolve.py +181 -57
- nab_python/universal/matrix.py +0 -2
- nab_python/universal/provider.py +20 -15
- nab_python/universal/reresolve.py +40 -21
- nab_python/universal/resolve.py +88 -55
- nab_python/universal/validate.py +114 -30
- nab_python/universal/wheel_selection.py +67 -19
- nab_python/workspace.py +8 -1
- {nab_python-0.0.5.dist-info → nab_python-0.0.7.dist-info}/METADATA +3 -3
- nab_python-0.0.7.dist-info/RECORD +75 -0
- nab_python/_vendor/packaging/_range_utils.py +0 -998
- nab_python/_vendor/packaging/_version_utils.py +0 -90
- nab_python-0.0.5.dist-info/RECORD +0 -74
- {nab_python-0.0.5.dist-info → nab_python-0.0.7.dist-info}/WHEEL +0 -0
nab_python/_build/env.py
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* ``venv.EnvBuilder`` (stdlib) creates an empty interpreter at a temp
|
|
7
7
|
path, ``with_pip=False``; nab does not need pip in there.
|
|
8
8
|
* nab's own resolver picks versions for ``[build-system].requires``
|
|
9
|
-
using the same indexes / ``
|
|
9
|
+
using the same indexes / ``uploaded-prior-to`` window as the outer
|
|
10
10
|
resolve.
|
|
11
11
|
* ``download_lock`` from :mod:`nab_python.download` fetches the
|
|
12
12
|
resolved wheels into a temp directory.
|
|
@@ -37,6 +37,7 @@ from installer.destinations import SchemeDictionaryDestination
|
|
|
37
37
|
from installer.sources import WheelFile
|
|
38
38
|
from nab_index.urllib3_async_transport import Urllib3AsyncTransport
|
|
39
39
|
|
|
40
|
+
from .._vcs_admission import UnsupportedVcsError
|
|
40
41
|
from ..config import NabProjectConfig
|
|
41
42
|
from ..download import download_lock
|
|
42
43
|
|
|
@@ -97,7 +98,7 @@ class NabBuildEnv:
|
|
|
97
98
|
|
|
98
99
|
``requires`` is the PEP 508 string list from
|
|
99
100
|
``[build-system].requires``. ``config`` carries the indexes,
|
|
100
|
-
``
|
|
101
|
+
``uploaded-prior-to`` window and other nab inputs from the outer
|
|
101
102
|
resolve; it is pruned (no local sources, no workspace, no
|
|
102
103
|
marker overlay) before the inner resolve so the build env is
|
|
103
104
|
computed against PyPI alone.
|
|
@@ -269,20 +270,27 @@ class NabBuildEnv:
|
|
|
269
270
|
|
|
270
271
|
inner_config = NabProjectConfig(
|
|
271
272
|
indexes=self._config.indexes,
|
|
273
|
+
package_overrides=self._config.package_overrides,
|
|
272
274
|
index_overrides=self._config.index_overrides,
|
|
273
275
|
uploaded_prior_to=self._config.uploaded_prior_to,
|
|
274
|
-
uploaded_prior_to_overrides=self._config.uploaded_prior_to_overrides,
|
|
275
276
|
)
|
|
276
277
|
# download_lock closes its transport, and ``install`` may call
|
|
277
278
|
# this again for ``get_requires_for_build_wheel`` follow-ups;
|
|
278
279
|
# build a fresh transport each time.
|
|
279
280
|
transport = self._transport_factory()
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
try:
|
|
282
|
+
result = resolve_pyproject(
|
|
283
|
+
synthetic,
|
|
284
|
+
transport,
|
|
285
|
+
config=inner_config,
|
|
286
|
+
python_version=self._python_version,
|
|
287
|
+
)
|
|
288
|
+
except (UnsupportedVcsError, NotImplementedError) as exc:
|
|
289
|
+
# A direct-URL/VCS build requirement means nab cannot build this
|
|
290
|
+
# sdist. Report it as a build-env failure so the outer resolve
|
|
291
|
+
# skips the version instead of aborting on the raw error.
|
|
292
|
+
msg = f"build env resolve failed: {exc}"
|
|
293
|
+
raise BuildEnvError(msg) from exc
|
|
286
294
|
|
|
287
295
|
# Reject sdist-only pins early: build deps that ship only an
|
|
288
296
|
# sdist trigger a recursive backend invocation that this
|
nab_python/_build/runner.py
CHANGED
|
@@ -33,12 +33,12 @@ import build
|
|
|
33
33
|
import pyproject_hooks
|
|
34
34
|
import tomli
|
|
35
35
|
|
|
36
|
-
from
|
|
36
|
+
from nab_resolver.resolver import ResolutionError
|
|
37
|
+
|
|
38
|
+
from .._vendor.packaging.requirements import InvalidRequirement, Requirement
|
|
37
39
|
from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
38
40
|
from .._vendor.packaging.utils import canonicalize_name
|
|
39
41
|
from .._vendor.packaging.version import InvalidVersion, Version
|
|
40
|
-
from nab_resolver.resolver import ResolutionError
|
|
41
|
-
|
|
42
42
|
from ..metadata import WheelMetadata
|
|
43
43
|
from .env import BuildEnvError, NabBuildEnv
|
|
44
44
|
from .errors import BuildBackendError
|
|
@@ -238,21 +238,25 @@ def _parse_metadata(metadata_path: Path) -> WheelMetadata:
|
|
|
238
238
|
SpecifierSet(requires_python_raw) if requires_python_raw else None
|
|
239
239
|
)
|
|
240
240
|
except InvalidSpecifier as exc:
|
|
241
|
-
msg =
|
|
241
|
+
msg = (
|
|
242
|
+
f"backend METADATA has invalid Requires-Python "
|
|
243
|
+
f"{requires_python_raw!r}: {exc}"
|
|
244
|
+
)
|
|
242
245
|
raise BuildBackendError(msg) from exc
|
|
243
246
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
247
|
+
try:
|
|
248
|
+
requires_dist: list[Requirement] = [
|
|
249
|
+
Requirement(raw) for raw in msg_obj.get_all("Requires-Dist") or ()
|
|
250
|
+
]
|
|
251
|
+
except InvalidRequirement as exc:
|
|
252
|
+
msg = f"backend METADATA has an invalid Requires-Dist: {exc}"
|
|
253
|
+
raise BuildBackendError(msg) from exc
|
|
250
254
|
|
|
251
255
|
provides_extra: list[str] = sorted(
|
|
252
256
|
{
|
|
253
|
-
canonicalize_name(
|
|
257
|
+
canonicalize_name(stripped)
|
|
254
258
|
for extra in msg_obj.get_all("Provides-Extra") or ()
|
|
255
|
-
if extra
|
|
259
|
+
if (stripped := extra.strip())
|
|
256
260
|
}
|
|
257
261
|
)
|
|
258
262
|
|
nab_python/_conflict_kind.py
CHANGED
|
@@ -8,6 +8,14 @@ cycle. :class:`nab_python.config.ConflictKind` takes its enum values from
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import re
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
|
|
17
|
+
from ._vendor.packaging.markers import Marker
|
|
18
|
+
|
|
11
19
|
KIND_EXTRA = "extra"
|
|
12
20
|
KIND_GROUP = "group"
|
|
13
21
|
|
|
@@ -18,3 +26,37 @@ MARKER_VARIABLE_FOR_KIND = {
|
|
|
18
26
|
KIND_EXTRA: "extras",
|
|
19
27
|
KIND_GROUP: "dependency_groups",
|
|
20
28
|
}
|
|
29
|
+
|
|
30
|
+
# ``extras`` and ``dependency_groups`` are PEP 685 / PEP 735 set variables that
|
|
31
|
+
# packaging only defines when consuming a lockfile. At resolve time no
|
|
32
|
+
# conflict-fork member is active as a marker-set member (forks fold their
|
|
33
|
+
# members into requirements, not the environment), so both are empty. Seeding
|
|
34
|
+
# them keeps a dependency marker that tests one from raising a raw KeyError at
|
|
35
|
+
# evaluation; the membership simply tests False and the dep is dropped.
|
|
36
|
+
EMPTY_MEMBERSHIP_SETS: dict[str, frozenset[str]] = {
|
|
37
|
+
variable: frozenset() for variable in MARKER_VARIABLE_FOR_KIND.values()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_MEMBERSHIP_SET_PATTERN = re.compile(
|
|
41
|
+
r"\b(" + "|".join(MARKER_VARIABLE_FOR_KIND.values()) + r")\b"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def membership_set_in_marker(marker_text: str) -> str | None:
|
|
46
|
+
"""Return the lockfile-only set variable a marker tests, or ``None``.
|
|
47
|
+
|
|
48
|
+
A dependency marker that tests ``extras`` or ``dependency_groups`` is a
|
|
49
|
+
mistake (usually meant as ``extra ==``): those variables are defined only
|
|
50
|
+
when consuming a lockfile, so they are empty at resolve time.
|
|
51
|
+
"""
|
|
52
|
+
match = _MEMBERSHIP_SET_PATTERN.search(marker_text)
|
|
53
|
+
return match.group(1) if match else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def dependency_marker_holds(marker: Marker, environment: Mapping[str, str]) -> bool:
|
|
57
|
+
"""Evaluate a dependency marker for a resolve-time ``environment``.
|
|
58
|
+
|
|
59
|
+
Seeds the empty lockfile-only set variables so a marker that tests one
|
|
60
|
+
evaluates cleanly to False instead of raising a raw KeyError.
|
|
61
|
+
"""
|
|
62
|
+
return bool(marker.evaluate({**environment, **EMPTY_MEMBERSHIP_SETS}))
|
nab_python/_lockfile/builder.py
CHANGED
|
@@ -13,7 +13,7 @@ from collections import defaultdict
|
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import TYPE_CHECKING, Protocol, overload
|
|
16
|
-
from urllib.parse import urlsplit, urlunsplit
|
|
16
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
17
17
|
|
|
18
18
|
import tomli
|
|
19
19
|
|
|
@@ -21,6 +21,7 @@ from nab_index.client import SdistFile, WheelFile
|
|
|
21
21
|
|
|
22
22
|
from .._toml import tool_nab_section
|
|
23
23
|
from .._vendor.packaging.pylock import Pylock, PylockValidationError
|
|
24
|
+
from .._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
24
25
|
from .._vendor.packaging.utils import canonicalize_name
|
|
25
26
|
|
|
26
27
|
if TYPE_CHECKING:
|
|
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
|
|
|
30
31
|
|
|
31
32
|
from .._vendor.packaging.version import Version
|
|
32
33
|
from ..lockfile import (
|
|
34
|
+
ArchivePin,
|
|
33
35
|
IndexPin,
|
|
34
36
|
LockInput,
|
|
35
37
|
PinShape,
|
|
@@ -37,7 +39,7 @@ if TYPE_CHECKING:
|
|
|
37
39
|
VcsPin,
|
|
38
40
|
WheelArtifact,
|
|
39
41
|
)
|
|
40
|
-
from ..provider import DistPolicy, LocalSource, VcsSource
|
|
42
|
+
from ..provider import ArchiveSource, DistPolicy, LocalSource, VcsSource
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
__all__ = [
|
|
@@ -47,6 +49,7 @@ __all__ = [
|
|
|
47
49
|
"build_lock_input_from_provider",
|
|
48
50
|
"read_lockfile_anchor",
|
|
49
51
|
"read_lockfile_packages",
|
|
52
|
+
"require_artifact_hashes",
|
|
50
53
|
]
|
|
51
54
|
|
|
52
55
|
|
|
@@ -94,6 +97,10 @@ class LockInputProvider(Protocol):
|
|
|
94
97
|
"""Return the configured VcsSource for ``canonical_name`` or None."""
|
|
95
98
|
...
|
|
96
99
|
|
|
100
|
+
def archive_source_for(self, canonical_name: str, /) -> ArchiveSource | None:
|
|
101
|
+
"""Return the configured ArchiveSource for ``canonical_name`` or None."""
|
|
102
|
+
...
|
|
103
|
+
|
|
97
104
|
def vcs_pin_for(self, canonical_name: str, /) -> str | None:
|
|
98
105
|
"""Return the resolved 40-char SHA captured during materialisation."""
|
|
99
106
|
...
|
|
@@ -104,8 +111,16 @@ class LockInputProvider(Protocol):
|
|
|
104
111
|
"""Return the listing slice that matches ``(canonical_name, version)``."""
|
|
105
112
|
...
|
|
106
113
|
|
|
107
|
-
def effective_dist_policy(
|
|
108
|
-
|
|
114
|
+
def effective_dist_policy(
|
|
115
|
+
self, canonical_name: str, version: Version, index_name: str | None = None, /
|
|
116
|
+
) -> DistPolicy:
|
|
117
|
+
"""Return the effective :class:`DistPolicy` for ``canonical_name==version``."""
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
def effective_requires_python(
|
|
121
|
+
self, canonical_name: str, version: Version, /
|
|
122
|
+
) -> str | None:
|
|
123
|
+
"""Return the ``requires-python`` override for ``canonical_name==version``."""
|
|
109
124
|
...
|
|
110
125
|
|
|
111
126
|
|
|
@@ -205,17 +220,23 @@ def read_lockfile_packages(path: Path) -> dict[str, Version] | None:
|
|
|
205
220
|
|
|
206
221
|
|
|
207
222
|
def _strip_userinfo(url: str) -> str:
|
|
208
|
-
"""Return ``url`` with
|
|
209
|
-
|
|
210
|
-
Lockfiles are committed to version control, so an index or VCS
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
and
|
|
214
|
-
|
|
223
|
+
"""Return ``url`` with credential userinfo removed.
|
|
224
|
+
|
|
225
|
+
Lockfiles are committed to version control, so an index or VCS URL
|
|
226
|
+
carrying an embedded ``user:password`` must not be written verbatim.
|
|
227
|
+
An SSH login such as ``git@`` is the protocol login, not a secret,
|
|
228
|
+
and is required to clone, so it is kept (only an embedded password is
|
|
229
|
+
dropped); host case and port are preserved. A no-op for URLs without
|
|
230
|
+
userinfo.
|
|
215
231
|
"""
|
|
216
232
|
parts = urlsplit(url)
|
|
217
|
-
|
|
218
|
-
|
|
233
|
+
userinfo, sep, host = parts.netloc.rpartition("@")
|
|
234
|
+
if not sep:
|
|
235
|
+
return url
|
|
236
|
+
if parts.scheme.endswith("ssh"):
|
|
237
|
+
login = userinfo.split(":", 1)[0]
|
|
238
|
+
host = f"{login}@{host}"
|
|
239
|
+
return urlunsplit(parts._replace(netloc=host))
|
|
219
240
|
|
|
220
241
|
|
|
221
242
|
def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a distinct lockfile field
|
|
@@ -272,6 +293,12 @@ def build_lock_input_from_provider( # noqa: PLR0913 - each flag maps to a disti
|
|
|
272
293
|
resolved_sha=provider.vcs_pin_for(canonical),
|
|
273
294
|
)
|
|
274
295
|
continue
|
|
296
|
+
archive_source = provider.archive_source_for(canonical)
|
|
297
|
+
if archive_source is not None:
|
|
298
|
+
lock_pins[canonical] = _archive_pin_from_source(
|
|
299
|
+
canonical, version, archive_source
|
|
300
|
+
)
|
|
301
|
+
continue
|
|
275
302
|
lock_pins[canonical] = _index_pin_from_listing(
|
|
276
303
|
provider, canonical, version, indexes
|
|
277
304
|
)
|
|
@@ -324,6 +351,9 @@ def _forward_dependency_graph(
|
|
|
324
351
|
for dep in extra_map.get(extra, {})
|
|
325
352
|
)
|
|
326
353
|
dep_names &= pinned
|
|
354
|
+
# An umbrella extra (pkg[all] pulling pkg[graphviz]) can name its own
|
|
355
|
+
# package; drop it so pkg is never an edge to itself.
|
|
356
|
+
dep_names.discard(canonical)
|
|
327
357
|
if dep_names:
|
|
328
358
|
graph[canonical] = tuple(sorted(dep_names))
|
|
329
359
|
return graph
|
|
@@ -348,13 +378,23 @@ def _index_pin_from_listing(
|
|
|
348
378
|
package's wheels stayed in ``versions_cache`` as a possible
|
|
349
379
|
metadata source for the resolver; only the sdist is emitted
|
|
350
380
|
into the lock so installers download and build that archive.
|
|
381
|
+
|
|
382
|
+
A ``requires-python`` metadata override takes precedence over the
|
|
383
|
+
Simple-API value so the pin records the specifier the resolver
|
|
384
|
+
actually admitted against; a conforming :pep:`751` installer would
|
|
385
|
+
otherwise reject a widened pin whose lock still carried the narrow
|
|
386
|
+
artefact value.
|
|
351
387
|
"""
|
|
352
388
|
from ..fetch import DEFAULT_INDEX_URL
|
|
353
389
|
from ..lockfile import IndexPin, SdistArtifact, WheelArtifact
|
|
354
390
|
from ..provider import DistPolicy
|
|
355
391
|
|
|
356
392
|
files = list(provider.dist_files_for(canonical, version))
|
|
357
|
-
|
|
393
|
+
serving = provider.coordinator.index.get_listing_index(canonical)
|
|
394
|
+
if (
|
|
395
|
+
provider.effective_dist_policy(canonical, version, serving)
|
|
396
|
+
is DistPolicy.SDIST_INSTALL
|
|
397
|
+
):
|
|
358
398
|
files = [f for f in files if not isinstance(f, WheelFile)]
|
|
359
399
|
if not any(isinstance(f, SdistFile) for f in files):
|
|
360
400
|
msg = (
|
|
@@ -365,17 +405,18 @@ def _index_pin_from_listing(
|
|
|
365
405
|
raise MissingSdistError(msg)
|
|
366
406
|
|
|
367
407
|
wheels = tuple(
|
|
368
|
-
_build_artifact(
|
|
369
|
-
for f in files
|
|
370
|
-
if isinstance(f, WheelFile)
|
|
408
|
+
_build_artifact(f, WheelArtifact) for f in files if isinstance(f, WheelFile)
|
|
371
409
|
)
|
|
372
410
|
sdist_file = next((f for f in files if isinstance(f, SdistFile)), None)
|
|
373
411
|
sdist = (
|
|
374
|
-
_build_artifact(
|
|
375
|
-
|
|
376
|
-
|
|
412
|
+
_build_artifact(sdist_file, SdistArtifact) if sdist_file is not None else None
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
override_rp = provider.effective_requires_python(canonical, version)
|
|
416
|
+
requires_python = (
|
|
417
|
+
override_rp if override_rp is not None else _common_requires_python(files)
|
|
377
418
|
)
|
|
378
|
-
|
|
419
|
+
|
|
379
420
|
serving_name = provider.coordinator.index.get_listing_index(canonical)
|
|
380
421
|
by_name = {ix.name: ix.url for ix in indexes}
|
|
381
422
|
index_url = by_name.get(serving_name) if serving_name is not None else None
|
|
@@ -402,25 +443,22 @@ def _index_pin_from_listing(
|
|
|
402
443
|
|
|
403
444
|
@overload
|
|
404
445
|
def _build_artifact(
|
|
405
|
-
canonical: str,
|
|
406
446
|
source: WheelFile | SdistFile,
|
|
407
447
|
cls: type[WheelArtifact],
|
|
408
448
|
) -> WheelArtifact: ...
|
|
409
449
|
@overload
|
|
410
450
|
def _build_artifact(
|
|
411
|
-
canonical: str,
|
|
412
451
|
source: WheelFile | SdistFile,
|
|
413
452
|
cls: type[SdistArtifact],
|
|
414
453
|
) -> SdistArtifact: ...
|
|
415
454
|
def _build_artifact(
|
|
416
|
-
canonical: str,
|
|
417
455
|
source: WheelFile | SdistFile,
|
|
418
456
|
cls: type[WheelArtifact | SdistArtifact],
|
|
419
457
|
) -> WheelArtifact | SdistArtifact:
|
|
420
|
-
hashes = _filter_acceptable_hashes(
|
|
458
|
+
hashes = _filter_acceptable_hashes(source.hashes)
|
|
421
459
|
return cls(
|
|
422
460
|
filename=source.filename,
|
|
423
|
-
url=source.url,
|
|
461
|
+
url=_strip_userinfo(source.url),
|
|
424
462
|
hashes=hashes,
|
|
425
463
|
size=source.size,
|
|
426
464
|
upload_time=_parse_upload_time(source.upload_time),
|
|
@@ -449,38 +487,74 @@ def _parse_upload_time(raw: str | None) -> datetime | None:
|
|
|
449
487
|
|
|
450
488
|
|
|
451
489
|
def _filter_acceptable_hashes(
|
|
452
|
-
|
|
490
|
+
hashes: tuple[tuple[str, str], ...],
|
|
453
491
|
) -> tuple[tuple[str, str], ...]:
|
|
454
492
|
"""Return the subset of ``hashes`` whose algorithm is consumable.
|
|
455
493
|
|
|
456
494
|
Pip's hash-checking mode and PEP 751 both accept any of sha256,
|
|
457
495
|
sha384, or sha512; nab forwards every recorded entry so consumers
|
|
458
|
-
can pick.
|
|
459
|
-
|
|
496
|
+
can pick. Unacceptable algorithms (e.g. md5) are dropped, so the
|
|
497
|
+
result may be empty.
|
|
460
498
|
"""
|
|
461
499
|
from ..lockfile import ACCEPTED_HASH_ALGORITHMS
|
|
462
500
|
|
|
463
|
-
|
|
501
|
+
return tuple(
|
|
464
502
|
(algo, digest)
|
|
465
503
|
for algo, digest in sorted(hashes)
|
|
466
504
|
if algo in ACCEPTED_HASH_ALGORITHMS
|
|
467
505
|
)
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def require_artifact_hashes(lock_input: LockInput) -> None:
|
|
509
|
+
"""Raise :class:`MissingHashError` if a pinned artefact has no hash.
|
|
510
|
+
|
|
511
|
+
PEP 751 and pip's hash-checking mode each need at least one of
|
|
512
|
+
sha256/sha384/sha512 per artefact. The plain ``name==version``
|
|
513
|
+
writer records no hash and does not call this.
|
|
514
|
+
"""
|
|
515
|
+
from ..lockfile import ACCEPTED_HASH_ALGORITHMS, IndexPin
|
|
516
|
+
|
|
517
|
+
pin_groups: Iterable[Mapping[str, PinShape]] = (
|
|
518
|
+
lock_input.pins,
|
|
519
|
+
*lock_input.per_tuple_pins.values(),
|
|
520
|
+
)
|
|
521
|
+
for pins in pin_groups:
|
|
522
|
+
for pin in pins.values():
|
|
523
|
+
if not isinstance(pin, IndexPin):
|
|
524
|
+
continue
|
|
525
|
+
artefacts = (*pin.wheels, *((pin.sdist,) if pin.sdist is not None else ()))
|
|
526
|
+
for artefact in artefacts:
|
|
527
|
+
if not artefact.hashes:
|
|
528
|
+
msg = (
|
|
529
|
+
f"{pin.name}: artefact {artefact.filename!r} has no "
|
|
530
|
+
f"acceptable hash (need one of "
|
|
531
|
+
f"{list(ACCEPTED_HASH_ALGORITHMS)!r})"
|
|
532
|
+
)
|
|
533
|
+
raise MissingHashError(msg)
|
|
476
534
|
|
|
477
535
|
|
|
478
536
|
def _common_requires_python(files: Iterable[WheelFile | SdistFile]) -> str | None:
|
|
479
|
-
"""Return
|
|
537
|
+
"""Return the package-level Requires-Python value, or ``None``.
|
|
538
|
+
|
|
539
|
+
An artefact with no Requires-Python is unconstrained, so a single
|
|
540
|
+
such artefact leaves the whole package unconstrained. Otherwise the
|
|
541
|
+
value survives only when every artefact agrees.
|
|
542
|
+
|
|
543
|
+
A malformed value counts as unconstrained too, matching
|
|
544
|
+
``excluded_by_python``, which admits a dist whose Requires-Python is
|
|
545
|
+
an unparseable specifier on any Python. So the lock writer always
|
|
546
|
+
parses a valid specifier or ``None``, and the pin is never
|
|
547
|
+
over-constrained by an artefact whose floor nab could not read.
|
|
548
|
+
"""
|
|
480
549
|
seen: set[str] = set()
|
|
481
550
|
for f in files:
|
|
482
|
-
if f.requires_python is
|
|
483
|
-
|
|
551
|
+
if f.requires_python is None:
|
|
552
|
+
return None
|
|
553
|
+
try:
|
|
554
|
+
SpecifierSet(f.requires_python)
|
|
555
|
+
except InvalidSpecifier:
|
|
556
|
+
return None
|
|
557
|
+
seen.add(f.requires_python)
|
|
484
558
|
if len(seen) == 1:
|
|
485
559
|
return next(iter(seen))
|
|
486
560
|
return None
|
|
@@ -538,7 +612,7 @@ def _vcs_pin_from_source(
|
|
|
538
612
|
bare_repo_url = _strip_userinfo(parsed.repo_url)
|
|
539
613
|
repo_url = f"{parsed.scheme}+{bare_repo_url}@{resolved_sha}"
|
|
540
614
|
if parsed.subdirectory:
|
|
541
|
-
repo_url += f"#subdirectory={parsed.subdirectory}"
|
|
615
|
+
repo_url += f"#subdirectory={quote(parsed.subdirectory, safe='/')}"
|
|
542
616
|
|
|
543
617
|
return VcsPin(
|
|
544
618
|
name=canonical,
|
|
@@ -550,3 +624,30 @@ def _vcs_pin_from_source(
|
|
|
550
624
|
requested_revision=requested_revision,
|
|
551
625
|
vcs_type=parsed.scheme,
|
|
552
626
|
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _archive_pin_from_source(
|
|
630
|
+
canonical: str,
|
|
631
|
+
version: Version,
|
|
632
|
+
source: ArchiveSource,
|
|
633
|
+
) -> ArchivePin:
|
|
634
|
+
"""Build an :class:`ArchivePin` from an :class:`ArchiveSource`.
|
|
635
|
+
|
|
636
|
+
The URL, hashes, and subdirectory come from the source declaration,
|
|
637
|
+
which config parse validated (a hash is required), so the pin records
|
|
638
|
+
the exact archive the resolve used. The URL is stripped of any
|
|
639
|
+
credential userinfo, like every other pin, so a committed lockfile
|
|
640
|
+
never carries a token.
|
|
641
|
+
"""
|
|
642
|
+
from nab_index.archive import ArchiveRequest
|
|
643
|
+
|
|
644
|
+
from ..lockfile import ArchivePin
|
|
645
|
+
|
|
646
|
+
request = ArchiveRequest.parse(source.url)
|
|
647
|
+
return ArchivePin(
|
|
648
|
+
name=canonical,
|
|
649
|
+
version=str(version),
|
|
650
|
+
url=_strip_userinfo(request.url),
|
|
651
|
+
hashes=request.hashes,
|
|
652
|
+
subdirectory=request.subdirectory or None,
|
|
653
|
+
)
|
|
@@ -99,8 +99,8 @@ def validate_marker_disjointness(
|
|
|
99
99
|
|
|
100
100
|
``declared_groups`` carries every conflict set regardless of policy
|
|
101
101
|
so the hint can distinguish an undeclared collision (suggest adding
|
|
102
|
-
a declaration) from one already declared under ``
|
|
103
|
-
(suggest tightening to ``
|
|
102
|
+
a declaration) from one already declared under ``at-least-one``
|
|
103
|
+
(suggest tightening to ``at-most-one`` or ``exactly-one``).
|
|
104
104
|
"""
|
|
105
105
|
if not environments:
|
|
106
106
|
return
|
|
@@ -265,7 +265,7 @@ def _conflict_hint(
|
|
|
265
265
|
because a conflict declaration would not help.
|
|
266
266
|
|
|
267
267
|
When ``declared_groups`` already covers the active members, the
|
|
268
|
-
hint instead suggests tightening the policy: an ``
|
|
268
|
+
hint instead suggests tightening the policy: an ``at-least-one``
|
|
269
269
|
declaration permits co-selection, so the validator still raises and
|
|
270
270
|
the user has to switch to an exclusive policy to prune the point.
|
|
271
271
|
"""
|
|
@@ -288,8 +288,8 @@ def _conflict_hint(
|
|
|
288
288
|
if already_declared:
|
|
289
289
|
return (
|
|
290
290
|
". These members are declared in [tool.nab].conflicts under a"
|
|
291
|
-
" policy that permits co-selection; switch to
|
|
292
|
-
"
|
|
291
|
+
" policy that permits co-selection; switch to at-most-one or"
|
|
292
|
+
" exactly-one to prune the colliding context"
|
|
293
293
|
)
|
|
294
294
|
return (
|
|
295
295
|
". If these are intentionally mutually exclusive, declare them in"
|
nab_python/_lockfile/pylock.py
CHANGED
|
@@ -21,6 +21,7 @@ import tomli_w
|
|
|
21
21
|
from .._vendor.packaging.markers import Marker
|
|
22
22
|
from .._vendor.packaging.pylock import (
|
|
23
23
|
Package,
|
|
24
|
+
PackageArchive,
|
|
24
25
|
PackageDirectory,
|
|
25
26
|
PackageSdist,
|
|
26
27
|
PackageVcs,
|
|
@@ -31,6 +32,7 @@ from .._vendor.packaging.specifiers import SpecifierSet
|
|
|
31
32
|
from .._vendor.packaging.utils import canonicalize_name
|
|
32
33
|
from .._vendor.packaging.version import Version
|
|
33
34
|
from ..config import conflict_exclusion_groups, conflict_member_groups
|
|
35
|
+
from .builder import require_artifact_hashes
|
|
34
36
|
from .disjointness import validate_marker_disjointness
|
|
35
37
|
|
|
36
38
|
if TYPE_CHECKING:
|
|
@@ -82,14 +84,26 @@ def write_lock(
|
|
|
82
84
|
With no ``output_path`` the current directory is the base.
|
|
83
85
|
"""
|
|
84
86
|
lock_dir = Path(output_path).parent if output_path is not None else None
|
|
85
|
-
|
|
86
|
-
pylock.validate()
|
|
87
|
-
text = tomli_w.dumps(dict(pylock.to_dict()))
|
|
87
|
+
text = render_lock(lock_input, lock_dir=lock_dir)
|
|
88
88
|
if output_path is not None:
|
|
89
89
|
Path(output_path).write_text(text, encoding="utf-8")
|
|
90
90
|
return text
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def render_lock(lock_input: LockInput, *, lock_dir: Path | None = None) -> str:
|
|
94
|
+
"""Serialise ``lock_input`` to PEP 751 TOML text without writing a file.
|
|
95
|
+
|
|
96
|
+
``lock_dir`` is the directory the lock will live in; directory, wheel
|
|
97
|
+
and sdist paths are emitted relative to it so the lock stays portable.
|
|
98
|
+
Defaults to the current directory. Used by ``write_lock`` and by
|
|
99
|
+
``nab lock --locked`` to render the would-be lock for comparison.
|
|
100
|
+
"""
|
|
101
|
+
require_artifact_hashes(lock_input)
|
|
102
|
+
pylock = build_pylock(lock_input, lock_dir=lock_dir)
|
|
103
|
+
pylock.validate()
|
|
104
|
+
return tomli_w.dumps(dict(pylock.to_dict()))
|
|
105
|
+
|
|
106
|
+
|
|
93
107
|
def build_pylock(lock_input: LockInput, *, lock_dir: Path | None = None) -> Pylock:
|
|
94
108
|
"""Build a :class:`Pylock` from the input shape.
|
|
95
109
|
|
|
@@ -201,7 +215,7 @@ def _pin_to_package(
|
|
|
201
215
|
lock_dir: Path,
|
|
202
216
|
dependencies: list[dict[str, str]] | None = None,
|
|
203
217
|
) -> Package:
|
|
204
|
-
from ..lockfile import IndexPin, LocalPin, VcsPin
|
|
218
|
+
from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
|
|
205
219
|
|
|
206
220
|
if isinstance(pin, IndexPin):
|
|
207
221
|
return Package(
|
|
@@ -254,6 +268,20 @@ def _pin_to_package(
|
|
|
254
268
|
requested_revision=pin.requested_revision,
|
|
255
269
|
),
|
|
256
270
|
)
|
|
271
|
+
if isinstance(pin, ArchivePin):
|
|
272
|
+
# An archive is content-pinned by its hash, so the version is
|
|
273
|
+
# stable and recorded (unlike the directory/VCS cases above).
|
|
274
|
+
return Package(
|
|
275
|
+
name=canonicalize_name(pin.name),
|
|
276
|
+
version=Version(pin.version),
|
|
277
|
+
marker=marker,
|
|
278
|
+
dependencies=dependencies,
|
|
279
|
+
archive=PackageArchive(
|
|
280
|
+
url=pin.url,
|
|
281
|
+
hashes=dict(pin.hashes),
|
|
282
|
+
subdirectory=pin.subdirectory,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
257
285
|
msg = f"unknown pin shape: {pin!r}"
|
|
258
286
|
raise TypeError(msg)
|
|
259
287
|
|
|
@@ -410,7 +438,7 @@ def _group_pins_by_pin(
|
|
|
410
438
|
|
|
411
439
|
def _pin_discriminator(pin: PinShape) -> tuple:
|
|
412
440
|
"""Return a hashable key that identifies the source + version of ``pin``."""
|
|
413
|
-
from ..lockfile import IndexPin, LocalPin, VcsPin
|
|
441
|
+
from ..lockfile import ArchivePin, IndexPin, LocalPin, VcsPin
|
|
414
442
|
|
|
415
443
|
if isinstance(pin, IndexPin):
|
|
416
444
|
return ("index", pin.version, pin.index)
|
|
@@ -434,6 +462,8 @@ def _pin_discriminator(pin: PinShape) -> tuple:
|
|
|
434
462
|
pin.repo_url,
|
|
435
463
|
pin.subdirectory or "",
|
|
436
464
|
)
|
|
465
|
+
if isinstance(pin, ArchivePin):
|
|
466
|
+
return ("archive", pin.version, pin.url, pin.hashes, pin.subdirectory or "")
|
|
437
467
|
msg = f"unknown pin shape: {pin!r}"
|
|
438
468
|
raise TypeError(msg)
|
|
439
469
|
|
|
@@ -443,8 +473,9 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
|
|
|
443
473
|
|
|
444
474
|
For :class:`IndexPin`, accumulates every distinct wheel filename
|
|
445
475
|
across the contributing tuples and keeps the first non-``None``
|
|
446
|
-
sdist. ``requires_python`` survives only when every tuple
|
|
447
|
-
|
|
476
|
+
sdist. ``requires_python`` survives only when every tuple carried
|
|
477
|
+
the same value and none was unconstrained, matching
|
|
478
|
+
:func:`_common_requires_python`'s rule.
|
|
448
479
|
Non-IndexPin shapes are already fully discriminated, so the first
|
|
449
480
|
pin is returned unchanged.
|
|
450
481
|
"""
|
|
@@ -456,16 +487,21 @@ def _merge_pins_in_group(pins: list[PinShape]) -> PinShape:
|
|
|
456
487
|
seen_wheels: dict[str, WheelArtifact] = {}
|
|
457
488
|
sdist = head.sdist
|
|
458
489
|
requires_python_set: set[str] = set()
|
|
490
|
+
any_unconstrained = False
|
|
459
491
|
for pin in pins:
|
|
460
492
|
assert isinstance(pin, IndexPin)
|
|
461
493
|
for wheel in pin.wheels:
|
|
462
494
|
seen_wheels.setdefault(wheel.filename, wheel)
|
|
463
495
|
if sdist is None and pin.sdist is not None:
|
|
464
496
|
sdist = pin.sdist
|
|
465
|
-
if pin.requires_python is
|
|
497
|
+
if pin.requires_python is None:
|
|
498
|
+
any_unconstrained = True
|
|
499
|
+
else:
|
|
466
500
|
requires_python_set.add(pin.requires_python)
|
|
467
501
|
requires_python = (
|
|
468
|
-
next(iter(requires_python_set))
|
|
502
|
+
next(iter(requires_python_set))
|
|
503
|
+
if not any_unconstrained and len(requires_python_set) == 1
|
|
504
|
+
else None
|
|
469
505
|
)
|
|
470
506
|
return IndexPin(
|
|
471
507
|
name=head.name,
|
|
@@ -539,7 +575,7 @@ def _build_marker(
|
|
|
539
575
|
``tuple_markers`` means the caller has not declared a tuple
|
|
540
576
|
universe and the marker is omitted.
|
|
541
577
|
|
|
542
|
-
A dep required by every member of an ``
|
|
578
|
+
A dep required by every member of an ``at-most-one`` set but not
|
|
543
579
|
by the base is absent from ``env_base_names``, so it keeps the
|
|
544
580
|
membership OR and does not install when no member is selected.
|
|
545
581
|
An environment with no base-name set (no conflict fork ran) leaves
|