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/__init__.py +5 -0
- cpdocs/__main__.py +5 -0
- cpdocs/build.py +645 -0
- cpdocs/clang_backend.py +1254 -0
- cpdocs/cli.py +78 -0
- cpdocs/config.py +160 -0
- cpdocs/contract.py +407 -0
- cpdocs/html_renderer.py +1803 -0
- cpdocs/model.py +228 -0
- cpdocs/properdocs_core.py +158 -0
- cpdocs/properdocs_plugin.py +236 -0
- cpdocs/python_backend.py +469 -0
- cpdocs/python_manifest.py +84 -0
- cpdocs/render.py +838 -0
- cpdocs/rust_backend.py +234 -0
- cpdocs/share/cmake/cpdocs.cmake +464 -0
- cpdocs/versioning.py +201 -0
- cpdocs-0.1.0.dist-info/METADATA +48 -0
- cpdocs-0.1.0.dist-info/RECORD +21 -0
- cpdocs-0.1.0.dist-info/WHEEL +4 -0
- cpdocs-0.1.0.dist-info/entry_points.txt +5 -0
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()
|