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/model.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 cpdocs developers
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Language-neutral API graph used by cpdocs' reference renderer."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class SourceLocation:
|
|
14
|
+
path: str
|
|
15
|
+
line: int | None = None
|
|
16
|
+
column: int | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ApiParameter:
|
|
21
|
+
name: str
|
|
22
|
+
type: str = ""
|
|
23
|
+
default: str | None = None
|
|
24
|
+
kind: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ApiSignature:
|
|
29
|
+
parameters: tuple[ApiParameter, ...] = ()
|
|
30
|
+
returns: str = ""
|
|
31
|
+
qualifiers: tuple[str, ...] = ()
|
|
32
|
+
spelling: str = ""
|
|
33
|
+
template: str = ""
|
|
34
|
+
# Documentation and location of this overload; not part of its identity.
|
|
35
|
+
documentation: str = field(default="", compare=False)
|
|
36
|
+
source: SourceLocation | None = field(default=None, compare=False)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def compact(self) -> str:
|
|
40
|
+
values = [parameter.type or parameter.name or "?" for parameter in self.parameters]
|
|
41
|
+
return f"({', '.join(values)})"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ApiEntity:
|
|
46
|
+
id: str
|
|
47
|
+
language: str
|
|
48
|
+
kind: str
|
|
49
|
+
name: str
|
|
50
|
+
qualified_name: str
|
|
51
|
+
parent: str | None = None
|
|
52
|
+
signatures: list[ApiSignature] = field(default_factory=list)
|
|
53
|
+
documentation: str = ""
|
|
54
|
+
source: SourceLocation | None = None
|
|
55
|
+
properties: list[str] = field(default_factory=list)
|
|
56
|
+
variants: list[str] = field(default_factory=list)
|
|
57
|
+
children: list[str] = field(default_factory=list)
|
|
58
|
+
bases: list[str] = field(default_factory=list)
|
|
59
|
+
aliases: list[str] = field(default_factory=list)
|
|
60
|
+
related: list[str] = field(default_factory=list)
|
|
61
|
+
template: str = ""
|
|
62
|
+
declaration: str = ""
|
|
63
|
+
# Variants of each signature, parallel to ``signatures``; empty when unknown.
|
|
64
|
+
signature_variants: list[list[str]] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def navigation_label(self) -> str:
|
|
68
|
+
# The outline is deliberately name-only. Detailed/overload signatures belong to the
|
|
69
|
+
# entity page and its local "On this page" navigation, not to the global API tree.
|
|
70
|
+
return self.name
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def member_label(self) -> str:
|
|
74
|
+
if self.kind in {"function", "method", "constructor"}:
|
|
75
|
+
return f"{self.name}()"
|
|
76
|
+
return self.name
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ApiGraph:
|
|
81
|
+
project: str
|
|
82
|
+
version: str
|
|
83
|
+
language: str
|
|
84
|
+
entities: dict[str, ApiEntity] = field(default_factory=dict)
|
|
85
|
+
variants: dict[str, dict[str, object]] = field(default_factory=dict)
|
|
86
|
+
sources: dict[str, str] = field(default_factory=dict)
|
|
87
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
def add(self, entity: ApiEntity) -> None:
|
|
90
|
+
existing = self.entities.get(entity.id)
|
|
91
|
+
if existing is None:
|
|
92
|
+
self.entities[entity.id] = entity
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
while len(existing.signature_variants) < len(existing.signatures):
|
|
96
|
+
existing.signature_variants.append([])
|
|
97
|
+
for index, signature in enumerate(entity.signatures):
|
|
98
|
+
incoming = (
|
|
99
|
+
entity.signature_variants[index] if index < len(entity.signature_variants) else []
|
|
100
|
+
)
|
|
101
|
+
if signature not in existing.signatures:
|
|
102
|
+
existing.signatures.append(signature)
|
|
103
|
+
existing.signature_variants.append(list(incoming))
|
|
104
|
+
continue
|
|
105
|
+
known = existing.signature_variants[existing.signatures.index(signature)]
|
|
106
|
+
known.extend(value for value in incoming if value not in known)
|
|
107
|
+
for value in entity.properties:
|
|
108
|
+
if value not in existing.properties:
|
|
109
|
+
existing.properties.append(value)
|
|
110
|
+
for value in entity.variants:
|
|
111
|
+
if value not in existing.variants:
|
|
112
|
+
existing.variants.append(value)
|
|
113
|
+
for value in entity.children:
|
|
114
|
+
if value not in existing.children:
|
|
115
|
+
existing.children.append(value)
|
|
116
|
+
for value in entity.bases:
|
|
117
|
+
if value not in existing.bases:
|
|
118
|
+
existing.bases.append(value)
|
|
119
|
+
for value in entity.aliases:
|
|
120
|
+
if value not in existing.aliases:
|
|
121
|
+
existing.aliases.append(value)
|
|
122
|
+
for value in entity.related:
|
|
123
|
+
if value not in existing.related:
|
|
124
|
+
existing.related.append(value)
|
|
125
|
+
if not existing.documentation and entity.documentation:
|
|
126
|
+
existing.documentation = entity.documentation
|
|
127
|
+
if existing.source is None and entity.source is not None:
|
|
128
|
+
existing.source = entity.source
|
|
129
|
+
if not existing.template and entity.template:
|
|
130
|
+
existing.template = entity.template
|
|
131
|
+
if not existing.declaration and entity.declaration:
|
|
132
|
+
existing.declaration = entity.declaration
|
|
133
|
+
|
|
134
|
+
def rebuild_children(self) -> None:
|
|
135
|
+
for entity in self.entities.values():
|
|
136
|
+
entity.children = []
|
|
137
|
+
for entity in self.entities.values():
|
|
138
|
+
if entity.parent and entity.parent in self.entities:
|
|
139
|
+
parent = self.entities[entity.parent]
|
|
140
|
+
if entity.id not in parent.children:
|
|
141
|
+
parent.children.append(entity.id)
|
|
142
|
+
for entity in self.entities.values():
|
|
143
|
+
entity.children.sort(key=lambda key: entity_sort_key(self.entities[key]))
|
|
144
|
+
|
|
145
|
+
def roots(self) -> list[ApiEntity]:
|
|
146
|
+
values = [
|
|
147
|
+
entity
|
|
148
|
+
for entity in self.entities.values()
|
|
149
|
+
if not entity.parent or entity.parent not in self.entities
|
|
150
|
+
]
|
|
151
|
+
return sorted(values, key=entity_sort_key)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def entity_sort_key(entity: ApiEntity) -> tuple[int, str, str]:
|
|
155
|
+
order = {
|
|
156
|
+
"package": 0,
|
|
157
|
+
"module": 1,
|
|
158
|
+
"namespace": 1,
|
|
159
|
+
"class": 2,
|
|
160
|
+
"struct": 2,
|
|
161
|
+
"union": 2,
|
|
162
|
+
"trait": 2,
|
|
163
|
+
"protocol": 2,
|
|
164
|
+
"enum": 3,
|
|
165
|
+
"type_alias": 4,
|
|
166
|
+
"constructor": 5,
|
|
167
|
+
"method": 6,
|
|
168
|
+
"function": 6,
|
|
169
|
+
"property": 7,
|
|
170
|
+
"attribute": 8,
|
|
171
|
+
"variable": 8,
|
|
172
|
+
"constant": 8,
|
|
173
|
+
"macro": 9,
|
|
174
|
+
"concept": 4,
|
|
175
|
+
"deduction_guide": 5,
|
|
176
|
+
}
|
|
177
|
+
return (order.get(entity.kind, 50), entity.name.casefold(), entity.qualified_name)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def stable_entity_id(language: str, kind: str, qualified_name: str, signature_key: str = "") -> str:
|
|
181
|
+
suffix = f"::{signature_key}" if signature_key else ""
|
|
182
|
+
return f"{language}:{kind}:{qualified_name}{suffix}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def relative_source_path(path: str | Path, project_root: Path) -> str:
|
|
186
|
+
source = Path(path)
|
|
187
|
+
try:
|
|
188
|
+
return source.resolve().relative_to(project_root.resolve()).as_posix()
|
|
189
|
+
except (OSError, ValueError):
|
|
190
|
+
return source.as_posix()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def merge_graphs(
|
|
194
|
+
graphs: Iterable[tuple[str, ApiGraph]], *, project: str, version: str, language: str
|
|
195
|
+
) -> ApiGraph:
|
|
196
|
+
merged = ApiGraph(project=project, version=version, language=language)
|
|
197
|
+
for variant_name, graph in graphs:
|
|
198
|
+
for key, value in graph.metadata.items():
|
|
199
|
+
merged.metadata.setdefault(key, value)
|
|
200
|
+
merged.variants[variant_name] = graph.variants.get(variant_name, {})
|
|
201
|
+
for source_path, content in graph.sources.items():
|
|
202
|
+
merged.sources.setdefault(source_path, content)
|
|
203
|
+
for entity in graph.entities.values():
|
|
204
|
+
entity_copy = ApiEntity(
|
|
205
|
+
id=entity.id,
|
|
206
|
+
language=entity.language,
|
|
207
|
+
kind=entity.kind,
|
|
208
|
+
name=entity.name,
|
|
209
|
+
qualified_name=entity.qualified_name,
|
|
210
|
+
parent=entity.parent,
|
|
211
|
+
signatures=list(entity.signatures),
|
|
212
|
+
documentation=entity.documentation,
|
|
213
|
+
source=entity.source,
|
|
214
|
+
properties=list(entity.properties),
|
|
215
|
+
variants=list(entity.variants),
|
|
216
|
+
children=list(entity.children),
|
|
217
|
+
bases=list(entity.bases),
|
|
218
|
+
aliases=list(entity.aliases),
|
|
219
|
+
related=list(entity.related),
|
|
220
|
+
template=entity.template,
|
|
221
|
+
declaration=entity.declaration,
|
|
222
|
+
signature_variants=[[variant_name] for _signature in entity.signatures],
|
|
223
|
+
)
|
|
224
|
+
if variant_name not in entity_copy.variants:
|
|
225
|
+
entity_copy.variants.append(variant_name)
|
|
226
|
+
merged.add(entity_copy)
|
|
227
|
+
merged.rebuild_children()
|
|
228
|
+
return merged
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 cpdocs developers
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Framework-independent helpers used by the ProperDocs cpdocs plugin."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import posixpath
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
_APIDOCS_REFERENCE = re.compile(
|
|
15
|
+
r"@apidocs(?:\[(?P<version>[^\]]+)\])?::"
|
|
16
|
+
r"(?P<symbol>[A-Za-z_](?:[A-Za-z0-9_:.]*[A-Za-z0-9_])?)"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def repository_provider(repo_url: str, extra: dict[str, object]) -> str:
|
|
21
|
+
explicit = str(extra.get("cpdocs_repo_provider") or "").strip().lower()
|
|
22
|
+
if explicit:
|
|
23
|
+
return explicit
|
|
24
|
+
host = repo_url.lower()
|
|
25
|
+
if "github" in host:
|
|
26
|
+
return "github"
|
|
27
|
+
if "bitbucket" in host:
|
|
28
|
+
return "bitbucket"
|
|
29
|
+
return "gitlab"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _repository_base(repo_url: str) -> str:
|
|
33
|
+
value = repo_url.rstrip("/")
|
|
34
|
+
return value[:-4] if value.endswith(".git") else value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def source_url(repo_url: str, provider: str, ref: str, source_path: str) -> str:
|
|
38
|
+
base = _repository_base(repo_url)
|
|
39
|
+
ref_part = quote(ref, safe="/")
|
|
40
|
+
path_part = quote(source_path, safe="/")
|
|
41
|
+
if provider == "github":
|
|
42
|
+
return f"{base}/blob/{ref_part}/{path_part}"
|
|
43
|
+
if provider == "bitbucket":
|
|
44
|
+
return f"{base}/src/{ref_part}/{path_part}"
|
|
45
|
+
return f"{base}/-/blob/{ref_part}/{path_part}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def edit_url(repo_url: str, provider: str, ref: str, source_path: str) -> str:
|
|
49
|
+
base = _repository_base(repo_url)
|
|
50
|
+
ref_part = quote(ref, safe="/")
|
|
51
|
+
path_part = quote(source_path, safe="/")
|
|
52
|
+
if provider == "github":
|
|
53
|
+
return f"{base}/edit/{ref_part}/{path_part}"
|
|
54
|
+
if provider == "bitbucket":
|
|
55
|
+
return f"{base}/src/{ref_part}/{path_part}?mode=edit"
|
|
56
|
+
return f"{base}/-/edit/{ref_part}/{path_part}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def default_issue_url(repo_url: str, provider: str) -> str:
|
|
60
|
+
base = _repository_base(repo_url)
|
|
61
|
+
return f"{base}/-/issues/new" if provider == "gitlab" else f"{base}/issues/new"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Fenced code blocks and inline code spans show ``@apidocs`` literally; they are never rewritten.
|
|
65
|
+
_CODE = re.compile(
|
|
66
|
+
r"^(?P<fence>`{3,}|~{3,}).*?^(?P=fence)[ \t]*$|`[^`\n]+`", re.MULTILINE | re.DOTALL
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def api_symbol_public_path(symbol: str, version: str, mount: str) -> PurePosixPath:
|
|
71
|
+
return PurePosixPath(mount) / version / "_symbols" / PurePosixPath(*re.split(r"::|\.", symbol))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_api_references(
|
|
75
|
+
markdown: str,
|
|
76
|
+
*,
|
|
77
|
+
page_dest_uri: str,
|
|
78
|
+
mount: str,
|
|
79
|
+
default_version: str,
|
|
80
|
+
locate: Callable[[str, str], str | None] | None = None,
|
|
81
|
+
missing: list[tuple[str, str]] | None = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Resolve ``@apidocs`` names to links into the versioned API.
|
|
84
|
+
|
|
85
|
+
``locate(version, symbol)`` returns the entity's page below the version directory, such as
|
|
86
|
+
``api/demo/struct-settings/``; the link then points straight at it. Without ``locate``, or for
|
|
87
|
+
symbols it does not know, the link points at the stable ``_symbols`` alias, and unknown symbols
|
|
88
|
+
are appended to ``missing`` as ``(version, symbol)``.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
page_directory = PurePosixPath(page_dest_uri).parent
|
|
92
|
+
|
|
93
|
+
def href_to(target: PurePosixPath, anchor: str = "") -> str:
|
|
94
|
+
relative = posixpath.relpath(target.as_posix(), page_directory.as_posix())
|
|
95
|
+
return relative + "/" + (f"#{anchor}" if anchor else "")
|
|
96
|
+
|
|
97
|
+
def replace(match: re.Match[str]) -> str:
|
|
98
|
+
symbol = match.group("symbol")
|
|
99
|
+
version = match.group("version") or default_version
|
|
100
|
+
found = locate(version, symbol) if locate is not None else None
|
|
101
|
+
if found:
|
|
102
|
+
path, _, anchor = found.partition("#")
|
|
103
|
+
return f"[`{symbol}`]({href_to(PurePosixPath(mount) / version / path, anchor)})"
|
|
104
|
+
if locate is not None and missing is not None:
|
|
105
|
+
missing.append((version, symbol))
|
|
106
|
+
return f"[`{symbol}`]({href_to(api_symbol_public_path(symbol, version, mount))})"
|
|
107
|
+
|
|
108
|
+
parts: list[str] = []
|
|
109
|
+
position = 0
|
|
110
|
+
for code in _CODE.finditer(markdown):
|
|
111
|
+
parts.append(_APIDOCS_REFERENCE.sub(replace, markdown[position : code.start()]))
|
|
112
|
+
parts.append(code.group(0))
|
|
113
|
+
position = code.end()
|
|
114
|
+
parts.append(_APIDOCS_REFERENCE.sub(replace, markdown[position:]))
|
|
115
|
+
return "".join(parts)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_VERSIONS_DIRECTIVE = re.compile(r"^@apidocs-versions[ \t]*$", re.MULTILINE)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def expand_versions_directive(
|
|
122
|
+
markdown: str, *, page_dest_uri: str, mount: str, metadata: dict[str, object]
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Replace a line ``@apidocs-versions`` with a list of every published API version."""
|
|
125
|
+
|
|
126
|
+
if not _VERSIONS_DIRECTIVE.search(markdown):
|
|
127
|
+
return markdown
|
|
128
|
+
page_directory = PurePosixPath(page_dest_uri).parent
|
|
129
|
+
default = metadata.get("default")
|
|
130
|
+
aliases = metadata.get("aliases", {})
|
|
131
|
+
aliases = aliases if isinstance(aliases, dict) else {}
|
|
132
|
+
lines: list[str] = []
|
|
133
|
+
versions = metadata.get("versions", [])
|
|
134
|
+
for entry in versions if isinstance(versions, list) else []:
|
|
135
|
+
if not isinstance(entry, dict):
|
|
136
|
+
continue
|
|
137
|
+
name = str(entry.get("name"))
|
|
138
|
+
target = PurePosixPath(mount) / name
|
|
139
|
+
href = posixpath.relpath(target.as_posix(), page_directory.as_posix()) + "/"
|
|
140
|
+
notes = [str(entry.get("kind", ""))]
|
|
141
|
+
if name == default:
|
|
142
|
+
notes.append("default")
|
|
143
|
+
notes.extend(f"`{alias}`" for alias, value in sorted(aliases.items()) if value == name)
|
|
144
|
+
lines.append(f"- [{name}]({href}) — " + ", ".join(note for note in notes if note))
|
|
145
|
+
listing = "\n".join(lines) if lines else "No API version has been published."
|
|
146
|
+
return _VERSIONS_DIRECTIVE.sub(lambda _match: listing, markdown)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def publish_api(source: Path, site_directory: Path, mount: str) -> Path:
|
|
150
|
+
"""Replace the mounted API subtree with cpdocs's complete versioned output."""
|
|
151
|
+
|
|
152
|
+
destination = site_directory / Path(*PurePosixPath(mount).parts)
|
|
153
|
+
if destination.exists():
|
|
154
|
+
shutil.rmtree(destination)
|
|
155
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
shutil.copytree(source, destination)
|
|
157
|
+
(site_directory / ".nojekyll").touch()
|
|
158
|
+
return destination
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 cpdocs developers
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""ProperDocs plugin which builds the versioned cpdocs API site and mounts it."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
|
|
14
|
+
from properdocs.config import config_options as c
|
|
15
|
+
from properdocs.exceptions import PluginError
|
|
16
|
+
from properdocs.plugins import BasePlugin
|
|
17
|
+
|
|
18
|
+
from cpdocs.build import Layout, build_versions, recorded_default_version
|
|
19
|
+
from cpdocs.config import DEFAULT_CONFIG, CpdocsConfig, load_config
|
|
20
|
+
|
|
21
|
+
from .properdocs_core import (
|
|
22
|
+
default_issue_url,
|
|
23
|
+
edit_url,
|
|
24
|
+
expand_versions_directive,
|
|
25
|
+
publish_api,
|
|
26
|
+
repository_provider,
|
|
27
|
+
resolve_api_references,
|
|
28
|
+
source_url,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_LOG = logging.getLogger("properdocs.plugins.cpdocs")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _symbol_key(symbol: str) -> tuple[str, ...]:
|
|
35
|
+
return tuple(part for part in re.split(r"::|\.", symbol) if part)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CpdocsPlugin(BasePlugin):
|
|
39
|
+
"""Build cpdocs before ProperDocs and mount it into the final static site."""
|
|
40
|
+
|
|
41
|
+
config_scheme = (
|
|
42
|
+
("config", c.Type(str, default=str(DEFAULT_CONFIG))),
|
|
43
|
+
("output", c.Type(str, default="build")),
|
|
44
|
+
("mount", c.Type(str, default="reference/api")),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._project_root: Path | None = None
|
|
49
|
+
self._cpdocs: CpdocsConfig | None = None
|
|
50
|
+
self._last_fingerprint: tuple[tuple[str, int, int], ...] | None = None
|
|
51
|
+
self._serve_active = False
|
|
52
|
+
self._symbols: dict[str, dict[tuple[str, ...], str]] = {}
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def mount(self) -> str:
|
|
56
|
+
value = str(self.config.get("mount", "reference/api")).strip("/")
|
|
57
|
+
if not value:
|
|
58
|
+
raise PluginError("cpdocs: mount must not be the site root")
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def output(self) -> Path:
|
|
63
|
+
assert self._project_root is not None
|
|
64
|
+
path = Path(str(self.config.get("output", "build")))
|
|
65
|
+
return path if path.is_absolute() else self._project_root / path
|
|
66
|
+
|
|
67
|
+
def on_startup(self, *, command: str, dirty: bool = False) -> None:
|
|
68
|
+
del dirty
|
|
69
|
+
self._serve_active = command == "serve"
|
|
70
|
+
|
|
71
|
+
def _config_file(self, config) -> Path:
|
|
72
|
+
value = getattr(config, "config_file_path", None)
|
|
73
|
+
if value:
|
|
74
|
+
return Path(value).resolve()
|
|
75
|
+
return (Path.cwd() / "properdocs.yml").resolve()
|
|
76
|
+
|
|
77
|
+
def on_config(self, config):
|
|
78
|
+
self._project_root = self._config_file(config).parent
|
|
79
|
+
cpdocs_file = Path(str(self.config.get("config", DEFAULT_CONFIG)))
|
|
80
|
+
if not cpdocs_file.is_absolute():
|
|
81
|
+
cpdocs_file = self._project_root / cpdocs_file
|
|
82
|
+
try:
|
|
83
|
+
self._cpdocs = load_config(cpdocs_file)
|
|
84
|
+
except RuntimeError as error:
|
|
85
|
+
raise PluginError(f"cpdocs: {error}") from error
|
|
86
|
+
|
|
87
|
+
extra = dict(config.get("extra", {}) or {})
|
|
88
|
+
repo_url = str(config.get("repo_url") or "").strip()
|
|
89
|
+
if repo_url:
|
|
90
|
+
provider = repository_provider(repo_url, extra)
|
|
91
|
+
extra["cpdocs_repo_provider"] = provider
|
|
92
|
+
extra["cpdocs_source_ref"] = os.environ.get("CPDOCS_SOURCE_REF") or self._source_ref()
|
|
93
|
+
if not extra.get("cpdocs_issue_url"):
|
|
94
|
+
extra["cpdocs_issue_url"] = default_issue_url(repo_url, provider)
|
|
95
|
+
config["extra"] = extra
|
|
96
|
+
return config
|
|
97
|
+
|
|
98
|
+
def _git(self, *arguments: str) -> subprocess.CompletedProcess[str]:
|
|
99
|
+
assert self._project_root is not None
|
|
100
|
+
return subprocess.run(
|
|
101
|
+
["git", *arguments],
|
|
102
|
+
cwd=self._project_root,
|
|
103
|
+
check=False,
|
|
104
|
+
text=True,
|
|
105
|
+
stdout=subprocess.PIPE,
|
|
106
|
+
stderr=subprocess.DEVNULL,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _source_ref(self) -> str:
|
|
110
|
+
result = self._git("symbolic-ref", "--quiet", "--short", "HEAD")
|
|
111
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
112
|
+
return result.stdout.strip()
|
|
113
|
+
result = self._git("describe", "--tags", "--exact-match")
|
|
114
|
+
return result.stdout.strip() if result.returncode == 0 else "main"
|
|
115
|
+
|
|
116
|
+
def _watched_files(self) -> list[Path]:
|
|
117
|
+
"""Files whose change should trigger a rebuild during ``properdocs serve``."""
|
|
118
|
+
|
|
119
|
+
assert self._project_root is not None
|
|
120
|
+
result = self._git("ls-files", "--cached", "--others", "--exclude-standard")
|
|
121
|
+
if result.returncode == 0:
|
|
122
|
+
files = [self._project_root / line for line in result.stdout.splitlines() if line]
|
|
123
|
+
else:
|
|
124
|
+
files = [path for path in self._project_root.rglob("*") if path.is_file()]
|
|
125
|
+
output = self.output.resolve()
|
|
126
|
+
watched = [
|
|
127
|
+
path
|
|
128
|
+
for path in files
|
|
129
|
+
if output not in path.resolve().parents and ".git" not in path.parts
|
|
130
|
+
]
|
|
131
|
+
refs = self._project_root / ".git" / "refs"
|
|
132
|
+
if refs.is_dir():
|
|
133
|
+
watched.extend(path for path in refs.rglob("*") if path.is_file())
|
|
134
|
+
return watched
|
|
135
|
+
|
|
136
|
+
def _fingerprint(self) -> tuple[tuple[str, int, int], ...]:
|
|
137
|
+
values: list[tuple[str, int, int]] = []
|
|
138
|
+
for path in self._watched_files():
|
|
139
|
+
try:
|
|
140
|
+
stat = path.stat()
|
|
141
|
+
except OSError:
|
|
142
|
+
continue
|
|
143
|
+
values.append((str(path), stat.st_mtime_ns, stat.st_size))
|
|
144
|
+
return tuple(sorted(values))
|
|
145
|
+
|
|
146
|
+
def on_pre_build(self, *, config) -> None:
|
|
147
|
+
del config
|
|
148
|
+
assert self._cpdocs is not None
|
|
149
|
+
fingerprint = self._fingerprint()
|
|
150
|
+
versions = Layout.for_output(self.output).versions
|
|
151
|
+
if self._serve_active and fingerprint == self._last_fingerprint and versions.is_dir():
|
|
152
|
+
return
|
|
153
|
+
try:
|
|
154
|
+
build_versions(
|
|
155
|
+
self._cpdocs,
|
|
156
|
+
output=self.output,
|
|
157
|
+
project_docs_root_depth=len(PurePosixPath(self.mount).parts),
|
|
158
|
+
)
|
|
159
|
+
except RuntimeError as error:
|
|
160
|
+
raise PluginError(f"cpdocs: {error}") from error
|
|
161
|
+
self._last_fingerprint = fingerprint
|
|
162
|
+
self._symbols = {}
|
|
163
|
+
|
|
164
|
+
def _symbol_pages(self, version: str) -> dict[tuple[str, ...], str]:
|
|
165
|
+
if version not in self._symbols:
|
|
166
|
+
path = Layout.for_output(self.output).versions / version / "symbols.json"
|
|
167
|
+
try:
|
|
168
|
+
names = json.loads(path.read_text(encoding="utf-8"))
|
|
169
|
+
except (OSError, json.JSONDecodeError):
|
|
170
|
+
names = {}
|
|
171
|
+
self._symbols[version] = {
|
|
172
|
+
_symbol_key(name): str(target) for name, target in names.items()
|
|
173
|
+
}
|
|
174
|
+
return self._symbols[version]
|
|
175
|
+
|
|
176
|
+
def _locate(self, version: str, symbol: str) -> str | None:
|
|
177
|
+
return self._symbol_pages(version).get(_symbol_key(symbol))
|
|
178
|
+
|
|
179
|
+
def _versions_metadata(self) -> dict[str, object]:
|
|
180
|
+
path = Layout.for_output(self.output).versions / "versions.json"
|
|
181
|
+
try:
|
|
182
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
183
|
+
except (OSError, json.JSONDecodeError):
|
|
184
|
+
return {}
|
|
185
|
+
|
|
186
|
+
def on_page_markdown(self, markdown, page, config, files=None):
|
|
187
|
+
del config, files
|
|
188
|
+
assert self._cpdocs is not None
|
|
189
|
+
level = self._cpdocs.check_broken_references
|
|
190
|
+
missing: list[tuple[str, str]] = []
|
|
191
|
+
markdown = expand_versions_directive(
|
|
192
|
+
markdown,
|
|
193
|
+
page_dest_uri=page.file.dest_uri,
|
|
194
|
+
mount=self.mount,
|
|
195
|
+
metadata=self._versions_metadata(),
|
|
196
|
+
)
|
|
197
|
+
result = resolve_api_references(
|
|
198
|
+
markdown,
|
|
199
|
+
page_dest_uri=page.file.dest_uri,
|
|
200
|
+
mount=self.mount,
|
|
201
|
+
default_version=recorded_default_version(self.output),
|
|
202
|
+
locate=self._locate,
|
|
203
|
+
missing=missing,
|
|
204
|
+
)
|
|
205
|
+
if missing and level != "ignore":
|
|
206
|
+
listing = ", ".join(f"{symbol} ({version})" for version, symbol in missing)
|
|
207
|
+
message = f"cpdocs: {page.file.src_uri} references unknown API entities: {listing}"
|
|
208
|
+
if level == "fail":
|
|
209
|
+
raise PluginError(message)
|
|
210
|
+
_LOG.warning(message)
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
def on_page_context(self, context, page, config, nav=None):
|
|
214
|
+
del nav
|
|
215
|
+
assert self._project_root is not None
|
|
216
|
+
repo_url = str(config.get("repo_url") or "").strip()
|
|
217
|
+
extra = config.get("extra", {}) or {}
|
|
218
|
+
ref = extra.get("cpdocs_source_ref")
|
|
219
|
+
context["cpdocs_source_url"] = None
|
|
220
|
+
context["cpdocs_edit_url"] = None
|
|
221
|
+
if not repo_url or not ref:
|
|
222
|
+
return context
|
|
223
|
+
source = Path(page.file.abs_src_path).resolve()
|
|
224
|
+
try:
|
|
225
|
+
relative_source = source.relative_to(self._project_root).as_posix()
|
|
226
|
+
except ValueError:
|
|
227
|
+
return context
|
|
228
|
+
provider = repository_provider(repo_url, extra)
|
|
229
|
+
context["cpdocs_source_url"] = source_url(repo_url, provider, str(ref), relative_source)
|
|
230
|
+
context["cpdocs_edit_url"] = edit_url(repo_url, provider, str(ref), relative_source)
|
|
231
|
+
return context
|
|
232
|
+
|
|
233
|
+
def on_post_build(self, *, config) -> None:
|
|
234
|
+
publish_api(
|
|
235
|
+
Layout.for_output(self.output).versions, Path(config["site_dir"]).resolve(), self.mount
|
|
236
|
+
)
|