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 ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Language-neutral semantic API documentation generator."""
4
+
5
+ __version__ = "0.1.0"
cpdocs/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
cpdocs/build.py ADDED
@@ -0,0 +1,645 @@
1
+ # SPDX-FileCopyrightText: 2026 cpdocs developers
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Build operations: describe, resolve variants, extract, merge and render.
4
+
5
+ Output layout below ``<output>/cpdocs``::
6
+
7
+ current/ API site of the working tree (``cpdocs build``)
8
+ versions/ versioned API site (``cpdocs versions``), one directory per ref
9
+ work/<ref>/ requests, manifests and build directories handed to the build system
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import fnmatch
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ from importlib import resources
23
+ from pathlib import Path, PurePosixPath
24
+
25
+ from .clang_backend import extract_clang_graph
26
+ from .config import CpdocsConfig, load_config
27
+ from .contract import (
28
+ ContractError,
29
+ Description,
30
+ Request,
31
+ UnitManifest,
32
+ VariantManifest,
33
+ check_variant,
34
+ read_description,
35
+ read_variant_manifest,
36
+ )
37
+ from .html_renderer import render_html_site
38
+ from .model import ApiGraph, merge_graphs
39
+ from .python_backend import build_python_graph
40
+ from .rust_backend import build_rust_graph
41
+ from .versioning import SelectedRef, latest_tag, select_refs
42
+
43
+ _PLACEHOLDERS = ("source", "build", "request", "manifest", "python", "cpdocs.cmake")
44
+ _CONTAINER_KINDS = {"namespace", "package", "module"}
45
+
46
+
47
+ @dataclasses.dataclass(frozen=True)
48
+ class Layout:
49
+ """Directories below ``<output>/cpdocs``."""
50
+
51
+ root: Path
52
+
53
+ @classmethod
54
+ def for_output(cls, output: Path) -> Layout:
55
+ return cls(root=(output / "cpdocs").resolve())
56
+
57
+ @property
58
+ def current(self) -> Path:
59
+ return self.root / "current"
60
+
61
+ @property
62
+ def versions(self) -> Path:
63
+ return self.root / "versions"
64
+
65
+ def work(self, ref: str) -> Path:
66
+ return self.root / "work" / ref.replace("/", "__")
67
+
68
+
69
+ def cmake_module() -> Path:
70
+ """Path of the CMake adapter shipped with cpdocs."""
71
+
72
+ return Path(str(resources.files("cpdocs") / "share" / "cmake" / "cpdocs.cmake"))
73
+
74
+
75
+ # --------------------------------------------------------------------------------------------------
76
+ # Talking to the build system
77
+ # --------------------------------------------------------------------------------------------------
78
+
79
+
80
+ def _expand(argument: str, request: Request, request_file: Path) -> str:
81
+ values = {
82
+ "source": str(request.source),
83
+ "build": str(request.build),
84
+ "request": str(request_file),
85
+ "manifest": str(request.manifest),
86
+ "python": sys.executable,
87
+ "cpdocs.cmake": str(cmake_module()),
88
+ }
89
+ for name in _PLACEHOLDERS:
90
+ argument = argument.replace("{" + name + "}", values[name])
91
+ return argument
92
+
93
+
94
+ def _run_commands(
95
+ commands: tuple[tuple[str, ...], ...], *, request: Request, request_file: Path, log: Path
96
+ ) -> None:
97
+ environment = dict(os.environ)
98
+ environment["CPDOCS_REQUEST"] = str(request_file)
99
+ environment["CPDOCS_MANIFEST"] = str(request.manifest)
100
+ with log.open("w", encoding="utf-8") as stream:
101
+ for command in commands:
102
+ argv = [_expand(argument, request, request_file) for argument in command]
103
+ stream.write("$ " + " ".join(argv) + "\n")
104
+ stream.flush()
105
+ try:
106
+ result = subprocess.run(
107
+ argv,
108
+ cwd=request.source,
109
+ env=environment,
110
+ check=False,
111
+ text=True,
112
+ stdout=subprocess.PIPE,
113
+ stderr=subprocess.STDOUT,
114
+ )
115
+ except FileNotFoundError as error:
116
+ raise RuntimeError(
117
+ f"cannot run build-system command {argv[0]!r}: {error}"
118
+ ) from error
119
+ stream.write(result.stdout)
120
+ if result.returncode != 0:
121
+ raise RuntimeError(
122
+ f"build-system command failed ({request.phase}"
123
+ + (f", variant {request.variant}" if request.variant else "")
124
+ + "):\n "
125
+ + " ".join(argv)
126
+ + "\n\n"
127
+ + result.stdout.rstrip()
128
+ )
129
+
130
+
131
+ def describe(
132
+ commands: tuple[tuple[str, ...], ...], *, source: Path, work: Path, ref: str, ref_kind: str
133
+ ) -> Description:
134
+ """Run the describe phase and read the describe manifest."""
135
+
136
+ directory = work / "describe"
137
+ shutil.rmtree(directory, ignore_errors=True)
138
+ directory.mkdir(parents=True)
139
+ request = Request(
140
+ phase="describe",
141
+ ref=ref,
142
+ ref_kind=ref_kind,
143
+ source=source.resolve(),
144
+ build=directory / "build",
145
+ manifest=directory / "manifest.json",
146
+ variant=None,
147
+ )
148
+ request_file = directory / "request.json"
149
+ request.write(request_file)
150
+ _run_commands(commands, request=request, request_file=request_file, log=directory / "log.txt")
151
+ return read_description(request.manifest)
152
+
153
+
154
+ def resolve_variant(
155
+ description: Description, name: str, *, source: Path, work: Path, ref: str, ref_kind: str
156
+ ) -> VariantManifest:
157
+ """Run one variant's commands and read its variant manifest."""
158
+
159
+ directory = work / "variants" / name
160
+ shutil.rmtree(directory, ignore_errors=True)
161
+ directory.mkdir(parents=True)
162
+ request = Request(
163
+ phase="variant",
164
+ ref=ref,
165
+ ref_kind=ref_kind,
166
+ source=source.resolve(),
167
+ build=directory / "build",
168
+ manifest=directory / "manifest.json",
169
+ variant=name,
170
+ )
171
+ request_file = directory / "request.json"
172
+ request.write(request_file)
173
+ _run_commands(
174
+ description.variant(name).commands,
175
+ request=request,
176
+ request_file=request_file,
177
+ log=directory / "log.txt",
178
+ )
179
+ manifest = read_variant_manifest(request.manifest, source=request.source)
180
+ check_variant(manifest, description=description, requested=name, path=request.manifest)
181
+ return manifest
182
+
183
+
184
+ # --------------------------------------------------------------------------------------------------
185
+ # Extraction
186
+ # --------------------------------------------------------------------------------------------------
187
+
188
+
189
+ def _clang() -> str:
190
+ for candidate in (os.environ.get("CPDOCS_CLANG"), "clang++", "clang"):
191
+ if candidate and shutil.which(candidate):
192
+ return candidate
193
+ raise RuntimeError(
194
+ "cpdocs needs clang to document C and C++ units; put clang++ on PATH "
195
+ "(or name it in CPDOCS_CLANG)"
196
+ )
197
+
198
+
199
+ def _generated_sources(unit: UnitManifest, graph: ApiGraph) -> list[str]:
200
+ values: list[str] = []
201
+ for root in unit.api:
202
+ if not root.generated:
203
+ continue
204
+ for path in root.path.rglob("*"):
205
+ if path.is_file():
206
+ logical = path.relative_to(root.path).as_posix()
207
+ if logical in graph.sources:
208
+ values.append(logical)
209
+ return values
210
+
211
+
212
+ def extract_unit(
213
+ unit: UnitManifest,
214
+ *,
215
+ project: str,
216
+ version: str,
217
+ project_root: Path,
218
+ variant: str,
219
+ work: Path,
220
+ config: CpdocsConfig,
221
+ ) -> ApiGraph:
222
+ if unit.language in {"c", "cpp"}:
223
+ assert unit.parse is not None
224
+ graph = extract_clang_graph(
225
+ project=project,
226
+ version=version,
227
+ project_root=project_root,
228
+ language=unit.language,
229
+ api_roots=[root.path for root in unit.api],
230
+ parse=unit.parse,
231
+ variant=variant,
232
+ clang_executable=_clang(),
233
+ work_directory=work / "clang" / unit.id,
234
+ )
235
+ generated = _generated_sources(unit, graph)
236
+ if generated:
237
+ graph.metadata["generated_sources"] = generated
238
+ return graph
239
+ if unit.language == "python":
240
+ assert unit.package is not None
241
+ return build_python_graph(
242
+ project_root=project_root,
243
+ package_root=unit.api[0].path,
244
+ package=unit.package,
245
+ version=version,
246
+ include_private=not config.hide_private_python,
247
+ )
248
+ if unit.language == "rust":
249
+ assert unit.rustdoc_json is not None
250
+ return build_rust_graph(
251
+ rustdoc_json=unit.rustdoc_json,
252
+ project=project,
253
+ version=version,
254
+ project_root=project_root,
255
+ )
256
+ raise ContractError(f"unsupported unit language {unit.language!r}")
257
+
258
+
259
+ def _merge_metadata(target: dict[str, object], source: dict[str, object]) -> None:
260
+ for key, value in source.items():
261
+ if isinstance(value, list):
262
+ existing = target.setdefault(key, [])
263
+ assert isinstance(existing, list)
264
+ existing.extend(item for item in value if item not in existing)
265
+ else:
266
+ target.setdefault(key, value)
267
+
268
+
269
+ def _combine(graphs: list[ApiGraph], *, project: str, version: str, language: str) -> ApiGraph:
270
+ """Combine the graphs of several units extracted for the same variant."""
271
+
272
+ combined = ApiGraph(project=project, version=version, language=language)
273
+ for graph in graphs:
274
+ for entity in graph.entities.values():
275
+ combined.add(entity)
276
+ for path, content in graph.sources.items():
277
+ combined.sources.setdefault(path, content)
278
+ _merge_metadata(combined.metadata, graph.metadata)
279
+ combined.rebuild_children()
280
+ return combined
281
+
282
+
283
+ def _apply_visibility(graph: ApiGraph, config: CpdocsConfig) -> None:
284
+ patterns = config.hide_cpp_namespaces
285
+ if not patterns:
286
+ return
287
+ hidden = {
288
+ entity.id
289
+ for entity in graph.entities.values()
290
+ if entity.language in {"c", "cpp"}
291
+ and entity.kind == "namespace"
292
+ and any(fnmatch.fnmatchcase(entity.qualified_name, pattern) for pattern in patterns)
293
+ }
294
+ changed = True
295
+ while changed:
296
+ changed = False
297
+ for entity in graph.entities.values():
298
+ if entity.id not in hidden and entity.parent in hidden:
299
+ hidden.add(entity.id)
300
+ changed = True
301
+ for identifier in hidden:
302
+ del graph.entities[identifier]
303
+ graph.rebuild_children()
304
+
305
+
306
+ def _check_undocumented(graph: ApiGraph, level: str) -> None:
307
+ if level == "ignore":
308
+ return
309
+ missing = sorted(
310
+ entity.qualified_name
311
+ for entity in graph.entities.values()
312
+ if entity.kind not in _CONTAINER_KINDS and not entity.documentation.strip()
313
+ )
314
+ if not missing:
315
+ return
316
+ message = f"{len(missing)} public API entities have no documentation:\n" + "\n".join(
317
+ f" - {name}" for name in missing
318
+ )
319
+ if level == "fail":
320
+ raise RuntimeError(message)
321
+ print(f"cpdocs: warning: {message}", file=sys.stderr)
322
+
323
+
324
+ def build_graph(
325
+ config: CpdocsConfig,
326
+ *,
327
+ source: Path,
328
+ work: Path,
329
+ ref: str,
330
+ ref_kind: str,
331
+ version: str,
332
+ variants: str,
333
+ ) -> ApiGraph:
334
+ """Describe, resolve the selected variants, and merge them into one API graph."""
335
+
336
+ description = describe(config.describe, source=source, work=work, ref=ref, ref_kind=ref_kind)
337
+ selected = (
338
+ [variant.name for variant in description.variants]
339
+ if variants == "all"
340
+ else [description.primary.name]
341
+ )
342
+ languages = sorted({unit.language for unit in description.units})
343
+ language = languages[0] if len(languages) == 1 else "mixed"
344
+
345
+ per_variant: list[tuple[str, ApiGraph]] = []
346
+ metadata: dict[str, object] = {}
347
+ for name in selected:
348
+ manifest = resolve_variant(
349
+ description, name, source=source, work=work, ref=ref, ref_kind=ref_kind
350
+ )
351
+ unit_graphs = [
352
+ extract_unit(
353
+ unit,
354
+ project=description.project,
355
+ version=version,
356
+ project_root=source,
357
+ variant=name,
358
+ work=work / "variants" / name,
359
+ config=config,
360
+ )
361
+ for unit in manifest.units
362
+ ]
363
+ graph = _combine(
364
+ unit_graphs, project=description.project, version=version, language=language
365
+ )
366
+ graph.variants[name] = {
367
+ "primary": description.variant(name).primary,
368
+ "features": {axis: list(values) for axis, values in manifest.features.items()},
369
+ }
370
+ _merge_metadata(metadata, graph.metadata)
371
+ per_variant.append((name, graph))
372
+
373
+ merged = merge_graphs(
374
+ per_variant, project=description.project, version=version, language=language
375
+ )
376
+ metadata["features"] = {axis.name: list(axis.values) for axis in description.features}
377
+ metadata["units"] = [{"id": unit.id, "language": unit.language} for unit in description.units]
378
+ metadata["generator"] = description.generator
379
+ metadata["project_version"] = description.version
380
+ merged.metadata = metadata
381
+
382
+ _apply_visibility(merged, config)
383
+ _check_undocumented(merged, config.check_undocumented)
384
+ return merged
385
+
386
+
387
+ # --------------------------------------------------------------------------------------------------
388
+ # Rendering
389
+ # --------------------------------------------------------------------------------------------------
390
+
391
+
392
+ def _graph_json(graph: ApiGraph) -> str:
393
+ return json.dumps(dataclasses.asdict(graph), indent=2, sort_keys=True, default=str) + "\n"
394
+
395
+
396
+ def _project_docs_url(graph: ApiGraph, configured: str, *, root_depth: int) -> str:
397
+ if configured != "auto":
398
+ return configured
399
+ version_depth = max(1, len(PurePosixPath(graph.version).parts))
400
+ return "../" * (root_depth + version_depth)
401
+
402
+
403
+ def _render(
404
+ graph: ApiGraph, config: CpdocsConfig, *, output: Path, work: Path, root_depth: int
405
+ ) -> None:
406
+ work.mkdir(parents=True, exist_ok=True)
407
+ render_html_site(
408
+ graph,
409
+ output_directory=output,
410
+ project_docs_url=_project_docs_url(graph, config.project_docs, root_depth=root_depth),
411
+ copyright_text=config.copyright or f"Copyright © {graph.project} developers",
412
+ introduction_file=config.index,
413
+ )
414
+ (work / "api-graph.json").write_text(_graph_json(graph), encoding="utf-8")
415
+
416
+
417
+ def build_current(
418
+ config: CpdocsConfig,
419
+ *,
420
+ output: Path,
421
+ version: str = "main",
422
+ variants: str = "all",
423
+ project_docs_root_depth: int = 2,
424
+ ) -> ApiGraph:
425
+ """Document the working tree into ``<output>/cpdocs/current``."""
426
+
427
+ layout = Layout.for_output(output)
428
+ work = layout.work("current")
429
+ graph = build_graph(
430
+ config,
431
+ source=config.project_root,
432
+ work=work,
433
+ ref=version,
434
+ ref_kind="worktree",
435
+ version=version,
436
+ variants=variants,
437
+ )
438
+ _render(
439
+ graph,
440
+ config,
441
+ output=layout.current,
442
+ work=work / "render",
443
+ root_depth=project_docs_root_depth,
444
+ )
445
+ return graph
446
+
447
+
448
+ def _html_pages(root: Path) -> list[str]:
449
+ if not root.is_dir():
450
+ return []
451
+ return sorted(path.relative_to(root).as_posix() for path in root.rglob("*.html"))
452
+
453
+
454
+ def _redirect_page(target: str) -> str:
455
+ return (
456
+ '<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
457
+ f'<meta http-equiv="refresh" content="0; url={target}">'
458
+ f'<link rel="canonical" href="{target}"></head>'
459
+ f'<body><a href="{target}">{target}</a></body></html>\n'
460
+ )
461
+
462
+
463
+ def _write_versions(
464
+ output: Path, built: list[SelectedRef], *, default: str, aliases: dict[str, str]
465
+ ) -> None:
466
+ names = [ref.name for ref in built]
467
+ newest = latest_tag([ref.name for ref in built if ref.kind == "tag"])
468
+
469
+ def target(value: str) -> str | None:
470
+ if value == "latest-tag":
471
+ return newest
472
+ return value if value in names else None
473
+
474
+ default_name = target(default) or (names[0] if names else "main")
475
+ resolved_aliases: dict[str, str] = {}
476
+ for alias, value in aliases.items():
477
+ resolved = target(value)
478
+ if resolved is None or alias in names:
479
+ continue
480
+ resolved_aliases[alias] = resolved
481
+ directory = output / alias
482
+ directory.mkdir(parents=True, exist_ok=True)
483
+ depth = len(PurePosixPath(alias).parts)
484
+ (directory / "index.html").write_text(
485
+ _redirect_page("../" * depth + resolved + "/"), encoding="utf-8"
486
+ )
487
+
488
+ metadata = {
489
+ "default": default_name,
490
+ "aliases": resolved_aliases,
491
+ "versions": [
492
+ {
493
+ "name": ref.name,
494
+ "kind": ref.kind,
495
+ "url": f"{ref.name}/",
496
+ "pages": _html_pages(output / ref.name),
497
+ }
498
+ for ref in built
499
+ if (output / ref.name / "index.html").is_file()
500
+ ],
501
+ }
502
+ output.mkdir(parents=True, exist_ok=True)
503
+ (output / "versions.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
504
+ (output / "index.html").write_text(_redirect_page(default_name + "/"), encoding="utf-8")
505
+
506
+
507
+ def _git(command: list[str], *, cwd: Path) -> None:
508
+ result = subprocess.run(
509
+ ["git", *command],
510
+ cwd=cwd,
511
+ check=False,
512
+ text=True,
513
+ stdout=subprocess.PIPE,
514
+ stderr=subprocess.STDOUT,
515
+ )
516
+ if result.returncode != 0:
517
+ raise RuntimeError("Git command failed:\n" + result.stdout.rstrip())
518
+
519
+
520
+ def _config_for_checkout(config: CpdocsConfig, checkout: Path) -> CpdocsConfig | None:
521
+ """Use the checkout's own cpdocs.yml; fall back according to ``versions.legacy``."""
522
+
523
+ relative = config.config_file.relative_to(config.project_root)
524
+ candidate = checkout / relative
525
+ if candidate.is_file():
526
+ return load_config(candidate)
527
+ if config.legacy == "skip":
528
+ return None
529
+ index = config.index
530
+ if index is not None:
531
+ moved = checkout / index.relative_to(config.project_root)
532
+ index = moved if moved.is_file() else index
533
+ return dataclasses.replace(
534
+ config, config_file=candidate, project_root=checkout.resolve(), index=index
535
+ )
536
+
537
+
538
+ def _repository_root(project_root: Path) -> Path:
539
+ result = subprocess.run(
540
+ ["git", "rev-parse", "--show-toplevel"],
541
+ cwd=project_root,
542
+ check=False,
543
+ text=True,
544
+ stdout=subprocess.PIPE,
545
+ stderr=subprocess.PIPE,
546
+ )
547
+ if result.returncode != 0:
548
+ raise RuntimeError(f"{project_root} is not inside a Git repository")
549
+ return Path(result.stdout.strip()).resolve()
550
+
551
+
552
+ def build_versions(
553
+ config: CpdocsConfig,
554
+ *,
555
+ output: Path,
556
+ tags: str | None = None,
557
+ project_docs_root_depth: int = 2,
558
+ ) -> list[str]:
559
+ """Document every selected ref into ``<output>/cpdocs/versions``.
560
+
561
+ The project may live in a subdirectory of its Git repository. Each ref is then checked out as a
562
+ whole and the same subdirectory is documented; refs in which it does not exist are skipped.
563
+ """
564
+
565
+ layout = Layout.for_output(output)
566
+ project_root = config.project_root.resolve()
567
+ subdirectory = project_root.relative_to(_repository_root(project_root))
568
+ selected = select_refs(project_root, branches=config.branches, tags=tags or config.tags)
569
+ shutil.rmtree(layout.versions, ignore_errors=True)
570
+ layout.versions.mkdir(parents=True, exist_ok=True)
571
+
572
+ built: list[SelectedRef] = []
573
+ for ref in selected:
574
+ temporary: tempfile.TemporaryDirectory[str] | None = None
575
+ worktree: Path | None = None
576
+ checkout = project_root
577
+ if ref.checkout is not None:
578
+ temporary = tempfile.TemporaryDirectory(prefix="cpdocs-ref-")
579
+ worktree = Path(temporary.name) / "checkout"
580
+ _git(["worktree", "add", "--detach", str(worktree), ref.checkout], cwd=project_root)
581
+ checkout = worktree / subdirectory
582
+ try:
583
+ if not checkout.is_dir():
584
+ print(
585
+ f"cpdocs: skipping {ref.name}: {subdirectory} does not exist", file=sys.stderr
586
+ )
587
+ continue
588
+ ref_config = _config_for_checkout(config, checkout) if ref.checkout else config
589
+ if ref_config is None:
590
+ print(f"cpdocs: skipping {ref.name}: it has no cpdocs.yml", file=sys.stderr)
591
+ continue
592
+ work = layout.work(ref.name)
593
+ policy = ref_config.branch_variants if ref.kind == "branch" else ref_config.tag_variants
594
+ try:
595
+ graph = build_graph(
596
+ ref_config,
597
+ source=checkout,
598
+ work=work,
599
+ ref=ref.name,
600
+ ref_kind=ref.kind,
601
+ version=ref.name,
602
+ variants=policy,
603
+ )
604
+ except RuntimeError as error:
605
+ if config.failure == "fail":
606
+ raise
607
+ print(f"cpdocs: skipping {ref.name}: {error}", file=sys.stderr)
608
+ continue
609
+ _render(
610
+ graph,
611
+ ref_config,
612
+ output=layout.versions / ref.name,
613
+ work=work / "render",
614
+ root_depth=project_docs_root_depth,
615
+ )
616
+ built.append(ref)
617
+ finally:
618
+ if temporary is not None and worktree is not None:
619
+ _git(["worktree", "remove", "--force", str(worktree)], cwd=project_root)
620
+ temporary.cleanup()
621
+
622
+ _write_versions(layout.versions, built, default=config.default_version, aliases=config.aliases)
623
+ return [ref.name for ref in built]
624
+
625
+
626
+ def recorded_default_version(output: Path) -> str:
627
+ """The default version recorded by the last :func:`build_versions` run."""
628
+
629
+ metadata = Layout.for_output(output).versions / "versions.json"
630
+ try:
631
+ return str(json.loads(metadata.read_text(encoding="utf-8"))["default"])
632
+ except (OSError, KeyError, json.JSONDecodeError):
633
+ return "main"
634
+
635
+
636
+ __all__ = [
637
+ "Layout",
638
+ "build_current",
639
+ "build_graph",
640
+ "build_versions",
641
+ "cmake_module",
642
+ "describe",
643
+ "recorded_default_version",
644
+ "resolve_variant",
645
+ ]