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.
@@ -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