cpdocs 0.2.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (152) hide show
  1. {cpdocs-0.2.0 → cpdocs-0.3.1}/PKG-INFO +1 -1
  2. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/reference/cli.md +5 -4
  3. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/__init__.py +1 -1
  4. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/build.py +93 -12
  5. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/clang_backend.py +73 -3
  6. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/cli.py +12 -2
  7. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/html_renderer.py +16 -2
  8. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/model.py +9 -0
  9. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/properdocs_plugin.py +11 -0
  10. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/render.py +7 -2
  11. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_api_reference.py +14 -3
  12. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_build.py +44 -1
  13. cpdocs-0.2.0/src/cpdocs/html_renderer.py.rej +0 -12
  14. {cpdocs-0.2.0 → cpdocs-0.3.1}/.github/workflows/docs.yml +0 -0
  15. {cpdocs-0.2.0 → cpdocs-0.3.1}/.github/workflows/test.yml +0 -0
  16. {cpdocs-0.2.0 → cpdocs-0.3.1}/.gitignore +0 -0
  17. {cpdocs-0.2.0 → cpdocs-0.3.1}/LICENSES/Apache-2.0.txt +0 -0
  18. {cpdocs-0.2.0 → cpdocs-0.3.1}/REUSE.toml +0 -0
  19. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/api/index.md +0 -0
  20. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/cpdocs.yml +0 -0
  21. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/examples/index.md +0 -0
  22. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/explanation/architecture.md +0 -0
  23. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/explanation/feature-sets.md +0 -0
  24. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/how-to/ci.md +0 -0
  25. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/how-to/feature-sets.md +0 -0
  26. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/how-to/generated-headers.md +0 -0
  27. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/how-to/other-build-systems.md +0 -0
  28. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/how-to/properdocs.md +0 -0
  29. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/index.md +0 -0
  30. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/reference/api.md +0 -0
  31. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/reference/cmake.md +0 -0
  32. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/reference/comments.md +0 -0
  33. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/reference/configuration.md +0 -0
  34. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/reference/contract.md +0 -0
  35. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/reference/extraction.md +0 -0
  36. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/reference/links.md +0 -0
  37. {cpdocs-0.2.0 → cpdocs-0.3.1}/docs/web/tutorials/first-reference.md +0 -0
  38. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/.gitignore +0 -0
  39. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/CMakeLists.txt +0 -0
  40. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/docs/api/index.md +0 -0
  41. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/docs/cpdocs.yml +0 -0
  42. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/docs/web/api.md +0 -0
  43. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/docs/web/index.md +0 -0
  44. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/include/acme/math.hpp +0 -0
  45. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/properdocs.yml +0 -0
  46. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/readme.md +0 -0
  47. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/basic-cpp/src/math.cpp +0 -0
  48. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/.gitignore +0 -0
  49. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/CMakeLists.txt +0 -0
  50. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/docs/api/index.md +0 -0
  51. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/docs/cpdocs.yml +0 -0
  52. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/docs/web/api.md +0 -0
  53. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/docs/web/index.md +0 -0
  54. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/include/ring/ring.h +0 -0
  55. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/properdocs.yml +0 -0
  56. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/readme.md +0 -0
  57. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/c-basic/src/ring.c +0 -0
  58. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/.gitignore +0 -0
  59. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/CMakeLists.txt +0 -0
  60. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/docs/api/index.md +0 -0
  61. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/docs/cpdocs-minibuild.yml +0 -0
  62. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/docs/cpdocs.yml +0 -0
  63. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/docs/web/api.md +0 -0
  64. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/docs/web/index.md +0 -0
  65. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/include/demo/solver.hpp +0 -0
  66. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/properdocs.yml +0 -0
  67. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/readme.md +0 -0
  68. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-features/tools/minibuild.py +0 -0
  69. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-templates/.gitignore +0 -0
  70. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-templates/CMakeLists.txt +0 -0
  71. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-templates/docs/api/index.md +0 -0
  72. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-templates/docs/cpdocs.yml +0 -0
  73. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-templates/docs/web/api.md +0 -0
  74. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-templates/docs/web/index.md +0 -0
  75. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-templates/include/meta/meta.hpp +0 -0
  76. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-templates/properdocs.yml +0 -0
  77. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/cpp-templates/readme.md +0 -0
  78. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/fortran-basic/CMakeLists.txt +0 -0
  79. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/fortran-basic/docs/cpdocs.yml +0 -0
  80. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/fortran-basic/docs/index.md +0 -0
  81. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/fortran-basic/lib/normalize.f90 +0 -0
  82. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/fortran-basic/mod/geometry.f90 +0 -0
  83. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/fortran-basic/properdocs.yml +0 -0
  84. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/fortran-basic/readme.md +0 -0
  85. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/.gitignore +0 -0
  86. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/CMakeLists.txt +0 -0
  87. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/docs/api/index.md +0 -0
  88. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/docs/cpdocs.yml +0 -0
  89. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/docs/web/api.md +0 -0
  90. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/docs/web/index.md +0 -0
  91. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/generated-template/generated.hpp +0 -0
  92. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/include/acme/core.hpp +0 -0
  93. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/properdocs.yml +0 -0
  94. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/readme.md +0 -0
  95. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/generated-cpp/src/generated.cpp +0 -0
  96. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/.gitignore +0 -0
  97. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/CMakeLists.txt +0 -0
  98. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/docs/api/index.md +0 -0
  99. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/docs/cpdocs.yml +0 -0
  100. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/docs/web/api.md +0 -0
  101. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/docs/web/index.md +0 -0
  102. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/include/geom/vector.hpp +0 -0
  103. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/properdocs.yml +0 -0
  104. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/python/pygeom/__init__.py +0 -0
  105. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/readme.md +0 -0
  106. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/mixed/src/geom.cpp +0 -0
  107. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/.gitignore +0 -0
  108. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/docs/api/index.md +0 -0
  109. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/docs/cpdocs.yml +0 -0
  110. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/docs/web/api.md +0 -0
  111. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/docs/web/index.md +0 -0
  112. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/properdocs.yml +0 -0
  113. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/pyproject.toml +0 -0
  114. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/readme.md +0 -0
  115. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/src/dice/__init__.py +0 -0
  116. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/src/dice/_rng.py +0 -0
  117. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/python-basic/src/dice/core.py +0 -0
  118. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/Cargo.lock +0 -0
  119. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/Cargo.toml +0 -0
  120. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/docs/api.md +0 -0
  121. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/docs/cpdocs.yml +0 -0
  122. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/docs/index.md +0 -0
  123. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/properdocs.yml +0 -0
  124. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/readme.md +0 -0
  125. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/rust-toolchain.toml +0 -0
  126. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/src/geometry.rs +0 -0
  127. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/src/internal.rs +0 -0
  128. {cpdocs-0.2.0 → cpdocs-0.3.1}/examples/rust-basic/src/lib.rs +0 -0
  129. {cpdocs-0.2.0 → cpdocs-0.3.1}/properdocs.yml +0 -0
  130. {cpdocs-0.2.0 → cpdocs-0.3.1}/pyproject.toml +0 -0
  131. {cpdocs-0.2.0 → cpdocs-0.3.1}/readme.md +0 -0
  132. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/__main__.py +0 -0
  133. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/config.py +0 -0
  134. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/contract.py +0 -0
  135. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/fortran_backend.py +0 -0
  136. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/properdocs_core.py +0 -0
  137. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/python_backend.py +0 -0
  138. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/python_manifest.py +0 -0
  139. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/rust_backend.py +0 -0
  140. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/rust_manifest.py +0 -0
  141. {cpdocs-0.2.0 → cpdocs-0.3.1}/src/cpdocs/versioning.py +0 -0
  142. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/conftest.py +0 -0
  143. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_api_reference.py.rej +0 -0
  144. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_config.py +0 -0
  145. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_contract.py +0 -0
  146. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_examples.py +0 -0
  147. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_feature_set_signatures.py +0 -0
  148. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_properdocs_core.py +0 -0
  149. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_rust_example.py +0 -0
  150. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_rust_provider.py +0 -0
  151. {cpdocs-0.2.0 → cpdocs-0.3.1}/test/test_versioning.py +0 -0
  152. {cpdocs-0.2.0 → cpdocs-0.3.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cpdocs
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Semantic, versioned API documentation for C, C++, CUDA, HIP, Fortran, Python and Rust projects
5
5
  Project-URL: Source, https://github.com/ambhora/cpdocs
6
6
  Author-email: Jayesh Badwaik <j.badwaik@fz-juelich.de>
@@ -5,8 +5,8 @@ SPDX-License-Identifier: Apache-2.0
5
5
  # Command line
6
6
 
7
7
  ```text
8
- cpdocs build [--config docs/cpdocs.yml] [--output build] [--label main]
9
- cpdocs versions [--config docs/cpdocs.yml] [--output build]
8
+ cpdocs build [--config docs/cpdocs.yml] [--output build] [--tmp-directory DIR] [--label main]
9
+ cpdocs versions [--config docs/cpdocs.yml] [--output build] [--tmp-directory DIR]
10
10
  cpdocs manifest python feature-sets ...
11
11
  cpdocs manifest python build ...
12
12
  cpdocs rust feature-sets --source <project> --output <feature-sets.json>
@@ -26,5 +26,6 @@ declared Cargo feature is treated as a disjoint feature set and documented once;
26
26
  specific combinations can list them in `[package.metadata.cpdocs]`. `rust build` runs Cargo rustdoc
27
27
  with nightly JSON output and writes a normal cpdocs manifest.
28
28
 
29
- Feature-set inputs, manifests, scratch/build directories and command logs are kept below
30
- `<output>/cpdocs/work/<version>/feature-sets/`.
29
+ By default, feature-set inputs, manifests, scratch/build directories and command logs are kept below
30
+ `<output>/cpdocs/work/<version>/feature-sets/`. Pass `--tmp-directory DIR` to keep this workspace
31
+ under `DIR/cpdocs/work/` while leaving rendered output under `<output>/cpdocs/`.
@@ -2,4 +2,4 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  """Language-neutral semantic API documentation generator."""
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.3.1"
@@ -36,7 +36,7 @@ from .contract import (
36
36
  )
37
37
  from .fortran_backend import extract_fortran_graph
38
38
  from .html_renderer import render_html_site
39
- from .model import ApiGraph, merge_graphs
39
+ from .model import ApiGraph, merge_graphs, relative_source_path
40
40
  from .python_backend import build_python_graph
41
41
  from .rust_backend import build_rust_graph
42
42
  from .versioning import SelectedRef, current_branch, select_refs
@@ -47,13 +47,16 @@ _CONTAINER_KINDS = {"namespace", "package", "module"}
47
47
 
48
48
  @dataclasses.dataclass(frozen=True)
49
49
  class Layout:
50
- """Directories below ``<output>/cpdocs``."""
50
+ """Published and temporary directories used by cpdocs."""
51
51
 
52
52
  root: Path
53
+ work_root: Path
53
54
 
54
55
  @classmethod
55
- def for_output(cls, output: Path) -> Layout:
56
- return cls(root=(output / "cpdocs").resolve())
56
+ def for_output(cls, output: Path, *, tmp_directory: Path | None = None) -> Layout:
57
+ root = (output / "cpdocs").resolve()
58
+ work_root = (tmp_directory.resolve() / "cpdocs" if tmp_directory else root)
59
+ return cls(root=root, work_root=work_root)
57
60
 
58
61
  @property
59
62
  def current(self) -> Path:
@@ -64,7 +67,7 @@ class Layout:
64
67
  return self.root / "versions"
65
68
 
66
69
  def work(self, ref: str) -> Path:
67
- return self.root / "work" / ref.replace("/", "__")
70
+ return self.work_root / "work" / ref.replace("/", "__")
68
71
 
69
72
 
70
73
 
@@ -196,8 +199,8 @@ def _browse_logical_path(
196
199
  try:
197
200
  return path.resolve().relative_to(project_root.resolve()).as_posix()
198
201
  except (OSError, ValueError):
199
- # Generated files live outside the source checkout. Give them a stable logical home rather
200
- # than leaking profile/build-directory paths into the published file hierarchy.
202
+ # Keep a stable internal key for files outside the checkout. This path is not presented to
203
+ # readers; rendering uses the semantic layout recorded in ``source_display_paths``.
201
204
  base = Path("_generated") / unit.id / category
202
205
  try:
203
206
  relative = (
@@ -208,11 +211,69 @@ def _browse_logical_path(
208
211
  return (base / relative).as_posix()
209
212
 
210
213
 
214
+ def _semantic_display_path(path: Path, *, root: Path, generated: bool) -> str:
215
+ """Return the compiler-facing layout path shown in the file browser.
216
+
217
+ Manifest roots are semantic roots. A file below ``.../src/cuda/include`` is therefore shown
218
+ below ``include`` and a file below ``.../src/cuda/lib`` below ``lib``. Build-generated roots
219
+ use the same flattened layout under one ``_generated`` prefix.
220
+ """
221
+
222
+ try:
223
+ if root.is_dir():
224
+ relative = path.resolve().relative_to(root.resolve())
225
+ root_name = root.name
226
+ else:
227
+ relative = Path(path.name)
228
+ root_name = root.parent.name
229
+ except (OSError, ValueError):
230
+ relative = Path(path.name)
231
+ root_name = root.name if root.name != path.name else root.parent.name
232
+
233
+ base = Path("_generated") if generated else Path()
234
+ if root_name:
235
+ base /= root_name
236
+ return (base / relative).as_posix()
237
+
238
+
239
+ def _api_display_paths(
240
+ unit: UnitManifest, graph: ApiGraph, *, project_root: Path
241
+ ) -> dict[str, str]:
242
+ """Map API source keys to their semantic include/module layout."""
243
+
244
+ values: dict[str, str] = {}
245
+ for api_root in unit.api:
246
+ root = api_root.path
247
+ if not root.is_dir():
248
+ continue
249
+ for path in sorted(root.rglob("*")):
250
+ if not path.is_file():
251
+ continue
252
+ try:
253
+ root_relative = path.resolve().relative_to(root.resolve()).as_posix()
254
+ except (OSError, ValueError):
255
+ root_relative = path.name
256
+ project_relative = relative_source_path(path, project_root)
257
+ logical = next(
258
+ (candidate for candidate in (root_relative, project_relative) if candidate in graph.sources),
259
+ None,
260
+ )
261
+ if logical is not None:
262
+ values.setdefault(
263
+ logical,
264
+ _semantic_display_path(
265
+ path, root=root, generated=unit.generated or api_root.generated
266
+ ),
267
+ )
268
+ return values
269
+
270
+
211
271
  def _collect_browse_files(
212
272
  unit: UnitManifest, *, project_root: Path
213
- ) -> tuple[dict[str, str], list[str]]:
273
+ ) -> tuple[dict[str, str], list[str], dict[str, str]]:
214
274
  files: dict[str, str] = {}
215
275
  generated: list[str] = []
276
+ display_paths: dict[str, str] = {}
216
277
  # Public headers are already present in ``graph.sources`` after API extraction. These roots
217
278
  # add implementation-only material without duplicating public headers in the file hierarchy.
218
279
  groups = (
@@ -239,9 +300,13 @@ def _collect_browse_files(
239
300
  path, project_root=project_root, unit=unit, category=category, root=root
240
301
  )
241
302
  files.setdefault(logical, content)
303
+ display_paths.setdefault(
304
+ logical,
305
+ _semantic_display_path(path, root=root, generated=unit.generated),
306
+ )
242
307
  if unit.generated and logical not in generated:
243
308
  generated.append(logical)
244
- return files, generated
309
+ return files, generated, display_paths
245
310
 
246
311
 
247
312
  def _generated_sources(unit: UnitManifest, graph: ApiGraph) -> list[str]:
@@ -322,6 +387,11 @@ def _merge_metadata(target: dict[str, object], source: dict[str, object]) -> Non
322
387
  existing = target.setdefault(key, [])
323
388
  assert isinstance(existing, list)
324
389
  existing.extend(item for item in value if item not in existing)
390
+ elif isinstance(value, dict):
391
+ existing = target.setdefault(key, {})
392
+ assert isinstance(existing, dict)
393
+ for item_key, item_value in value.items():
394
+ existing.setdefault(item_key, item_value)
325
395
  else:
326
396
  target.setdefault(key, value)
327
397
 
@@ -424,9 +494,18 @@ def build_graph(
424
494
  work=work / "feature-sets" / feature_set.key,
425
495
  config=config,
426
496
  )
427
- browse_files, generated_files = _collect_browse_files(unit, project_root=source)
497
+ display_paths = unit_graph.metadata.setdefault("source_display_paths", {})
498
+ assert isinstance(display_paths, dict)
499
+ for path, display in _api_display_paths(unit, unit_graph, project_root=source).items():
500
+ display_paths.setdefault(path, display)
501
+
502
+ browse_files, generated_files, browse_display_paths = _collect_browse_files(
503
+ unit, project_root=source
504
+ )
428
505
  for path, content in browse_files.items():
429
506
  unit_graph.files.setdefault(path, content)
507
+ for path, display in browse_display_paths.items():
508
+ display_paths.setdefault(path, display)
430
509
  if generated_files:
431
510
  existing = unit_graph.metadata.setdefault("generated_files", [])
432
511
  assert isinstance(existing, list)
@@ -505,10 +584,11 @@ def build_current(
505
584
  output: Path,
506
585
  version: str = "main",
507
586
  project_docs_root_depth: int = 2,
587
+ tmp_directory: Path | None = None,
508
588
  ) -> ApiGraph:
509
589
  """Document the working tree into ``<output>/cpdocs/current``."""
510
590
 
511
- layout = Layout.for_output(output)
591
+ layout = Layout.for_output(output, tmp_directory=tmp_directory)
512
592
  work = layout.work("current")
513
593
  graph = build_graph(
514
594
  config,
@@ -610,10 +690,11 @@ def build_versions(
610
690
  output: Path,
611
691
  project_docs_root_depth: int = 2,
612
692
  include_working_tree: bool = False,
693
+ tmp_directory: Path | None = None,
613
694
  ) -> list[str]:
614
695
  """Document the explicitly configured ``versions`` in order."""
615
696
 
616
- layout = Layout.for_output(output)
697
+ layout = Layout.for_output(output, tmp_directory=tmp_directory)
617
698
  project_root = config.project_root.resolve()
618
699
  subdirectory = project_root.relative_to(_repository_root(project_root))
619
700
  selected = select_refs(project_root, config.versions)
@@ -21,6 +21,7 @@ from pathlib import Path
21
21
 
22
22
  from .contract import ParseOptions
23
23
  from .model import (
24
+ ApiAttribute,
24
25
  ApiEntity,
25
26
  ApiGraph,
26
27
  ApiParameter,
@@ -351,10 +352,79 @@ def _qualifiers(node: dict[str, object]) -> tuple[str, ...]:
351
352
  return tuple(values)
352
353
 
353
354
 
354
- def _signature(node: dict[str, object]) -> ApiSignature:
355
+ _ATTRIBUTE_NAMES = {
356
+ "WarnUnusedResultAttr": "nodiscard",
357
+ "DeprecatedAttr": "deprecated",
358
+ "UnusedAttr": "maybe_unused",
359
+ "CXX11NoReturnAttr": "noreturn",
360
+ "NoUniqueAddressAttr": "no_unique_address",
361
+ "CarriesDependencyAttr": "carries_dependency",
362
+ "LikelyAttr": "likely",
363
+ "UnlikelyAttr": "unlikely",
364
+ }
365
+
366
+
367
+ def _attribute_name(node: dict[str, object]) -> str:
368
+ kind = str(node.get("kind", ""))
369
+ if kind in _ATTRIBUTE_NAMES:
370
+ return _ATTRIBUTE_NAMES[kind]
371
+ if not kind.endswith("Attr"):
372
+ return ""
373
+ stem = kind[:-4]
374
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", stem).lower()
375
+
376
+
377
+ def _attribute_spelling(node: dict[str, object], path: Path | None) -> str:
378
+ """Recover the complete ``[[...]]`` specifier containing one Clang Attr node."""
379
+
380
+ if path is None:
381
+ return ""
382
+ offsets = _range_offsets(node)
383
+ if offsets is None:
384
+ return ""
385
+ data = _SOURCE_BYTES.get(path)
386
+ if data is None:
387
+ try:
388
+ data = path.read_bytes()
389
+ except OSError:
390
+ return ""
391
+ _SOURCE_BYTES[path] = data
392
+ start, stop = offsets
393
+ left = data.rfind(b"[[", max(0, start - 2048), start + 1)
394
+ if left < 0:
395
+ return ""
396
+ right = data.find(b"]]", left + 2, min(len(data), stop + 2048))
397
+ if right < 0 or not (left <= start and stop <= right + 2):
398
+ return ""
399
+ return re.sub(r"\s+", " ", data[left : right + 2].decode("utf-8", errors="replace")).strip()
400
+
401
+
402
+ def _attributes(node: dict[str, object], path: Path | None) -> tuple[ApiAttribute, ...]:
403
+ children = node.get("inner")
404
+ if not isinstance(children, list):
405
+ return ()
406
+ values: list[ApiAttribute] = []
407
+ for child in children:
408
+ if not isinstance(child, dict) or not str(child.get("kind", "")).endswith("Attr"):
409
+ continue
410
+ name = _attribute_name(child)
411
+ if not name:
412
+ continue
413
+ attribute = ApiAttribute(name=name, spelling=_attribute_spelling(child, path))
414
+ if attribute not in values:
415
+ values.append(attribute)
416
+ return tuple(values)
417
+
418
+
419
+ def _signature(node: dict[str, object], path: Path | None) -> ApiSignature:
355
420
  parameters = _parameters(node)
356
421
  qualifiers = _qualifiers(node)
357
- return ApiSignature(parameters=parameters, returns=_return_type(node), qualifiers=qualifiers)
422
+ return ApiSignature(
423
+ parameters=parameters,
424
+ returns=_return_type(node),
425
+ qualifiers=qualifiers,
426
+ attributes=_attributes(node, path),
427
+ )
358
428
 
359
429
 
360
430
  # --------------------------------------------------------------------------------------------------
@@ -704,7 +774,7 @@ def _walk_ast(
704
774
  parent=parent_entity.id if parent_entity else None,
705
775
  signatures=[
706
776
  replace(
707
- _signature(node),
777
+ _signature(node, path),
708
778
  template=template_header,
709
779
  documentation=_attached_comment(node),
710
780
  source=_location(node, path, project_root, public_roots),
@@ -34,6 +34,11 @@ def _parser() -> argparse.ArgumentParser:
34
34
  default=Path("build"),
35
35
  help="output root; cpdocs writes below <output>/cpdocs (default: build)",
36
36
  )
37
+ command.add_argument(
38
+ "--tmp-directory",
39
+ type=_path,
40
+ help="temporary workspace root; defaults to the output root",
41
+ )
37
42
  return command
38
43
 
39
44
  build = documentation("build", "document the working tree")
@@ -110,10 +115,15 @@ def main(argv: list[str] | None = None) -> int:
110
115
  else:
111
116
  config = load_config(arguments.config)
112
117
  output = arguments.output.resolve()
118
+ tmp_directory = (
119
+ arguments.tmp_directory.resolve() if arguments.tmp_directory is not None else None
120
+ )
113
121
  if arguments.command == "build":
114
- build_current(config, output=output, version=arguments.label)
122
+ build_current(
123
+ config, output=output, version=arguments.label, tmp_directory=tmp_directory
124
+ )
115
125
  else:
116
- build_versions(config, output=output)
126
+ build_versions(config, output=output, tmp_directory=tmp_directory)
117
127
  except RuntimeError as error:
118
128
  parser.exit(1, f"cpdocs: error: {error}\n")
119
129
  return 0
@@ -137,7 +137,13 @@ def _entity_href(source: Path, entity: ApiEntity, graph: ApiGraph) -> str:
137
137
 
138
138
 
139
139
  def _display_source_path(source_path: str, graph: ApiGraph) -> str:
140
- """Return the source path shown to readers while preserving the real repository path for links."""
140
+ """Return the semantic path shown to readers while preserving internal source keys for links."""
141
+ display_paths = graph.metadata.get("source_display_paths", {})
142
+ if isinstance(display_paths, dict):
143
+ value = display_paths.get(source_path)
144
+ if isinstance(value, str) and value:
145
+ return value
146
+
141
147
  source_root = str(graph.metadata.get("source_root") or "").strip("/")
142
148
  logical = PurePosixPath(source_path)
143
149
  if not source_root:
@@ -610,7 +616,15 @@ def _signature_card(
610
616
  declaration = _highlight_declaration(
611
617
  spelling, entity.language, entity=entity, graph=graph, document_path=document
612
618
  )
613
- properties = list(dict.fromkeys([*entity.properties, *signature.qualifiers]))
619
+ properties = list(
620
+ dict.fromkeys(
621
+ [
622
+ *entity.properties,
623
+ *signature.qualifiers,
624
+ *(attribute.name for attribute in signature.attributes),
625
+ ]
626
+ )
627
+ )
614
628
  rows = []
615
629
  if properties:
616
630
  rows.append(
@@ -24,11 +24,20 @@ class ApiParameter:
24
24
  kind: str = ""
25
25
 
26
26
 
27
+ @dataclass(frozen=True)
28
+ class ApiAttribute:
29
+ """One source-level attribute attached to an API declaration."""
30
+
31
+ name: str
32
+ spelling: str = ""
33
+
34
+
27
35
  @dataclass(frozen=True)
28
36
  class ApiSignature:
29
37
  parameters: tuple[ApiParameter, ...] = ()
30
38
  returns: str = ""
31
39
  qualifiers: tuple[str, ...] = ()
40
+ attributes: tuple[ApiAttribute, ...] = ()
32
41
  spelling: str = ""
33
42
  template: str = ""
34
43
  # Documentation and location of this overload; not part of its identity.
@@ -44,6 +44,7 @@ class CpdocsPlugin(BasePlugin):
44
44
  config_scheme = (
45
45
  ("config", c.Type(str, default=str(DEFAULT_CONFIG))),
46
46
  ("output", c.Type(str, default="build")),
47
+ ("tmp_directory", c.Type(str, default="")),
47
48
  ("mount", c.Type(str, default="reference/api")),
48
49
  ("subsites", c.ListOfItems(c.Type(str), default=[])),
49
50
  )
@@ -69,6 +70,15 @@ class CpdocsPlugin(BasePlugin):
69
70
  path = Path(str(self.config.get("output", "build")))
70
71
  return path if path.is_absolute() else self._project_root / path
71
72
 
73
+ @property
74
+ def tmp_directory(self) -> Path | None:
75
+ assert self._project_root is not None
76
+ value = str(self.config.get("tmp_directory", "")).strip()
77
+ if not value:
78
+ return None
79
+ path = Path(value).expanduser()
80
+ return path if path.is_absolute() else self._project_root / path
81
+
72
82
  def on_startup(self, *, command: str, dirty: bool = False) -> None:
73
83
  del dirty
74
84
  self._serve_active = command == "serve" or os.environ.get("_CPDOCS_SERVE_IN_SITU") == "1"
@@ -221,6 +231,7 @@ class CpdocsPlugin(BasePlugin):
221
231
  output=self.output,
222
232
  project_docs_root_depth=len(PurePosixPath(self.mount).parts),
223
233
  include_working_tree=self._serve_active,
234
+ tmp_directory=self.tmp_directory,
224
235
  )
225
236
  except RuntimeError as error:
226
237
  raise PluginError(f"cpdocs: {error}") from error
@@ -631,8 +631,12 @@ def _signature_spelling(entity: ApiEntity, signature: ApiSignature) -> str:
631
631
 
632
632
 
633
633
  def _plain_signature_spelling(entity: ApiEntity, signature: ApiSignature) -> str:
634
+ attribute_spellings = list(
635
+ dict.fromkeys(attribute.spelling for attribute in signature.attributes if attribute.spelling)
636
+ )
637
+ attribute_prefix = " ".join(attribute_spellings)
634
638
  if signature.spelling:
635
- return signature.spelling
639
+ return f"{attribute_prefix} {signature.spelling}".strip()
636
640
  parameters = _parameter_spelling(signature, entity.language)
637
641
  if entity.language == "python":
638
642
  prefix = "async def" if "async" in signature.qualifiers else "def"
@@ -656,7 +660,8 @@ def _plain_signature_spelling(entity: ApiEntity, signature: ApiSignature) -> str
656
660
  result = (signature.returns + " ") if signature.returns else ""
657
661
  suffix_values = [value for value in signature.qualifiers if value not in prefix_values]
658
662
  suffix = (" " + " ".join(suffix_values)) if suffix_values else ""
659
- return f"{prefix}{result}{entity.qualified_name}({parameters}){suffix}".strip()
663
+ declaration = f"{prefix}{result}{entity.qualified_name}({parameters}){suffix}".strip()
664
+ return f"{attribute_prefix} {declaration}".strip()
660
665
 
661
666
 
662
667
  def _entity_declaration(entity: ApiEntity) -> str:
@@ -13,6 +13,7 @@ from cpdocs.clang_backend import extract_clang_graph
13
13
  from cpdocs.contract import ParseOptions
14
14
  from cpdocs.html_renderer import render_html_site
15
15
  from cpdocs.model import (
16
+ ApiAttribute,
16
17
  ApiEntity,
17
18
  ApiGraph,
18
19
  ApiParameter,
@@ -37,11 +38,11 @@ def test_cpp_semantic_profiles_merge_conditional_api(tmp_path: Path) -> None:
37
38
  #ifdef USE_CUDA
38
39
  /// Active backend.
39
40
  #define DICE_BACKEND \"cuda\"
40
- namespace dice { struct cuda_view {}; void foo(cuda_view); }
41
+ namespace dice { struct cuda_view {}; void foo(cuda_view); [[nodiscard]] int backend_name(); }
41
42
  #else
42
43
  /// Active backend.
43
44
  #define DICE_BACKEND \"cpu\"
44
- namespace dice { struct cpu_view {}; void foo(cpu_view); }
45
+ namespace dice { struct cpu_view {}; void foo(cpu_view); [[nodiscard]] int backend_name(); }
45
46
  #endif
46
47
  class visible { public: void yes(); private: void no(); };
47
48
  """,
@@ -76,6 +77,10 @@ class visible { public: void yes(); private: void no(); };
76
77
  foo = merged.entities[stable_entity_id("cpp", "function", "dice::foo")]
77
78
  assert {signature.compact for signature in foo.signatures} == {"(cpu_view)", "(cuda_view)"}
78
79
  assert set(foo.feature_sets) == {"cpu", "cuda"}
80
+ backend_name = merged.entities[stable_entity_id("cpp", "function", "dice::backend_name")]
81
+ assert backend_name.signatures[0].attributes == (
82
+ ApiAttribute(name="nodiscard", spelling="[[nodiscard]]"),
83
+ )
79
84
  assert stable_entity_id("cpp", "method", "visible::yes") in merged.entities
80
85
  assert stable_entity_id("cpp", "method", "visible::no") not in merged.entities
81
86
 
@@ -206,7 +211,11 @@ def test_renderer_generates_standalone_code_oriented_html_site(tmp_path: Path) -
206
211
  qualified_name="dice::foo",
207
212
  parent=namespace.id,
208
213
  signatures=[
209
- ApiSignature(parameters=(ApiParameter("value", "int"),), returns="void"),
214
+ ApiSignature(
215
+ parameters=(ApiParameter("value", "int"),),
216
+ returns="void",
217
+ attributes=(ApiAttribute(name="nodiscard", spelling="[[nodiscard]]"),),
218
+ ),
210
219
  ApiSignature(parameters=(ApiParameter("value", "float"),), returns="void"),
211
220
  ],
212
221
  documentation=(
@@ -315,6 +324,8 @@ def test_renderer_generates_standalone_code_oriented_html_site(tmp_path: Path) -
315
324
  page = html_page(entity_document(function)).read_text(encoding="utf-8")
316
325
  assert '<article class="api-signature-card"' in page
317
326
  assert "foo(int)" in page
327
+ assert page.count("nodiscard") >= 2
328
+ assert "<span>nodiscard</span>" in page
318
329
  assert "Available in feature sets" in page
319
330
  assert "<code>cpu</code>" in page
320
331
  assert "<strong>Related:</strong>" in page
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  import pytest
11
11
 
12
12
  from conftest import fake_project, git, init_repository
13
- from cpdocs.build import build_current, build_versions
13
+ from cpdocs.build import _semantic_display_path, build_current, build_versions
14
14
  from cpdocs.cli import main
15
15
  from cpdocs.config import load_config
16
16
  from cpdocs.model import stable_entity_id
@@ -27,6 +27,28 @@ def _no_ci_branch(monkeypatch: pytest.MonkeyPatch) -> None:
27
27
  monkeypatch.delenv(variable, raising=False)
28
28
 
29
29
 
30
+ def test_semantic_file_layout_flattens_manifest_roots(tmp_path: Path) -> None:
31
+ include = tmp_path / "src" / "cuda" / "include"
32
+ library = tmp_path / "src" / "cuda" / "lib"
33
+ header = include / "kprof" / "cuda.hpp"
34
+ source = library / "kprof" / "synchronize.cu"
35
+ header.parent.mkdir(parents=True)
36
+ source.parent.mkdir(parents=True)
37
+ header.write_text("#pragma once\n", encoding="utf-8")
38
+ source.write_text("// source\n", encoding="utf-8")
39
+
40
+ assert _semantic_display_path(header, root=include, generated=False) == "include/kprof/cuda.hpp"
41
+ assert _semantic_display_path(source, root=library, generated=False) == "lib/kprof/synchronize.cu"
42
+ assert (
43
+ _semantic_display_path(header, root=include, generated=True)
44
+ == "_generated/include/kprof/cuda.hpp"
45
+ )
46
+ assert (
47
+ _semantic_display_path(source, root=library, generated=True)
48
+ == "_generated/lib/kprof/synchronize.cu"
49
+ )
50
+
51
+
30
52
  def test_build_runs_feature_sets_once_then_build_once_per_feature_set(tmp_path: Path) -> None:
31
53
  project = fake_project(
32
54
  tmp_path / "p",
@@ -153,3 +175,24 @@ def test_obsolete_cpdocs_cmake_placeholder_is_rejected(tmp_path: Path) -> None:
153
175
  )
154
176
  with pytest.raises(RuntimeError, match="cpdocs-cmake"):
155
177
  build_current(load_config(config), output=project / "build")
178
+
179
+
180
+ def test_tmp_directory_separates_work_from_output(tmp_path, monkeypatch):
181
+ project = tmp_path / "project"
182
+ project.mkdir()
183
+ config_file = project / "cpdocs.yml"
184
+ config_file.write_text(
185
+ "contract: 3\nproject: demo\nlanguages: [python]\n"
186
+ "build:\n"
187
+ " feature-sets: >-\n"
188
+ " python -c \"import pathlib; pathlib.Path(r'{{output-file}}').write_text('[]')\"\n"
189
+ " build: >-\n"
190
+ " python -c \"import pathlib; pathlib.Path(r'{{output-file}}').write_text('[]')\"\n",
191
+ encoding="utf-8",
192
+ )
193
+ # Layout behavior is the contract under test; avoid coupling this regression to providers.
194
+ from cpdocs.build import Layout
195
+
196
+ layout = Layout.for_output(project / "build", tmp_directory=tmp_path / "scratch")
197
+ assert layout.root == (project / "build" / "cpdocs").resolve()
198
+ assert layout.work("main") == (tmp_path / "scratch" / "cpdocs" / "work" / "main").resolve()
@@ -1,12 +0,0 @@
1
- diff a/src/cpdocs/html_renderer.py b/src/cpdocs/html_renderer.py (rejected hunks)
2
- @@ -698,7 +698,9 @@ def _namespace_page(
3
- label = child.member_label
4
- body.append(
5
- '<li>' + _kind_badge(child.kind)
6
- - + f'<a href="{html.escape(_entity_href(document, child, graph))}">{html.escape(label)}</a></li>'
7
- + + f'<a href="{html.escape(_entity_href(document, child, graph))}">{html.escape(label)}</a>'
8
- + + _language_badge(child)
9
- + + '</li>'
10
- )
11
- body.extend(["</ul>", _availability(entity, graph, document), "</section>"])
12
- return "".join(body), [("Members", "members", 1)]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes