49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
233 lines
9.6 KiB
Python
233 lines
9.6 KiB
Python
"""Generate the API reference pages and navigation.
|
|
|
|
Zensical does not run MkDocs plugins, so the work that `mkdocs-gen-files` and
|
|
`mkdocs-literate-nav` used to do at build time happens here as a plain
|
|
pre-build step: this module writes a mkdocstrings stub (`::: <module>`) for
|
|
every public module under `docs/api/` and returns the matching nested
|
|
navigation, which `scripts/docs/build_config.py` splices into the build config.
|
|
|
|
Run as a script it just (re)generates `docs/api/`; imported, `generate`
|
|
also returns the nav so the config builder can consume it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import griffe
|
|
|
|
# A MkDocs/Zensical nav is a list of entries, each either `{title: url}` for a
|
|
# page or `{title: [children]}` for a section (a bare `url` string attaches
|
|
# a section index page, courtesy of the `navigation.indexes` feature).
|
|
NavItem = "str | dict[str, str | list[NavItem]]"
|
|
|
|
ROOT = Path(__file__).parent.parent.parent
|
|
API_DIR = ROOT / "docs" / "api"
|
|
|
|
# `src/mcp-types` is a distribution directory, not an import package, so each
|
|
# package's dotted module path is taken relative to its own parent: deriving
|
|
# it from `src/` would emit the unimportable `mcp-types.mcp_types.*`.
|
|
PACKAGES = (ROOT / "src" / "mcp", ROOT / "src" / "mcp-types" / "mcp_types")
|
|
|
|
_KIND_SECTIONS = {
|
|
griffe.Kind.MODULE: "Modules",
|
|
griffe.Kind.CLASS: "Classes",
|
|
griffe.Kind.FUNCTION: "Functions",
|
|
griffe.Kind.ATTRIBUTE: "Attributes",
|
|
griffe.Kind.TYPE_ALIAS: "Type aliases",
|
|
}
|
|
|
|
|
|
class _Node:
|
|
"""A module (`url`) and/or a package with child modules (`children`)."""
|
|
|
|
def __init__(self) -> None:
|
|
self.url: str | None = None
|
|
self.children: dict[str, _Node] = {}
|
|
|
|
def child(self, name: str) -> _Node:
|
|
return self.children.setdefault(name, _Node())
|
|
|
|
def to_nav(self, title: str) -> NavItem:
|
|
if not self.children:
|
|
assert self.url is not None
|
|
return {title: self.url}
|
|
items: list[NavItem] = []
|
|
if self.url is not None:
|
|
items.append(self.url)
|
|
items.extend(self.children[name].to_nav(name) for name in sorted(self.children))
|
|
return {title: items}
|
|
|
|
|
|
def _compact_index(module: griffe.Module, documented: set[str]) -> str | None:
|
|
"""Build a compact page body for a module that re-exports from outside its own subtree.
|
|
|
|
mkdocstrings renders a re-export whose canonical documentation lives on
|
|
another page as a full duplicate of it: deterministically for aliases
|
|
within one top-level package (`mcp.client.auth` re-exporting from
|
|
`mcp.shared.auth`), and order-dependently across top-level packages
|
|
(`from mcp_types import y` + `__all__` renders the duplicate only when
|
|
the other package happens to be loaded already, and silently omits the
|
|
member when it isn't). Modules whose exports all live in their own
|
|
subtree (`mcp_types` re-exporting its private `._types` module, or a
|
|
module whose `__all__` lists only its own definitions) are unaffected
|
|
and keep the plain `::: module` stub (return `None`): their page is
|
|
itself the canonical rendering.
|
|
|
|
For an affected module, pin the semantics instead of inheriting the
|
|
accident: every export whose canonical page exists elsewhere under the API
|
|
reference becomes a link to it, and only exports documented nowhere else
|
|
(re-exports from private modules) keep their full body here, via an
|
|
explicit `members:` list.
|
|
"""
|
|
prefix = f"{module.path}."
|
|
exports: dict[str, griffe.Object | griffe.Alias] = {}
|
|
for export in module.exports or ():
|
|
name = str(export)
|
|
# Listed exports must be statically documentable: a name provided at
|
|
# runtime (module `__getattr__`) is a docs error by policy, not a skip.
|
|
if (member := module.members.get(name)) is None:
|
|
msg = f"gen_ref_pages: export {module.path}.{name} is not statically visible"
|
|
raise SystemExit(msg)
|
|
exports[name] = member
|
|
if not any(member.is_alias and not member.target_path.startswith(prefix) for member in exports.values()):
|
|
return None
|
|
|
|
# A plain stub also renders the module's own public members that are not
|
|
# in `__all__` (`show_if_no_docstring: false` hides the docstring-less
|
|
# ones); keep them, so flipping a page to compact drops nothing.
|
|
public = dict(exports)
|
|
for name, member in module.members.items():
|
|
if (
|
|
name not in public
|
|
and not name.startswith("_")
|
|
and not member.is_alias
|
|
and member.kind is not griffe.Kind.MODULE
|
|
and member.has_docstrings
|
|
):
|
|
public[name] = member
|
|
|
|
inline: list[str] = []
|
|
sections: dict[str, list[str]] = {}
|
|
for name in sorted(public, key=str.lower):
|
|
member = public[name]
|
|
try:
|
|
target = member.final_target if member.is_alias else member
|
|
except griffe.AliasResolutionError as exc:
|
|
msg = f"gen_ref_pages: export {module.path}.{name} resolves outside the documented packages"
|
|
raise SystemExit(msg) from exc
|
|
# Link to the anchor another page actually renders: the deepest alias
|
|
# hop whose module is documented (the final target may live in a
|
|
# private module and only be re-exported by a public one), rendered
|
|
# there only when docstringed (`show_if_no_docstring: false`).
|
|
anchor = None
|
|
hop = member
|
|
while hop.is_alias:
|
|
if hop.target_path.rpartition(".")[0] in documented:
|
|
anchor = hop.target_path
|
|
hop = hop.target
|
|
if anchor is not None and member.has_docstrings:
|
|
link_target = anchor
|
|
else:
|
|
inline.append(name)
|
|
link_target = f"{module.path}.{name}"
|
|
entry = f"- [`{name}`][{link_target}]"
|
|
if docstring := target.docstring:
|
|
summary = " ".join(docstring.value.split("\n\n", 1)[0].split("\n"))
|
|
entry += f" — {summary}"
|
|
sections.setdefault(_KIND_SECTIONS[target.kind], []).append(entry)
|
|
|
|
# Rendering the stub resolves the cross-package aliases again, in
|
|
# mkdocstrings' own collection. On a warm incremental rebuild the target
|
|
# package's pages can all be cache hits, so nothing else loads it and the
|
|
# resolution crashes (AliasResolutionError); preloading pins it. The
|
|
# module's own root package needs no pin: rendering the stub loads it.
|
|
preload = sorted(
|
|
{member.target_path.split(".")[0] for member in exports.values() if member.is_alias}
|
|
- {module.path.split(".")[0]}
|
|
)
|
|
body = [f"::: {module.path}", " options:"]
|
|
if preload:
|
|
body += [" preload_modules:", *(f" - {pkg}" for pkg in preload)]
|
|
if inline:
|
|
body += [" members:", *(f" - {name}" for name in inline)]
|
|
else:
|
|
body += [" members: false"]
|
|
body.append("")
|
|
for title in _KIND_SECTIONS.values():
|
|
if title in sections:
|
|
body += [f"## {title}", "", *sections[title], ""]
|
|
return "\n".join(body)
|
|
|
|
|
|
def _stub(title: str, body: str) -> str:
|
|
"""A stub page: explicit title frontmatter plus the page body.
|
|
|
|
The explicit title matters: the stubs have no H1 of their own, and a
|
|
title-less page falls back to "Index"/the filename — which is what
|
|
pruned nav rows, browser tabs, and search results show.
|
|
"""
|
|
return f'---\ntitle: "{title}"\n---\n\n{body.rstrip()}\n'
|
|
|
|
|
|
def generate() -> list[NavItem]:
|
|
"""Write `docs/api/**.md` stubs and return the API-section navigation."""
|
|
if API_DIR.exists():
|
|
shutil.rmtree(API_DIR)
|
|
|
|
root = _Node()
|
|
stubs: dict[Path, str] = {}
|
|
pages: dict[str, Path] = {}
|
|
documented: set[str] = set()
|
|
|
|
for package in PACKAGES:
|
|
base = package.parent
|
|
for path in sorted(package.rglob("*.py")):
|
|
module_path = path.relative_to(base).with_suffix("")
|
|
doc_path = path.relative_to(base).with_suffix(".md")
|
|
|
|
parts = tuple(module_path.parts)
|
|
if parts[-1] == "__init__":
|
|
parts = parts[:-1]
|
|
doc_path = doc_path.with_name("index.md")
|
|
# A private component anywhere makes the module private: checking
|
|
# only the leaf would publish pages for e.g. mcp._vendor.util.
|
|
if any(part.startswith("_") for part in parts):
|
|
continue
|
|
|
|
ident = ".".join(parts)
|
|
documented.add(ident)
|
|
stubs[API_DIR / doc_path] = _stub(parts[-1], f"::: {ident}")
|
|
pages[ident] = API_DIR / doc_path
|
|
|
|
node = root
|
|
for part in parts:
|
|
node = node.child(part)
|
|
node.url = f"api/{doc_path.as_posix()}"
|
|
|
|
# Load the root packages before inspecting any module: aliases only
|
|
# resolve once the module they point at is in the loader's collection.
|
|
loader = griffe.GriffeLoader(search_paths=[str(package.parent) for package in PACKAGES])
|
|
for package in PACKAGES:
|
|
loader.load(package.name)
|
|
for ident, doc_path in pages.items():
|
|
try:
|
|
module = loader.modules_collection[ident]
|
|
except KeyError as exc:
|
|
raise SystemExit(f"gen_ref_pages: cannot find {ident} in the loaded packages") from exc
|
|
if not isinstance(module, griffe.Module):
|
|
raise SystemExit(f"gen_ref_pages: {ident} is shadowed by a non-module member")
|
|
if body := _compact_index(module, documented):
|
|
stubs[doc_path] = _stub(ident.rpartition(".")[2], body)
|
|
|
|
for full_doc_path, stub in stubs.items():
|
|
full_doc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
full_doc_path.write_text(stub, encoding="utf-8")
|
|
|
|
return [root.children[name].to_nav(name) for name in sorted(root.children)]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
generate()
|