chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate Docusaurus reference pages for MCP for Unity tools and resources.
|
||||
|
||||
Single source of truth: the Python `@mcp_for_unity_tool` and
|
||||
`@mcp_for_unity_resource` registries under Server/src/services/. The C#
|
||||
attributes carry only Name/Group/Description; the Python decorator owns the
|
||||
richest typing (Annotated[...] parameter docs) and is what the MCP client
|
||||
actually sees over the wire.
|
||||
|
||||
Outputs:
|
||||
website/docs/reference/tools/<group>/<tool-name>.md — one per tool
|
||||
website/docs/reference/tools/<group>/index.md — group landing
|
||||
website/docs/reference/tools/index.md — catalog landing
|
||||
website/docs/reference/resources/index.md — resources catalog
|
||||
|
||||
Modes:
|
||||
--write (default) regenerate files in place
|
||||
--check re-emit to a temp dir and diff against committed files;
|
||||
exits non-zero on drift (used by CI / pre-commit hook)
|
||||
|
||||
Hand-authored example blocks between <!-- examples:start --> and
|
||||
<!-- examples:end --> are preserved across regeneration.
|
||||
|
||||
Run requirements: the Server/ Python dependencies must be importable, since
|
||||
we load every tool module to trigger decorator registration. In CI:
|
||||
cd Server && uv sync && cd .. && uv --project Server run python tools/generate_docs_reference.py --check
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import filecmp
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import GenericAlias
|
||||
from typing import Annotated, Any, Literal, Union, get_args, get_origin
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SERVER_SRC = REPO_ROOT / "Server" / "src"
|
||||
WEBSITE_DOCS = REPO_ROOT / "website" / "docs"
|
||||
TOOLS_OUT = WEBSITE_DOCS / "reference" / "tools"
|
||||
RESOURCES_OUT = WEBSITE_DOCS / "reference" / "resources"
|
||||
|
||||
GENERATED_BANNER = (
|
||||
"> **Auto-generated** from the Python tool registry. Do not hand-edit "
|
||||
"outside `<!-- examples:start --><!-- examples:end -->` blocks — the "
|
||||
"generator (`tools/generate_docs_reference.py`) will overwrite them."
|
||||
)
|
||||
|
||||
EXAMPLES_OPEN = "<!-- examples:start -->"
|
||||
EXAMPLES_CLOSE = "<!-- examples:end -->"
|
||||
EXAMPLES_PLACEHOLDER = (
|
||||
f"{EXAMPLES_OPEN}\n"
|
||||
"*No examples yet. Add usage examples here — they will be preserved across regenerations.*\n"
|
||||
f"{EXAMPLES_CLOSE}\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_server_on_path() -> None:
|
||||
if str(SERVER_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_SRC))
|
||||
|
||||
|
||||
def load_registries() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Import every tool/resource module so the decorators fire, then return
|
||||
the populated registries."""
|
||||
_ensure_server_on_path()
|
||||
|
||||
from services.registry import ( # noqa: WPS433 (deferred import by design)
|
||||
get_registered_tools,
|
||||
get_registered_resources,
|
||||
clear_tool_registry,
|
||||
clear_resource_registry,
|
||||
)
|
||||
from utils.module_discovery import discover_modules
|
||||
|
||||
clear_tool_registry()
|
||||
clear_resource_registry()
|
||||
|
||||
tools_pkg = importlib.import_module("services.tools")
|
||||
resources_pkg = importlib.import_module("services.resources")
|
||||
|
||||
# Walk both directories and import every module — the @decorator
|
||||
# side-effects populate the registries.
|
||||
list(discover_modules(Path(tools_pkg.__file__).parent, tools_pkg.__name__))
|
||||
list(discover_modules(Path(resources_pkg.__file__).parent, resources_pkg.__name__))
|
||||
|
||||
return get_registered_tools(), get_registered_resources()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parameter introspection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParamDoc:
|
||||
name: str
|
||||
type_str: str
|
||||
required: bool
|
||||
description: str | None
|
||||
default: str | None
|
||||
|
||||
|
||||
def _render_type(annotation: Any) -> str:
|
||||
"""Render a typing annotation as a short Markdown-safe string."""
|
||||
if annotation is inspect.Parameter.empty or annotation is None:
|
||||
return "any"
|
||||
|
||||
origin = get_origin(annotation)
|
||||
|
||||
if origin is Annotated:
|
||||
return _render_type(get_args(annotation)[0])
|
||||
|
||||
if origin in (Union, getattr(typing, "UnionType", Union)):
|
||||
parts = [_render_type(a) for a in get_args(annotation) if a is not type(None)]
|
||||
has_none = type(None) in get_args(annotation) or any(
|
||||
part.endswith(" | None") or part == "None" for part in parts
|
||||
)
|
||||
# Strip any inner "| None" — we'll add a single one at the end if needed.
|
||||
parts = [p[: -len(" | None")] if p.endswith(" | None") else p for p in parts]
|
||||
rendered = " | ".join(p for p in parts if p and p != "None")
|
||||
return rendered + (" | None" if has_none else "")
|
||||
|
||||
if origin is Literal:
|
||||
literals = ", ".join(repr(a) for a in get_args(annotation))
|
||||
return f"Literal[{literals}]"
|
||||
|
||||
if origin is list or annotation is list:
|
||||
args = get_args(annotation)
|
||||
inner = ", ".join(_render_type(a) for a in args) if args else "Any"
|
||||
return f"list[{inner}]"
|
||||
|
||||
if origin is dict or annotation is dict:
|
||||
args = get_args(annotation)
|
||||
inner = ", ".join(_render_type(a) for a in args) if args else "Any"
|
||||
return f"dict[{inner}]"
|
||||
|
||||
if origin is tuple or annotation is tuple:
|
||||
args = get_args(annotation)
|
||||
inner = ", ".join(_render_type(a) for a in args) if args else "Any"
|
||||
return f"tuple[{inner}]"
|
||||
|
||||
if isinstance(annotation, type):
|
||||
return annotation.__name__
|
||||
|
||||
if isinstance(annotation, GenericAlias): # e.g. list[str] without origin
|
||||
return str(annotation)
|
||||
|
||||
return str(annotation).replace("typing.", "")
|
||||
|
||||
|
||||
def _annotation_description(annotation: Any) -> str | None:
|
||||
"""Pull the human-readable string from an Annotated[...] parameter."""
|
||||
|
||||
def _walk(a: Any) -> str | None:
|
||||
origin = get_origin(a)
|
||||
if origin is Annotated:
|
||||
for meta in get_args(a)[1:]:
|
||||
if isinstance(meta, str):
|
||||
return meta
|
||||
# Recurse into the underlying type — e.g. Annotated[str, "..."] | None
|
||||
return _walk(get_args(a)[0])
|
||||
if origin in (Union, getattr(typing, "UnionType", Union)):
|
||||
for arg in get_args(a):
|
||||
desc = _walk(arg)
|
||||
if desc:
|
||||
return desc
|
||||
return None
|
||||
|
||||
return _walk(annotation)
|
||||
|
||||
|
||||
def _is_required(param: inspect.Parameter) -> bool:
|
||||
return param.default is inspect.Parameter.empty
|
||||
|
||||
|
||||
def _render_default(default: Any) -> str | None:
|
||||
if default is inspect.Parameter.empty:
|
||||
return None
|
||||
if default is None:
|
||||
return "None"
|
||||
return repr(default)
|
||||
|
||||
|
||||
def introspect_params(func: Any) -> list[ParamDoc]:
|
||||
sig = inspect.signature(func)
|
||||
try:
|
||||
hints = typing.get_type_hints(func, include_extras=True)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
out: list[ParamDoc] = []
|
||||
for name, param in sig.parameters.items():
|
||||
if name in {"self", "cls", "ctx"}:
|
||||
continue
|
||||
annotation = hints.get(name, param.annotation)
|
||||
out.append(
|
||||
ParamDoc(
|
||||
name=name,
|
||||
type_str=_render_type(annotation),
|
||||
required=_is_required(param),
|
||||
description=_annotation_description(annotation),
|
||||
default=_render_default(param.default),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+(?=[A-Z])|\n\s*\n")
|
||||
|
||||
|
||||
def _first_sentence(description: str) -> str:
|
||||
"""Return the first sentence of a tool description, suitable for
|
||||
frontmatter / catalog blurbs.
|
||||
|
||||
The earlier implementation used `description.split(".")[0]` which
|
||||
cut the string at the first period — including periods inside
|
||||
abbreviations and parenthesized lists (e.g. `etc.) in Unity`),
|
||||
producing truncated frontmatter like `"...modify, delete, etc"`.
|
||||
|
||||
Split on a real sentence boundary instead: a `.`, `!`, or `?`
|
||||
followed by whitespace + a capital letter (or a paragraph break).
|
||||
Fall back to the entire string if no boundary is found, then
|
||||
smart-truncate to keep frontmatter compact.
|
||||
"""
|
||||
text = (description or "").strip().replace('"', "'")
|
||||
if not text:
|
||||
return ""
|
||||
first = _SENTENCE_BOUNDARY.split(text, maxsplit=1)[0].strip()
|
||||
# Cap absurdly long single-sentence descriptions
|
||||
if len(first) > 240:
|
||||
first = first[:237].rstrip() + "…"
|
||||
return first
|
||||
|
||||
|
||||
def _escape_table_cell(s: str) -> str:
|
||||
return s.replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def _read_existing_examples(path: Path) -> str:
|
||||
"""Return the existing examples block from a generated file, if any.
|
||||
|
||||
Requires the start/end markers to sit on their own line so we don't
|
||||
match the markers that appear inside the generator's own warning
|
||||
banner (which references the literal marker strings)."""
|
||||
if not path.exists():
|
||||
return EXAMPLES_PLACEHOLDER
|
||||
text = path.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
rf"^{re.escape(EXAMPLES_OPEN)}\s*\n(.*?)^{re.escape(EXAMPLES_CLOSE)}\s*$",
|
||||
text,
|
||||
re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
if not match:
|
||||
return EXAMPLES_PLACEHOLDER
|
||||
captured = match.group(1)
|
||||
if not captured.strip():
|
||||
return EXAMPLES_PLACEHOLDER
|
||||
return f"{EXAMPLES_OPEN}\n{captured.strip()}\n{EXAMPLES_CLOSE}\n"
|
||||
|
||||
|
||||
def render_tool_page(tool: dict[str, Any], existing_examples: str) -> str:
|
||||
name = tool["name"]
|
||||
description = (tool.get("description") or "").strip()
|
||||
group = tool.get("group") or "core"
|
||||
func = tool["func"]
|
||||
module = getattr(func, "__module__", "")
|
||||
params = introspect_params(func)
|
||||
|
||||
# Sidebar/title metadata
|
||||
desc_for_meta = _first_sentence(description) or name
|
||||
front_matter = textwrap.dedent(
|
||||
f"""\
|
||||
---
|
||||
title: {name}
|
||||
sidebar_label: {name}
|
||||
description: "{desc_for_meta}"
|
||||
---
|
||||
"""
|
||||
)
|
||||
|
||||
if params:
|
||||
rows = ["| Name | Type | Required | Description |", "|------|------|----------|-------------|"]
|
||||
for p in params:
|
||||
req = "yes" if p.required else "—"
|
||||
desc = _escape_table_cell(p.description or "")
|
||||
type_cell = _escape_table_cell(f"`{p.type_str}`")
|
||||
rows.append(f"| `{p.name}` | {type_cell} | {req} | {desc} |")
|
||||
params_section = "\n".join(rows)
|
||||
else:
|
||||
params_section = "_No parameters._"
|
||||
|
||||
return (
|
||||
f"{front_matter}\n"
|
||||
f"# `{name}`\n\n"
|
||||
f"{GENERATED_BANNER}\n\n"
|
||||
f"**Group:** `{group}` · "
|
||||
f"**Module:** `{module}`\n\n"
|
||||
f"## Description\n\n"
|
||||
f"{description or '_No description provided._'}\n\n"
|
||||
f"## Parameters\n\n"
|
||||
f"{params_section}\n\n"
|
||||
f"## Returns\n\n"
|
||||
f"A `dict` containing the Unity response. The exact shape depends on the action.\n\n"
|
||||
f"## Examples\n\n"
|
||||
f"{existing_examples}\n"
|
||||
)
|
||||
|
||||
|
||||
def render_group_index(group: str, tools: list[dict[str, Any]], group_blurb: str) -> str:
|
||||
front_matter = textwrap.dedent(
|
||||
f"""\
|
||||
---
|
||||
title: "{group} tools"
|
||||
sidebar_label: "{group}"
|
||||
description: "MCP for Unity tools in the {group} group."
|
||||
---
|
||||
"""
|
||||
)
|
||||
|
||||
bullets = []
|
||||
for tool in sorted(tools, key=lambda t: t["name"]):
|
||||
n = tool["name"]
|
||||
d = _first_sentence(tool.get("description") or "")
|
||||
bullets.append(f"- **[`{n}`](./{n}.md)** — {d}")
|
||||
body = "\n".join(bullets) if bullets else "_No tools in this group._"
|
||||
|
||||
return (
|
||||
f"{front_matter}\n"
|
||||
f"# `{group}` tools\n\n"
|
||||
f"{group_blurb}\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
|
||||
|
||||
def render_catalog_index(tools_by_group: dict[str, list[dict[str, Any]]],
|
||||
group_blurbs: dict[str, str]) -> str:
|
||||
front_matter = textwrap.dedent(
|
||||
"""\
|
||||
---
|
||||
title: Tool reference
|
||||
sidebar_label: Tools
|
||||
sidebar_class_name: sidebar-hidden
|
||||
slug: /reference/tools
|
||||
description: Auto-generated catalog of every MCP for Unity tool, grouped by domain.
|
||||
---
|
||||
"""
|
||||
)
|
||||
|
||||
sections = [
|
||||
"# Tool reference\n",
|
||||
GENERATED_BANNER + "\n",
|
||||
"Every tool MCP for Unity exposes, generated directly from the Python "
|
||||
"`@mcp_for_unity_tool` registry under `Server/src/services/tools/`.\n",
|
||||
]
|
||||
|
||||
for group in sorted(tools_by_group):
|
||||
tools = tools_by_group[group]
|
||||
sections.append(f"## `{group}` ({len(tools)} tool{'s' if len(tools) != 1 else ''})")
|
||||
sections.append(group_blurbs.get(group, ""))
|
||||
for tool in sorted(tools, key=lambda t: t["name"]):
|
||||
n = tool["name"]
|
||||
d = _first_sentence(tool.get("description") or "")
|
||||
sections.append(f"- **[`{n}`](./{group}/{n}.md)** — {d}")
|
||||
sections.append("")
|
||||
|
||||
return front_matter + "\n" + "\n".join(sections) + "\n"
|
||||
|
||||
|
||||
def render_resources_catalog(resources: list[dict[str, Any]]) -> str:
|
||||
front_matter = textwrap.dedent(
|
||||
"""\
|
||||
---
|
||||
title: Resource reference
|
||||
sidebar_label: Resources
|
||||
slug: /reference/resources
|
||||
description: Auto-generated catalog of every MCP for Unity resource.
|
||||
---
|
||||
"""
|
||||
)
|
||||
|
||||
head = (
|
||||
"# Resource reference\n\n"
|
||||
f"{GENERATED_BANNER}\n\n"
|
||||
"Resources are read-only state surfaces exposed to MCP clients. "
|
||||
"Tools mutate; resources observe.\n\n"
|
||||
)
|
||||
|
||||
items = []
|
||||
for res in sorted(resources, key=lambda r: r["name"]):
|
||||
name = res["name"]
|
||||
uri = res.get("uri", "")
|
||||
desc = (res.get("description") or "").strip() or "_No description._"
|
||||
func = res["func"]
|
||||
params = introspect_params(func)
|
||||
if params:
|
||||
param_lines = ["", "**Parameters:**", ""]
|
||||
for p in params:
|
||||
req = "required" if p.required else "optional"
|
||||
d = p.description or ""
|
||||
param_lines.append(f"- `{p.name}` (`{p.type_str}`, {req}) — {d}")
|
||||
param_block = "\n".join(param_lines)
|
||||
else:
|
||||
param_block = ""
|
||||
|
||||
items.append(
|
||||
f"## `{name}`\n\n"
|
||||
f"**URI:** `{uri}`\n\n"
|
||||
f"{desc}\n"
|
||||
f"{param_block}\n"
|
||||
)
|
||||
|
||||
return front_matter + "\n" + head + "\n".join(items) + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-writing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
GROUP_BLURBS_FALLBACK = {
|
||||
"core": "Essential scene, script, asset, and editor tools — always on by default.",
|
||||
"docs": "Unity API reflection and documentation lookup.",
|
||||
"vfx": "Visual effects — VFX Graph, shaders, procedural textures.",
|
||||
"animation": "Animator control and AnimationClip creation.",
|
||||
"ui": "UI Toolkit — UXML, USS, UIDocument.",
|
||||
"scripting_ext": "ScriptableObject management.",
|
||||
"testing": "Test runner and async test jobs.",
|
||||
"probuilder": "ProBuilder 3D modeling — requires `com.unity.probuilder`.",
|
||||
"profiling": "Unity Profiler session control, counters, memory snapshots, Frame Debugger.",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_group_blurbs() -> dict[str, str]:
|
||||
"""Pull live blurbs from the registry, falling back to the local copy."""
|
||||
try:
|
||||
from services.registry import TOOL_GROUPS # type: ignore
|
||||
return {g: blurb for g, blurb in TOOL_GROUPS.items()}
|
||||
except Exception:
|
||||
return GROUP_BLURBS_FALLBACK
|
||||
|
||||
|
||||
def _write(path: Path, content: str) -> bool:
|
||||
"""Write only if content differs. Return True if a write happened."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists() and path.read_text(encoding="utf-8") == content:
|
||||
return False
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def generate(
|
||||
tools_root: Path = TOOLS_OUT,
|
||||
resources_root: Path = RESOURCES_OUT,
|
||||
examples_source: Path | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Generate reference pages.
|
||||
|
||||
When `examples_source` is provided, hand-authored examples blocks are
|
||||
read from there instead of from `tools_root`. `--check` uses this to
|
||||
write into a tempdir while still preserving examples from the committed
|
||||
canonical location.
|
||||
"""
|
||||
tools, resources = load_registries()
|
||||
group_blurbs = _resolve_group_blurbs()
|
||||
|
||||
# Group tools.
|
||||
tools_by_group: dict[str, list[dict[str, Any]]] = {}
|
||||
for t in tools:
|
||||
g = t.get("group") or "core"
|
||||
tools_by_group.setdefault(g, []).append(t)
|
||||
|
||||
stats = {"tools": 0, "groups": 0, "resources": 0, "writes": 0}
|
||||
examples_root = examples_source if examples_source is not None else tools_root
|
||||
|
||||
# Per-tool pages + per-group landing + Docusaurus category metadata.
|
||||
for group, group_tools in sorted(tools_by_group.items()):
|
||||
group_dir = tools_root / group
|
||||
examples_dir = examples_root / group
|
||||
for tool in sorted(group_tools, key=lambda t: t["name"]):
|
||||
page_path = group_dir / f"{tool['name']}.md"
|
||||
examples_path = examples_dir / f"{tool['name']}.md"
|
||||
existing_examples = _read_existing_examples(examples_path)
|
||||
page_md = render_tool_page(tool, existing_examples)
|
||||
if _write(page_path, page_md):
|
||||
stats["writes"] += 1
|
||||
stats["tools"] += 1
|
||||
index_md = render_group_index(group, group_tools, group_blurbs.get(group, ""))
|
||||
if _write(group_dir / "index.md", index_md):
|
||||
stats["writes"] += 1
|
||||
# _category_.json tells the autogenerated sidebar to wrap this
|
||||
# directory in a collapsible category. Without it, the group's
|
||||
# tool pages render flat as siblings of the group index.
|
||||
category_json = json.dumps(
|
||||
{
|
||||
"label": group,
|
||||
"link": {"type": "doc", "id": f"reference/tools/{group}/index"},
|
||||
"collapsed": True,
|
||||
},
|
||||
indent=2,
|
||||
) + "\n"
|
||||
if _write(group_dir / "_category_.json", category_json):
|
||||
stats["writes"] += 1
|
||||
stats["groups"] += 1
|
||||
|
||||
# Top-level catalog index. The sidebar's "Tools" parent category in
|
||||
# sidebars.js is what links to this doc — no root `_category_.json`
|
||||
# here, because that would compete with the parent's explicit link
|
||||
# and end up listing the catalog as a duplicate "Tools" child.
|
||||
catalog_md = render_catalog_index(tools_by_group, group_blurbs)
|
||||
if _write(tools_root / "index.md", catalog_md):
|
||||
stats["writes"] += 1
|
||||
root_cat = tools_root / "_category_.json"
|
||||
if root_cat.exists():
|
||||
root_cat.unlink()
|
||||
|
||||
# Resources catalog (single page).
|
||||
resources_md = render_resources_catalog(resources)
|
||||
if _write(resources_root / "index.md", resources_md):
|
||||
stats["writes"] += 1
|
||||
stats["resources"] = len(resources)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _copytree_into(src: Path, dst: Path) -> None:
|
||||
if src.exists():
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
|
||||
|
||||
def _diff_trees(a: Path, b: Path) -> list[str]:
|
||||
diffs: list[str] = []
|
||||
|
||||
def _walk(rel: Path) -> None:
|
||||
cmp = filecmp.dircmp(a / rel, b / rel)
|
||||
for name in cmp.left_only:
|
||||
diffs.append(f"committed-only: {rel / name}")
|
||||
for name in cmp.right_only:
|
||||
diffs.append(f"generated-only: {rel / name}")
|
||||
for name in cmp.diff_files:
|
||||
diffs.append(f"differs: {rel / name}")
|
||||
for name in cmp.common_dirs:
|
||||
_walk(rel / name)
|
||||
|
||||
if a.exists() and b.exists():
|
||||
_walk(Path("."))
|
||||
elif b.exists():
|
||||
diffs.append(f"committed missing entirely: {a}")
|
||||
return diffs
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="Generate into a temp dir and diff against the committed reference. Non-zero exit on drift.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.check:
|
||||
# Use a persistent dir under /tmp if MCP4U_KEEP_CHECK is set, so the
|
||||
# user can diff committed vs generated by hand.
|
||||
import os
|
||||
keep = bool(os.environ.get("MCP4U_KEEP_CHECK"))
|
||||
ctx = tempfile.TemporaryDirectory(prefix="mcp4u-docs-check-") if not keep else None
|
||||
tmp = ctx.__enter__() if ctx else tempfile.mkdtemp(prefix="mcp4u-docs-check-keep-")
|
||||
try:
|
||||
tmp_root = Path(tmp)
|
||||
tmp_tools = tmp_root / "tools"
|
||||
tmp_resources = tmp_root / "resources"
|
||||
# Read existing examples from the committed location so
|
||||
# preservation is honored in --check too.
|
||||
generate(tmp_tools, tmp_resources, examples_source=TOOLS_OUT)
|
||||
if keep:
|
||||
print(f"[--check] generated tree retained at {tmp_root}")
|
||||
|
||||
diffs = []
|
||||
diffs.extend(_diff_trees(TOOLS_OUT, tmp_tools))
|
||||
diffs.extend(_diff_trees(RESOURCES_OUT, tmp_resources))
|
||||
|
||||
if diffs:
|
||||
print("Generated reference is stale. Run:")
|
||||
print(" python tools/generate_docs_reference.py")
|
||||
print("then commit the changes. Details:")
|
||||
for d in diffs:
|
||||
print(f" - {d}")
|
||||
return 1
|
||||
|
||||
print("Generated docs reference is up-to-date.")
|
||||
return 0
|
||||
finally:
|
||||
if ctx:
|
||||
ctx.__exit__(None, None, None)
|
||||
|
||||
stats = generate()
|
||||
print(
|
||||
f"Generated {stats['tools']} tool pages across {stats['groups']} groups "
|
||||
f"({stats['writes']} file(s) written) + {stats['resources']} resource entries."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user