From c6e5138b1f29c6bd3894a94a1e5071ccc057f8eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Apr 2026 08:54:26 -0500 Subject: [PATCH 001/174] chore(repo[gitignore]): ignore repo-local out/ mirror why: out/ was a repo-local symlink tree mirroring testpaths, used as a workaround for an upstream pytest capture-teardown bug. Removing it from tracked history (filter-repo) requires a gitignore so it cannot be recommitted by accident if the validator mirror is recreated locally. what: - Add out/ to .gitignore under a new "Repo-local pytest mirror" section --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e583f9d4..359e8839 100644 --- a/.gitignore +++ b/.gitignore @@ -218,6 +218,9 @@ docs/_static/css/fonts.css # Playwright MCP .playwright-mcp/ +# Repo-local pytest mirror (do not track — validator-only) +out/ + # Misc .vim/ *.lprof From cff660cc547b9b39393b693202f9d243c89640ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 7 Apr 2026 17:18:44 -0500 Subject: [PATCH 002/174] layout(feat): componentized autodoc layout with region wrapping and parameter folding why: Large autodoc entries (90+ kwargs) need semantic regions for independent styling and progressive disclosure. Parameters repeated 3x due to autoclass_content="both" + autodoc_class_signature="separated". what: - Fix DEFAULT_AUTOCLASS_CONTENT from "both" to "class" (eliminates 3x params) - Two custom nodes: gal_region (contiguous content wrappers) and gal_fold (
/ for large field lists) - doctree-resolved handler at priority 600 (after api-style at 500) - Order-preserving contiguous chunking: narrative / fields / members - Fold large field_list in desc_content, not desc_signature - Descendant CSS selectors (survive
wrapping) - JS hash-based auto-expand for
ancestors - Tests for nodes, classification, wrapping, and folding --- docs/configuration.md | 2 +- docs/packages/index.md | 3 +- docs/packages/sphinx-autodoc-layout.md | 20 ++ docs/redirects.txt | 1 + packages/gp-sphinx/src/gp_sphinx/defaults.py | 9 +- packages/sphinx-autodoc-layout/README.md | 5 + packages/sphinx-autodoc-layout/pyproject.toml | 14 + .../src/sphinx_autodoc_layout/__init__.py | 99 +++++++ .../src/sphinx_autodoc_layout/_nodes.py | 64 +++++ .../_static/css/layout.css | 54 ++++ .../_static/js/layout.js | 35 +++ .../src/sphinx_autodoc_layout/_transforms.py | 246 ++++++++++++++++++ .../src/sphinx_autodoc_layout/_visitors.py | 81 ++++++ .../src/sphinx_autodoc_layout/py.typed | 0 pyproject.toml | 2 + scripts/ci/package_tools.py | 19 ++ tests/ext/layout/__init__.py | 0 tests/ext/layout/test_nodes.py | 35 +++ tests/ext/layout/test_transforms.py | 169 ++++++++++++ tests/test_package_reference.py | 1 + uv.lock | 15 ++ 21 files changed, 870 insertions(+), 4 deletions(-) create mode 100644 docs/packages/sphinx-autodoc-layout.md create mode 100644 packages/sphinx-autodoc-layout/README.md create mode 100644 packages/sphinx-autodoc-layout/pyproject.toml create mode 100644 packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py create mode 100644 packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py create mode 100644 packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css create mode 100644 packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js create mode 100644 packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py create mode 100644 packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py create mode 100644 packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/py.typed create mode 100644 tests/ext/layout/__init__.py create mode 100644 tests/ext/layout/test_nodes.py create mode 100644 tests/ext/layout/test_transforms.py diff --git a/docs/configuration.md b/docs/configuration.md index f3bcb791..f34774d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -138,7 +138,7 @@ These are injected even though they are not exposed as `DEFAULT_*` constants: | Constant | Value | | --- | --- | -| `DEFAULT_AUTOCLASS_CONTENT` | `"both"` | +| `DEFAULT_AUTOCLASS_CONTENT` | `"class"` | | `DEFAULT_AUTODOC_MEMBER_ORDER` | `"bysource"` | | `DEFAULT_AUTODOC_CLASS_SIGNATURE` | `"separated"` | | `DEFAULT_AUTODOC_TYPEHINTS` | `"description"` | diff --git a/docs/packages/index.md b/docs/packages/index.md index 26a21a98..1263e367 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,6 +1,6 @@ # Packages -Ten workspace packages, each independently installable. +Eleven workspace packages, each independently installable. ```{workspace-package-grid} ``` @@ -13,6 +13,7 @@ sphinx-autodoc-api-style sphinx-autodoc-badges sphinx-autodoc-docutils sphinx-autodoc-fastmcp +sphinx-autodoc-layout sphinx-autodoc-sphinx sphinx-autodoc-pytest-fixtures sphinx-fonts diff --git a/docs/packages/sphinx-autodoc-layout.md b/docs/packages/sphinx-autodoc-layout.md new file mode 100644 index 00000000..f94723b3 --- /dev/null +++ b/docs/packages/sphinx-autodoc-layout.md @@ -0,0 +1,20 @@ +(sphinx-autodoc-layout)= + +# sphinx-autodoc-layout + +{bdg-warning-line}`Alpha` + +Wraps contiguous `desc_content` runs into semantic `gal_region` nodes +and folds large parameter sections with native `
/`. +Does not modify `desc_signature`. + +## Configuration + +| Setting | Default | Meaning | +|---------|---------|---------| +| `gal_enabled` | `False` | Enables the transform | +| `gal_fold_parameters` | `True` | Folds large field-list sections | +| `gal_collapsed_threshold` | `10` | Minimum field count before folding | + +```{package-reference} sphinx-autodoc-layout +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index 62b2fdcd..377a61e6 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -7,5 +7,6 @@ extensions/sphinx-autodoc-sphinx packages/sphinx-autodoc-sphinx extensions/sphinx-autodoc-api-style packages/sphinx-autodoc-api-style extensions/sphinx-autodoc-badges packages/sphinx-autodoc-badges extensions/sphinx-autodoc-fastmcp packages/sphinx-autodoc-fastmcp +extensions/sphinx-autodoc-layout packages/sphinx-autodoc-layout extensions/sphinx-fonts packages/sphinx-fonts extensions/sphinx-gptheme packages/sphinx-gptheme diff --git a/packages/gp-sphinx/src/gp_sphinx/defaults.py b/packages/gp-sphinx/src/gp_sphinx/defaults.py index 69fc7bf3..5870f265 100644 --- a/packages/gp-sphinx/src/gp_sphinx/defaults.py +++ b/packages/gp-sphinx/src/gp_sphinx/defaults.py @@ -306,8 +306,13 @@ class FontConfig(_FontConfigRequired, total=False): 'bysource' """ -DEFAULT_AUTOCLASS_CONTENT: str = "both" -"""Default autodoc autoclass_content setting (show __init__ and class docstring).""" +DEFAULT_AUTOCLASS_CONTENT: str = "class" +"""Default autodoc autoclass_content setting. + +Uses ``"class"`` (class docstring only) with ``autodoc_class_signature = +"separated"`` so ``__init__`` is documented as a separate member and its +parameters appear only once -- not duplicated in the class body. +""" DEFAULT_AUTODOC_MEMBER_ORDER: str = "bysource" """Default autodoc member ordering.""" diff --git a/packages/sphinx-autodoc-layout/README.md b/packages/sphinx-autodoc-layout/README.md new file mode 100644 index 00000000..1ea2aff6 --- /dev/null +++ b/packages/sphinx-autodoc-layout/README.md @@ -0,0 +1,5 @@ +# sphinx-autodoc-layout + +Componentized layout for Sphinx autodoc output. Wraps contiguous +`desc_content` runs into semantic regions and folds large parameter +sections without rewriting `desc_signature`. diff --git a/packages/sphinx-autodoc-layout/pyproject.toml b/packages/sphinx-autodoc-layout/pyproject.toml new file mode 100644 index 00000000..e3082e3e --- /dev/null +++ b/packages/sphinx-autodoc-layout/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sphinx-autodoc-layout" +version = "0.0.1a6" +description = "Componentized layout for Sphinx autodoc output" +requires-python = ">=3.10" +license = "MIT" +dependencies = ["sphinx>=7.2"] + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_autodoc_layout"] diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py new file mode 100644 index 00000000..b1db0cdb --- /dev/null +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py @@ -0,0 +1,99 @@ +"""Componentized layout for Sphinx autodoc output. + +Wraps contiguous ``desc_content`` runs into semantic ``gal_region`` +nodes and folds large parameter sections with ``gal_fold`` disclosure +blocks. Does not modify ``desc_signature`` -- that is owned by Sphinx +and ``sphinx-autodoc-api-style``. + +Examples +-------- +>>> from sphinx_autodoc_layout import setup +>>> callable(setup) +True +""" + +from __future__ import annotations + +import pathlib +import typing as t + +from sphinx_autodoc_layout._nodes import gal_fold, gal_region +from sphinx_autodoc_layout._transforms import on_doctree_resolved +from sphinx_autodoc_layout._visitors import ( + depart_gal_fold, + depart_gal_region, + passthrough_depart, + passthrough_visit, + visit_gal_fold, + visit_gal_region, +) + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the extension with Sphinx. + + Parameters + ---------- + app : Sphinx + The Sphinx application object. + + Returns + ------- + dict[str, Any] + Extension metadata. + + Examples + -------- + >>> setup # doctest: +ELLIPSIS + + """ + # Config values + app.add_config_value("gal_enabled", default=False, rebuild="env", types=(bool,)) + app.add_config_value( + "gal_fold_parameters", default=True, rebuild="env", types=(bool,) + ) + app.add_config_value( + "gal_collapsed_threshold", default=10, rebuild="env", types=(int,) + ) + + # Custom nodes with HTML visitors + passthrough for other builders + _pt = (passthrough_visit, passthrough_depart) + app.add_node( + gal_region, + html=(visit_gal_region, depart_gal_region), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + app.add_node( + gal_fold, + html=(visit_gal_fold, depart_gal_fold), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + + # Transform: doctree-resolved at priority 600 (after api-style at 500) + app.connect("doctree-resolved", on_doctree_resolved, priority=600) + + # Static assets + _static_dir = str(pathlib.Path(__file__).parent / "_static") + + def _add_static_path(app: Sphinx) -> None: + if _static_dir not in app.config.html_static_path: + app.config.html_static_path.append(_static_dir) + + app.connect("builder-inited", _add_static_path) + app.add_css_file("css/layout.css") + app.add_js_file("js/layout.js", loading_method="defer") + + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py new file mode 100644 index 00000000..bdcec330 --- /dev/null +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py @@ -0,0 +1,64 @@ +"""Custom docutils nodes for autodoc layout regions. + +Two generic nodes cover the full component tree: + +- ``gal_region`` wraps contiguous runs of ``desc_content`` children + (narrative paragraphs, field lists, nested members). +- ``gal_fold`` wraps a region in ``
/`` for + progressive disclosure of large parameter sections. + +Examples +-------- +>>> from sphinx_autodoc_layout._nodes import gal_region, gal_fold +>>> r = gal_region(kind="fields") +>>> r.get("kind") +'fields' + +>>> f = gal_fold(kind="parameters", summary="Parameters (5)") +>>> f.get("summary") +'Parameters (5)' +""" + +from __future__ import annotations + +from docutils import nodes + + +class gal_region(nodes.General, nodes.Element): + """Wrapper for a contiguous ``desc_content`` run. + + Parameters + ---------- + kind : str + One of ``"narrative"``, ``"fields"``, or ``"members"``. + + Examples + -------- + >>> r = gal_region(kind="narrative") + >>> isinstance(r, gal_region) + True + >>> r.get("kind") + 'narrative' + """ + + +class gal_fold(nodes.General, nodes.Element): + """Block disclosure wrapper rendered as ``
/``. + + Parameters + ---------- + kind : str + Fold category, e.g. ``"parameters"``. + summary : str + Text shown in the ```` element. + open : bool + Whether the fold starts expanded. + + Examples + -------- + >>> f = gal_fold(kind="parameters", summary="Parameters (3)") + >>> f.get("kind") + 'parameters' + >>> f.get("summary") + 'Parameters (3)' + """ diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css new file mode 100644 index 00000000..b618949b --- /dev/null +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css @@ -0,0 +1,54 @@ +/* sphinx_autodoc_layout — layout.css + * Prefix: gal- + * Semantic region wrappers and disclosure fold styling. + */ + +/* ── Region spacing ────────────────────────────────── */ +.gal-region + .gal-region { + margin-top: 1rem; +} + +.gal-region--members { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--color-background-border); +} + +/* ── Override api-style field-list grid ─────────────── */ +/* Use descendant selector (not child >) so it survives + the
wrapper inserted by gal_fold. */ +dl.py:not(.fixture) > dd .gal-region--fields dl.field-list { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0 1rem; +} + +/* ── Fold (disclosure) ─────────────────────────────── */ +details.gal-fold { + margin: 0; +} + +details.gal-fold > summary.gal-fold-summary { + cursor: pointer; + color: var(--color-foreground-muted); + font-size: 0.85em; + font-weight: 600; + padding: 0.25rem 0; + list-style: none; +} + +details.gal-fold > summary.gal-fold-summary::-webkit-details-marker { + display: none; +} + +details.gal-fold > summary.gal-fold-summary::before { + content: "\25B8 "; +} + +details.gal-fold[open] > summary.gal-fold-summary::before { + content: "\25BE "; +} + +details.gal-fold > summary.gal-fold-summary:hover { + color: var(--color-link); +} diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js new file mode 100644 index 00000000..2f609442 --- /dev/null +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js @@ -0,0 +1,35 @@ +/** + * sphinx_autodoc_layout — layout.js + * + * Hash-based auto-expansion: when the URL fragment targets an + * element inside a closed
, open it so the target is + * visible. + */ + +(function () { + 'use strict'; + + function expandForHash() { + var hash = window.location.hash; + if (!hash) return; + + var id = hash.slice(1); + var target = document.getElementById(id); + if (!target) return; + + var node = target; + while (node) { + if (node.tagName === 'DETAILS' && !node.open) { + node.open = true; + } + node = node.parentElement; + } + + setTimeout(function () { + target.scrollIntoView({ block: 'center' }); + }, 50); + } + + document.addEventListener('DOMContentLoaded', expandForHash); + window.addEventListener('hashchange', expandForHash); +})(); diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py new file mode 100644 index 00000000..9b878d34 --- /dev/null +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py @@ -0,0 +1,246 @@ +"""Doctree transforms for componentized autodoc layout. + +Runs as a ``doctree-resolved`` event handler at priority 600, after +``sphinx-autodoc-api-style`` (priority 500). Wraps contiguous runs +of ``desc_content`` children into ``gal_region`` nodes and optionally +folds large field-list regions into ``gal_fold`` disclosure blocks. + +Examples +-------- +>>> from sphinx_autodoc_layout._transforms import _classify_child +>>> from docutils import nodes +>>> _classify_child(nodes.paragraph()) +'narrative' +>>> _classify_child(nodes.field_list()) +'fields' +""" + +from __future__ import annotations + +import logging +import typing as t + +from docutils import nodes +from sphinx import addnodes + +from sphinx_autodoc_layout._nodes import gal_fold, gal_region + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + +_SKIP_FOLD_OBJTYPES: frozenset[str] = frozenset( + { + "attribute", + "data", + "fixture", + "module", + "property", + } +) + + +def _classify_child(child: nodes.Node) -> str: + """Classify a ``desc_content`` child by its node type. + + Parameters + ---------- + child : nodes.Node + A direct child of ``desc_content``. + + Returns + ------- + str + One of ``"fields"``, ``"members"``, or ``"narrative"``. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> _classify_child(nodes.field_list()) + 'fields' + >>> _classify_child(addnodes.desc()) + 'members' + >>> _classify_child(nodes.paragraph()) + 'narrative' + >>> _classify_child(nodes.note()) + 'narrative' + """ + if isinstance(child, nodes.field_list): + return "fields" + if isinstance(child, addnodes.desc): + return "members" + return "narrative" + + +def _wrap_content_runs(desc_node: addnodes.desc) -> None: + """Wrap contiguous runs of ``desc_content`` children in regions. + + Iterates children in order, grouping contiguous same-type nodes + into ``gal_region`` wrappers. Never reorders children. + + Parameters + ---------- + desc_node : addnodes.desc + The description node to restructure. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> desc = addnodes.desc(domain="py", objtype="function") + >>> sig = addnodes.desc_signature() + >>> desc += sig + >>> content = addnodes.desc_content() + >>> content += nodes.paragraph("", "hello") + >>> content += nodes.field_list() + >>> desc += content + >>> _wrap_content_runs(desc) + >>> len(content.children) + 2 + >>> isinstance(content.children[0], gal_region) + True + >>> content.children[0].get("kind") + 'narrative' + >>> content.children[1].get("kind") + 'fields' + """ + content = next( + (c for c in desc_node.children if isinstance(c, addnodes.desc_content)), + None, + ) + if content is None or not content.children: + return + + original = list(content.children) + content.children = [] + + current_kind: str | None = None + current_region: gal_region | None = None + + for child in original: + kind = _classify_child(child) + if kind != current_kind: + if current_region is not None: + content += current_region + current_region = gal_region(kind=kind) + current_kind = kind + assert current_region is not None + current_region += child + + if current_region is not None: + content += current_region + + +def _fold_large_field_regions( + content: addnodes.desc_content, + threshold: int, +) -> None: + """Wrap large ``field_list`` nodes in ``gal_fold`` disclosure blocks. + + Only folds ``gal_region(kind="fields")`` children whose + ``field_list`` contains at least *threshold* ``field`` entries. + + Parameters + ---------- + content : addnodes.desc_content + The description content node (already wrapped in regions). + threshold : int + Minimum field count to trigger folding. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> content = addnodes.desc_content() + >>> region = gal_region(kind="fields") + >>> fl = nodes.field_list() + >>> for i in range(12): + ... f = nodes.field() + ... f += nodes.field_name("", f"p{i}") + ... f += nodes.field_body("", nodes.paragraph("", "...")) + ... fl += f + >>> region += fl + >>> content += region + >>> _fold_large_field_regions(content, threshold=10) + >>> fold = region.children[0] + >>> isinstance(fold, gal_fold) + True + >>> fold.get("summary") + 'Parameters (12)' + """ + for region in content.children: + if not isinstance(region, gal_region): + continue + if region.get("kind") != "fields": + continue + for field_list in list(region.children): + if not isinstance(field_list, nodes.field_list): + continue + param_count = sum( + 1 for f in field_list.children if isinstance(f, nodes.field) + ) + if param_count < threshold: + continue + fold = gal_fold( + kind="parameters", + summary=f"Parameters ({param_count})", + ) + idx = region.children.index(field_list) + region.remove(field_list) + fold += field_list + region.insert(idx, fold) + + +def on_doctree_resolved( + app: Sphinx, + doctree: nodes.document, + docname: str, +) -> None: + """Restructure autodoc ``desc_content`` into semantic regions. + + Connected to ``doctree-resolved`` at priority 600, after + ``sphinx-autodoc-api-style`` (priority 500). + + Parameters + ---------- + app : Sphinx + The Sphinx application. + doctree : nodes.document + The resolved doctree. + docname : str + The document name. + + Examples + -------- + >>> on_doctree_resolved # doctest: +ELLIPSIS + + """ + if not app.config.gal_enabled: + return + if getattr(app.builder, "format", "") != "html": + return + + threshold: int = app.config.gal_collapsed_threshold + fold_params: bool = app.config.gal_fold_parameters + + for desc_node in doctree.findall(addnodes.desc): + if desc_node.get("domain") != "py": + continue + + _wrap_content_runs(desc_node) + + if fold_params: + objtype = desc_node.get("objtype", "") + if objtype not in _SKIP_FOLD_OBJTYPES: + content = next( + ( + c + for c in desc_node.children + if isinstance(c, addnodes.desc_content) + ), + None, + ) + if content is not None: + _fold_large_field_regions(content, threshold) diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py new file mode 100644 index 00000000..4d0deb5f --- /dev/null +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py @@ -0,0 +1,81 @@ +"""HTML visitors for layout nodes. + +Each node maps to a simple wrapper element: + +- ``gal_region`` -> ``
`` +- ``gal_fold`` -> ``
...`` + +Non-HTML builders get passthrough visitors that render children +without any wrapper markup. + +Examples +-------- +>>> callable(visit_gal_region) +True +>>> callable(depart_gal_region) +True +>>> callable(visit_gal_fold) +True +>>> callable(depart_gal_fold) +True +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + +if t.TYPE_CHECKING: + from sphinx.writers.html5 import HTML5Translator + + +# -- HTML visitors ----------------------------------------------------------- + + +def visit_gal_region(self: HTML5Translator, node: nodes.Element) -> None: + """Open a region wrapper ``
``.""" + kind = node.get("kind", "narrative") + self.body.append(f'
') + + +def depart_gal_region(self: HTML5Translator, node: nodes.Element) -> None: + """Close the region ``
``.""" + self.body.append("
") + + +def visit_gal_fold(self: HTML5Translator, node: nodes.Element) -> None: + """Open a ``
`` disclosure element.""" + summary = node.get("summary", "") + kind = node.get("kind", "") + open_attr = " open" if node.get("open", False) else "" + self.body.append( + f'
' + f'{summary}' + ) + + +def depart_gal_fold(self: HTML5Translator, node: nodes.Element) -> None: + """Close the ``
`` element.""" + self.body.append("
") + + +# -- Passthrough visitors (non-HTML builders) -------------------------------- + + +def passthrough_visit(self: t.Any, node: nodes.Element) -> None: + """No-op visit for non-HTML builders. + + Examples + -------- + >>> passthrough_visit(None, None) + """ + + +def passthrough_depart(self: t.Any, node: nodes.Element) -> None: + """No-op depart for non-HTML builders. + + Examples + -------- + >>> passthrough_depart(None, None) + """ diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/py.typed b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index 2c1632e1..db0b8167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ sphinx-autodoc-sphinx = { workspace = true } sphinx-autodoc-api-style = { workspace = true } sphinx-autodoc-fastmcp = { workspace = true } sphinx-autodoc-badges = { workspace = true } +sphinx-autodoc-layout = { workspace = true } gp-sphinx = { workspace = true } [dependency-groups] @@ -37,6 +38,7 @@ dev = [ "sphinx-autodoc-api-style", "sphinx-autodoc-fastmcp", "sphinx-autodoc-badges", + "sphinx-autodoc-layout", # Docs "sphinx-autobuild", # Testing diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index a5ad6434..3cc3b9fa 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -573,6 +573,24 @@ def smoke_sphinx_autodoc_badges(dist_dir: pathlib.Path, version: str) -> None: ) +def smoke_sphinx_autodoc_layout(dist_dir: pathlib.Path, version: str) -> None: + """Verify the autodoc-layout extension installs and imports cleanly.""" + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv( + python_path, + *_workspace_wheel_requirements(dist_dir), + ) + _run_python( + python_path, + ( + "import sphinx_autodoc_layout; " + "from sphinx_autodoc_layout import setup; " + "assert callable(setup)" + ), + ) + + def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: """Verify the autodoc-fastmcp extension installs and imports cleanly.""" with tempfile.TemporaryDirectory() as tmp: @@ -598,6 +616,7 @@ def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: "sphinx-autodoc-badges": smoke_sphinx_autodoc_badges, "sphinx-autodoc-docutils": smoke_sphinx_autodoc_docutils, "sphinx-autodoc-fastmcp": smoke_sphinx_autodoc_fastmcp, + "sphinx-autodoc-layout": smoke_sphinx_autodoc_layout, "sphinx-autodoc-pytest-fixtures": smoke_sphinx_autodoc_pytest_fixtures, "sphinx-autodoc-sphinx": smoke_sphinx_autodoc_sphinx, "sphinx-fonts": smoke_sphinx_fonts, diff --git a/tests/ext/layout/__init__.py b/tests/ext/layout/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ext/layout/test_nodes.py b/tests/ext/layout/test_nodes.py new file mode 100644 index 00000000..9535aa9f --- /dev/null +++ b/tests/ext/layout/test_nodes.py @@ -0,0 +1,35 @@ +"""Tests for sphinx_autodoc_layout._nodes.""" + +from __future__ import annotations + +from docutils import nodes +from sphinx_autodoc_layout._nodes import gal_fold, gal_region + + +def test_gal_region_is_general_element() -> None: + r = gal_region(kind="narrative") + assert isinstance(r, nodes.General) + assert isinstance(r, nodes.Element) + + +def test_gal_region_stores_kind() -> None: + r = gal_region(kind="fields") + assert r.get("kind") == "fields" + + +def test_gal_fold_is_general_element() -> None: + f = gal_fold(kind="parameters", summary="Parameters (5)") + assert isinstance(f, nodes.General) + assert isinstance(f, nodes.Element) + + +def test_gal_fold_stores_attributes() -> None: + f = gal_fold(kind="parameters", summary="Parameters (5)", open=True) + assert f.get("kind") == "parameters" + assert f.get("summary") == "Parameters (5)" + assert f.get("open") is True + + +def test_gal_fold_default_open_is_falsy() -> None: + f = gal_fold(kind="parameters", summary="P (1)") + assert not f.get("open") diff --git a/tests/ext/layout/test_transforms.py b/tests/ext/layout/test_transforms.py new file mode 100644 index 00000000..73ecbb02 --- /dev/null +++ b/tests/ext/layout/test_transforms.py @@ -0,0 +1,169 @@ +"""Tests for sphinx_autodoc_layout._transforms.""" + +from __future__ import annotations + +from docutils import nodes +from sphinx import addnodes +from sphinx_autodoc_layout._nodes import gal_fold, gal_region +from sphinx_autodoc_layout._transforms import ( + _classify_child, + _fold_large_field_regions, + _wrap_content_runs, +) + +# -- helpers ----------------------------------------------------------------- + + +def _make_desc( + *content_children: nodes.Node, + domain: str = "py", + objtype: str = "function", +) -> addnodes.desc: + desc = addnodes.desc(domain=domain, objtype=objtype) + desc += addnodes.desc_signature() + content = addnodes.desc_content() + for child in content_children: + content += child + desc += content + return desc + + +def _make_field_list(num_fields: int = 5) -> nodes.field_list: + fl = nodes.field_list() + for i in range(num_fields): + f = nodes.field() + f += nodes.field_name("", f"param{i}") + f += nodes.field_body("", nodes.paragraph("", f"desc {i}")) + fl += f + return fl + + +# -- _classify_child -------------------------------------------------------- + + +def test_classify_paragraph_as_narrative() -> None: + assert _classify_child(nodes.paragraph()) == "narrative" + + +def test_classify_field_list_as_fields() -> None: + assert _classify_child(nodes.field_list()) == "fields" + + +def test_classify_desc_as_members() -> None: + assert _classify_child(addnodes.desc()) == "members" + + +def test_classify_note_as_narrative() -> None: + assert _classify_child(nodes.note()) == "narrative" + + +# -- _wrap_content_runs ------------------------------------------------------ + + +def test_wrap_groups_narrative() -> None: + desc = _make_desc( + nodes.paragraph("", "hello"), + nodes.paragraph("", "world"), + ) + _wrap_content_runs(desc) + + content = desc.children[-1] + assert len(content.children) == 1 + r = content.children[0] + assert isinstance(r, gal_region) + assert r.get("kind") == "narrative" + assert len(r.children) == 2 + + +def test_wrap_groups_contiguous_types() -> None: + desc = _make_desc( + nodes.paragraph("", "text"), + _make_field_list(3), + addnodes.desc(domain="py", objtype="method"), + ) + _wrap_content_runs(desc) + + content = desc.children[-1] + assert len(content.children) == 3 + r0, r1, r2 = content.children + assert isinstance(r0, gal_region) and r0.get("kind") == "narrative" + assert isinstance(r1, gal_region) and r1.get("kind") == "fields" + assert isinstance(r2, gal_region) and r2.get("kind") == "members" + + +def test_wrap_preserves_order() -> None: + """Interleaved types stay in authored order.""" + desc = _make_desc( + nodes.paragraph("", "intro"), + _make_field_list(2), + nodes.paragraph("", "examples"), + addnodes.desc(domain="py", objtype="method"), + ) + _wrap_content_runs(desc) + + content = desc.children[-1] + for c in content.children: + assert isinstance(c, gal_region) + kinds = [c.get("kind") for c in content.children if isinstance(c, gal_region)] + assert kinds == ["narrative", "fields", "narrative", "members"] + + +def test_wrap_empty_content_noop() -> None: + desc = _make_desc() + _wrap_content_runs(desc) + content = desc.children[-1] + assert len(content.children) == 0 + + +def test_wrap_non_python_noop() -> None: + """Non-Python desc nodes are still wrapped (wrapping is domain-agnostic).""" + desc = _make_desc( + nodes.paragraph("", "text"), + domain="cpp", + objtype="function", + ) + _wrap_content_runs(desc) + content = desc.children[-1] + assert len(content.children) == 1 + assert isinstance(content.children[0], gal_region) + + +# -- _fold_large_field_regions ----------------------------------------------- + + +def test_fold_wraps_large_field_list() -> None: + content = addnodes.desc_content() + region = gal_region(kind="fields") + region += _make_field_list(12) + content += region + + _fold_large_field_regions(content, threshold=10) + + fold = region.children[0] + assert isinstance(fold, gal_fold) + assert fold.get("summary") == "Parameters (12)" + assert isinstance(fold.children[0], nodes.field_list) + + +def test_fold_skips_small_field_list() -> None: + content = addnodes.desc_content() + region = gal_region(kind="fields") + fl = _make_field_list(5) + region += fl + content += region + + _fold_large_field_regions(content, threshold=10) + + assert isinstance(region.children[0], nodes.field_list) + assert not isinstance(region.children[0], gal_fold) + + +def test_fold_skips_narrative_regions() -> None: + content = addnodes.desc_content() + region = gal_region(kind="narrative") + region += nodes.paragraph("", "text") + content += region + + _fold_large_field_regions(content, threshold=1) + + assert isinstance(region.children[0], nodes.paragraph) diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 69369375..d58e5017 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -25,6 +25,7 @@ def test_workspace_packages_lists_publishable_packages() -> None: "sphinx-autodoc-badges", "sphinx-autodoc-docutils", "sphinx-autodoc-fastmcp", + "sphinx-autodoc-layout", "sphinx-autodoc-pytest-fixtures", "sphinx-autodoc-sphinx", "sphinx-fonts", diff --git a/uv.lock b/uv.lock index 26029101..d6dbe376 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,7 @@ members = [ "sphinx-autodoc-badges", "sphinx-autodoc-docutils", "sphinx-autodoc-fastmcp", + "sphinx-autodoc-layout", "sphinx-autodoc-pytest-fixtures", "sphinx-autodoc-sphinx", "sphinx-fonts", @@ -472,6 +473,7 @@ dev = [ { name = "sphinx-autodoc-badges" }, { name = "sphinx-autodoc-docutils" }, { name = "sphinx-autodoc-fastmcp" }, + { name = "sphinx-autodoc-layout" }, { name = "sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -501,6 +503,7 @@ dev = [ { name = "sphinx-autodoc-badges", editable = "packages/sphinx-autodoc-badges" }, { name = "sphinx-autodoc-docutils", editable = "packages/sphinx-autodoc-docutils" }, { name = "sphinx-autodoc-fastmcp", editable = "packages/sphinx-autodoc-fastmcp" }, + { name = "sphinx-autodoc-layout", editable = "packages/sphinx-autodoc-layout" }, { name = "sphinx-autodoc-pytest-fixtures", editable = "packages/sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx", editable = "packages/sphinx-autodoc-sphinx" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -1327,6 +1330,18 @@ requires-dist = [ { name = "sphinx-autodoc-badges", editable = "packages/sphinx-autodoc-badges" }, ] +[[package]] +name = "sphinx-autodoc-layout" +version = "0.0.1a6" +source = { editable = "packages/sphinx-autodoc-layout" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "sphinx", specifier = ">=7.2" }] + [[package]] name = "sphinx-autodoc-pytest-fixtures" version = "0.0.1a7" From 2a3799662f7f13fdaa2af31f02310906b737d8ed Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 7 Apr 2026 17:34:00 -0500 Subject: [PATCH 003/174] layout(fix[fold]): count Sphinx-style collapsed params, wire demo page why: Sphinx's DocFieldTransformer collapses params into a single field with a bullet_list of list_items. The fold logic counted field nodes (always 1-3) instead of list_items (the actual param count), so the fold never triggered. what: - Add _count_field_entries() that counts list_items inside collapsed bullet_list fields plus standalone field nodes - Enable extension in docs/conf.py with gal_enabled=True - Add gal_demo_api.py demo with LayoutDemo class (13 params) - Add live demo section to sphinx-autodoc-layout.md - Verify: docs build shows
wrapping params --- docs/_ext/gal_demo_api.py | 120 ++++++++++++++++++ docs/conf.py | 7 + docs/packages/sphinx-autodoc-layout.md | 35 +++++ .../src/sphinx_autodoc_layout/_transforms.py | 100 +++++++++++++-- tests/ext/layout/test_transforms.py | 43 +++++++ 5 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 docs/_ext/gal_demo_api.py diff --git a/docs/_ext/gal_demo_api.py b/docs/_ext/gal_demo_api.py new file mode 100644 index 00000000..8a910066 --- /dev/null +++ b/docs/_ext/gal_demo_api.py @@ -0,0 +1,120 @@ +"""Demo module for sphinx-autodoc-layout live showcase. + +Provides classes with varying parameter counts to demonstrate +region wrapping and parameter folding. +""" + +from __future__ import annotations + + +def compact_function(name: str, value: int = 0) -> str: + """Format a name-value pair. + + This should render without any fold -- just a narrative region + followed by a fields region. + + Parameters + ---------- + name : str + The item name. + value : int + The item value. + + Returns + ------- + str + Formatted result. + """ + return f"{name}={value}" + + +class LayoutDemo: + """A class demonstrating all layout regions. + + The class docstring forms the **narrative** region. The parameter + field list below forms the **fields** region (folded if large + enough). Nested methods form the **members** region. + + Parameters + ---------- + host : str + Server hostname. + port : int + Server port number. + username : str + Authentication username. + password : str + Authentication password. + database : str + Database name. + timeout : float + Connection timeout in seconds. + retries : int + Number of connection retries. + ssl : bool + Enable SSL/TLS. + pool_size : int + Connection pool size. + pool_timeout : float + Pool checkout timeout. + echo : bool + Log all SQL statements. + encoding : str + Character encoding. + isolation_level : str + Transaction isolation level. + """ + + def __init__( + self, + host: str, + port: int = 5432, + *, + username: str = "admin", + password: str = "", + database: str = "default", + timeout: float = 30.0, + retries: int = 3, + ssl: bool = True, + pool_size: int = 5, + pool_timeout: float = 10.0, + echo: bool = False, + encoding: str = "utf-8", + isolation_level: str = "READ COMMITTED", + ) -> None: + self.host = host + self.port = port + + def connect(self) -> bool: + """Open a connection to the server. + + Returns + ------- + bool + True if connection succeeded. + """ + return True + + def execute( + self, + query: str, + params: dict[str, str] | None = None, + ) -> list[dict[str, str]]: + """Execute a query and return results. + + Parameters + ---------- + query : str + SQL query string. + params : dict[str, str] | None + Query parameters. + + Returns + ------- + list[dict[str, str]] + Query result rows. + """ + return [] + + def close(self) -> None: + """Close the connection.""" diff --git a/docs/conf.py b/docs/conf.py index 7fb6cb20..f3ee8ccf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,6 +23,10 @@ 0, str(project_root / "packages" / "sphinx-autodoc-badges" / "src"), ) +sys.path.insert( + 0, + str(project_root / "packages" / "sphinx-autodoc-layout" / "src"), +) sys.path.insert(0, str(cwd / "_ext")) # docs demo modules import gp_sphinx # noqa: E402 @@ -49,7 +53,10 @@ "sphinx_autodoc_docutils", "sphinx_autodoc_sphinx", "sphinx_argparse_neo.exemplar", + "sphinx_autodoc_layout", ], + gal_enabled=True, + gal_collapsed_threshold=10, pytest_fixture_lint_level="none", rediraffe_redirects="redirects.txt", intersphinx_mapping=intersphinx_mapping, diff --git a/docs/packages/sphinx-autodoc-layout.md b/docs/packages/sphinx-autodoc-layout.md index f94723b3..a10f0ff8 100644 --- a/docs/packages/sphinx-autodoc-layout.md +++ b/docs/packages/sphinx-autodoc-layout.md @@ -8,6 +8,30 @@ Wraps contiguous `desc_content` runs into semantic `gal_region` nodes and folds large parameter sections with native `
/`. Does not modify `desc_signature`. +## Live demo + +```{py:module} gal_demo_api +``` + +### Small function (no fold) + +```{eval-rst} +.. autofunction:: gal_demo_api.compact_function +``` + +### Class with members (regions + fold) + +```{eval-rst} +.. autoclass:: gal_demo_api.LayoutDemo + :members: +``` + +The class above should render with: + +- **narrative** region (class docstring) +- **fields** region with fold (13 parameters > threshold of 10) +- **members** region (connect, execute, close methods) + ## Configuration | Setting | Default | Meaning | @@ -16,5 +40,16 @@ Does not modify `desc_signature`. | `gal_fold_parameters` | `True` | Folds large field-list sections | | `gal_collapsed_threshold` | `10` | Minimum field count before folding | +## CSS classes + +| Class | Element | Purpose | +|-------|---------|---------| +| `gal-region` | `
` | Base class for all content regions | +| `gal-region--narrative` | `
` | Wraps paragraphs, notes, examples | +| `gal-region--fields` | `
` | Wraps field lists (Parameters, Returns) | +| `gal-region--members` | `
` | Wraps nested method/attribute entries | +| `gal-fold` | `
` | Disclosure wrapper for large sections | +| `gal-fold-summary` | `` | Click target showing field count | + ```{package-reference} sphinx-autodoc-layout ``` diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py index 9b878d34..ca889091 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py @@ -133,6 +133,75 @@ def _wrap_content_runs(desc_node: addnodes.desc) -> None: content += current_region +def _count_field_entries(field_list: nodes.field_list) -> int: + """Count individual entries in a Sphinx field list. + + Sphinx's ``DocFieldTransformer`` collapses multiple params into a + single ``field`` containing a ``bullet_list`` of ``list_item`` + nodes. This function counts ``list_item`` nodes inside collapsed + fields plus standalone ``field`` nodes. + + Parameters + ---------- + field_list : nodes.field_list + The field list to count. + + Returns + ------- + int + Total entry count. + + Examples + -------- + Collapsed (Sphinx-style) — single field with bullet_list: + + >>> from docutils import nodes + >>> fl = nodes.field_list() + >>> f = nodes.field() + >>> f += nodes.field_name("", "Parameters") + >>> body = nodes.field_body() + >>> bl = nodes.bullet_list() + >>> for i in range(5): + ... bl += nodes.list_item("", nodes.paragraph("", f"p{i}")) + >>> body += bl + >>> f += body + >>> fl += f + >>> _count_field_entries(fl) + 5 + + Non-collapsed — individual fields: + + >>> fl2 = nodes.field_list() + >>> for i in range(3): + ... f2 = nodes.field() + ... f2 += nodes.field_name("", f"p{i}") + ... f2 += nodes.field_body("", nodes.paragraph("", "...")) + ... fl2 += f2 + >>> _count_field_entries(fl2) + 3 + """ + count = 0 + for field in field_list.children: + if not isinstance(field, nodes.field): + continue + # Check if field body contains a bullet_list (collapsed params) + for body_child in field.children: + if isinstance(body_child, nodes.field_body): + for item in body_child.children: + if isinstance(item, nodes.bullet_list): + count += sum( + 1 for li in item.children if isinstance(li, nodes.list_item) + ) + break + else: + # No bullet_list found — count the field itself + count += 1 + break + else: + count += 1 + return count + + def _fold_large_field_regions( content: addnodes.desc_content, threshold: int, @@ -140,27 +209,38 @@ def _fold_large_field_regions( """Wrap large ``field_list`` nodes in ``gal_fold`` disclosure blocks. Only folds ``gal_region(kind="fields")`` children whose - ``field_list`` contains at least *threshold* ``field`` entries. + ``field_list`` contains enough entries to exceed *threshold*. + + Sphinx's ``DocFieldTransformer`` collapses multiple params into a + single ``field`` with a ``bullet_list`` of ``list_item`` children. + We count ``list_item`` nodes (individual params) plus standalone + ``field`` nodes (Returns, Raises) to get the total entry count. Parameters ---------- content : addnodes.desc_content The description content node (already wrapped in regions). threshold : int - Minimum field count to trigger folding. + Minimum entry count to trigger folding. Examples -------- + Sphinx-style collapsed field list (single field with bullet_list): + >>> from docutils import nodes >>> from sphinx import addnodes >>> content = addnodes.desc_content() >>> region = gal_region(kind="fields") >>> fl = nodes.field_list() + >>> f = nodes.field() + >>> f += nodes.field_name("", "Parameters") + >>> body = nodes.field_body() + >>> bl = nodes.bullet_list() >>> for i in range(12): - ... f = nodes.field() - ... f += nodes.field_name("", f"p{i}") - ... f += nodes.field_body("", nodes.paragraph("", "...")) - ... fl += f + ... bl += nodes.list_item("", nodes.paragraph("", f"p{i}")) + >>> body += bl + >>> f += body + >>> fl += f >>> region += fl >>> content += region >>> _fold_large_field_regions(content, threshold=10) @@ -178,14 +258,12 @@ def _fold_large_field_regions( for field_list in list(region.children): if not isinstance(field_list, nodes.field_list): continue - param_count = sum( - 1 for f in field_list.children if isinstance(f, nodes.field) - ) - if param_count < threshold: + entry_count = _count_field_entries(field_list) + if entry_count < threshold: continue fold = gal_fold( kind="parameters", - summary=f"Parameters ({param_count})", + summary=f"Parameters ({entry_count})", ) idx = region.children.index(field_list) region.remove(field_list) diff --git a/tests/ext/layout/test_transforms.py b/tests/ext/layout/test_transforms.py index 73ecbb02..c29352b8 100644 --- a/tests/ext/layout/test_transforms.py +++ b/tests/ext/layout/test_transforms.py @@ -7,6 +7,7 @@ from sphinx_autodoc_layout._nodes import gal_fold, gal_region from sphinx_autodoc_layout._transforms import ( _classify_child, + _count_field_entries, _fold_large_field_regions, _wrap_content_runs, ) @@ -128,6 +129,34 @@ def test_wrap_non_python_noop() -> None: assert isinstance(content.children[0], gal_region) +def _make_sphinx_field_list(num_params: int) -> nodes.field_list: + """Build a Sphinx-style collapsed field list (single field + bullet_list).""" + fl = nodes.field_list() + f = nodes.field() + f += nodes.field_name("", "Parameters") + body = nodes.field_body() + bl = nodes.bullet_list() + for i in range(num_params): + bl += nodes.list_item("", nodes.paragraph("", f"param{i}")) + body += bl + f += body + fl += f + return fl + + +# -- _count_field_entries ---------------------------------------------------- + + +def test_count_individual_fields() -> None: + fl = _make_field_list(5) + assert _count_field_entries(fl) == 5 + + +def test_count_collapsed_bullet_list() -> None: + fl = _make_sphinx_field_list(13) + assert _count_field_entries(fl) == 13 + + # -- _fold_large_field_regions ----------------------------------------------- @@ -145,6 +174,20 @@ def test_fold_wraps_large_field_list() -> None: assert isinstance(fold.children[0], nodes.field_list) +def test_fold_wraps_sphinx_collapsed_field_list() -> None: + """Sphinx-style field list (single field with bullet_list) gets folded.""" + content = addnodes.desc_content() + region = gal_region(kind="fields") + region += _make_sphinx_field_list(13) + content += region + + _fold_large_field_regions(content, threshold=10) + + fold = region.children[0] + assert isinstance(fold, gal_fold) + assert fold.get("summary") == "Parameters (13)" + + def test_fold_skips_small_field_list() -> None: content = addnodes.desc_content() region = gal_region(kind="fields") From 45dbaf2489b496dc56e733511ce919486e6d2ce5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 7 Apr 2026 17:45:59 -0500 Subject: [PATCH 004/174] layout(feat[sig-fold]): collapse large signature param lists inline why: Large signatures (13+ params) overwhelm the header. The user wants collapsed: __init__(host, [...]) that expands to show each param on its own indented line. what: - Add gal_sig_fold node wrapping desc_parameterlist in
- Summary shows first param + [...]; open shows full list one-per-line - CSS: font-size:0 hides commas, block em.sig-param for indentation - Parens from desc_parameterlist visitor stay intact (no duplication) - Fix _count_field_entries for Sphinx collapsed bullet_list fields --- .../src/sphinx_autodoc_layout/__init__.py | 12 ++- .../src/sphinx_autodoc_layout/_nodes.py | 26 ++++++ .../_static/css/layout.css | 58 ++++++++++++ .../src/sphinx_autodoc_layout/_transforms.py | 89 ++++++++++++++++--- .../src/sphinx_autodoc_layout/_visitors.py | 23 +++++ 5 files changed, 193 insertions(+), 15 deletions(-) diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py index b1db0cdb..af4a6b64 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py @@ -17,15 +17,17 @@ import pathlib import typing as t -from sphinx_autodoc_layout._nodes import gal_fold, gal_region +from sphinx_autodoc_layout._nodes import gal_fold, gal_region, gal_sig_fold from sphinx_autodoc_layout._transforms import on_doctree_resolved from sphinx_autodoc_layout._visitors import ( depart_gal_fold, depart_gal_region, + depart_gal_sig_fold, passthrough_depart, passthrough_visit, visit_gal_fold, visit_gal_region, + visit_gal_sig_fold, ) if t.TYPE_CHECKING: @@ -77,6 +79,14 @@ def setup(app: Sphinx) -> dict[str, t.Any]: man=_pt, texinfo=_pt, ) + app.add_node( + gal_sig_fold, + html=(visit_gal_sig_fold, depart_gal_sig_fold), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) # Transform: doctree-resolved at priority 600 (after api-style at 500) app.connect("doctree-resolved", on_doctree_resolved, priority=600) diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py index bdcec330..050a21b3 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py @@ -6,6 +6,8 @@ (narrative paragraphs, field lists, nested members). - ``gal_fold`` wraps a region in ``
/`` for progressive disclosure of large parameter sections. +- ``gal_sig_fold`` wraps a ``desc_parameterlist`` in + ``
/`` for inline signature disclosure. Examples -------- @@ -62,3 +64,27 @@ class gal_fold(nodes.General, nodes.Element): >>> f.get("summary") 'Parameters (3)' """ + + +class gal_sig_fold(nodes.General, nodes.Element): + """Inline disclosure wrapper for ``desc_parameterlist`` in signatures. + + Wraps the parameter list in ``
/`` so the + collapsed state shows the first parameter plus ``[...]`` and + expanding reveals the full list one-per-line. + + Parameters + ---------- + first_param : str + Text of the first parameter (shown in collapsed summary). + param_count : int + Total number of parameters. + + Examples + -------- + >>> sf = gal_sig_fold(first_param="host", param_count=13) + >>> sf.get("first_param") + 'host' + >>> sf.get("param_count") + 13 + """ diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css index b618949b..fd4d53b3 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css @@ -52,3 +52,61 @@ details.gal-fold[open] > summary.gal-fold-summary::before { details.gal-fold > summary.gal-fold-summary:hover { color: var(--color-link); } + +/* ── Signature fold (inline in
) ───────────────── */ +details.gal-sig-fold { + display: inline; +} + +details.gal-sig-fold > summary.gal-sig-fold-summary { + display: inline; + list-style: none; + cursor: pointer; + font-family: var(--font-stack--monospace); +} + +details.gal-sig-fold > summary.gal-sig-fold-summary::-webkit-details-marker { + display: none; +} + +details.gal-sig-fold > summary.gal-sig-fold-summary .gal-sig-preview { + color: var(--color-foreground-muted); +} + +details.gal-sig-fold > summary.gal-sig-fold-summary:hover .gal-sig-preview { + color: var(--color-link); +} + +/* When open: hide summary, show params as indented block */ +details.gal-sig-fold[open] > summary.gal-sig-fold-summary { + display: none; +} + +/* When open, switch to block layout so each param + renders on its own indented line. */ +details.gal-sig-fold[open] { + display: inline-block; + vertical-align: top; +} + +/* Hide raw comma text nodes between params via font-size */ +details.gal-sig-fold[open] > .desc-parameterlist, +details.gal-sig-fold[open] > span { + font-size: 0; +} + +details.gal-sig-fold[open] > .desc-parameterlist > *, +details.gal-sig-fold[open] > span > * { + font-size: 1rem; +} + +/* Each param on its own line, indented */ +details.gal-sig-fold[open] em.sig-param { + display: block; + margin-left: 2rem; +} + +/* Opening paren stays inline; closing paren on last line */ +details.gal-sig-fold[open] .sig-paren { + font-size: 1rem; +} diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py index ca889091..175a3988 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py @@ -23,7 +23,7 @@ from docutils import nodes from sphinx import addnodes -from sphinx_autodoc_layout._nodes import gal_fold, gal_region +from sphinx_autodoc_layout._nodes import gal_fold, gal_region, gal_sig_fold if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -271,6 +271,68 @@ def _fold_large_field_regions( region.insert(idx, fold) +def _fold_signature_params(desc_node: addnodes.desc, threshold: int) -> None: + """Wrap large ``desc_parameterlist`` in ``gal_sig_fold``. + + Replaces the ``desc_parameterlist`` with a ``gal_sig_fold`` node + that contains it. The visitor renders a ``
/`` + showing the first param + ``[...]`` when collapsed. + + Parameters + ---------- + desc_node : addnodes.desc + The description node. + threshold : int + Minimum param count to trigger folding. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> desc = addnodes.desc(domain="py", objtype="function") + >>> sig = addnodes.desc_signature() + >>> sig += addnodes.desc_name("", "func") + >>> plist = addnodes.desc_parameterlist() + >>> for i in range(15): + ... plist += addnodes.desc_parameter("", f"p{i}") + >>> sig += plist + >>> desc += sig + >>> desc += addnodes.desc_content() + >>> _fold_signature_params(desc, threshold=10) + >>> fold = [c for c in sig.children if isinstance(c, gal_sig_fold)] + >>> len(fold) + 1 + >>> fold[0].get("first_param") + 'p0' + >>> fold[0].get("param_count") + 15 + """ + for sig in desc_node.children: + if not isinstance(sig, addnodes.desc_signature): + continue + plists = list(sig.findall(addnodes.desc_parameterlist)) + if not plists: + continue + plist = plists[0] + params = [c for c in plist.children if isinstance(c, addnodes.desc_parameter)] + if len(params) < threshold: + continue + + first_text = params[0].astext().strip() if params else "" + fold = gal_sig_fold( + first_param=first_text, + param_count=len(params), + ) + + # Wrap the desc_parameterlist in the fold node. + # The fold visitor emits a
with a compact summary; + # the desc_parameterlist visitor emits its own ( and ). + idx = list(sig.children).index(plist) + sig.remove(plist) + fold += plist + sig.insert(idx, fold) + + def on_doctree_resolved( app: Sphinx, doctree: nodes.document, @@ -309,16 +371,15 @@ def on_doctree_resolved( _wrap_content_runs(desc_node) - if fold_params: - objtype = desc_node.get("objtype", "") - if objtype not in _SKIP_FOLD_OBJTYPES: - content = next( - ( - c - for c in desc_node.children - if isinstance(c, addnodes.desc_content) - ), - None, - ) - if content is not None: - _fold_large_field_regions(content, threshold) + objtype = desc_node.get("objtype", "") + if fold_params and objtype not in _SKIP_FOLD_OBJTYPES: + # Fold the signature param list (inline in
) + _fold_signature_params(desc_node, threshold) + + # Fold the field list in desc_content + content = next( + (c for c in desc_node.children if isinstance(c, addnodes.desc_content)), + None, + ) + if content is not None: + _fold_large_field_regions(content, threshold) diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py index 4d0deb5f..4e15cbd6 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py @@ -60,6 +60,29 @@ def depart_gal_fold(self: HTML5Translator, node: nodes.Element) -> None: self.body.append("
") +def visit_gal_sig_fold(self: HTML5Translator, node: nodes.Element) -> None: + """Open an inline ``
`` for signature parameter disclosure. + + The ```` shows ``(first_param, [...])`` as a compact + preview. The ``
`` body contains the full + ``desc_parameterlist`` which renders its own ``(`` and ``)``. + """ + first = node.get("first_param", "") + self.body.append( + f'
' + f'' + f'(' + f'{first}, [\u2026]' + f')' + f"" + ) + + +def depart_gal_sig_fold(self: HTML5Translator, node: nodes.Element) -> None: + """Close the inline signature ``
``.""" + self.body.append("
") + + # -- Passthrough visitors (non-HTML builders) -------------------------------- From f2b8dfbf80968e65133dd109889947708d4e272e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 7 Apr 2026 18:06:35 -0500 Subject: [PATCH 005/174] layout(fix[sig-fold]): fix expanded signature layout and lighter param indent why: Raw comma text nodes and flex centering broke the expanded gal-sig-fold display; param lines used heavy 2rem indent. what: - Hide comma text nodes via font-size on open details; restore em/sig-paren - Trailing commas via ::after; last param has none - dt:has(open) align-items flex-start for api-style flex dt - Use display block on open details; reduce param margin-left to 1rem --- .../_static/css/layout.css | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css index fd4d53b3..2ebd0790 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css @@ -82,31 +82,35 @@ details.gal-sig-fold[open] > summary.gal-sig-fold-summary { display: none; } -/* When open, switch to block layout so each param - renders on its own indented line. */ +/* When open: block flex item; font-size 0 hides raw ", " text nodes + between params (they are direct children of
, not spans). */ details.gal-sig-fold[open] { - display: inline-block; - vertical-align: top; -} - -/* Hide raw comma text nodes between params via font-size */ -details.gal-sig-fold[open] > .desc-parameterlist, -details.gal-sig-fold[open] > span { + display: block; font-size: 0; } -details.gal-sig-fold[open] > .desc-parameterlist > *, -details.gal-sig-fold[open] > span > * { +/* Restore monospace sizing for visible signature pieces */ +details.gal-sig-fold[open] em.sig-param, +details.gal-sig-fold[open] .sig-paren { font-size: 1rem; } -/* Each param on its own line, indented */ +/* Each param on its own line, slightly indented; trailing comma via ::after */ details.gal-sig-fold[open] em.sig-param { display: block; - margin-left: 2rem; + margin-left: 1rem; } -/* Opening paren stays inline; closing paren on last line */ -details.gal-sig-fold[open] .sig-paren { - font-size: 1rem; +details.gal-sig-fold[open] em.sig-param::after { + content: ","; +} + +details.gal-sig-fold[open] em.sig-param:last-of-type::after { + content: ""; +} + +/* api-style uses flex + align-items:center on
; when sig-fold is + tall, centering floats the name/badge/¶ mid-height — pin to start. */ +dt:has(details.gal-sig-fold[open]) { + align-items: flex-start; } From 446d9a41031401431167dd0b4af2b85457fb879c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 7 Apr 2026 20:21:01 -0500 Subject: [PATCH 006/174] layout(feat[autodoc]): Rebuild managed API component layout why: Preserve Sphinx autodoc semantics while exposing a stable, customizable DOM contract and letting long signatures expand onto a real second row. what: - rebuild managed Python autodoc entries into explicit api-* header, content, and footer components with custom inline signature disclosure - add layout integration coverage for parameter regions, member nesting, badge and source placement, and hash-driven expansion behavior - align pytest fixture warning paths with both caplog-based unit tests and Sphinx integration warning streams --- docs/packages/sphinx-autodoc-layout.md | 26 +- packages/sphinx-autodoc-layout/README.md | 8 +- .../src/sphinx_autodoc_layout/__init__.py | 55 +- .../src/sphinx_autodoc_layout/_nodes.py | 98 ++- .../_static/css/layout.css | 175 ++++-- .../_static/js/layout.js | 69 ++- .../src/sphinx_autodoc_layout/_transforms.py | 577 ++++++++++++------ .../src/sphinx_autodoc_layout/_visitors.py | 127 ++-- .../_metadata.py | 20 +- .../_validation.py | 21 +- tests/ext/layout/test_integration.py | 183 ++++++ tests/ext/layout/test_nodes.py | 34 +- tests/ext/layout/test_transforms.py | 250 ++++++-- 13 files changed, 1245 insertions(+), 398 deletions(-) create mode 100644 tests/ext/layout/test_integration.py diff --git a/docs/packages/sphinx-autodoc-layout.md b/docs/packages/sphinx-autodoc-layout.md index a10f0ff8..74041566 100644 --- a/docs/packages/sphinx-autodoc-layout.md +++ b/docs/packages/sphinx-autodoc-layout.md @@ -5,8 +5,9 @@ {bdg-warning-line}`Alpha` Wraps contiguous `desc_content` runs into semantic `gal_region` nodes -and folds large parameter sections with native `
/`. -Does not modify `desc_signature`. +and rebuilds Python autodoc entries into stable `api-*` components. +Large field-list parameter sections still use native `
/`, +while inline signature expansion uses a custom disclosure layout. ## Live demo @@ -44,10 +45,23 @@ The class above should render with: | Class | Element | Purpose | |-------|---------|---------| -| `gal-region` | `
` | Base class for all content regions | -| `gal-region--narrative` | `
` | Wraps paragraphs, notes, examples | -| `gal-region--fields` | `
` | Wraps field lists (Parameters, Returns) | -| `gal-region--members` | `
` | Wraps nested method/attribute entries | +| `api-container` | `
` | Managed autodoc shell | +| `api-header` | `
` | Signature row shell | +| `api-content` | `
` | Description/content shell | +| `api-layout` | `
` | Header split between left and right | +| `api-layout-left` | `
` | Signature text, custom disclosure, permalink | +| `api-layout-right` | `
` | Badge container and source link | +| `api-signature` | `
` | Compact signature row | +| `api-link` | `` | Managed permalink in the left layout | +| `api-badge-container` | `` | Wrapper for badge group output | +| `api-source-link` | `` | Wrapper for the `[source]` link | +| `api-description` | `
` | Wraps paragraphs, notes, examples | +| `api-parameters` | `
` | Wraps field lists (Parameters, Returns) | +| `api-footer` | `
` | Wraps nested method/attribute entries | +| `gal-region` | `
` | Compatibility alias on content sections | +| `gal-region--narrative` | `
` | Compatibility alias on narrative sections | +| `gal-region--fields` | `
` | Compatibility alias on parameter sections | +| `gal-region--members` | `
` | Compatibility alias on footer/member sections | | `gal-fold` | `
` | Disclosure wrapper for large sections | | `gal-fold-summary` | `` | Click target showing field count | diff --git a/packages/sphinx-autodoc-layout/README.md b/packages/sphinx-autodoc-layout/README.md index 1ea2aff6..cd20d250 100644 --- a/packages/sphinx-autodoc-layout/README.md +++ b/packages/sphinx-autodoc-layout/README.md @@ -1,5 +1,7 @@ # sphinx-autodoc-layout -Componentized layout for Sphinx autodoc output. Wraps contiguous -`desc_content` runs into semantic regions and folds large parameter -sections without rewriting `desc_signature`. +Componentized layout for Sphinx autodoc output. It preserves Sphinx's +outer `dl / dt / dd` structure while rebuilding managed Python autodoc +entries into stable `api-*` components, folding block parameter sections +with native `
`, and rendering inline signature disclosure with a +custom two-row layout. diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py index af4a6b64..dbc7b7e8 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py @@ -1,9 +1,7 @@ """Componentized layout for Sphinx autodoc output. -Wraps contiguous ``desc_content`` runs into semantic ``gal_region`` -nodes and folds large parameter sections with ``gal_fold`` disclosure -blocks. Does not modify ``desc_signature`` -- that is owned by Sphinx -and ``sphinx-autodoc-api-style``. +Preserves Sphinx's outer ``dl / dt / dd`` structure while rebuilding +managed Python autodoc entries into stable ``api-*`` components. Examples -------- @@ -17,14 +15,29 @@ import pathlib import typing as t -from sphinx_autodoc_layout._nodes import gal_fold, gal_region, gal_sig_fold +from sphinx import addnodes + +from sphinx_autodoc_layout._nodes import ( + api_component, + api_inline_component, + api_permalink, + gal_fold, + gal_region, + gal_sig_fold, +) from sphinx_autodoc_layout._transforms import on_doctree_resolved from sphinx_autodoc_layout._visitors import ( + depart_api_component, + depart_api_permalink, + depart_desc_signature_html, depart_gal_fold, depart_gal_region, depart_gal_sig_fold, passthrough_depart, passthrough_visit, + visit_api_component, + visit_api_permalink, + visit_desc_signature_html, visit_gal_fold, visit_gal_region, visit_gal_sig_fold, @@ -87,6 +100,38 @@ def setup(app: Sphinx) -> dict[str, t.Any]: man=_pt, texinfo=_pt, ) + app.add_node( + api_component, + html=(visit_api_component, depart_api_component), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + app.add_node( + api_inline_component, + html=(visit_api_component, depart_api_component), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + app.add_node( + api_permalink, + html=(visit_api_permalink, depart_api_permalink), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + + # Managed desc signatures keep Sphinx's outer ``dt`` handling but skip the + # stock permalink injection so layout can place ``api-link`` explicitly. + app.add_node( + addnodes.desc_signature, + override=True, + html=(visit_desc_signature_html, depart_desc_signature_html), + ) # Transform: doctree-resolved at priority 600 (after api-style at 500) app.connect("doctree-resolved", on_doctree_resolved, priority=600) diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py index 050a21b3..d0590b04 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py @@ -1,23 +1,17 @@ -"""Custom docutils nodes for autodoc layout regions. +"""Custom docutils nodes for autodoc layout components. -Two generic nodes cover the full component tree: - -- ``gal_region`` wraps contiguous runs of ``desc_content`` children - (narrative paragraphs, field lists, nested members). -- ``gal_fold`` wraps a region in ``
/`` for - progressive disclosure of large parameter sections. -- ``gal_sig_fold`` wraps a ``desc_parameterlist`` in - ``
/`` for inline signature disclosure. +The extension keeps Sphinx's outer ``dl / dt / dd`` structure but +builds an explicit API component tree within those nodes. Examples -------- ->>> from sphinx_autodoc_layout._nodes import gal_region, gal_fold ->>> r = gal_region(kind="fields") ->>> r.get("kind") -'fields' +>>> from sphinx_autodoc_layout._nodes import api_component, gal_fold +>>> comp = api_component(name="api-layout", tag="div") +>>> comp.get("name") +'api-layout' ->>> f = gal_fold(kind="parameters", summary="Parameters (5)") ->>> f.get("summary") +>>> fold = gal_fold(kind="parameters", summary="Parameters (5)") +>>> fold.get("summary") 'Parameters (5)' """ @@ -27,7 +21,7 @@ class gal_region(nodes.General, nodes.Element): - """Wrapper for a contiguous ``desc_content`` run. + """Legacy wrapper for a contiguous ``desc_content`` run. Parameters ---------- @@ -66,25 +60,85 @@ class gal_fold(nodes.General, nodes.Element): """ +class api_component(nodes.General, nodes.Element): + """Generic API wrapper node with a stable component name. + + Parameters + ---------- + name : str + Stable DOM contract name such as ``"api-layout"``. + tag : str + HTML tag to emit. Defaults to ``"div"``. + + Examples + -------- + >>> node = api_component(name="api-content", tag="div") + >>> node.get("name") + 'api-content' + >>> node.get("tag") + 'div' + """ + + +class api_inline_component(nodes.General, nodes.Inline, nodes.TextElement): + """Inline API wrapper node for text-compatible header components. + + Parameters + ---------- + name : str + Stable DOM contract name such as ``"api-source-link"``. + tag : str + HTML tag to emit. Defaults to ``"span"``. + + Examples + -------- + >>> node = api_inline_component(name="api-source-link", tag="span") + >>> node.get("name") + 'api-source-link' + """ + + +class api_permalink(nodes.General, nodes.Element): + """Permalink anchor rendered inside ``api-layout-left``. + + Parameters + ---------- + href : str + Fragment link target such as ``"#mod.func"``. + title : str + Link title shown on hover. + + Examples + -------- + >>> link = api_permalink(href="#mod.func", title="Link to this definition") + >>> link.get("href") + '#mod.func' + """ + + class gal_sig_fold(nodes.General, nodes.Element): - """Inline disclosure wrapper for ``desc_parameterlist`` in signatures. + """Inline signature disclosure toggle for large parameter lists. - Wraps the parameter list in ``
/`` so the - collapsed state shows the first parameter plus ``[...]`` and - expanding reveals the full list one-per-line. + The preview button lives in the signature row, while the full + parameter list is rendered in a sibling ``api-signature-panel`` + wrapper beneath it. Parameters ---------- first_param : str - Text of the first parameter (shown in collapsed summary). + Text of the first parameter shown in collapsed preview. param_count : int Total number of parameters. + panel_id : str + DOM id of the controlled signature panel. Examples -------- - >>> sf = gal_sig_fold(first_param="host", param_count=13) + >>> sf = gal_sig_fold(first_param="host", param_count=13, panel_id="sig-panel") >>> sf.get("first_param") 'host' >>> sf.get("param_count") 13 + >>> sf.get("panel_id") + 'sig-panel' """ diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css index 2ebd0790..12cddd89 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css @@ -1,116 +1,177 @@ /* sphinx_autodoc_layout — layout.css - * Prefix: gal- - * Semantic region wrappers and disclosure fold styling. + * Stable api-* component wrappers and disclosure styling. */ -/* ── Region spacing ────────────────────────────────── */ +/* ── Content sections ───────────────────────────────── */ .gal-region + .gal-region { margin-top: 1rem; } +.api-footer, .gal-region--members { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--color-background-border); } -/* ── Override api-style field-list grid ─────────────── */ -/* Use descendant selector (not child >) so it survives - the
wrapper inserted by gal_fold. */ -dl.py:not(.fixture) > dd .gal-region--fields dl.field-list { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 0 1rem; +/* ── API shell ──────────────────────────────────────── */ +dl.py:not(.fixture).api-container > dt.api-header { + display: block; } -/* ── Fold (disclosure) ─────────────────────────────── */ -details.gal-fold { - margin: 0; +dl.py:not(.fixture).api-container > dt.api-header > .api-layout { + display: flex; + align-items: flex-start; + gap: 1rem; + width: 100%; } -details.gal-fold > summary.gal-fold-summary { - cursor: pointer; - color: var(--color-foreground-muted); - font-size: 0.85em; - font-weight: 600; - padding: 0.25rem 0; - list-style: none; +dl.py:not(.fixture).api-container > dt.api-header .api-layout-left { + flex: 1 1 auto; + min-width: 0; } -details.gal-fold > summary.gal-fold-summary::-webkit-details-marker { - display: none; +dl.py:not(.fixture).api-container > dt.api-header .api-layout-right { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-left: auto; + white-space: nowrap; + flex: 0 0 auto; } -details.gal-fold > summary.gal-fold-summary::before { - content: "\25B8 "; +dl.py:not(.fixture).api-container > dt.api-header .api-layout-right:empty { + display: none; } -details.gal-fold[open] > summary.gal-fold-summary::before { - content: "\25BE "; +dl.py:not(.fixture).api-container > dt.api-header .api-source-link { + display: inline-flex; + align-items: center; } -details.gal-fold > summary.gal-fold-summary:hover { - color: var(--color-link); +/* ── Signature row ──────────────────────────────────── */ +dl.py:not(.fixture).api-container > dt.api-header .api-signature { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; + min-width: 0; } -/* ── Signature fold (inline in
) ───────────────── */ -details.gal-sig-fold { - display: inline; +dl.py:not(.fixture).api-container > dt.api-header .api-link { + margin-left: 0.1rem; } -details.gal-sig-fold > summary.gal-sig-fold-summary { - display: inline; - list-style: none; +.api-signature-toggle { + appearance: none; + border: 0; + background: none; + color: inherit; cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0; font-family: var(--font-stack--monospace); + padding: 0; } -details.gal-sig-fold > summary.gal-sig-fold-summary::-webkit-details-marker { - display: none; -} - -details.gal-sig-fold > summary.gal-sig-fold-summary .gal-sig-preview { +.api-signature-toggle .api-signature-preview { color: var(--color-foreground-muted); } -details.gal-sig-fold > summary.gal-sig-fold-summary:hover .gal-sig-preview { +.api-signature-toggle:hover .api-signature-preview, +.api-signature-toggle:focus-visible .api-signature-preview { color: var(--color-link); } -/* When open: hide summary, show params as indented block */ -details.gal-sig-fold[open] > summary.gal-sig-fold-summary { +.api-signature-toggle[aria-expanded="true"] { display: none; } -/* When open: block flex item; font-size 0 hides raw ", " text nodes - between params (they are direct children of
, not spans). */ -details.gal-sig-fold[open] { - display: block; +/* ── Expanded signature panel ───────────────────────── */ +.api-signature-panel { + font-family: var(--font-stack--monospace); font-size: 0; + margin-top: 0.45rem; + width: 100%; +} + +.api-signature-panel[hidden] { + display: none; } -/* Restore monospace sizing for visible signature pieces */ -details.gal-sig-fold[open] em.sig-param, -details.gal-sig-fold[open] .sig-paren { +.api-signature-panel > .sig-paren { + display: block; font-size: 1rem; } -/* Each param on its own line, slightly indented; trailing comma via ::after */ -details.gal-sig-fold[open] em.sig-param { +.api-signature-panel > .sig-paren:first-child { + display: none; +} + +.api-signature-panel em.sig-param { display: block; + font-size: 1rem; margin-left: 1rem; } -details.gal-sig-fold[open] em.sig-param::after { +.api-signature-panel em.sig-param::after { content: ","; } -details.gal-sig-fold[open] em.sig-param:last-of-type::after { +.api-signature-panel em.sig-param:last-of-type::after { content: ""; } -/* api-style uses flex + align-items:center on
; when sig-fold is - tall, centering floats the name/badge/¶ mid-height — pin to start. */ -dt:has(details.gal-sig-fold[open]) { - align-items: flex-start; +/* ── Field-list grid ────────────────────────────────── */ +dl.py:not(.fixture).api-container > dd.api-content .api-parameters dl.field-list { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0 1rem; +} + +/* ── Fold (block disclosure) ────────────────────────── */ +details.gal-fold { + margin: 0; +} + +details.gal-fold > summary.gal-fold-summary { + cursor: pointer; + color: var(--color-foreground-muted); + font-size: 0.85em; + font-weight: 600; + padding: 0.25rem 0; + list-style: none; +} + +details.gal-fold > summary.gal-fold-summary::-webkit-details-marker { + display: none; +} + +details.gal-fold > summary.gal-fold-summary::before { + content: "\25B8 "; +} + +details.gal-fold[open] > summary.gal-fold-summary::before { + content: "\25BE "; +} + +details.gal-fold > summary.gal-fold-summary:hover { + color: var(--color-link); +} + +/* ── Mobile adjustments ─────────────────────────────── */ +@media (max-width: 52rem) { + dl.py:not(.fixture).api-container > dt.api-header > .api-layout { + flex-direction: column; + gap: 0.5rem; + } + + dl.py:not(.fixture).api-container > dt.api-header .api-layout-right { + margin-left: 0; + white-space: normal; + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } } diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js index 2f609442..606f3338 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js @@ -1,22 +1,35 @@ /** * sphinx_autodoc_layout — layout.js * - * Hash-based auto-expansion: when the URL fragment targets an - * element inside a closed
, open it so the target is - * visible. + * Hash-based auto-expansion for both block
folds and the + * custom api-signature disclosure panel. */ (function () { 'use strict'; - function expandForHash() { - var hash = window.location.hash; - if (!hash) return; + function setSignatureExpanded(button, expanded) { + if (!button) return; - var id = hash.slice(1); - var target = document.getElementById(id); - if (!target) return; + var panelId = button.getAttribute('aria-controls'); + if (!panelId) return; + var panel = document.getElementById(panelId); + if (!panel) return; + + button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + if (expanded) { + panel.hidden = false; + panel.setAttribute('data-expanded', 'true'); + panel.setAttribute('aria-hidden', 'false'); + } else { + panel.hidden = true; + panel.setAttribute('data-expanded', 'false'); + panel.setAttribute('aria-hidden', 'true'); + } + } + + function expandAncestors(target) { var node = target; while (node) { if (node.tagName === 'DETAILS' && !node.open) { @@ -24,12 +37,50 @@ } node = node.parentElement; } + } + + function expandSignatureForTarget(target) { + var header = null; + + if (target.classList && target.classList.contains('api-header')) { + header = target; + } else if (target.closest) { + header = target.closest('.api-header'); + } + + if (!header) return; + + var button = header.querySelector('.api-signature-toggle'); + if (!button) return; + + setSignatureExpanded(button, true); + } + + function expandForHash() { + var hash = window.location.hash; + if (!hash) return; + + var id = hash.slice(1); + var target = document.getElementById(id); + if (!target) return; + + expandAncestors(target); + expandSignatureForTarget(target); setTimeout(function () { target.scrollIntoView({ block: 'center' }); }, 50); } + document.addEventListener('click', function (event) { + var button = event.target.closest('.api-signature-toggle'); + if (!button) return; + + event.preventDefault(); + var expanded = button.getAttribute('aria-expanded') === 'true'; + setSignatureExpanded(button, !expanded); + }); + document.addEventListener('DOMContentLoaded', expandForHash); window.addEventListener('hashchange', expandForHash); })(); diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py index 175a3988..9021d068 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py @@ -1,9 +1,9 @@ """Doctree transforms for componentized autodoc layout. -Runs as a ``doctree-resolved`` event handler at priority 600, after -``sphinx-autodoc-api-style`` (priority 500). Wraps contiguous runs -of ``desc_content`` children into ``gal_region`` nodes and optionally -folds large field-list regions into ``gal_fold`` disclosure blocks. +Runs as a ``doctree-resolved`` event handler after +``sphinx-autodoc-api-style``. It rebuilds Python autodoc entries into +stable ``api-*`` wrappers while preserving Sphinx's outer ``dl / dt / dd`` +structure. Examples -------- @@ -17,18 +17,28 @@ from __future__ import annotations -import logging import typing as t from docutils import nodes from sphinx import addnodes -from sphinx_autodoc_layout._nodes import gal_fold, gal_region, gal_sig_fold +from sphinx_autodoc_layout._nodes import ( + api_component, + api_inline_component, + api_permalink, + gal_fold, + gal_region, + gal_sig_fold, +) if t.TYPE_CHECKING: from sphinx.application import Sphinx -logger = logging.getLogger(__name__) +_SECTION_COMPONENTS: dict[str, str] = { + "narrative": "api-description", + "fields": "api-parameters", + "members": "api-footer", +} _SKIP_FOLD_OBJTYPES: frozenset[str] = frozenset( { @@ -40,6 +50,92 @@ } ) +_MEMBER_CONTAINER_OBJTYPES: frozenset[str] = frozenset({"class", "exception"}) + + +def _append_class(node: nodes.Element, class_name: str) -> None: + """Append *class_name* to ``node['classes']`` if needed.""" + classes = list(node.get("classes", [])) + if class_name not in classes: + classes.append(class_name) + node["classes"] = classes + + +def _make_api_component( + name: str, + *, + tag: str = "div", + classes: tuple[str, ...] = (), + html_attrs: dict[str, str] | None = None, +) -> api_component: + """Create an ``api_component`` node with stable DOM classes. + + Parameters + ---------- + name : str + Public component class name. + tag : str + HTML tag emitted by the visitor. + classes : tuple[str, ...] + Extra compatibility classes. + html_attrs : dict[str, str] | None + Extra HTML attributes for the rendered tag. + + Returns + ------- + api_component + A configured component wrapper. + + Examples + -------- + >>> wrapper = _make_api_component("api-content", classes=("legacy",)) + >>> wrapper.get("classes") + ['api-content', 'legacy'] + """ + component = api_component(name=name, tag=tag) + component["classes"] = [name, *classes] + if html_attrs: + component["html_attrs"] = html_attrs + return component + + +def _make_api_inline_component( + name: str, + *, + tag: str = "span", + classes: tuple[str, ...] = (), + html_attrs: dict[str, str] | None = None, +) -> api_inline_component: + """Create an inline API wrapper for text-compatible header content.""" + component = api_inline_component(name=name, tag=tag) + component["classes"] = [name, *classes] + if html_attrs: + component["html_attrs"] = html_attrs + return component + + +def _make_api_permalink(desc_sig: addnodes.desc_signature) -> api_permalink | None: + """Create the managed permalink node for a signature.""" + ids = list(desc_sig.get("ids", [])) + if not ids: + return None + link = api_permalink( + href=f"#{ids[0]}", + title="Link to this definition", + ) + link["classes"] = ["headerlink", "api-link"] + return link + + +def _primary_signature_id(desc_node: addnodes.desc) -> str | None: + """Return the first signature id for a ``desc`` node.""" + for child in desc_node.children: + if isinstance(child, addnodes.desc_signature): + ids = list(child.get("ids", [])) + if ids: + return ids[0] + return None + def _classify_child(child: nodes.Node) -> str: """Classify a ``desc_content`` child by its node type. @@ -62,8 +158,6 @@ def _classify_child(child: nodes.Node) -> str: 'fields' >>> _classify_child(addnodes.desc()) 'members' - >>> _classify_child(nodes.paragraph()) - 'narrative' >>> _classify_child(nodes.note()) 'narrative' """ @@ -74,11 +168,13 @@ def _classify_child(child: nodes.Node) -> str: return "narrative" -def _wrap_content_runs(desc_node: addnodes.desc) -> None: - """Wrap contiguous runs of ``desc_content`` children in regions. +def _component_name_for_kind(kind: str) -> str: + """Return the public API section name for a legacy kind string.""" + return _SECTION_COMPONENTS[kind] + - Iterates children in order, grouping contiguous same-type nodes - into ``gal_region`` wrappers. Never reorders children. +def _wrap_content_runs(desc_node: addnodes.desc) -> None: + """Wrap contiguous ``desc_content`` runs in explicit API sections. Parameters ---------- @@ -90,57 +186,135 @@ def _wrap_content_runs(desc_node: addnodes.desc) -> None: >>> from docutils import nodes >>> from sphinx import addnodes >>> desc = addnodes.desc(domain="py", objtype="function") - >>> sig = addnodes.desc_signature() - >>> desc += sig + >>> desc += addnodes.desc_signature() >>> content = addnodes.desc_content() >>> content += nodes.paragraph("", "hello") >>> content += nodes.field_list() >>> desc += content >>> _wrap_content_runs(desc) - >>> len(content.children) - 2 - >>> isinstance(content.children[0], gal_region) - True - >>> content.children[0].get("kind") - 'narrative' - >>> content.children[1].get("kind") - 'fields' + >>> [child.get("name") for child in content.children] + ['api-description', 'api-parameters'] """ content = next( (c for c in desc_node.children if isinstance(c, addnodes.desc_content)), None, ) - if content is None or not content.children: + if content is None: + return + + _append_class(content, "api-content") + if not content.children: return original = list(content.children) content.children = [] current_kind: str | None = None - current_region: gal_region | None = None + current_section: api_component | None = None for child in original: kind = _classify_child(child) if kind != current_kind: - if current_region is not None: - content += current_region - current_region = gal_region(kind=kind) + if current_section is not None: + content += current_section + current_section = _make_api_component( + _component_name_for_kind(kind), + classes=("gal-region", f"gal-region--{kind}"), + ) current_kind = kind - assert current_region is not None - current_region += child + assert current_section is not None + current_section += child + + if current_section is not None: + content += current_section + + +def _ensure_desc_content(desc_node: addnodes.desc) -> addnodes.desc_content: + """Return an existing ``desc_content`` child or create one.""" + for child in desc_node.children: + if isinstance(child, addnodes.desc_content): + return child + content = addnodes.desc_content() + desc_node += content + return content - if current_region is not None: - content += current_region + +def _is_member_desc_for_container( + member_desc: addnodes.desc, + *, + container_id: str, +) -> bool: + """Return ``True`` when *member_desc* belongs to *container_id*.""" + member_id = _primary_signature_id(member_desc) + if member_id is None: + return False + return member_id.startswith(f"{container_id}.") + + +def _nest_python_members(container: nodes.Element) -> None: + """Move sibling Python member descriptions into their parent class. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> parent = nodes.section() + >>> klass = addnodes.desc(domain="py", objtype="class") + >>> klass += addnodes.desc_signature(ids=["demo.Widget"]) + >>> klass += addnodes.desc_content() + >>> method = addnodes.desc(domain="py", objtype="method") + >>> method += addnodes.desc_signature(ids=["demo.Widget.run"]) + >>> method += addnodes.desc_content() + >>> parent += klass + >>> parent += method + >>> _nest_python_members(parent) + >>> len(parent.children) + 1 + >>> len(klass[-1].children) + 1 + """ + index = 0 + while index < len(container.children): + child = container.children[index] + + if isinstance(child, nodes.Element): + _nest_python_members(child) + + if not isinstance(child, addnodes.desc): + index += 1 + continue + if child.get("domain") != "py": + index += 1 + continue + if child.get("objtype") not in _MEMBER_CONTAINER_OBJTYPES: + index += 1 + continue + + container_id = _primary_signature_id(child) + if container_id is None: + index += 1 + continue + + content = _ensure_desc_content(child) + + while index + 1 < len(container.children): + sibling = container.children[index + 1] + if not isinstance(sibling, addnodes.desc): + break + if sibling.get("domain") != "py": + break + if not _is_member_desc_for_container(sibling, container_id=container_id): + break + + container.remove(sibling) + content += sibling + + index += 1 def _count_field_entries(field_list: nodes.field_list) -> int: """Count individual entries in a Sphinx field list. - Sphinx's ``DocFieldTransformer`` collapses multiple params into a - single ``field`` containing a ``bullet_list`` of ``list_item`` - nodes. This function counts ``list_item`` nodes inside collapsed - fields plus standalone ``field`` nodes. - Parameters ---------- field_list : nodes.field_list @@ -150,41 +324,11 @@ def _count_field_entries(field_list: nodes.field_list) -> int: ------- int Total entry count. - - Examples - -------- - Collapsed (Sphinx-style) — single field with bullet_list: - - >>> from docutils import nodes - >>> fl = nodes.field_list() - >>> f = nodes.field() - >>> f += nodes.field_name("", "Parameters") - >>> body = nodes.field_body() - >>> bl = nodes.bullet_list() - >>> for i in range(5): - ... bl += nodes.list_item("", nodes.paragraph("", f"p{i}")) - >>> body += bl - >>> f += body - >>> fl += f - >>> _count_field_entries(fl) - 5 - - Non-collapsed — individual fields: - - >>> fl2 = nodes.field_list() - >>> for i in range(3): - ... f2 = nodes.field() - ... f2 += nodes.field_name("", f"p{i}") - ... f2 += nodes.field_body("", nodes.paragraph("", "...")) - ... fl2 += f2 - >>> _count_field_entries(fl2) - 3 """ count = 0 for field in field_list.children: if not isinstance(field, nodes.field): continue - # Check if field body contains a bullet_list (collapsed params) for body_child in field.children: if isinstance(body_child, nodes.field_body): for item in body_child.children: @@ -194,7 +338,6 @@ def _count_field_entries(field_list: nodes.field_list) -> int: ) break else: - # No bullet_list found — count the field itself count += 1 break else: @@ -202,60 +345,25 @@ def _count_field_entries(field_list: nodes.field_list) -> int: return count +def _is_parameters_section(node: nodes.Node) -> bool: + """Return ``True`` when *node* is an API parameters section.""" + if isinstance(node, api_component): + return node.get("name") == "api-parameters" + if isinstance(node, gal_region): + return node.get("kind") == "fields" + return False + + def _fold_large_field_regions( content: addnodes.desc_content, threshold: int, ) -> None: - """Wrap large ``field_list`` nodes in ``gal_fold`` disclosure blocks. - - Only folds ``gal_region(kind="fields")`` children whose - ``field_list`` contains enough entries to exceed *threshold*. - - Sphinx's ``DocFieldTransformer`` collapses multiple params into a - single ``field`` with a ``bullet_list`` of ``list_item`` children. - We count ``list_item`` nodes (individual params) plus standalone - ``field`` nodes (Returns, Raises) to get the total entry count. - - Parameters - ---------- - content : addnodes.desc_content - The description content node (already wrapped in regions). - threshold : int - Minimum entry count to trigger folding. - - Examples - -------- - Sphinx-style collapsed field list (single field with bullet_list): - - >>> from docutils import nodes - >>> from sphinx import addnodes - >>> content = addnodes.desc_content() - >>> region = gal_region(kind="fields") - >>> fl = nodes.field_list() - >>> f = nodes.field() - >>> f += nodes.field_name("", "Parameters") - >>> body = nodes.field_body() - >>> bl = nodes.bullet_list() - >>> for i in range(12): - ... bl += nodes.list_item("", nodes.paragraph("", f"p{i}")) - >>> body += bl - >>> f += body - >>> fl += f - >>> region += fl - >>> content += region - >>> _fold_large_field_regions(content, threshold=10) - >>> fold = region.children[0] - >>> isinstance(fold, gal_fold) - True - >>> fold.get("summary") - 'Parameters (12)' - """ - for region in content.children: - if not isinstance(region, gal_region): + """Wrap large field-list regions in ``gal_fold`` disclosure blocks.""" + for section in content.children: + if not _is_parameters_section(section): continue - if region.get("kind") != "fields": - continue - for field_list in list(region.children): + assert isinstance(section, nodes.Element) + for field_list in list(section.children): if not isinstance(field_list, nodes.field_list): continue entry_count = _count_field_entries(field_list) @@ -265,72 +373,150 @@ def _fold_large_field_regions( kind="parameters", summary=f"Parameters ({entry_count})", ) - idx = region.children.index(field_list) - region.remove(field_list) + idx = section.children.index(field_list) + section.remove(field_list) fold += field_list - region.insert(idx, fold) + section.insert(idx, fold) -def _fold_signature_params(desc_node: addnodes.desc, threshold: int) -> None: - """Wrap large ``desc_parameterlist`` in ``gal_sig_fold``. +def _is_viewcode_ref(node: nodes.Node) -> bool: + """Return ``True`` when *node* is a viewcode/source reference.""" + return isinstance(node, nodes.reference) and any( + "viewcode-link" in getattr(child, "get", lambda *_: [])("classes", []) + for child in node.children + if isinstance(child, nodes.inline) + ) - Replaces the ``desc_parameterlist`` with a ``gal_sig_fold`` node - that contains it. The visitor renders a ``
/`` - showing the first param + ``[...]`` when collapsed. - Parameters - ---------- - desc_node : addnodes.desc - The description node. - threshold : int - Minimum param count to trigger folding. +def _count_signature_parameters( + parameter_list: addnodes.desc_parameterlist, +) -> tuple[str, int]: + """Return the first parameter text and total parameter count.""" + params = list(parameter_list.findall(addnodes.desc_parameter)) + first = params[0].astext().strip() if params else "" + return first, len(params) - Examples - -------- - >>> from docutils import nodes - >>> from sphinx import addnodes - >>> desc = addnodes.desc(domain="py", objtype="function") - >>> sig = addnodes.desc_signature() - >>> sig += addnodes.desc_name("", "func") - >>> plist = addnodes.desc_parameterlist() - >>> for i in range(15): - ... plist += addnodes.desc_parameter("", f"p{i}") - >>> sig += plist - >>> desc += sig - >>> desc += addnodes.desc_content() - >>> _fold_signature_params(desc, threshold=10) - >>> fold = [c for c in sig.children if isinstance(c, gal_sig_fold)] - >>> len(fold) - 1 - >>> fold[0].get("first_param") - 'p0' - >>> fold[0].get("param_count") - 15 - """ - for sig in desc_node.children: - if not isinstance(sig, addnodes.desc_signature): + +def _signature_panel_id(desc_sig: addnodes.desc_signature) -> str: + """Return the stable DOM id for an expanded signature panel.""" + ids = list(desc_sig.get("ids", [])) + base = ids[0] if ids else "api-signature" + return f"{base}--signature-panel" + + +def _extract_toolbar_content( + toolbar: nodes.inline | None, +) -> tuple[list[nodes.Node], nodes.reference | None]: + """Split toolbar content into badge-side children and source link.""" + badge_children: list[nodes.Node] = [] + source_ref: nodes.reference | None = None + + if toolbar is None: + return badge_children, source_ref + + for child in list(toolbar.children): + if source_ref is None and _is_viewcode_ref(child): + assert isinstance(child, nodes.reference) + source_ref = child continue - plists = list(sig.findall(addnodes.desc_parameterlist)) - if not plists: + badge_children.append(child) + + return badge_children, source_ref + + +def _rebuild_signature_layout( + desc_sig: addnodes.desc_signature, + *, + threshold: int, + include_permalink: bool, +) -> None: + """Rebuild a signature into explicit API header subcomponents.""" + if desc_sig.get("is_multiline"): + return + + original = list(desc_sig.children) + desc_sig.children = [] + + toolbar: nodes.inline | None = None + row_children: list[nodes.Node] = [] + fallback_source_ref: nodes.reference | None = None + + for child in original: + if isinstance(child, nodes.inline) and "gas-toolbar" in child.get( + "classes", [] + ): + toolbar = child continue - plist = plists[0] - params = [c for c in plist.children if isinstance(c, addnodes.desc_parameter)] - if len(params) < threshold: + if isinstance(child, nodes.reference) and "headerlink" in child.get( + "classes", [] + ): continue + if fallback_source_ref is None and _is_viewcode_ref(child): + assert isinstance(child, nodes.reference) + fallback_source_ref = child + continue + row_children.append(child) + + badge_children, source_ref = _extract_toolbar_content(toolbar) + if source_ref is None: + source_ref = fallback_source_ref + + layout = _make_api_component("api-layout") + left = _make_api_component("api-layout-left") + signature = _make_api_component("api-signature") + right = _make_api_component("api-layout-right", classes=("gas-toolbar",)) + + panel: api_component | None = None + folded = False + + for child in row_children: + if not folded and isinstance(child, addnodes.desc_parameterlist): + first_param, param_count = _count_signature_parameters(child) + if param_count >= threshold: + panel_id = _signature_panel_id(desc_sig) + signature += gal_sig_fold( + first_param=first_param, + param_count=param_count, + panel_id=panel_id, + ) + panel = _make_api_component( + "api-signature-panel", + classes=("gal-sig-panel",), + html_attrs={ + "aria-hidden": "true", + "data-expanded": "false", + "hidden": "hidden", + "id": panel_id, + }, + ) + panel += child + folded = True + continue + signature += child - first_text = params[0].astext().strip() if params else "" - fold = gal_sig_fold( - first_param=first_text, - param_count=len(params), - ) + if include_permalink: + permalink = _make_api_permalink(desc_sig) + if permalink is not None: + signature += permalink - # Wrap the desc_parameterlist in the fold node. - # The fold visitor emits a
with a compact summary; - # the desc_parameterlist visitor emits its own ( and ). - idx = list(sig.children).index(plist) - sig.remove(plist) - fold += plist - sig.insert(idx, fold) + left += signature + if panel is not None: + left += panel + + if badge_children: + badge_container = _make_api_inline_component("api-badge-container") + for child in badge_children: + badge_container += child + right += badge_container + + if source_ref is not None: + source_container = _make_api_inline_component("api-source-link") + source_container += source_ref + right += source_container + + layout += left + layout += right + desc_sig += layout def on_doctree_resolved( @@ -338,10 +524,7 @@ def on_doctree_resolved( doctree: nodes.document, docname: str, ) -> None: - """Restructure autodoc ``desc_content`` into semantic regions. - - Connected to ``doctree-resolved`` at priority 600, after - ``sphinx-autodoc-api-style`` (priority 500). + """Restructure Python autodoc output into stable API components. Parameters ---------- @@ -351,11 +534,6 @@ def on_doctree_resolved( The resolved doctree. docname : str The document name. - - Examples - -------- - >>> on_doctree_resolved # doctest: +ELLIPSIS - """ if not app.config.gal_enabled: return @@ -364,22 +542,39 @@ def on_doctree_resolved( threshold: int = app.config.gal_collapsed_threshold fold_params: bool = app.config.gal_fold_parameters + include_permalink = bool( + app.config.html_permalinks and getattr(app.builder, "add_permalinks", False) + ) + + _nest_python_members(doctree) for desc_node in doctree.findall(addnodes.desc): if desc_node.get("domain") != "py": continue + _append_class(desc_node, "api-container") _wrap_content_runs(desc_node) objtype = desc_node.get("objtype", "") - if fold_params and objtype not in _SKIP_FOLD_OBJTYPES: - # Fold the signature param list (inline in
) - _fold_signature_params(desc_node, threshold) - - # Fold the field list in desc_content - content = next( - (c for c in desc_node.children if isinstance(c, addnodes.desc_content)), - None, + allow_signature_fold = fold_params and objtype not in _SKIP_FOLD_OBJTYPES + + for child in desc_node.children: + if not isinstance(child, addnodes.desc_signature): + continue + _append_class(child, "api-header") + child["api_managed"] = not child.get("is_multiline", False) + _rebuild_signature_layout( + child, + threshold=threshold if allow_signature_fold else 10**9, + include_permalink=include_permalink, ) - if content is not None: - _fold_large_field_regions(content, threshold) + + if not allow_signature_fold: + continue + + content = next( + (c for c in desc_node.children if isinstance(c, addnodes.desc_content)), + None, + ) + if content is not None: + _fold_large_field_regions(content, threshold) diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py index 4e15cbd6..33dccf70 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py @@ -1,49 +1,90 @@ -"""HTML visitors for layout nodes. +"""HTML visitors for autodoc layout nodes. -Each node maps to a simple wrapper element: - -- ``gal_region`` -> ``
`` -- ``gal_fold`` -> ``
...`` - -Non-HTML builders get passthrough visitors that render children -without any wrapper markup. +The extension keeps Sphinx's outer ``dl / dt / dd`` shell, then renders +explicit API subcomponents inside those nodes. Examples -------- ->>> callable(visit_gal_region) -True ->>> callable(depart_gal_region) +>>> callable(visit_api_component) True ->>> callable(visit_gal_fold) +>>> callable(visit_api_permalink) True ->>> callable(depart_gal_fold) +>>> callable(visit_desc_signature_html) True """ from __future__ import annotations +import html import typing as t from docutils import nodes +from sphinx.locale import _ +from sphinx.writers.html5 import HTML5Translator as SphinxHTML5Translator if t.TYPE_CHECKING: from sphinx.writers.html5 import HTML5Translator +_LEGACY_SECTION_COMPONENTS: dict[str, str] = { + "narrative": "api-description", + "fields": "api-parameters", + "members": "api-footer", +} -# -- HTML visitors ----------------------------------------------------------- + +def _html_attrs(node: nodes.Element) -> dict[str, str]: + """Return sanitized HTML attributes stored on a custom node.""" + attrs = node.get("html_attrs", {}) + return {str(key): str(value) for key, value in attrs.items()} def visit_gal_region(self: HTML5Translator, node: nodes.Element) -> None: - """Open a region wrapper ``
``.""" + """Open a legacy region wrapper ``
``.""" kind = node.get("kind", "narrative") - self.body.append(f'
') + component = _LEGACY_SECTION_COMPONENTS.get(kind) + classes = ["gal-region", f"gal-region--{kind}"] + if component is not None: + classes.insert(0, component) + self.body.append(self.starttag(node, "div", "", classes=classes)) def depart_gal_region(self: HTML5Translator, node: nodes.Element) -> None: - """Close the region ``
``.""" + """Close the legacy region wrapper.""" self.body.append("
") +def visit_api_component(self: HTML5Translator, node: nodes.Element) -> None: + """Open an API component wrapper element.""" + tag = node.get("tag", "div") + attrs = _html_attrs(node) + ids = [attrs.pop("id")] if "id" in attrs else [] + self.body.append(self.starttag(node, tag, "", ids=ids, **attrs)) + + +def depart_api_component(self: HTML5Translator, node: nodes.Element) -> None: + """Close an API component wrapper element.""" + self.body.append(f"") + + +def visit_api_permalink(self: HTML5Translator, node: nodes.Element) -> None: + """Open a managed permalink anchor.""" + self.body.append( + self.starttag( + node, + "a", + "", + href=node.get("href", "#"), + title=node.get("title", _("Link to this definition")), + ) + ) + self.body.append(node.get("text", self.config.html_permalinks_icon)) + + +def depart_api_permalink(self: HTML5Translator, node: nodes.Element) -> None: + """Close the managed permalink anchor.""" + self.body.append("") + + def visit_gal_fold(self: HTML5Translator, node: nodes.Element) -> None: """Open a ``
`` disclosure element.""" summary = node.get("summary", "") @@ -51,7 +92,7 @@ def visit_gal_fold(self: HTML5Translator, node: nodes.Element) -> None: open_attr = " open" if node.get("open", False) else "" self.body.append( f'
' - f'{summary}' + f'{html.escape(summary)}' ) @@ -61,29 +102,47 @@ def depart_gal_fold(self: HTML5Translator, node: nodes.Element) -> None: def visit_gal_sig_fold(self: HTML5Translator, node: nodes.Element) -> None: - """Open an inline ``
`` for signature parameter disclosure. - - The ```` shows ``(first_param, [...])`` as a compact - preview. The ``
`` body contains the full - ``desc_parameterlist`` which renders its own ``(`` and ``)``. - """ - first = node.get("first_param", "") + """Open the custom signature disclosure toggle button.""" + first = html.escape(node.get("first_param", "")) + panel_id = node.get("panel_id", "") + preview = first if first else "..." self.body.append( - f'
' - f'' - f'(' - f'{first}, [\u2026]' - f')' - f"" + self.starttag( + node, + "button", + "", + type="button", + classes=["api-signature-toggle", "gal-sig-toggle"], + **{ + "aria-controls": panel_id, + "aria-expanded": "false", + }, + ) ) + self.body.append('(') + self.body.append( + f'{preview}, [...]' + ) + self.body.append(')') def depart_gal_sig_fold(self: HTML5Translator, node: nodes.Element) -> None: - """Close the inline signature ``
``.""" - self.body.append("
") + """Close the custom signature disclosure toggle button.""" + self.body.append("") + + +def visit_desc_signature_html(self: HTML5Translator, node: nodes.Element) -> None: + """Render managed desc signatures without Sphinx's default permalink.""" + SphinxHTML5Translator.visit_desc_signature(self, node) -# -- Passthrough visitors (non-HTML builders) -------------------------------- +def depart_desc_signature_html(self: HTML5Translator, node: nodes.Element) -> None: + """Close managed desc signatures while skipping Sphinx's auto permalink.""" + if not node.get("api_managed", False): + SphinxHTML5Translator.depart_desc_signature(self, node) + return + self.protect_literal_text -= 1 + self.body.append("
\n") def passthrough_visit(self: t.Any, node: nodes.Element) -> None: diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py index 1389dd2a..96349ee0 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py @@ -4,6 +4,7 @@ import ast import inspect +import logging import re import typing as t @@ -28,7 +29,22 @@ if t.TYPE_CHECKING: from sphinx import addnodes -logger = sphinx_logging.getLogger(__name__) +logger = logging.getLogger(__name__) +sphinx_logger = sphinx_logging.getLogger(__name__) + + +def _active_logger(app: t.Any) -> t.Any: + """Return the logger best suited to the current execution context. + + Examples + -------- + >>> import types + >>> _active_logger(types.SimpleNamespace()).name + 'sphinx_autodoc_pytest_fixtures._metadata' + """ + if hasattr(app, "_warning"): + return sphinx_logger + return logger def _is_type_checking_guard(node: ast.If) -> bool: @@ -277,7 +293,7 @@ def _register_fixture_meta( inferred_kind = _infer_kind(obj, kind or None) if inferred_kind not in _KNOWN_KINDS: - logger.warning( + _active_logger(app).warning( "unknown fixture kind %r for %r; expected one of %r", inferred_kind, canonical_name, diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py index 90997908..bfc80ce7 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t from sphinx.util import logging as sphinx_logging @@ -9,7 +10,22 @@ if t.TYPE_CHECKING: from sphinx_autodoc_pytest_fixtures._store import FixtureStoreDict -logger = sphinx_logging.getLogger(__name__) +logger = logging.getLogger(__name__) +sphinx_logger = sphinx_logging.getLogger(__name__) + + +def _active_logger(app: t.Any) -> t.Any: + """Return the logger best suited to the current execution context. + + Examples + -------- + >>> import types + >>> _active_logger(types.SimpleNamespace()).name + 'sphinx_autodoc_pytest_fixtures._validation' + """ + if hasattr(app, "_warning"): + return sphinx_logger + return logger def _validate_store(store: FixtureStoreDict, app: t.Any) -> None: @@ -40,7 +56,8 @@ def _validate_store(store: FixtureStoreDict, app: t.Any) -> None: if lint_level == "none": return - _emit = logger.error if lint_level == "error" else logger.warning + active_logger = _active_logger(app) + _emit = active_logger.error if lint_level == "error" else active_logger.warning emitted = False for canon, meta in store["fixtures"].items(): diff --git a/tests/ext/layout/test_integration.py b/tests/ext/layout/test_integration.py new file mode 100644 index 00000000..029d520f --- /dev/null +++ b/tests/ext/layout/test_integration.py @@ -0,0 +1,183 @@ +"""Integration tests for sphinx_autodoc_layout HTML output.""" + +from __future__ import annotations + +import io +import pathlib +import re +import textwrap + +import pytest +from sphinx.application import Sphinx + +_MODULE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + + + class LayoutDemo: + \"\"\"A class demonstrating all layout regions. + + Parameters + ---------- + host : str + Server hostname. + port : int + Server port number. + username : str + Authentication username. + password : str + Authentication password. + database : str + Database name. + timeout : float + Connection timeout in seconds. + retries : int + Number of connection retries. + ssl : bool + Enable SSL/TLS. + pool_size : int + Connection pool size. + pool_timeout : float + Pool checkout timeout. + echo : bool + Log all SQL statements. + encoding : str + Character encoding. + isolation_level : str + Transaction isolation level. + \"\"\" + + def __init__( + self, + host: str, + port: int = 5432, + *, + username: str = "admin", + password: str = "", + database: str = "default", + timeout: float = 30.0, + retries: int = 3, + ssl: bool = True, + pool_size: int = 5, + pool_timeout: float = 10.0, + echo: bool = False, + encoding: str = "utf-8", + isolation_level: str = "READ COMMITTED", + ) -> None: + self.host = host + self.port = port + + def connect(self) -> bool: + \"\"\"Open a connection to the server.\"\"\" + return True + """ +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + import pathlib + import sys + + sys.path.insert(0, str(pathlib.Path(__file__).parent)) + + extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx_autodoc_api_style", + "sphinx_autodoc_layout", + ] + + gal_enabled = True + gal_fold_parameters = True + gal_collapsed_threshold = 10 + """ +) + +_INDEX_RST = textwrap.dedent( + """\ + Layout Demo + =========== + + .. autoclass:: gal_demo_api.LayoutDemo + :members: + :special-members: __init__ + """ +) + + +def _build_layout_demo(tmp_path: pathlib.Path) -> str: + srcdir = tmp_path / "src" + outdir = tmp_path / "out" + doctreedir = tmp_path / "doctrees" + srcdir.mkdir() + outdir.mkdir() + doctreedir.mkdir() + + (srcdir / "gal_demo_api.py").write_text(_MODULE_SOURCE, encoding="utf-8") + (srcdir / "conf.py").write_text(_CONF_PY, encoding="utf-8") + (srcdir / "index.rst").write_text(_INDEX_RST, encoding="utf-8") + + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + status=io.StringIO(), + warning=io.StringIO(), + freshenv=True, + ) + app.build() + return (outdir / "index.html").read_text(encoding="utf-8") + + +@pytest.mark.integration +def test_layout_demo_renders_api_component_contract(tmp_path: pathlib.Path) -> None: + html = _build_layout_demo(tmp_path) + + assert re.search(r'
', html) + assert re.search(r'
', html) + assert 'class="api-description gal-region gal-region--narrative"' in html + assert 'class="api-parameters gal-region gal-region--fields"' in html + assert 'class="api-footer gal-region gal-region--members"' in html + assert '
' in html + assert 'class="gal-sig-fold"' not in html + + init_match = re.search( + r'
(.*?)
', + html, + re.DOTALL, + ) + assert init_match is not None + init_html = init_match.group(1) + + assert 'class="api-layout"' in init_html + assert 'class="api-layout-left"' in init_html + assert 'class="api-layout-right gas-toolbar"' in init_html + assert 'class="api-signature"' in init_html + assert 'class="headerlink api-link"' in init_html + assert 'class="api-badge-container"' in init_html + assert 'class="api-source-link"' in init_html + assert 'class="api-signature-panel gal-sig-panel"' in init_html + assert ( + 'aria-controls="gal_demo_api.LayoutDemo.__init__--signature-panel"' in init_html + ) + assert 'id="gal_demo_api.LayoutDemo.__init__--signature-panel"' in init_html + assert "[source]" in init_html + assert "host" in init_html + + +@pytest.mark.integration +def test_layout_demo_members_stay_in_api_footer(tmp_path: pathlib.Path) -> None: + html = _build_layout_demo(tmp_path) + + footer_start = html.find('class="api-footer gal-region gal-region--members"') + assert footer_start != -1 + footer_html = html[footer_start:] + + assert "gal_demo_api.LayoutDemo.__init__" in footer_html + assert "gal_demo_api.LayoutDemo.connect" in footer_html diff --git a/tests/ext/layout/test_nodes.py b/tests/ext/layout/test_nodes.py index 9535aa9f..f85cf76a 100644 --- a/tests/ext/layout/test_nodes.py +++ b/tests/ext/layout/test_nodes.py @@ -3,7 +3,14 @@ from __future__ import annotations from docutils import nodes -from sphinx_autodoc_layout._nodes import gal_fold, gal_region +from sphinx_autodoc_layout._nodes import ( + api_component, + api_inline_component, + api_permalink, + gal_fold, + gal_region, + gal_sig_fold, +) def test_gal_region_is_general_element() -> None: @@ -33,3 +40,28 @@ def test_gal_fold_stores_attributes() -> None: def test_gal_fold_default_open_is_falsy() -> None: f = gal_fold(kind="parameters", summary="P (1)") assert not f.get("open") + + +def test_api_component_stores_name_and_tag() -> None: + node = api_component(name="api-layout", tag="div") + assert node.get("name") == "api-layout" + assert node.get("tag") == "div" + + +def test_api_permalink_stores_href() -> None: + link = api_permalink(href="#demo.func", title="Link to this definition") + assert link.get("href") == "#demo.func" + assert link.get("title") == "Link to this definition" + + +def test_api_inline_component_stores_name_and_tag() -> None: + node = api_inline_component(name="api-source-link", tag="span") + assert node.get("name") == "api-source-link" + assert node.get("tag") == "span" + + +def test_gal_sig_fold_stores_panel_id() -> None: + fold = gal_sig_fold(first_param="host", param_count=13, panel_id="sig-panel") + assert fold.get("first_param") == "host" + assert fold.get("param_count") == 13 + assert fold.get("panel_id") == "sig-panel" diff --git a/tests/ext/layout/test_transforms.py b/tests/ext/layout/test_transforms.py index c29352b8..5e54ac89 100644 --- a/tests/ext/layout/test_transforms.py +++ b/tests/ext/layout/test_transforms.py @@ -2,26 +2,36 @@ from __future__ import annotations +import typing as t + from docutils import nodes from sphinx import addnodes -from sphinx_autodoc_layout._nodes import gal_fold, gal_region +from sphinx_autodoc_layout._nodes import ( + api_component, + api_inline_component, + api_permalink, + gal_fold, + gal_sig_fold, +) from sphinx_autodoc_layout._transforms import ( _classify_child, _count_field_entries, _fold_large_field_regions, + _nest_python_members, + _rebuild_signature_layout, _wrap_content_runs, ) -# -- helpers ----------------------------------------------------------------- - def _make_desc( *content_children: nodes.Node, domain: str = "py", objtype: str = "function", + ids: tuple[str, ...] = (), ) -> addnodes.desc: desc = addnodes.desc(domain=domain, objtype=objtype) - desc += addnodes.desc_signature() + signature = addnodes.desc_signature(ids=list(ids)) + desc += signature content = addnodes.desc_content() for child in content_children: content += child @@ -32,14 +42,52 @@ def _make_desc( def _make_field_list(num_fields: int = 5) -> nodes.field_list: fl = nodes.field_list() for i in range(num_fields): - f = nodes.field() - f += nodes.field_name("", f"param{i}") - f += nodes.field_body("", nodes.paragraph("", f"desc {i}")) - fl += f + field = nodes.field() + field += nodes.field_name("", f"param{i}") + field += nodes.field_body("", nodes.paragraph("", f"desc {i}")) + fl += field return fl -# -- _classify_child -------------------------------------------------------- +def _make_sphinx_field_list(num_params: int) -> nodes.field_list: + fl = nodes.field_list() + field = nodes.field() + field += nodes.field_name("", "Parameters") + body = nodes.field_body() + bullets = nodes.bullet_list() + for i in range(num_params): + bullets += nodes.list_item("", nodes.paragraph("", f"param{i}")) + body += bullets + field += body + fl += field + return fl + + +def _make_parameter_list(num_params: int = 2) -> addnodes.desc_parameterlist: + plist = addnodes.desc_parameterlist() + for i in range(num_params): + plist += addnodes.desc_parameter("", f"param{i}") + return plist + + +def _make_toolbar() -> nodes.inline: + toolbar = nodes.inline(classes=["gas-toolbar"]) + badge_group = nodes.inline(classes=["gas-badge-group"]) + badge_group += nodes.inline("", "method", classes=["gas-badge"]) + source_span = nodes.inline(classes=["viewcode-link"]) + source_span += nodes.Text("[source]") + source_ref = nodes.reference("", "", source_span, internal=False) + toolbar += badge_group + toolbar += source_ref + return toolbar + + +def _child_component_names(node: nodes.Element) -> list[str]: + return [ + child.get("name") + for child in node.children + if isinstance(child, (api_component, api_inline_component)) + ] def test_classify_paragraph_as_narrative() -> None: @@ -58,9 +106,6 @@ def test_classify_note_as_narrative() -> None: assert _classify_child(nodes.note()) == "narrative" -# -- _wrap_content_runs ------------------------------------------------------ - - def test_wrap_groups_narrative() -> None: desc = _make_desc( nodes.paragraph("", "hello"), @@ -69,11 +114,12 @@ def test_wrap_groups_narrative() -> None: _wrap_content_runs(desc) content = desc.children[-1] - assert len(content.children) == 1 - r = content.children[0] - assert isinstance(r, gal_region) - assert r.get("kind") == "narrative" - assert len(r.children) == 2 + assert isinstance(content, addnodes.desc_content) + assert _child_component_names(content) == ["api-description"] + section = content.children[0] + assert isinstance(section, api_component) + assert "gal-region" in section.get("classes", []) + assert len(section.children) == 2 def test_wrap_groups_contiguous_types() -> None: @@ -85,15 +131,15 @@ def test_wrap_groups_contiguous_types() -> None: _wrap_content_runs(desc) content = desc.children[-1] - assert len(content.children) == 3 - r0, r1, r2 = content.children - assert isinstance(r0, gal_region) and r0.get("kind") == "narrative" - assert isinstance(r1, gal_region) and r1.get("kind") == "fields" - assert isinstance(r2, gal_region) and r2.get("kind") == "members" + assert isinstance(content, addnodes.desc_content) + assert _child_component_names(content) == [ + "api-description", + "api-parameters", + "api-footer", + ] def test_wrap_preserves_order() -> None: - """Interleaved types stay in authored order.""" desc = _make_desc( nodes.paragraph("", "intro"), _make_field_list(2), @@ -103,21 +149,25 @@ def test_wrap_preserves_order() -> None: _wrap_content_runs(desc) content = desc.children[-1] - for c in content.children: - assert isinstance(c, gal_region) - kinds = [c.get("kind") for c in content.children if isinstance(c, gal_region)] - assert kinds == ["narrative", "fields", "narrative", "members"] + assert isinstance(content, addnodes.desc_content) + assert _child_component_names(content) == [ + "api-description", + "api-parameters", + "api-description", + "api-footer", + ] def test_wrap_empty_content_noop() -> None: desc = _make_desc() _wrap_content_runs(desc) content = desc.children[-1] + assert isinstance(content, addnodes.desc_content) + assert content.get("classes") == ["api-content"] assert len(content.children) == 0 def test_wrap_non_python_noop() -> None: - """Non-Python desc nodes are still wrapped (wrapping is domain-agnostic).""" desc = _make_desc( nodes.paragraph("", "text"), domain="cpp", @@ -125,26 +175,26 @@ def test_wrap_non_python_noop() -> None: ) _wrap_content_runs(desc) content = desc.children[-1] - assert len(content.children) == 1 - assert isinstance(content.children[0], gal_region) + assert isinstance(content, addnodes.desc_content) + assert _child_component_names(content) == ["api-description"] -def _make_sphinx_field_list(num_params: int) -> nodes.field_list: - """Build a Sphinx-style collapsed field list (single field + bullet_list).""" - fl = nodes.field_list() - f = nodes.field() - f += nodes.field_name("", "Parameters") - body = nodes.field_body() - bl = nodes.bullet_list() - for i in range(num_params): - bl += nodes.list_item("", nodes.paragraph("", f"param{i}")) - body += bl - f += body - fl += f - return fl +def test_nest_python_members_moves_siblings_into_class_content() -> None: + section = nodes.section() + class_desc = _make_desc(objtype="class", ids=("demo.LayoutDemo",)) + method_desc = _make_desc(objtype="method", ids=("demo.LayoutDemo.connect",)) + foreign_desc = _make_desc(objtype="method", ids=("demo.Other.call",)) + section += class_desc + section += method_desc + section += foreign_desc -# -- _count_field_entries ---------------------------------------------------- + _nest_python_members(section) + + assert list(section.children) == [class_desc, foreign_desc] + class_content = class_desc.children[-1] + assert isinstance(class_content, addnodes.desc_content) + assert list(class_content.children) == [method_desc] def test_count_individual_fields() -> None: @@ -157,56 +207,124 @@ def test_count_collapsed_bullet_list() -> None: assert _count_field_entries(fl) == 13 -# -- _fold_large_field_regions ----------------------------------------------- - - def test_fold_wraps_large_field_list() -> None: content = addnodes.desc_content() - region = gal_region(kind="fields") - region += _make_field_list(12) - content += region + section = api_component(name="api-parameters", tag="div") + section["classes"] = ["api-parameters", "gal-region", "gal-region--fields"] + section += _make_field_list(12) + content += section _fold_large_field_regions(content, threshold=10) - fold = region.children[0] + fold = section.children[0] assert isinstance(fold, gal_fold) assert fold.get("summary") == "Parameters (12)" assert isinstance(fold.children[0], nodes.field_list) def test_fold_wraps_sphinx_collapsed_field_list() -> None: - """Sphinx-style field list (single field with bullet_list) gets folded.""" content = addnodes.desc_content() - region = gal_region(kind="fields") - region += _make_sphinx_field_list(13) - content += region + section = api_component(name="api-parameters", tag="div") + section["classes"] = ["api-parameters", "gal-region", "gal-region--fields"] + section += _make_sphinx_field_list(13) + content += section _fold_large_field_regions(content, threshold=10) - fold = region.children[0] + fold = section.children[0] assert isinstance(fold, gal_fold) assert fold.get("summary") == "Parameters (13)" def test_fold_skips_small_field_list() -> None: content = addnodes.desc_content() - region = gal_region(kind="fields") + section = api_component(name="api-parameters", tag="div") + section["classes"] = ["api-parameters", "gal-region", "gal-region--fields"] fl = _make_field_list(5) - region += fl - content += region + section += fl + content += section _fold_large_field_regions(content, threshold=10) - assert isinstance(region.children[0], nodes.field_list) - assert not isinstance(region.children[0], gal_fold) + assert isinstance(section.children[0], nodes.field_list) + assert not isinstance(section.children[0], gal_fold) -def test_fold_skips_narrative_regions() -> None: +def test_fold_skips_non_parameter_sections() -> None: content = addnodes.desc_content() - region = gal_region(kind="narrative") - region += nodes.paragraph("", "text") - content += region + section = api_component(name="api-description", tag="div") + section["classes"] = ["api-description", "gal-region", "gal-region--narrative"] + section += nodes.paragraph("", "text") + content += section _fold_large_field_regions(content, threshold=1) - assert isinstance(region.children[0], nodes.paragraph) + assert isinstance(section.children[0], nodes.paragraph) + + +def test_rebuild_signature_layout_splits_toolbar_and_permalink() -> None: + sig = addnodes.desc_signature(ids=["demo.func"]) + sig += addnodes.desc_name("", "func") + sig += _make_parameter_list(2) + sig += _make_toolbar() + + _rebuild_signature_layout(sig, threshold=10, include_permalink=True) + + assert len(sig.children) == 1 + layout = sig.children[0] + assert isinstance(layout, api_component) + assert layout.get("name") == "api-layout" + + left, right = layout.children + assert isinstance(left, api_component) + assert left.get("name") == "api-layout-left" + assert isinstance(right, api_component) + assert right.get("name") == "api-layout-right" + + signature = left.children[0] + assert isinstance(signature, api_component) + assert signature.get("name") == "api-signature" + assert any(isinstance(child, api_permalink) for child in signature.children) + assert any( + isinstance(child, addnodes.desc_parameterlist) for child in signature.children + ) + + assert _child_component_names(right) == ["api-badge-container", "api-source-link"] + + +def test_rebuild_signature_layout_creates_fold_panel_for_large_signature() -> None: + sig = addnodes.desc_signature(ids=["demo.LayoutDemo.__init__"]) + sig += addnodes.desc_name("", "__init__") + sig += _make_parameter_list(13) + sig += _make_toolbar() + + _rebuild_signature_layout(sig, threshold=10, include_permalink=True) + + layout = sig.children[0] + assert isinstance(layout, api_component) + left = layout.children[0] + assert isinstance(left, api_component) + assert _child_component_names(left) == ["api-signature", "api-signature-panel"] + + signature = left.children[0] + panel = left.children[1] + assert isinstance(signature, api_component) + assert isinstance(panel, api_component) + + assert any(isinstance(child, gal_sig_fold) for child in signature.children) + assert not any( + isinstance(child, addnodes.desc_parameterlist) for child in signature.children + ) + html_attrs = t.cast(dict[str, str], panel.get("html_attrs", {})) + assert html_attrs.get("id") == ("demo.LayoutDemo.__init__--signature-panel") + assert isinstance(panel.children[0], addnodes.desc_parameterlist) + + +def test_rebuild_signature_layout_skips_multiline_signatures() -> None: + sig = addnodes.desc_signature(ids=["demo.func"], is_multiline=True) + sig += addnodes.desc_signature_line() + original = list(sig.children) + + _rebuild_signature_layout(sig, threshold=10, include_permalink=True) + + assert list(sig.children) == original From 98cd71745ee2da3f03fa60ae565c13d5f58a2e34 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 05:05:57 -0500 Subject: [PATCH 007/174] py(deps[dev]): Add Syrupy for HTML snapshot coverage why: We need focused snapshot tests for rendered autodoc HTML, and the user asked for the dependency to land in its own isolated commit. what: - Add Syrupy to the dev dependency group - Refresh uv.lock for the new snapshot testing dependency --- pyproject.toml | 1 + uv.lock | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index db0b8167..487ad500 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dev = [ "typing-extensions", "types-docutils", "types-Pygments", + "syrupy>=5.1.0", ] [build-system] diff --git a/uv.lock b/uv.lock index d6dbe376..08190adc 100644 --- a/uv.lock +++ b/uv.lock @@ -476,6 +476,7 @@ dev = [ { name = "sphinx-autodoc-layout" }, { name = "sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx" }, + { name = "syrupy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "types-docutils" }, { name = "types-pygments" }, @@ -506,6 +507,7 @@ dev = [ { name = "sphinx-autodoc-layout", editable = "packages/sphinx-autodoc-layout" }, { name = "sphinx-autodoc-pytest-fixtures", editable = "packages/sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx", editable = "packages/sphinx-autodoc-sphinx" }, + { name = "syrupy", specifier = ">=5.1.0" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "types-docutils" }, { name = "types-pygments" }, @@ -1589,6 +1591,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "syrupy" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/b0/24bca682d6a6337854be37f242d116cceeda9942571d5804c44bc1bdd427/syrupy-5.1.0.tar.gz", hash = "sha256:df543c7aa50d3cf1246e83d58fe490afe5f7dab7b41e74ecc0d8d23ae19bd4b8", size = 50495, upload-time = "2026-01-25T14:53:06.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/70/cf880c3b95a6034ef673e74b369941b42315c01f1554a5637a4f8b911009/syrupy-5.1.0-py3-none-any.whl", hash = "sha256:95162d2b05e61ed3e13f117b88dfab7c58bd6f90e66ebbf918e8a77114ad51c5", size = 51658, upload-time = "2026-01-25T14:53:05.105Z" }, +] + [[package]] name = "tomli" version = "2.4.1" From 4272b883e72c9b23b0a9a007b678ca19c91cc183 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 06:12:58 -0500 Subject: [PATCH 008/174] layout(fix[signature]): Polish folded multiline signature output why: Expanded folded signatures needed cleaner formatting, explicit collapse affordances, and stronger regression coverage to keep the new API layout maintainable. what: - Rework folded signature output around Sphinx's native multiline parameter HTML with typed annotation recovery - Add explicit collapse controls and CSS fixes for single-indent multiline rendering - Cover the rendered API header with integration checks and Syrupy-backed HTML snapshots --- docs/packages/sphinx-autodoc-layout.md | 4 +- packages/sphinx-autodoc-layout/README.md | 6 +- .../src/sphinx_autodoc_layout/__init__.py | 3 + .../src/sphinx_autodoc_layout/_nodes.py | 8 +- .../_static/css/layout.css | 55 +-- .../_static/js/layout.js | 53 ++- .../src/sphinx_autodoc_layout/_transforms.py | 312 ++++++++++++++++-- .../src/sphinx_autodoc_layout/_visitors.py | 8 +- .../layout/__snapshots__/test_snapshots.ambr | 51 +++ tests/ext/layout/test_css.py | 34 ++ tests/ext/layout/test_integration.py | 72 +++- tests/ext/layout/test_snapshots.py | 48 +++ tests/ext/layout/test_transforms.py | 193 +++++++++-- 13 files changed, 742 insertions(+), 105 deletions(-) create mode 100644 tests/ext/layout/__snapshots__/test_snapshots.ambr create mode 100644 tests/ext/layout/test_css.py create mode 100644 tests/ext/layout/test_snapshots.py diff --git a/docs/packages/sphinx-autodoc-layout.md b/docs/packages/sphinx-autodoc-layout.md index 74041566..e2e51c75 100644 --- a/docs/packages/sphinx-autodoc-layout.md +++ b/docs/packages/sphinx-autodoc-layout.md @@ -7,7 +7,8 @@ Wraps contiguous `desc_content` runs into semantic `gal_region` nodes and rebuilds Python autodoc entries into stable `api-*` components. Large field-list parameter sections still use native `
/`, -while inline signature expansion uses a custom disclosure layout. +while inline signature expansion uses a custom disclosure that reveals +Sphinx's native multiline parameter-list rendering. ## Live demo @@ -40,6 +41,7 @@ The class above should render with: | `gal_enabled` | `False` | Enables the transform | | `gal_fold_parameters` | `True` | Folds large field-list sections | | `gal_collapsed_threshold` | `10` | Minimum field count before folding | +| `gal_signature_show_annotations` | `True` | Shows `name: type` in expanded folded signatures when type data is available | ## CSS classes diff --git a/packages/sphinx-autodoc-layout/README.md b/packages/sphinx-autodoc-layout/README.md index cd20d250..59907663 100644 --- a/packages/sphinx-autodoc-layout/README.md +++ b/packages/sphinx-autodoc-layout/README.md @@ -3,5 +3,7 @@ Componentized layout for Sphinx autodoc output. It preserves Sphinx's outer `dl / dt / dd` structure while rebuilding managed Python autodoc entries into stable `api-*` components, folding block parameter sections -with native `
`, and rendering inline signature disclosure with a -custom two-row layout. +with native `
`, and rendering inline signature disclosure with +Sphinx's native multiline parameter-list markup. Expanded folded +signatures show annotations by default and can be configured with +`gal_signature_show_annotations`. diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py index dbc7b7e8..dc90b7ba 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/__init__.py @@ -73,6 +73,9 @@ def setup(app: Sphinx) -> dict[str, t.Any]: app.add_config_value( "gal_collapsed_threshold", default=10, rebuild="env", types=(int,) ) + app.add_config_value( + "gal_signature_show_annotations", default=True, rebuild="env", types=(bool,) + ) # Custom nodes with HTML visitors + passthrough for other builders _pt = (passthrough_visit, passthrough_depart) diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py index d0590b04..4d3f62e2 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_nodes.py @@ -119,9 +119,9 @@ class api_permalink(nodes.General, nodes.Element): class gal_sig_fold(nodes.General, nodes.Element): """Inline signature disclosure toggle for large parameter lists. - The preview button lives in the signature row, while the full - parameter list is rendered in a sibling ``api-signature-panel`` - wrapper beneath it. + The preview button lives in the signature row, while the expanded + multiline signature content is rendered in a controlled wrapper + inside ``api-signature``. Parameters ---------- @@ -130,7 +130,7 @@ class gal_sig_fold(nodes.General, nodes.Element): param_count : int Total number of parameters. panel_id : str - DOM id of the controlled signature panel. + DOM id of the controlled expanded signature wrapper. Examples -------- diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css index 12cddd89..9a18ec87 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css @@ -28,6 +28,9 @@ dl.py:not(.fixture).api-container > dt.api-header > .api-layout { dl.py:not(.fixture).api-container > dt.api-header .api-layout-left { flex: 1 1 auto; + display: flex; + align-items: flex-start; + gap: 0.25rem; min-width: 0; } @@ -51,15 +54,14 @@ dl.py:not(.fixture).api-container > dt.api-header .api-source-link { /* ── Signature row ──────────────────────────────────── */ dl.py:not(.fixture).api-container > dt.api-header .api-signature { - display: flex; - align-items: center; - gap: 0.35rem; - flex-wrap: wrap; + flex: 1 1 auto; + font-family: var(--font-stack--monospace); min-width: 0; } dl.py:not(.fixture).api-container > dt.api-header .api-link { margin-left: 0.1rem; + flex: 0 0 auto; } .api-signature-toggle { @@ -88,39 +90,44 @@ dl.py:not(.fixture).api-container > dt.api-header .api-link { display: none; } -/* ── Expanded signature panel ───────────────────────── */ -.api-signature-panel { - font-family: var(--font-stack--monospace); - font-size: 0; - margin-top: 0.45rem; - width: 100%; +/* ── Expanded signature wrapper ─────────────────────── */ +.api-signature-expanded { + display: contents; } -.api-signature-panel[hidden] { +.api-signature-expanded[hidden] { display: none; } -.api-signature-panel > .sig-paren { - display: block; - font-size: 1rem; +.api-signature-expanded > dl { + margin: 0; + padding-inline-start: var(--gal-signature-indent, 1rem); } -.api-signature-panel > .sig-paren:first-child { - display: none; +.api-signature-expanded > dl > dd { + margin: 0; + margin-inline-start: 0 !important; + margin-left: 0 !important; } -.api-signature-panel em.sig-param { - display: block; - font-size: 1rem; - margin-left: 1rem; +.api-signature-expanded > .sig-paren:last-of-type { + margin-right: 0.35rem; } -.api-signature-panel em.sig-param::after { - content: ","; +.gal-sig-collapse { + appearance: none; + background: none; + border: 0; + color: var(--color-foreground-muted); + cursor: pointer; + display: inline-flex; + font: inherit; + padding: 0; } -.api-signature-panel em.sig-param:last-of-type::after { - content: ""; +.gal-sig-collapse:hover, +.gal-sig-collapse:focus-visible { + color: var(--color-link); } /* ── Field-list grid ────────────────────────────────── */ diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js index 606f3338..d1c5dc08 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js @@ -2,33 +2,50 @@ * sphinx_autodoc_layout — layout.js * * Hash-based auto-expansion for both block
folds and the - * custom api-signature disclosure panel. + * custom api-signature disclosure wrapper. */ (function () { 'use strict'; - function setSignatureExpanded(button, expanded) { - if (!button) return; + function syncSignatureControls(expandedId, expanded) { + document + .querySelectorAll('.api-signature-toggle, .gal-sig-collapse') + .forEach(function (control) { + if (control.getAttribute('aria-controls') !== expandedId) return; + control.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + }); + } - var panelId = button.getAttribute('aria-controls'); - if (!panelId) return; + function setSignatureExpandedById(expandedId, expanded) { + if (!expandedId) return; + + var expandedPanel = document.getElementById(expandedId); + if (!expandedPanel) return; + + var signature = expandedPanel.closest('.api-signature'); + if (signature) { + signature.setAttribute('data-expanded', expanded ? 'true' : 'false'); + } - var panel = document.getElementById(panelId); - if (!panel) return; + syncSignatureControls(expandedId, expanded); - button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); if (expanded) { - panel.hidden = false; - panel.setAttribute('data-expanded', 'true'); - panel.setAttribute('aria-hidden', 'false'); + expandedPanel.hidden = false; + expandedPanel.setAttribute('data-expanded', 'true'); + expandedPanel.setAttribute('aria-hidden', 'false'); } else { - panel.hidden = true; - panel.setAttribute('data-expanded', 'false'); - panel.setAttribute('aria-hidden', 'true'); + expandedPanel.hidden = true; + expandedPanel.setAttribute('data-expanded', 'false'); + expandedPanel.setAttribute('aria-hidden', 'true'); } } + function setSignatureExpanded(button, expanded) { + if (!button) return; + setSignatureExpandedById(button.getAttribute('aria-controls'), expanded); + } + function expandAncestors(target) { var node = target; while (node) { @@ -50,10 +67,10 @@ if (!header) return; - var button = header.querySelector('.api-signature-toggle'); - if (!button) return; + var expandedPanel = header.querySelector('.api-signature-expanded'); + if (!expandedPanel || !expandedPanel.id) return; - setSignatureExpanded(button, true); + setSignatureExpandedById(expandedPanel.id, true); } function expandForHash() { @@ -73,7 +90,7 @@ } document.addEventListener('click', function (event) { - var button = event.target.closest('.api-signature-toggle'); + var button = event.target.closest('.api-signature-toggle, .gal-sig-collapse'); if (!button) return; event.preventDefault(); diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py index 9021d068..0884bf97 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py @@ -116,7 +116,9 @@ def _make_api_inline_component( def _make_api_permalink(desc_sig: addnodes.desc_signature) -> api_permalink | None: """Create the managed permalink node for a signature.""" - ids = list(desc_sig.get("ids", [])) + ids: list[str] = [ + str(node_id) for node_id in t.cast(list[t.Any], desc_sig.get("ids", [])) + ] if not ids: return None link = api_permalink( @@ -131,7 +133,9 @@ def _primary_signature_id(desc_node: addnodes.desc) -> str | None: """Return the first signature id for a ``desc`` node.""" for child in desc_node.children: if isinstance(child, addnodes.desc_signature): - ids = list(child.get("ids", [])) + ids: list[str] = [ + str(node_id) for node_id in t.cast(list[t.Any], child.get("ids", [])) + ] if ids: return ids[0] return None @@ -348,9 +352,9 @@ def _count_field_entries(field_list: nodes.field_list) -> int: def _is_parameters_section(node: nodes.Node) -> bool: """Return ``True`` when *node* is an API parameters section.""" if isinstance(node, api_component): - return node.get("name") == "api-parameters" + return str(node.get("name", "")) == "api-parameters" if isinstance(node, gal_region): - return node.get("kind") == "fields" + return str(node.get("kind", "")) == "fields" return False @@ -388,20 +392,268 @@ def _is_viewcode_ref(node: nodes.Node) -> bool: ) +def _parameter_key(text: str) -> str: + """Return a normalized parameter key for preview and type lookup. + + Examples + -------- + >>> _parameter_key("host: str") + 'host' + >>> _parameter_key("port: int = 5432") + 'port' + """ + key = text.strip().rstrip(",") + for separator in (":", "="): + if separator in key: + key = key.split(separator, 1)[0].rstrip() + return key.strip() + + def _count_signature_parameters( parameter_list: addnodes.desc_parameterlist, ) -> tuple[str, int]: """Return the first parameter text and total parameter count.""" params = list(parameter_list.findall(addnodes.desc_parameter)) - first = params[0].astext().strip() if params else "" + first = _parameter_key(params[0].astext()) if params else "" return first, len(params) -def _signature_panel_id(desc_sig: addnodes.desc_signature) -> str: - """Return the stable DOM id for an expanded signature panel.""" - ids = list(desc_sig.get("ids", [])) +def _signature_expanded_id(desc_sig: addnodes.desc_signature) -> str: + """Return the stable DOM id for an expanded signature wrapper.""" + ids: list[str] = [ + str(node_id) for node_id in t.cast(list[t.Any], desc_sig.get("ids", [])) + ] base = ids[0] if ids else "api-signature" - return f"{base}--signature-panel" + return f"{base}--signature-expanded" + + +def _parameter_has_annotation(parameter: addnodes.desc_parameter) -> bool: + """Return ``True`` when a parameter already contains a type annotation.""" + return any( + isinstance(child, addnodes.desc_sig_punctuation) and ":" in child.astext() + for child in parameter.children + ) + + +def _parameter_default_index(parameter: addnodes.desc_parameter) -> int | None: + """Return the index of the default operator, if present.""" + for index, child in enumerate(parameter.children): + if isinstance(child, addnodes.desc_sig_operator) and child.astext() == "=": + return index + return None + + +def _is_space_node(child: nodes.Node) -> bool: + """Return ``True`` when a node renders as whitespace only.""" + return child.astext().isspace() + + +def _replace_parameter_children( + parameter: addnodes.desc_parameter, + children: list[nodes.Node], +) -> None: + """Replace a parameter's direct children with *children*.""" + parameter.children = [] + for child in children: + parameter += child + + +def _strip_parameter_annotation(parameter: addnodes.desc_parameter) -> None: + """Remove only the parameter annotation while preserving defaults.""" + children = list(parameter.children) + annotation_start = next( + ( + index + for index, child in enumerate(children) + if isinstance(child, addnodes.desc_sig_punctuation) + and ":" in child.astext() + ), + None, + ) + if annotation_start is None: + return + + default_index = _parameter_default_index(parameter) + prefix = children[:annotation_start] + while prefix and _is_space_node(prefix[-1]): + prefix.pop() + + if default_index is None: + _replace_parameter_children(parameter, prefix) + return + + suffix = children[default_index:] + while len(suffix) > 1 and _is_space_node(suffix[1]): + suffix.pop(1) + _replace_parameter_children(parameter, [*prefix, *suffix]) + + +def _make_annotation_nodes(type_nodes: list[nodes.Node]) -> list[nodes.Node]: + """Create signature annotation nodes from cloned *type_nodes*.""" + type_name = addnodes.desc_sig_name("", "") + for child in type_nodes: + type_name += child.deepcopy() + return [ + addnodes.desc_sig_punctuation("", ":"), + addnodes.desc_sig_space("", " "), + type_name, + ] + + +def _inject_parameter_annotation( + parameter: addnodes.desc_parameter, + type_nodes: list[nodes.Node], +) -> None: + """Insert a type annotation before a parameter default, if needed.""" + if not type_nodes or _parameter_has_annotation(parameter): + return + + key = _parameter_key(parameter.astext()) + if key in {"*", "/"}: + return + + children = list(parameter.children) + default_index = _parameter_default_index(parameter) + insert_at = len(children) if default_index is None else default_index + + annotation_children = _make_annotation_nodes(type_nodes) + if default_index is not None: + annotation_children.append(addnodes.desc_sig_space("", " ")) + + children[insert_at:insert_at] = annotation_children + + if default_index is not None: + operator_index = insert_at + len(annotation_children) + next_index = operator_index + 1 + if next_index < len(children) and not _is_space_node(children[next_index]): + children.insert(next_index, addnodes.desc_sig_space("", " ")) + + _replace_parameter_children(parameter, children) + + +def _iter_parameter_paragraphs( + field_body: nodes.field_body, +) -> t.Iterator[nodes.paragraph]: + """Yield parameter paragraphs from a Sphinx ``Parameters`` field body.""" + for child in field_body.children: + if isinstance(child, nodes.bullet_list): + for item in child.children: + if not isinstance(item, nodes.list_item): + continue + for grandchild in item.children: + if isinstance(grandchild, nodes.paragraph): + yield grandchild + elif isinstance(child, nodes.paragraph): + yield child + + +def _extract_type_nodes_from_paragraph( + paragraph: nodes.paragraph, +) -> tuple[str, list[nodes.Node]] | None: + """Extract a parameter name and its type nodes from a field-list paragraph.""" + if not paragraph.children: + return None + + first = paragraph.children[0] + if not isinstance(first, (nodes.strong, addnodes.literal_strong)): + return None + + key = _parameter_key(first.astext()) + if not key: + return None + + collected: list[nodes.Node] = [] + in_type = False + + for child in paragraph.children[1:]: + text = child.astext() + if not in_type: + if "(" not in text: + continue + in_type = True + after_open = text.split("(", 1)[1] + if ")" in after_open: + before_close = after_open.split(")", 1)[0] + if before_close: + collected.append(nodes.Text(before_close)) + break + if after_open: + collected.append(nodes.Text(after_open)) + continue + + if ")" in text: + before_close = text.split(")", 1)[0] + if before_close: + collected.append(nodes.Text(before_close)) + break + collected.append(child.deepcopy()) + + if not collected: + return None + return key, collected + + +def _extract_parameter_types(desc_node: addnodes.desc) -> dict[str, list[nodes.Node]]: + """Collect parameter annotations from the entry's ``Parameters`` field list.""" + content = next( + ( + child + for child in desc_node.children + if isinstance(child, addnodes.desc_content) + ), + None, + ) + if content is None: + return {} + + mapping: dict[str, list[nodes.Node]] = {} + for section in content.children: + if not _is_parameters_section(section): + continue + assert isinstance(section, nodes.Element) + for child in section.children: + if not isinstance(child, nodes.field_list): + continue + for field in child.children: + if not isinstance(field, nodes.field) or len(field.children) < 2: + continue + name = field.children[0] + body = field.children[1] + if not isinstance(name, nodes.field_name) or not isinstance( + body, nodes.field_body + ): + continue + if name.astext().strip().casefold() != "parameters": + continue + for paragraph in _iter_parameter_paragraphs(body): + extracted = _extract_type_nodes_from_paragraph(paragraph) + if extracted is None: + continue + key, type_nodes = extracted + mapping[key] = type_nodes + return mapping + + +def _prepare_folded_parameter_list( + parameter_list: addnodes.desc_parameterlist, + *, + parameter_types: dict[str, list[nodes.Node]], + show_annotations: bool, +) -> None: + """Convert a parameter list to Sphinx's multiline signature rendering.""" + parameter_list["multi_line_parameter_list"] = True + parameter_list["multi_line_trailing_comma"] = False + + for parameter in parameter_list.findall(addnodes.desc_parameter): + key = _parameter_key(parameter.astext()) + if show_annotations: + if _parameter_has_annotation(parameter): + continue + type_nodes = parameter_types.get(key) + if type_nodes is not None: + _inject_parameter_annotation(parameter, type_nodes) + continue + _strip_parameter_annotation(parameter) def _extract_toolbar_content( @@ -425,10 +677,12 @@ def _extract_toolbar_content( def _rebuild_signature_layout( + desc_node: addnodes.desc, desc_sig: addnodes.desc_signature, *, threshold: int, include_permalink: bool, + show_annotations: bool, ) -> None: """Rebuild a signature into explicit API header subcomponents.""" if desc_sig.get("is_multiline"): @@ -465,23 +719,27 @@ def _rebuild_signature_layout( left = _make_api_component("api-layout-left") signature = _make_api_component("api-signature") right = _make_api_component("api-layout-right", classes=("gas-toolbar",)) - - panel: api_component | None = None + parameter_types = _extract_parameter_types(desc_node) folded = False for child in row_children: if not folded and isinstance(child, addnodes.desc_parameterlist): first_param, param_count = _count_signature_parameters(child) if param_count >= threshold: - panel_id = _signature_panel_id(desc_sig) + panel_id = _signature_expanded_id(desc_sig) signature += gal_sig_fold( first_param=first_param, param_count=param_count, panel_id=panel_id, ) - panel = _make_api_component( - "api-signature-panel", - classes=("gal-sig-panel",), + _prepare_folded_parameter_list( + child, + parameter_types=parameter_types, + show_annotations=show_annotations, + ) + expanded = _make_api_component( + "api-signature-expanded", + classes=("gal-sig-expanded",), html_attrs={ "aria-hidden": "true", "data-expanded": "false", @@ -489,19 +747,28 @@ def _rebuild_signature_layout( "id": panel_id, }, ) - panel += child + expanded += child + collapse = _make_api_inline_component( + "gal-sig-collapse", + tag="button", + html_attrs={ + "aria-controls": panel_id, + "aria-expanded": "true", + "type": "button", + }, + ) + collapse += nodes.Text("[collapse]") + expanded += collapse + signature += expanded folded = True continue signature += child + left += signature if include_permalink: permalink = _make_api_permalink(desc_sig) if permalink is not None: - signature += permalink - - left += signature - if panel is not None: - left += panel + left += permalink if badge_children: badge_container = _make_api_inline_component("api-badge-container") @@ -542,6 +809,7 @@ def on_doctree_resolved( threshold: int = app.config.gal_collapsed_threshold fold_params: bool = app.config.gal_fold_parameters + show_annotations: bool = app.config.gal_signature_show_annotations include_permalink = bool( app.config.html_permalinks and getattr(app.builder, "add_permalinks", False) ) @@ -564,9 +832,11 @@ def on_doctree_resolved( _append_class(child, "api-header") child["api_managed"] = not child.get("is_multiline", False) _rebuild_signature_layout( + desc_node, child, threshold=threshold if allow_signature_fold else 10**9, include_permalink=include_permalink, + show_annotations=show_annotations, ) if not allow_signature_fold: diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py index 33dccf70..641b0711 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py @@ -34,7 +34,7 @@ def _html_attrs(node: nodes.Element) -> dict[str, str]: """Return sanitized HTML attributes stored on a custom node.""" - attrs = node.get("html_attrs", {}) + attrs: t.Any = node.get("html_attrs", {}) return {str(key): str(value) for key, value in attrs.items()} @@ -58,7 +58,8 @@ def visit_api_component(self: HTML5Translator, node: nodes.Element) -> None: tag = node.get("tag", "div") attrs = _html_attrs(node) ids = [attrs.pop("id")] if "id" in attrs else [] - self.body.append(self.starttag(node, tag, "", ids=ids, **attrs)) + starttag = t.cast(t.Any, self).starttag + self.body.append(starttag(node, tag, "", ids=ids, **attrs)) def depart_api_component(self: HTML5Translator, node: nodes.Element) -> None: @@ -106,8 +107,9 @@ def visit_gal_sig_fold(self: HTML5Translator, node: nodes.Element) -> None: first = html.escape(node.get("first_param", "")) panel_id = node.get("panel_id", "") preview = first if first else "..." + starttag = t.cast(t.Any, self).starttag self.body.append( - self.starttag( + starttag( node, "button", "", diff --git a/tests/ext/layout/__snapshots__/test_snapshots.ambr b/tests/ext/layout/__snapshots__/test_snapshots.ambr new file mode 100644 index 00000000..eb9c6efe --- /dev/null +++ b/tests/ext/layout/__snapshots__/test_snapshots.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_layout_demo_init_header_snapshot_annotated[annotated] + ''' +
+
__init__ None
method[source]
+ ''' +# --- +# name: test_layout_demo_init_header_snapshot_annotation_disabled[annotation_disabled] + ''' +
+
+ ''' +# --- diff --git a/tests/ext/layout/test_css.py b/tests/ext/layout/test_css.py new file mode 100644 index 00000000..fb69f3d1 --- /dev/null +++ b/tests/ext/layout/test_css.py @@ -0,0 +1,34 @@ +"""Regression tests for layout CSS rules.""" + +from __future__ import annotations + +import pathlib + +_LAYOUT_CSS = pathlib.Path( + "packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css" +) + + +def test_signature_expanded_uses_contents_layout() -> None: + css = _LAYOUT_CSS.read_text(encoding="utf-8") + + assert ".api-signature-expanded {\n display: contents;\n}" in css + + +def test_signature_multiline_list_uses_padding_indent() -> None: + css = _LAYOUT_CSS.read_text(encoding="utf-8") + + assert "padding-inline-start: var(--gal-signature-indent, 1rem);" in css + + +def test_signature_multiline_list_clears_theme_dd_indent() -> None: + css = _LAYOUT_CSS.read_text(encoding="utf-8") + + assert "margin-inline-start: 0 !important;" in css + assert "margin-left: 0 !important;" in css + + +def test_signature_css_does_not_force_sig_param_block_layout() -> None: + css = _LAYOUT_CSS.read_text(encoding="utf-8") + + assert ".api-signature-expanded em.sig-param" not in css diff --git a/tests/ext/layout/test_integration.py b/tests/ext/layout/test_integration.py index 029d520f..3d9cd513 100644 --- a/tests/ext/layout/test_integration.py +++ b/tests/ext/layout/test_integration.py @@ -109,7 +109,22 @@ def connect(self) -> bool: ) -def _build_layout_demo(tmp_path: pathlib.Path) -> str: +def _extract_init_header(html: str) -> str: + """Return the ``LayoutDemo.__init__`` header fragment.""" + init_match = re.search( + r'
(.*?)
', + html, + re.DOTALL, + ) + assert init_match is not None + return init_match.group(1).strip() + + +def _build_layout_demo( + tmp_path: pathlib.Path, + *, + extra_conf: str = "", +) -> str: srcdir = tmp_path / "src" outdir = tmp_path / "out" doctreedir = tmp_path / "doctrees" @@ -118,7 +133,10 @@ def _build_layout_demo(tmp_path: pathlib.Path) -> str: doctreedir.mkdir() (srcdir / "gal_demo_api.py").write_text(_MODULE_SOURCE, encoding="utf-8") - (srcdir / "conf.py").write_text(_CONF_PY, encoding="utf-8") + conf_text = _CONF_PY + if extra_conf: + conf_text = f"{conf_text.rstrip()}\n{extra_conf}\n" + (srcdir / "conf.py").write_text(conf_text, encoding="utf-8") (srcdir / "index.rst").write_text(_INDEX_RST, encoding="utf-8") app = Sphinx( @@ -147,13 +165,7 @@ def test_layout_demo_renders_api_component_contract(tmp_path: pathlib.Path) -> N assert '
' in html assert 'class="gal-sig-fold"' not in html - init_match = re.search( - r'
(.*?)
', - html, - re.DOTALL, - ) - assert init_match is not None - init_html = init_match.group(1) + init_html = _extract_init_header(html) assert 'class="api-layout"' in init_html assert 'class="api-layout-left"' in init_html @@ -162,13 +174,27 @@ def test_layout_demo_renders_api_component_contract(tmp_path: pathlib.Path) -> N assert 'class="headerlink api-link"' in init_html assert 'class="api-badge-container"' in init_html assert 'class="api-source-link"' in init_html - assert 'class="api-signature-panel gal-sig-panel"' in init_html + assert 'class="api-signature-expanded gal-sig-expanded"' in init_html assert ( - 'aria-controls="gal_demo_api.LayoutDemo.__init__--signature-panel"' in init_html + 'aria-controls="gal_demo_api.LayoutDemo.__init__--signature-expanded"' + in init_html + ) + assert 'id="gal_demo_api.LayoutDemo.__init__--signature-expanded"' in init_html + assert "
" in init_html + assert '(' in init_html + assert ')' in init_html + assert 'class="gal-sig-collapse"' in init_html + assert "[collapse]" in init_html + assert re.search( + r'\)\s*]*class="[^"]*gal-sig-collapse[^"]*"', + init_html, ) - assert 'id="gal_demo_api.LayoutDemo.__init__--signature-panel"' in init_html - assert "[source]" in init_html assert "host" in init_html + assert "port" in init_html + assert "str" in init_html + assert "int" in init_html + assert "[source]" in init_html + assert "api-signature-panel" not in init_html @pytest.mark.integration @@ -181,3 +207,23 @@ def test_layout_demo_members_stay_in_api_footer(tmp_path: pathlib.Path) -> None: assert "gal_demo_api.LayoutDemo.__init__" in footer_html assert "gal_demo_api.LayoutDemo.connect" in footer_html + + +@pytest.mark.integration +def test_layout_demo_can_hide_folded_signature_annotations( + tmp_path: pathlib.Path, +) -> None: + html = _build_layout_demo( + tmp_path, + extra_conf="gal_signature_show_annotations = False", + ) + + init_html = _extract_init_header(html) + + assert 'class="api-signature-expanded gal-sig-expanded"' in init_html + assert "host" in init_html + assert "port" in init_html + assert "portstr' not in init_html + assert 'int' not in init_html diff --git a/tests/ext/layout/test_snapshots.py b/tests/ext/layout/test_snapshots.py new file mode 100644 index 00000000..5529a5e7 --- /dev/null +++ b/tests/ext/layout/test_snapshots.py @@ -0,0 +1,48 @@ +"""Snapshot coverage for rendered layout HTML fragments.""" + +from __future__ import annotations + +import pathlib +import re +import typing as t + +import pytest + +from tests.ext.layout.test_integration import _build_layout_demo + +if t.TYPE_CHECKING: + from syrupy.assertion import SnapshotAssertion + + +def _extract_init_header_fragment(html: str) -> str: + """Return the full ``dt.api-header`` fragment for ``LayoutDemo.__init__``.""" + init_match = re.search( + r'(
.*?
)', + html, + re.DOTALL, + ) + assert init_match is not None + return init_match.group(1).strip() + + +@pytest.mark.integration +def test_layout_demo_init_header_snapshot_annotated( + tmp_path: pathlib.Path, + snapshot: SnapshotAssertion, +) -> None: + html = _build_layout_demo(tmp_path) + + assert _extract_init_header_fragment(html) == snapshot(name="annotated") + + +@pytest.mark.integration +def test_layout_demo_init_header_snapshot_annotation_disabled( + tmp_path: pathlib.Path, + snapshot: SnapshotAssertion, +) -> None: + html = _build_layout_demo( + tmp_path, + extra_conf="gal_signature_show_annotations = False", + ) + + assert _extract_init_header_fragment(html) == snapshot(name="annotation_disabled") diff --git a/tests/ext/layout/test_transforms.py b/tests/ext/layout/test_transforms.py index 5e54ac89..acb0c5c3 100644 --- a/tests/ext/layout/test_transforms.py +++ b/tests/ext/layout/test_transforms.py @@ -63,10 +63,37 @@ def _make_sphinx_field_list(num_params: int) -> nodes.field_list: return fl -def _make_parameter_list(num_params: int = 2) -> addnodes.desc_parameterlist: +def _make_parameter( + name: str, + *, + annotation: str | None = None, + default: str | None = None, +) -> addnodes.desc_parameter: + param = addnodes.desc_parameter() + param += addnodes.desc_sig_name("", name) + if annotation is not None: + type_name = addnodes.desc_sig_name("", "", nodes.emphasis("", annotation)) + param += addnodes.desc_sig_punctuation("", ":") + param += addnodes.desc_sig_space("", " ") + param += type_name + if default is not None: + if annotation is not None: + param += addnodes.desc_sig_space("", " ") + param += addnodes.desc_sig_operator("", "=") + if annotation is not None: + param += addnodes.desc_sig_space("", " ") + param += nodes.inline("", default, classes=["default_value"]) + return param + + +def _make_parameter_list( + num_params: int = 2, + *, + annotation: str | None = None, +) -> addnodes.desc_parameterlist: plist = addnodes.desc_parameterlist() for i in range(num_params): - plist += addnodes.desc_parameter("", f"param{i}") + plist += _make_parameter(f"param{i}", annotation=annotation) return plist @@ -90,6 +117,32 @@ def _child_component_names(node: nodes.Element) -> list[str]: ] +def _make_typed_parameters_field_list(types: dict[str, str]) -> nodes.field_list: + fl = nodes.field_list() + field = nodes.field() + field += nodes.field_name("", "Parameters") + body = nodes.field_body() + bullets = nodes.bullet_list() + for name, type_name in types.items(): + paragraph = nodes.paragraph() + paragraph += addnodes.literal_strong("", name) + paragraph += nodes.Text(" (") + paragraph += nodes.emphasis("", type_name) + paragraph += nodes.Text(")") + bullets += nodes.list_item("", paragraph) + body += bullets + field += body + fl += field + return fl + + +def _find_component(node: nodes.Element, name: str) -> api_component: + for child in node.children: + if isinstance(child, api_component) and child.get("name") == name: + return child + raise AssertionError(f"component not found: {name}") + + def test_classify_paragraph_as_narrative() -> None: assert _classify_child(nodes.paragraph()) == "narrative" @@ -263,12 +316,20 @@ def test_fold_skips_non_parameter_sections() -> None: def test_rebuild_signature_layout_splits_toolbar_and_permalink() -> None: - sig = addnodes.desc_signature(ids=["demo.func"]) + desc = _make_desc(ids=("demo.func",)) + sig = desc.children[0] + assert isinstance(sig, addnodes.desc_signature) sig += addnodes.desc_name("", "func") sig += _make_parameter_list(2) sig += _make_toolbar() - _rebuild_signature_layout(sig, threshold=10, include_permalink=True) + _rebuild_signature_layout( + desc, + sig, + threshold=10, + include_permalink=True, + show_annotations=True, + ) assert len(sig.children) == 1 layout = sig.children[0] @@ -284,7 +345,7 @@ def test_rebuild_signature_layout_splits_toolbar_and_permalink() -> None: signature = left.children[0] assert isinstance(signature, api_component) assert signature.get("name") == "api-signature" - assert any(isinstance(child, api_permalink) for child in signature.children) + assert isinstance(left.children[1], api_permalink) assert any( isinstance(child, addnodes.desc_parameterlist) for child in signature.children ) @@ -292,39 +353,133 @@ def test_rebuild_signature_layout_splits_toolbar_and_permalink() -> None: assert _child_component_names(right) == ["api-badge-container", "api-source-link"] -def test_rebuild_signature_layout_creates_fold_panel_for_large_signature() -> None: - sig = addnodes.desc_signature(ids=["demo.LayoutDemo.__init__"]) +def test_rebuild_signature_layout_uses_expanded_wrapper_for_large_signature() -> None: + desc = _make_desc(ids=("demo.LayoutDemo.__init__",)) + sig = desc.children[0] + assert isinstance(sig, addnodes.desc_signature) sig += addnodes.desc_name("", "__init__") sig += _make_parameter_list(13) sig += _make_toolbar() - _rebuild_signature_layout(sig, threshold=10, include_permalink=True) + _rebuild_signature_layout( + desc, + sig, + threshold=10, + include_permalink=True, + show_annotations=True, + ) layout = sig.children[0] assert isinstance(layout, api_component) left = layout.children[0] assert isinstance(left, api_component) - assert _child_component_names(left) == ["api-signature", "api-signature-panel"] + assert isinstance(left.children[0], api_component) + assert isinstance(left.children[1], api_permalink) signature = left.children[0] - panel = left.children[1] assert isinstance(signature, api_component) - assert isinstance(panel, api_component) - assert any(isinstance(child, gal_sig_fold) for child in signature.children) - assert not any( - isinstance(child, addnodes.desc_parameterlist) for child in signature.children + expanded = _find_component(signature, "api-signature-expanded") + html_attrs = t.cast(dict[str, str], expanded.get("html_attrs", {})) + assert html_attrs.get("id") == ("demo.LayoutDemo.__init__--signature-expanded") + plist = expanded.children[0] + assert isinstance(plist, addnodes.desc_parameterlist) + assert plist.get("multi_line_parameter_list") is True + assert plist.get("multi_line_trailing_comma") is False + collapse = expanded.children[1] + assert isinstance(collapse, api_inline_component) + assert collapse.get("name") == "gal-sig-collapse" + collapse_attrs = t.cast(dict[str, str], collapse.get("html_attrs", {})) + assert collapse_attrs.get("aria-controls") == ( + "demo.LayoutDemo.__init__--signature-expanded" + ) + assert collapse.astext() == "[collapse]" + + +def test_rebuild_signature_layout_enriches_annotations_from_field_list() -> None: + desc = _make_desc( + _make_typed_parameters_field_list({"host": "str", "port": "int"}), + ids=("demo.LayoutDemo.__init__",), + ) + sig = desc.children[0] + assert isinstance(sig, addnodes.desc_signature) + sig += addnodes.desc_name("", "__init__") + plist = addnodes.desc_parameterlist() + plist += _make_parameter("host") + plist += _make_parameter("port", default="5432") + sig += plist + + _wrap_content_runs(desc) + _rebuild_signature_layout( + desc, + sig, + threshold=1, + include_permalink=False, + show_annotations=True, + ) + + layout = sig.children[0] + assert isinstance(layout, api_component) + left = layout.children[0] + assert isinstance(left, api_component) + signature = left.children[0] + assert isinstance(signature, api_component) + expanded = _find_component(signature, "api-signature-expanded") + expanded_plist = expanded.children[0] + assert isinstance(expanded_plist, addnodes.desc_parameterlist) + params = list(expanded_plist.findall(addnodes.desc_parameter)) + + assert params[0].astext() == "host: str" + assert params[1].astext() == "port: int = 5432" + + +def test_rebuild_signature_layout_strips_annotations_when_disabled() -> None: + desc = _make_desc(ids=("demo.LayoutDemo.__init__",)) + sig = desc.children[0] + assert isinstance(sig, addnodes.desc_signature) + sig += addnodes.desc_name("", "__init__") + plist = addnodes.desc_parameterlist() + plist += _make_parameter("host", annotation="str") + plist += _make_parameter("port", annotation="int", default="5432") + sig += plist + + _rebuild_signature_layout( + desc, + sig, + threshold=1, + include_permalink=False, + show_annotations=False, ) - html_attrs = t.cast(dict[str, str], panel.get("html_attrs", {})) - assert html_attrs.get("id") == ("demo.LayoutDemo.__init__--signature-panel") - assert isinstance(panel.children[0], addnodes.desc_parameterlist) + + layout = sig.children[0] + assert isinstance(layout, api_component) + left = layout.children[0] + assert isinstance(left, api_component) + signature = left.children[0] + assert isinstance(signature, api_component) + expanded = _find_component(signature, "api-signature-expanded") + expanded_plist = expanded.children[0] + assert isinstance(expanded_plist, addnodes.desc_parameterlist) + params = list(expanded_plist.findall(addnodes.desc_parameter)) + + assert params[0].astext() == "host" + assert params[1].astext() == "port=5432" def test_rebuild_signature_layout_skips_multiline_signatures() -> None: - sig = addnodes.desc_signature(ids=["demo.func"], is_multiline=True) + desc = _make_desc(ids=("demo.func",)) + sig = desc.children[0] + assert isinstance(sig, addnodes.desc_signature) + sig["is_multiline"] = True sig += addnodes.desc_signature_line() original = list(sig.children) - _rebuild_signature_layout(sig, threshold=10, include_permalink=True) + _rebuild_signature_layout( + desc, + sig, + threshold=10, + include_permalink=True, + show_annotations=True, + ) assert list(sig.children) == original From 11887094fe475722e780b5a582b68015def47068 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 8 Apr 2026 07:08:55 -0500 Subject: [PATCH 009/174] layout(fix[signature]): Center managed API headers on dt why: Collapsed folded signatures were still pinned to the top of the padded dt card because the centering state lived on the inner layout row instead of the real desc_signature shell. what: - move managed signature expansion state onto desc_signature/dt and sync it in JS - render managed dt tags directly so custom header attributes reach the HTML output - retarget CSS, integration coverage, and snapshots to the dt-level header model --- .../_static/css/layout.css | 24 ++++++++-- .../_static/js/layout.js | 5 +++ .../src/sphinx_autodoc_layout/_transforms.py | 2 + .../src/sphinx_autodoc_layout/_visitors.py | 8 +++- .../layout/__snapshots__/test_snapshots.ambr | 4 +- tests/ext/layout/test_css.py | 45 +++++++++++++++++++ tests/ext/layout/test_integration.py | 11 ++++- tests/ext/layout/test_snapshots.py | 3 +- tests/ext/layout/test_transforms.py | 31 +++++++++++++ tests/ext/layout/test_visitors.py | 42 +++++++++++++++++ 10 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 tests/ext/layout/test_visitors.py diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css index 9a18ec87..165e3fe4 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/css/layout.css @@ -16,12 +16,12 @@ /* ── API shell ──────────────────────────────────────── */ dl.py:not(.fixture).api-container > dt.api-header { - display: block; + align-items: center; } dl.py:not(.fixture).api-container > dt.api-header > .api-layout { display: flex; - align-items: flex-start; + align-items: center; gap: 1rem; width: 100%; } @@ -29,20 +29,36 @@ dl.py:not(.fixture).api-container > dt.api-header > .api-layout { dl.py:not(.fixture).api-container > dt.api-header .api-layout-left { flex: 1 1 auto; display: flex; - align-items: flex-start; + align-items: center; gap: 0.25rem; min-width: 0; } dl.py:not(.fixture).api-container > dt.api-header .api-layout-right { display: flex; - align-items: flex-start; + align-items: center; gap: 0.5rem; margin-left: auto; white-space: nowrap; flex: 0 0 auto; } +dl.py:not(.fixture).api-container > dt.api-header[data-signature-expanded="true"] { + align-items: flex-start; +} + +dl.py:not(.fixture).api-container > dt.api-header[data-signature-expanded="true"] > .api-layout { + align-items: flex-start; +} + +dl.py:not(.fixture).api-container > dt.api-header[data-signature-expanded="true"] .api-layout-left { + align-items: flex-start; +} + +dl.py:not(.fixture).api-container > dt.api-header[data-signature-expanded="true"] .api-layout-right { + align-items: flex-start; +} + dl.py:not(.fixture).api-container > dt.api-header .api-layout-right:empty { display: none; } diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js index d1c5dc08..8fd06bbf 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_static/js/layout.js @@ -28,6 +28,11 @@ signature.setAttribute('data-expanded', expanded ? 'true' : 'false'); } + var header = expandedPanel.closest('.api-header'); + if (header) { + header.setAttribute('data-signature-expanded', expanded ? 'true' : 'false'); + } + syncSignatureControls(expandedId, expanded); if (expanded) { diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py index 0884bf97..cf56fb05 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_transforms.py @@ -831,6 +831,8 @@ def on_doctree_resolved( continue _append_class(child, "api-header") child["api_managed"] = not child.get("is_multiline", False) + if child["api_managed"]: + child["html_attrs"] = {"data-signature-expanded": "false"} _rebuild_signature_layout( desc_node, child, diff --git a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py index 641b0711..fb499a91 100644 --- a/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py +++ b/packages/sphinx-autodoc-layout/src/sphinx_autodoc_layout/_visitors.py @@ -135,7 +135,13 @@ def depart_gal_sig_fold(self: HTML5Translator, node: nodes.Element) -> None: def visit_desc_signature_html(self: HTML5Translator, node: nodes.Element) -> None: """Render managed desc signatures without Sphinx's default permalink.""" - SphinxHTML5Translator.visit_desc_signature(self, node) + if not node.get("api_managed", False): + SphinxHTML5Translator.visit_desc_signature(self, node) + return + + attrs = _html_attrs(node) + self.body.append(self.starttag(node, "dt", **attrs)) + self.protect_literal_text += 1 def depart_desc_signature_html(self: HTML5Translator, node: nodes.Element) -> None: diff --git a/tests/ext/layout/__snapshots__/test_snapshots.ambr b/tests/ext/layout/__snapshots__/test_snapshots.ambr index eb9c6efe..7b90a49e 100644 --- a/tests/ext/layout/__snapshots__/test_snapshots.ambr +++ b/tests/ext/layout/__snapshots__/test_snapshots.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_layout_demo_init_header_snapshot_annotated[annotated] ''' -
+
__init__