tools: add a static public-API audit and record its first run
Adds tools/api_audit.py, a read-only static audit of the public API surface, and docs/api-audit.md, its first run against this revision. The audit answers three questions the reference cannot answer about itself: 1. Does every declared export exist, have a docstring, and appear in docs/api.md? It reads the root __all__ plus each submodule's __all__. 2. How complete is each public definition? Docstring presence, detected docstring convention, parameter and return annotations. 3. Which public names do examples and tests actually use but the reference never explains? That set is the real documentation gap. It parses with ast and never imports the package, so it costs no numpy/cv2 import and cannot be affected by the environment. Findings on this revision (full detail in docs/api-audit.md): - 393 public classes/methods/functions; 65% have a docstring, 32% have a fully annotated signature, 38 document their return value. - 243 docstrings are unstructured prose, 137 are absent, 13 use Google or NumPy sections. There is effectively one convention: none. - 9 of the 18 root exports are absent from docs/api.md, including StreamPipeline, StreamRecorder, mosaic_videos and FFCFrameProcessor - the pipelined multi-stream and offline-recording paths. - Six sub-surfaces have zero coverage in the reference: unroll (18 exports), capture (17), hhd + solvers (7), pipeline (3), export (2). - 74 names are used by examples or tests but absent from the reference. - 21 public classes are exported nowhere; LowpassFilter is documented in docs/api.md and importable, but declared in no __all__. No behaviour change: a new tool and a new document, no source file touched.
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit the public API surface of the orisys SDK: what is declared, what is documented.
|
||||
|
||||
Read-only and static. Never imports the package, so it costs no numpy/cv2 import and cannot be
|
||||
affected by the environment.
|
||||
|
||||
What it reports
|
||||
1. Coverage of every declared export (the root ``__all__`` plus each submodule's ``__all__``):
|
||||
where it lives, whether it has a docstring, docstring style, whether its parameters and
|
||||
return value are annotated, and whether docs/api.md mentions it.
|
||||
2. The docstring and annotation coverage of every public class and method, per module.
|
||||
3. Public definitions that are *not* exported anywhere — candidates for hiding or for adding to
|
||||
an ``__all__``.
|
||||
4. Public names used by ``examples/`` and ``tests/`` but absent from docs/api.md. These are the
|
||||
APIs users reach for that the reference does not explain.
|
||||
|
||||
Output is a text report by default, or ``--json`` for machine use.
|
||||
|
||||
Usage::
|
||||
|
||||
python tools/api_audit.py # text report
|
||||
python tools/api_audit.py --json # machine-readable
|
||||
python tools/api_audit.py --quiet # headline numbers only
|
||||
|
||||
Exit codes: 0 always. This is a report, not a gate; add thresholds when the numbers are known.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
# The report contains Chinese headings from the docs it inspects.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError): # pragma: no cover
|
||||
pass
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SRC = REPO / "src" / "orisys"
|
||||
DOC = REPO / "docs" / "api.md"
|
||||
USAGE_DIRS = ("examples", "tests")
|
||||
|
||||
#: Docstring conventions, detected from the body. Order matters: the first match wins.
|
||||
STYLE_PATTERNS = (
|
||||
("google", re.compile(r"(?m)^\s*(Args|Arguments|Returns|Raises|Yields|Attributes)\s*:")),
|
||||
("numpy", re.compile(r"(?m)^\s*(Parameters|Returns|Raises|Attributes)\s*\n\s*-{3,}")),
|
||||
("sphinx", re.compile(r"(?m)^\s*:(param|type|returns?|rtype|raises)\b")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Symbol:
|
||||
"""One public definition, keyed by 'module:qualname'."""
|
||||
|
||||
module: str
|
||||
qualname: str
|
||||
kind: str # class | function | method
|
||||
docstring: str
|
||||
style: str
|
||||
params: int = 0
|
||||
params_annotated: int = 0
|
||||
has_return_annotation: bool = False
|
||||
has_return_section: bool = False
|
||||
|
||||
@property
|
||||
def documented(self) -> bool:
|
||||
return bool(self.docstring.strip())
|
||||
|
||||
@property
|
||||
def signature_complete(self) -> bool:
|
||||
return self.params == self.params_annotated and self.has_return_annotation
|
||||
|
||||
|
||||
@dataclass
|
||||
class Analysis:
|
||||
symbols: dict[str, Symbol] = field(default_factory=dict)
|
||||
classes: dict[str, list[str]] = field(default_factory=dict) # qualname -> method names
|
||||
exported: dict[str, list[str]] = field(default_factory=dict) # source module -> names
|
||||
root_all: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def module_name(path: Path) -> str:
|
||||
rel = path.relative_to(SRC).as_posix()
|
||||
if rel.endswith("/__init__.py"):
|
||||
rel = rel[: -len("/__init__.py")]
|
||||
elif rel == "__init__.py":
|
||||
return "orisys"
|
||||
elif rel.endswith(".py"):
|
||||
rel = rel[:-3]
|
||||
dotted = rel.replace("/", ".").removesuffix(".__init__")
|
||||
return f"orisys.{dotted}" if dotted else "orisys"
|
||||
|
||||
|
||||
def detect_style(doc: str) -> str:
|
||||
if not doc.strip():
|
||||
return "none"
|
||||
for name, pattern in STYLE_PATTERNS:
|
||||
if pattern.search(doc):
|
||||
return name
|
||||
return "plain"
|
||||
|
||||
|
||||
def param_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.arg]:
|
||||
args = [*node.args.args, *node.args.kwonlyargs]
|
||||
if node.args.vararg:
|
||||
args.append(node.args.vararg)
|
||||
if node.args.kwarg:
|
||||
args.append(node.args.kwarg)
|
||||
return [a for a in args if a.arg not in ("self", "cls")]
|
||||
|
||||
|
||||
def has_return_section(doc: str) -> bool:
|
||||
lowered = doc.lower()
|
||||
return "returns:" in lowered or "return:" in lowered or ":returns:" in lowered or ":return:" in lowered
|
||||
|
||||
|
||||
def collect() -> Analysis:
|
||||
analysis = Analysis()
|
||||
for path in sorted(SRC.rglob("*.py")):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
mod = module_name(path)
|
||||
|
||||
# __all__ declarations
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(
|
||||
getattr(t, "id", "") == "__all__" for t in node.targets
|
||||
):
|
||||
names = [e.value for e in getattr(node.value, "elts", []) if isinstance(e, ast.Constant)]
|
||||
analysis.exported[mod] = names
|
||||
if mod == "orisys":
|
||||
analysis.root_all = names
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if node.name.startswith("_"):
|
||||
continue
|
||||
doc = ast.get_docstring(node) or ""
|
||||
if isinstance(node, ast.ClassDef):
|
||||
key = f"{mod}:{node.name}"
|
||||
analysis.symbols[key] = Symbol(mod, node.name, "class", doc, detect_style(doc))
|
||||
methods: list[str] = []
|
||||
for sub in node.body:
|
||||
if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and not sub.name.startswith("_"):
|
||||
params = param_names(sub)
|
||||
mdoc = ast.get_docstring(sub) or ""
|
||||
mkey = f"{mod}:{node.name}.{sub.name}"
|
||||
analysis.symbols[mkey] = Symbol(
|
||||
mod, f"{node.name}.{sub.name}", "method", mdoc, detect_style(mdoc),
|
||||
params=len(params),
|
||||
params_annotated=sum(1 for a in params if a.annotation is not None),
|
||||
has_return_annotation=sub.returns is not None,
|
||||
has_return_section=has_return_section(mdoc),
|
||||
)
|
||||
methods.append(sub.name)
|
||||
analysis.classes[node.name] = methods
|
||||
else:
|
||||
params = param_names(node)
|
||||
key = f"{mod}:{node.name}"
|
||||
analysis.symbols[key] = Symbol(
|
||||
mod, node.name, "function", doc, detect_style(doc),
|
||||
params=len(params),
|
||||
params_annotated=sum(1 for a in params if a.annotation is not None),
|
||||
has_return_annotation=node.returns is not None,
|
||||
has_return_section=has_return_section(doc),
|
||||
)
|
||||
return analysis
|
||||
|
||||
|
||||
def doc_names() -> tuple[set[str], str]:
|
||||
"""Identifiers mentioned in docs/api.md, plus the raw text."""
|
||||
text = DOC.read_text(encoding="utf-8") if DOC.is_file() else ""
|
||||
names = set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", text))
|
||||
return names, text
|
||||
|
||||
|
||||
def usage_names() -> dict[str, set[str]]:
|
||||
"""Public names imported or attribute-accessed by examples/ and tests/."""
|
||||
used: dict[str, set[str]] = {}
|
||||
for sub in USAGE_DIRS:
|
||||
base = REPO / sub
|
||||
if not base.is_dir():
|
||||
continue
|
||||
found: set[str] = set()
|
||||
for path in base.rglob("*.py"):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (SyntaxError, OSError):
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
for alias in node.names:
|
||||
found.add(alias.name.split(".")[-1])
|
||||
for part in alias.name.split("."):
|
||||
found.add(part)
|
||||
elif isinstance(node, ast.Attribute):
|
||||
found.add(node.attr)
|
||||
used[sub] = found
|
||||
return used
|
||||
|
||||
|
||||
def resolve_export(analysis: Analysis, name: str) -> Symbol | None:
|
||||
"""Find where an exported name is actually defined."""
|
||||
if name in analysis.classes:
|
||||
for key, sym in analysis.symbols.items():
|
||||
if sym.qualname == name and sym.kind == "class":
|
||||
return sym
|
||||
for key, sym in analysis.symbols.items():
|
||||
if sym.qualname == name and sym.kind in ("class", "function"):
|
||||
return sym
|
||||
return None
|
||||
|
||||
|
||||
def build_report(analysis: Analysis, names_in_doc: set[str], used: dict[str, set[str]]) -> dict:
|
||||
symbols = analysis.symbols
|
||||
|
||||
docstrings = sum(1 for s in symbols.values() if s.documented)
|
||||
annotated = sum(1 for s in symbols.values() if s.signature_complete)
|
||||
returns_documented = sum(1 for s in symbols.values() if s.has_return_section)
|
||||
|
||||
styles: dict[str, int] = {}
|
||||
for s in symbols.values():
|
||||
styles[s.style] = styles.get(s.style, 0) + 1
|
||||
|
||||
per_module: dict[str, dict[str, int]] = {}
|
||||
for s in symbols.values():
|
||||
bucket = per_module.setdefault(s.module, {"total": 0, "documented": 0, "annotated": 0})
|
||||
bucket["total"] += 1
|
||||
bucket["documented"] += 1 if s.documented else 0
|
||||
bucket["annotated"] += 1 if s.signature_complete else 0
|
||||
|
||||
# Declared exports. Submodule packages export their own names; the root re-exports some.
|
||||
declared: list[dict] = []
|
||||
for mod, names in sorted(analysis.exported.items()):
|
||||
if mod == "orisys":
|
||||
continue
|
||||
for name in names:
|
||||
sym = resolve_export(analysis, name)
|
||||
declared.append({
|
||||
"name": name,
|
||||
"exported_by": mod,
|
||||
"defined_in": sym.module if sym else None,
|
||||
"in_api_md": name in names_in_doc,
|
||||
"has_docstring": bool(sym and sym.documented),
|
||||
"style": sym.style if sym else "n/a",
|
||||
})
|
||||
|
||||
root_exports = []
|
||||
for name in analysis.root_all:
|
||||
sym = resolve_export(analysis, name)
|
||||
root_exports.append({
|
||||
"name": name,
|
||||
"defined_in": sym.module if sym else "(submodule or re-export)",
|
||||
"in_api_md": name in names_in_doc,
|
||||
"has_docstring": bool(sym and sym.documented),
|
||||
"style": sym.style if sym else "n/a",
|
||||
})
|
||||
|
||||
exported_names = {d["name"] for d in declared} | set(analysis.root_all)
|
||||
unexported = [
|
||||
s.qualname
|
||||
for s in symbols.values()
|
||||
if s.kind == "class" and s.qualname not in exported_names
|
||||
]
|
||||
|
||||
# A name used by examples/tests that the reference never explains. Restricted to names this
|
||||
# package actually defines, so local variables and stdlib names do not appear.
|
||||
defined_names = set(analysis.classes) | {s.qualname for s in symbols.values()}
|
||||
used_but_undocumented = sorted({
|
||||
name
|
||||
for sub in used.values()
|
||||
for name in sub
|
||||
if name not in names_in_doc and name in defined_names
|
||||
})
|
||||
|
||||
return {
|
||||
"totals": {
|
||||
"public_symbols": len(symbols),
|
||||
"classes": sum(1 for s in symbols.values() if s.kind == "class"),
|
||||
"methods": sum(1 for s in symbols.values() if s.kind == "method"),
|
||||
"functions": sum(1 for s in symbols.values() if s.kind == "function"),
|
||||
"with_docstring": docstrings,
|
||||
"documented_pct": round(100 * docstrings / max(len(symbols), 1)),
|
||||
"signature_complete": annotated,
|
||||
"signature_complete_pct": round(100 * annotated / max(len(symbols), 1)),
|
||||
"return_section": returns_documented,
|
||||
},
|
||||
"docstring_styles": dict(sorted(styles.items(), key=lambda kv: -kv[1])),
|
||||
"per_module": dict(sorted(per_module.items())),
|
||||
"root_exports": root_exports,
|
||||
"declared_exports": declared,
|
||||
"unexported_public_classes": sorted(unexported),
|
||||
"used_but_undocumented": used_but_undocumented,
|
||||
"usage_dirs": {k: len(v) for k, v in used.items()},
|
||||
}
|
||||
|
||||
|
||||
def print_text(report: dict) -> None:
|
||||
t = report["totals"]
|
||||
print("=" * 78)
|
||||
print("orisys SDK public API audit")
|
||||
print("=" * 78)
|
||||
print(f" public classes/methods/functions : {t['public_symbols']} "
|
||||
f"({t['classes']} classes, {t['methods']} methods, {t['functions']} functions)")
|
||||
print(f" with a docstring : {t['with_docstring']} ({t['documented_pct']}%)")
|
||||
print(f" fully annotated signature : {t['signature_complete']} ({t['signature_complete_pct']}%)")
|
||||
print(f" docstring documents its return : {t['return_section']}")
|
||||
print()
|
||||
print(" docstring conventions in use:")
|
||||
for style, count in report["docstring_styles"].items():
|
||||
print(f" {style:<10} {count}")
|
||||
|
||||
print()
|
||||
print("-" * 78)
|
||||
print("ROOT `__all__` — the declared top-level API")
|
||||
print("-" * 78)
|
||||
print(f" {'name':<24}{'defined in':<22}{'in api.md':<11}{'docstring'}")
|
||||
for row in report["root_exports"]:
|
||||
print(f" {row['name']:<24}{str(row['defined_in']):<22}"
|
||||
f"{'yes' if row['in_api_md'] else 'NO':<11}{'yes' if row['has_docstring'] else 'NO'}")
|
||||
missing = [r["name"] for r in report["root_exports"] if not r["in_api_md"]]
|
||||
print()
|
||||
print(f" not mentioned in docs/api.md: {len(missing)} of {len(report['root_exports'])}")
|
||||
if missing:
|
||||
print(f" {', '.join(missing)}")
|
||||
|
||||
print()
|
||||
print("-" * 78)
|
||||
print("SUB-SURFACE EXPORTS — coverage per declaring module")
|
||||
print("-" * 78)
|
||||
by_mod: dict[str, list[dict]] = {}
|
||||
for row in report["declared_exports"]:
|
||||
by_mod.setdefault(row["exported_by"], []).append(row)
|
||||
for mod, rows in by_mod.items():
|
||||
in_doc = sum(1 for r in rows if r["in_api_md"])
|
||||
documented = sum(1 for r in rows if r["has_docstring"])
|
||||
print(f" {mod:<22} {len(rows):>3} exports in api.md: {in_doc:>3} "
|
||||
f"have docstring: {documented:>3}")
|
||||
undocumented = [r for r in report["declared_exports"] if not r["has_docstring"]]
|
||||
print()
|
||||
print(f" exports with NO docstring: {len(undocumented)} of {len(report['declared_exports'])}")
|
||||
for row in undocumented[:20]:
|
||||
print(f" {row['exported_by']:<22} {row['name']}")
|
||||
if len(undocumented) > 20:
|
||||
print(f" ... and {len(undocumented) - 20} more")
|
||||
|
||||
print()
|
||||
print("-" * 78)
|
||||
print("DOC STRING / ANNOTATION COVERAGE BY MODULE")
|
||||
print("-" * 78)
|
||||
print(f" {'module':<34}{'symbols':>8}{'docs':>7}{'docs%':>7}{'annot':>7}{'annot%':>7}")
|
||||
for mod, b in report["per_module"].items():
|
||||
if b["total"] < 3:
|
||||
continue
|
||||
print(f" {mod:<34}{b['total']:>8}{b['documented']:>7}"
|
||||
f"{round(100*b['documented']/b['total']):>6}%"
|
||||
f"{b['annotated']:>7}{round(100*b['annotated']/b['total']):>6}%")
|
||||
|
||||
print()
|
||||
print("-" * 78)
|
||||
print("USED BY EXAMPLES/TESTS BUT ABSENT FROM docs/api.md")
|
||||
print("-" * 78)
|
||||
used_undoc = report["used_but_undocumented"]
|
||||
print(f" {len(used_undoc)} name(s)")
|
||||
for name in used_undoc[:30]:
|
||||
print(f" {name}")
|
||||
if len(used_undoc) > 30:
|
||||
print(f" ... and {len(used_undoc) - 30} more")
|
||||
|
||||
print()
|
||||
print("-" * 78)
|
||||
print("PUBLIC CLASSES NOT EXPORTED ANYWHERE — hide, or add to an `__all__`")
|
||||
print("-" * 78)
|
||||
unexp = report["unexported_public_classes"]
|
||||
print(f" {len(unexp)} class(es)")
|
||||
for name in unexp[:30]:
|
||||
print(f" {name}")
|
||||
if len(unexp) > 30:
|
||||
print(f" ... and {len(unexp) - 30} more")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--json", action="store_true", help="machine-readable report on stdout")
|
||||
parser.add_argument("--quiet", action="store_true", help="headline numbers only")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not SRC.is_dir():
|
||||
print(f"api_audit: source not found at {SRC}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
analysis = collect()
|
||||
names_in_doc, _text = doc_names()
|
||||
used = usage_names()
|
||||
report = build_report(analysis, names_in_doc, used)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
if args.quiet:
|
||||
t = report["totals"]
|
||||
print(f"public={t['public_symbols']} docs={t['documented_pct']}% "
|
||||
f"annotated={t['signature_complete_pct']}% "
|
||||
f"root_exports_missing_from_api_md="
|
||||
f"{sum(1 for r in report['root_exports'] if not r['in_api_md'])}")
|
||||
return 0
|
||||
|
||||
print_text(report)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user