diff --git a/CHANGES b/CHANGES index d783bae0..43e1e91d 100644 --- a/CHANGES +++ b/CHANGES @@ -31,6 +31,25 @@ $ uv add gp-sphinx --prerelease allow ### Features +- `sphinx-gp-theme`: dispatch `gp-sphinx:navigated` CustomEvent on + `document` after every SPA-nav DOM swap. Third-party widgets that bind to + swapped DOM can listen for this event to re-initialise after navigation + without a full page reload. Payload: `event.detail.url` is the new URL. +- `sphinx-autodoc-fastmcp`: autodoc MCP prompts and resources end-to-end. + Four new directives — + ``{fastmcp-prompt}``, ``{fastmcp-prompt-input}``, + ``{fastmcp-resource}``, ``{fastmcp-resource-template}`` — render the + same card-style layout used for tools, with dedicated type badges + (``prompt``, ``resource``, ``resource-template``) plus a MIME pill + next to resources. A new ``fastmcp_server_module`` config value + (``"pkg.server:mcp"``) points the collector at a live + ``FastMCP`` instance; the collector reads + ``local_provider._components`` directly and strips FastMCP's + auto-appended ``"Provide as a JSON string matching the following + schema: {...}"`` hint from prompt argument descriptions so docs + read naturally. If the configured server hasn't run its registration + hook yet, the collector calls ``register_all()`` / ``_register_all()`` + to populate the components. - `sphinx-autodoc-fastmcp`: new Sphinx extension for FastMCP tool docs (card-style `desc` layouts, safety badges, MyST directives, cross-reference roles) - `sphinx-ux-badges`: shared badge node (`BadgeNode`), builder API @@ -65,6 +84,32 @@ $ uv add gp-sphinx --prerelease allow ### Bug fixes +- `sphinx-autodoc-fastmcp`: section labels for prompts and resources now + use the typed `env.domains.standard_domain` accessor and route through the + same direct-write pattern as `_transforms.register_tool_labels`. Resource + and resource-template card labels carry the actual component name (e.g. + ``my_resource``) rather than a slugified round-trip, so ``{ref}`` lookups + resolve against the human-readable identifier (#21) +- `sphinx-autodoc-fastmcp`: the FastMCP register-all hook now fires whenever + the resolved server exposes ``local_provider``, regardless of whether + ``_components`` is already populated. Servers that register some components + at module-import time (decorators) and others via an explicit + ``register_all()`` previously dropped the deferred ones from autodoc (#21) +- `sphinx-autodoc-fastmcp`: narrow the ``except Exception`` around the optional + ``fastmcp.*`` imports to ``except ImportError`` so unrelated runtime errors + during fastmcp import propagate instead of producing silently empty docs + (matches Sphinx's own ``sphinx.util.images`` pattern) (#21) +- `sphinx-autodoc-typehints-gp`: ``:exc:`~mod.Foo``` in ``Raises`` sections + now renders as ``Foo`` (last-component shortener), matching Sphinx's + ``PyXRefRole.process_link`` convention (#21) +- `sphinx-autodoc-typehints-gp`: ``Raises`` type fields split on commas only + at bracket depth 0, keeping parameterised generics like ``Dict[str, X]`` + intact (#21) +- `sphinx-autodoc-typehints-gp`: empty ``Notes`` sections drop their rubric + (intentional after ``.. todo::`` filtering); empty ``Examples``, + ``References``, and other generic stub sections regain their rubric for + legitimate stub usage (scoped via a new ``suppress_empty`` flag on + ``_fmt_generic``) (#21) - `sphinx-gp-theme`: SPA nav now scrolls to the target anchor on cross-page fragment links. `document.querySelector(hash)` mis-parses any hash containing `.` (e.g. Python autodoc IDs like diff --git a/docs/packages/sphinx-autodoc-fastmcp.md b/docs/packages/sphinx-autodoc-fastmcp.md index cb80fb88..8cd6afdb 100644 --- a/docs/packages/sphinx-autodoc-fastmcp.md +++ b/docs/packages/sphinx-autodoc-fastmcp.md @@ -36,7 +36,12 @@ fastmcp_tool_modules = [ fastmcp_area_map = { "fastmcp_tools": "api/tools", } -fastmcp_collector_mode = "introspect" +fastmcp_collector_mode = "register" + +# Optional: point at a live FastMCP server instance to autodoc its prompts, +# resources, and resource templates. Format is "module.path:attr_name". +# Both an instance and a zero-arg factory callable are accepted. +fastmcp_server_module = "my_project.server:mcp" ``` `sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, @@ -49,12 +54,29 @@ You do not need to add them separately to your `extensions` list. |---------|---------|-------------| | `fastmcp_tool_modules` | `[]` | Python module paths that expose tool callables | | `fastmcp_area_map` | `{}` | Maps module stem to area path for ToC labels | -| `fastmcp_collector_mode` | `"introspect"` | `"introspect"` or `"register"` — how tools are discovered | +| `fastmcp_collector_mode` | `"register"` | `"register"` or `"introspect"` — how tools are discovered | +| `fastmcp_server_module` | `""` | `"module.path:attr"` — live FastMCP instance for prompt/resource autodoc | | `fastmcp_model_module` | `None` | Module containing Pydantic model classes | | `fastmcp_model_classes` | `set()` | Set of model class names to cross-reference | | `fastmcp_section_badge_map` | `{}` | Maps section names to safety badge labels | | `fastmcp_section_badge_pages` | `set()` | Pages where section safety badges are injected | +### `fastmcp_server_module` + +Pointing the collector at a live FastMCP instance enables autodoc of +**prompts**, **resources**, and **resource templates** — see the four new +directives below. The collector accepts either: + +* A live instance: `"my_project.server:mcp"` (where `mcp = FastMCP(...)`). +* A zero-argument factory: `"my_project.server:make_server"` returning a + `FastMCP` instance. + +If the resolved object is not a `FastMCP` (no `local_provider` attribute), +collection is skipped and a warning is logged. The collector also invokes +the server's `register_all` / `_register_all` hook (if exported) to +ensure components registered lazily appear in the docs; FastMCP's default +`on_duplicate="error"` policy is suppressed for this call. + ## Working usage examples Render one tool card: @@ -88,6 +110,47 @@ Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` for a plain inline reference. ```` +### Prompts and resources + +After setting `fastmcp_server_module`, four MyST directives become available +for documenting MCP prompts and resources: + +````myst +```{fastmcp-prompt} my_prompt +``` + +```{fastmcp-prompt-input} my_prompt +``` + +```{fastmcp-resource} my_resource +``` + +```{fastmcp-resource-template} my_resource_template +``` +```` + +Resources and resource templates accept either the friendly component name +(`my_resource`) or the literal URI (`mem://my_resource`). When two +distinct resources share a name, autodoc keeps the first registration and +emits a warning — disambiguate by URI. + +### `:ref:` cross-reference IDs + +Section IDs follow `fastmcp-{kind}-{name}` (canonical): + +```text +{ref}`fastmcp-tool-list-sessions` +{ref}`fastmcp-prompt-greet` +{ref}`fastmcp-resource-status` +{ref}`fastmcp-resource-template-events-by-day` +``` + +Tool sections additionally register the bare slug as a back-compat alias +(e.g. `{ref}`list-sessions`` continues to resolve), preserving links +shipped before the kind-prefix introduction. Prompts, resources, and +resource templates use the canonical ID only — no bare alias is created +for them. + ## Live demos Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 227fadc1..9ceb750e 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -15,8 +15,15 @@ from sphinx.application import Sphinx -from sphinx_autodoc_fastmcp._collector import collect_tools +from sphinx_autodoc_fastmcp._collector import ( + collect_prompts_and_resources, + collect_tools, +) from sphinx_autodoc_fastmcp._directives import ( + FastMCPPromptDirective, + FastMCPPromptInputDirective, + FastMCPResourceDirective, + FastMCPResourceTemplateDirective, FastMCPToolDirective, FastMCPToolInputDirective, FastMCPToolSummaryDirective, @@ -77,6 +84,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: app.add_config_value("fastmcp_section_badge_map", {}, "env") app.add_config_value("fastmcp_section_badge_pages", (), "env") app.add_config_value("fastmcp_collector_mode", "register", "env") + app.add_config_value("fastmcp_server_module", "", "env") _static_dir = str(pathlib.Path(__file__).parent / "_static") @@ -88,6 +96,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_fastmcp.css") app.connect("builder-inited", collect_tools) + app.connect("builder-inited", collect_prompts_and_resources) app.connect("doctree-read", register_tool_labels) app.connect("doctree-read", collect_tool_section_content) app.connect("doctree-resolved", add_section_badges) @@ -105,6 +114,10 @@ def _add_static_path(app: Sphinx) -> None: app.add_directive("fastmcp-tool", FastMCPToolDirective) app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) app.add_directive("fastmcp-tool-summary", FastMCPToolSummaryDirective) + app.add_directive("fastmcp-prompt", FastMCPPromptDirective) + app.add_directive("fastmcp-prompt-input", FastMCPPromptInputDirective) + app.add_directive("fastmcp-resource", FastMCPResourceDirective) + app.add_directive("fastmcp-resource-template", FastMCPResourceTemplateDirective) return { "version": _EXTENSION_VERSION, diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py index 841a4362..995af20d 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -143,3 +143,94 @@ def build_toolbar(safety: str) -> nodes.inline: True """ return _sab_build_toolbar(build_tool_badge_group(safety)) + + +_TYPE_TOOLTIP_PROMPT = "MCP prompt recipe" +_TYPE_TOOLTIP_RESOURCE = "MCP resource (fixed URI)" +_TYPE_TOOLTIP_RESOURCE_TEMPLATE = "MCP resource template (parameterised URI)" + + +def build_prompt_badge_group(tags: t.Sequence[str] = ()) -> nodes.inline: + """Badge group: ``prompt`` type + optional tag pills. + + Examples + -------- + >>> g = build_prompt_badge_group(()) + >>> "gp-sphinx-badge-group" in g["classes"] + True + """ + specs = [ + BadgeSpec( + "prompt", + tooltip=_TYPE_TOOLTIP_PROMPT, + classes=( + SAB.DENSE, + SAB.NO_UNDERLINE, + SAB.BADGE_TYPE, + _CSS.TYPE_PROMPT, + ), + ), + ] + for tag in tags: + specs.append( + BadgeSpec( + tag, + tooltip=f"Tag: {tag}", + classes=(SAB.DENSE, SAB.NO_UNDERLINE, _CSS.BADGE_TAG), + ), + ) + return build_badge_group_from_specs(specs) + + +def build_resource_badge_group( + mime_type: str, + tags: t.Sequence[str] = (), + *, + kind: t.Literal["resource", "resource-template"] = "resource", +) -> nodes.inline: + """Badge group for a resource or resource template. + + Emits a type badge (``resource`` or ``resource-template``), an optional + MIME pill, and optional tag pills. + + Examples + -------- + >>> g = build_resource_badge_group("application/json") + >>> "gp-sphinx-badge-group" in g["classes"] + True + """ + if kind == "resource-template": + type_spec = BadgeSpec( + "resource-template", + tooltip=_TYPE_TOOLTIP_RESOURCE_TEMPLATE, + classes=( + SAB.DENSE, + SAB.NO_UNDERLINE, + SAB.BADGE_TYPE, + _CSS.TYPE_RESOURCE_TEMPLATE, + ), + ) + else: + type_spec = BadgeSpec( + "resource", + tooltip=_TYPE_TOOLTIP_RESOURCE, + classes=(SAB.DENSE, SAB.NO_UNDERLINE, SAB.BADGE_TYPE, _CSS.TYPE_RESOURCE), + ) + specs = [type_spec] + if mime_type: + specs.append( + BadgeSpec( + mime_type, + tooltip=f"MIME type: {mime_type}", + classes=(SAB.DENSE, SAB.NO_UNDERLINE, _CSS.BADGE_MIME), + ), + ) + for tag in tags: + specs.append( + BadgeSpec( + tag, + tooltip=f"Tag: {tag}", + classes=(SAB.DENSE, SAB.NO_UNDERLINE, _CSS.BADGE_TAG), + ), + ) + return build_badge_group_from_specs(specs) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py index 5be99dfb..b0e51466 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -1,7 +1,8 @@ -"""Collect FastMCP tool metadata at Sphinx build time.""" +"""Collect FastMCP tool / prompt / resource metadata at Sphinx build time.""" from __future__ import annotations +import contextlib import importlib import inspect import logging @@ -9,8 +10,14 @@ from sphinx.application import Sphinx -from sphinx_autodoc_fastmcp._models import ToolInfo -from sphinx_autodoc_fastmcp._parsing import extract_params +from sphinx_autodoc_fastmcp._models import ( + PromptArgInfo, + PromptInfo, + ResourceInfo, + ResourceTemplateInfo, + ToolInfo, +) +from sphinx_autodoc_fastmcp._parsing import extract_params, first_paragraph from sphinx_autodoc_typehints_gp import normalize_annotation_text logger = logging.getLogger(__name__) @@ -188,3 +195,436 @@ def collect_tools(app: Sphinx) -> None: collector_tools.append(info) app.env.fastmcp_tools = {tool.name: tool for tool in collector_tools} # type: ignore[attr-defined] + + +def _resolve_server_instance(dotted: str) -> t.Any | None: + """Import ``module.attr`` and return a populated ``FastMCP`` instance. + + ``fastmcp_server_module`` may point at either: + + * a live ``FastMCP`` instance — used as-is + * a zero-argument callable that returns one — invoked once + * a module whose ``mcp`` attribute is a ``FastMCP`` instance that + has not yet called its registration hook (common when a server + exposes ``_register_all()`` and invokes it only from ``run_server``) + + In the last case we call ``register_all()`` / ``_register_all()`` on + the module after resolving the instance, so docs can enumerate the + same surface area as a running server. + """ + if not dotted: + return None + if ":" in dotted: + module_path, attr = dotted.split(":", 1) + else: + module_path, _, attr = dotted.rpartition(".") + if not module_path or not attr: + logger.warning( + "sphinx_autodoc_fastmcp: fastmcp_server_module %r has no attribute", + dotted, + ) + return None + try: + mod = importlib.import_module(module_path) + except (ImportError, ModuleNotFoundError): + logger.warning( + "sphinx_autodoc_fastmcp: could not import server module %s", + module_path, + exc_info=True, + ) + return None + obj = getattr(mod, attr, None) + if obj is None: + return None + if callable(obj) and not hasattr(obj, "local_provider"): + try: + obj = obj() + except Exception: # pragma: no cover - defensive + logger.warning( + "sphinx_autodoc_fastmcp: calling %s() failed", + dotted, + exc_info=True, + ) + return None + if getattr(obj, "local_provider", None) is None: + # Catches both: (a) the configured attribute resolved directly to a + # non-FastMCP object, and (b) a factory callable that returned one. + # Without this guard ``_iter_components`` silently yields ``()`` and + # the user sees empty docs with no diagnostic. + logger.warning( + "sphinx_autodoc_fastmcp: %s did not resolve to a FastMCP instance " + "(no local_provider attribute); prompts/resources will be empty", + dotted, + ) + return None + # Always invoke the server's register-all hook when one is exported. + # Servers that pre-register some components at import time (decorators) + # while leaving others to an explicit register_all() would otherwise + # have those deferred components silently missing from the docs. + # + # FastMCP's LocalProvider defaults to on_duplicate="error", so calling + # register_all() on an already-populated provider raises and would leave + # the server half-populated. Temporarily switch to "ignore" via a + # context manager so re-invocation is idempotent. If the hook still + # raises (real bug, not duplicate), fail closed (return None) — visible + # empty docs beat partially-wrong docs. + provider = getattr(obj, "local_provider", None) + if provider is not None: + # Instance-first lookup — FastMCP's canonical MCPMixin.register_all + # is an instance method; module-level register_all is the ad-hoc + # fallback used by simple scripts. + hook = ( + getattr(obj, "register_all", None) + or getattr(obj, "_register_all", None) + or getattr(mod, "register_all", None) + or getattr(mod, "_register_all", None) + ) + if callable(hook): + try: + with _ignore_duplicate_policy(provider): + # MCPMixin.register_all takes the server as a positional + # arg; ad-hoc module-level hooks are zero-arg with the + # server captured in a closure. + try: + hook(obj) + except TypeError: + hook() + except Exception: + logger.warning( + "sphinx_autodoc_fastmcp: register-all hook for %s raised; " + "skipping server", + dotted, + exc_info=True, + ) + return None + return obj + + +_MISSING_DUPLICATE_POLICY = object() + + +@contextlib.contextmanager +def _ignore_duplicate_policy(provider: t.Any) -> t.Iterator[None]: + """Temporarily set ``provider._on_duplicate = "ignore"`` and restore on exit. + + FastMCP's LocalProvider raises ``ValueError`` when re-registering a + component under the default ``on_duplicate="error"`` policy. The + autodoc collector needs idempotent re-invocation, but should not + permanently mutate the user's provider configuration. + + Sphinx invokes ``builder-inited`` synchronously in the main process + before any read/write fork (``sphinx/application.py`` ``_init_builder``); + this manager is therefore safe without locking. + """ + original = getattr(provider, "_on_duplicate", _MISSING_DUPLICATE_POLICY) + provider._on_duplicate = "ignore" + try: + yield + finally: + if original is _MISSING_DUPLICATE_POLICY: + with contextlib.suppress(AttributeError): + delattr(provider, "_on_duplicate") + else: + provider._on_duplicate = original + + +def _iter_components(server: t.Any) -> t.Iterable[t.Any]: + """Yield every FastMCPComponent registered on ``server.local_provider``. + + Bypasses the async ``_list_*`` helpers and iterates the underlying + ``_components`` dict directly — the helpers are trivial type-filter + comprehensions, so reading ``_components.values()`` is equivalent and + avoids needing an event loop at Sphinx build time. + """ + provider = getattr(server, "local_provider", None) + if provider is None: + return () + components = getattr(provider, "_components", None) + if components is None: + return () + return tuple(components.values()) + + +_SCHEMA_NOTE_MARKER = "Provide as a JSON string matching the following schema:" + + +def _strip_schema_note(text: str) -> str: + r"""Remove FastMCP's auto-appended JSON-schema hint from a description. + + FastMCP's prompt argument builder tacks on + ``"\n\nProvide as a JSON string matching the following schema: {...}"`` + to help LLMs; it's noise in human-facing docs. + + Examples + -------- + >>> _strip_schema_note("Summary.") + 'Summary.' + >>> _strip_schema_note("Summary.\n\nProvide as a JSON string matching the following schema: {}") + 'Summary.' + >>> _strip_schema_note(" Summary. \n\nProvide as a JSON string matching the following schema: {}") + 'Summary.' + """ + idx = text.find(_SCHEMA_NOTE_MARKER) + if idx == -1: + return text.strip() + return text[:idx].strip() + + +def _prompt_from_component(prompt: t.Any) -> PromptInfo: + """Build a ``PromptInfo`` from a FastMCP ``Prompt`` component.""" + arguments: list[PromptArgInfo] = [] + for arg in getattr(prompt, "arguments", None) or []: + arguments.append( + PromptArgInfo( + name=str(arg.name), + description=_strip_schema_note(str(arg.description or "")), + required=bool(arg.required), + type_str="", + ), + ) + func = getattr(prompt, "fn", None) + if func is not None and hasattr(func, "__wrapped__"): + func = func.__wrapped__ + docstring = (func.__doc__ or "") if func is not None else "" + if func is not None and arguments: + # Enrich argument metadata with signature type annotations where + # possible so the rendered table can show types, not just names. + try: + sig = inspect.signature(func) + except (TypeError, ValueError): # pragma: no cover - defensive + sig = None + if sig is not None: + for arg in arguments: + param = sig.parameters.get(arg.name) + if param is not None: + arg.type_str = normalize_annotation_text(param.annotation) + tags = tuple(sorted(str(tag) for tag in getattr(prompt, "tags", None) or ())) + module_name = getattr(func, "__module__", "") if func is not None else "" + return PromptInfo( + name=str(prompt.name), + title=str(prompt.title or prompt.name), + description=first_paragraph(str(prompt.description or "")), + docstring=docstring, + tags=tags, + arguments=arguments, + module_name=module_name, + ) + + +def _resource_from_component(res: t.Any) -> ResourceInfo: + """Build a ``ResourceInfo`` from a FastMCP ``Resource`` component.""" + func = getattr(res, "fn", None) + if func is not None and hasattr(func, "__wrapped__"): + func = func.__wrapped__ + docstring = (func.__doc__ or "") if func is not None else "" + tags = tuple(sorted(str(tag) for tag in getattr(res, "tags", None) or ())) + annotations = getattr(res, "annotations", None) + ann_dict: dict[str, t.Any] = {} + if annotations is not None: + for field_name in ( + "audience", + "priority", + "lastModified", + ): + val = getattr(annotations, field_name, None) + if val is not None: + ann_dict[field_name] = val + module_name = getattr(func, "__module__", "") if func is not None else "" + return ResourceInfo( + name=str(res.name), + uri=str(res.uri), + title=str(res.title or res.name), + description=first_paragraph(str(res.description or "")), + mime_type=str(getattr(res, "mime_type", "") or ""), + docstring=docstring, + tags=tags, + annotations=ann_dict, + module_name=module_name, + ) + + +def _template_params_from_schema( + schema: dict[str, t.Any] | None, +) -> list[PromptArgInfo]: + """Flatten a JSON Schema ``properties`` dict into ``PromptArgInfo`` rows. + + Examples + -------- + >>> _template_params_from_schema(None) + [] + >>> _template_params_from_schema({}) + [] + >>> _template_params_from_schema({"properties": {"n": {"type": "string"}}, "required": ["n"]}) + [PromptArgInfo(name='n', description='', required=True, type_str='string')] + """ + if not schema: + return [] + props = schema.get("properties") or {} + required = set(schema.get("required") or ()) + rows: list[PromptArgInfo] = [] + for name, subschema in props.items(): + if not isinstance(subschema, dict): + continue + type_str = str(subschema.get("type", "")) if subschema.get("type") else "" + # Anyof/oneof unions: join short type names. + if not type_str: + union = subschema.get("anyOf") or subschema.get("oneOf") or () + parts = [ + str(member.get("type", "")) + for member in union + if isinstance(member, dict) and member.get("type") + ] + if parts: + type_str = " | ".join(parts) + rows.append( + PromptArgInfo( + name=str(name), + description=str(subschema.get("description", "") or ""), + required=name in required, + type_str=type_str, + ), + ) + return rows + + +def _resource_template_from_component(tpl: t.Any) -> ResourceTemplateInfo: + """Build a ``ResourceTemplateInfo`` from a ``ResourceTemplate`` component.""" + func = getattr(tpl, "fn", None) + if func is not None and hasattr(func, "__wrapped__"): + func = func.__wrapped__ + docstring = (func.__doc__ or "") if func is not None else "" + tags = tuple(sorted(str(tag) for tag in getattr(tpl, "tags", None) or ())) + annotations = getattr(tpl, "annotations", None) + ann_dict: dict[str, t.Any] = {} + if annotations is not None: + for field_name in ("audience", "priority", "lastModified"): + val = getattr(annotations, field_name, None) + if val is not None: + ann_dict[field_name] = val + parameters = _template_params_from_schema(getattr(tpl, "parameters", None)) + module_name = getattr(func, "__module__", "") if func is not None else "" + return ResourceTemplateInfo( + name=str(tpl.name), + uri_template=str(tpl.uri_template), + title=str(tpl.title or tpl.name), + description=first_paragraph(str(tpl.description or "")), + mime_type=str(getattr(tpl, "mime_type", "") or ""), + parameters=parameters, + docstring=docstring, + tags=tags, + annotations=ann_dict, + module_name=module_name, + ) + + +def collect_prompts_and_resources(app: Sphinx) -> None: + """Populate ``app.env.fastmcp_prompts`` / ``_resources`` / ``_resource_templates``. + + Imports ``fastmcp_server_module`` (e.g. ``"pkg.server:mcp"``) and + enumerates the live FastMCP instance's registered components. Does + nothing if the config value is unset. + + Examples + -------- + >>> import types + >>> app = types.SimpleNamespace( + ... config=types.SimpleNamespace(fastmcp_server_module=""), + ... env=types.SimpleNamespace(), + ... ) + >>> collect_prompts_and_resources(app) + >>> app.env.fastmcp_prompts + {} + >>> app.env.fastmcp_resources + {} + >>> app.env.fastmcp_resource_names + {} + >>> app.env.fastmcp_resource_templates + {} + >>> app.env.fastmcp_resource_template_names + {} + """ + server_dotted = str(getattr(app.config, "fastmcp_server_module", "") or "") + prompts: dict[str, PromptInfo] = {} + # Resources and templates are URI-keyed because FastMCP itself keys by + # ``str(uri)`` / ``uri_template`` (see fastmcp/resources/base.py:Resource.key). + # Two distinct resources sharing a ``.name`` would silently overwrite each + # other if we keyed by name. The companion ``*_names`` dicts let + # ``{fastmcp-resource} my_resource`` directives resolve by friendly name + # while still preserving URI identity for collisions. + resources: dict[str, ResourceInfo] = {} + resource_names: dict[str, str] = {} + templates: dict[str, ResourceTemplateInfo] = {} + template_names: dict[str, str] = {} + + if server_dotted: + server = _resolve_server_instance(server_dotted) + if server is None: + logger.warning( + "sphinx_autodoc_fastmcp: fastmcp_server_module %r did not resolve " + "to a FastMCP instance; prompts/resources will be empty", + server_dotted, + ) + else: + try: + # Local imports so callers without fastmcp installed don't pay + # the import cost unless they actually point at a server. + from fastmcp.prompts.base import Prompt as _Prompt + from fastmcp.resources.base import Resource as _Resource + from fastmcp.resources.template import ( + ResourceTemplate as _ResourceTemplate, + ) + except ImportError: # pragma: no cover - defensive + logger.warning( + "sphinx_autodoc_fastmcp: could not import fastmcp types", + exc_info=True, + ) + _Prompt = _Resource = _ResourceTemplate = None + + if _Prompt is not None: + for component in _iter_components(server): + if isinstance(component, _ResourceTemplate): + info_tpl = _resource_template_from_component(component) + key_tpl = str(info_tpl.uri_template) + templates[key_tpl] = info_tpl + _index_by_name( + template_names, info_tpl.name, key_tpl, "template" + ) + elif isinstance(component, _Resource): + info_res = _resource_from_component(component) + key_res = str(info_res.uri) + resources[key_res] = info_res + _index_by_name( + resource_names, info_res.name, key_res, "resource" + ) + elif isinstance(component, _Prompt): + info_p = _prompt_from_component(component) + prompts[info_p.name] = info_p + + app.env.fastmcp_prompts = prompts # type: ignore[attr-defined] + app.env.fastmcp_resources = resources # type: ignore[attr-defined] + app.env.fastmcp_resource_names = resource_names # type: ignore[attr-defined] + app.env.fastmcp_resource_templates = templates # type: ignore[attr-defined] + app.env.fastmcp_resource_template_names = template_names # type: ignore[attr-defined] + + +def _index_by_name(name_index: dict[str, str], name: str, key: str, kind: str) -> None: + """Record ``name -> key`` mapping; warn on first-wins collision. + + FastMCP allows two distinct resources/templates to share a display name + while remaining keyed apart by URI. Authoring docs by name is convenient + but ambiguous in that case — first-wins, with a clear warning so users + know to disambiguate by URI. + """ + existing = name_index.get(name) + if existing is not None and existing != key: + logger.warning( + "sphinx_autodoc_fastmcp: %s name %r is ambiguous " + "(%s and %s share it); resolving to %s", + kind, + name, + existing, + key, + existing, + ) + return + name_index[name] = key diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py index fd25da47..8aed7a7b 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py @@ -25,8 +25,19 @@ class _CSS: TOOL_SECTION = "gp-sphinx-fastmcp__tool-section" SECTION_TITLE_HIDDEN = "gp-sphinx-fastmcp__visually-hidden" TYPE_TOOL = "gp-sphinx-fastmcp__type-tool" + TYPE_PROMPT = "gp-sphinx-fastmcp__type-prompt" + TYPE_RESOURCE = "gp-sphinx-fastmcp__type-resource" + TYPE_RESOURCE_TEMPLATE = "gp-sphinx-fastmcp__type-resource-template" + BADGE_MIME = "gp-sphinx-fastmcp__mime" + BADGE_TAG = "gp-sphinx-fastmcp__tag" TOOL_ENTRY = "gp-sphinx-fastmcp__tool-entry" TOOL_SIGNATURE = "gp-sphinx-fastmcp__tool-signature" + PROMPT_SECTION = "gp-sphinx-fastmcp__prompt-section" + PROMPT_ENTRY = "gp-sphinx-fastmcp__prompt-entry" + PROMPT_SIGNATURE = "gp-sphinx-fastmcp__prompt-signature" + RESOURCE_SECTION = "gp-sphinx-fastmcp__resource-section" + RESOURCE_ENTRY = "gp-sphinx-fastmcp__resource-entry" + RESOURCE_SIGNATURE = "gp-sphinx-fastmcp__resource-signature" BODY_SECTION = "gp-sphinx-fastmcp__body-section" # Safety slot + tier values diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py index d299dfac..7c39a6bb 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -2,12 +2,29 @@ from __future__ import annotations +import logging +import typing as t + from docutils import nodes from sphinx.util.docutils import SphinxDirective -from sphinx_autodoc_fastmcp._badges import build_tool_badge_group +if t.TYPE_CHECKING: + from sphinx.environment import BuildEnvironment + +from sphinx_autodoc_fastmcp._badges import ( + build_prompt_badge_group, + build_resource_badge_group, + build_tool_badge_group, +) from sphinx_autodoc_fastmcp._css import _CSS -from sphinx_autodoc_fastmcp._models import ParamInfo, ToolInfo +from sphinx_autodoc_fastmcp._models import ( + ParamInfo, + PromptArgInfo, + PromptInfo, + ResourceInfo, + ResourceTemplateInfo, + ToolInfo, +) from sphinx_autodoc_fastmcp._parsing import ( first_paragraph, make_para, @@ -30,6 +47,109 @@ build_api_table_section, ) +logger = logging.getLogger(__name__) + + +def _register_section_label( + env: BuildEnvironment, section_id: str, display_name: str +) -> None: + """Register section in Sphinx std domain at parse time so {ref} resolves it. + + Must run inside a directive's run() — at that point env.docname is set and + domain.data is the live dict that gets pickled, surviving incremental builds. + + Direct dict writes mirror the same-package pattern in + :func:`sphinx_autodoc_fastmcp._transforms.register_tool_labels`; Sphinx + flags ``StandardDomain.note_hyperlink_target`` as internal-use-only. + + Examples + -------- + >>> import types + >>> std = types.SimpleNamespace(labels={}, anonlabels={}) + >>> domains = types.SimpleNamespace(standard_domain=std) + >>> env = types.SimpleNamespace(docname="api", domains=domains) + >>> _register_section_label(env, "fastmcp-tool-foo", "foo") + >>> std.labels["fastmcp-tool-foo"] + ('api', 'fastmcp-tool-foo', 'foo') + >>> std.anonlabels["fastmcp-tool-foo"] + ('api', 'fastmcp-tool-foo') + """ + std = env.domains.standard_domain + std.anonlabels[section_id] = (env.docname, section_id) + std.labels[section_id] = (env.docname, section_id, display_name) + + +def _component_ids(kind: str, name: str) -> tuple[str, list[str]]: + """Derive canonical section id + back-compat aliases for a component. + + Canonical IDs always namespace by kind so a tool ``status`` and a + prompt ``status`` cannot collide in ``std.labels``. Tools additionally + keep the bare slug as an alias because their unprefixed IDs were the + public ``{ref}`` shape on ``main`` and live in downstream user docs; + prompts/resources/templates are new in this branch and have no such + history, so they get the canonical ID only. + + Examples + -------- + >>> _component_ids("tool", "list_sessions") + ('fastmcp-tool-list-sessions', ['list-sessions']) + >>> _component_ids("prompt", "greet_user") + ('fastmcp-prompt-greet-user', []) + >>> _component_ids("resource-template", "events_by_day") + ('fastmcp-resource-template-events-by-day', []) + """ + slug = name.replace("_", "-") + canonical = f"fastmcp-{kind}-{slug}" + aliases: list[str] = [slug] if kind == "tool" else [] + return canonical, aliases + + +def _register_alias_if_free( + env: BuildEnvironment, + *, + alias: str, + display_name: str, + kind: str, +) -> bool: + """Register a bare-slug alias in std.labels iff currently unclaimed. + + Aliases are tool-only by policy (back-compat with v1 ``{ref}`` URLs). + Calling this for any other kind is a programming error — raises + :class:`ValueError`. If the alias is already bound to a different + target, log WARNING and return ``False`` (canonical-only, no + silent overwrite). + + The alias maps to itself (``std.labels[alias] = (docname, alias, ...)``); + the bare slug is also pushed onto ``section["ids"]`` so the alias + resolves to a real HTML anchor without forcing existing ``:ref:`` + consumers to update href fragments. + + Returns True if the alias was registered, False if skipped. + """ + if kind != "tool": + msg = f"alias registration not permitted for kind={kind!r}" + raise ValueError(msg) + + std = env.domains.standard_domain + existing = std.labels.get(alias) or std.anonlabels.get(alias) + if existing is not None: + existing_doc = existing[0] + existing_id = existing[1] + if (existing_doc, existing_id) != (env.docname, alias): + logger.warning( + "sphinx_autodoc_fastmcp: bare alias %r for %s already claimed " + "by %s#%s; using canonical id only", + alias, + display_name, + existing_doc, + existing_id, + ) + return False + + std.anonlabels[alias] = (env.docname, alias) + std.labels[alias] = (env.docname, alias, display_name) + return True + class FastMCPToolDirective(SphinxDirective): """Autodocument one MCP tool: section (ToC/labels) + card body.""" @@ -61,11 +181,20 @@ def run(self) -> list[nodes.Node]: def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: """Build section card with shared API layout regions.""" document = self.state.document - section_id = tool.name.replace("_", "-") + section_id, aliases = _component_ids("tool", tool.name) section = nodes.section() section["ids"].append(section_id) + section["ids"].extend(aliases) section["classes"].extend((_CSS.TOOL_SECTION, API.CARD_SHELL)) + _register_section_label(self.env, section_id, tool.name) + for alias in aliases: + _register_alias_if_free( + self.env, + alias=alias, + display_name=tool.name, + kind="tool", + ) document.note_explicit_target(section) title_node = nodes.title("", "") @@ -267,3 +396,314 @@ def run(self) -> list[nodes.Node]: result_nodes.append(section) return result_nodes + + +def _arg_table( + args: list[PromptArgInfo], + *, + env: t.Any, + state: t.Any, + lineno: int, + heading_word: str, +) -> list[nodes.Node]: + """Build a Parameters/Arguments section for prompt or template args.""" + if not args: + return [] + headers = ["Argument", "Type", "Required", "Description"] + rows: list[list[str | nodes.Node]] = [] + for arg in args: + type_cell: str | nodes.Node = "—" + if arg.type_str: + try: + type_cell = build_annotation_display_paragraph(arg.type_str, env) + except Exception: # pragma: no cover - defensive + type_cell = make_para(nodes.literal("", arg.type_str)) + desc_node: nodes.Node + if arg.description: + desc_node = parse_rst_inline(arg.description, state, lineno) + else: + desc_node = nodes.paragraph("", "—") + rows.append( + [ + make_para(nodes.literal("", arg.name)), + type_cell, + "yes" if arg.required else "no", + desc_node, + ], + ) + return [ + make_para(nodes.strong("", heading_word)), + build_api_table_section( + API.PARAMETERS, + make_table(headers, rows, col_widths=[20, 20, 10, 50]), + ), + ] + + +class FastMCPPromptDirective(SphinxDirective): + """Autodocument one MCP prompt: section + card body.""" + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build section with title + description for one prompt.""" + arg = self.arguments[0] + prompt_name = arg.split(".")[-1] if "." in arg else arg + prompts: dict[str, PromptInfo] = getattr(self.env, "fastmcp_prompts", {}) + prompt = prompts.get(prompt_name) + if prompt is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-prompt: prompt '{prompt_name}' not found. " + f"Available: {', '.join(sorted(prompts.keys()))}", + line=self.lineno, + ), + ] + + document = self.state.document + section_id, _ = _component_ids("prompt", prompt.name) + section = nodes.section() + section["ids"].append(section_id) + section["classes"].extend((_CSS.PROMPT_SECTION, API.CARD_SHELL)) + _register_section_label(self.env, section_id, prompt.name) + document.note_explicit_target(section) + + title_node = nodes.title("", "") + title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) + title_node += nodes.literal("", prompt.name) + section += title_node + + link = api_permalink(href=f"#{section_id}", title="Link to this prompt") + link["classes"] = ["headerlink", API.LINK] + + description = prompt.description or first_paragraph(prompt.docstring) + content_nodes: list[nodes.Node] = [] + if description: + content_nodes.append( + build_api_section( + API.DESCRIPTION, + parse_rst_inline(description, self.state, self.lineno), + classes=(_CSS.BODY_SECTION,), + ), + ) + + section += build_api_card_entry( + profile_class=API.profile("fastmcp-prompt"), + signature_children=(nodes.literal("", prompt.name),), + content_children=tuple(content_nodes), + badge_group=build_prompt_badge_group(prompt.tags), + permalink=link, + entry_classes=(_CSS.PROMPT_ENTRY,), + signature_classes=(_CSS.PROMPT_SIGNATURE,), + ) + return [section] + + +class FastMCPPromptInputDirective(SphinxDirective): + """Emit the argument table for a prompt.""" + + required_arguments = 1 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build argument table nodes.""" + arg = self.arguments[0] + prompt_name = arg.split(".")[-1] if "." in arg else arg + prompts: dict[str, PromptInfo] = getattr(self.env, "fastmcp_prompts", {}) + prompt = prompts.get(prompt_name) + if prompt is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-prompt-input: prompt '{prompt_name}' not found.", + line=self.lineno, + ), + ] + return _arg_table( + prompt.arguments, + env=self.env, + state=self.state, + lineno=self.lineno, + heading_word="Arguments", + ) + + +def _build_resource_card( + *, + env: BuildEnvironment, + state: t.Any, + lineno: int, + signature_text: str, + description: str, + docstring: str, + badge_group: nodes.inline, + mime_type: str, + shell_class: str, + entry_class: str, + signature_class: str, + profile_name: str, + section_id: str, + display_name: str, + document: t.Any, + permalink_title: str = "Link to this resource", +) -> nodes.Node: + """Shared card builder for resources & resource templates.""" + content_nodes: list[nodes.Node] = [] + body = description or first_paragraph(docstring) + if body: + content_nodes.append( + build_api_section( + API.DESCRIPTION, + parse_rst_inline(body, state, lineno), + classes=(_CSS.BODY_SECTION,), + ), + ) + + if mime_type: + content_nodes.append( + build_api_facts_section( + [ApiFactRow("MIME type", nodes.literal("", mime_type))], + classes=(_CSS.BODY_SECTION,), + ), + ) + + section = nodes.section() + section["ids"].append(section_id) + section["classes"].extend((shell_class, API.CARD_SHELL)) + _register_section_label(env, section_id, display_name) + document.note_explicit_target(section) + + title_node = nodes.title("", "") + title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) + title_node += nodes.literal("", display_name) + section += title_node + + link = api_permalink(href=f"#{section_id}", title=permalink_title) + link["classes"] = ["headerlink", API.LINK] + + section += build_api_card_entry( + profile_class=API.profile(profile_name), + signature_children=(nodes.literal("", signature_text),), + content_children=tuple(content_nodes), + badge_group=badge_group, + permalink=link, + entry_classes=(entry_class,), + signature_classes=(signature_class,), + ) + return section + + +class FastMCPResourceDirective(SphinxDirective): + """Autodocument one MCP resource (fixed URI).""" + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build section card for a resource.""" + arg = self.arguments[0] + resources: dict[str, ResourceInfo] = getattr(self.env, "fastmcp_resources", {}) + names: dict[str, str] = getattr(self.env, "fastmcp_resource_names", {}) + # Try literal URI first, then friendly-name lookup via the name index. + name = arg.split(".")[-1] if "." in arg else arg + res = resources.get(arg) or resources.get(names.get(name, "")) + if res is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-resource: resource '{name}' not found. " + f"Available: {', '.join(sorted(resources.keys()))}", + line=self.lineno, + ), + ] + return [ + _build_resource_card( + env=self.env, + state=self.state, + lineno=self.lineno, + signature_text=res.uri, + description=res.description, + docstring=res.docstring, + badge_group=build_resource_badge_group( + res.mime_type, + res.tags, + kind="resource", + ), + mime_type=res.mime_type, + shell_class=_CSS.RESOURCE_SECTION, + entry_class=_CSS.RESOURCE_ENTRY, + signature_class=_CSS.RESOURCE_SIGNATURE, + profile_name="fastmcp-resource", + section_id=_component_ids("resource", res.name)[0], + display_name=res.name, + document=self.state.document, + ), + ] + + +class FastMCPResourceTemplateDirective(SphinxDirective): + """Autodocument one MCP resource template (parameterised URI).""" + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build section card for a resource template.""" + arg = self.arguments[0] + templates: dict[str, ResourceTemplateInfo] = getattr( + self.env, + "fastmcp_resource_templates", + {}, + ) + names: dict[str, str] = getattr(self.env, "fastmcp_resource_template_names", {}) + # Try literal URI template first, then friendly-name lookup. + name = arg.split(".")[-1] if "." in arg else arg + tpl = templates.get(arg) or templates.get(names.get(name, "")) + if tpl is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-resource-template: template '{name}' not found. " + f"Available: {', '.join(sorted(templates.keys()))}", + line=self.lineno, + ), + ] + card = _build_resource_card( + env=self.env, + state=self.state, + lineno=self.lineno, + signature_text=tpl.uri_template, + description=tpl.description, + docstring=tpl.docstring, + badge_group=build_resource_badge_group( + tpl.mime_type, + tpl.tags, + kind="resource-template", + ), + mime_type=tpl.mime_type, + shell_class=_CSS.RESOURCE_SECTION, + entry_class=_CSS.RESOURCE_ENTRY, + signature_class=_CSS.RESOURCE_SIGNATURE, + profile_name="fastmcp-resource-template", + section_id=_component_ids("resource-template", tpl.name)[0], + display_name=tpl.name, + document=self.state.document, + permalink_title="Link to this resource template", + ) + result: list[nodes.Node] = [card] + if tpl.parameters: + result.extend( + _arg_table( + tpl.parameters, + env=self.env, + state=self.state, + lineno=self.lineno, + heading_word="Parameters", + ), + ) + return result diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py index 031f2f6f..0afd1cb3 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py @@ -1,9 +1,9 @@ -"""Data models for FastMCP tool documentation.""" +"""Data models for FastMCP tool / prompt / resource documentation.""" from __future__ import annotations import typing as t -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass @@ -33,10 +33,61 @@ class ToolInfo: return_annotation: str +@dataclass +class PromptArgInfo: + """One ``arguments[]`` entry on an MCP prompt.""" + + name: str + description: str + required: bool + type_str: str = "" + + +@dataclass +class PromptInfo: + """Collected metadata for a single MCP prompt. + + The underlying function is intentionally not retained — FastMCP + resources and prompts are frequently defined as closure-local + functions, which cannot be pickled into Sphinx's environment + cache. We extract the docstring eagerly at collect time. + """ + + name: str + title: str + description: str + docstring: str + tags: tuple[str, ...] + arguments: list[PromptArgInfo] + module_name: str = "" + + @dataclass class ResourceInfo: - """Placeholder for future FastMCP resource documentation.""" + """Collected metadata for a single MCP resource (fixed URI).""" + + name: str + uri: str + title: str + description: str + mime_type: str + docstring: str + tags: tuple[str, ...] = () + annotations: dict[str, t.Any] = field(default_factory=dict) + module_name: str = "" + + +@dataclass +class ResourceTemplateInfo: + """Collected metadata for a single MCP resource *template* (URI pattern).""" name: str - uri_template: str = "" + uri_template: str + title: str + description: str + mime_type: str + parameters: list[PromptArgInfo] + docstring: str + tags: tuple[str, ...] = () + annotations: dict[str, t.Any] = field(default_factory=dict) module_name: str = "" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css index bd090bc1..e84c0e58 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css @@ -6,9 +6,13 @@ * * Safety palette: same tokens as sphinx_gp_theme/theme/static/css/custom.css * so readonly / mutating / destructive match production regardless of load order. + * + * Type badge palette (tool / prompt / resource): one variable set per type; + * dark mode overrides the same variables in a scoped block — no -dark suffix antipattern. */ :root { + /* ── Safety tier (kept in sync with sphinx_gp_theme/custom.css) ── */ --gp-sphinx-fastmcp-safety-readonly-bg: #1f7a3f; --gp-sphinx-fastmcp-safety-readonly-border: #2a8d4d; --gp-sphinx-fastmcp-safety-readonly-text: #f3fff7; @@ -18,9 +22,31 @@ --gp-sphinx-fastmcp-safety-destructive-bg: #b4232c; --gp-sphinx-fastmcp-safety-destructive-border: #cb3640; --gp-sphinx-fastmcp-safety-destructive-text: #fff5f5; + + /* ── Type: tool — teal ── */ --gp-sphinx-fastmcp-type-tool-bg: #0e7490; - --gp-sphinx-fastmcp-type-tool-fg: #fff; + --gp-sphinx-fastmcp-type-tool-fg: #ffffff; --gp-sphinx-fastmcp-type-tool-border: #0f766e; + + /* ── Type: prompt — muted violet ── */ + --gp-sphinx-fastmcp-type-prompt-bg: #5b21b6; + --gp-sphinx-fastmcp-type-prompt-fg: #ffffff; + --gp-sphinx-fastmcp-type-prompt-border: #4c1d95; + + /* ── Type: resource (fixed URI) — steel blue ── */ + --gp-sphinx-fastmcp-type-resource-bg: #1e40af; + --gp-sphinx-fastmcp-type-resource-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-border: #1e3a8a; + + /* ── Type: resource-template (parameterised URI) — sky blue ── */ + --gp-sphinx-fastmcp-type-resource-template-bg: #0369a1; + --gp-sphinx-fastmcp-type-resource-template-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-template-border: #075985; + + /* ── MIME / tag ghost pill ── */ + --gp-sphinx-fastmcp-mime-bg: transparent; + --gp-sphinx-fastmcp-mime-fg: #6b7280; + --gp-sphinx-fastmcp-mime-border: #d1d5db; } /* ── Safety badges: gp-sphinx-badge--dense provides compact metrics; restore inline-flex for icon gap ── */ @@ -54,43 +80,107 @@ box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -/* MCP "tool": white label (never use --sd-color-info-text; it is dark on light teal) */ +/* ── Type badges: use variables; dark mode re-declares the same vars in a scoped block ── */ + .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { - background-color: #0e7490 !important; - color: #ffffff !important; - border: 1px solid #0f766e !important; + background-color: var(--gp-sphinx-fastmcp-type-tool-bg) !important; + color: var(--gp-sphinx-fastmcp-type-tool-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-type-tool-border) !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +} + +.gp-sphinx-badge.gp-sphinx-fastmcp__type-prompt:not(.gp-sphinx-badge--inline-icon) { + background-color: var(--gp-sphinx-fastmcp-type-prompt-bg) !important; + color: var(--gp-sphinx-fastmcp-type-prompt-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-type-prompt-border) !important; box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } +.gp-sphinx-badge.gp-sphinx-fastmcp__type-resource:not(.gp-sphinx-badge--inline-icon) { + background-color: var(--gp-sphinx-fastmcp-type-resource-bg) !important; + color: var(--gp-sphinx-fastmcp-type-resource-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-type-resource-border) !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +} + +.gp-sphinx-badge.gp-sphinx-fastmcp__type-resource-template:not(.gp-sphinx-badge--inline-icon) { + background-color: var(--gp-sphinx-fastmcp-type-resource-template-bg) !important; + color: var(--gp-sphinx-fastmcp-type-resource-template-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-type-resource-template-border) !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; +} + +/* MIME type and tag pills: ghost outline — whispers rather than shouts */ +.gp-sphinx-badge.gp-sphinx-fastmcp__mime:not(.gp-sphinx-badge--inline-icon), +.gp-sphinx-badge.gp-sphinx-fastmcp__tag:not(.gp-sphinx-badge--inline-icon) { + background-color: var(--gp-sphinx-fastmcp-mime-bg) !important; + color: var(--gp-sphinx-fastmcp-mime-fg) !important; + border: 1px solid var(--gp-sphinx-fastmcp-mime-border) !important; + box-shadow: none !important; +} + +/* ── Dark mode: override variables only; selectors above need not repeat ── */ + +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) { + --gp-sphinx-fastmcp-type-tool-bg: #0d9488; + --gp-sphinx-fastmcp-type-tool-fg: #ffffff; + --gp-sphinx-fastmcp-type-tool-border: #14b8a6; + + --gp-sphinx-fastmcp-type-prompt-bg: #7c3aed; + --gp-sphinx-fastmcp-type-prompt-fg: #ffffff; + --gp-sphinx-fastmcp-type-prompt-border: #8b5cf6; + + --gp-sphinx-fastmcp-type-resource-bg: #2563eb; + --gp-sphinx-fastmcp-type-resource-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-border: #3b82f6; + + --gp-sphinx-fastmcp-type-resource-template-bg: #0284c7; + --gp-sphinx-fastmcp-type-resource-template-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-template-border: #0ea5e9; + + --gp-sphinx-fastmcp-mime-fg: #9ca3af; + --gp-sphinx-fastmcp-mime-border: #4b5563; + } +} + +body[data-theme="dark"] { + --gp-sphinx-fastmcp-type-tool-bg: #0d9488; + --gp-sphinx-fastmcp-type-tool-fg: #ffffff; + --gp-sphinx-fastmcp-type-tool-border: #14b8a6; + + --gp-sphinx-fastmcp-type-prompt-bg: #7c3aed; + --gp-sphinx-fastmcp-type-prompt-fg: #ffffff; + --gp-sphinx-fastmcp-type-prompt-border: #8b5cf6; + + --gp-sphinx-fastmcp-type-resource-bg: #2563eb; + --gp-sphinx-fastmcp-type-resource-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-border: #3b82f6; + + --gp-sphinx-fastmcp-type-resource-template-bg: #0284c7; + --gp-sphinx-fastmcp-type-resource-template-fg: #ffffff; + --gp-sphinx-fastmcp-type-resource-template-border: #0ea5e9; + + --gp-sphinx-fastmcp-mime-fg: #9ca3af; + --gp-sphinx-fastmcp-mime-border: #4b5563; +} + +/* Safety dark-mode box-shadow (safety badges intentionally keep hardcoded hex — see note above) */ @media (prefers-color-scheme: dark) { body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-readonly:not(.gp-sphinx-badge--inline-icon), body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-mutating:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon), - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon) { box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } - - body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { - background-color: #0d9488 !important; - color: #ffffff !important; - border: 1px solid #14b8a6 !important; - } } body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-readonly:not(.gp-sphinx-badge--inline-icon), body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-mutating:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon), -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon) { box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } -body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { - background-color: #0d9488 !important; - color: #ffffff !important; - border: 1px solid #14b8a6 !important; -} - -/* ── Emoji when data-icon absent (unicode); data-icon wins via badges base ── */ +/* ── Emoji fallback (CSS ::before) when data-icon absent ── */ .gp-sphinx-fastmcp__safety-readonly:not([data-icon])::before { content: "\1F50D"; } @@ -118,11 +208,27 @@ section.gp-sphinx-fastmcp__tool-section > .gp-sphinx-fastmcp__tool-entry > .gp-s margin-top: 0.75rem; } -/* Hide the "tool" type badge in TOC sidebar (redundant there). */ -.toc-tree .gp-sphinx-fastmcp__type-tool { +/* Hide type badges in TOC sidebar (redundant — the section title already names the item). */ +.toc-tree .gp-sphinx-fastmcp__type-tool, +.toc-tree .gp-sphinx-fastmcp__type-prompt, +.toc-tree .gp-sphinx-fastmcp__type-resource, +.toc-tree .gp-sphinx-fastmcp__type-resource-template { display: none !important; } +/* ── Prompt / resource section cards ──────────────────────── */ +section.gp-sphinx-fastmcp__prompt-section, +section.gp-sphinx-fastmcp__resource-section { + padding: 0; + overflow: clip; +} + +section.gp-sphinx-fastmcp__prompt-section > .gp-sphinx-fastmcp__prompt-entry > .gp-sphinx-api-header .gp-sphinx-api-signature, +section.gp-sphinx-fastmcp__resource-section > .gp-sphinx-fastmcp__resource-entry > .gp-sphinx-api-header .gp-sphinx-api-signature { + min-width: 0; + font-family: var(--font-stack--monospace); +} + .gp-sphinx-fastmcp__visually-hidden { position: absolute; width: 1px; diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py index ec8edf2b..44c01673 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -31,17 +31,23 @@ def _tool_content_container(section: nodes.section) -> nodes.Element: return section +_CARD_SECTION_CLASSES: frozenset[str] = frozenset( + {_CSS.TOOL_SECTION, _CSS.PROMPT_SECTION, _CSS.RESOURCE_SECTION} +) + + def collect_tool_section_content(app: Sphinx, doctree: nodes.document) -> None: - """Move siblings following each tool section into the section. + """Move siblings following each card section into the section. Directive-returned ``nodes.section`` is a closed node — MyST does not "enter" it. This transform runs after parsing and re-parents prose, - code blocks, and ``{fastmcp-tool-input}`` tables that sit between one - tool section and the next boundary (``---`` transition or another tool - section). + code blocks, and input tables that sit between one card section and the + next boundary (``---`` transition or another section). + + Covers tool, prompt, and resource card sections. """ for section in list(doctree.findall(nodes.section)): - if _CSS.TOOL_SECTION not in section.get("classes", []): + if not _CARD_SECTION_CLASSES.intersection(section.get("classes", [])): continue parent = section.parent if parent is None: @@ -60,22 +66,30 @@ def collect_tool_section_content(app: Sphinx, doctree: nodes.document) -> None: def register_tool_labels(app: Sphinx, doctree: nodes.document) -> None: - """Mirror autosectionlabel for tool sections (``{ref}`tool-id```).""" + """Mirror autosectionlabel for fastmcp card sections (``{ref}````). + + Re-registers labels for every id on each card section (canonical AND + any back-compat aliases), so incremental Sphinx rebuilds — which + purge labels when a doc changes — restore both shapes from the + doctree cache without re-running the directive's parse-time + registration. + """ domain = app.env.domains.standard_domain docname = app.env.docname for section in doctree.findall(nodes.section): if not section["ids"]: continue - section_id = section["ids"][0] - if section.children and isinstance(section[0], nodes.title): - title_node = section[0] - tool_name = "" - for child in title_node.children: - if isinstance(child, nodes.literal): - tool_name = child.astext() - break - if not tool_name: - continue + if not (section.children and isinstance(section[0], nodes.title)): + continue + title_node = section[0] + tool_name = "" + for child in title_node.children: + if isinstance(child, nodes.literal): + tool_name = child.astext() + break + if not tool_name: + continue + for section_id in section["ids"]: domain.anonlabels[section_id] = (docname, section_id) domain.labels[section_id] = (docname, section_id, tool_name) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py index 06767d4e..a809e9ee 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py @@ -36,6 +36,8 @@ import typing as t _NUMPY_UNDERLINE_RE = re.compile(r"^[=\-`:'\"~^_*+#<>]{2,}\s*$") +_ROLE_RE = re.compile(r":[a-zA-Z][a-zA-Z0-9_.-]*:`([^`]+)`") +_TODO_DIRECTIVE_RE = re.compile(r"^\.\.\s+todo\s*::") _XREF_OR_CODE_RE = re.compile( r"((?::(?:[a-zA-Z0-9]+[\-_+:.])*[a-zA-Z0-9]+:`.+?`)|" @@ -299,6 +301,113 @@ def _indent(lines: list[str], n: int = 4) -> list[str]: return [pad + line for line in lines] +def _shorten_role_target(target: str) -> str: + """Shorten a role target so a leading ``~`` keeps only the last component. + + Mirrors :class:`sphinx.domains.python.PyXRefRole`. + + Examples + -------- + >>> _shorten_role_target("~pkg.mod.MyErr") + 'MyErr' + >>> _shorten_role_target("~MyErr") + 'MyErr' + >>> _shorten_role_target("pkg.mod.MyErr") + 'pkg.mod.MyErr' + """ + if not target.startswith("~"): + return target + target = target[1:] + dot = target.rfind(".") + if dot != -1: + return target[dot + 1 :] + return target + + +def _strip_roles(s: str) -> str: + """Strip RST inline role markup, returning just the target text. + + Honors the leading ``~`` shortener convention used by Sphinx + cross-references. + + Examples + -------- + >>> _strip_roles(":exc:`ValueError`") + 'ValueError' + >>> _strip_roles(":exc:`exc.Foo`, :exc:`exc.Bar`") + 'exc.Foo, exc.Bar' + >>> _strip_roles(":exc:`~pkg.mod.MyErr`") + 'MyErr' + >>> _strip_roles("plain") + 'plain' + """ + return _ROLE_RE.sub(lambda m: _shorten_role_target(m.group(1)), s) + + +def _split_top_level(s: str, sep: str = ",") -> list[str]: + """Split *s* on *sep* at bracket depth 0 only. + + Keeps parameterised generics like ``Dict[str, X]`` intact while still + splitting ``exc.A, exc.B``. + + Examples + -------- + >>> _split_top_level("a, b, c") + ['a', ' b', ' c'] + >>> _split_top_level("Dict[str, X], int") + ['Dict[str, X]', ' int'] + >>> _split_top_level("List[Tuple[int, str]]") + ['List[Tuple[int, str]]'] + >>> _split_top_level("") + [''] + """ + parts: list[str] = [] + current: list[str] = [] + depth = 0 + for ch in s: + if ch in "[(": + depth += 1 + current.append(ch) + elif ch in "])": + depth -= 1 + current.append(ch) + elif ch == sep and depth == 0: + parts.append("".join(current)) + current = [] + else: + current.append(ch) + parts.append("".join(current)) + return parts + + +def _filter_todo_directives(lines: list[str]) -> list[str]: + """Remove ``.. todo::`` directives and their indented body from *lines*. + + Only ``.. todo::`` is removed — other "invisible" directives + (``.. only::``, ``.. ifconfig::``, etc.) pass through verbatim. The + name reflects the actual scope. + + Examples + -------- + >>> _filter_todo_directives([".. todo::", "", " assure it works.", "text"]) + ['text'] + >>> _filter_todo_directives(["keep this", ".. todo::", " body"]) + ['keep this'] + """ + result: list[str] = [] + skip_indent: int | None = None + for line in lines: + if skip_indent is not None: + if not line or _is_indented(line, skip_indent + 1): + continue + skip_indent = None + if _TODO_DIRECTIVE_RE.match(line.lstrip()): + skip_indent = _get_indent(line) + continue + result.append(line) + return result + + def _is_list(lines: list[str]) -> bool: """Return whether *lines* starts with a bullet or enumerated list.""" if not lines: @@ -613,7 +722,7 @@ def _dispatch(self, section: str) -> list[str]: label = "Example" if section == "example" else "Examples" return self._fmt_generic(label) if section in _NOTE_NAMES: - return self._fmt_generic("Notes") + return self._fmt_generic("Notes", suppress_empty=True) if section in _REFERENCE_NAMES: return self._fmt_generic("References") if section in _SEE_ALSO_NAMES: @@ -669,10 +778,21 @@ def _fmt_raises(self) -> list[str]: fields = self._consume_fields(parse_type=False, prefer_type=True) lines: list[str] = [] for _name, type_, desc in fields: - type_str = f" {type_}" if type_ else "" + type_ = _strip_roles(type_) + exc_types = [ + part.strip().rstrip(",") + for part in _split_top_level(type_) + if part.strip().rstrip(",") + ] + if not exc_types: + exc_types = [""] desc = _strip_empty(desc) - desc_str = " " + "\n ".join(desc) if any(desc) else "" - lines.append(f":raises{type_str}:{desc_str}") + for exc_type in exc_types: + type_str = f" {exc_type}" if exc_type else "" + if any(desc): + lines.extend(_format_block(f":raises{type_str}: ", desc)) + else: + lines.append(f":raises{type_str}:") if lines: lines.append("") return lines @@ -720,12 +840,15 @@ def _fmt_methods(self) -> list[str]: lines.append("") return lines - def _fmt_generic(self, label: str) -> list[str]: + def _fmt_generic(self, label: str, suppress_empty: bool = False) -> list[str]: raw = _strip_empty(self._consume_to_next_section()) raw = _dedent(raw) + raw = _strip_empty(_filter_todo_directives(raw)) header = f".. rubric:: {label}" if raw: return [header, "", *raw, ""] + if suppress_empty: + return [] return [header, ""] def _fmt_admonition(self, directive: str) -> list[str]: diff --git a/packages/sphinx-gp-theme/README.md b/packages/sphinx-gp-theme/README.md index 44b477be..e9289554 100644 --- a/packages/sphinx-gp-theme/README.md +++ b/packages/sphinx-gp-theme/README.md @@ -21,3 +21,28 @@ html_theme = "sphinx-gp-theme" ``` Or use with [gp-sphinx](https://gp-sphinx.git-pull.com) which sets the theme automatically. + +## JavaScript events + +The bundled SPA-navigation layer dispatches the following `CustomEvent`s on +`document`: + +| Event | When | `event.detail` | +|-------|------|----------------| +| `gp-sphinx:navigated` | After an SPA page swap completes — the new `.article-container` / `.sidebar-tree` / `.toc-drawer` are in the DOM and the built-in reinit (copybutton, scroll-spy, theme toggle) has run. | `{ url: string }` — the new page URL. | + +Widgets that bind event listeners to DOM inside `.article-container` should +listen for `gp-sphinx:navigated` in addition to `DOMContentLoaded`, because +that region is replaced in-place on every link click and old listeners are +destroyed along with it. Listeners on `document` or `window` (including those +added inside the handler) persist across swaps. + +Minimal pattern: + +```javascript +function init() { + document.querySelectorAll(".my-widget").forEach(/* ... */); +} +document.addEventListener("DOMContentLoaded", init); +document.addEventListener("gp-sphinx:navigated", init); +``` diff --git a/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js index 4f45b313..556fffe9 100644 --- a/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js @@ -187,6 +187,16 @@ initScrollSpy(); var btn = document.querySelector(".content-icon-container .theme-toggle"); if (btn) btn.addEventListener("click", cycleTheme); + + // Public hook: widgets that bind to swapped DOM re-initialise from here. + // Dispatched after the article/sidebar/TOC regions have been replaced and + // every built-in reinit step (copybutton, scroll-spy, theme toggle) has + // run. Consumers: `document.addEventListener("gp-sphinx:navigated", fn)`. + document.dispatchEvent( + new CustomEvent("gp-sphinx:navigated", { + detail: { url: location.href }, + }), + ); } // --- Navigation --- diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index bca4cfd3..d7387695 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -260,6 +260,15 @@ dl.gp-sphinx-api-container:not(.py) > dd.gp-sphinx-api-content { background: var(--color-api-background-hover); } +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-link { + visibility: hidden; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header:hover .gp-sphinx-api-link, +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-link:focus-visible { + visibility: visible; +} + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout { display: flex; align-items: center; diff --git a/pyproject.toml b/pyproject.toml index b7d82af5..b0c96623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,12 @@ disable_error_code = ["attr-defined", "unused-ignore"] module = ["sphinx_autodoc_pytest_fixtures", "sphinx_autodoc_pytest_fixtures.*", "tests.ext.pytest_fixtures.*"] ignore_errors = true +[[tool.mypy.overrides]] +# fastmcp is an optional runtime dependency; users without it installed still get +# full static analysis of everything else in sphinx-autodoc-fastmcp. +module = ["fastmcp", "fastmcp.*"] +ignore_missing_imports = true + [tool.ruff] target-version = "py310" extend-exclude = [ diff --git a/tests/ext/fastmcp/test_directives_integration.py b/tests/ext/fastmcp/test_directives_integration.py new file mode 100644 index 00000000..aa513b83 --- /dev/null +++ b/tests/ext/fastmcp/test_directives_integration.py @@ -0,0 +1,245 @@ +"""Integration coverage for the four FastMCP MyST directives. + +Builds one synthetic Sphinx project that registers a fake FastMCP-shaped +server (no real ``fastmcp`` dependency) via a tiny extension, renders all +four directives, and asserts the rendered HTML contains: + +* canonical kind-prefixed section IDs (``fastmcp-{kind}-{name}``) +* the resource MIME pill +* the prompt argument table +* the resource-template parameter table inside the card section + (sibling-adoption transform working) +* ``:ref:`` cross-references resolve to the canonical IDs and the build + emits zero ``undefined label`` warnings +""" + +from __future__ import annotations + +import textwrap + +import pytest +from docutils import nodes + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + get_doctree, + read_output, +) + +# A minimal fake-extension that builds in-memory ``PromptInfo`` / +# ``ResourceInfo`` / ``ResourceTemplateInfo`` instances and stuffs them onto +# ``app.env.fastmcp_*`` directly. Bypasses ``_resolve_server_instance`` so +# we don't need a real fastmcp dep — the directives are what we want to +# exercise here. +_FAKE_EXT_SOURCE = textwrap.dedent( + '''\ + """In-memory fastmcp-shaped fixture extension.""" + + from __future__ import annotations + + import typing as t + + from sphinx.application import Sphinx + + from sphinx_autodoc_fastmcp._models import ( + PromptArgInfo, + PromptInfo, + ResourceInfo, + ResourceTemplateInfo, + ) + + + def _populate(app: Sphinx) -> None: + prompts = { + "greet": PromptInfo( + name="greet", + title="Greet", + description="Greet a user.", + docstring="Greet a user.", + tags=("ops",), + arguments=[ + PromptArgInfo( + name="who", + description="Who to greet.", + required=True, + type_str="str", + ), + ], + ) + } + resources = { + "mem://hello": ResourceInfo( + name="hello", + uri="mem://hello", + title="Hello", + description="Static hello blob.", + mime_type="text/markdown", + docstring="Static hello blob.", + tags=("readonly",), + ) + } + templates = { + "mem://user/{id}": ResourceTemplateInfo( + name="user_record", + uri_template="mem://user/{id}", + title="User record", + description="Per-user record.", + mime_type="application/json", + parameters=[ + PromptArgInfo( + name="id", + description="User identifier.", + required=True, + type_str="str", + ), + ], + docstring="Per-user record.", + tags=("readonly",), + ) + } + app.env.fastmcp_prompts = prompts + app.env.fastmcp_resources = resources + app.env.fastmcp_resource_names = {"hello": "mem://hello"} + app.env.fastmcp_resource_templates = templates + app.env.fastmcp_resource_template_names = { + "user_record": "mem://user/{id}", + } + + + def setup(app: Sphinx) -> dict[str, t.Any]: + # Run AFTER sphinx_autodoc_fastmcp's collect_prompts_and_resources + # has cleared the env attributes — connect to builder-inited with + # the priority lever (>500) so we win. + app.connect("builder-inited", _populate, priority=600) + return {"version": "0.1", "parallel_read_safe": True} + ''' +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + import sys + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = [ + "myst_parser", + "sphinx_autodoc_fastmcp", + "fastmcp_fixture_ext", + ] + myst_enable_extensions = ["colon_fence"] + fastmcp_tool_modules = [] + """ +) + +_INDEX_MD = textwrap.dedent( + """\ + # FastMCP demo + + See {ref}`fastmcp-prompt-greet`, {ref}`fastmcp-resource-hello`, and + {ref}`fastmcp-resource-template-user-record`. + + ```{fastmcp-prompt} greet + ``` + + ```{fastmcp-prompt-input} greet + ``` + + --- + + ```{fastmcp-resource} hello + ``` + + --- + + ```{fastmcp-resource-template} user_record + ``` + """ +) + + +@pytest.fixture(scope="module") +def fastmcp_directives_html( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build the demo doc once per module.""" + cache_root = tmp_path_factory.mktemp("fastmcp-directives") + scenario = SphinxScenario( + files=( + ScenarioFile("fastmcp_fixture_ext.py", _FAKE_EXT_SOURCE), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile("index.md", _INDEX_MD), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("fastmcp_fixture_ext",), + ) + + +@pytest.mark.integration +def test_card_section_ids_use_canonical_prefix( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """Each directive emits a section with the canonical ``fastmcp-{kind}-{name}`` id.""" + html = read_output(fastmcp_directives_html, "index.html") + assert 'id="fastmcp-prompt-greet"' in html + assert 'id="fastmcp-resource-hello"' in html + assert 'id="fastmcp-resource-template-user-record"' in html + + +@pytest.mark.integration +def test_resource_mime_pill_renders( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """Resource cards expose their MIME type via the dedicated pill class.""" + html = read_output(fastmcp_directives_html, "index.html") + assert "text/markdown" in html + assert "gp-sphinx-api-facts" in html + + +@pytest.mark.integration +def test_prompt_argument_table_renders( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """The prompt argument table emits the parameter name + description.""" + html = read_output(fastmcp_directives_html, "index.html") + assert "who" in html + assert "Who to greet." in html + + +@pytest.mark.integration +def test_resource_template_param_table_inside_card( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """Sibling-adoption transform pulls the template's param table inside the card section.""" + doctree = get_doctree(fastmcp_directives_html, "index") + sections = [ + s + for s in doctree.findall(nodes.section) + if "fastmcp-resource-template-user-record" in s.get("ids", []) + ] + assert sections, "resource-template card section missing" + section = sections[0] + tables = list(section.findall(nodes.table)) + assert tables, "expected at least one parameter table inside the card section" + + +@pytest.mark.integration +def test_ref_xrefs_resolve_with_no_undefined_labels( + fastmcp_directives_html: SharedSphinxResult, +) -> None: + """All three :ref: targets resolve to the canonical IDs and emit no warnings.""" + html = read_output(fastmcp_directives_html, "index.html") + assert 'href="#fastmcp-prompt-greet"' in html + assert 'href="#fastmcp-resource-hello"' in html + assert 'href="#fastmcp-resource-template-user-record"' in html + assert "undefined label" not in fastmcp_directives_html.warnings diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py index 0f33f723..a4bc12cd 100644 --- a/tests/ext/fastmcp/test_fastmcp.py +++ b/tests/ext/fastmcp/test_fastmcp.py @@ -2,11 +2,16 @@ from __future__ import annotations +import logging +import sys +import types import typing as t +import pytest from docutils import nodes from sphinx_autodoc_fastmcp._badges import build_safety_badge, build_tool_badge_group +from sphinx_autodoc_fastmcp._collector import _resolve_server_instance from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._parsing import ( extract_params, @@ -91,3 +96,89 @@ def test_make_table_minimal() -> None: """make_table builds a table node.""" t = make_table(["A"], [["x"]]) assert isinstance(t, nodes.table) + + +def test_resolve_server_invokes_register_all_even_when_components_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hook fires regardless of pre-registered components. + + Servers may register some components at import time (decorators) while + leaving others to an explicit ``register_all()`` — gating on + ``_components`` being empty would silently drop the deferred ones. + """ + calls: list[str] = [] + + provider = types.SimpleNamespace(_components={"existing": object()}) + server = types.SimpleNamespace(local_provider=provider) + + fake_module = types.ModuleType("fake_fastmcp_server") + fake_module.mcp = server # type: ignore[attr-defined] + fake_module.register_all = lambda: calls.append("register_all") # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "fake_fastmcp_server", fake_module) + + resolved = _resolve_server_instance("fake_fastmcp_server:mcp") + + assert resolved is server + assert calls == ["register_all"] + + +def test_resolve_server_returns_none_when_attr_is_not_fastmcp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A configured attr that is not a FastMCP instance resolves to ``None``. + + Returning the bare object would cause ``_iter_components`` to silently + yield ``()`` and produce empty docs without any diagnostic. + """ + calls: list[str] = [] + + bare_obj = types.SimpleNamespace() # no local_provider + + fake_module = types.ModuleType("fake_fastmcp_bare") + fake_module.mcp = bare_obj # type: ignore[attr-defined] + fake_module.register_all = lambda: calls.append("register_all") # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "fake_fastmcp_bare", fake_module) + + resolved = _resolve_server_instance("fake_fastmcp_bare:mcp") + + assert resolved is None + assert calls == [] + + +def test_resolve_server_warns_when_attr_is_not_fastmcp( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The not-a-FastMCP path emits a WARNING-level diagnostic via caplog.records.""" + bare_obj = types.SimpleNamespace() + fake_module = types.ModuleType("fake_fastmcp_bare2") + fake_module.mcp = bare_obj # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "fake_fastmcp_bare2", fake_module) + + with caplog.at_level(logging.WARNING, logger="sphinx_autodoc_fastmcp"): + _resolve_server_instance("fake_fastmcp_bare2:mcp") + + matched = [ + r + for r in caplog.records + if r.name == "sphinx_autodoc_fastmcp._collector" + and "local_provider" in r.getMessage() + ] + assert len(matched) == 1 + assert matched[0].levelno == logging.WARNING + + +def test_resolve_server_returns_none_when_factory_yields_non_fastmcp( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A factory callable returning a non-FastMCP object resolves to ``None``.""" + fake_module = types.ModuleType("fake_fastmcp_factory") + fake_module.mcp = lambda: types.SimpleNamespace() # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "fake_fastmcp_factory", fake_module) + + resolved = _resolve_server_instance("fake_fastmcp_factory:mcp") + + assert resolved is None diff --git a/tests/ext/typehints_gp/test_unit.py b/tests/ext/typehints_gp/test_unit.py index 6332b332..9a2f64ed 100644 --- a/tests/ext/typehints_gp/test_unit.py +++ b/tests/ext/typehints_gp/test_unit.py @@ -804,6 +804,55 @@ class RaisesSectionFixture(t.NamedTuple): ], expected_in_output=[":raises TypeError:"], ), + RaisesSectionFixture( + test_id="raises_with_exc_role", + input_lines=[ + "Summary.", + "", + "Raises", + "------", + ":exc:`exc.LibTmuxException`", + ], + expected_in_output=[":raises exc.LibTmuxException:"], + ), + RaisesSectionFixture( + test_id="raises_multiple_comma_roles", + input_lines=[ + "Summary.", + "", + "Raises", + "------", + ":exc:`exc.OptionError`, :exc:`exc.UnknownOption`,", + ":exc:`exc.InvalidOption`", + ], + expected_in_output=[ + ":raises exc.OptionError:", + ":raises exc.UnknownOption:", + ":raises exc.InvalidOption:", + ], + ), + RaisesSectionFixture( + test_id="raises_with_tilde_role", + input_lines=[ + "Summary.", + "", + "Raises", + "------", + ":exc:`~pkg.mod.MyErr`", + ], + expected_in_output=[":raises MyErr:"], + ), + RaisesSectionFixture( + test_id="raises_with_bracketed_generic", + input_lines=[ + "Summary.", + "", + "Raises", + "------", + "Dict[str, MyErr]", + ], + expected_in_output=[":raises Dict[str, MyErr]:"], + ), ] @@ -904,6 +953,102 @@ def test_numpy_generic_section( assert fragment in joined, f"[{test_id}] Missing: {fragment!r}" +class GenericSectionEmptyFixture(t.NamedTuple): + """Fixture for empty/stub generic section behavior.""" + + test_id: str + input_lines: list[str] + rubric: str + should_emit: bool + + +_GENERIC_EMPTY_FIXTURES: list[GenericSectionEmptyFixture] = [ + GenericSectionEmptyFixture( + test_id="notes_empty_content", + input_lines=[ + "Summary.", + "", + "Notes", + "-----", + ], + rubric=".. rubric:: Notes", + should_emit=False, + ), + GenericSectionEmptyFixture( + test_id="notes_only_todo", + input_lines=[ + "Summary.", + "", + "Notes", + "-----", + ".. todo::", + "", + " assure it works.", + ], + rubric=".. rubric:: Notes", + should_emit=False, + ), + GenericSectionEmptyFixture( + test_id="examples_empty_keeps_rubric", + input_lines=[ + "Summary.", + "", + "Examples", + "--------", + ], + rubric=".. rubric:: Examples", + should_emit=True, + ), + GenericSectionEmptyFixture( + test_id="references_empty_keeps_rubric", + input_lines=[ + "Summary.", + "", + "References", + "----------", + ], + rubric=".. rubric:: References", + should_emit=True, + ), + GenericSectionEmptyFixture( + test_id="examples_only_todo_keeps_rubric", + input_lines=[ + "Summary.", + "", + "Examples", + "--------", + ".. todo::", + "", + " write me", + ], + rubric=".. rubric:: Examples", + should_emit=True, + ), +] + + +@pytest.mark.parametrize( + list(GenericSectionEmptyFixture._fields), + _GENERIC_EMPTY_FIXTURES, + ids=[f.test_id for f in _GENERIC_EMPTY_FIXTURES], +) +def test_numpy_generic_section_empty( + test_id: str, + input_lines: list[str], + rubric: str, + should_emit: bool, +) -> None: + """Empty Notes drops its rubric; other generic sections keep theirs.""" + result = process_numpy_docstring(input_lines) + joined = "\n".join(result) + if should_emit: + assert rubric in joined, f"[{test_id}] Expected rubric {rubric!r} to be emitted" + else: + assert rubric not in joined, ( + f"[{test_id}] Empty rubric {rubric!r} should not be emitted" + ) + + # --------------------------------------------------------------------------- # Special sections (See Also, Attributes, Admonitions) — parametrized # ---------------------------------------------------------------------------