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/python_backend.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 cpdocs developers
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Static Python API backend.
|
|
5
|
+
|
|
6
|
+
This backend deliberately avoids importing the documented package. It uses the standard AST as a
|
|
7
|
+
portable baseline; the language-neutral model means a Griffe-backed extractor can replace this
|
|
8
|
+
module later without changing rendering or versioning.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import inspect
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .model import ApiEntity, ApiGraph, ApiParameter, ApiSignature, SourceLocation, stable_entity_id
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _annotation(node: ast.expr | None) -> str:
|
|
21
|
+
if node is None:
|
|
22
|
+
return ""
|
|
23
|
+
try:
|
|
24
|
+
return ast.unparse(node)
|
|
25
|
+
except Exception:
|
|
26
|
+
return ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default(node: ast.expr | None) -> str | None:
|
|
30
|
+
if node is None:
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return ast.unparse(node)
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _signature(
|
|
39
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef, *, implicit_first: bool = False
|
|
40
|
+
) -> ApiSignature:
|
|
41
|
+
args = node.args
|
|
42
|
+
positional = [*args.posonlyargs, *args.args]
|
|
43
|
+
if implicit_first and positional and positional[0].arg in {"self", "cls"}:
|
|
44
|
+
positional = positional[1:]
|
|
45
|
+
defaults = [None] * (len(positional) - len(args.defaults)) + list(args.defaults)
|
|
46
|
+
parameters: list[ApiParameter] = []
|
|
47
|
+
posonly_count = len(args.posonlyargs)
|
|
48
|
+
for index, (argument, default) in enumerate(zip(positional, defaults, strict=True)):
|
|
49
|
+
parameters.append(
|
|
50
|
+
ApiParameter(
|
|
51
|
+
name=argument.arg,
|
|
52
|
+
type=_annotation(argument.annotation),
|
|
53
|
+
default=_default(default),
|
|
54
|
+
kind="positional-only" if index < posonly_count else "positional",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
if args.vararg is not None:
|
|
58
|
+
parameters.append(
|
|
59
|
+
ApiParameter(
|
|
60
|
+
name=args.vararg.arg,
|
|
61
|
+
type=_annotation(args.vararg.annotation),
|
|
62
|
+
kind="var-positional",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
for argument, default in zip(args.kwonlyargs, args.kw_defaults, strict=True):
|
|
66
|
+
parameters.append(
|
|
67
|
+
ApiParameter(
|
|
68
|
+
name=argument.arg,
|
|
69
|
+
type=_annotation(argument.annotation),
|
|
70
|
+
default=_default(default),
|
|
71
|
+
kind="keyword-only",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
if args.kwarg is not None:
|
|
75
|
+
parameters.append(
|
|
76
|
+
ApiParameter(
|
|
77
|
+
name=args.kwarg.arg,
|
|
78
|
+
type=_annotation(args.kwarg.annotation),
|
|
79
|
+
kind="var-keyword",
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
qualifiers: list[str] = []
|
|
83
|
+
if isinstance(node, ast.AsyncFunctionDef):
|
|
84
|
+
qualifiers.append("async")
|
|
85
|
+
for decorator in node.decorator_list:
|
|
86
|
+
text = _annotation(decorator)
|
|
87
|
+
if text in {"classmethod", "staticmethod", "property", "abstractmethod"}:
|
|
88
|
+
qualifiers.append(text)
|
|
89
|
+
elif text.endswith(".overload") or text == "overload":
|
|
90
|
+
qualifiers.append("overload")
|
|
91
|
+
return ApiSignature(
|
|
92
|
+
parameters=tuple(parameters),
|
|
93
|
+
returns=_annotation(node.returns),
|
|
94
|
+
qualifiers=tuple(dict.fromkeys(qualifiers)),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _module_info(root: Path, path: Path, package: str) -> tuple[str, str]:
|
|
99
|
+
"""Return the public module/package name and normalized entity kind for one source file."""
|
|
100
|
+
|
|
101
|
+
relative = path.relative_to(root)
|
|
102
|
+
parts = list(relative.with_suffix("").parts)
|
|
103
|
+
is_package = bool(parts and parts[-1] == "__init__")
|
|
104
|
+
if is_package:
|
|
105
|
+
parts.pop()
|
|
106
|
+
qualified = ".".join([package, *parts]) if parts else package
|
|
107
|
+
return qualified, "package" if is_package else "module"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parent_qualified_name(qualified: str) -> str | None:
|
|
111
|
+
return qualified.rsplit(".", 1)[0] if "." in qualified else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _public_names(tree: ast.Module) -> set[str] | None:
|
|
115
|
+
for node in tree.body:
|
|
116
|
+
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
117
|
+
continue
|
|
118
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
119
|
+
if not any(isinstance(target, ast.Name) and target.id == "__all__" for target in targets):
|
|
120
|
+
continue
|
|
121
|
+
value = node.value
|
|
122
|
+
if not isinstance(value, (ast.List, ast.Tuple, ast.Set)):
|
|
123
|
+
return None
|
|
124
|
+
names: set[str] = set()
|
|
125
|
+
for item in value.elts:
|
|
126
|
+
if isinstance(item, ast.Constant) and isinstance(item.value, str):
|
|
127
|
+
names.add(item.value)
|
|
128
|
+
return names
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _attribute_docstring(body: list[ast.stmt], index: int) -> str:
|
|
133
|
+
"""Docstring written as a string literal directly below an assignment (PEP 257)."""
|
|
134
|
+
|
|
135
|
+
if index + 1 >= len(body):
|
|
136
|
+
return ""
|
|
137
|
+
following = body[index + 1]
|
|
138
|
+
if (
|
|
139
|
+
isinstance(following, ast.Expr)
|
|
140
|
+
and isinstance(following.value, ast.Constant)
|
|
141
|
+
and isinstance(following.value.value, str)
|
|
142
|
+
):
|
|
143
|
+
return inspect.cleandoc(following.value.value)
|
|
144
|
+
return ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _walk_body(
|
|
148
|
+
body: list[ast.stmt],
|
|
149
|
+
*,
|
|
150
|
+
graph: ApiGraph,
|
|
151
|
+
module: str,
|
|
152
|
+
source_path: str,
|
|
153
|
+
parent: ApiEntity,
|
|
154
|
+
scopes: tuple[str, ...],
|
|
155
|
+
explicit_public: set[str] | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
for index, node in enumerate(body):
|
|
158
|
+
entity: ApiEntity | None = None
|
|
159
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
160
|
+
in_class = parent.kind in {"class", "protocol", "enum"}
|
|
161
|
+
if (
|
|
162
|
+
node.name.startswith("_")
|
|
163
|
+
and node.name != "__init__"
|
|
164
|
+
and (explicit_public is None or node.name not in explicit_public)
|
|
165
|
+
):
|
|
166
|
+
continue
|
|
167
|
+
qualified = ".".join((*scopes, node.name))
|
|
168
|
+
kind = (
|
|
169
|
+
"constructor"
|
|
170
|
+
if in_class and node.name == "__init__"
|
|
171
|
+
else ("method" if in_class else "function")
|
|
172
|
+
)
|
|
173
|
+
signature = _signature(node, implicit_first=in_class)
|
|
174
|
+
properties = list(signature.qualifiers)
|
|
175
|
+
if "property" in properties:
|
|
176
|
+
kind = "property"
|
|
177
|
+
entity = ApiEntity(
|
|
178
|
+
id=stable_entity_id("python", kind, qualified),
|
|
179
|
+
language="python",
|
|
180
|
+
kind=kind,
|
|
181
|
+
name=node.name,
|
|
182
|
+
qualified_name=qualified,
|
|
183
|
+
parent=parent.id,
|
|
184
|
+
signatures=[signature],
|
|
185
|
+
documentation=ast.get_docstring(node, clean=True) or "",
|
|
186
|
+
source=SourceLocation(source_path, node.lineno, node.col_offset + 1),
|
|
187
|
+
properties=properties,
|
|
188
|
+
)
|
|
189
|
+
graph.add(entity)
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if isinstance(node, ast.ClassDef):
|
|
193
|
+
if node.name.startswith("_") and (
|
|
194
|
+
explicit_public is None or node.name not in explicit_public
|
|
195
|
+
):
|
|
196
|
+
continue
|
|
197
|
+
qualified = ".".join((*scopes, node.name))
|
|
198
|
+
bases = [_annotation(base) for base in node.bases]
|
|
199
|
+
decorators = [_annotation(item) for item in node.decorator_list]
|
|
200
|
+
properties = [value for value in decorators if value]
|
|
201
|
+
if any(value.endswith("Protocol") or value == "Protocol" for value in bases):
|
|
202
|
+
kind = "protocol"
|
|
203
|
+
elif any(value.endswith("Enum") or value == "Enum" for value in bases):
|
|
204
|
+
kind = "enum"
|
|
205
|
+
else:
|
|
206
|
+
kind = "class"
|
|
207
|
+
entity = ApiEntity(
|
|
208
|
+
id=stable_entity_id("python", kind, qualified),
|
|
209
|
+
language="python",
|
|
210
|
+
kind=kind,
|
|
211
|
+
name=node.name,
|
|
212
|
+
qualified_name=qualified,
|
|
213
|
+
parent=parent.id,
|
|
214
|
+
documentation=ast.get_docstring(node, clean=True) or "",
|
|
215
|
+
source=SourceLocation(source_path, node.lineno, node.col_offset + 1),
|
|
216
|
+
properties=properties,
|
|
217
|
+
bases=[base for base in bases if base],
|
|
218
|
+
)
|
|
219
|
+
graph.add(entity)
|
|
220
|
+
_walk_body(
|
|
221
|
+
node.body,
|
|
222
|
+
graph=graph,
|
|
223
|
+
module=module,
|
|
224
|
+
source_path=source_path,
|
|
225
|
+
parent=graph.entities[entity.id],
|
|
226
|
+
scopes=(*scopes, node.name),
|
|
227
|
+
)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
if parent.kind == "enum" and isinstance(node, ast.Assign):
|
|
231
|
+
for target in node.targets:
|
|
232
|
+
if not isinstance(target, ast.Name) or target.id.startswith("_"):
|
|
233
|
+
continue
|
|
234
|
+
name = target.id
|
|
235
|
+
qualified = ".".join((*scopes, name))
|
|
236
|
+
graph.add(
|
|
237
|
+
ApiEntity(
|
|
238
|
+
id=stable_entity_id("python", "constant", qualified),
|
|
239
|
+
language="python",
|
|
240
|
+
kind="constant",
|
|
241
|
+
name=name,
|
|
242
|
+
qualified_name=qualified,
|
|
243
|
+
parent=parent.id,
|
|
244
|
+
source=SourceLocation(source_path, node.lineno, node.col_offset + 1),
|
|
245
|
+
documentation=_attribute_docstring(body, index),
|
|
246
|
+
properties=[_default(node.value) or ""],
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# PEP 695 ``type Alias = ...`` (Python 3.12+) is represented explicitly in the AST.
|
|
252
|
+
type_alias_cls = getattr(ast, "TypeAlias", None)
|
|
253
|
+
if type_alias_cls is not None and isinstance(node, type_alias_cls):
|
|
254
|
+
name_node = getattr(node, "name", None)
|
|
255
|
+
name = getattr(name_node, "id", None)
|
|
256
|
+
if isinstance(name, str) and not name.startswith("_"):
|
|
257
|
+
qualified = ".".join((*scopes, name))
|
|
258
|
+
target = _annotation(getattr(node, "value", None))
|
|
259
|
+
graph.add(
|
|
260
|
+
ApiEntity(
|
|
261
|
+
id=stable_entity_id("python", "type_alias", qualified),
|
|
262
|
+
language="python",
|
|
263
|
+
kind="type_alias",
|
|
264
|
+
name=name,
|
|
265
|
+
qualified_name=qualified,
|
|
266
|
+
parent=parent.id,
|
|
267
|
+
source=SourceLocation(source_path, node.lineno, node.col_offset + 1),
|
|
268
|
+
documentation=_attribute_docstring(body, index),
|
|
269
|
+
properties=[f"= {target}"] if target else [],
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
if isinstance(node, ast.Assign):
|
|
275
|
+
for target in node.targets:
|
|
276
|
+
if not isinstance(target, ast.Name):
|
|
277
|
+
continue
|
|
278
|
+
name = target.id
|
|
279
|
+
if name.startswith("_") and (
|
|
280
|
+
explicit_public is None or name not in explicit_public
|
|
281
|
+
):
|
|
282
|
+
continue
|
|
283
|
+
# Ordinary module/class assignments are useful API constants when explicitly public.
|
|
284
|
+
# Avoid documenting imports/cache helpers merely because they are assigned.
|
|
285
|
+
if not (
|
|
286
|
+
name.isupper() or (explicit_public is not None and name in explicit_public)
|
|
287
|
+
):
|
|
288
|
+
continue
|
|
289
|
+
qualified = ".".join((*scopes, name))
|
|
290
|
+
value = _default(node.value)
|
|
291
|
+
graph.add(
|
|
292
|
+
ApiEntity(
|
|
293
|
+
id=stable_entity_id("python", "constant", qualified),
|
|
294
|
+
language="python",
|
|
295
|
+
kind="constant",
|
|
296
|
+
name=name,
|
|
297
|
+
qualified_name=qualified,
|
|
298
|
+
parent=parent.id,
|
|
299
|
+
source=SourceLocation(source_path, node.lineno, node.col_offset + 1),
|
|
300
|
+
documentation=_attribute_docstring(body, index),
|
|
301
|
+
properties=[f"= {value}"] if value else [],
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
307
|
+
name = node.target.id
|
|
308
|
+
if name.startswith("_") and (explicit_public is None or name not in explicit_public):
|
|
309
|
+
continue
|
|
310
|
+
qualified = ".".join((*scopes, name))
|
|
311
|
+
annotation = _annotation(node.annotation)
|
|
312
|
+
is_alias = annotation in {
|
|
313
|
+
"TypeAlias",
|
|
314
|
+
"typing.TypeAlias",
|
|
315
|
+
"typing_extensions.TypeAlias",
|
|
316
|
+
}
|
|
317
|
+
kind = "type_alias" if is_alias else ("constant" if name.isupper() else "attribute")
|
|
318
|
+
properties: list[str] = []
|
|
319
|
+
if not is_alias and annotation:
|
|
320
|
+
properties.append(annotation)
|
|
321
|
+
value = _default(node.value)
|
|
322
|
+
if value is not None:
|
|
323
|
+
properties.append(f"= {value}")
|
|
324
|
+
entity = ApiEntity(
|
|
325
|
+
id=stable_entity_id("python", kind, qualified),
|
|
326
|
+
language="python",
|
|
327
|
+
kind=kind,
|
|
328
|
+
name=name,
|
|
329
|
+
qualified_name=qualified,
|
|
330
|
+
parent=parent.id,
|
|
331
|
+
documentation=_attribute_docstring(body, index),
|
|
332
|
+
source=SourceLocation(source_path, node.lineno, node.col_offset + 1),
|
|
333
|
+
properties=properties,
|
|
334
|
+
)
|
|
335
|
+
graph.add(entity)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def build_python_graph(
|
|
339
|
+
*,
|
|
340
|
+
project_root: Path,
|
|
341
|
+
package_root: Path,
|
|
342
|
+
package: str,
|
|
343
|
+
version: str = "main",
|
|
344
|
+
include_private: bool = False,
|
|
345
|
+
) -> ApiGraph:
|
|
346
|
+
"""Build a static Python API graph without importing the documented package."""
|
|
347
|
+
|
|
348
|
+
project_root = project_root.resolve()
|
|
349
|
+
package_root = package_root.resolve()
|
|
350
|
+
graph = ApiGraph(project=package, version=version, language="python")
|
|
351
|
+
try:
|
|
352
|
+
source_root = package_root.parent.resolve().relative_to(project_root.resolve()).as_posix()
|
|
353
|
+
except (OSError, ValueError):
|
|
354
|
+
source_root = ""
|
|
355
|
+
if source_root not in {"", "."}:
|
|
356
|
+
graph.metadata["source_root"] = source_root
|
|
357
|
+
|
|
358
|
+
package_entity = ApiEntity(
|
|
359
|
+
id=stable_entity_id("python", "package", package),
|
|
360
|
+
language="python",
|
|
361
|
+
kind="package",
|
|
362
|
+
name=package.rsplit(".", 1)[-1],
|
|
363
|
+
qualified_name=package,
|
|
364
|
+
)
|
|
365
|
+
graph.add(package_entity)
|
|
366
|
+
|
|
367
|
+
def public_source(path: Path) -> bool:
|
|
368
|
+
parts = path.relative_to(package_root).parts
|
|
369
|
+
if any(part.startswith(".") or part == "__pycache__" for part in parts):
|
|
370
|
+
return False
|
|
371
|
+
if include_private:
|
|
372
|
+
return True
|
|
373
|
+
# ``__init__`` defines a package's public surface. Other underscore-prefixed modules are
|
|
374
|
+
# implementation details unless ``api.hide.python-modules: none`` opts them in.
|
|
375
|
+
return path.name == "__init__.py" or (
|
|
376
|
+
not path.stem.startswith("_") and all(not part.startswith("_") for part in parts[:-1])
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
sources = sorted(
|
|
380
|
+
{
|
|
381
|
+
path
|
|
382
|
+
for suffix in ("*.py", "*.pyi")
|
|
383
|
+
for path in package_root.rglob(suffix)
|
|
384
|
+
if public_source(path)
|
|
385
|
+
},
|
|
386
|
+
key=lambda path: (path.relative_to(package_root).as_posix(), path.suffix != ".py"),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
parsed: list[tuple[Path, ast.Module, str, str, str]] = []
|
|
390
|
+
# Create the module/package hierarchy first so children can always refer to a semantic parent.
|
|
391
|
+
for path in sources:
|
|
392
|
+
try:
|
|
393
|
+
tree = ast.parse(
|
|
394
|
+
path.read_text(encoding="utf-8"), filename=str(path), type_comments=True
|
|
395
|
+
)
|
|
396
|
+
except (OSError, UnicodeDecodeError, SyntaxError) as error:
|
|
397
|
+
raise RuntimeError(f"Cannot parse Python API source {path}: {error}") from error
|
|
398
|
+
qualified, kind = _module_info(package_root, path, package)
|
|
399
|
+
try:
|
|
400
|
+
source_path = path.relative_to(project_root).as_posix()
|
|
401
|
+
except ValueError:
|
|
402
|
+
source_path = path.as_posix()
|
|
403
|
+
parsed.append((path, tree, qualified, kind, source_path))
|
|
404
|
+
try:
|
|
405
|
+
graph.sources.setdefault(source_path, path.read_text(encoding="utf-8"))
|
|
406
|
+
except (OSError, UnicodeDecodeError):
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
if qualified == package:
|
|
410
|
+
root = graph.entities[package_entity.id]
|
|
411
|
+
if not root.documentation:
|
|
412
|
+
root.documentation = ast.get_docstring(tree, clean=True) or ""
|
|
413
|
+
if root.source is None:
|
|
414
|
+
root.source = SourceLocation(source_path, 1, 1)
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
parent_name = _parent_qualified_name(qualified) or package
|
|
418
|
+
parent_id = stable_entity_id(
|
|
419
|
+
"python",
|
|
420
|
+
"package"
|
|
421
|
+
if (package_root / Path(*parent_name.split(".")[1:]) / "__init__.py").is_file()
|
|
422
|
+
else "module",
|
|
423
|
+
parent_name,
|
|
424
|
+
)
|
|
425
|
+
if parent_id not in graph.entities:
|
|
426
|
+
# Namespace-package directories need no __init__.py. Represent the missing container as a
|
|
427
|
+
# package so the API tree remains hierarchical without importing it.
|
|
428
|
+
parent_id = stable_entity_id("python", "package", parent_name)
|
|
429
|
+
graph.add(
|
|
430
|
+
ApiEntity(
|
|
431
|
+
id=parent_id,
|
|
432
|
+
language="python",
|
|
433
|
+
kind="package",
|
|
434
|
+
name=parent_name.rsplit(".", 1)[-1],
|
|
435
|
+
qualified_name=parent_name,
|
|
436
|
+
parent=package_entity.id if parent_name != package else None,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
entity_id = stable_entity_id("python", kind, qualified)
|
|
440
|
+
graph.add(
|
|
441
|
+
ApiEntity(
|
|
442
|
+
id=entity_id,
|
|
443
|
+
language="python",
|
|
444
|
+
kind=kind,
|
|
445
|
+
name=qualified.rsplit(".", 1)[-1],
|
|
446
|
+
qualified_name=qualified,
|
|
447
|
+
parent=parent_id,
|
|
448
|
+
documentation=ast.get_docstring(tree, clean=True) or "",
|
|
449
|
+
source=SourceLocation(source_path, 1, 1),
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
for _path, tree, qualified, kind, source_path in parsed:
|
|
454
|
+
if qualified == package:
|
|
455
|
+
parent = graph.entities[package_entity.id]
|
|
456
|
+
else:
|
|
457
|
+
parent = graph.entities[stable_entity_id("python", kind, qualified)]
|
|
458
|
+
_walk_body(
|
|
459
|
+
tree.body,
|
|
460
|
+
graph=graph,
|
|
461
|
+
module=qualified,
|
|
462
|
+
source_path=source_path,
|
|
463
|
+
parent=parent,
|
|
464
|
+
scopes=tuple(qualified.split(".")),
|
|
465
|
+
explicit_public=_public_names(tree),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
graph.rebuild_children()
|
|
469
|
+
return graph
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 cpdocs developers
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""``cpdocs manifest python``: the contract answered for a plain ``pyproject.toml`` project.
|
|
4
|
+
|
|
5
|
+
A Python package built with a standard backend (hatchling, setuptools, ...) has nothing to
|
|
6
|
+
configure before its API can be read. This helper therefore plays the role of the build system in
|
|
7
|
+
the cpdocs contract: it answers the describe request with a single primary ``default`` variant, and
|
|
8
|
+
answers that variant's request with one Python unit.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import tomllib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .contract import CONTRACT_VERSION, Request, read_request
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _project(source: Path) -> dict[str, object]:
|
|
21
|
+
path = source / "pyproject.toml"
|
|
22
|
+
if not path.is_file():
|
|
23
|
+
raise RuntimeError(f"cpdocs manifest python: {path} does not exist")
|
|
24
|
+
project = tomllib.loads(path.read_text(encoding="utf-8")).get("project", {})
|
|
25
|
+
if not isinstance(project, dict) or not project.get("name"):
|
|
26
|
+
raise RuntimeError(f"cpdocs manifest python: {path} has no [project] name")
|
|
27
|
+
return project
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _describe(request: Request, *, package: str, package_source: str) -> dict[str, object]:
|
|
31
|
+
project = _project(request.source)
|
|
32
|
+
command = [
|
|
33
|
+
"{python}",
|
|
34
|
+
"-m",
|
|
35
|
+
"cpdocs",
|
|
36
|
+
"manifest",
|
|
37
|
+
"python",
|
|
38
|
+
"--request",
|
|
39
|
+
"{request}",
|
|
40
|
+
"--package",
|
|
41
|
+
package,
|
|
42
|
+
"--source",
|
|
43
|
+
package_source,
|
|
44
|
+
]
|
|
45
|
+
return {
|
|
46
|
+
"cpdocs-manifest": CONTRACT_VERSION,
|
|
47
|
+
"kind": "describe",
|
|
48
|
+
"generator": "cpdocs manifest python",
|
|
49
|
+
"project": {"name": str(project["name"]), "version": str(project.get("version") or "")},
|
|
50
|
+
"features": {},
|
|
51
|
+
"units": [{"id": package, "language": "python"}],
|
|
52
|
+
"variants": [{"name": "default", "primary": True, "features": {}, "commands": [command]}],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _variant(request: Request, *, package: str, package_source: str) -> dict[str, object]:
|
|
57
|
+
root = request.source / package_source
|
|
58
|
+
if not root.is_dir():
|
|
59
|
+
raise RuntimeError(f"cpdocs manifest python: package source {root} does not exist")
|
|
60
|
+
return {
|
|
61
|
+
"cpdocs-manifest": CONTRACT_VERSION,
|
|
62
|
+
"kind": "variant",
|
|
63
|
+
"variant": request.variant,
|
|
64
|
+
"features": {},
|
|
65
|
+
"units": [
|
|
66
|
+
{
|
|
67
|
+
"id": package,
|
|
68
|
+
"language": "python",
|
|
69
|
+
"package": package,
|
|
70
|
+
"api": [{"path": package_source}],
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write_manifest(request_file: Path, *, package: str, package_source: str) -> Path:
|
|
77
|
+
request = read_request(request_file)
|
|
78
|
+
if request.phase == "describe":
|
|
79
|
+
manifest = _describe(request, package=package, package_source=package_source)
|
|
80
|
+
else:
|
|
81
|
+
manifest = _variant(request, package=package, package_source=package_source)
|
|
82
|
+
request.manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
request.manifest.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
84
|
+
return request.manifest
|