Skip to content

Commit 6ef1f84

Browse files
committed
docs: pin the API index rendering for cross-package re-exports
mkdocstrings renders a member re-exported across a package boundary as a full duplicate of its canonical documentation whenever the target package happens to be loaded already, and silently omits it when it isn't - which of the two the api/mcp/ index got depended on page rendering order (255 headings under Zensical vs 123 under MkDocs, from the same stub). Pin the semantics in the generated stub instead: every export with a canonical page elsewhere under the API reference becomes a compact link to it, and only exports documented nowhere else (re-exports from private modules) keep their full body via an explicit members list. Packages whose re-exports never cross a package boundary (mcp_types) are deterministic already and keep the plain stub. griffelib moves from a transitive to a declared docs dependency since the generator now imports it directly.
1 parent 3786d39 commit 6ef1f84

3 files changed

Lines changed: 94 additions & 8 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ docs = [
9292
"mkdocstrings-python>=2.0.5",
9393
# scripts/docs/build_config.py and llms_txt.py read mkdocs.yml directly.
9494
"pyyaml>=6.0.2",
95+
"griffelib>=2.1.0",
9596
]
9697
codegen = ["datamodel-code-generator==0.57.0"]
9798

scripts/docs/gen_ref_pages.py

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
import shutil
1616
from pathlib import Path
1717

18+
import griffe
19+
1820
# A MkDocs/Zensical nav is a list of entries, each either ``{title: url}`` for a
1921
# page or ``{title: [children]}`` for a section (a bare ``url`` string attaches
2022
# a section index page, courtesy of the ``navigation.indexes`` feature).
@@ -23,6 +25,19 @@
2325
ROOT = Path(__file__).parent.parent.parent
2426
API_DIR = ROOT / "docs" / "api"
2527

28+
# `src/mcp-types` is a distribution directory, not an import package, so each
29+
# package's dotted module path is taken relative to its own parent: deriving
30+
# it from `src/` would emit the unimportable `mcp-types.mcp_types.*`.
31+
PACKAGES = (ROOT / "src" / "mcp", ROOT / "src" / "mcp-types" / "mcp_types")
32+
33+
_KIND_SECTIONS = {
34+
griffe.Kind.MODULE: "Modules",
35+
griffe.Kind.CLASS: "Classes",
36+
griffe.Kind.FUNCTION: "Functions",
37+
griffe.Kind.ATTRIBUTE: "Attributes",
38+
griffe.Kind.TYPE_ALIAS: "Type aliases",
39+
}
40+
2641

2742
class _Node:
2843
"""A module (``url``) and/or a package with child modules (``children``)."""
@@ -45,18 +60,71 @@ def to_nav(self, title: str) -> NavItem:
4560
return {title: items}
4661

4762

