nab-python 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nab_python/__init__.py +1 -0
- nab_python/_build/__init__.py +1 -0
- nab_python/_build/env.py +364 -0
- nab_python/_build/errors.py +17 -0
- nab_python/_build/runner.py +254 -0
- nab_python/_lockfile/__init__.py +1 -0
- nab_python/_lockfile/builder.py +339 -0
- nab_python/_lockfile/disjointness.py +207 -0
- nab_python/_lockfile/pylock.py +323 -0
- nab_python/_lockfile/requirements.py +121 -0
- nab_python/_packaging_provider.py +98 -0
- nab_python/_provider/__init__.py +1 -0
- nab_python/_provider/build_remote.py +95 -0
- nab_python/_provider/extras.py +231 -0
- nab_python/_provider/listing.py +442 -0
- nab_python/_provider/lookahead.py +156 -0
- nab_python/_provider/metadata_resolver.py +450 -0
- nab_python/_provider/priority.py +174 -0
- nab_python/_provider/sources.py +215 -0
- nab_python/_testing/__init__.py +1 -0
- nab_python/_testing/coordinator_fake.py +240 -0
- nab_python/_vcs_admission.py +209 -0
- nab_python/_vendor/__init__.py +6 -0
- nab_python/_vendor/packaging/LICENSE +3 -0
- nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
- nab_python/_vendor/packaging/LICENSE.BSD +23 -0
- nab_python/_vendor/packaging/PROVENANCE.md +73 -0
- nab_python/_vendor/packaging/__init__.py +15 -0
- nab_python/_vendor/packaging/_elffile.py +108 -0
- nab_python/_vendor/packaging/_manylinux.py +265 -0
- nab_python/_vendor/packaging/_musllinux.py +88 -0
- nab_python/_vendor/packaging/_parser.py +394 -0
- nab_python/_vendor/packaging/_structures.py +33 -0
- nab_python/_vendor/packaging/_tokenizer.py +196 -0
- nab_python/_vendor/packaging/dependency_groups.py +302 -0
- nab_python/_vendor/packaging/direct_url.py +325 -0
- nab_python/_vendor/packaging/errors.py +94 -0
- nab_python/_vendor/packaging/licenses/__init__.py +186 -0
- nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
- nab_python/_vendor/packaging/markers.py +506 -0
- nab_python/_vendor/packaging/metadata.py +964 -0
- nab_python/_vendor/packaging/py.typed +0 -0
- nab_python/_vendor/packaging/pylock.py +910 -0
- nab_python/_vendor/packaging/ranges.py +1803 -0
- nab_python/_vendor/packaging/requirements.py +132 -0
- nab_python/_vendor/packaging/specifiers.py +1141 -0
- nab_python/_vendor/packaging/tags.py +929 -0
- nab_python/_vendor/packaging/utils.py +296 -0
- nab_python/_vendor/packaging/version.py +1230 -0
- nab_python/build_backend.py +184 -0
- nab_python/config.py +805 -0
- nab_python/download.py +170 -0
- nab_python/fetch.py +827 -0
- nab_python/lockfile.py +238 -0
- nab_python/metadata.py +145 -0
- nab_python/provider.py +1235 -0
- nab_python/py.typed +0 -0
- nab_python/requirements_file.py +180 -0
- nab_python/resolve.py +497 -0
- nab_python/universal/__init__.py +1 -0
- nab_python/universal/matrix.py +235 -0
- nab_python/universal/provider.py +214 -0
- nab_python/universal/reresolve.py +310 -0
- nab_python/universal/resolve.py +508 -0
- nab_python/universal/validate.py +439 -0
- nab_python/universal/wheel_selection.py +327 -0
- nab_python/workspace.py +214 -0
- nab_python-0.0.1.dist-info/METADATA +49 -0
- nab_python-0.0.1.dist-info/RECORD +71 -0
- nab_python-0.0.1.dist-info/WHEEL +4 -0
- nab_python-0.0.1.dist-info/licenses/LICENSE +21 -0
nab_python/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Read dependencies and dependency groups from pyproject.toml files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import tomli
|
|
8
|
+
|
|
9
|
+
from ._vendor.packaging.dependency_groups import resolve_dependency_groups
|
|
10
|
+
from ._vendor.packaging.requirements import Requirement
|
|
11
|
+
from ._vendor.packaging.utils import canonicalize_name
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"expand_self_extras",
|
|
19
|
+
"read_pyproject_dependencies",
|
|
20
|
+
"read_pyproject_groups",
|
|
21
|
+
"read_pyproject_name",
|
|
22
|
+
"read_pyproject_optional_dependencies",
|
|
23
|
+
"resolve_groups_to_requirements",
|
|
24
|
+
"select_optional_dependencies",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_pyproject_dependencies(path: Path) -> list[Requirement]:
|
|
29
|
+
"""Read [project].dependencies from a pyproject.toml file.
|
|
30
|
+
|
|
31
|
+
Returns a list of Requirement objects parsed from the dependency
|
|
32
|
+
strings. Raises FileNotFoundError if the file doesn't exist, or
|
|
33
|
+
KeyError if [project] or [project].dependencies is missing.
|
|
34
|
+
"""
|
|
35
|
+
with path.open("rb") as f:
|
|
36
|
+
data = tomli.load(f)
|
|
37
|
+
|
|
38
|
+
dep_strings: list[str] = data["project"]["dependencies"]
|
|
39
|
+
return [Requirement(s) for s in dep_strings]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_pyproject_name(path: Path) -> str | None:
|
|
43
|
+
"""Read [project].name from a pyproject.toml file.
|
|
44
|
+
|
|
45
|
+
Returns the project name as a string, or ``None`` when the file
|
|
46
|
+
has no ``[project]`` table or no ``name`` key (a workspace-root
|
|
47
|
+
pyproject without its own distribution).
|
|
48
|
+
"""
|
|
49
|
+
with path.open("rb") as f:
|
|
50
|
+
data = tomli.load(f)
|
|
51
|
+
name = data.get("project", {}).get("name")
|
|
52
|
+
return name if isinstance(name, str) else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_pyproject_optional_dependencies(
|
|
56
|
+
path: Path,
|
|
57
|
+
) -> Mapping[str, Sequence[str]]:
|
|
58
|
+
"""Read [project.optional-dependencies] from a pyproject.toml file.
|
|
59
|
+
|
|
60
|
+
Returns the raw mapping of extra name to requirement strings.
|
|
61
|
+
Returns an empty dict when ``[project.optional-dependencies]``
|
|
62
|
+
is absent.
|
|
63
|
+
"""
|
|
64
|
+
with path.open("rb") as f:
|
|
65
|
+
data = tomli.load(f)
|
|
66
|
+
raw = data.get("project", {}).get("optional-dependencies", {})
|
|
67
|
+
if not isinstance(raw, dict):
|
|
68
|
+
msg = (
|
|
69
|
+
f"[project.optional-dependencies] must be a table, got {type(raw).__name__}"
|
|
70
|
+
)
|
|
71
|
+
raise TypeError(msg)
|
|
72
|
+
return raw
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def select_optional_dependencies(
|
|
76
|
+
optional_deps: Mapping[str, Sequence[str]],
|
|
77
|
+
selected: Sequence[str],
|
|
78
|
+
) -> list[Requirement]:
|
|
79
|
+
"""Return the union of requirement strings for ``selected`` extras.
|
|
80
|
+
|
|
81
|
+
Unknown extra names raise ``LookupError``. Returns an empty
|
|
82
|
+
list when ``selected`` is empty.
|
|
83
|
+
"""
|
|
84
|
+
if not selected:
|
|
85
|
+
return []
|
|
86
|
+
out: list[Requirement] = []
|
|
87
|
+
for name in selected:
|
|
88
|
+
if name not in optional_deps:
|
|
89
|
+
msg = (
|
|
90
|
+
f"extra {name!r} is not declared in"
|
|
91
|
+
f" [project.optional-dependencies]; defined: {sorted(optional_deps)!r}"
|
|
92
|
+
)
|
|
93
|
+
raise LookupError(msg)
|
|
94
|
+
out.extend(Requirement(req_str) for req_str in optional_deps[name])
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def expand_self_extras(
|
|
99
|
+
optional_deps: Mapping[str, Sequence[str]],
|
|
100
|
+
project_name: str | None,
|
|
101
|
+
selected: Sequence[str],
|
|
102
|
+
) -> list[str]:
|
|
103
|
+
"""Return ``selected`` plus every extra reachable through self-references.
|
|
104
|
+
|
|
105
|
+
When an extra's contents include a requirement of the form
|
|
106
|
+
``{project_name}[a, b]`` (the project depending on itself with
|
|
107
|
+
other extras activated), the referenced extras are walked
|
|
108
|
+
transitively. Without this, an ``[all] = ["{name}[graphviz, otel,
|
|
109
|
+
...]"]`` self-reference leaves the actual third-party deps
|
|
110
|
+
(graphviz, opentelemetry-api, etc.) out of the resolver's root
|
|
111
|
+
requirements and look-ahead loses the ability to predict
|
|
112
|
+
candidates.
|
|
113
|
+
|
|
114
|
+
The original ``selected`` order is preserved at the front of the
|
|
115
|
+
result; reachable extras are appended in BFS order without
|
|
116
|
+
duplicates. ``project_name`` ``None`` short-circuits to the
|
|
117
|
+
input list (no project name = nothing to self-reference).
|
|
118
|
+
Unknown extras are tolerated here; the caller is expected to
|
|
119
|
+
feed the result into :func:`select_optional_dependencies`, which
|
|
120
|
+
raises if an extra is not declared.
|
|
121
|
+
"""
|
|
122
|
+
if project_name is None:
|
|
123
|
+
return list(selected)
|
|
124
|
+
canonical_project = canonicalize_name(project_name)
|
|
125
|
+
out: list[str] = []
|
|
126
|
+
seen: set[str] = set()
|
|
127
|
+
worklist: list[str] = list(selected)
|
|
128
|
+
while worklist:
|
|
129
|
+
extra = worklist.pop(0)
|
|
130
|
+
if extra in seen:
|
|
131
|
+
continue
|
|
132
|
+
seen.add(extra)
|
|
133
|
+
out.append(extra)
|
|
134
|
+
for req_str in optional_deps.get(extra, ()):
|
|
135
|
+
try:
|
|
136
|
+
req = Requirement(req_str)
|
|
137
|
+
except (ValueError, TypeError):
|
|
138
|
+
continue
|
|
139
|
+
if canonicalize_name(req.name) != canonical_project:
|
|
140
|
+
continue
|
|
141
|
+
worklist.extend(sub for sub in req.extras if sub not in seen)
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_pyproject_groups(
|
|
146
|
+
path: Path,
|
|
147
|
+
) -> Mapping[str, Sequence[str | Mapping[str, str]]]:
|
|
148
|
+
"""Read [dependency-groups] from a pyproject.toml file (PEP 735).
|
|
149
|
+
|
|
150
|
+
Returns the raw group table: a mapping of group name to a list
|
|
151
|
+
of requirement strings or include records
|
|
152
|
+
(``{"include-group": "other-group"}``). Returns an empty dict
|
|
153
|
+
when the table is absent so callers can pass the result to
|
|
154
|
+
:func:`resolve_groups_to_requirements` unconditionally.
|
|
155
|
+
"""
|
|
156
|
+
with path.open("rb") as f:
|
|
157
|
+
data = tomli.load(f)
|
|
158
|
+
raw = data.get("dependency-groups", {})
|
|
159
|
+
if not isinstance(raw, dict):
|
|
160
|
+
msg = f"[dependency-groups] must be a table, got {type(raw).__name__}"
|
|
161
|
+
raise TypeError(msg)
|
|
162
|
+
return raw
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_groups_to_requirements(
|
|
166
|
+
groups: Mapping[str, Sequence[str | Mapping[str, str]]],
|
|
167
|
+
selected: Sequence[str],
|
|
168
|
+
) -> list[Requirement]:
|
|
169
|
+
"""Resolve PEP 735 group includes and return the union of requirements.
|
|
170
|
+
|
|
171
|
+
``selected`` names the groups whose requirements should be
|
|
172
|
+
expanded. Unknown group names surface as :class:`LookupError`
|
|
173
|
+
from the vendored resolver. Cyclic or malformed groups raise
|
|
174
|
+
the matching packaging error. Returns an empty list when
|
|
175
|
+
``selected`` is empty.
|
|
176
|
+
"""
|
|
177
|
+
if not selected:
|
|
178
|
+
return []
|
|
179
|
+
resolved = resolve_dependency_groups(groups, *selected)
|
|
180
|
+
return [Requirement(s) for s in resolved]
|
nab_python/resolve.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Orchestrate dependency resolution from a pyproject.toml file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from nab_resolver.resolver import (
|
|
11
|
+
Incompatibility,
|
|
12
|
+
IncompatibilityCause,
|
|
13
|
+
ResolutionError,
|
|
14
|
+
Resolver,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from ._vcs_admission import admit_vcs_url
|
|
18
|
+
from ._vendor.packaging.markers import default_environment
|
|
19
|
+
from ._vendor.packaging.ranges import VersionRange
|
|
20
|
+
from ._vendor.packaging.requirements import Requirement
|
|
21
|
+
from ._vendor.packaging.specifiers import SpecifierSet
|
|
22
|
+
from ._vendor.packaging.utils import canonicalize_name
|
|
23
|
+
from ._vendor.packaging.version import InvalidVersion, Version
|
|
24
|
+
from .config import NabProjectConfig, ResolveMode, read_pyproject_config
|
|
25
|
+
from .fetch import FetchCoordinator
|
|
26
|
+
from .lockfile import LockInput, build_lock_input_from_provider
|
|
27
|
+
from .provider import (
|
|
28
|
+
Provider,
|
|
29
|
+
ResolutionStrategy,
|
|
30
|
+
join_extra,
|
|
31
|
+
split_extra,
|
|
32
|
+
)
|
|
33
|
+
from .requirements_file import (
|
|
34
|
+
expand_self_extras,
|
|
35
|
+
read_pyproject_dependencies,
|
|
36
|
+
read_pyproject_groups,
|
|
37
|
+
read_pyproject_name,
|
|
38
|
+
read_pyproject_optional_dependencies,
|
|
39
|
+
resolve_groups_to_requirements,
|
|
40
|
+
select_optional_dependencies,
|
|
41
|
+
)
|
|
42
|
+
from .universal.matrix import Matrix
|
|
43
|
+
from .universal.resolve import UniversalResult, resolve_universal
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Mapping, Sequence
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
from nab_index.transport import AsyncHttpTransport
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ResolutionResult",
|
|
54
|
+
"UnsupportedModeError",
|
|
55
|
+
"resolve_pyproject",
|
|
56
|
+
"resolve_universal_pyproject",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class ResolutionResult:
|
|
65
|
+
"""A finished single-environment resolution and its lock input.
|
|
66
|
+
|
|
67
|
+
``pins`` is the canonical-name -> :class:`Version` mapping.
|
|
68
|
+
``lock_input`` carries everything needed to write a PEP 751
|
|
69
|
+
``pylock.toml`` or a hashed ``requirements.txt`` and to download
|
|
70
|
+
the chosen artefacts.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
pins: dict[str, Version]
|
|
74
|
+
lock_input: LockInput
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class UnsupportedModeError(NotImplementedError):
|
|
78
|
+
"""Resolve mode requested is not handled by this entry point."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_pyproject( # noqa: PLR0913 - the surface mirrors the CLI; bundling into a config object would hide it
|
|
82
|
+
path: Path,
|
|
83
|
+
transport: AsyncHttpTransport,
|
|
84
|
+
*,
|
|
85
|
+
config: NabProjectConfig | None = None,
|
|
86
|
+
cache_dir: Path | None = None,
|
|
87
|
+
offline: bool = False,
|
|
88
|
+
python_version: str | None = None,
|
|
89
|
+
groups: Sequence[str] = (),
|
|
90
|
+
extras: Sequence[str] = (),
|
|
91
|
+
resolution_strategy: ResolutionStrategy | None = None,
|
|
92
|
+
) -> ResolutionResult:
|
|
93
|
+
"""Resolve a project's dependencies for a single environment.
|
|
94
|
+
|
|
95
|
+
``config`` defaults to :func:`read_pyproject_config(path)`. The
|
|
96
|
+
caller supplies ``transport`` so the HTTP library choice stays
|
|
97
|
+
outside nab-python. ``cache_dir``, ``offline`` and
|
|
98
|
+
``python_version`` are runtime overrides from the CLI.
|
|
99
|
+
|
|
100
|
+
``groups`` and ``extras`` name PEP 735 groups and
|
|
101
|
+
``[project.optional-dependencies]`` keys to fold in.
|
|
102
|
+
``resolution_strategy`` overrides ``config.resolution`` when set.
|
|
103
|
+
|
|
104
|
+
Use :func:`resolve_universal_pyproject` when
|
|
105
|
+
``config.mode is ResolveMode.UNIVERSAL``. Returns a
|
|
106
|
+
:class:`ResolutionResult` with ``pins`` and ``lock_input``.
|
|
107
|
+
"""
|
|
108
|
+
if config is None:
|
|
109
|
+
config = read_pyproject_config(path)
|
|
110
|
+
|
|
111
|
+
if config.mode is not ResolveMode.SPECIFIC:
|
|
112
|
+
msg = (
|
|
113
|
+
f"resolve_pyproject only handles ResolveMode.SPECIFIC; got"
|
|
114
|
+
f" {config.mode.value}. Use resolve_universal_pyproject for"
|
|
115
|
+
" mode = 'universal'."
|
|
116
|
+
)
|
|
117
|
+
raise UnsupportedModeError(msg)
|
|
118
|
+
|
|
119
|
+
if python_version is not None:
|
|
120
|
+
effective_python = python_version
|
|
121
|
+
elif config.requires_python is not None:
|
|
122
|
+
effective_python = _resolve_target_python(config.requires_python)
|
|
123
|
+
else:
|
|
124
|
+
vi = sys.version_info
|
|
125
|
+
effective_python = f"{vi.major}.{vi.minor}.{vi.micro}"
|
|
126
|
+
|
|
127
|
+
effective_strategy = (
|
|
128
|
+
resolution_strategy if resolution_strategy is not None else config.resolution
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
requirements = read_pyproject_dependencies(path)
|
|
132
|
+
requirements.extend(_load_group_requirements(path, groups))
|
|
133
|
+
requirements.extend(_load_extra_requirements(path, extras))
|
|
134
|
+
marker_environment = _build_marker_environment(
|
|
135
|
+
python_version=effective_python,
|
|
136
|
+
overrides=config.marker_environment,
|
|
137
|
+
)
|
|
138
|
+
resolver_requirements, root_extras = _build_resolver_inputs(
|
|
139
|
+
requirements, config, environment=marker_environment
|
|
140
|
+
)
|
|
141
|
+
resolver_constraints = _build_constraints(config)
|
|
142
|
+
direct_packages = frozenset(
|
|
143
|
+
name for name in resolver_requirements if split_extra(name)[1] is None
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
with FetchCoordinator(
|
|
147
|
+
transport,
|
|
148
|
+
indexes=list(config.indexes),
|
|
149
|
+
cache_dir=cache_dir,
|
|
150
|
+
offline=offline,
|
|
151
|
+
index_overrides=list(config.index_overrides),
|
|
152
|
+
marker_environment=dict(config.marker_environment) or None,
|
|
153
|
+
) as coordinator:
|
|
154
|
+
provider = Provider(
|
|
155
|
+
coordinator,
|
|
156
|
+
python_version=effective_python,
|
|
157
|
+
root_requirements=resolver_requirements,
|
|
158
|
+
uploaded_prior_to=config.uploaded_prior_to,
|
|
159
|
+
uploaded_prior_to_overrides=config.uploaded_prior_to_overrides or None,
|
|
160
|
+
root_extras=root_extras,
|
|
161
|
+
dist_policy=config.dist_policy,
|
|
162
|
+
dist_policy_overrides=config.dist_policy_overrides or None,
|
|
163
|
+
build_policy=config.build_policy,
|
|
164
|
+
build_policy_overrides=dict(config.build_policy_overrides) or None,
|
|
165
|
+
vcs_config=config.vcs,
|
|
166
|
+
marker_environment=dict(config.marker_environment) or None,
|
|
167
|
+
local_sources=list(config.local_sources) or None,
|
|
168
|
+
vcs_sources=list(config.vcs_sources) or None,
|
|
169
|
+
vcs_cache_dir=cache_dir / "vcs" if cache_dir is not None else None,
|
|
170
|
+
build_config=config,
|
|
171
|
+
resolution_strategy=effective_strategy,
|
|
172
|
+
direct_packages=direct_packages,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
resolver: Resolver[str, Version] = Resolver(
|
|
176
|
+
provider, range_type=VersionRange, root_version="0"
|
|
177
|
+
)
|
|
178
|
+
try:
|
|
179
|
+
raw = resolver.resolve(
|
|
180
|
+
resolver_requirements, constraints=resolver_constraints
|
|
181
|
+
)
|
|
182
|
+
except ResolutionError as exc:
|
|
183
|
+
_augment_resolution_error(exc, provider)
|
|
184
|
+
raise
|
|
185
|
+
pins = {k: v for k, v in raw.items() if split_extra(k)[1] is None}
|
|
186
|
+
lock_input = build_lock_input_from_provider(
|
|
187
|
+
provider,
|
|
188
|
+
pins,
|
|
189
|
+
requires_python=config.requires_python,
|
|
190
|
+
extras=tuple(extras),
|
|
191
|
+
dependency_groups=tuple(groups),
|
|
192
|
+
default_groups=tuple(groups),
|
|
193
|
+
indexes=config.indexes,
|
|
194
|
+
)
|
|
195
|
+
return ResolutionResult(pins=pins, lock_input=lock_input)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _load_group_requirements(path: Path, selected: Sequence[str]) -> list[Requirement]:
|
|
199
|
+
"""Read [dependency-groups] from ``path`` and expand ``selected``."""
|
|
200
|
+
if not selected:
|
|
201
|
+
return []
|
|
202
|
+
groups = read_pyproject_groups(path)
|
|
203
|
+
if not groups:
|
|
204
|
+
msg = (
|
|
205
|
+
"groups requested but [dependency-groups] is missing from"
|
|
206
|
+
f" {path}: {sorted(selected)!r}"
|
|
207
|
+
)
|
|
208
|
+
raise LookupError(msg)
|
|
209
|
+
return resolve_groups_to_requirements(groups, selected)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _load_extra_requirements(path: Path, selected: Sequence[str]) -> list[Requirement]:
|
|
213
|
+
"""Read [project.optional-dependencies] from ``path`` and expand ``selected``.
|
|
214
|
+
|
|
215
|
+
Self-references (``{project_name}[a, b]`` inside an extra's
|
|
216
|
+
contents) are expanded transitively so that the third-party deps
|
|
217
|
+
they ultimately reach reach the resolver as root requirements.
|
|
218
|
+
See :func:`expand_self_extras` for the rationale.
|
|
219
|
+
"""
|
|
220
|
+
if not selected:
|
|
221
|
+
return []
|
|
222
|
+
optional = read_pyproject_optional_dependencies(path)
|
|
223
|
+
if not optional:
|
|
224
|
+
msg = (
|
|
225
|
+
"extras requested but [project.optional-dependencies] is"
|
|
226
|
+
f" missing from {path}: {sorted(selected)!r}"
|
|
227
|
+
)
|
|
228
|
+
raise LookupError(msg)
|
|
229
|
+
project_name = read_pyproject_name(path)
|
|
230
|
+
expanded = expand_self_extras(optional, project_name, selected)
|
|
231
|
+
return select_optional_dependencies(optional, expanded)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _augment_resolution_error(exc: ResolutionError, provider: Provider) -> None:
|
|
235
|
+
"""Append per-package NO_VERSIONS diagnostics to ``exc`` in-place.
|
|
236
|
+
|
|
237
|
+
Walks the derivation tree carried on the exception, collects
|
|
238
|
+
every package that appears in a NO_VERSIONS clause, and looks up
|
|
239
|
+
the provider-side reason for each. When at least one reason is
|
|
240
|
+
available, rewrites the exception's args so that ``str(exc)``
|
|
241
|
+
surfaces the diagnostics alongside the original derivation tree.
|
|
242
|
+
"""
|
|
243
|
+
if exc.incompatibility is None:
|
|
244
|
+
return
|
|
245
|
+
packages: list[str] = []
|
|
246
|
+
seen: set[str] = set()
|
|
247
|
+
for package in _walk_no_versions_packages(exc.incompatibility):
|
|
248
|
+
if package in seen:
|
|
249
|
+
continue
|
|
250
|
+
seen.add(package)
|
|
251
|
+
packages.append(package)
|
|
252
|
+
hints: list[str] = []
|
|
253
|
+
for package in packages:
|
|
254
|
+
reason = provider.get_no_versions_reason(package)
|
|
255
|
+
if reason is not None:
|
|
256
|
+
hints.append(f"{package}: {reason}")
|
|
257
|
+
if not hints:
|
|
258
|
+
return
|
|
259
|
+
base = str(exc)
|
|
260
|
+
augmented = base + "\n\nDiagnostics:\n - " + "\n - ".join(hints)
|
|
261
|
+
exc.args = (augmented,)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _walk_no_versions_packages(
|
|
265
|
+
incompatibility: Incompatibility[Any, Any],
|
|
266
|
+
) -> list[str]:
|
|
267
|
+
"""Return package names from every NO_VERSIONS clause in the tree."""
|
|
268
|
+
out: list[str] = []
|
|
269
|
+
seen_ids: set[int] = set()
|
|
270
|
+
|
|
271
|
+
def visit(node: Incompatibility[Any, Any]) -> None:
|
|
272
|
+
if id(node) in seen_ids:
|
|
273
|
+
return
|
|
274
|
+
seen_ids.add(id(node))
|
|
275
|
+
if node.cause is IncompatibilityCause.NO_VERSIONS:
|
|
276
|
+
for term in node.terms:
|
|
277
|
+
pkg = term.package
|
|
278
|
+
if isinstance(pkg, str):
|
|
279
|
+
out.append(pkg)
|
|
280
|
+
if node.cause_left is not None:
|
|
281
|
+
visit(node.cause_left)
|
|
282
|
+
if node.cause_right is not None:
|
|
283
|
+
visit(node.cause_right)
|
|
284
|
+
|
|
285
|
+
visit(incompatibility)
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _build_resolver_inputs(
|
|
290
|
+
requirements: list[Requirement],
|
|
291
|
+
config: NabProjectConfig,
|
|
292
|
+
*,
|
|
293
|
+
environment: dict[str, str],
|
|
294
|
+
) -> tuple[dict[str, VersionRange], set[tuple[str, str]]]:
|
|
295
|
+
"""Convert PEP 508 ``Requirement`` objects to the resolver's input shape.
|
|
296
|
+
|
|
297
|
+
Requirements whose PEP 508 marker evaluates to ``False`` under
|
|
298
|
+
``environment`` are skipped, matching pip/uv's root-requirement
|
|
299
|
+
handling.
|
|
300
|
+
"""
|
|
301
|
+
resolver_requirements: dict[str, VersionRange] = {}
|
|
302
|
+
root_extras: set[tuple[str, str]] = set()
|
|
303
|
+
for req in requirements:
|
|
304
|
+
if req.marker is not None and not req.marker.evaluate(environment):
|
|
305
|
+
continue
|
|
306
|
+
if req.url is not None:
|
|
307
|
+
admit_vcs_url(req.url, config.vcs)
|
|
308
|
+
msg = (
|
|
309
|
+
f"VCS requirement admitted by policy but resolver path is not"
|
|
310
|
+
f" implemented: {req.name} @ {req.url}"
|
|
311
|
+
)
|
|
312
|
+
raise NotImplementedError(msg)
|
|
313
|
+
name = str(canonicalize_name(req.name))
|
|
314
|
+
resolver_requirements[name] = req.specifier.to_range()
|
|
315
|
+
for extra in req.extras:
|
|
316
|
+
extra_key = join_extra(name, extra)
|
|
317
|
+
resolver_requirements[extra_key] = VersionRange.full()
|
|
318
|
+
_, normalized_extra = split_extra(extra_key)
|
|
319
|
+
assert normalized_extra is not None # join_extra always sets one
|
|
320
|
+
root_extras.add((name, normalized_extra))
|
|
321
|
+
return resolver_requirements, root_extras
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
_PYTHON_VERSION_PARTS = 2
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _build_marker_environment(
|
|
328
|
+
*,
|
|
329
|
+
python_version: str,
|
|
330
|
+
overrides: Mapping[str, str],
|
|
331
|
+
) -> dict[str, str]:
|
|
332
|
+
"""Return the merged PEP 508 environment used for root-marker evaluation.
|
|
333
|
+
|
|
334
|
+
Mirrors :class:`Provider`: defaults from
|
|
335
|
+
:func:`default_environment`, then ``python_version`` /
|
|
336
|
+
``python_full_version`` rewritten from the effective Python, then
|
|
337
|
+
user overrides.
|
|
338
|
+
"""
|
|
339
|
+
env: dict[str, str] = {
|
|
340
|
+
key: value
|
|
341
|
+
for key, value in default_environment().items()
|
|
342
|
+
if isinstance(value, str)
|
|
343
|
+
}
|
|
344
|
+
try:
|
|
345
|
+
release = Version(python_version).release
|
|
346
|
+
except InvalidVersion:
|
|
347
|
+
pass
|
|
348
|
+
else:
|
|
349
|
+
env["python_version"] = (
|
|
350
|
+
f"{release[0]}.{release[1]}"
|
|
351
|
+
if len(release) >= _PYTHON_VERSION_PARTS
|
|
352
|
+
else python_version
|
|
353
|
+
)
|
|
354
|
+
env["python_full_version"] = python_version
|
|
355
|
+
env.update(overrides)
|
|
356
|
+
return env
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def resolve_universal_pyproject(
|
|
360
|
+
path: Path,
|
|
361
|
+
*,
|
|
362
|
+
config: NabProjectConfig | None = None,
|
|
363
|
+
cache_dir: Path | None = None,
|
|
364
|
+
transport: AsyncHttpTransport | None = None,
|
|
365
|
+
offline: bool = False,
|
|
366
|
+
groups: Sequence[str] = (),
|
|
367
|
+
extras: Sequence[str] = (),
|
|
368
|
+
resolution_strategy: ResolutionStrategy | None = None,
|
|
369
|
+
) -> UniversalResult:
|
|
370
|
+
"""Run a universal resolve for the project at ``path``.
|
|
371
|
+
|
|
372
|
+
Reads ``[project].dependencies`` as the requirement list and the
|
|
373
|
+
matrix declaration from ``[tool.nab.matrix]``. Requires
|
|
374
|
+
``config.mode == ResolveMode.UNIVERSAL``.
|
|
375
|
+
|
|
376
|
+
``groups`` names PEP 735 dependency groups; ``extras`` names
|
|
377
|
+
entries from ``[project.optional-dependencies]``. Both are
|
|
378
|
+
folded into every per-tuple resolve. The CLI passes the
|
|
379
|
+
selections through to ``merge_universal_lock_inputs`` so the
|
|
380
|
+
lockfile records what produced the pin set.
|
|
381
|
+
"""
|
|
382
|
+
if config is None:
|
|
383
|
+
config = read_pyproject_config(path)
|
|
384
|
+
if config.mode is not ResolveMode.UNIVERSAL:
|
|
385
|
+
msg = (
|
|
386
|
+
f"resolve_universal_pyproject requires mode = 'universal'; got"
|
|
387
|
+
f" {config.mode.value}. Set [tool.nab].mode = 'universal'."
|
|
388
|
+
)
|
|
389
|
+
raise UnsupportedModeError(msg)
|
|
390
|
+
if config.matrix is None: # pragma: no cover - guarded at config parse
|
|
391
|
+
msg = "mode = 'universal' requires a [tool.nab.matrix] table"
|
|
392
|
+
raise UnsupportedModeError(msg)
|
|
393
|
+
|
|
394
|
+
requirements = read_pyproject_dependencies(path)
|
|
395
|
+
requirements.extend(_load_group_requirements(path, groups))
|
|
396
|
+
requirements.extend(_load_extra_requirements(path, extras))
|
|
397
|
+
requirement_strings = [str(r) for r in requirements]
|
|
398
|
+
matrix = Matrix(
|
|
399
|
+
python=config.matrix.python,
|
|
400
|
+
platforms=config.matrix.platforms,
|
|
401
|
+
python_order=config.matrix.python_order,
|
|
402
|
+
python_patches=(
|
|
403
|
+
dict(config.matrix.python_patches)
|
|
404
|
+
if config.matrix.python_patches is not None
|
|
405
|
+
else None
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
effective_strategy = (
|
|
409
|
+
resolution_strategy if resolution_strategy is not None else config.resolution
|
|
410
|
+
)
|
|
411
|
+
return resolve_universal(
|
|
412
|
+
matrix=matrix,
|
|
413
|
+
requirements=requirement_strings,
|
|
414
|
+
transport=transport,
|
|
415
|
+
offline=offline,
|
|
416
|
+
constraints=list(config.constraints) or None,
|
|
417
|
+
cache_dir=cache_dir,
|
|
418
|
+
uploaded_prior_to=config.uploaded_prior_to,
|
|
419
|
+
uploaded_prior_to_overrides=config.uploaded_prior_to_overrides or None,
|
|
420
|
+
dist_policy=config.dist_policy,
|
|
421
|
+
dist_policy_overrides=config.dist_policy_overrides or None,
|
|
422
|
+
build_policy=config.build_policy,
|
|
423
|
+
build_policy_overrides=dict(config.build_policy_overrides) or None,
|
|
424
|
+
vcs_config=config.vcs,
|
|
425
|
+
local_sources=list(config.local_sources) or None,
|
|
426
|
+
vcs_sources=list(config.vcs_sources) or None,
|
|
427
|
+
vcs_cache_dir=cache_dir / "vcs" if cache_dir is not None else None,
|
|
428
|
+
build_config=config,
|
|
429
|
+
indexes=list(config.indexes),
|
|
430
|
+
index_overrides=list(config.index_overrides) or None,
|
|
431
|
+
resolution_strategy=effective_strategy.value,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
_PYTHON_CANDIDATE_MAJORS = (3, 4)
|
|
436
|
+
_PYTHON_CANDIDATE_MINORS = range(30)
|
|
437
|
+
_PYTHON_CANDIDATE_PATCHES = range(30)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _resolve_target_python(specifier: str) -> str:
|
|
441
|
+
"""Pick a concrete Python version that satisfies ``specifier``.
|
|
442
|
+
|
|
443
|
+
``specifier`` is a PEP 440 specifier set (already validated at
|
|
444
|
+
config-parse time, see :func:`_parse_requires_python`). The
|
|
445
|
+
resolve target is the lowest enumerated ``M.N.P`` release that the
|
|
446
|
+
specifier admits: deterministic regardless of host, and matches
|
|
447
|
+
the user's written intent ("lock for >=3.13" -> "use 3.13.0
|
|
448
|
+
markers"; "lock for ==3.10.5" -> "use 3.10.5 markers").
|
|
449
|
+
|
|
450
|
+
Falls back to the host Python when no enumerated candidate
|
|
451
|
+
satisfies (e.g. an open-ended ``<X.Y`` specifier with no lower
|
|
452
|
+
bound, or a range that admits only versions outside the candidate
|
|
453
|
+
grid). The host fallback warns via the logger so the operator can
|
|
454
|
+
notice when their lockfile's ``requires-python`` field implies a
|
|
455
|
+
target the resolve could not actually impersonate.
|
|
456
|
+
"""
|
|
457
|
+
spec_set = SpecifierSet(specifier)
|
|
458
|
+
|
|
459
|
+
# M.N.0 first so >=3.13 resolves to 3.13.0 (not 3.13.<something>).
|
|
460
|
+
# filter() preserves input order, so the first match is the lowest.
|
|
461
|
+
candidates: list[Version] = []
|
|
462
|
+
for major in _PYTHON_CANDIDATE_MAJORS:
|
|
463
|
+
for minor in _PYTHON_CANDIDATE_MINORS:
|
|
464
|
+
candidates.append(Version(f"{major}.{minor}.0"))
|
|
465
|
+
for patch in _PYTHON_CANDIDATE_PATCHES:
|
|
466
|
+
if patch == 0:
|
|
467
|
+
continue
|
|
468
|
+
candidates.append(Version(f"{major}.{minor}.{patch}"))
|
|
469
|
+
|
|
470
|
+
matches = [str(v) for v in spec_set.filter(candidates)]
|
|
471
|
+
if matches:
|
|
472
|
+
return matches[0]
|
|
473
|
+
vi = sys.version_info
|
|
474
|
+
host = f"{vi.major}.{vi.minor}.{vi.micro}"
|
|
475
|
+
_logger.warning(
|
|
476
|
+
"requires-python = %r matches no enumerated CPython release;"
|
|
477
|
+
" falling back to host Python %s for the resolve target",
|
|
478
|
+
specifier,
|
|
479
|
+
host,
|
|
480
|
+
)
|
|
481
|
+
return host
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _build_constraints(config: NabProjectConfig) -> dict[str, VersionRange]:
|
|
485
|
+
"""Parse constraint strings from config into resolver-input ranges."""
|
|
486
|
+
out: dict[str, VersionRange] = {}
|
|
487
|
+
for cstr in config.constraints:
|
|
488
|
+
req = Requirement(cstr)
|
|
489
|
+
if req.url is not None:
|
|
490
|
+
admit_vcs_url(req.url, config.vcs)
|
|
491
|
+
msg = (
|
|
492
|
+
f"VCS constraint admitted by policy but resolver path is not"
|
|
493
|
+
f" implemented: {req.name} @ {req.url}"
|
|
494
|
+
)
|
|
495
|
+
raise NotImplementedError(msg)
|
|
496
|
+
out[canonicalize_name(req.name)] = req.specifier.to_range()
|
|
497
|
+
return out
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""User-driven universal resolution for nab."""
|