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/config.py
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
"""Read ``[tool.nab]`` from a ``pyproject.toml`` into a typed config.
|
|
2
|
+
|
|
3
|
+
The CLI is intentionally narrow: anything that defines *what* gets
|
|
4
|
+
resolved lives in ``[tool.nab]``; anything about *how this run executes*
|
|
5
|
+
lives on the CLI. This module owns the project side.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import enum
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
import tomli
|
|
18
|
+
|
|
19
|
+
from nab_index.multi_index import IndexConfig
|
|
20
|
+
|
|
21
|
+
from ._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
22
|
+
from ._vendor.packaging.utils import canonicalize_name
|
|
23
|
+
from .fetch import DEFAULT_INDEX_NAME, DEFAULT_INDEX_URL, IndexOverride
|
|
24
|
+
from .provider import (
|
|
25
|
+
BuildPolicy,
|
|
26
|
+
DistPolicy,
|
|
27
|
+
LocalSource,
|
|
28
|
+
ResolutionStrategy,
|
|
29
|
+
VcsConfig,
|
|
30
|
+
VcsPolicy,
|
|
31
|
+
VcsSource,
|
|
32
|
+
)
|
|
33
|
+
from .workspace import (
|
|
34
|
+
WorkspaceConfig,
|
|
35
|
+
auto_promote_build_policy_for_workspace,
|
|
36
|
+
discover_workspace_root,
|
|
37
|
+
merge_workspace_local_sources,
|
|
38
|
+
read_workspace_members,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Mapping
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ConfigError",
|
|
47
|
+
"MatrixConfig",
|
|
48
|
+
"NabProjectConfig",
|
|
49
|
+
"ResolveMode",
|
|
50
|
+
"read_pyproject_config",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_TOP_LEVEL_KEYS = frozenset(
|
|
55
|
+
{
|
|
56
|
+
"mode",
|
|
57
|
+
"constraints",
|
|
58
|
+
"requires-python",
|
|
59
|
+
"uploaded-prior-to",
|
|
60
|
+
"uploaded-prior-to-package",
|
|
61
|
+
"dist-policy",
|
|
62
|
+
"dist-policy-package",
|
|
63
|
+
"build-policy",
|
|
64
|
+
"build-policy-package",
|
|
65
|
+
"marker-environment",
|
|
66
|
+
"indexes",
|
|
67
|
+
"index-overrides",
|
|
68
|
+
"vcs",
|
|
69
|
+
"local-sources",
|
|
70
|
+
"vcs-sources",
|
|
71
|
+
"matrix",
|
|
72
|
+
"resolution",
|
|
73
|
+
"workspace",
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
_DURATION_PATTERN = re.compile(r"^P(\d+)D$")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ResolveMode(enum.Enum):
|
|
81
|
+
"""How the resolver interprets the project.
|
|
82
|
+
|
|
83
|
+
``SPECIFIC`` runs the single-environment resolver against one
|
|
84
|
+
marker environment (host or impersonated). ``UNIVERSAL`` runs the
|
|
85
|
+
matrix-based per-tuple resolver and is *experimental*: users
|
|
86
|
+
must opt in by setting ``[tool.nab].mode = "universal"`` and
|
|
87
|
+
declaring ``[tool.nab.matrix]``.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
SPECIFIC = "specific"
|
|
91
|
+
UNIVERSAL = "universal"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class MatrixConfig:
|
|
96
|
+
"""User-declared matrix axes for universal resolution."""
|
|
97
|
+
|
|
98
|
+
python: str
|
|
99
|
+
platforms: tuple[str, ...]
|
|
100
|
+
python_order: str = "asc"
|
|
101
|
+
python_patches: Mapping[str, str] | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class NabProjectConfig:
|
|
106
|
+
"""Everything ``[tool.nab]`` says about how to resolve this project."""
|
|
107
|
+
|
|
108
|
+
mode: ResolveMode = ResolveMode.SPECIFIC
|
|
109
|
+
constraints: tuple[str, ...] = ()
|
|
110
|
+
requires_python: str | None = None
|
|
111
|
+
uploaded_prior_to: datetime | None = None
|
|
112
|
+
uploaded_prior_to_overrides: Mapping[str, datetime | None] = field(
|
|
113
|
+
default_factory=dict
|
|
114
|
+
)
|
|
115
|
+
dist_policy: DistPolicy = DistPolicy.WHEEL_OR_SDIST
|
|
116
|
+
dist_policy_overrides: Mapping[str, DistPolicy] = field(default_factory=dict)
|
|
117
|
+
build_policy: BuildPolicy = BuildPolicy.BUILD_LOCAL
|
|
118
|
+
build_policy_overrides: Mapping[str, BuildPolicy] = field(default_factory=dict)
|
|
119
|
+
marker_environment: Mapping[str, str] = field(default_factory=dict)
|
|
120
|
+
indexes: tuple[IndexConfig, ...] = (
|
|
121
|
+
IndexConfig(DEFAULT_INDEX_NAME, DEFAULT_INDEX_URL),
|
|
122
|
+
)
|
|
123
|
+
index_overrides: tuple[IndexOverride, ...] = ()
|
|
124
|
+
vcs: VcsConfig = field(default_factory=VcsConfig)
|
|
125
|
+
local_sources: tuple[LocalSource, ...] = ()
|
|
126
|
+
vcs_sources: tuple[VcsSource, ...] = ()
|
|
127
|
+
matrix: MatrixConfig | None = None
|
|
128
|
+
resolution: ResolutionStrategy = ResolutionStrategy.HIGHEST
|
|
129
|
+
workspace: WorkspaceConfig | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ConfigError(ValueError):
|
|
133
|
+
"""Raised when ``[tool.nab]`` is structurally invalid."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_pyproject_config(
|
|
137
|
+
path: Path,
|
|
138
|
+
*,
|
|
139
|
+
discover_workspace: bool = True,
|
|
140
|
+
anchor: datetime | None = None,
|
|
141
|
+
) -> NabProjectConfig:
|
|
142
|
+
"""Parse ``[tool.nab]`` from ``path`` into :class:`NabProjectConfig`.
|
|
143
|
+
|
|
144
|
+
Returns the default ``NabProjectConfig`` when the table is absent.
|
|
145
|
+
Unknown keys at the top level are rejected so typos fail loud.
|
|
146
|
+
|
|
147
|
+
When ``discover_workspace`` is true (the default), this function
|
|
148
|
+
also walks up from ``path`` looking for a ``pyproject.toml`` whose
|
|
149
|
+
``[tool.nab.workspace]`` table is present. If found, every member
|
|
150
|
+
is materialised as an additional :class:`LocalSource` (explicit
|
|
151
|
+
``[[tool.nab.local-sources]]`` entries win on collision) and the
|
|
152
|
+
effective ``build-policy`` is floored at
|
|
153
|
+
:attr:`BuildPolicy.BUILD_LOCAL`. Pass ``discover_workspace=False``
|
|
154
|
+
to skip the walk; useful for tests or for callers that layer their
|
|
155
|
+
own workspace logic on top of a base config.
|
|
156
|
+
|
|
157
|
+
``anchor`` is the timestamp ``P<n>D`` durations resolve against.
|
|
158
|
+
Defaults to ``datetime.now(UTC)`` when not supplied, which gives
|
|
159
|
+
fresh-resolve semantics. The ``nab lock`` CLI passes the anchor
|
|
160
|
+
captured in any existing lockfile so re-locks reproduce the same
|
|
161
|
+
cutoff for relative durations.
|
|
162
|
+
"""
|
|
163
|
+
if anchor is None:
|
|
164
|
+
anchor = datetime.now(timezone.utc)
|
|
165
|
+
with path.open("rb") as f:
|
|
166
|
+
data = tomli.load(f)
|
|
167
|
+
raw = data.get("tool", {}).get("nab", {})
|
|
168
|
+
if not isinstance(raw, dict):
|
|
169
|
+
msg = f"[tool.nab] must be a table, got {type(raw).__name__}"
|
|
170
|
+
raise ConfigError(msg)
|
|
171
|
+
config = _parse_nab_table(raw, anchor=anchor, pyproject_dir=path.parent.resolve())
|
|
172
|
+
if discover_workspace:
|
|
173
|
+
config = _apply_workspace_discovery(path, config)
|
|
174
|
+
return config
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_logger = logging.getLogger(__name__)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _apply_workspace_discovery(
|
|
181
|
+
path: Path, config: NabProjectConfig
|
|
182
|
+
) -> NabProjectConfig:
|
|
183
|
+
root_pyproject = discover_workspace_root(path)
|
|
184
|
+
if root_pyproject is None:
|
|
185
|
+
return config
|
|
186
|
+
discovered = read_workspace_members(root_pyproject)
|
|
187
|
+
if not discovered:
|
|
188
|
+
return config
|
|
189
|
+
merged = merge_workspace_local_sources(config.local_sources, discovered)
|
|
190
|
+
promoted_policy = auto_promote_build_policy_for_workspace(config.build_policy)
|
|
191
|
+
if promoted_policy is not config.build_policy:
|
|
192
|
+
_logger.info(
|
|
193
|
+
"workspace discovery promoted build-policy from %r to %r"
|
|
194
|
+
" (workspace root: %s)",
|
|
195
|
+
config.build_policy.value,
|
|
196
|
+
promoted_policy.value,
|
|
197
|
+
root_pyproject,
|
|
198
|
+
)
|
|
199
|
+
return replace(
|
|
200
|
+
config,
|
|
201
|
+
local_sources=merged,
|
|
202
|
+
build_policy=promoted_policy,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_nab_table(
|
|
207
|
+
raw: dict[str, Any], *, anchor: datetime, pyproject_dir: Path
|
|
208
|
+
) -> NabProjectConfig:
|
|
209
|
+
unknown = sorted(set(raw) - _TOP_LEVEL_KEYS)
|
|
210
|
+
if unknown:
|
|
211
|
+
msg = (
|
|
212
|
+
f"unknown [tool.nab] keys: {unknown!r}; expected one of"
|
|
213
|
+
f" {sorted(_TOP_LEVEL_KEYS)!r}"
|
|
214
|
+
)
|
|
215
|
+
raise ConfigError(msg)
|
|
216
|
+
|
|
217
|
+
mode = _parse_mode(raw.get("mode"))
|
|
218
|
+
matrix = _parse_matrix(raw.get("matrix"))
|
|
219
|
+
if mode is ResolveMode.UNIVERSAL and matrix is None:
|
|
220
|
+
msg = (
|
|
221
|
+
"mode = 'universal' requires a [tool.nab.matrix] table"
|
|
222
|
+
" declaring python and platforms"
|
|
223
|
+
)
|
|
224
|
+
raise ConfigError(msg)
|
|
225
|
+
if mode is ResolveMode.SPECIFIC and matrix is not None:
|
|
226
|
+
msg = (
|
|
227
|
+
"[tool.nab.matrix] is set but mode is 'specific'; set"
|
|
228
|
+
" mode = 'universal' to opt in to the experimental"
|
|
229
|
+
" matrix-based resolver"
|
|
230
|
+
)
|
|
231
|
+
raise ConfigError(msg)
|
|
232
|
+
|
|
233
|
+
return NabProjectConfig(
|
|
234
|
+
mode=mode,
|
|
235
|
+
constraints=_parse_string_list("constraints", raw.get("constraints", [])),
|
|
236
|
+
requires_python=_parse_requires_python(raw.get("requires-python")),
|
|
237
|
+
uploaded_prior_to=_parse_uploaded_prior_to(
|
|
238
|
+
raw.get("uploaded-prior-to"), anchor=anchor
|
|
239
|
+
),
|
|
240
|
+
uploaded_prior_to_overrides=_parse_uploaded_prior_to_package(
|
|
241
|
+
raw.get("uploaded-prior-to-package"), anchor=anchor
|
|
242
|
+
),
|
|
243
|
+
dist_policy=_parse_enum(
|
|
244
|
+
"dist-policy", raw.get("dist-policy"), DistPolicy, DistPolicy.WHEEL_OR_SDIST
|
|
245
|
+
),
|
|
246
|
+
dist_policy_overrides=_parse_dist_policy_package(
|
|
247
|
+
raw.get("dist-policy-package")
|
|
248
|
+
),
|
|
249
|
+
build_policy=_parse_enum(
|
|
250
|
+
"build-policy",
|
|
251
|
+
raw.get("build-policy"),
|
|
252
|
+
BuildPolicy,
|
|
253
|
+
BuildPolicy.BUILD_LOCAL,
|
|
254
|
+
),
|
|
255
|
+
build_policy_overrides=_parse_build_policy_package(
|
|
256
|
+
raw.get("build-policy-package")
|
|
257
|
+
),
|
|
258
|
+
marker_environment=_parse_marker_environment(raw.get("marker-environment", {})),
|
|
259
|
+
indexes=_parse_indexes(raw.get("indexes")),
|
|
260
|
+
index_overrides=_parse_index_overrides(raw.get("index-overrides", [])),
|
|
261
|
+
vcs=_parse_vcs(raw.get("vcs", {})),
|
|
262
|
+
local_sources=_parse_local_sources(
|
|
263
|
+
raw.get("local-sources", []), pyproject_dir=pyproject_dir
|
|
264
|
+
),
|
|
265
|
+
vcs_sources=_parse_vcs_sources(raw.get("vcs-sources", [])),
|
|
266
|
+
matrix=matrix,
|
|
267
|
+
resolution=_parse_enum(
|
|
268
|
+
"resolution",
|
|
269
|
+
raw.get("resolution"),
|
|
270
|
+
ResolutionStrategy,
|
|
271
|
+
ResolutionStrategy.HIGHEST,
|
|
272
|
+
),
|
|
273
|
+
workspace=_parse_workspace(raw.get("workspace")),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _parse_mode(value: object) -> ResolveMode:
|
|
278
|
+
if value is None:
|
|
279
|
+
return ResolveMode.SPECIFIC
|
|
280
|
+
if not isinstance(value, str):
|
|
281
|
+
msg = f"mode must be a string, got {type(value).__name__}"
|
|
282
|
+
raise ConfigError(msg)
|
|
283
|
+
try:
|
|
284
|
+
return ResolveMode(value)
|
|
285
|
+
except ValueError as exc:
|
|
286
|
+
valid = sorted(m.value for m in ResolveMode)
|
|
287
|
+
msg = f"mode must be one of {valid!r}, got {value!r}"
|
|
288
|
+
raise ConfigError(msg) from exc
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_string_list(key: str, value: object) -> tuple[str, ...]:
|
|
292
|
+
if not isinstance(value, list):
|
|
293
|
+
msg = f"{key} must be a list of strings, got {type(value).__name__}"
|
|
294
|
+
raise ConfigError(msg)
|
|
295
|
+
out: list[str] = []
|
|
296
|
+
for i, item in enumerate(value):
|
|
297
|
+
if not isinstance(item, str):
|
|
298
|
+
msg = f"{key}[{i}] must be a string, got {type(item).__name__}"
|
|
299
|
+
raise ConfigError(msg)
|
|
300
|
+
out.append(item)
|
|
301
|
+
return tuple(out)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _parse_optional_string(key: str, value: object) -> str | None:
|
|
305
|
+
if value is None:
|
|
306
|
+
return None
|
|
307
|
+
if not isinstance(value, str):
|
|
308
|
+
msg = f"{key} must be a string, got {type(value).__name__}"
|
|
309
|
+
raise ConfigError(msg)
|
|
310
|
+
return value
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _parse_requires_python(value: object) -> str | None:
|
|
314
|
+
"""Parse ``[tool.nab].requires-python`` as a PEP 440 specifier.
|
|
315
|
+
|
|
316
|
+
Stored as the raw specifier string so the lockfile writer can pass
|
|
317
|
+
it straight to :class:`SpecifierSet`. The resolve path later
|
|
318
|
+
derives a concrete Python version target from the same specifier.
|
|
319
|
+
Raises :class:`ConfigError` for invalid specifiers and for
|
|
320
|
+
well-meaning bare versions like ``"3.13"``; those are not valid
|
|
321
|
+
specifiers and must be written ``"==3.13"`` or ``">=3.13,<3.14"``.
|
|
322
|
+
"""
|
|
323
|
+
raw = _parse_optional_string("requires-python", value)
|
|
324
|
+
if raw is None:
|
|
325
|
+
return None
|
|
326
|
+
try:
|
|
327
|
+
SpecifierSet(raw)
|
|
328
|
+
except InvalidSpecifier as exc:
|
|
329
|
+
msg = (
|
|
330
|
+
f"requires-python must be a PEP 440 specifier, got {raw!r}."
|
|
331
|
+
f" Did you mean ==X.Y or >=X.Y,<X.{{Y+1}}?"
|
|
332
|
+
)
|
|
333
|
+
raise ConfigError(msg) from exc
|
|
334
|
+
return raw
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _parse_uploaded_prior_to(value: object, *, anchor: datetime) -> datetime | None:
|
|
338
|
+
"""Parse ``uploaded-prior-to`` (ISO datetime, TOML datetime, or ``P<n>D``).
|
|
339
|
+
|
|
340
|
+
Naive datetimes are rejected so lockfiles read identically across
|
|
341
|
+
timezones. ``P<n>D`` (a nab extension) is resolved against
|
|
342
|
+
``anchor`` so re-locks reproduce the same cutoff.
|
|
343
|
+
"""
|
|
344
|
+
if value is None:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
if isinstance(value, datetime):
|
|
348
|
+
if value.tzinfo is None:
|
|
349
|
+
msg = (
|
|
350
|
+
"uploaded-prior-to TOML datetime must have an explicit"
|
|
351
|
+
" timezone offset (e.g. ``Z`` or ``+00:00``); got"
|
|
352
|
+
f" {value!r}"
|
|
353
|
+
)
|
|
354
|
+
raise ConfigError(msg)
|
|
355
|
+
return value
|
|
356
|
+
|
|
357
|
+
if not isinstance(value, str):
|
|
358
|
+
msg = (
|
|
359
|
+
"uploaded-prior-to must be a TOML offset-date-time, an ISO"
|
|
360
|
+
" 8601 datetime string with timezone, or a 'PnD' duration;"
|
|
361
|
+
f" got {type(value).__name__}"
|
|
362
|
+
)
|
|
363
|
+
raise ConfigError(msg)
|
|
364
|
+
|
|
365
|
+
duration_match = _DURATION_PATTERN.match(value)
|
|
366
|
+
if duration_match is not None:
|
|
367
|
+
days = int(duration_match.group(1))
|
|
368
|
+
return anchor - timedelta(days=days)
|
|
369
|
+
try:
|
|
370
|
+
dt = datetime.fromisoformat(value)
|
|
371
|
+
except ValueError as exc:
|
|
372
|
+
msg = (
|
|
373
|
+
"uploaded-prior-to must be an ISO 8601 datetime with"
|
|
374
|
+
" timezone (e.g. '2026-05-01T00:00:00Z') or a 'PnD'"
|
|
375
|
+
f" duration (e.g. 'P4D'); got {value!r}"
|
|
376
|
+
)
|
|
377
|
+
raise ConfigError(msg) from exc
|
|
378
|
+
if dt.tzinfo is None:
|
|
379
|
+
msg = (
|
|
380
|
+
"uploaded-prior-to ISO datetime must include an explicit"
|
|
381
|
+
" timezone offset (e.g. 'Z' or '+00:00'); got"
|
|
382
|
+
f" {value!r}"
|
|
383
|
+
)
|
|
384
|
+
raise ConfigError(msg)
|
|
385
|
+
return dt
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _parse_uploaded_prior_to_package(
|
|
389
|
+
value: object, *, anchor: datetime
|
|
390
|
+
) -> Mapping[str, datetime | None]:
|
|
391
|
+
"""Parse the optional ``[tool.nab.uploaded-prior-to-package]`` table.
|
|
392
|
+
|
|
393
|
+
Each entry maps a package name to either:
|
|
394
|
+
|
|
395
|
+
- ``false``: disable the cooldown for that package entirely.
|
|
396
|
+
- any value :func:`_parse_uploaded_prior_to` accepts (ISO datetime
|
|
397
|
+
with timezone, TOML offset-date-time, or ``"P<n>D"`` duration);
|
|
398
|
+
use that cutoff for the package, overriding the global value.
|
|
399
|
+
|
|
400
|
+
``true`` is rejected: the only meaningful boolean is ``false``
|
|
401
|
+
("no cooldown"). Package names are canonicalised so that
|
|
402
|
+
``Foo-Bar`` and ``foo_bar`` collapse to the same entry; a
|
|
403
|
+
duplicate raises :class:`ConfigError` with the original keys
|
|
404
|
+
in the message.
|
|
405
|
+
|
|
406
|
+
``anchor`` is forwarded to :func:`_parse_uploaded_prior_to` so
|
|
407
|
+
per-package ``P<n>D`` overrides resolve against the same reference
|
|
408
|
+
timestamp as the global value.
|
|
409
|
+
"""
|
|
410
|
+
if value is None:
|
|
411
|
+
return {}
|
|
412
|
+
if not isinstance(value, dict):
|
|
413
|
+
msg = (
|
|
414
|
+
"[tool.nab.uploaded-prior-to-package] must be a table,"
|
|
415
|
+
f" got {type(value).__name__}"
|
|
416
|
+
)
|
|
417
|
+
raise ConfigError(msg)
|
|
418
|
+
out: dict[str, datetime | None] = {}
|
|
419
|
+
seen: dict[str, str] = {}
|
|
420
|
+
for raw_key, raw_val in value.items():
|
|
421
|
+
if raw_val is True:
|
|
422
|
+
msg = (
|
|
423
|
+
f"[tool.nab.uploaded-prior-to-package].{raw_key}: ``true`` is"
|
|
424
|
+
" not a valid override; use ``false`` to disable the cooldown"
|
|
425
|
+
" or an absolute datetime / 'PnD' duration to set a window"
|
|
426
|
+
)
|
|
427
|
+
raise ConfigError(msg)
|
|
428
|
+
cutoff: datetime | None
|
|
429
|
+
if raw_val is False:
|
|
430
|
+
cutoff = None
|
|
431
|
+
else:
|
|
432
|
+
try:
|
|
433
|
+
cutoff = _parse_uploaded_prior_to(raw_val, anchor=anchor)
|
|
434
|
+
except ConfigError as exc:
|
|
435
|
+
msg = f"[tool.nab.uploaded-prior-to-package].{raw_key}: {exc}"
|
|
436
|
+
raise ConfigError(msg) from exc
|
|
437
|
+
canonical = canonicalize_name(raw_key)
|
|
438
|
+
if canonical in seen:
|
|
439
|
+
msg = (
|
|
440
|
+
"[tool.nab.uploaded-prior-to-package] declares duplicate"
|
|
441
|
+
f" canonical name {canonical!r} via {seen[canonical]!r}"
|
|
442
|
+
f" and {raw_key!r}"
|
|
443
|
+
)
|
|
444
|
+
raise ConfigError(msg)
|
|
445
|
+
seen[canonical] = raw_key
|
|
446
|
+
out[canonical] = cutoff
|
|
447
|
+
return out
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _parse_dist_policy_package(value: object) -> Mapping[str, DistPolicy]:
|
|
451
|
+
"""Parse the optional ``[tool.nab.dist-policy-package]`` table.
|
|
452
|
+
|
|
453
|
+
Maps a package name to one of the :class:`DistPolicy` values
|
|
454
|
+
("no-sdist", "prefer-binary", "allow", "sdist-only"). The
|
|
455
|
+
per-package value overrides the global ``dist-policy`` for that
|
|
456
|
+
package; mirrors pip's ``--no-binary-package <pkg>`` shape by
|
|
457
|
+
mapping ``<pkg> = "sdist-only"``.
|
|
458
|
+
|
|
459
|
+
Package names are canonicalised; duplicates raise
|
|
460
|
+
:class:`ConfigError` with the original keys named.
|
|
461
|
+
"""
|
|
462
|
+
if value is None:
|
|
463
|
+
return {}
|
|
464
|
+
if not isinstance(value, dict):
|
|
465
|
+
msg = (
|
|
466
|
+
"[tool.nab.dist-policy-package] must be a table,"
|
|
467
|
+
f" got {type(value).__name__}"
|
|
468
|
+
)
|
|
469
|
+
raise ConfigError(msg)
|
|
470
|
+
out: dict[str, DistPolicy] = {}
|
|
471
|
+
seen: dict[str, str] = {}
|
|
472
|
+
valid = sorted(p.value for p in DistPolicy)
|
|
473
|
+
for raw_key, raw_val in value.items():
|
|
474
|
+
if not isinstance(raw_val, str):
|
|
475
|
+
msg = (
|
|
476
|
+
f"[tool.nab.dist-policy-package].{raw_key} must be a string,"
|
|
477
|
+
f" got {type(raw_val).__name__}"
|
|
478
|
+
)
|
|
479
|
+
raise ConfigError(msg)
|
|
480
|
+
try:
|
|
481
|
+
policy = DistPolicy(raw_val)
|
|
482
|
+
except ValueError as exc:
|
|
483
|
+
msg = (
|
|
484
|
+
f"[tool.nab.dist-policy-package].{raw_key} must be one of"
|
|
485
|
+
f" {valid!r}, got {raw_val!r}"
|
|
486
|
+
)
|
|
487
|
+
raise ConfigError(msg) from exc
|
|
488
|
+
canonical = canonicalize_name(raw_key)
|
|
489
|
+
if canonical in seen:
|
|
490
|
+
msg = (
|
|
491
|
+
"[tool.nab.dist-policy-package] declares duplicate canonical"
|
|
492
|
+
f" name {canonical!r} via {seen[canonical]!r} and {raw_key!r}"
|
|
493
|
+
)
|
|
494
|
+
raise ConfigError(msg)
|
|
495
|
+
seen[canonical] = raw_key
|
|
496
|
+
out[canonical] = policy
|
|
497
|
+
return out
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _parse_enum(
|
|
501
|
+
key: str,
|
|
502
|
+
value: object,
|
|
503
|
+
enum_cls: type[enum.Enum],
|
|
504
|
+
default: enum.Enum,
|
|
505
|
+
) -> Any:
|
|
506
|
+
if value is None:
|
|
507
|
+
return default
|
|
508
|
+
if not isinstance(value, str):
|
|
509
|
+
msg = f"{key} must be a string, got {type(value).__name__}"
|
|
510
|
+
raise ConfigError(msg)
|
|
511
|
+
try:
|
|
512
|
+
return enum_cls(value)
|
|
513
|
+
except ValueError as exc:
|
|
514
|
+
valid = sorted(m.value for m in enum_cls)
|
|
515
|
+
msg = f"{key} must be one of {valid!r}, got {value!r}"
|
|
516
|
+
raise ConfigError(msg) from exc
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _parse_marker_environment(value: object) -> dict[str, str]:
|
|
520
|
+
if not isinstance(value, dict):
|
|
521
|
+
msg = (
|
|
522
|
+
"marker-environment must be a table of string -> string,"
|
|
523
|
+
f" got {type(value).__name__}"
|
|
524
|
+
)
|
|
525
|
+
raise ConfigError(msg)
|
|
526
|
+
out: dict[str, str] = {}
|
|
527
|
+
for k, v in value.items():
|
|
528
|
+
if not isinstance(k, str) or not isinstance(v, str):
|
|
529
|
+
msg = (
|
|
530
|
+
f"marker-environment entries must be string -> string, got {k!r}: {v!r}"
|
|
531
|
+
)
|
|
532
|
+
raise ConfigError(msg)
|
|
533
|
+
out[k] = v
|
|
534
|
+
return out
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _parse_indexes(value: object) -> tuple[IndexConfig, ...]:
|
|
538
|
+
if value is None:
|
|
539
|
+
return (IndexConfig(DEFAULT_INDEX_NAME, DEFAULT_INDEX_URL),)
|
|
540
|
+
|
|
541
|
+
if not isinstance(value, list):
|
|
542
|
+
msg = f"indexes must be an array of tables, got {type(value).__name__}"
|
|
543
|
+
raise ConfigError(msg)
|
|
544
|
+
|
|
545
|
+
if not value:
|
|
546
|
+
msg = "indexes must contain at least one entry when present"
|
|
547
|
+
raise ConfigError(msg)
|
|
548
|
+
|
|
549
|
+
out: list[IndexConfig] = []
|
|
550
|
+
seen: set[str] = set()
|
|
551
|
+
for i, entry in enumerate(value):
|
|
552
|
+
if not isinstance(entry, dict):
|
|
553
|
+
msg = f"indexes[{i}] must be a table, got {type(entry).__name__}"
|
|
554
|
+
raise ConfigError(msg)
|
|
555
|
+
try:
|
|
556
|
+
name = entry["name"]
|
|
557
|
+
url = entry["url"]
|
|
558
|
+
except KeyError as missing:
|
|
559
|
+
msg = f"indexes[{i}] missing required key {missing!s}"
|
|
560
|
+
raise ConfigError(msg) from None
|
|
561
|
+
if not isinstance(name, str) or not isinstance(url, str):
|
|
562
|
+
msg = f"indexes[{i}] name and url must be strings"
|
|
563
|
+
raise ConfigError(msg)
|
|
564
|
+
if name in seen:
|
|
565
|
+
msg = f"duplicate index name: {name!r}"
|
|
566
|
+
raise ConfigError(msg)
|
|
567
|
+
seen.add(name)
|
|
568
|
+
out.append(IndexConfig(name=name, url=url))
|
|
569
|
+
return tuple(out)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _parse_index_overrides(value: object) -> tuple[IndexOverride, ...]:
|
|
573
|
+
if not isinstance(value, list):
|
|
574
|
+
msg = f"index-overrides must be an array of tables, got {type(value).__name__}"
|
|
575
|
+
raise ConfigError(msg)
|
|
576
|
+
out: list[IndexOverride] = []
|
|
577
|
+
for i, entry in enumerate(value):
|
|
578
|
+
if not isinstance(entry, dict):
|
|
579
|
+
msg = f"index-overrides[{i}] must be a table, got {type(entry).__name__}"
|
|
580
|
+
raise ConfigError(msg)
|
|
581
|
+
try:
|
|
582
|
+
name = entry["name"]
|
|
583
|
+
index = entry["index"]
|
|
584
|
+
except KeyError as missing:
|
|
585
|
+
msg = f"index-overrides[{i}] missing required key {missing!s}"
|
|
586
|
+
raise ConfigError(msg) from None
|
|
587
|
+
marker = entry.get("marker")
|
|
588
|
+
if not isinstance(name, str) or not isinstance(index, str):
|
|
589
|
+
msg = f"index-overrides[{i}] name and index must be strings"
|
|
590
|
+
raise ConfigError(msg)
|
|
591
|
+
if marker is not None and not isinstance(marker, str):
|
|
592
|
+
msg = f"index-overrides[{i}] marker must be a string when set"
|
|
593
|
+
raise ConfigError(msg)
|
|
594
|
+
out.append(IndexOverride(name=name, index=index, marker=marker))
|
|
595
|
+
return tuple(out)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _parse_vcs(value: object) -> VcsConfig:
|
|
599
|
+
if not isinstance(value, dict):
|
|
600
|
+
msg = f"[tool.nab.vcs] must be a table, got {type(value).__name__}"
|
|
601
|
+
raise ConfigError(msg)
|
|
602
|
+
allowed = sorted({"policy", "allowed-schemes", "allowed-repos", "require-pin"})
|
|
603
|
+
unknown = sorted(set(value) - set(allowed))
|
|
604
|
+
if unknown:
|
|
605
|
+
msg = f"unknown [tool.nab.vcs] keys: {unknown!r}; expected {allowed!r}"
|
|
606
|
+
raise ConfigError(msg)
|
|
607
|
+
policy = _parse_enum("vcs.policy", value.get("policy"), VcsPolicy, VcsPolicy.BLOCK)
|
|
608
|
+
allowed_schemes = _parse_string_list(
|
|
609
|
+
"vcs.allowed-schemes", value.get("allowed-schemes", [])
|
|
610
|
+
)
|
|
611
|
+
allowed_repos = _parse_string_list(
|
|
612
|
+
"vcs.allowed-repos", value.get("allowed-repos", [])
|
|
613
|
+
)
|
|
614
|
+
require_pin_raw = value.get("require-pin", True)
|
|
615
|
+
if not isinstance(require_pin_raw, bool):
|
|
616
|
+
msg = f"vcs.require-pin must be a boolean, got {type(require_pin_raw).__name__}"
|
|
617
|
+
raise ConfigError(msg)
|
|
618
|
+
return VcsConfig(
|
|
619
|
+
policy=policy,
|
|
620
|
+
allowed_schemes=frozenset(allowed_schemes),
|
|
621
|
+
allowed_repos=tuple(allowed_repos),
|
|
622
|
+
require_pin=require_pin_raw,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _parse_build_policy_package(value: object) -> Mapping[str, BuildPolicy]:
|
|
627
|
+
"""Parse the optional ``[tool.nab.build-policy-package]`` table.
|
|
628
|
+
|
|
629
|
+
Maps a package name to one of the :class:`BuildPolicy` values
|
|
630
|
+
("never", "build-local", "build-remote"). The per-package value
|
|
631
|
+
overrides the global ``build-policy`` for that package.
|
|
632
|
+
|
|
633
|
+
Package names are canonicalised; duplicates raise
|
|
634
|
+
:class:`ConfigError` with the original keys named.
|
|
635
|
+
"""
|
|
636
|
+
if value is None:
|
|
637
|
+
return {}
|
|
638
|
+
if not isinstance(value, dict):
|
|
639
|
+
msg = (
|
|
640
|
+
"[tool.nab.build-policy-package] must be a table,"
|
|
641
|
+
f" got {type(value).__name__}"
|
|
642
|
+
)
|
|
643
|
+
raise ConfigError(msg)
|
|
644
|
+
out: dict[str, BuildPolicy] = {}
|
|
645
|
+
seen: dict[str, str] = {}
|
|
646
|
+
valid = sorted(p.value for p in BuildPolicy)
|
|
647
|
+
for raw_key, raw_val in value.items():
|
|
648
|
+
if not isinstance(raw_val, str):
|
|
649
|
+
msg = (
|
|
650
|
+
f"[tool.nab.build-policy-package].{raw_key} must be a string,"
|
|
651
|
+
f" got {type(raw_val).__name__}"
|
|
652
|
+
)
|
|
653
|
+
raise ConfigError(msg)
|
|
654
|
+
try:
|
|
655
|
+
policy = BuildPolicy(raw_val)
|
|
656
|
+
except ValueError as exc:
|
|
657
|
+
msg = (
|
|
658
|
+
f"[tool.nab.build-policy-package].{raw_key} must be one of"
|
|
659
|
+
f" {valid!r}, got {raw_val!r}"
|
|
660
|
+
)
|
|
661
|
+
raise ConfigError(msg) from exc
|
|
662
|
+
canonical = canonicalize_name(raw_key)
|
|
663
|
+
if canonical in seen:
|
|
664
|
+
msg = (
|
|
665
|
+
"[tool.nab.build-policy-package] declares duplicate canonical"
|
|
666
|
+
f" name {canonical!r} via {seen[canonical]!r} and {raw_key!r}"
|
|
667
|
+
)
|
|
668
|
+
raise ConfigError(msg)
|
|
669
|
+
seen[canonical] = raw_key
|
|
670
|
+
out[canonical] = policy
|
|
671
|
+
return out
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _parse_local_sources(
|
|
675
|
+
value: object, *, pyproject_dir: Path
|
|
676
|
+
) -> tuple[LocalSource, ...]:
|
|
677
|
+
if not isinstance(value, list):
|
|
678
|
+
msg = f"local-sources must be an array of tables, got {type(value).__name__}"
|
|
679
|
+
raise ConfigError(msg)
|
|
680
|
+
out: list[LocalSource] = []
|
|
681
|
+
for i, entry in enumerate(value):
|
|
682
|
+
if not isinstance(entry, dict):
|
|
683
|
+
msg = f"local-sources[{i}] must be a table, got {type(entry).__name__}"
|
|
684
|
+
raise ConfigError(msg)
|
|
685
|
+
try:
|
|
686
|
+
name = entry["name"]
|
|
687
|
+
path_value = entry["path"]
|
|
688
|
+
except KeyError as missing:
|
|
689
|
+
msg = f"local-sources[{i}] missing required key {missing!s}"
|
|
690
|
+
raise ConfigError(msg) from None
|
|
691
|
+
if not isinstance(name, str) or not isinstance(path_value, str):
|
|
692
|
+
msg = f"local-sources[{i}] name and path must be strings"
|
|
693
|
+
raise ConfigError(msg)
|
|
694
|
+
resolved = str((pyproject_dir / path_value).resolve())
|
|
695
|
+
out.append(LocalSource(name=name, path=resolved))
|
|
696
|
+
return tuple(out)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _parse_vcs_sources(value: object) -> tuple[VcsSource, ...]:
|
|
700
|
+
if not isinstance(value, list):
|
|
701
|
+
msg = f"vcs-sources must be an array of tables, got {type(value).__name__}"
|
|
702
|
+
raise ConfigError(msg)
|
|
703
|
+
out: list[VcsSource] = []
|
|
704
|
+
for i, entry in enumerate(value):
|
|
705
|
+
if not isinstance(entry, dict):
|
|
706
|
+
msg = f"vcs-sources[{i}] must be a table, got {type(entry).__name__}"
|
|
707
|
+
raise ConfigError(msg)
|
|
708
|
+
try:
|
|
709
|
+
name = entry["name"]
|
|
710
|
+
url = entry["url"]
|
|
711
|
+
except KeyError as missing:
|
|
712
|
+
msg = f"vcs-sources[{i}] missing required key {missing!s}"
|
|
713
|
+
raise ConfigError(msg) from None
|
|
714
|
+
if not isinstance(name, str) or not isinstance(url, str):
|
|
715
|
+
msg = f"vcs-sources[{i}] name and url must be strings"
|
|
716
|
+
raise ConfigError(msg)
|
|
717
|
+
out.append(VcsSource(name=name, url=url))
|
|
718
|
+
return tuple(out)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _parse_python_patches(value: object) -> dict[str, str] | None:
|
|
722
|
+
if value is None:
|
|
723
|
+
return None
|
|
724
|
+
if not isinstance(value, dict):
|
|
725
|
+
msg = (
|
|
726
|
+
"matrix.python-patches must be a table of"
|
|
727
|
+
" minor -> full version, got"
|
|
728
|
+
f" {type(value).__name__}"
|
|
729
|
+
)
|
|
730
|
+
raise ConfigError(msg)
|
|
731
|
+
out: dict[str, str] = {}
|
|
732
|
+
for k, v in value.items():
|
|
733
|
+
if not isinstance(k, str) or not isinstance(v, str):
|
|
734
|
+
msg = (
|
|
735
|
+
"matrix.python-patches entries must be"
|
|
736
|
+
f" string -> string, got {k!r}: {v!r}"
|
|
737
|
+
)
|
|
738
|
+
raise ConfigError(msg)
|
|
739
|
+
out[k] = v
|
|
740
|
+
return out
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _parse_workspace(value: object) -> WorkspaceConfig | None:
|
|
744
|
+
"""Parse the optional ``[tool.nab.workspace]`` table.
|
|
745
|
+
|
|
746
|
+
Schema today is a single ``members`` field listing literal paths.
|
|
747
|
+
Globs and member-existence checks happen in
|
|
748
|
+
:func:`nab_python.workspace.read_workspace_members`; this layer only
|
|
749
|
+
validates the table shape so typos like ``member = ...`` (missing
|
|
750
|
+
the ``s``) fail loud at config-parse time.
|
|
751
|
+
"""
|
|
752
|
+
if value is None:
|
|
753
|
+
return None
|
|
754
|
+
if not isinstance(value, dict):
|
|
755
|
+
msg = f"[tool.nab.workspace] must be a table, got {type(value).__name__}"
|
|
756
|
+
raise ConfigError(msg)
|
|
757
|
+
allowed = {"members"}
|
|
758
|
+
unknown = sorted(set(value) - allowed)
|
|
759
|
+
if unknown:
|
|
760
|
+
msg = (
|
|
761
|
+
f"unknown [tool.nab.workspace] keys: {unknown!r};"
|
|
762
|
+
f" expected {sorted(allowed)!r}"
|
|
763
|
+
)
|
|
764
|
+
raise ConfigError(msg)
|
|
765
|
+
members = _parse_string_list("workspace.members", value.get("members", []))
|
|
766
|
+
return WorkspaceConfig(members=members)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _parse_matrix(value: object) -> MatrixConfig | None:
|
|
770
|
+
if value is None:
|
|
771
|
+
return None
|
|
772
|
+
if not isinstance(value, dict):
|
|
773
|
+
msg = f"[tool.nab.matrix] must be a table, got {type(value).__name__}"
|
|
774
|
+
raise ConfigError(msg)
|
|
775
|
+
allowed = {"python", "platforms", "python-order", "python-patches"}
|
|
776
|
+
unknown = sorted(set(value) - allowed)
|
|
777
|
+
if unknown:
|
|
778
|
+
msg = (
|
|
779
|
+
f"unknown [tool.nab.matrix] keys: {unknown!r}; expected {sorted(allowed)!r}"
|
|
780
|
+
)
|
|
781
|
+
raise ConfigError(msg)
|
|
782
|
+
try:
|
|
783
|
+
python = value["python"]
|
|
784
|
+
platforms_raw = value["platforms"]
|
|
785
|
+
except KeyError as missing:
|
|
786
|
+
msg = f"[tool.nab.matrix] missing required key {missing!s}"
|
|
787
|
+
raise ConfigError(msg) from None
|
|
788
|
+
if not isinstance(python, str):
|
|
789
|
+
msg = "matrix.python must be a string PEP 440 specifier"
|
|
790
|
+
raise ConfigError(msg)
|
|
791
|
+
platforms = _parse_string_list("matrix.platforms", platforms_raw)
|
|
792
|
+
if not platforms:
|
|
793
|
+
msg = "matrix.platforms must list at least one platform id"
|
|
794
|
+
raise ConfigError(msg)
|
|
795
|
+
python_order = value.get("python-order", "asc")
|
|
796
|
+
if python_order not in {"asc", "desc"}:
|
|
797
|
+
msg = f"matrix.python-order must be 'asc' or 'desc', got {python_order!r}"
|
|
798
|
+
raise ConfigError(msg)
|
|
799
|
+
patches = _parse_python_patches(value.get("python-patches"))
|
|
800
|
+
return MatrixConfig(
|
|
801
|
+
python=python,
|
|
802
|
+
platforms=platforms,
|
|
803
|
+
python_order=python_order,
|
|
804
|
+
python_patches=patches,
|
|
805
|
+
)
|