cpdocs 0.1.0__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.
cpdocs/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The ``cpdocs`` command."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .build import build_current, build_versions, cmake_module
12
+ from .config import DEFAULT_CONFIG, load_config
13
+ from .python_manifest import write_manifest
14
+
15
+
16
+ def _path(value: str) -> Path:
17
+ return Path(value).expanduser()
18
+
19
+
20
+ def _parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(prog="cpdocs", description="Semantic API documentation")
22
+ parser.add_argument("--version", action="version", version=f"cpdocs {__version__}")
23
+ commands = parser.add_subparsers(dest="command", required=True)
24
+
25
+ def documentation(name: str, help_text: str) -> argparse.ArgumentParser:
26
+ command = commands.add_parser(name, help=help_text)
27
+ command.add_argument("--config", type=_path, default=DEFAULT_CONFIG)
28
+ command.add_argument(
29
+ "--output",
30
+ type=_path,
31
+ default=Path("build"),
32
+ help="output root; cpdocs writes below <output>/cpdocs (default: build)",
33
+ )
34
+ return command
35
+
36
+ build = documentation("build", "document the working tree")
37
+ build.add_argument("--label", default="main", help="version label of the working tree")
38
+ build.add_argument("--variants", choices=("all", "primary"), default="all")
39
+
40
+ versions = documentation("versions", "document the configured branches and tags")
41
+ versions.add_argument("--tags", help="override versions.tags.select")
42
+
43
+ manifest = commands.add_parser("manifest", help="answer a cpdocs request as a build system")
44
+ kinds = manifest.add_subparsers(dest="kind", required=True)
45
+ python = kinds.add_parser("python", help="plain pyproject.toml project")
46
+ python.add_argument("--request", type=_path, required=True)
47
+ python.add_argument("--package", required=True)
48
+ python.add_argument("--source", required=True, help="package directory, relative to the root")
49
+
50
+ commands.add_parser("cmake-module", help="print the path of the CMake adapter")
51
+ return parser
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ parser = _parser()
56
+ arguments = parser.parse_args(argv)
57
+ try:
58
+ if arguments.command == "cmake-module":
59
+ print(cmake_module())
60
+ elif arguments.command == "manifest":
61
+ write_manifest(
62
+ arguments.request, package=arguments.package, package_source=arguments.source
63
+ )
64
+ else:
65
+ config = load_config(arguments.config)
66
+ output = arguments.output.resolve()
67
+ if arguments.command == "build":
68
+ build_current(
69
+ config, output=output, version=arguments.label, variants=arguments.variants
70
+ )
71
+ else:
72
+ build_versions(config, output=output, tags=arguments.tags)
73
+ except RuntimeError as error:
74
+ parser.exit(1, f"cpdocs: error: {error}\n")
75
+ return 0
76
+
77
+
78
+ __all__ = ["main"]
cpdocs/config.py ADDED
@@ -0,0 +1,160 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Loading ``cpdocs.yml``.
4
+
5
+ The configuration holds documentation and publishing policy only. Everything about the project
6
+ itself -- its name, features, variants, units and API files -- comes from the build system through
7
+ the manifests described in :mod:`cpdocs.contract`. ``cpdocs.yml`` therefore never names a feature or
8
+ a variant.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+ CONTRACT_VERSION = 1
20
+ DEFAULT_CONFIG = Path("docs/cpdocs.yml")
21
+ _CHECK_LEVELS = {"ignore", "warn", "fail"}
22
+ _VARIANT_POLICIES = {"all", "primary"}
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class CpdocsConfig:
27
+ config_file: Path
28
+ project_root: Path
29
+ describe: tuple[tuple[str, ...], ...]
30
+ branches: tuple[str, ...]
31
+ branch_variants: str
32
+ tags: str
33
+ tag_variants: str
34
+ default_version: str
35
+ aliases: dict[str, str]
36
+ failure: str
37
+ legacy: str
38
+ index: Path | None
39
+ copyright: str | None
40
+ project_docs: str
41
+ hide_cpp_namespaces: tuple[str, ...]
42
+ hide_private_python: bool
43
+ check_undocumented: str
44
+ check_broken_references: str
45
+
46
+
47
+ def project_root_for(config_file: Path) -> Path:
48
+ """Return the repository root for a configuration file.
49
+
50
+ ``<root>/docs/cpdocs.yml`` belongs to ``<root>``; any other location belongs to the directory
51
+ that contains the file. All paths in the configuration are relative to this root.
52
+ """
53
+
54
+ parent = config_file.resolve().parent
55
+ return parent.parent if parent.name == "docs" else parent
56
+
57
+
58
+ def _mapping(value: object, name: str) -> dict[str, Any]:
59
+ if value is None:
60
+ return {}
61
+ if not isinstance(value, dict):
62
+ raise RuntimeError(f"cpdocs.yml: {name} must be a mapping")
63
+ return {str(key): item for key, item in value.items()}
64
+
65
+
66
+ def _strings(value: object, name: str) -> tuple[str, ...]:
67
+ if value is None:
68
+ return ()
69
+ if isinstance(value, str):
70
+ return (value,)
71
+ if not isinstance(value, list):
72
+ raise RuntimeError(f"cpdocs.yml: {name} must be a list")
73
+ return tuple(str(item) for item in value)
74
+
75
+
76
+ def _choice(value: object, name: str, allowed: set[str], default: str) -> str:
77
+ result = str(value if value is not None else default)
78
+ if result not in allowed:
79
+ raise RuntimeError(f"cpdocs.yml: {name} must be one of {', '.join(sorted(allowed))}")
80
+ return result
81
+
82
+
83
+ def _commands(value: object) -> tuple[tuple[str, ...], ...]:
84
+ if not isinstance(value, list) or not value:
85
+ raise RuntimeError("cpdocs.yml: build.describe must be a non-empty list of commands")
86
+ commands: list[tuple[str, ...]] = []
87
+ for index, command in enumerate(value):
88
+ if not isinstance(command, list) or not command:
89
+ raise RuntimeError(f"cpdocs.yml: build.describe[{index}] must be a non-empty argv list")
90
+ commands.append(tuple(str(item) for item in command))
91
+ return tuple(commands)
92
+
93
+
94
+ def load_config(path: Path | str = DEFAULT_CONFIG) -> CpdocsConfig:
95
+ config_file = Path(path).expanduser().resolve()
96
+ if not config_file.is_file():
97
+ raise RuntimeError(f"cpdocs configuration does not exist: {config_file}")
98
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
99
+ if not isinstance(raw, dict):
100
+ raise RuntimeError("cpdocs.yml: top level must be a mapping")
101
+ if raw.get("contract") != CONTRACT_VERSION:
102
+ raise RuntimeError(
103
+ f"cpdocs.yml: contract must be {CONTRACT_VERSION}, found {raw.get('contract')!r}"
104
+ )
105
+ known = {"contract", "build", "versions", "site", "api", "checks"}
106
+ unknown = sorted(set(raw) - known)
107
+ if unknown:
108
+ raise RuntimeError(f"cpdocs.yml: unknown top-level keys: {', '.join(unknown)}")
109
+
110
+ root = project_root_for(config_file)
111
+ build = _mapping(raw.get("build"), "build")
112
+ versions = _mapping(raw.get("versions"), "versions")
113
+ branches = _mapping(versions.get("branches"), "versions.branches")
114
+ tags = _mapping(versions.get("tags"), "versions.tags")
115
+ site = _mapping(raw.get("site"), "site")
116
+ api = _mapping(raw.get("api"), "api")
117
+ hide = _mapping(api.get("hide"), "api.hide")
118
+ checks = _mapping(raw.get("checks"), "checks")
119
+
120
+ index_value = site.get("index")
121
+ index = (
122
+ (root / str(index_value)).resolve() if index_value else root / "docs" / "api" / "index.md"
123
+ )
124
+ aliases = {
125
+ str(key): str(value)
126
+ for key, value in _mapping(versions.get("aliases"), "versions.aliases").items()
127
+ }
128
+ copyright_value = site.get("copyright")
129
+
130
+ return CpdocsConfig(
131
+ config_file=config_file,
132
+ project_root=root,
133
+ describe=_commands(build.get("describe")),
134
+ branches=_strings(branches.get("include", ["main"]), "versions.branches.include"),
135
+ branch_variants=_choice(
136
+ branches.get("variants"), "versions.branches.variants", _VARIANT_POLICIES, "all"
137
+ ),
138
+ tags=str(tags.get("select", "all")).strip(),
139
+ tag_variants=_choice(
140
+ tags.get("variants"), "versions.tags.variants", _VARIANT_POLICIES, "all"
141
+ ),
142
+ default_version=str(versions.get("default") or "latest-tag"),
143
+ aliases=aliases,
144
+ failure=_choice(versions.get("failure"), "versions.failure", {"skip", "fail"}, "fail"),
145
+ legacy=_choice(versions.get("legacy"), "versions.legacy", {"current", "skip"}, "current"),
146
+ index=index if index.is_file() else None,
147
+ copyright=str(copyright_value) if copyright_value is not None else None,
148
+ project_docs=str(site.get("project-docs") or "auto"),
149
+ hide_cpp_namespaces=_strings(hide.get("cpp-namespaces"), "api.hide.cpp-namespaces"),
150
+ hide_private_python=_choice(
151
+ hide.get("python-modules"), "api.hide.python-modules", {"private", "none"}, "private"
152
+ )
153
+ == "private",
154
+ check_undocumented=_choice(
155
+ checks.get("undocumented"), "checks.undocumented", _CHECK_LEVELS, "ignore"
156
+ ),
157
+ check_broken_references=_choice(
158
+ checks.get("broken-references"), "checks.broken-references", _CHECK_LEVELS, "warn"
159
+ ),
160
+ )
cpdocs/contract.py ADDED
@@ -0,0 +1,407 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """The contract between cpdocs and a project's build system.
4
+
5
+ cpdocs never knows how a project is built. It hands the build system a *request* and reads back a
6
+ *manifest*. There are two phases:
7
+
8
+ ``describe``
9
+ The build system reports the project identity, its feature axes, the documentation units and
10
+ the variants worth documenting. Every variant carries the commands that make it concrete.
11
+
12
+ ``variant``
13
+ The build system has been configured for exactly one variant and reports, per unit, which files
14
+ form the public API and how they must be parsed.
15
+
16
+ The normative description lives in ``docs/web/reference/contract.md``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ CONTRACT_VERSION = 1
27
+ LANGUAGES = ("c", "cpp", "python", "rust")
28
+
29
+
30
+ class ContractError(RuntimeError):
31
+ """A request or manifest does not satisfy the cpdocs build-system contract."""
32
+
33
+
34
+ # --------------------------------------------------------------------------------------------------
35
+ # Request
36
+ # --------------------------------------------------------------------------------------------------
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Request:
41
+ phase: str
42
+ ref: str
43
+ ref_kind: str
44
+ source: Path
45
+ build: Path
46
+ manifest: Path
47
+ variant: str | None
48
+
49
+ def to_json(self) -> dict[str, object]:
50
+ return {
51
+ "cpdocs-request": CONTRACT_VERSION,
52
+ "phase": self.phase,
53
+ "ref": self.ref,
54
+ "ref-kind": self.ref_kind,
55
+ "variant": self.variant,
56
+ "source": str(self.source),
57
+ "build": str(self.build),
58
+ "manifest": str(self.manifest),
59
+ }
60
+
61
+ def write(self, path: Path) -> None:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(json.dumps(self.to_json(), indent=2) + "\n", encoding="utf-8")
64
+
65
+
66
+ def read_request(path: Path) -> Request:
67
+ raw = _load_json(path, "request")
68
+ if raw.get("cpdocs-request") != CONTRACT_VERSION:
69
+ raise ContractError(f"{path}: unsupported cpdocs-request {raw.get('cpdocs-request')!r}")
70
+ phase = _string(raw, "phase", path)
71
+ if phase not in {"describe", "variant"}:
72
+ raise ContractError(f"{path}: phase must be 'describe' or 'variant'")
73
+ variant = raw.get("variant")
74
+ if phase == "variant" and not isinstance(variant, str):
75
+ raise ContractError(f"{path}: a variant request must name its variant")
76
+ return Request(
77
+ phase=phase,
78
+ ref=_string(raw, "ref", path),
79
+ ref_kind=_string(raw, "ref-kind", path),
80
+ source=Path(_string(raw, "source", path)),
81
+ build=Path(_string(raw, "build", path)),
82
+ manifest=Path(_string(raw, "manifest", path)),
83
+ variant=variant if isinstance(variant, str) else None,
84
+ )
85
+
86
+
87
+ # --------------------------------------------------------------------------------------------------
88
+ # Describe manifest
89
+ # --------------------------------------------------------------------------------------------------
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class FeatureAxis:
94
+ name: str
95
+ values: tuple[str, ...]
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class UnitDeclaration:
100
+ id: str
101
+ language: str
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class VariantDeclaration:
106
+ name: str
107
+ primary: bool
108
+ features: dict[str, tuple[str, ...]]
109
+ commands: tuple[tuple[str, ...], ...]
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Description:
114
+ project: str
115
+ version: str
116
+ features: tuple[FeatureAxis, ...]
117
+ units: tuple[UnitDeclaration, ...]
118
+ variants: tuple[VariantDeclaration, ...]
119
+ generator: str
120
+
121
+ @property
122
+ def primary(self) -> VariantDeclaration:
123
+ return next(variant for variant in self.variants if variant.primary)
124
+
125
+ def variant(self, name: str) -> VariantDeclaration:
126
+ for variant in self.variants:
127
+ if variant.name == name:
128
+ return variant
129
+ raise ContractError(f"describe manifest has no variant {name!r}")
130
+
131
+
132
+ def read_description(path: Path) -> Description:
133
+ raw = _load_json(path, "describe manifest")
134
+ _check_header(raw, path, "describe")
135
+ project = _mapping(raw.get("project"), "project", path)
136
+
137
+ axes: list[FeatureAxis] = []
138
+ for name, value in _mapping(raw.get("features", {}), "features", path).items():
139
+ data = _mapping(value, f"features.{name}", path)
140
+ values = _string_tuple(data.get("values"), f"features.{name}.values", path)
141
+ if len(set(values)) != len(values):
142
+ raise ContractError(f"{path}: features.{name}.values contains duplicates")
143
+ axes.append(FeatureAxis(name=name, values=values))
144
+ axis_values = {axis.name: set(axis.values) for axis in axes}
145
+
146
+ units = _units(raw.get("units"), path)
147
+
148
+ variants: list[VariantDeclaration] = []
149
+ raw_variants = raw.get("variants")
150
+ if not isinstance(raw_variants, list) or not raw_variants:
151
+ raise ContractError(f"{path}: variants must be a non-empty list")
152
+ for index, value in enumerate(raw_variants):
153
+ where = f"variants[{index}]"
154
+ data = _mapping(value, where, path)
155
+ name = _string(data, "name", path, where)
156
+ features = _features(data.get("features", {}), f"{where}.features", path)
157
+ for axis, selected in features.items():
158
+ if axis not in axis_values:
159
+ raise ContractError(f"{path}: {where} uses undeclared feature axis {axis!r}")
160
+ unknown = sorted(set(selected) - axis_values[axis])
161
+ if unknown:
162
+ raise ContractError(
163
+ f"{path}: {where} selects unknown {axis} values: {', '.join(unknown)}"
164
+ )
165
+ commands = data.get("commands")
166
+ if not isinstance(commands, list):
167
+ raise ContractError(f"{path}: {where}.commands must be a list of argv lists")
168
+ variants.append(
169
+ VariantDeclaration(
170
+ name=name,
171
+ primary=bool(data.get("primary", False)),
172
+ features=features,
173
+ commands=tuple(
174
+ _string_tuple(command, f"{where}.commands[{number}]", path)
175
+ for number, command in enumerate(commands)
176
+ ),
177
+ )
178
+ )
179
+
180
+ names = [variant.name for variant in variants]
181
+ if len(set(names)) != len(names):
182
+ raise ContractError(f"{path}: variant names must be unique")
183
+ primaries = [variant.name for variant in variants if variant.primary]
184
+ if len(primaries) != 1:
185
+ raise ContractError(f"{path}: exactly one variant must be primary, found {len(primaries)}")
186
+
187
+ return Description(
188
+ project=_string(project, "name", path, "project"),
189
+ version=str(project.get("version") or ""),
190
+ features=tuple(axes),
191
+ units=units,
192
+ variants=tuple(variants),
193
+ generator=str(raw.get("generator") or ""),
194
+ )
195
+
196
+
197
+ # --------------------------------------------------------------------------------------------------
198
+ # Variant manifest
199
+ # --------------------------------------------------------------------------------------------------
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class ApiRoot:
204
+ path: Path
205
+ generated: bool
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class ParseOptions:
210
+ standard: str
211
+ include: tuple[Path, ...]
212
+ system_include: tuple[Path, ...]
213
+ define: tuple[str, ...]
214
+ flags: tuple[str, ...]
215
+ compile_database: Path | None
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class UnitManifest:
220
+ id: str
221
+ language: str
222
+ api: tuple[ApiRoot, ...]
223
+ parse: ParseOptions | None
224
+ package: str | None
225
+ rustdoc_json: Path | None
226
+
227
+
228
+ @dataclass(frozen=True)
229
+ class VariantManifest:
230
+ variant: str
231
+ features: dict[str, tuple[str, ...]]
232
+ units: tuple[UnitManifest, ...]
233
+
234
+
235
+ def read_variant_manifest(path: Path, *, source: Path) -> VariantManifest:
236
+ """Read one variant manifest; relative paths are resolved against ``source``."""
237
+
238
+ raw = _load_json(path, "variant manifest")
239
+ _check_header(raw, path, "variant")
240
+ units: list[UnitManifest] = []
241
+ for declaration, value in zip(_units(raw.get("units"), path), raw["units"], strict=True):
242
+ where = f"unit {declaration.id!r}"
243
+ data = _mapping(value, where, path)
244
+ roots = data.get("api", [])
245
+ if not isinstance(roots, list):
246
+ raise ContractError(f"{path}: {where}: api must be a list")
247
+ api: list[ApiRoot] = []
248
+ for index, root in enumerate(roots):
249
+ root_data = _mapping(root, f"{where}.api[{index}]", path)
250
+ api.append(
251
+ ApiRoot(
252
+ path=_path(_string(root_data, "path", path, where), source),
253
+ generated=bool(root_data.get("generated", False)),
254
+ )
255
+ )
256
+
257
+ parse: ParseOptions | None = None
258
+ package: str | None = None
259
+ rustdoc_json: Path | None = None
260
+ if declaration.language in {"c", "cpp"}:
261
+ if not api:
262
+ raise ContractError(f"{path}: {where}: a C/C++ unit needs at least one API root")
263
+ options = _mapping(data.get("parse", {}), f"{where}.parse", path)
264
+ database = options.get("compile-database")
265
+ parse = ParseOptions(
266
+ standard=str(options.get("standard") or ""),
267
+ include=tuple(
268
+ _path(item, source)
269
+ for item in _string_tuple(options.get("include", []), "include", path)
270
+ ),
271
+ system_include=tuple(
272
+ _path(item, source)
273
+ for item in _string_tuple(
274
+ options.get("system-include", []), "system-include", path
275
+ )
276
+ ),
277
+ define=_string_tuple(options.get("define", []), "define", path),
278
+ flags=_string_tuple(options.get("flags", []), "flags", path),
279
+ compile_database=_path(str(database), source) if database else None,
280
+ )
281
+ elif declaration.language == "python":
282
+ if len(api) != 1:
283
+ raise ContractError(f"{path}: {where}: a Python unit needs exactly one API root")
284
+ package = _string(data, "package", path, where)
285
+ elif declaration.language == "rust":
286
+ rustdoc_json = _path(_string(data, "rustdoc-json", path, where), source)
287
+
288
+ units.append(
289
+ UnitManifest(
290
+ id=declaration.id,
291
+ language=declaration.language,
292
+ api=tuple(api),
293
+ parse=parse,
294
+ package=package,
295
+ rustdoc_json=rustdoc_json,
296
+ )
297
+ )
298
+
299
+ return VariantManifest(
300
+ variant=_string(raw, "variant", path),
301
+ features=_features(raw.get("features", {}), "features", path),
302
+ units=tuple(units),
303
+ )
304
+
305
+
306
+ def check_variant(
307
+ manifest: VariantManifest, *, description: Description, requested: str, path: Path
308
+ ) -> None:
309
+ """Reject a variant manifest that disagrees with what was requested and described."""
310
+
311
+ if manifest.variant != requested:
312
+ raise ContractError(
313
+ f"{path}: build system answered for variant {manifest.variant!r}, "
314
+ f"cpdocs requested {requested!r}"
315
+ )
316
+ declared = description.variant(requested)
317
+ expected = {axis: tuple(sorted(values)) for axis, values in declared.features.items()}
318
+ actual = {axis: tuple(sorted(values)) for axis, values in manifest.features.items()}
319
+ for axis in {axis.name for axis in description.features}:
320
+ expected.setdefault(axis, ())
321
+ actual.setdefault(axis, ())
322
+ if expected != actual:
323
+ raise ContractError(
324
+ f"{path}: variant {requested!r} was configured with features {actual}, "
325
+ f"but the describe manifest declares {expected}"
326
+ )
327
+ described = [(unit.id, unit.language) for unit in description.units]
328
+ reported = [(unit.id, unit.language) for unit in manifest.units]
329
+ if sorted(described) != sorted(reported):
330
+ raise ContractError(
331
+ f"{path}: variant {requested!r} reports units {reported}, described units are "
332
+ f"{described}"
333
+ )
334
+
335
+
336
+ # --------------------------------------------------------------------------------------------------
337
+ # Helpers
338
+ # --------------------------------------------------------------------------------------------------
339
+
340
+
341
+ def _load_json(path: Path, what: str) -> dict[str, Any]:
342
+ if not path.is_file():
343
+ raise ContractError(f"the build system did not write the {what} {path}")
344
+ try:
345
+ raw = json.loads(path.read_text(encoding="utf-8"))
346
+ except (OSError, json.JSONDecodeError) as error:
347
+ raise ContractError(f"cannot read {what} {path}: {error}") from error
348
+ if not isinstance(raw, dict):
349
+ raise ContractError(f"{path}: {what} must be a JSON object")
350
+ return raw
351
+
352
+
353
+ def _check_header(raw: dict[str, Any], path: Path, kind: str) -> None:
354
+ if raw.get("cpdocs-manifest") != CONTRACT_VERSION:
355
+ raise ContractError(f"{path}: unsupported cpdocs-manifest {raw.get('cpdocs-manifest')!r}")
356
+ if raw.get("kind") != kind:
357
+ raise ContractError(f"{path}: expected a {kind!r} manifest, found {raw.get('kind')!r}")
358
+
359
+
360
+ def _mapping(value: object, name: str, path: Path) -> dict[str, Any]:
361
+ if not isinstance(value, dict):
362
+ raise ContractError(f"{path}: {name} must be an object")
363
+ return {str(key): item for key, item in value.items()}
364
+
365
+
366
+ def _string(data: dict[str, Any], key: str, path: Path, where: str = "") -> str:
367
+ value = data.get(key)
368
+ if not isinstance(value, str) or not value:
369
+ prefix = f"{where}." if where else ""
370
+ raise ContractError(f"{path}: {prefix}{key} must be a non-empty string")
371
+ return value
372
+
373
+
374
+ def _string_tuple(value: object, name: str, path: Path) -> tuple[str, ...]:
375
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
376
+ raise ContractError(f"{path}: {name} must be a list of strings")
377
+ return tuple(value)
378
+
379
+
380
+ def _features(value: object, name: str, path: Path) -> dict[str, tuple[str, ...]]:
381
+ data = _mapping(value, name, path)
382
+ return {axis: _string_tuple(item, f"{name}.{axis}", path) for axis, item in data.items()}
383
+
384
+
385
+ def _units(value: object, path: Path) -> tuple[UnitDeclaration, ...]:
386
+ if not isinstance(value, list) or not value:
387
+ raise ContractError(f"{path}: units must be a non-empty list")
388
+ units: list[UnitDeclaration] = []
389
+ for index, item in enumerate(value):
390
+ data = _mapping(item, f"units[{index}]", path)
391
+ language = _string(data, "language", path, f"units[{index}]")
392
+ if language not in LANGUAGES:
393
+ raise ContractError(
394
+ f"{path}: units[{index}].language must be one of {', '.join(LANGUAGES)}"
395
+ )
396
+ units.append(
397
+ UnitDeclaration(id=_string(data, "id", path, f"units[{index}]"), language=language)
398
+ )
399
+ ids = [unit.id for unit in units]
400
+ if len(set(ids)) != len(ids):
401
+ raise ContractError(f"{path}: unit ids must be unique")
402
+ return tuple(units)
403
+
404
+
405
+ def _path(value: str, source: Path) -> Path:
406
+ path = Path(value).expanduser()
407
+ return (path if path.is_absolute() else source / path).resolve()