mirror of
https://github.com/pytorch/pytorch.git
synced 2026-10-28 18:56:51 +08:00
authored with codex as discussed w/ @eellison , @ngimel ,~~~ just stashing this prototype here as performance doesn't look great on the hot path:~~~ ``` Latest benchmark results: Backend Shape (M,N,K) Cached Eager Delta ━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━ ━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━ cuBLAS (64,64,64) 14.807 µs 15.092 µs +0.284 µs / +1.92% ────────── ────────────────── ─────────── ─────────── ──────────────────── cuBLAS (256,256,256) 14.904 µs 15.301 µs +0.397 µs / +2.66% ────────── ────────────────── ─────────── ─────────── ──────────────────── cuBLAS (1024,1024,1024) 14.850 µs 15.363 µs +0.513 µs / +3.46% ────────── ────────────────── ─────────── ─────────── ──────────────────── cuBLAS (32,2048,10944) 16.777 µs 17.626 µs +0.849 µs / +5.06% ────────── ────────────────── ─────────── ─────────── ──────────────────── cuBLASLt (64,64,64) 14.781 µs 15.181 µs +0.399 µs / +2.70% ────────── ────────────────── ─────────── ─────────── ──────────────────── cuBLASLt (256,256,256) 14.850 µs 15.099 µs +0.249 µs / +1.68% ────────── ────────────────── ─────────── ─────────── ──────────────────── cuBLASLt (1024,1024,1024) 14.718 µs 14.792 µs +0.074 µs / +0.50% ────────── ────────────────── ─────────── ─────────── ──────────────────── cuBLASLt (32,2048,10944) 16.715 µs 17.247 µs +0.532 µs / +3.18% Paste-ready discussion summary—if this goes on GitHub, please add your own commentary around the quoted AI-generated material: > AI-generated benchmark summary, provided for human review > > Benchmarked cached versus operation-scoped eager cuBLAS workspaces on an NVIDIA GB300, CC 10.3, CUDA 13.4. PyTorch was built for CC 10.0. Measurements are medians across five alternating cached/eager process trials using synchronized > torch.utils.benchmark.Timer measurements of BF16 torch.mm. > > Eager workspace allocation added approximately 0.07–0.85 µs per operation. Relative overhead ranged from 0.5% to 5.1%, with the largest measured overhead on the legacy cuBLAS (M,N,K)=(32,2048,10944) regression shape. > > The cached implementation reproduced CUDA_ERROR_ILLEGAL_ADDRESS when separately captured graphs reused a workspace on the same stream. The eager implementation passed that reproducer. > > Given that most of the eager overhead appears to come from caching-allocator allocation/free bookkeeping, a simpler alternative under consideration is to retain the existing workspace cache but explicitly clear or isolate cached workspaces at > graph-capture boundaries. ``` Pull Request resolved: https://github.com/pytorch/pytorch/pull/194311 Approved by: https://github.com/ngimel, https://github.com/eellison
787 lines
27 KiB
Python
787 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""C++ API documentation coverage checker.
|
|
|
|
Auto-discovers public C++ APIs from Doxygen XML output and checks which ones
|
|
are documented in the RST source files via Breathe or Sphinx C++ domain
|
|
directives.
|
|
|
|
Uses an exclusion list (EXCLUDED_APIS) to skip internal/detail symbols that
|
|
don't need public documentation, rather than maintaining a hardcoded allowlist.
|
|
|
|
Additionally checks built HTML for broken formatting (empty pages,
|
|
unresolved directives, rendering errors).
|
|
|
|
Usage:
|
|
python check_coverage.py # RST coverage + HTML checks
|
|
python check_coverage.py --coverxygen # also run coverxygen on Doxygen XML
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
|
|
# ─── Paths ───────────────────────────────────────────────────────────────────
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
SOURCE_DIR = SCRIPT_DIR / "source"
|
|
BUILD_HTML = SCRIPT_DIR / "build" / "html"
|
|
BUILD_XML = SCRIPT_DIR / "build" / "xml"
|
|
COVERAGE_OUTPUT = SCRIPT_DIR / "cpp_coverage.txt"
|
|
HTML_REPORT = SCRIPT_DIR / "cpp_html_issues.txt"
|
|
|
|
# ─── Inclusion override ──────────────────────────────────────────────────────
|
|
# Symbols that match an exclusion pattern but should still be tracked.
|
|
# Use this for "internal" APIs that are widely used as public API.
|
|
# Add the fully-qualified symbol name here and it will bypass all exclusions.
|
|
INCLUDED_SYMBOLS: set[str] = {
|
|
# Example: "c10::IValue" would track it even though c10::IValue is excluded
|
|
}
|
|
|
|
# ─── Exclusion list ──────────────────────────────────────────────────────────
|
|
# Symbols that should NOT be flagged as missing documentation.
|
|
# Add internal, detail, or otherwise non-public symbols here.
|
|
# Note: INCLUDED_SYMBOLS takes priority over these exclusions.
|
|
|
|
EXCLUDED_PATTERNS = [
|
|
# Internal/detail namespaces
|
|
r".*::detail::.*",
|
|
r".*::detail_::.*",
|
|
r"torch::python::.*",
|
|
# Underscore-prefixed internal classes
|
|
r".*::_\w+",
|
|
# Enum helper structs
|
|
r"torch::enumtype::.*",
|
|
# OptimizerCloneableOptions SFINAE helpers
|
|
r"torch::optim::OptimizerCloneableOptions::.*",
|
|
# Internal optimizer state/options cloneable helpers
|
|
r"torch::optim::OptimizerCloneable.*",
|
|
# Error classes (c10 exceptions)
|
|
r"c10::.*Error$",
|
|
r"c10::ErrorAlwaysShowCppStacktrace",
|
|
# Warning internals
|
|
r"c10::Warning.*",
|
|
r"c10::WarningHandler",
|
|
r"c10::WarningUtils::.*",
|
|
# c10 IValue internals
|
|
r"c10::IValue::.*",
|
|
r"c10::IValue",
|
|
r"c10::WeakIValue",
|
|
r"c10::ivalue::.*",
|
|
r"c10::StrongTypePtr",
|
|
r"c10::WeakTypePtr",
|
|
r"c10::WeakOrStrongTypePtr",
|
|
r"c10::WeakOrStrongCompilationUnit",
|
|
r"c10::Capsule",
|
|
r"c10::OptionalArray",
|
|
r"c10::StreamData3",
|
|
# OrderedDict::Item (internal helper)
|
|
r"torch::OrderedDict::Item",
|
|
# ExpandingArray (internal template utility)
|
|
r"torch::ExpandingArray.*",
|
|
# IMethod (internal)
|
|
r"torch::IMethod",
|
|
# CustomClassHolder (internal base)
|
|
r"torch::CustomClassHolder",
|
|
# NodeGuard (internal autograd)
|
|
r"torch::autograd::NodeGuard",
|
|
# Autograd internals
|
|
r"torch::autograd::CppNode",
|
|
r"torch::autograd::ExtractVariables",
|
|
r"torch::autograd::Node",
|
|
r"torch::autograd::Node::.*",
|
|
r"torch::autograd::TraceableFunction",
|
|
r"torch::autograd::TypeAndSize",
|
|
# Sequencer internals
|
|
r"torch::data::.*::detail::.*",
|
|
# cuDNN descriptor internals
|
|
r"at::native::ActivationDescriptor",
|
|
r"at::native::ConvolutionDescriptor",
|
|
r"at::native::SpatialTransformerDescriptor",
|
|
r"at::native::DropoutDescriptor",
|
|
r"at::native::RNNDataDescriptor",
|
|
r"at::native::DftiDescriptor",
|
|
r"at::native::DescriptorDeleter",
|
|
r"at::native::DftiDescriptorDeleter",
|
|
r"at::native::RNNDescriptor",
|
|
# ATen internals
|
|
r"at::OptionalTensorRef",
|
|
r"at::TensorRef",
|
|
# at::cuda internals (allocator, workspace, cublas)
|
|
r"at::cuda::WorkspaceMapWithMutex",
|
|
r"at::cuda::CUDABlasHandleWithWorkspace.*",
|
|
r"at::cuda::allocateCUDABlasWorkspace",
|
|
r"at::cuda::clearCublasWorkspaces.*",
|
|
r"at::cuda::cublas_handle_stream_to_workspace",
|
|
r"at::cuda::cublaslt_handle_stream_to_workspace",
|
|
r"at::cuda::getCUDABlasLt.*",
|
|
r"at::cuda::getCurrentCUDABlasHandleWithWorkspace",
|
|
r"at::cuda::getCUDADeviceAllocator",
|
|
r"at::cuda::getChosenWorkspaceSize",
|
|
r"at::cuda::getNumGPUs",
|
|
r"at::cuda::is_available",
|
|
r"at::cuda::isCUDABlasWorkspaceCachingEnabled",
|
|
r"at::cuda::warp_size",
|
|
# jit namespace (deprecated)
|
|
r"torch::jit::.*",
|
|
# Operators that are just operator<< or operator>>
|
|
r".*::operator<<",
|
|
r".*::operator>>",
|
|
r".*::operator==",
|
|
r".*::operator!=",
|
|
# Internal serialize helpers
|
|
r"torch::optim::serialize",
|
|
r"torch::optim::detail::.*",
|
|
# Reduction enum helpers
|
|
r"torch::nn::reduction",
|
|
r"torch::nn::log_target",
|
|
# Internal module utils
|
|
r"torch::nn::modules::utils::.*",
|
|
# Internal c10 helpers
|
|
r"c10::detail::.*",
|
|
r"c10::detail_::.*",
|
|
r"c10::makeArrayRef",
|
|
r"c10::checkObjectSortSchema",
|
|
r"c10::getGreaterThanComparator",
|
|
r"c10::getLessThanComparator",
|
|
r"c10::value_or_else",
|
|
r"c10::warn",
|
|
r"c10::GetExceptionString",
|
|
# torch::detail
|
|
r"torch::detail::.*",
|
|
# Internal data shuttle/queue
|
|
r"torch::data::detail::.*",
|
|
# DataLoaderBase internal types
|
|
r"torch::data::DataLoaderBase::.*",
|
|
r"torch::data::WorkerException",
|
|
r"torch::data::FullDataLoaderOptions",
|
|
# Template specializations of Stack
|
|
r"torch::data::transforms::Stack< .*>",
|
|
# Example partial specialization
|
|
r"torch::data::Example< .*>",
|
|
# Doxygen internal macros
|
|
r"DEFINE_CASE",
|
|
r"DEFINE_TAG",
|
|
r"COUNT_TAG",
|
|
r"TRUTH_TABLE_ENTRY",
|
|
r"C10_EXPAND_MSVC_WORKAROUND",
|
|
r"TORCH_FORALL_TAGS",
|
|
# Non-public torch::nn functions (module stream operators, etc.)
|
|
r"torch::nn::operator.*",
|
|
# AnyModule/AnyValue internal holders
|
|
r"torch::nn::AnyModuleHolder.*",
|
|
r"torch::nn::AnyModulePlaceholder",
|
|
r"torch::nn::AnyValue.*",
|
|
r"torch::nn::NamedAnyModule",
|
|
# Internal base classes (users use the derived classes)
|
|
r"torch::nn::ConvNdImpl",
|
|
r"torch::nn::ConvTransposeNdImpl",
|
|
r"torch::nn::BatchNormImplBase",
|
|
r"torch::nn::NormImplBase",
|
|
r"torch::nn::InstanceNormImpl",
|
|
r"torch::nn::MaxPoolImpl",
|
|
r"torch::nn::AvgPoolImpl",
|
|
r"torch::nn::AdaptiveAvgPoolImpl",
|
|
r"torch::nn::AdaptiveMaxPoolImpl",
|
|
r"torch::nn::MaxUnpoolImpl",
|
|
r"torch::nn::LPPoolImpl",
|
|
r"torch::nn::ConstantPadImpl",
|
|
r"torch::nn::ReflectionPadImpl",
|
|
r"torch::nn::ReplicationPadImpl",
|
|
r"torch::nn::ZeroPadImpl",
|
|
r"torch::nn::FractionalMaxPoolImpl",
|
|
# nn::functions internal namespace
|
|
r"torch::nn::functions::.*",
|
|
# AdaptiveLogSoftmaxWithLoss (niche, rarely used in C++)
|
|
r"torch::nn::AdaptiveLogSoftmaxWithLoss.*",
|
|
r"torch::nn::ASMoutput",
|
|
# CrossMapLRN2d (niche)
|
|
r"torch::nn::CrossMapLRN2d.*",
|
|
# _out function variants (documented alongside the main function)
|
|
r"torch::special::.*_out",
|
|
r"torch::fft::.*_out",
|
|
# torch internal helpers
|
|
r"torch::InitLambda",
|
|
r"torch::dispatch",
|
|
r"torch::equal_if_defined",
|
|
r"torch::getAllCustomClassesNames",
|
|
r"torch::init",
|
|
r"torch::make_custom_class",
|
|
r"torch::selective_class_",
|
|
r"torch::pickle_load",
|
|
r"torch::pickle_save",
|
|
r"torch::schema",
|
|
r"torch::nativert::.*",
|
|
# RNNCellOptionsBase (internal base)
|
|
r".*::RNNCellOptionsBase",
|
|
# Unnamespaced Options structs (indexed without namespace by Doxygen)
|
|
r"^[A-Z]\w+Options$",
|
|
# Unnamespaced classes without namespace (Doxygen quirk)
|
|
r"^TransformerDecoderLayer$",
|
|
r"^TransformerDecoderLayerOptions$",
|
|
# functional namespace internal options structs
|
|
r"functional::.*FuncOptions",
|
|
]
|
|
|
|
# Specific symbols to exclude (exact match)
|
|
EXCLUDED_SYMBOLS = {
|
|
# Internal / not useful to document individually
|
|
"torch::data::datasets::map",
|
|
"torch::data::datasets::make_shared_dataset",
|
|
"torch::data::datasets::operator<<",
|
|
"torch::data::datasets::operator>>",
|
|
"torch::enumtype::get_enum_name",
|
|
"torch::enumtype::reduction_get_enum",
|
|
"torch::autograd::_wrap_outputs",
|
|
"torch::autograd::check_variable_result",
|
|
"torch::autograd::CppNode_apply_functional",
|
|
"torch::autograd::CppNode_apply_functional_ivalue",
|
|
"torch::autograd::forward_ad::enter_dual_level",
|
|
"torch::autograd::forward_ad::exit_dual_level",
|
|
"torch::autograd::any_variable_requires_grad",
|
|
"torch::autograd::collect_next_edges",
|
|
"torch::autograd::create_gradient_edge",
|
|
"torch::autograd::deleteNode",
|
|
"torch::autograd::extract_vars",
|
|
"torch::autograd::get_current_node",
|
|
"torch::autograd::to_optional",
|
|
"torch::autograd::to_output_type",
|
|
"torch::nn::parallel::replicate",
|
|
"torch::nn::parallel::parallel_apply",
|
|
"torch::nn::parallel::data_parallel",
|
|
"torch::python::add_module_bindings",
|
|
"torch::python::bind_module",
|
|
"torch::python::init_bindings",
|
|
# at::native cuDNN internals
|
|
"at::native::dataSize",
|
|
"at::native::fixSizeOneDimStride",
|
|
"at::native::operator<<",
|
|
"at::native::getCudnnDataTypeFromScalarType",
|
|
# c10 cuda pool functions (internal)
|
|
"c10::cuda::getStreamFromPool",
|
|
"c10::cuda::getStreamFromExternal",
|
|
"c10::xpu::getStreamFromPool",
|
|
"c10::xpu::getStreamFromExternal",
|
|
# c10 private use backend registration (internal)
|
|
"c10::get_privateuse1_backend",
|
|
"c10::is_privateuse1_backend_registered",
|
|
"c10::register_privateuse1_backend",
|
|
"c10::isValidDeviceType",
|
|
"c10::DeviceTypeName",
|
|
# torch::stable::detail internals
|
|
"torch::stable::detail::unbox_to_tuple_impl",
|
|
"torch::stable::detail::unbox_to_tuple",
|
|
"torch::stable::detail::box_from_tuple_impl",
|
|
"torch::stable::detail::box_from_tuple",
|
|
# torch::stable::accelerator (documented in stable API page)
|
|
"torch::stable::accelerator::getCurrentStream",
|
|
}
|
|
|
|
# Namespaces whose free functions should be checked for documentation
|
|
PUBLIC_FUNCTION_NAMESPACES = {
|
|
"torch",
|
|
"torch::autograd",
|
|
"torch::cuda",
|
|
"torch::mps",
|
|
"torch::xpu",
|
|
"torch::fft",
|
|
"torch::special",
|
|
"torch::nn::functional",
|
|
"torch::nn::init",
|
|
"torch::nn::utils",
|
|
"torch::nn::utils::rnn",
|
|
"torch::data",
|
|
"torch::stable",
|
|
"torch::stable::accelerator",
|
|
"c10",
|
|
"c10::cuda",
|
|
"c10::xpu",
|
|
"at::cuda",
|
|
}
|
|
|
|
|
|
# ─── XML parsing ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _is_excluded(symbol: str) -> bool:
|
|
"""Check if a symbol should be excluded from coverage tracking."""
|
|
if symbol in INCLUDED_SYMBOLS:
|
|
return False
|
|
if symbol in EXCLUDED_SYMBOLS:
|
|
return True
|
|
for pattern in EXCLUDED_PATTERNS:
|
|
if re.fullmatch(pattern, symbol):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _categorize(name: str) -> str:
|
|
"""Assign a category based on the symbol's namespace."""
|
|
if name.startswith("torch::nn::functional::"):
|
|
return "torch::nn::functional"
|
|
if name.startswith("torch::nn::init::"):
|
|
return "torch::nn::init"
|
|
if name.startswith("torch::nn::utils::"):
|
|
return "torch::nn::utils"
|
|
if name.startswith("torch::nn::"):
|
|
# Distinguish modules from other nn symbols
|
|
short = name.split("::")[-1]
|
|
if short[0].isupper():
|
|
return "torch::nn (modules)"
|
|
return "torch::nn"
|
|
if name.startswith("torch::optim::"):
|
|
return "torch::optim"
|
|
if name.startswith("torch::data::"):
|
|
return "torch::data"
|
|
if name.startswith("torch::autograd::"):
|
|
return "torch::autograd"
|
|
if name.startswith("torch::serialize::") or name in ("torch::save", "torch::load"):
|
|
return "torch::serialize"
|
|
if name.startswith("torch::stable::"):
|
|
return "torch::stable"
|
|
if name.startswith("torch::fft::"):
|
|
return "torch::fft"
|
|
if name.startswith("torch::special::"):
|
|
return "torch::special"
|
|
if name.startswith(("torch::cuda::", "torch::mps::", "torch::xpu::")):
|
|
return "torch (device)"
|
|
if name.startswith("torch::"):
|
|
return "torch (core)"
|
|
if name.startswith("c10::cuda::"):
|
|
return "c10::cuda"
|
|
if name.startswith("c10::xpu::"):
|
|
return "c10::xpu"
|
|
if name.startswith("c10::"):
|
|
return "c10"
|
|
if name.startswith("at::cuda::"):
|
|
return "at::cuda"
|
|
if name.startswith("at::"):
|
|
return "at"
|
|
return "other"
|
|
|
|
|
|
def discover_apis_from_xml(xml_dir: Path) -> dict[str, list[tuple[str, str]]]:
|
|
"""Parse Doxygen index.xml to discover all public APIs.
|
|
|
|
Returns dict of category -> list of (symbol, kind).
|
|
"""
|
|
index_path = xml_dir / "index.xml"
|
|
if not index_path.exists():
|
|
print(
|
|
f"ERROR: {index_path} not found. Run 'make doxygen' first.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
tree = ET.parse(index_path)
|
|
root = tree.getroot()
|
|
|
|
apis: dict[str, list[tuple[str, str]]] = {}
|
|
|
|
# Collect classes and structs
|
|
for compound in root.findall("compound"):
|
|
kind = compound.get("kind")
|
|
if kind not in ("class", "struct"):
|
|
continue
|
|
name = compound.find("name").text
|
|
if _is_excluded(name):
|
|
continue
|
|
category = _categorize(name)
|
|
apis.setdefault(category, []).append((name, kind))
|
|
|
|
# Collect free functions from public namespaces
|
|
for compound in root.findall("compound"):
|
|
if compound.get("kind") != "namespace":
|
|
continue
|
|
ns_name = compound.find("name").text
|
|
if ns_name not in PUBLIC_FUNCTION_NAMESPACES:
|
|
continue
|
|
seen_funcs = set()
|
|
for member in compound.findall("member"):
|
|
if member.get("kind") != "function":
|
|
continue
|
|
func_name = member.find("name").text
|
|
qualified = f"{ns_name}::{func_name}"
|
|
if qualified in seen_funcs:
|
|
continue # skip overloads
|
|
seen_funcs.add(qualified)
|
|
if _is_excluded(qualified):
|
|
continue
|
|
category = _categorize(qualified)
|
|
apis.setdefault(category, []).append((qualified, "function"))
|
|
|
|
# Collect macros (defines) from file compounds
|
|
for compound in root.findall("compound"):
|
|
if compound.get("kind") != "file":
|
|
continue
|
|
for member in compound.findall("member"):
|
|
if member.get("kind") != "define":
|
|
continue
|
|
macro_name = member.find("name").text
|
|
# Only track well-known public macros
|
|
if macro_name.startswith(("TORCH_LIBRARY", "TORCH_MODULE")):
|
|
if _is_excluded(macro_name):
|
|
continue
|
|
apis.setdefault("torch (macros)", []).append((macro_name, "define"))
|
|
|
|
# Sort each category and deduplicate
|
|
for category in apis:
|
|
apis[category] = sorted(set(apis[category]))
|
|
|
|
return apis
|
|
|
|
|
|
# ─── Source scanning ─────────────────────────────────────────────────────────
|
|
|
|
# RST directives: .. doxygenclass:: torch::nn::ReLU
|
|
RST_DIRECTIVE_RE = re.compile(
|
|
r"^\.\.\s+doxygen(class|struct|function|typedef|define|enum|namespace)"
|
|
r"::\s*(.+?)\s*$",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
RST_CPP_DIRECTIVE_RE = re.compile(
|
|
r"^\.\.\s+cpp:(class|struct|function|enum|type)" r"::\s*(.+?)\s*$",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
# MyST directives: ```{doxygenclass} torch::nn::ReLU
|
|
MYST_DIRECTIVE_RE = re.compile(
|
|
r"^`{3,}\{doxygen(class|struct|function|typedef|define|enum|namespace)\}\s*(.+?)\s*$",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
MYST_CPP_DIRECTIVE_RE = re.compile(
|
|
r"^`{3,}\{cpp:(class|struct|function|enum|type)\}\s*(.+?)\s*$",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
|
|
def scan_sources(source_dir: Path) -> set[str]:
|
|
"""Extract all documented symbols from RST/MyST breathe and cpp domain directives."""
|
|
documented = set()
|
|
for src_file in list(source_dir.rglob("*.rst")) + list(source_dir.rglob("*.md")):
|
|
content = src_file.read_text(errors="replace")
|
|
patterns = (
|
|
RST_DIRECTIVE_RE,
|
|
RST_CPP_DIRECTIVE_RE,
|
|
MYST_DIRECTIVE_RE,
|
|
MYST_CPP_DIRECTIVE_RE,
|
|
)
|
|
for pattern in patterns:
|
|
for match in pattern.finditer(content):
|
|
symbol = match.group(2)
|
|
# Strip template prefix
|
|
if symbol.startswith("template"):
|
|
gt = symbol.find(">")
|
|
if gt != -1:
|
|
symbol = symbol[gt + 1 :].lstrip()
|
|
# Strip function signature
|
|
paren = symbol.find("(")
|
|
if paren != -1:
|
|
symbol = symbol[:paren].rstrip()
|
|
documented.add(symbol)
|
|
return documented
|
|
|
|
|
|
# ─── Coverage report ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def generate_coverage_report(
|
|
apis: dict[str, list[tuple[str, str]]], documented: set[str]
|
|
) -> str:
|
|
"""Generate a coverage report comparing discovered APIs against RST docs."""
|
|
lines = []
|
|
lines.append("Undocumented C++ objects")
|
|
lines.append("=" * 50)
|
|
lines.append("")
|
|
|
|
total = 0
|
|
total_missing = 0
|
|
section_stats = []
|
|
|
|
for category in sorted(apis.keys()):
|
|
symbols = apis[category]
|
|
section_missing = []
|
|
for symbol, kind in symbols:
|
|
total += 1
|
|
unqualified = symbol.rsplit("::", 1)[-1]
|
|
if symbol not in documented and unqualified not in documented:
|
|
section_missing.append((symbol, kind))
|
|
total_missing += 1
|
|
|
|
covered = len(symbols) - len(section_missing)
|
|
section_stats.append((category, covered, len(symbols)))
|
|
|
|
if section_missing:
|
|
lines.append(category)
|
|
lines.append("-" * len(category))
|
|
for symbol, kind in section_missing:
|
|
lines.append(f" * {symbol} ({kind})")
|
|
lines.append("")
|
|
|
|
# Summary
|
|
total_covered = total - total_missing
|
|
pct = (total_covered / total * 100) if total else 0
|
|
|
|
lines.append("")
|
|
lines.append("=" * 50)
|
|
lines.append("Summary")
|
|
lines.append("=" * 50)
|
|
lines.append("")
|
|
lines.append(f"Total APIs discovered: {total}")
|
|
lines.append(f"Documented: {total_covered}")
|
|
lines.append(f"Missing: {total_missing}")
|
|
lines.append(f"Coverage: {pct:.1f}%")
|
|
lines.append("")
|
|
|
|
# Per-section table
|
|
lines.append(f"{'Category':<45} {'Covered':>8} {'Total':>6} {'%':>7}")
|
|
lines.append("-" * 70)
|
|
for category, covered, section_total in section_stats:
|
|
spct = (covered / section_total * 100) if section_total else 0
|
|
lines.append(f"{category:<45} {covered:>8} {section_total:>6} {spct:>6.1f}%")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ─── HTML checks ─────────────────────────────────────────────────────────────
|
|
|
|
BROKEN_PATTERNS = [
|
|
(
|
|
re.compile(r"Cannot find (?:class|struct|function|file)", re.IGNORECASE),
|
|
"unresolved breathe directive",
|
|
),
|
|
(
|
|
re.compile(r"Unable to resolve (?:class|struct|function)", re.IGNORECASE),
|
|
"unresolved breathe directive (ambiguous overload)",
|
|
),
|
|
(
|
|
re.compile(r"doxygenclass:|doxygenfunction:|doxygenstruct:", re.IGNORECASE),
|
|
"raw directive text in output",
|
|
),
|
|
(
|
|
re.compile(r"<span class=\"problematic\">", re.IGNORECASE),
|
|
"Sphinx problematic node (broken reference)",
|
|
),
|
|
(
|
|
re.compile(r"System Message:", re.IGNORECASE),
|
|
"Sphinx system message (build error)",
|
|
),
|
|
]
|
|
|
|
MIN_CONTENT_LENGTH = 500
|
|
|
|
|
|
def check_html_output(build_dir: Path) -> str:
|
|
"""Check built HTML for broken formatting and empty pages."""
|
|
issues = []
|
|
|
|
if not build_dir.exists():
|
|
return "ERROR: build/html directory not found. Run 'make html' first.\n"
|
|
|
|
for html_file in sorted(build_dir.rglob("*.html")):
|
|
rel = html_file.relative_to(build_dir)
|
|
if rel.name in ("search.html", "genindex.html", "objects.inv"):
|
|
continue
|
|
|
|
try:
|
|
content = html_file.read_text(errors="replace")
|
|
except Exception as e:
|
|
issues.append((str(rel), f"cannot read: {e}"))
|
|
continue
|
|
|
|
for pattern, description in BROKEN_PATTERNS:
|
|
matches = pattern.findall(content)
|
|
if matches:
|
|
issues.append((str(rel), f"{description} ({len(matches)}x)"))
|
|
|
|
if str(rel).startswith("api/"):
|
|
text = re.sub(r"<[^>]+>", "", content)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
if len(text) < MIN_CONTENT_LENGTH:
|
|
issues.append((str(rel), f"possibly empty page ({len(text)} chars)"))
|
|
|
|
lines = []
|
|
lines.append("HTML Formatting Check")
|
|
lines.append("=" * 50)
|
|
lines.append("")
|
|
|
|
if not issues:
|
|
lines.append("No issues found.")
|
|
else:
|
|
lines.append(f"Found {len(issues)} issue(s):")
|
|
lines.append("")
|
|
lines.append(f"{'File':<55} Issue")
|
|
lines.append("-" * 90)
|
|
for filepath, issue in issues:
|
|
lines.append(f"{filepath:<55} {issue}")
|
|
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ─── coverxygen integration ─────────────────────────────────────────────────
|
|
|
|
|
|
def run_coverxygen(xml_dir: Path) -> str:
|
|
"""Run coverxygen on Doxygen XML output for doc-comment coverage."""
|
|
lines = []
|
|
lines.append("Coverxygen Report (Doxygen doc-comment coverage)")
|
|
lines.append("=" * 50)
|
|
lines.append("")
|
|
|
|
if not xml_dir.exists():
|
|
lines.append("ERROR: build/xml directory not found. Run 'make doxygen' first.")
|
|
return "\n".join(lines)
|
|
|
|
coverxygen_cmd = None
|
|
for cmd in [
|
|
["coverxygen", "--version"],
|
|
[sys.executable, "-m", "coverxygen", "--version"],
|
|
]:
|
|
try:
|
|
subprocess.run(cmd, capture_output=True, check=True)
|
|
coverxygen_cmd = cmd[:-1]
|
|
break
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
continue
|
|
if coverxygen_cmd is None:
|
|
lines.append("coverxygen not installed. Install with: pip install coverxygen")
|
|
lines.append("")
|
|
lines.append("Once installed, coverxygen analyzes Doxygen XML to report what")
|
|
lines.append("percentage of C++ symbols have doc comments in the source code.")
|
|
lines.append("This is complementary to the RST coverage check above.")
|
|
lines.append("")
|
|
lines.append("Usage:")
|
|
lines.append(
|
|
f" coverxygen --xml-dir {xml_dir} --src-dir ../../ --output coverxygen.info"
|
|
)
|
|
lines.append(" # Then use lcov/genhtml to visualize:")
|
|
lines.append(
|
|
" genhtml --no-function-coverage coverxygen.info -o coverxygen_html"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
coverxygen_cmd
|
|
+ [
|
|
"--xml-dir",
|
|
str(xml_dir),
|
|
"--src-dir",
|
|
str(SCRIPT_DIR / ".." / ".."),
|
|
"--output",
|
|
"-",
|
|
"--kind",
|
|
"class,struct,function",
|
|
"--scope",
|
|
"public",
|
|
"--exclude",
|
|
".*/build/.*",
|
|
"--exclude",
|
|
".*/detail/.*",
|
|
"--exclude",
|
|
".*/nativert/.*",
|
|
"--exclude",
|
|
".*/stable/library\\.h",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
if result.returncode == 0:
|
|
total = 0
|
|
documented_count = 0
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith("DA:"):
|
|
total += 1
|
|
parts = line.split(",")
|
|
if len(parts) >= 2 and parts[1].strip() != "0":
|
|
documented_count += 1
|
|
pct = (documented_count / total * 100) if total else 0
|
|
lines.append(f"Symbols scanned: {total}")
|
|
lines.append(f"With doc comments: {documented_count}")
|
|
lines.append(f"Coverage: {pct:.1f}%")
|
|
lines.append("")
|
|
lines.append("Full lcov output saved to: coverxygen.info")
|
|
(SCRIPT_DIR / "coverxygen.info").write_text(result.stdout)
|
|
else:
|
|
lines.append(f"coverxygen failed (exit {result.returncode}):")
|
|
lines.append(result.stderr[:500])
|
|
except subprocess.TimeoutExpired:
|
|
lines.append("coverxygen timed out after 120s")
|
|
except Exception as e:
|
|
lines.append(f"coverxygen error: {e}")
|
|
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="C++ docs coverage checker")
|
|
parser.add_argument(
|
|
"--coverxygen",
|
|
action="store_true",
|
|
help="Also run coverxygen on Doxygen XML for doc-comment coverage",
|
|
)
|
|
parser.add_argument(
|
|
"--html-only",
|
|
action="store_true",
|
|
help="Only run HTML formatting checks",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
reports = []
|
|
|
|
if not args.html_only:
|
|
# Phase 1: Discover APIs from Doxygen XML
|
|
print("Discovering APIs from Doxygen XML...")
|
|
apis = discover_apis_from_xml(BUILD_XML)
|
|
total_apis = sum(len(v) for v in apis.values())
|
|
print(f" Found {total_apis} public APIs across {len(apis)} categories")
|
|
|
|
# Phase 2: Scan RST for documented symbols
|
|
print("Scanning sources for breathe directives...")
|
|
documented = scan_sources(SOURCE_DIR)
|
|
print(f" Found {len(documented)} documented symbols")
|
|
|
|
coverage_report = generate_coverage_report(apis, documented)
|
|
reports.append(coverage_report)
|
|
|
|
COVERAGE_OUTPUT.write_text(coverage_report)
|
|
print(f" Coverage report written to: {COVERAGE_OUTPUT}")
|
|
|
|
# Phase 3: HTML checks
|
|
print("Checking HTML output for formatting issues...")
|
|
html_report = check_html_output(BUILD_HTML)
|
|
reports.append(html_report)
|
|
HTML_REPORT.write_text(html_report)
|
|
print(f" HTML report written to: {HTML_REPORT}")
|
|
|
|
# Phase 4: coverxygen (optional)
|
|
if args.coverxygen:
|
|
print("Running coverxygen...")
|
|
cov_report = run_coverxygen(BUILD_XML)
|
|
reports.append(cov_report)
|
|
|
|
# Print everything
|
|
print()
|
|
print("=" * 60)
|
|
for report in reports:
|
|
print(report)
|
|
print()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|