63+
def _compact_index(package: griffe.Module, documented: set[str]) -> str | None:
64+
"""Build a compact index body for a package that re-exports another package's API.
65+
66+
mkdocstrings renders a member re-exported across a package boundary
67+
(``from other_package import y`` + ``__all__``) as a full duplicate of its
68+
canonical documentation whenever the other package happens to be loaded
69+
already, and silently omits it when it isn't — which of the two a package
70+
index gets depends on page rendering order. Same-package re-exports
71+
(``mcp_types`` re-exporting its private ``._types`` module) always resolve
72+
and are unaffected, so such packages keep the plain ``::: package`` stub
73+
(return ``None``) and their index remains the full, canonical rendering.
74+
75+
For an affected package, pin the semantics instead of inheriting the
76+
accident: every export whose canonical page exists elsewhere under the API
77+
reference becomes a link to it, and only exports documented nowhere else
78+
(re-exports from private modules) keep their full body here, via an
79+
explicit ``members:`` list.
80+
"""
81+
prefix = f"{package.path}."
82+
exports = {str(export): package.members[str(export)] for export in package.exports or ()}
83+
if not any(member.is_alias and not member.target_path.startswith(prefix) for member in exports.values()):
84+
return None
85+
86+
inline: list[str] = []
87+
sections: dict[str, list[str]] = {}
88+
for name in sorted(exports, key=str.lower):
89+
member = exports[name]
90+
# A target only gets an anchor on its canonical page if it is rendered
91+
# there, which the default `show_if_no_docstring: false` limits to
92+
# objects with docstrings.
93+
if member.is_alias and member.target_path.rpartition(".")[0] in documented and member.has_docstrings:
94+
link_target = member.target_path
95+
else:
96+
inline.append(name)
97+
link_target = f"{package.path}.{name}"
98+
entry = f"- [`{name}`][{link_target}]"
99+
target = member.final_target if member.is_alias else member
100+
if docstring := target.docstring:
101+
summary = docstring.value.split("\n", 1)[0]
102+
entry += f" — {summary}"
103+
sections.setdefault(_KIND_SECTIONS[target.kind], []).append(entry)
104+
105+
body = [f"::: {package.path}", " options:"]
106+
if inline:
107+
body += [" members:", *(f" - {name}" for name in inline)]
108+
else:
109+
body += [" members: false"]
110+
body.append("")
111+
for title in _KIND_SECTIONS.values():
112+
if title in sections:
113+
body += [f"## {title}", "", *sections[title], ""]
114+
return "\n".join(body)
115+
116+
48117
def generate() -> list[NavItem]:
49118
"""Write ``docs/api/**.md`` stubs and return the API-section navigation."""
50119
if API_DIR.exists():
51120
shutil.rmtree(API_DIR)
52121

53-
src = ROOT / "src"
54122
root = _Node()
123+
stubs: dict[Path, str] = {}
124+
package_index: dict[str, Path] = {}
125+
documented: set[str] = set()
55126

56-
# `src/mcp-types` is a distribution directory, not an import package, so each
57-
# package's dotted module path is taken relative to its own parent: deriving
58-
# it from `src/` would emit the unimportable `mcp-types.mcp_types.*`.
59-
for package in (src / "mcp", src / "mcp-types" / "mcp_types"):
127+
for package in PACKAGES:
60128
base = package.parent
61129
for path in sorted(package.rglob("*.py")):
62130
module_path = path.relative_to(base).with_suffix("")
@@ -69,16 +137,31 @@ def generate() -> list[NavItem]:
69137
elif parts[-1].startswith("_"):
70138
continue
71139

72-
full_doc_path = API_DIR / doc_path
73-
full_doc_path.parent.mkdir(parents=True, exist_ok=True)
74140
ident = ".".join(parts)
75-
full_doc_path.write_text(f"::: {ident}\n", encoding="utf-8")
141+
documented.add(ident)
142+
stubs[API_DIR / doc_path] = f"::: {ident}\n"
143+
if len(parts) == 1:
144+
package_index[ident] = API_DIR / doc_path
76145

77146
node = root
78147
for part in parts:
79148
node = node.child(part)
80149
node.url = f"api/{doc_path.as_posix()}"
81150

151+
# Load every package before inspecting any of them: aliases only resolve
152+
# once the module they point at is in the loader's collection.
153+
loader = griffe.GriffeLoader(search_paths=[str(package.parent) for package in PACKAGES])
154+
modules = {ident: loader.load(ident) for ident in package_index}
155+
for ident, doc_path in package_index.items():
156+
module = modules[ident]
157+
assert isinstance(module, griffe.Module)
158+
if body := _compact_index(module, documented):
159+
stubs[doc_path] = body
160+
161+
for full_doc_path, stub in stubs.items():
162+
full_doc_path.parent.mkdir(parents=True, exist_ok=True)
163+
full_doc_path.write_text(stub, encoding="utf-8")
164+
82165
return [root.children[name].to_nav(name) for name in sorted(root.children)]
83166

84167

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)