YOLO-Master-WebUI-Demo / docs /build_reference.py
isLinXu
Track images with Xet; remove caches
2793310
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
"""
Helper file to build Ultralytics Docs reference section.
This script recursively walks through the ultralytics directory and builds a MkDocs reference section of *.md files
composed of classes and functions, and also creates a navigation menu for use in mkdocs.yaml.
Note: Must be run from repository root directory. Do not run from docs directory.
"""
from __future__ import annotations
import ast
import html
import re
import subprocess
import textwrap
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from ultralytics.utils.tqdm import TQDM
# Constants
FILE = Path(__file__).resolve()
REPO_ROOT = FILE.parents[1]
PACKAGE_DIR = REPO_ROOT / "ultralytics"
REFERENCE_DIR = PACKAGE_DIR.parent / "docs/en/reference"
GITHUB_REPO = "ultralytics/ultralytics"
SIGNATURE_LINE_LENGTH = 120
# Use Font Awesome brand GitHub icon (CSS already loaded via mkdocs.yml and HTML head)
GITHUB_ICON = '<i class="fa-brands fa-github" aria-hidden="true" style="margin-right:6px;"></i>'
MKDOCS_YAML = PACKAGE_DIR.parent / "mkdocs.yml"
INCLUDE_SPECIAL_METHODS = {
"__call__",
"__dir__",
"__enter__",
"__exit__",
"__aenter__",
"__aexit__",
"__getitem__",
"__iter__",
"__len__",
"__next__",
"__getattr__",
}
PROPERTY_DECORATORS = {"property", "cached_property"}
CLASS_DEF_RE = re.compile(r"(?:^|\n)class\s(\w+)(?:\(|:)")
FUNC_DEF_RE = re.compile(r"(?:^|\n)(?:async\s+)?def\s(\w+)\(")
SECTION_ENTRY_RE = re.compile(r"([\w*]+)\s*(?:\(([^)]+)\))?:\s*(.*)")
RETURNS_RE = re.compile(r"([^:]+):\s*(.*)")
@dataclass
class ParameterDoc:
"""Structured documentation for parameters, attributes, and exceptions."""
name: str
type: str | None
description: str
default: str | None = None
@dataclass
class ReturnDoc:
"""Structured documentation for return and yield values."""
type: str | None
description: str
@dataclass
class ParsedDocstring:
"""Normalized representation of a Google-style docstring."""
summary: str = ""
description: str = ""
params: list[ParameterDoc] = field(default_factory=list)
attributes: list[ParameterDoc] = field(default_factory=list)
returns: list[ReturnDoc] = field(default_factory=list)
yields: list[ReturnDoc] = field(default_factory=list)
raises: list[ParameterDoc] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
examples: list[str] = field(default_factory=list)
@dataclass
class DocItem:
"""Represents a documented symbol (class, function, method, or property)."""
name: str
qualname: str
kind: Literal["class", "function", "method", "property"]
signature: str
doc: ParsedDocstring
signature_params: list[ParameterDoc]
lineno: int
end_lineno: int
bases: list[str] = field(default_factory=list)
children: list[DocItem] = field(default_factory=list)
module_path: str = ""
source: str = ""
@dataclass
class DocumentedModule:
"""Container for all documented items within a Python module."""
path: Path
module_path: str
classes: list[DocItem]
functions: list[DocItem]
# --------------------------------------------------------------------------------------------- #
# Placeholder (legacy) generation for mkdocstrings-style stubs
# --------------------------------------------------------------------------------------------- #
def extract_classes_and_functions(filepath: Path) -> tuple[list[str], list[str]]:
"""Extract top-level class and (a)sync function names from a Python file."""
content = filepath.read_text()
classes = CLASS_DEF_RE.findall(content)
functions = FUNC_DEF_RE.findall(content)
return classes, functions
def create_placeholder_markdown(py_filepath: Path, module_path: str, classes: list[str], functions: list[str]) -> Path:
"""Create a minimal Markdown stub used by mkdocstrings."""
md_filepath = REFERENCE_DIR / py_filepath.relative_to(PACKAGE_DIR).with_suffix(".md")
exists = md_filepath.exists()
header_content = ""
if exists:
current = md_filepath.read_text()
if current.startswith("---"):
parts = current.split("---", 2)
if len(parts) > 2:
header_content = f"---{parts[1]}---\n\n"
if not header_content:
header_content = "---\ndescription: TODO ADD DESCRIPTION\nkeywords: TODO ADD KEYWORDS\n---\n\n"
module_path_dots = module_path
module_path_fs = module_path.replace(".", "/")
url = f"https://github.com/{GITHUB_REPO}/blob/main/{module_path_fs}.py"
pretty = url.replace("__init__.py", "\\_\\_init\\_\\_.py")
title_content = f"# Reference for `{module_path_fs}.py`\n\n" + contribution_admonition(
pretty, url, kind="success", title="Improvements"
)
md_content = ["<br>\n\n"]
md_content.extend(f"## ::: {module_path_dots}.{cls}\n\n<br><br><hr><br>\n\n" for cls in classes)
md_content.extend(f"## ::: {module_path_dots}.{func}\n\n<br><br><hr><br>\n\n" for func in functions)
if md_content[-1:]:
md_content[-1] = md_content[-1].replace("<hr><br>\n\n", "")
md_filepath.parent.mkdir(parents=True, exist_ok=True)
md_filepath.write_text(header_content + title_content + "".join(md_content) + "\n")
return _relative_to_workspace(md_filepath)
def _get_source(src: str, node: ast.AST) -> str:
"""Return the source segment for an AST node with safe fallbacks."""
segment = ast.get_source_segment(src, node)
if segment:
return segment
try:
return ast.unparse(node)
except Exception:
return ""
def _format_annotation(annotation: ast.AST | None, src: str) -> str | None:
"""Format a type annotation into a compact string."""
if annotation is None:
return None
text = _get_source(src, annotation).strip()
return " ".join(text.split()) if text else None
def _format_default(default: ast.AST | None, src: str) -> str | None:
"""Format a default value expression for display."""
if default is None:
return None
text = _get_source(src, default).strip()
return " ".join(text.split()) if text else None
def _format_parameter(arg: ast.arg, default: ast.AST | None, src: str) -> str:
"""Render a single parameter with annotation and default value."""
annotation = _format_annotation(arg.annotation, src)
rendered = arg.arg
if annotation:
rendered += f": {annotation}"
default_value = _format_default(default, src)
if default_value is not None:
rendered += f" = {default_value}"
return rendered
def collect_signature_parameters(args: ast.arguments, src: str, *, skip_self: bool = True) -> list[ParameterDoc]:
"""Collect parameters from an ast.arguments object with types and defaults."""
params: list[ParameterDoc] = []
def add_param(arg: ast.arg, default_value: ast.AST | None = None):
"""Append a parameter entry, optionally skipping self/cls."""
name = arg.arg
if skip_self and name in {"self", "cls"}:
return
params.append(
ParameterDoc(
name=name,
type=_format_annotation(arg.annotation, src),
description="",
default=_format_default(default_value, src),
)
)
posonly = list(getattr(args, "posonlyargs", []))
regular = list(getattr(args, "args", []))
defaults = list(getattr(args, "defaults", []))
total_regular = len(posonly) + len(regular)
default_offset = total_regular - len(defaults)
combined = posonly + regular
for idx, arg in enumerate(combined):
default = defaults[idx - default_offset] if idx >= default_offset else None
add_param(arg, default)
vararg = getattr(args, "vararg", None)
if vararg:
add_param(vararg)
params[-1].name = f"*{params[-1].name}"
kwonly = list(getattr(args, "kwonlyargs", []))
kw_defaults = list(getattr(args, "kw_defaults", []))
for kwarg, default in zip(kwonly, kw_defaults):
add_param(kwarg, default)
kwarg = getattr(args, "kwarg", None)
if kwarg:
add_param(kwarg)
params[-1].name = f"**{params[-1].name}"
return params
def format_signature(
node: ast.AST, src: str, *, is_class: bool = False, is_async: bool = False, display_name: str | None = None
) -> str:
"""Build a readable signature string for classes, functions, and methods."""
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
return ""
if isinstance(node, ast.ClassDef):
init_method = next(
(n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == "__init__"),
None,
)
args = (
init_method.args
if init_method
else ast.arguments(
posonlyargs=[], args=[], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]
)
)
else:
args = node.args
name = display_name or getattr(node, "name", "")
params: list[str] = []
posonly = list(getattr(args, "posonlyargs", []))
regular = list(getattr(args, "args", []))
defaults = list(getattr(args, "defaults", []))
total_regular = len(posonly) + len(regular)
default_offset = total_regular - len(defaults)
combined = posonly + regular
for idx, arg in enumerate(combined):
default = defaults[idx - default_offset] if idx >= default_offset else None
params.append(_format_parameter(arg, default, src))
if posonly and idx == len(posonly) - 1:
params.append("/")
vararg = getattr(args, "vararg", None)
if vararg:
rendered = _format_parameter(vararg, None, src)
params.append(f"*{rendered}")
kwonly = list(getattr(args, "kwonlyargs", []))
kw_defaults = list(getattr(args, "kw_defaults", []))
if kwonly:
if not vararg:
params.append("*")
for kwarg, default in zip(kwonly, kw_defaults):
params.append(_format_parameter(kwarg, default, src))
kwarg = getattr(args, "kwarg", None)
if kwarg:
rendered = _format_parameter(kwarg, None, src)
params.append(f"**{rendered}")
return_annotation = (
_format_annotation(node.returns, src)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns
else None
)
prefix = "" if is_class else ("async def " if is_async else "def ")
signature = f"{prefix}{name}({', '.join(params)})"
if return_annotation:
signature += f" -> {return_annotation}"
if len(signature) <= SIGNATURE_LINE_LENGTH or not params:
return signature
raw_signature = _get_definition_signature(node, src)
return raw_signature or signature
def _split_section_entries(lines: list[str]) -> list[list[str]]:
"""Split a docstring section into entries based on indentation."""
entries: list[list[str]] = []
current: list[str] = []
base_indent: int | None = None
for raw_line in lines:
if not raw_line.strip():
if current:
current.append("")
continue
indent = len(raw_line) - len(raw_line.lstrip(" "))
if base_indent is None:
base_indent = indent
if indent <= base_indent and current:
entries.append(current)
current = [raw_line]
else:
current.append(raw_line)
if current:
entries.append(current)
return entries
def _parse_named_entries(lines: list[str]) -> list[ParameterDoc]:
"""Parse Args/Attributes/Raises style sections."""
entries = []
for block in _split_section_entries(lines):
text = textwrap.dedent("\n".join(block)).strip()
if not text:
continue
first_line, *rest = text.splitlines()
match = SECTION_ENTRY_RE.match(first_line)
if match:
name, type_hint, desc = match.groups()
description = " ".join(desc.split())
if rest:
description = f"{description}\n" + "\n".join(rest)
entries.append(ParameterDoc(name=name, type=type_hint, description=_normalize_text(description)))
else:
entries.append(ParameterDoc(name=text, type=None, description=""))
return entries
def _parse_returns(lines: list[str]) -> list[ReturnDoc]:
"""Parse Returns/Yields sections."""
entries = []
for block in _split_section_entries(lines):
text = textwrap.dedent("\n".join(block)).strip()
if not text:
continue
match = RETURNS_RE.match(text)
if match:
type_hint, desc = match.groups()
cleaned_type = type_hint.strip()
if cleaned_type.startswith("(") and cleaned_type.endswith(")"):
cleaned_type = cleaned_type[1:-1].strip()
entries.append(ReturnDoc(type=cleaned_type, description=_normalize_text(desc.strip())))
else:
entries.append(ReturnDoc(type=None, description=_normalize_text(text)))
return entries
SECTION_ALIASES = {
"args": "params",
"arguments": "params",
"parameters": "params",
"params": "params",
"returns": "returns",
"return": "returns",
"yields": "yields",
"yield": "yields",
"raises": "raises",
"exceptions": "raises",
"exception": "raises",
"attributes": "attributes",
"attr": "attributes",
"examples": "examples",
"example": "examples",
"notes": "notes",
"note": "notes",
"methods": "methods",
}
def _normalize_text(text: str) -> str:
"""Normalize text while preserving markdown structures like tables, admonitions, and code blocks."""
if not text:
return ""
# Check if text contains markdown structures that need line preservation
if any(marker in text for marker in ("|", "!!!", "```", "\n#", "\n- ", "\n* ", "\n1. ", "\n ")):
# Preserve markdown formatting - just strip trailing whitespace from lines
return "\n".join(line.rstrip() for line in text.splitlines()).strip()
# Simple text - collapse single newlines within paragraphs
paragraphs: list[str] = []
current: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
if current:
paragraphs.append(" ".join(current))
current = []
continue
current.append(stripped)
if current:
paragraphs.append(" ".join(current))
return "\n\n".join(paragraphs)
def parse_google_docstring(docstring: str | None) -> ParsedDocstring:
"""Parse a Google-style docstring into structured data."""
if not docstring:
return ParsedDocstring()
lines = textwrap.dedent(docstring).splitlines()
while lines and not lines[0].strip():
lines.pop(0)
if not lines:
return ParsedDocstring()
summary = _normalize_text(lines[0].strip())
body = lines[1:]
sections: defaultdict[str, list[str]] = defaultdict(list)
current = "description"
for line in body:
stripped = line.strip()
key = SECTION_ALIASES.get(stripped.rstrip(":").lower())
if key and stripped.endswith(":"):
current = key
continue
if current != "methods": # ignore "Methods:" sections; methods are rendered from AST
sections[current].append(line)
description = "\n".join(sections.pop("description", [])).strip("\n")
description = _normalize_text(description)
return ParsedDocstring(
summary=summary,
description=description,
params=_parse_named_entries(sections.get("params", [])),
attributes=_parse_named_entries(sections.get("attributes", [])),
returns=_parse_returns(sections.get("returns", [])),
yields=_parse_returns(sections.get("yields", [])),
raises=_parse_named_entries(sections.get("raises", [])),
notes=[textwrap.dedent("\n".join(sections.get("notes", []))).strip()] if sections.get("notes") else [],
examples=[textwrap.dedent("\n".join(sections.get("examples", []))).strip()] if sections.get("examples") else [],
)
def merge_docstrings(base: ParsedDocstring, extra: ParsedDocstring, ignore_summary: bool = True) -> ParsedDocstring:
"""Merge init docstring content into a class docstring."""
# Keep existing class docs; append init docs only when they introduce new entries (class takes priority).
def _merge_unique(base_items, extra_items, key):
seen = {key(item) for item in base_items}
base_items.extend(item for item in extra_items if key(item) not in seen)
return base_items
if not base.summary and extra.summary and not ignore_summary:
base.summary = extra.summary
if extra.description:
base.description = "\n\n".join(filter(None, [base.description, extra.description]))
_merge_unique(base.params, extra.params, lambda p: (p.name, p.type, p.description, p.default))
_merge_unique(base.attributes, extra.attributes, lambda p: (p.name, p.type, p.description, p.default))
_merge_unique(base.returns, extra.returns, lambda r: (r.type, r.description))
_merge_unique(base.yields, extra.yields, lambda r: (r.type, r.description))
_merge_unique(base.raises, extra.raises, lambda r: (r.name, r.type, r.description, r.default))
_merge_unique(base.notes, extra.notes, lambda n: n.strip())
_merge_unique(base.examples, extra.examples, lambda e: e.strip())
return base
def _should_document(name: str, *, allow_private: bool = False) -> bool:
"""Decide whether to include a symbol based on its name."""
if name in INCLUDE_SPECIAL_METHODS:
return True
if name.startswith("_"):
return allow_private
return True
def _collect_source_block(src: str, node: ast.AST, end_line: int | None = None) -> str:
"""Return a dedented source snippet for the given node up to an optional end line."""
if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
return ""
lines = src.splitlines()
# Include decorators by starting from the first decorator line if present
decorator_lines = [getattr(d, "lineno", node.lineno) for d in getattr(node, "decorator_list", [])]
start_line = min([*decorator_lines, node.lineno]) if decorator_lines else node.lineno
start = max(start_line - 1, 0)
end = end_line or getattr(node, "end_lineno", node.lineno)
snippet = "\n".join(lines[start:end])
return textwrap.dedent(snippet).rstrip()
def _get_definition_signature(node: ast.AST, src: str) -> str:
"""Return the original multi-line definition signature from source if available."""
if not hasattr(node, "lineno"):
return ""
lines = src.splitlines()[node.lineno - 1 :]
collected: list[str] = []
for line in lines:
stripped = line.strip()
if not stripped:
continue
collected.append(line)
if stripped.endswith(":"):
break
header = textwrap.dedent("\n".join(collected)).rstrip()
return header[:-1].rstrip() if header.endswith(":") else header
def parse_function(
node: ast.FunctionDef | ast.AsyncFunctionDef,
module_path: str,
src: str,
*,
parent: str | None = None,
allow_private: bool = False,
) -> DocItem | None:
"""Parse a function or method node into a DocItem."""
raw_docstring = ast.get_docstring(node)
if not _should_document(node.name, allow_private=allow_private) and not raw_docstring:
return None
is_async = isinstance(node, ast.AsyncFunctionDef)
doc = parse_google_docstring(raw_docstring)
qualname = f"{module_path}.{node.name}" if not parent else f"{parent}.{node.name}"
decorators = {_get_source(src, d).split(".")[-1] for d in node.decorator_list}
kind: Literal["function", "method", "property"] = "method" if parent else "function"
if decorators & PROPERTY_DECORATORS:
kind = "property"
signature_params = collect_signature_parameters(node.args, src, skip_self=bool(parent))
return DocItem(
name=node.name,
qualname=qualname,
kind=kind,
signature=format_signature(node, src, is_async=is_async),
doc=doc,
signature_params=signature_params,
lineno=node.lineno,
end_lineno=node.end_lineno or node.lineno,
bases=[],
children=[],
module_path=module_path,
source=_collect_source_block(src, node),
)
def parse_class(node: ast.ClassDef, module_path: str, src: str) -> DocItem:
"""Parse a class node, merging __init__ docs and collecting methods."""
class_doc = parse_google_docstring(ast.get_docstring(node))
init_node: ast.FunctionDef | ast.AsyncFunctionDef | None = next(
(n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == "__init__"),
None,
)
signature_params: list[ParameterDoc] = []
if init_node:
init_doc = parse_google_docstring(ast.get_docstring(init_node))
class_doc = merge_docstrings(class_doc, init_doc, ignore_summary=True)
signature_params = collect_signature_parameters(init_node.args, src, skip_self=True)
bases = [_get_source(src, b) for b in node.bases] if node.bases else []
signature_node = init_node or node
class_signature = format_signature(signature_node, src, is_class=True, display_name=node.name)
methods: list[DocItem] = []
for child in node.body:
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child is not init_node:
method_doc = parse_function(child, module_path, src, parent=f"{module_path}.{node.name}")
if method_doc:
methods.append(method_doc)
return DocItem(
name=node.name,
qualname=f"{module_path}.{node.name}",
kind="class",
signature=class_signature,
doc=class_doc,
signature_params=signature_params,
lineno=node.lineno,
end_lineno=node.end_lineno or node.lineno,
bases=bases,
children=methods,
module_path=module_path,
source=_collect_source_block(src, node, end_line=init_node.end_lineno if init_node else node.lineno),
)
def parse_module(py_filepath: Path) -> DocumentedModule | None:
"""Parse a Python module into structured documentation objects."""
try:
src = py_filepath.read_text(encoding="utf-8")
except Exception:
return None
try:
tree = ast.parse(src)
except SyntaxError:
return None
module_path = (
f"{PACKAGE_DIR.name}.{py_filepath.relative_to(PACKAGE_DIR).with_suffix('').as_posix().replace('/', '.')}"
)
classes: list[DocItem] = []
functions: list[DocItem] = []
for node in tree.body:
if isinstance(node, ast.ClassDef):
classes.append(parse_class(node, module_path, src))
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
func = parse_function(node, module_path, src, parent=None)
if func:
functions.append(func)
return DocumentedModule(path=py_filepath, module_path=module_path, classes=classes, functions=functions)
def _render_section(title: str, entries: Iterable[str], level: int) -> str:
"""Render a section with a given heading level."""
entries = list(entries)
if not entries:
return ""
heading = f"{'#' * level} {title}\n"
body = "\n".join(entries).rstrip()
return f"{heading}{body}\n\n"
def _render_table(headers: list[str], rows: list[list[str]], level: int, title: str | None = None) -> str:
"""Render a Markdown table with an optional heading."""
if not rows:
return ""
def _clean_cell(value: str | None) -> str:
"""Normalize table cell values for Markdown output."""
if value is None:
return ""
return str(value).replace("\n", "<br>").strip()
rows = [[_clean_cell(c) for c in row] for row in rows]
table_lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
for row in rows:
table_lines.append("| " + " | ".join(row) + " |")
heading = f"{'#' * level} {title}\n" if title else ""
return f"{heading}" + "\n".join(table_lines) + "\n\n"
def _code_fence(source: str, lang: str = "python") -> str:
"""Return a fenced code block with optional language for highlighting."""
return f"```{lang}\n{source}\n```"
def _merge_params(doc_params: list[ParameterDoc], signature_params: list[ParameterDoc]) -> list[ParameterDoc]:
"""Merge docstring params with signature params to include defaults/types."""
sig_map = {p.name.lstrip("*"): p for p in signature_params}
merged: list[ParameterDoc] = []
seen = set()
for dp in doc_params:
sig = sig_map.get(dp.name.lstrip("*"))
merged.append(
ParameterDoc(
name=dp.name,
type=dp.type or (sig.type if sig else None),
description=dp.description,
default=sig.default if sig else None,
)
)
seen.add(dp.name.lstrip("*"))
for name, sig in sig_map.items():
if name in seen:
continue
merged.append(sig)
return merged
DEFAULT_SECTION_ORDER = ["args", "returns", "examples", "notes", "attributes", "yields", "raises"]
SUMMARY_BADGE_MAP = {"Classes": "class", "Properties": "property", "Methods": "method", "Functions": "function"}
def contribution_admonition(pretty: str, url: str, *, kind: str = "note", title: str | None = None) -> str:
"""Return a standardized contribution call-to-action admonition."""
label = f' "{title}"' if title else ""
body = (
f"This page is sourced from [{pretty}]({url}). Have an improvement or example to add? "
f"Open a [Pull Request](https://docs.ultralytics.com/help/contributing/) — thank you! 🙏"
)
return f"!!! {kind}{label}\n\n {body}\n\n"
def _relative_to_workspace(path: Path) -> Path:
"""Return path relative to workspace root when possible."""
try:
return path.relative_to(PACKAGE_DIR.parent)
except ValueError:
return path
def render_source_panel(item: DocItem, module_url: str, module_path: str) -> str:
"""Render a collapsible source panel with a GitHub link."""
if not item.source:
return ""
source_url = f"{module_url}#L{item.lineno}-L{item.end_lineno}"
summary = f"Source code in <code>{html.escape(module_path)}.py</code>"
return (
"<details>\n"
f"<summary>{summary}</summary>\n\n"
f'<a href="{source_url}">{GITHUB_ICON}View on GitHub</a>\n'
f"{_code_fence(item.source)}\n"
"</details>\n"
)
def render_docstring(
doc: ParsedDocstring,
level: int,
signature_params: list[ParameterDoc] | None = None,
section_order: list[str] | None = None,
extra_sections: dict[str, str] | None = None,
) -> str:
"""Convert a ParsedDocstring into Markdown with tables similar to mkdocstrings."""
parts: list[str] = []
if doc.summary:
parts.append(doc.summary)
if doc.description:
parts.append(doc.description)
sig_params = signature_params or []
merged_params = _merge_params(doc.params, sig_params)
sections: dict[str, str] = {}
if merged_params:
rows = []
for p in merged_params:
default_val = f"`{p.default}`" if p.default not in (None, "") else "*required*"
rows.append(
[
f"`{p.name}`",
f"`{p.type}`" if p.type else "",
p.description.strip() if p.description else "",
default_val,
]
)
table = _render_table(["Name", "Type", "Description", "Default"], rows, level, title=None)
sections["args"] = f"**Args**\n\n{table}"
if doc.returns:
rows = []
for r in doc.returns:
rows.append([f"`{r.type}`" if r.type else "", r.description])
table = _render_table(["Type", "Description"], rows, level, title=None)
sections["returns"] = f"**Returns**\n\n{table}"
if doc.examples:
code_block = "\n\n".join(f"```python\n{example.strip()}\n```" for example in doc.examples if example.strip())
if code_block:
sections["examples"] = f"**Examples**\n\n{code_block}\n\n"
if doc.notes:
note_text = "\n\n".join(doc.notes).strip()
indented = textwrap.indent(note_text, " ")
sections["notes"] = f'!!! note "Notes"\n\n{indented}\n\n'
if doc.attributes:
rows = []
for a in doc.attributes:
rows.append(
[f"`{a.name}`", f"`{a.type}`" if a.type else "", a.description.strip() if a.description else ""]
)
table = _render_table(["Name", "Type", "Description"], rows, level, title=None)
sections["attributes"] = f"**Attributes**\n\n{table}"
if doc.yields:
rows = []
for r in doc.yields:
rows.append([f"`{r.type}`" if r.type else "", r.description])
table = _render_table(["Type", "Description"], rows, level, title=None)
sections["yields"] = f"**Yields**\n\n{table}"
if doc.raises:
rows = []
for e in doc.raises:
type_cell = e.type or e.name
rows.append([f"`{type_cell}`" if type_cell else "", e.description or ""])
table = _render_table(["Type", "Description"], rows, level, title=None)
sections["raises"] = f"**Raises**\n\n{table}"
if extra_sections:
sections.update({k: v for k, v in extra_sections.items() if v})
# Ensure section order contains unique entries to avoid duplicate renders (e.g., classes injecting "examples")
order = list(dict.fromkeys(section_order or DEFAULT_SECTION_ORDER))
ordered_sections: list[str] = []
seen = set()
for key in order:
section = sections.get(key)
if section:
ordered_sections.append(section)
seen.add(key)
for key, section in sections.items():
if key not in seen:
ordered_sections.append(section)
parts.extend(filter(None, ordered_sections))
return "\n\n".join([p.rstrip() for p in parts if p]).strip() + ("\n\n" if parts else "")
def item_anchor(item: DocItem) -> str:
"""Create a stable anchor for a documented item."""
return item.qualname
def display_qualname(item: DocItem) -> str:
"""Return a cleaned, fully-qualified name for display (strip __init__ noise)."""
return item.qualname.replace(".__init__.", ".")
def render_summary_tabs(module: DocumentedModule) -> str:
"""Render a tabbed summary of classes, methods, and functions for quick navigation."""
tab_entries: list[tuple[str, list[str]]] = []
if module.classes:
tab_entries.append(
(
"Classes",
[f"- [`{cls.name}`](#{item_anchor(cls)})" for cls in module.classes],
)
)
property_links = []
method_links = []
for cls in module.classes:
for child in cls.children:
if child.kind == "property":
property_links.append(f"- [`{cls.name}.{child.name}`](#{item_anchor(child)})")
for child in cls.children:
if child.kind == "method":
method_links.append(f"- [`{cls.name}.{child.name}`](#{item_anchor(child)})")
if property_links:
tab_entries.append(("Properties", property_links))
if method_links:
tab_entries.append(("Methods", method_links))
if module.functions:
tab_entries.append(
(
"Functions",
[f"- [`{func.name}`](#{item_anchor(func)})" for func in module.functions],
)
)
if not tab_entries:
return ""
lines = ['!!! abstract "Summary"\n']
for label, bullets in tab_entries:
badge_class = SUMMARY_BADGE_MAP.get(label, label.lower())
label_badge = f'<span class="doc-kind doc-kind-{badge_class}">{label}</span>'
lines.append(f' === "{label_badge}"\n')
lines.append("\n".join(f" {line}" for line in bullets))
lines.append("") # Blank line after each tab block
return "\n".join(lines).rstrip() + "\n\n"
def render_item(item: DocItem, module_url: str, module_path: str, level: int = 2) -> str:
"""Render a class, function, or method to Markdown."""
anchor = item_anchor(item)
title_prefix = item.kind.capitalize()
anchor_id = anchor.replace("_", r"\_") # escape underscores so attr_list keeps them in the id
heading = f"{'#' * level} {title_prefix} `{display_qualname(item)}` {{#{anchor_id}}}"
signature_block = f"```python\n{item.signature}\n```\n"
parts = [heading, signature_block]
if item.bases:
bases = ", ".join(f"`{b}`" for b in item.bases)
parts.append(f"**Bases:** {bases}\n")
if item.kind == "class":
method_section = None
if item.children:
props = [c for c in item.children if c.kind == "property"]
methods = [c for c in item.children if c.kind == "method"]
methods.sort(key=lambda m: (not m.name.startswith("__"), m.name))
rows = []
for child in props + methods:
summary = child.doc.summary or (
_normalize_text(child.doc.description).split("\n\n")[0] if child.doc.description else ""
)
rows.append([f"[`{child.name}`](#{item_anchor(child)})", summary.strip()])
if rows:
table = _render_table(["Name", "Description"], rows, level + 1, title=None)
method_section = f"**Methods**\n\n{table}"
order = ["args", "attributes", "methods", "examples", *DEFAULT_SECTION_ORDER]
rendered = render_docstring(
item.doc,
level + 1,
signature_params=item.signature_params,
section_order=order,
extra_sections={"methods": method_section} if method_section else None,
)
parts.append(rendered)
else:
parts.append(render_docstring(item.doc, level + 1, signature_params=item.signature_params))
if item.kind == "class" and item.source:
parts.append(render_source_panel(item, module_url, module_path))
if item.children:
props = [c for c in item.children if c.kind == "property"]
methods = [c for c in item.children if c.kind == "method"]
methods.sort(key=lambda m: (not m.name.startswith("__"), m.name))
ordered_children = props + methods
parts.append("<br>\n")
for idx, child in enumerate(ordered_children):
parts.append(render_item(child, module_url, module_path, level + 1))
if idx != len(ordered_children) - 1:
parts.append("<br>\n")
if item.source and item.kind != "class":
parts.append(render_source_panel(item, module_url, module_path))
return "\n\n".join(p.rstrip() for p in parts if p).rstrip() + "\n\n"
def render_module_markdown(module: DocumentedModule) -> str:
"""Render the full module reference content."""
module_path = module.module_path.replace(".", "/")
module_url = f"https://github.com/{GITHUB_REPO}/blob/main/{module_path}.py"
content: list[str] = ["<br>\n"]
summary_tabs = render_summary_tabs(module)
if summary_tabs:
content.append(summary_tabs)
sections: list[str] = []
for idx, cls in enumerate(module.classes):
sections.append(render_item(cls, module_url, module_path, level=2))
if idx != len(module.classes) - 1 or module.functions:
sections.append("<br><br><hr><br>\n")
for idx, func in enumerate(module.functions):
sections.append(render_item(func, module_url, module_path, level=2))
if idx != len(module.functions) - 1:
sections.append("<br><br><hr><br>\n")
content.extend(sections)
return "\n".join(content).rstrip() + "\n\n<br><br>\n"
def create_markdown(module: DocumentedModule) -> Path:
"""Create a Markdown file containing the API reference for the given Python module."""
md_filepath = REFERENCE_DIR / module.path.relative_to(PACKAGE_DIR).with_suffix(".md")
exists = md_filepath.exists()
header_content = ""
if exists:
for part in md_filepath.read_text().split("---"):
if "description:" in part or "comments:" in part:
header_content += f"---{part}---\n\n"
if not header_content:
header_content = "---\ndescription: TODO ADD DESCRIPTION\nkeywords: TODO ADD KEYWORDS\n---\n\n"
module_path_fs = module.module_path.replace(".", "/")
url = f"https://github.com/{GITHUB_REPO}/blob/main/{module_path_fs}.py"
pretty = url.replace("__init__.py", "\\_\\_init\\_\\_.py") # Properly display __init__.py filenames
title_content = f"# Reference for `{module_path_fs}.py`\n\n" + contribution_admonition(
pretty, url, kind="success", title="Improvements"
)
md_filepath.parent.mkdir(parents=True, exist_ok=True)
md_filepath.write_text(header_content + title_content + render_module_markdown(module))
if not exists:
subprocess.run(["git", "add", "-f", str(md_filepath)], check=True, cwd=REPO_ROOT)
return _relative_to_workspace(md_filepath)
def nested_dict():
"""Create and return a nested defaultdict."""
return defaultdict(nested_dict)
def sort_nested_dict(d: dict) -> dict:
"""Sort a nested dictionary recursively."""
return {k: sort_nested_dict(v) if isinstance(v, dict) else v for k, v in sorted(d.items())}
def create_nav_menu_yaml(nav_items: list[str]) -> str:
"""Create and return a YAML string for the navigation menu."""
nav_tree = nested_dict()
for item_str in nav_items:
item = Path(item_str)
parts = item.parts
current_level = nav_tree["reference"]
for part in parts[2:-1]: # Skip docs/reference and filename
current_level = current_level[part]
current_level[parts[-1].replace(".md", "")] = item
def _dict_to_yaml(d, level=0):
"""Convert a nested dictionary to a YAML-formatted string with indentation."""
yaml_str = ""
indent = " " * level
for k, v in sorted(d.items()):
if isinstance(v, dict):
yaml_str += f"{indent}- {k}:\n{_dict_to_yaml(v, level + 1)}"
else:
yaml_str += f"{indent}- {k}: {str(v).replace('docs/en/', '')}\n"
return yaml_str
reference_yaml = _dict_to_yaml(sort_nested_dict(nav_tree))
print(f"Scan complete, generated reference section with {len(reference_yaml.splitlines())} lines")
return reference_yaml
def extract_document_paths(yaml_section: str) -> list[str]:
"""Extract document paths from a YAML section, ignoring formatting and structure."""
paths = []
# Match all paths that appear after a colon in the YAML
path_matches = re.findall(r":\s*([^\s][^:\n]*?)(?:\n|$)", yaml_section)
for path in path_matches:
# Clean up the path
path = path.strip()
if path and not path.startswith("-") and not path.endswith(":"):
paths.append(path)
return sorted(paths)
def update_mkdocs_file(reference_yaml: str) -> None:
"""Update the mkdocs.yaml file with the new reference section only if changes in document paths are detected."""
mkdocs_content = MKDOCS_YAML.read_text()
# Find the top-level Reference section
ref_pattern = r"(\n - Reference:[\s\S]*?)(?=\n - \w|$)"
ref_match = re.search(ref_pattern, mkdocs_content)
# Build new section with proper indentation
new_section_lines = ["\n - Reference:"]
new_section_lines.extend(
f" {line}"
for line in reference_yaml.splitlines()
if line.strip() != "- reference:" # Skip redundant header
)
new_ref_section = "\n".join(new_section_lines) + "\n"
if ref_match:
# We found an existing Reference section
ref_section = ref_match.group(1)
print(f"Found existing top-level Reference section ({len(ref_section)} chars)")
# Compare only document paths
existing_paths = extract_document_paths(ref_section)
new_paths = extract_document_paths(new_ref_section)
# Check if the document paths are the same (ignoring structure or formatting differences)
if len(existing_paths) == len(new_paths) and set(existing_paths) == set(new_paths):
print(f"No changes detected in document paths ({len(existing_paths)} items). Skipping update.")
return
print(f"Changes detected: {len(new_paths)} document paths vs {len(existing_paths)} existing")
# Update content
new_content = mkdocs_content.replace(ref_section, new_ref_section)
MKDOCS_YAML.write_text(new_content)
subprocess.run(["npx", "prettier", "--write", str(MKDOCS_YAML)], check=False, cwd=PACKAGE_DIR.parent)
print(f"Updated Reference section in {MKDOCS_YAML}")
elif help_match := re.search(r"(\n - Help:)", mkdocs_content):
# No existing Reference section, we need to add it
help_section = help_match.group(1)
# Insert before Help section
new_content = mkdocs_content.replace(help_section, f"{new_ref_section}{help_section}")
MKDOCS_YAML.write_text(new_content)
print(f"Added new Reference section before Help in {MKDOCS_YAML}")
else:
print("Could not find a suitable location to add Reference section")
def _finalize_reference(nav_items: list[str], update_nav: bool, created: int, created_label: str) -> list[str]:
"""Optionally sync navigation and print creation summary."""
if update_nav:
update_mkdocs_file(create_nav_menu_yaml(nav_items))
if created:
print(f"Created {created} new {created_label}")
return nav_items
def build_reference(update_nav: bool = True) -> list[str]:
"""Create placeholder reference files (legacy mkdocstrings flow)."""
return build_reference_placeholders(update_nav=update_nav)
def build_reference_placeholders(update_nav: bool = True) -> list[str]:
"""Create minimal placeholder reference files (mkdocstrings-style) and optionally update nav."""
nav_items: list[str] = []
created = 0
for py_filepath in TQDM(list(PACKAGE_DIR.rglob("*.py")), desc="Building reference stubs", unit="file"):
classes, functions = extract_classes_and_functions(py_filepath)
if not classes and not functions:
continue
module_path = (
f"{PACKAGE_DIR.name}.{py_filepath.relative_to(PACKAGE_DIR).with_suffix('').as_posix().replace('/', '.')}"
)
exists = (REFERENCE_DIR / py_filepath.relative_to(PACKAGE_DIR).with_suffix(".md")).exists()
md_rel = create_placeholder_markdown(py_filepath, module_path, classes, functions)
nav_items.append(str(md_rel))
if not exists:
created += 1
if update_nav:
update_mkdocs_file(create_nav_menu_yaml(nav_items))
if created:
print(f"Created {created} new reference stub files")
return nav_items
def build_reference_docs(update_nav: bool = False) -> list[str]:
"""Render full docstring-based reference content."""
nav_items: list[str] = []
created = 0
desc = f"Docstrings {GITHUB_REPO or PACKAGE_DIR.name}"
for py_filepath in TQDM(list(PACKAGE_DIR.rglob("*.py")), desc=desc, unit="file"):
md_target = REFERENCE_DIR / py_filepath.relative_to(PACKAGE_DIR).with_suffix(".md")
exists_before = md_target.exists()
module = parse_module(py_filepath)
if not module or (not module.classes and not module.functions):
continue
md_rel_filepath = create_markdown(module)
if not exists_before:
created += 1
nav_items.append(str(md_rel_filepath))
if update_nav:
update_mkdocs_file(create_nav_menu_yaml(nav_items))
if created:
print(f"Created {created} new reference files")
return nav_items
def build_reference_for(
package_dir: Path, reference_dir: Path, github_repo: str, update_nav: bool = False
) -> list[str]:
"""Temporarily switch package context to build reference docs for another project."""
global PACKAGE_DIR, REFERENCE_DIR, GITHUB_REPO
prev = (PACKAGE_DIR, REFERENCE_DIR, GITHUB_REPO)
try:
PACKAGE_DIR, REFERENCE_DIR, GITHUB_REPO = package_dir, reference_dir, github_repo
return build_reference_docs(update_nav=update_nav)
finally:
PACKAGE_DIR, REFERENCE_DIR, GITHUB_REPO = prev
def main():
"""CLI entrypoint."""
build_reference(update_nav=True)
if __name__ == "__main__":
main()