diff --git a/CHANGES b/CHANGES index deaaca3f..b3e6cce7 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,23 @@ $ uv add gp-sphinx --prerelease allow +### What's new + +#### Cross-reference roles for resources and prompts + +`sphinx-autodoc-fastmcp` adds the `{resource}` / `{resourceref}` and +`{prompt}` / `{promptref}` inline roles, mirroring the existing +`{tool}` / `{toolref}` family. `{resource}` links a fixed resource or a +resource template by name, and an unknown name degrades to a plain +literal rather than a broken link. (#57) + +#### `:no-index:` for components shown on more than one page + +The `fastmcp-prompt`, `fastmcp-resource`, `fastmcp-resource-template`, +and `argparse` directives now accept `:no-index:`. Add it to every +appearance except the canonical one: the card still renders everywhere, +but registers its cross-reference target exactly once. (#57) + ## gp-sphinx 0.0.1a30 (2026-06-07) ### Fixes diff --git a/docs/packages/sphinx-autodoc-fastmcp/tutorial.md b/docs/packages/sphinx-autodoc-fastmcp/tutorial.md index 3b5e0421..b3fbad34 100644 --- a/docs/packages/sphinx-autodoc-fastmcp/tutorial.md +++ b/docs/packages/sphinx-autodoc-fastmcp/tutorial.md @@ -35,6 +35,16 @@ Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` for a plain inline reference. ```` +Prompts and resources have the same affordance (without a safety badge, which +only tools carry). `{resource}` resolves a fixed resource or a resource +template by name; `{prompt}` resolves a prompt: + +````myst +See the {resource}`status` resource, the {resource}`events_by_day` template, +and the {prompt}`greet` prompt. The `{resourceref}` / `{promptref}` spellings +are aliases mirroring `{toolref}`. +```` + ### Prompts and resources After setting `fastmcp_server_module`, four MyST directives become available @@ -59,6 +69,17 @@ Resources and resource templates accept either the friendly component name distinct resources share a name, autodoc keeps the first registration and emits a warning — disambiguate by URI. +Each of these directives accepts the standard `:no-index:` flag. When a +prompt, resource, or resource template is shown on more than one page, add +`:no-index:` to every appearance except the canonical one so the card still +renders everywhere but registers its cross-reference target exactly once: + +````myst +```{fastmcp-resource} my_resource +:no-index: +``` +```` + ### `:ref:` cross-reference IDs Section IDs follow `fastmcp-{kind}-{name}` (canonical): diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py index 172b3b4a..51951276 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py @@ -43,6 +43,10 @@ class ArgparseDirective(SphinxDirective): Override the program name (optional). :path: Navigate to a specific subparser by path (e.g., "sync pull"). + :no-index: + Render the parser without registering cross-reference targets + (flag). Use on secondary appearances so a parser documented on + more than one page keeps a single canonical xref home. :no-defaults: Don't show default values (flag). :no-description: @@ -73,6 +77,7 @@ class ArgparseDirective(SphinxDirective): "func": directives.unchanged_required, "prog": directives.unchanged, "path": directives.unchanged, + "no-index": directives.flag, "no-defaults": directives.flag, "no-description": directives.flag, "no-epilog": directives.flag, @@ -200,6 +205,8 @@ def _build_render_config(self) -> RenderConfig: config.show_choices = False if "no-types" in self.options: config.show_types = False + if "no-index" in self.options: + config.register_xref_targets = False return config diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py index d4724cfe..d7d1b8ce 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py @@ -48,12 +48,20 @@ class RenderConfig: True >>> config.group_title_prefix '' + >>> config.register_xref_targets + True """ group_title_prefix: str = "" show_defaults: bool = True show_choices: bool = True show_types: bool = True + # When False (set by the directive's ``:no-index:`` flag) the render still + # emits full markup and per-section HTML anchors, but registers no + # cross-reference targets: no ``argparse:*`` / ``std:cmdoption`` inventory + # entries and no implicit section labels. Lets a parser appear on more than + # one page with a single canonical xref home. + register_xref_targets: bool = True @classmethod def from_sphinx_config(cls, config: Config) -> RenderConfig: @@ -194,7 +202,7 @@ def _register_argument( >>> r = ArgparseRenderer() >>> r._register_argument("myapp", ["--verbose"], "verbose") # no-op without env """ - if self.env is None or not names: + if self.env is None or not names or not self.config.register_xref_targets: return std_domain = self.env.domains.standard_domain std_program = prog_name.replace(" ", "-") if prog_name else None @@ -233,7 +241,7 @@ def _register_program(self, prog_name: str, anchor_id: str) -> None: >>> r = ArgparseRenderer() >>> r._register_program("myapp", "argparse-myapp") # no-op without env """ - if self.env is None or not prog_name: + if self.env is None or not prog_name or not self.config.register_xref_targets: return argparse_domain = self.env.domains["argparse"] argparse_domain.note_program( # type: ignore[attr-defined] @@ -265,7 +273,7 @@ def _register_subcommand( >>> r = ArgparseRenderer() >>> r._register_subcommand("myapp", "sync", "argparse-myapp-sync") """ - if self.env is None or not name: + if self.env is None or not name or not self.config.register_xref_targets: return argparse_domain = self.env.domains["argparse"] argparse_domain.note_subcommand( # type: ignore[attr-defined] @@ -443,7 +451,9 @@ def render_usage_section( section["ids"] = [section_id] # Scope section name by id_prefix so multi-page docs don't collide # on the implicit "usage" target docutils creates from section["names"]. - section["names"] = [nodes.fully_normalize_name(section_id)] + # Omitted under :no-index: so the opted-out render leaks no label. + if self.config.register_xref_targets: + section["names"] = [nodes.fully_normalize_name(section_id)] section += nodes.title("Usage", "Usage") usage_node = argparse_usage() @@ -518,10 +528,12 @@ def render_group_section( # Create section wrapper for TOC discovery. Scope section name by # id_prefix so multi-page docs don't collide on the implicit target - # docutils creates from section["names"]. + # docutils creates from section["names"]. Omitted under :no-index: so + # the opted-out render leaks no label. section = nodes.section() section["ids"] = [section_id] - section["names"] = [nodes.fully_normalize_name(section_id)] + if self.config.register_xref_targets: + section["names"] = [nodes.fully_normalize_name(section_id)] # Add title for TOC - Sphinx's TocTreeCollector looks for this section += nodes.title(title, title) 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 c3aeabb3..f85fe62e 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -29,6 +29,10 @@ FastMCPToolSummaryDirective, ) from sphinx_autodoc_fastmcp._roles import ( + _prompt_role, + _promptref_role, + _resource_role, + _resourceref_role, _tool_role, _toolicon_role, _tooliconil_role, @@ -42,6 +46,7 @@ badge_role, collect_tool_section_content, register_tool_labels, + resolve_component_refs, resolve_tool_refs, ) @@ -177,6 +182,7 @@ def _add_static_path(app: Sphinx) -> None: app.connect("doctree-read", collect_tool_section_content) app.connect("doctree-resolved", add_section_badges) app.connect("doctree-resolved", resolve_tool_refs) + app.connect("doctree-resolved", resolve_component_refs) app.add_role("tool", _tool_role) app.add_role("toolref", _toolref_role) @@ -185,6 +191,10 @@ def _add_static_path(app: Sphinx) -> None: app.add_role("tooliconr", _tooliconr_role) app.add_role("tooliconil", _tooliconil_role) app.add_role("tooliconir", _tooliconir_role) + app.add_role("resource", _resource_role) + app.add_role("resourceref", _resourceref_role) + app.add_role("prompt", _prompt_role) + app.add_role("promptref", _promptref_role) app.add_role("badge", badge_role) app.add_directive("fastmcp-tool", FastMCPToolDirective) 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 f046104d..92788a8b 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -488,12 +488,23 @@ def _arg_table( class FastMCPPromptDirective(SphinxDirective): - """Autodocument one MCP prompt: section + card body.""" + """Autodocument one MCP prompt: section + card body. + + Supports the standard Sphinx ``:no-index:`` flag (mirrors + :class:`FastMCPToolDirective`): when set, the card still renders in full + but its canonical section ID is not registered in :class:`StandardDomain` + ``labels`` / ``anonlabels``. Use it when a prompt appears on more than one + page — exactly one invocation should omit ``:no-index:`` so cross-references + have a single canonical home. + """ required_arguments = 1 optional_arguments = 0 has_content = True final_argument_whitespace = False + option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "no-index": directives.flag, + } def run(self) -> list[nodes.Node]: """Build section with title + description for one prompt.""" @@ -512,11 +523,17 @@ def run(self) -> list[nodes.Node]: document = self.state.document section_id, _ = _component_ids("prompt", prompt.name) + no_index = "no-index" in self.options 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) + if no_index: + # Marker consumed by ``register_tool_labels`` so the doctree-read + # pass mirrors the directive's skip on incremental rebuilds. + section["fastmcp_no_index"] = True + else: + _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) @@ -596,6 +613,7 @@ def _build_resource_card( display_name: str, document: t.Any, permalink_title: str = "Link to this resource", + no_index: bool = False, ) -> nodes.Node: """Shared card builder for resources & resource templates.""" content_nodes: list[nodes.Node] = [] @@ -620,8 +638,13 @@ def _build_resource_card( 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) + if no_index: + # Marker consumed by ``register_tool_labels`` so the doctree-read pass + # mirrors the directive's skip on incremental rebuilds. + section["fastmcp_no_index"] = True + else: + _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) @@ -644,12 +667,21 @@ def _build_resource_card( class FastMCPResourceDirective(SphinxDirective): - """Autodocument one MCP resource (fixed URI).""" + """Autodocument one MCP resource (fixed URI). + + Supports the standard Sphinx ``:no-index:`` flag (mirrors + :class:`FastMCPToolDirective`): when set, the card renders but its canonical + section ID is not registered as a cross-reference target, so a resource + shown on more than one page registers its canonical home exactly once. + """ required_arguments = 1 optional_arguments = 0 has_content = True final_argument_whitespace = False + option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "no-index": directives.flag, + } def run(self) -> list[nodes.Node]: """Build section card for a resource.""" @@ -688,17 +720,26 @@ def run(self) -> list[nodes.Node]: section_id=_component_ids("resource", res.name)[0], display_name=res.name, document=self.state.document, + no_index="no-index" in self.options, ), ] class FastMCPResourceTemplateDirective(SphinxDirective): - """Autodocument one MCP resource template (parameterised URI).""" + """Autodocument one MCP resource template (parameterised URI). + + Supports the standard Sphinx ``:no-index:`` flag (mirrors + :class:`FastMCPToolDirective`): when set, the card renders but its canonical + section ID is not registered as a cross-reference target. + """ required_arguments = 1 optional_arguments = 0 has_content = True final_argument_whitespace = False + option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { + "no-index": directives.flag, + } def run(self) -> list[nodes.Node]: """Build section card for a resource template.""" @@ -741,6 +782,7 @@ def run(self) -> list[nodes.Node]: display_name=tpl.name, document=self.state.document, permalink_title="Link to this resource template", + no_index="no-index" in self.options, ) result: list[nodes.Node] = [card] if tpl.parameters: diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py index 40c3e4c4..7d7183d0 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py @@ -72,3 +72,77 @@ def role_fn( _tooliconr_role = _make_toolicon_role("right") _tooliconil_role = _make_toolicon_role("inline-left") _tooliconir_role = _make_toolicon_role("inline-right") + + +class _component_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): + """Placeholder for prompt/resource cross-refs, resolved at ``doctree-resolved``. + + Unlike tools, resources and prompts carry no bare-slug alias. The + ``refkind`` attribute selects the canonical-id family that + :func:`sphinx_autodoc_fastmcp._transforms.resolve_component_refs` looks up: + ``prompt`` targets the single ``fastmcp-prompt-`` id, while + ``resource`` tries ``fastmcp-resource-`` then + ``fastmcp-resource-template-`` so one spelling links either. + """ + + +def _make_component_ref_role( + refkind: str, +) -> t.Callable[..., tuple[list[nodes.Node], list[nodes.system_message]]]: + """Create a resource/prompt cross-reference role callable. + + The role renders an inline code literal linked to the component card; it + carries no safety badge (only tools have a safety tier). + + Parameters + ---------- + refkind : str + Component family the role resolves against — ``"resource"`` or + ``"prompt"``. Stored on the placeholder so + :func:`sphinx_autodoc_fastmcp._transforms.resolve_component_refs` + picks the right canonical-id family. + + Returns + ------- + collections.abc.Callable + A docutils role function ``(name, rawtext, text, lineno, inliner, + options=None, content=None)`` returning ``([placeholder], [])``. + + Examples + -------- + >>> role = _make_component_ref_role("resource") + >>> emitted, messages = role("resource", "", "user_record", 0, None) + >>> emitted[0]["refkind"], emitted[0]["refslug"], emitted[0]["reftext"] + ('resource', 'user-record', 'user_record') + >>> messages + [] + """ + + def role_fn( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, + ) -> tuple[list[nodes.Node], list[nodes.system_message]]: + display = text.strip() + node = _component_ref_placeholder( + rawtext, + refkind=refkind, + refslug=display.replace("_", "-"), + reftext=display, + ) + return [node], [] + + return role_fn + + +# ``{resource}`` covers both fixed resources and resource templates (the +# resolver tries each id family in turn); ``{resourceref}`` is the explicit +# plain-reference spelling mirroring ``{toolref}``. +_resource_role = _make_component_ref_role("resource") +_resourceref_role = _make_component_ref_role("resource") +_prompt_role = _make_component_ref_role("prompt") +_promptref_role = _make_component_ref_role("prompt") 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 f7e984af..c76aa9ab 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -11,7 +11,10 @@ from sphinx_autodoc_fastmcp._badges import build_safety_badge from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._models import ToolInfo -from sphinx_autodoc_fastmcp._roles import _tool_ref_placeholder +from sphinx_autodoc_fastmcp._roles import ( + _component_ref_placeholder, + _tool_ref_placeholder, +) from sphinx_ux_autodoc_layout import API, api_component from sphinx_ux_badges import SAB @@ -213,6 +216,62 @@ def resolve_tool_refs( node.replace_self(newnode) +_COMPONENT_REF_CANDIDATES: dict[str, tuple[str, ...]] = { + "resource": ("fastmcp-resource-{slug}", "fastmcp-resource-template-{slug}"), + "prompt": ("fastmcp-prompt-{slug}",), +} + + +def resolve_component_refs( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Resolve ``:resource:`` / ``:resourceref:`` / ``:prompt:`` / ``:promptref:``. + + Mirrors :func:`resolve_tool_refs` without the safety-badge branches: + resources and prompts have no safety tier, so each placeholder becomes a + plain inline reference (``reference`` wrapping ``literal``). ``{resource}`` + resolves against both the resource and resource-template id families so one + role spelling covers both. An unresolved target degrades to a bare literal. + """ + domain = app.env.domains.standard_domain + builder = app.builder + + for node in list(doctree.findall(_component_ref_placeholder)): + kind = node.get("refkind", "") + slug = node.get("refslug", "") + display = node.get("reftext", "") or slug + + label_info = None + for template in _COMPONENT_REF_CANDIDATES.get(kind, ()): + label_info = domain.labels.get(template.format(slug=slug)) + if label_info is not None: + break + + if label_info is None: + node.replace_self(nodes.literal("", display)) + continue + + todocname, labelid, _title = label_info + newnode = nodes.reference("", "", internal=True) + try: + newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname) + if labelid: + newnode["refuri"] += "#" + labelid + except Exception: + logger.warning( + "sphinx_autodoc_fastmcp: failed to resolve URI for %s -> %s", + fromdocname, + todocname, + ) + newnode["refuri"] = "#" + labelid + newnode["classes"].append("reference") + newnode["classes"].append("internal") + newnode += nodes.literal("", display) + node.replace_self(newnode) + + def badge_role( name: str, rawtext: str, diff --git a/tests/ext/autodoc_argparse/test_no_index_integration.py b/tests/ext/autodoc_argparse/test_no_index_integration.py new file mode 100644 index 00000000..12afa783 --- /dev/null +++ b/tests/ext/autodoc_argparse/test_no_index_integration.py @@ -0,0 +1,136 @@ +"""Integration test for the ``:no-index:`` flag on the argparse directive. + +Builds a synthetic Sphinx project that renders one parser with ``:no-index:`` +and verifies the card still renders (HTML anchors intact) while registering no +cross-reference targets: no ``argparse:*`` / ``std:cmdoption`` ``objects.inv`` +entries, no ``std`` domain ``progoptions``, and no implicit section labels. This +lets a parser appear on more than one page with a single canonical xref home. +""" + +from __future__ import annotations + +import textwrap +import typing as t + +import pytest +from sphinx.util.inventory import InventoryFile + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +_PARSER_MOD = textwrap.dedent( + """\ + from __future__ import annotations + + import argparse + + + def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="myapp") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose") + parser.add_argument("filename", help="Input file") + + sub = parser.add_subparsers(dest="command") + sync = sub.add_parser("sync", help="Synchronise") + sync.add_argument("--force", action="store_true", help="Force sync") + + return parser + """ +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + import sys + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + project = "argparse_no_index" + extensions = [ + "myst_parser", + "sphinx_autodoc_argparse", + ] + """ +) + +_INDEX_MD = textwrap.dedent( + """\ + # CLI Reference + + ```{eval-rst} + .. argparse:: + :module: myparser + :func: create_parser + :prog: myapp + :no-index: + ``` + """ +) + + +def _load_inventory(result: SharedSphinxResult) -> dict[str, dict[str, t.Any]]: + """Parse the built ``objects.inv`` into ``{domain: {name: item}}``.""" + inv_path = result.outdir / "objects.inv" + with inv_path.open("rb") as handle: + return InventoryFile.load(handle, "", lambda base, target: target) + + +@pytest.fixture(scope="module") +def no_index_result(tmp_path_factory: pytest.TempPathFactory) -> SharedSphinxResult: + """Build a Sphinx project with a ``:no-index:`` argparse directive.""" + cache_root = tmp_path_factory.mktemp("argparse-no-index") + scenario = SphinxScenario( + files=( + ScenarioFile("myparser.py", _PARSER_MOD), + 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=("myparser",), + ) + + +@pytest.mark.integration +def test_no_index_registers_no_argparse_domain_entries( + no_index_result: SharedSphinxResult, +) -> None: + """No ``argparse:*`` cross-reference targets land in objects.inv.""" + inventory = _load_inventory(no_index_result) + argparse_domains = [ + domain for domain in inventory if domain.startswith("argparse:") + ] + assert argparse_domains == [] + + +@pytest.mark.integration +def test_no_index_registers_no_std_cmdoption( + no_index_result: SharedSphinxResult, +) -> None: + """No ``std:cmdoption`` entries and an empty std-domain progoptions table.""" + inventory = _load_inventory(no_index_result) + assert inventory.get("std:cmdoption", {}) == {} + + std_domain = no_index_result.app.env.domains.standard_domain + assert dict(std_domain.progoptions) == {} + + +@pytest.mark.integration +def test_no_index_still_renders_the_card( + no_index_result: SharedSphinxResult, +) -> None: + """The parser still renders with per-section HTML anchors.""" + index_html = read_output(no_index_result, "index.html") + assert "Usage" in index_html + assert 'id="usage"' in index_html diff --git a/tests/ext/fastmcp/test_component_linking.py b/tests/ext/fastmcp/test_component_linking.py new file mode 100644 index 00000000..33493afd --- /dev/null +++ b/tests/ext/fastmcp/test_component_linking.py @@ -0,0 +1,323 @@ +"""Cross-reference linking coverage for FastMCP component directives & roles. + +Locks issue #56's acceptance criteria. Tools/prompts already linked; this +suite proves the parity affordances for the non-tool families: + +* resources, prompts, and resource templates register std-domain labels, so + they land in ``objects.inv`` as ``std:label`` (intersphinx-reachable) and + resolve via ``{ref}`` +* the ``{resource}`` / ``{resourceref}`` / ``{prompt}`` / ``{promptref}`` role + families resolve to the canonical anchors, with ``{resource}`` covering both + fixed resources and resource templates and unknown names degrading to a bare + literal +* ``:no-index:`` suppresses cross-document label registration so a component + shown on more than one page keeps a single canonical home +""" + +from __future__ import annotations + +import textwrap +import typing as t + +import pytest +from sphinx.util.inventory import InventoryFile + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +# In-memory fastmcp-shaped fixture extension: stuffs one prompt, one resource, +# and one resource template onto ``app.env`` (priority >500 so it wins over +# ``collect_prompts_and_resources`` clearing the attributes). No real fastmcp +# dependency — the directives are what we exercise. +_FIXTURE_EXT = textwrap.dedent( + '''\ + """In-memory fastmcp-shaped fixture extension for linking tests.""" + + from __future__ import annotations + + import typing as t + + from sphinx.application import Sphinx + + from sphinx_autodoc_fastmcp._models import ( + PromptInfo, + ResourceInfo, + ResourceTemplateInfo, + ) + + + def _populate(app: Sphinx) -> None: + app.env.fastmcp_prompts = { + "greet": PromptInfo( + name="greet", + title="Greet", + description="Greet a user.", + docstring="Greet a user.", + tags=("ops",), + arguments=[], + ) + } + app.env.fastmcp_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",), + ) + } + app.env.fastmcp_resource_names = {"hello": "mem://hello"} + app.env.fastmcp_resource_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=[], + docstring="Per-user record.", + tags=("readonly",), + ) + } + app.env.fastmcp_resource_template_names = {"user_record": "mem://user/{id}"} + + + def setup(app: Sphinx) -> dict[str, t.Any]: + 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_link_fixture_ext", + ] + myst_enable_extensions = ["colon_fence"] + fastmcp_tool_modules = [] + """ +) + +_DIRECTIVES_MD = textwrap.dedent( + """\ + # FastMCP linking demo + + Roles: {prompt}`greet`, {promptref}`greet`, {resource}`hello`, + {resourceref}`hello`, {resource}`user_record`, {resource}`does_not_exist`. + + ```{fastmcp-prompt} greet + ``` + + --- + + ```{fastmcp-resource} hello + ``` + + --- + + ```{fastmcp-resource-template} user_record + ``` + """ +) + + +def _scenario(*files: ScenarioFile) -> SphinxScenario: + """Assemble a scenario with the shared fixture extension + conf.py.""" + return SphinxScenario( + files=( + ScenarioFile("fastmcp_link_fixture_ext.py", _FIXTURE_EXT), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + *files, + ), + ) + + +def _load_inventory(result: SharedSphinxResult) -> dict[str, dict[str, t.Any]]: + """Parse the built ``objects.inv`` into ``{domain: {name: item}}``.""" + inv_path = result.outdir / "objects.inv" + with inv_path.open("rb") as handle: + return InventoryFile.load(handle, "", lambda base, target: target) + + +@pytest.fixture(scope="module") +def linking_html(tmp_path_factory: pytest.TempPathFactory) -> SharedSphinxResult: + """Build the component-directives demo once per module.""" + cache_root = tmp_path_factory.mktemp("fastmcp-linking") + scenario = _scenario(ScenarioFile("index.md", _DIRECTIVES_MD)) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("fastmcp_link_fixture_ext",), + ) + + +# --- objects.inv (issue #56 acceptance #2) --------------------------------- + + +class InventoryCase(t.NamedTuple): + """One expected ``std:label`` inventory entry.""" + + test_id: str + label: str + + +_INVENTORY_CASES: list[InventoryCase] = [ + InventoryCase(test_id="prompt", label="fastmcp-prompt-greet"), + InventoryCase(test_id="resource", label="fastmcp-resource-hello"), + InventoryCase( + test_id="resource-template", + label="fastmcp-resource-template-user-record", + ), +] + + +@pytest.mark.integration +@pytest.mark.parametrize( + "case", + _INVENTORY_CASES, + ids=lambda c: c.test_id, +) +def test_component_label_lands_in_objects_inv( + linking_html: SharedSphinxResult, + case: InventoryCase, +) -> None: + """Each component registers a ``std:label`` reachable via intersphinx.""" + inventory = _load_inventory(linking_html) + assert case.label in inventory["std:label"], ( + f"{case.label} missing from objects.inv std:label entries" + ) + + +# --- role families (issue #56 optional follow-on) -------------------------- + + +class RoleCase(t.NamedTuple): + """One inline role usage and the canonical anchor it must resolve to.""" + + test_id: str + href_target: str + + +_ROLE_CASES: list[RoleCase] = [ + RoleCase(test_id="prompt", href_target="fastmcp-prompt-greet"), + RoleCase(test_id="promptref", href_target="fastmcp-prompt-greet"), + RoleCase(test_id="resource", href_target="fastmcp-resource-hello"), + RoleCase(test_id="resourceref", href_target="fastmcp-resource-hello"), + RoleCase( + test_id="resource-covers-template", + href_target="fastmcp-resource-template-user-record", + ), +] + + +@pytest.mark.integration +@pytest.mark.parametrize( + "case", + _ROLE_CASES, + ids=lambda c: c.test_id, +) +def test_component_role_resolves_to_canonical_anchor( + linking_html: SharedSphinxResult, + case: RoleCase, +) -> None: + """``{resource}`` / ``{prompt}`` role families link to the card anchor.""" + html = read_output(linking_html, "index.html") + assert f'href="#{case.href_target}"' in html + + +@pytest.mark.integration +def test_unknown_component_role_degrades_to_literal( + linking_html: SharedSphinxResult, +) -> None: + """An unresolved component name renders a bare literal, not a dangling link.""" + html = read_output(linking_html, "index.html") + assert "does_not_exist" in html + assert 'href="#fastmcp-resource-does-not-exist"' not in html + assert "undefined label" not in linking_html.warnings + + +# --- :no-index: parity (issue #56 request) --------------------------------- + +_NO_INDEX_INDEX_MD = textwrap.dedent( + """\ + # No-index demo + + ```{toctree} + canonical + secondary + ``` + """ +) + +_CANONICAL_MD = textwrap.dedent( + """\ + # Canonical home + + ```{fastmcp-resource} hello + ``` + """ +) + +_SECONDARY_MD = textwrap.dedent( + """\ + # Secondary appearance + + ```{fastmcp-resource} hello + :no-index: + ``` + """ +) + + +@pytest.fixture(scope="module") +def no_index_html(tmp_path_factory: pytest.TempPathFactory) -> SharedSphinxResult: + """Build a resource shown on two pages, the second with ``:no-index:``.""" + cache_root = tmp_path_factory.mktemp("fastmcp-no-index") + scenario = _scenario( + ScenarioFile("index.md", _NO_INDEX_INDEX_MD), + ScenarioFile("canonical.md", _CANONICAL_MD), + ScenarioFile("secondary.md", _SECONDARY_MD), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("fastmcp_link_fixture_ext",), + ) + + +@pytest.mark.integration +def test_no_index_resource_keeps_single_canonical_home( + no_index_html: SharedSphinxResult, +) -> None: + """``:no-index:`` binds the canonical label to the page that omits it.""" + inventory = _load_inventory(no_index_html) + item = inventory["std:label"]["fastmcp-resource-hello"] + assert item.uri.startswith("canonical"), ( + f"canonical label should point at canonical page, got {item.uri!r}" + ) + + +@pytest.mark.integration +def test_no_index_resource_emits_no_duplicate_label_warning( + no_index_html: SharedSphinxResult, +) -> None: + """The opted-out page registers no competing label, so no duplicate warning.""" + assert "duplicate label" not in no_index_html.warnings.lower()