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/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
+ )