nab-python 0.0.1__py3-none-any.whl

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