From 7b85c6b5d27c6e78ff36e06848792a74cea3e158 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 9 May 2026 17:18:06 -0500 Subject: [PATCH 01/44] chore(typehints-gp[defaults-audit]) Probe script + D1 discovery findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The improved-defaults-reprs branch needs empirical evidence about which parameter and data-attribute defaults render badly across workspace consumers before any framework lands. An earlier audit using `class="default_value"` as the match pattern silently missed every truly ugly case — when a default's repr contains `<`, `ast.parse` of the arglist fails inside `_parse_arglist` and Sphinx falls back to `_pseudo_parse_arglist`, which emits the whole `name=value` as one `desc_sig_name` text run with no `default_value` span at all. The corrected probe matches `` instead. what: - Add packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py with classify_param() recognising factory_sentinel, instance_sentinel, missing_sentinel, other, and long_data_value buckets. Doctests cover each bucket; pathlib-only IO; ruff/mypy clean. - Add notes/defaults-discovery-d1.md recording the aggregate inventory across libtmux/vcspull/gp-libs/gp-sphinx, the per-tree breakdown, sample defaults per ugliness class, and the resolver- catalog implications (DataclassFactoryRepr, SentinelInstanceRepr, BoundMethodRepr, TruncateLongRepr). - Document the architectural consequence: a docutils post-transform on default_value spans cannot reach the cases that need fixing, because those cases produce no default_value spans. Only an upstream string-level fix via autodoc-before-process-signature + DefaultValue shim applies. --- .../scripts/audit_defaults.py | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py diff --git a/packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py b/packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py new file mode 100644 index 00000000..b1f3a29f --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py @@ -0,0 +1,240 @@ +r"""Audit rendered Sphinx default-value strings across built docs. + +Walks every ```` and every long +``
`` (data/attribute) in a tree of +built HTML and classifies each occurrence. + +The audit pattern matters: matching only ``class="default_value"`` spans +silently misses the cases this audit exists to find. When a default's +``repr()`` contains ``<`` (e.g. +``scope=``), Sphinx's +``ast.parse`` of the arglist fails and rendering falls back to +``_pseudo_parse_arglist`` +(``sphinx/domains/python/_annotations.py:541-600``), which emits the +whole ``name=value`` as one ``desc_sig_name`` text run — no +``default_value`` span exists. Match ```` +instead. + +Usage +----- + +:: + + uv run python packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py \ + ~/work/python/libtmux/docs/_build \ + ~/work/python/libvcs/docs/_build/html + +Prints a per-tree summary plus an aggregate breakdown by ugliness +class. Pass ``--samples N`` to also print N example ugly defaults per +class. + +The classes are: + +- ``clean`` — no ``<``, no ``0x``, no `` object``; render fine. +- ``factory_sentinel`` — ``=``; from + ``dataclasses._HAS_DEFAULT_FACTORY`` for fields declared with + ``field(default_factory=…)`` on synthetic ``__init__``. +- ``instance_sentinel`` — ``=``; a custom + sentinel instance used as a default (libtmux's + ``DEFAULT_OPTION_SCOPE`` is the canonical case). +- ``missing_sentinel`` — ``_MISSING_TYPE``-shaped repr; rare today + because Sphinx's ``object_description`` strips memory addresses. +- ``other`` — contains ``<`` or ``0x`` but doesn't match the above. +- ``long_data_value`` — for module/class data (not parameters): + ``
`` whose rendered text exceeds the + --threshold (default 200 chars). +""" + +from __future__ import annotations + +import argparse +import collections +import html as html_module +import pathlib +import re +import sys +import typing as t + +_SIG_PARAM_RE = re.compile(r'(.*?)', re.DOTALL) +_DATA_DT_RE = re.compile( + r'
]*>(.*?)
', + re.DOTALL, +) +_TAG_RE = re.compile(r"<[^>]+>") +_WS_RE = re.compile(r"\s+") + + +def _strip_html(fragment: str) -> str: + """Strip tags and decode entities to plain text. + + Examples + -------- + >>> _strip_html('x=42') + 'x=42' + >>> _strip_html('scope=<a>') + 'scope=' + """ + text = _TAG_RE.sub("", fragment) + text = html_module.unescape(text) + return _WS_RE.sub(" ", text).strip() + + +def classify_param(text: str) -> str: + """Classify a sig-param text run by ugliness category. + + Examples + -------- + >>> classify_param("count=1") + 'clean' + >>> classify_param("alert_bell=") + 'factory_sentinel' + >>> classify_param("scope=") + 'instance_sentinel' + >>> classify_param("x=") + 'missing_sentinel' + """ + if "=" not in text: + return "clean" + if "=" in text: + return "factory_sentinel" + if "_MISSING_TYPE" in text or "_MISSING " in text: + return "missing_sentinel" + if " object" in text and "<" in text: + return "instance_sentinel" + if "<" in text or "0x" in text: + return "other" + return "clean" + + +class _ParamRow(t.NamedTuple): + repo: str + page: str + text: str + cls: str + + +class _DataRow(t.NamedTuple): + repo: str + page: str + qualname: str + char_count: int + + +def _audit_tree( + label: str, + root: pathlib.Path, + *, + long_threshold: int, +) -> tuple[list[_ParamRow], list[_DataRow]]: + """Walk *root* and return parameter and data audit rows.""" + params: list[_ParamRow] = [] + data: list[_DataRow] = [] + for path in root.rglob("*.html"): + try: + html = path.read_text(encoding="utf-8") + except OSError: + continue + page = str(path.relative_to(root)) + for fragment in _SIG_PARAM_RE.findall(html): + text = _strip_html(fragment) + if "=" not in text: + continue + params.append(_ParamRow(label, page, text, classify_param(text))) + for fragment in _DATA_DT_RE.findall(html): + text = _strip_html(fragment) + if len(text) <= long_threshold: + continue + head = text.split("=", 1)[0].strip() + data.append(_DataRow(label, page, head, len(text))) + return params, data + + +def _summary(rows: list[_ParamRow]) -> dict[str, int]: + """Return a class -> count mapping for the given rows.""" + counts: collections.Counter[str] = collections.Counter() + for row in rows: + counts[row.cls] += 1 + return dict(counts) + + +def _report( + trees: list[tuple[str, pathlib.Path]], + *, + long_threshold: int, + samples_per_class: int, +) -> int: + """Run the audit across all *trees* and print a summary report.""" + grand_params: list[_ParamRow] = [] + grand_data: list[_DataRow] = [] + for label, root in trees: + params, data = _audit_tree(label, root, long_threshold=long_threshold) + grand_params.extend(params) + grand_data.extend(data) + summary = _summary(params) + ugly = sum(v for k, v in summary.items() if k != "clean") + print(f"=== {label} ({root}) ===") + print(f" sig-params with defaults: {len(params)}") + print(f" ugly: {ugly} ({summary})") + print(f" long data values (>{long_threshold} chars): {len(data)}") + + print() + print("=== aggregate ===") + print(f" total sig-params: {len(grand_params)}") + print(f" ugly: {_summary(grand_params)}") + print(f" long data values: {len(grand_data)}") + + if samples_per_class: + by_cls: dict[str, list[_ParamRow]] = collections.defaultdict(list) + for row in grand_params: + if row.cls != "clean" and len(by_cls[row.cls]) < samples_per_class: + by_cls[row.cls].append(row) + print() + print("=== samples ===") + for cls, rows in sorted(by_cls.items()): + print(f"-- {cls} --") + for row in rows: + print(f" [{row.repo}] {row.page} :: {row.text!r}") + + return 0 + + +def main(argv: list[str]) -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "trees", + nargs="+", + help="Pairs of LABEL=PATH or just PATH (label inferred from basename).", + ) + parser.add_argument( + "--threshold", + type=int, + default=200, + help="Char-count threshold for long_data_value (default: 200).", + ) + parser.add_argument( + "--samples", + type=int, + default=0, + help="Print up to N sample ugly defaults per class.", + ) + args = parser.parse_args(argv) + + trees: list[tuple[str, pathlib.Path]] = [] + for spec in args.trees: + if "=" in spec: + label, _, raw_path = spec.partition("=") + path = pathlib.Path(raw_path) + else: + path = pathlib.Path(spec) + label = path.parent.name or str(path) + trees.append((label, path)) + return _report( + trees, + long_threshold=args.threshold, + samples_per_class=args.samples, + ) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 4a90861c5ef28b51b4fd3403b8866092f94228bf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 9 May 2026 17:40:53 -0500 Subject: [PATCH 02/44] feat(gp-sphinx[autodoc]) Default autodoc_preserve_defaults to True MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Across libtmux, libvcs, vcspull, gp-libs, and gp-sphinx, parameter defaults that hold non-primitive Python objects render as `` and similar unreadable repr text. Sphinx already ships a fix — `autodoc_preserve_defaults=True` invokes `update_default_value` which parses each function's source via AST and substitutes a `DefaultValue` shim whose `__repr__` returns the literal source text. The flag is off by default upstream and `gp-sphinx.defaults` never overrode it, so the workspace shipped with the ugly repr behavior. D2 evidence (notes/defaults-discovery-d2.md): the flip clears 81/171 (47%) of libtmux's ugly parameter defaults and is a prerequisite for the forthcoming Stage C cross-reference work that depends on having a parseable arglist. what: - Add `DEFAULT_AUTODOC_PRESERVE_DEFAULTS: bool = True` in `gp_sphinx.defaults` with a docstring documenting the limitation on synthetic dataclass / attrs / NamedTuple `__init__` (covered by D3 / Stage B2 follow-on). - Wire `autodoc_preserve_defaults` into the conf dict produced by `merge_sphinx_config()`. - Add `notes/defaults-discovery-d2.md` with reproducible before/after counts: libtmux's `_show_option(scope=...)` renders `DEFAULT_OPTION_SCOPE` instead of ``; libvcs's `DEFAULT_RULES` line under the combined preserve+no-value flags shrinks from 688 chars to 45. - Document why `autodoc_default_options['no-value']=True` was rejected as a workspace default (suppresses useful short values like `'/'`, `'HEAD'`, `OptionScope.Pane`); per-attribute opt-in via `:no-value:` directive option remains the right tool for individual long values until D4's curated resolver lands. --- packages/gp-sphinx/src/gp_sphinx/config.py | 2 ++ packages/gp-sphinx/src/gp_sphinx/defaults.py | 23 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 4963ad66..497c4555 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -39,6 +39,7 @@ DEFAULT_AUTODOC_CLASS_SIGNATURE, DEFAULT_AUTODOC_MEMBER_ORDER, DEFAULT_AUTODOC_OPTIONS, + DEFAULT_AUTODOC_PRESERVE_DEFAULTS, DEFAULT_AUTODOC_TYPEHINTS, DEFAULT_COPYBUTTON_LINE_CONTINUATION_CHARACTER, DEFAULT_COPYBUTTON_PROMPT_IS_REGEXP, @@ -442,6 +443,7 @@ def merge_sphinx_config( "autodoc_member_order": DEFAULT_AUTODOC_MEMBER_ORDER, "autodoc_class_signature": DEFAULT_AUTODOC_CLASS_SIGNATURE, "autodoc_typehints": DEFAULT_AUTODOC_TYPEHINTS, + "autodoc_preserve_defaults": DEFAULT_AUTODOC_PRESERVE_DEFAULTS, "toc_object_entries_show_parents": DEFAULT_TOC_OBJECT_ENTRIES_SHOW_PARENTS, "autodoc_default_options": dict(DEFAULT_AUTODOC_OPTIONS), # Copybutton diff --git a/packages/gp-sphinx/src/gp_sphinx/defaults.py b/packages/gp-sphinx/src/gp_sphinx/defaults.py index b6375b1c..5def5531 100644 --- a/packages/gp-sphinx/src/gp_sphinx/defaults.py +++ b/packages/gp-sphinx/src/gp_sphinx/defaults.py @@ -363,6 +363,29 @@ class FontConfig(_FontConfigRequired, total=False): 'description' """ +DEFAULT_AUTODOC_PRESERVE_DEFAULTS: bool = True +"""Preserve source text of parameter defaults instead of ``repr()``. + +When ``True``, Sphinx's ``update_default_value`` listener wraps each +``inspect.Parameter.default`` in a ``DefaultValue`` shim whose +``__repr__`` returns the *literal source text* of the default +expression. The result is that signatures render +``scope=DEFAULT_OPTION_SCOPE`` instead of +``scope=`` and +``retry_exceptions=(libtmux_exc.LibTmuxException,)`` instead of +``(,)``. + +The flag has no effect on synthetic ``__init__`` (dataclass / attrs / +NamedTuple) where ``inspect.getsource()`` returns nothing — Sphinx's +listener bails out for those, leaving the defaults as ``=`` +until a sibling listener handles them. + +Examples +-------- +>>> DEFAULT_AUTODOC_PRESERVE_DEFAULTS +True +""" + DEFAULT_COPYBUTTON_LINE_CONTINUATION_CHARACTER: str = "\\" """Line continuation character for sphinx-copybutton.""" From 198ab5b89b0a06ae8977cf481baa682516b7d358 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 9 May 2026 17:50:18 -0500 Subject: [PATCH 03/44] feat(typehints-gp[param-defaults]) Resolve dataclass __init__ defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: After defaulting `autodoc_preserve_defaults=True` workspace-wide in the previous commit, the only remaining cluster of ugly parameter defaults is dataclass synthetic `__init__` — Sphinx's own `update_default_value` bails out at `_dynamic/_preserve_defaults.py:107-110` because synthetic init has no source code for `inspect.getsource()`. The empirical inventory in notes/defaults-discovery-d1.md has libtmux's 90 `` occurrences in this bucket. This commit fills the gap so workspace consumers see clean source-text rendering for `field(default_factory=…)`. what: - Add `_param_defaults.py` with `ResolveContext`, `Resolver` Protocol, `DataclassFactoryRepr` resolver, and `update_synthetic_defvalues` listener. The listener walks `dataclasses.fields(parent)`, runs the resolver chain on each field's `default_factory`, and replaces matching `Parameter.default` with `sphinx.util.inspect.DefaultValue` shims so all downstream stringifiers emit the chosen text verbatim. - `DataclassFactoryRepr` covers stdlib container constructors (list/dict/set/frozenset/tuple → `[]`, `{}`, `set()`, etc.) and named callable types (`Foo` → `Foo()`). Lambdas and unrecognised factories defer (return `None`) — Sphinx's stock `` rendering remains for those. - Connect the listener via `app.connect("autodoc-before-process- signature", update_synthetic_defvalues)` in extension.setup(). - Add config flag `gp_typehints_curate_param_defaults` (default `True`) as a kill-switch for downstream debugging. - Tests: 18 unit tests (Style A NamedTuple parametrization for each factory shape; mock-app for listener semantics) plus 2 integration tests via `tests/_sphinx_scenarios.py`. Verify the rendered HTML's `default_value` spans contain the resolver-chosen text and that `` no longer appears. - gp-sphinx own docs audit: 1 ugly factory_sentinel → 0 after this change. --- .../_param_defaults.py | 294 ++++++++++++++++++ .../sphinx_autodoc_typehints_gp/extension.py | 14 + tests/ext/typehints_gp/test_param_defaults.py | 212 +++++++++++++ .../test_param_defaults_integration.py | 125 ++++++++ tests/ext/typehints_gp/test_unit.py | 1 + 5 files changed, 646 insertions(+) create mode 100644 packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py create mode 100644 tests/ext/typehints_gp/test_param_defaults.py create mode 100644 tests/ext/typehints_gp/test_param_defaults_integration.py diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py new file mode 100644 index 00000000..cceb4098 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py @@ -0,0 +1,294 @@ +"""Resolver chain for synthetic-init parameter defaults. + +Sphinx's ``autodoc_preserve_defaults`` flag handles regular function +and method signatures via :func:`inspect.getsource` plus AST source +slicing, wrapping each default in a ``DefaultValue`` shim whose +``__repr__`` returns the literal source text. It explicitly bails +out on synthetic ``__init__`` signatures (dataclass / attrs / +NamedTuple) — see +``sphinx/ext/autodoc/_dynamic/_preserve_defaults.py:107-110``. + +This module fills that gap. +:func:`update_synthetic_defvalues` is connected to the +``autodoc-before-process-signature`` event and runs after Sphinx's +own ``update_defvalue``. For each parameter whose default is still a +raw Python object (not a ``DefaultValue`` shim), it walks +:func:`dataclasses.fields` on the parent class, runs a resolver +chain over the field's ``default`` / ``default_factory``, and +replaces ``Parameter.default`` with ``DefaultValue()``. +After that, all downstream stringifiers emit the chosen text +verbatim, the directive arglist parses, and rendering is clean. + +The resolver chain is the seam for future extension. The built-in +catalog is seeded by the empirical inventory in +``notes/defaults-discovery-d1.md`` (libtmux's 90 ```` +occurrences from dataclass ``field(default_factory=…)``). +""" + +from __future__ import annotations + +import dataclasses +import inspect +import logging +import sys +import typing as t + +from sphinx.util.inspect import DefaultValue + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + + +class ResolveContext(t.NamedTuple): + """Context passed to each :class:`Resolver` in the chain. + + Parameters + ---------- + value : object + The live Python object (a callable for ``default_factory`` + cases, or the raw default for direct-value cases). + kind : str + One of ``'param'``, ``'data'``, ``'attribute'``. Stage B + listeners only emit ``'param'``; Stage A's + ``GpDataDocumenter`` (D4) emits ``'data'`` / ``'attribute'``. + qualname : str + Fully qualified name of the documented object, e.g. + ``'libtmux.constants.HookEventDataclass.__init__'``. + param_name : str | None + Set when ``kind == 'param'``; the parameter name being + resolved. + default_repr : str + Sphinx's stock ``object_description`` of ``value``, available + as a fallback for resolvers that want to compose with the + default rendering. + """ + + value: object + kind: str + qualname: str + param_name: str | None + default_repr: str + + +class Resolver(t.Protocol): + """Compute a symbolic source-text string for a default value. + + Resolvers are run in priority order; the first non-``None`` + result wins. Return ``None`` to defer to the next resolver. + Return an empty string to suppress the default (Site A only; + parameter defaults cannot be suppressed). + """ + + def __call__(self, ctx: ResolveContext) -> str | None: + """Return the chosen text or ``None`` to defer.""" + ... + + +class DataclassFactoryRepr: + """Render :func:`dataclasses.field` ``default_factory`` symbolically. + + Recognises stdlib container constructors (``list``, ``dict``, + ``set``, ``frozenset``, ``tuple``) and named callable types. + Defers (returns ``None``) on lambdas and unrecognised factories, + leaving Sphinx's stock ```` rendering in place. + + Examples + -------- + >>> r = DataclassFactoryRepr() + >>> ctx = ResolveContext( + ... value=list, + ... kind='param', + ... qualname='Foo.__init__', + ... param_name='items', + ... default_repr='', + ... ) + >>> r(ctx) + '[]' + >>> r(ctx._replace(value=dict)) + '{}' + >>> r(ctx._replace(value=set)) + 'set()' + >>> r(ctx._replace(value=lambda: 1)) is None + True + """ + + _BUILTIN_LITERALS: t.ClassVar[dict[type, str]] = { + list: "[]", + dict: "{}", + set: "set()", + frozenset: "frozenset()", + tuple: "()", + } + + def __call__(self, ctx: ResolveContext) -> str | None: + """Resolve a ``default_factory`` callable to its source text.""" + if ctx.kind != "param": + return None + factory = ctx.value + if isinstance(factory, type): + literal = self._BUILTIN_LITERALS.get(factory) + if literal is not None: + return literal + name = getattr(factory, "__name__", None) + if name and name != "": + return f"{name}()" + return None + + +_DEFAULT_RESOLVERS: tuple[Resolver, ...] = (DataclassFactoryRepr(),) + + +def _run_chain( + ctx: ResolveContext, + resolvers: tuple[Resolver, ...], +) -> str | None: + """Run *resolvers* in order and return the first non-``None`` result. + + Examples + -------- + >>> ctx = ResolveContext( + ... value=list, + ... kind='param', + ... qualname='Foo.__init__', + ... param_name='items', + ... default_repr='', + ... ) + >>> _run_chain(ctx, _DEFAULT_RESOLVERS) + '[]' + """ + for resolver in resolvers: + result = resolver(ctx) + if result is not None: + return result + return None + + +def _walk_to_dataclass(obj: t.Any) -> type | None: + """Find the dataclass that owns *obj*'s synthetic ``__init__``. + + Returns ``None`` if *obj* is not a synthetic dataclass init. + Handles both the ``isinstance(obj, type)`` case (autodoc passes + the class) and the bound-/unbound-method case (autodoc passes + ``Cls.__init__``). + + Examples + -------- + >>> import dataclasses + >>> @dataclasses.dataclass + ... class _ExampleDC: + ... x: int = 0 + >>> _walk_to_dataclass(_ExampleDC) is _ExampleDC + True + >>> class _Plain: + ... pass + >>> _walk_to_dataclass(_Plain) is None + True + >>> _walk_to_dataclass(42) is None + True + """ + if isinstance(obj, type) and dataclasses.is_dataclass(obj): + return obj + qualname = getattr(obj, "__qualname__", "") + if not qualname.endswith(".__init__"): + return None + module_name = getattr(obj, "__module__", None) + if not module_name: + return None + module = sys.modules.get(module_name) + if module is None: + return None + parent: t.Any = module + for part in qualname.split(".")[:-1]: + parent = getattr(parent, part, None) + if parent is None: + return None + if isinstance(parent, type) and dataclasses.is_dataclass(parent): + return parent + return None + + +def update_synthetic_defvalues( + app: Sphinx, + obj: t.Any, + bound_method: bool, +) -> None: + """Fill defaults for synthetic dataclass ``__init__`` signatures. + + Connected to ``autodoc-before-process-signature``. Mutates + ``obj.__signature__`` so that downstream stringifiers emit the + resolver-chosen text. No-op when: + + - the config flag ``gp_typehints_curate_param_defaults`` is + ``False``; + - *obj* is not (and is not the ``__init__`` of) a dataclass; + - every parameter's default is already a ``DefaultValue`` shim + (Sphinx's ``update_defvalue`` already handled them); + - no resolver returns a non-``None`` result. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + obj : Any + The function or class being introspected. + bound_method : bool + Whether *obj* is a bound method (Sphinx event arg). + + Examples + -------- + >>> update_synthetic_defvalues # doctest: +ELLIPSIS + + """ + if not getattr(app.config, "gp_typehints_curate_param_defaults", True): + return + parent = _walk_to_dataclass(obj) + if parent is None: + return + try: + sig = inspect.signature(obj) + except (TypeError, ValueError): + return + + fields_by_name = {f.name: f for f in dataclasses.fields(parent)} + new_parameters: list[inspect.Parameter] = [] + changed = False + for param in sig.parameters.values(): + if isinstance(param.default, DefaultValue): + new_parameters.append(param) + continue + if param.default is inspect.Parameter.empty: + new_parameters.append(param) + continue + field = fields_by_name.get(param.name) + if field is None: + new_parameters.append(param) + continue + if field.default_factory is dataclasses.MISSING: + new_parameters.append(param) + continue + ctx = ResolveContext( + value=field.default_factory, + kind="param", + qualname=getattr(obj, "__qualname__", ""), + param_name=param.name, + default_repr="", + ) + text = _run_chain(ctx, _DEFAULT_RESOLVERS) + if text is None: + new_parameters.append(param) + continue + new_parameters.append(param.replace(default=DefaultValue(text))) + changed = True + + if not changed: + return + new_sig = sig.replace(parameters=new_parameters) + try: + obj.__signature__ = new_sig + except (AttributeError, TypeError): + try: + obj.__dict__["__signature__"] = new_sig + except (AttributeError, TypeError): + logger.debug("failed to set __signature__ on %r", obj) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index 17bc256e..ae5ac213 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -581,10 +581,24 @@ def setup(app: Sphinx) -> dict[str, t.Any]: >>> setup # doctest: +ELLIPSIS """ + from sphinx_autodoc_typehints_gp._param_defaults import ( + update_synthetic_defvalues, + ) + + app.add_config_value( + "gp_typehints_curate_param_defaults", + default=True, + rebuild="env", + types=frozenset({bool}), + ) app.connect("builder-inited", _clear_caches) try: app.connect("autodoc-process-docstring", process_docstring) app.connect("autodoc-process-signature", record_typehints) + # Runs after Sphinx's own update_defvalue (which only handles + # regular methods with readable source). Fills the gap for + # synthetic dataclass __init__. + app.connect("autodoc-before-process-signature", update_synthetic_defvalues) except ExtensionError as exc: if "Unknown event name" not in str(exc): raise diff --git a/tests/ext/typehints_gp/test_param_defaults.py b/tests/ext/typehints_gp/test_param_defaults.py new file mode 100644 index 00000000..e198b1b1 --- /dev/null +++ b/tests/ext/typehints_gp/test_param_defaults.py @@ -0,0 +1,212 @@ +"""Unit tests for sphinx_autodoc_typehints_gp._param_defaults.""" + +from __future__ import annotations + +import dataclasses +import inspect +import typing as t + +import pytest +from sphinx.util.inspect import DefaultValue + +from sphinx_autodoc_typehints_gp._param_defaults import ( + DataclassFactoryRepr, + ResolveContext, + _walk_to_dataclass, + update_synthetic_defvalues, +) + +# --------------------------------------------------------------------------- +# DataclassFactoryRepr +# --------------------------------------------------------------------------- + + +class _FactoryFixture(t.NamedTuple): + test_id: str + factory: object + expected: str | None + + +def _ctx(value: object) -> ResolveContext: + return ResolveContext( + value=value, + kind="param", + qualname="Foo.__init__", + param_name="x", + default_repr="", + ) + + +_FACTORY_FIXTURES: list[_FactoryFixture] = [ + _FactoryFixture("list", list, "[]"), + _FactoryFixture("dict", dict, "{}"), + _FactoryFixture("set", set, "set()"), + _FactoryFixture("frozenset", frozenset, "frozenset()"), + _FactoryFixture("tuple", tuple, "()"), + _FactoryFixture("named_class", _FactoryFixture, "_FactoryFixture()"), + _FactoryFixture("lambda", lambda: 1, None), + _FactoryFixture("function", _ctx, None), +] + + +@pytest.mark.parametrize( + list(_FactoryFixture._fields), + _FACTORY_FIXTURES, + ids=[f.test_id for f in _FACTORY_FIXTURES], +) +def test_dataclass_factory_repr_resolves_each_shape( + test_id: str, + factory: object, + expected: str | None, +) -> None: + """DataclassFactoryRepr returns the expected text per factory shape.""" + del test_id + assert DataclassFactoryRepr()(_ctx(factory)) == expected + + +def test_dataclass_factory_repr_defers_for_non_param_kind() -> None: + """A 'data' / 'attribute' context is not handled by this resolver.""" + ctx = ResolveContext( + value=list, + kind="data", + qualname="mod.SOME_LIST", + param_name=None, + default_repr="[]", + ) + assert DataclassFactoryRepr()(ctx) is None + + +# --------------------------------------------------------------------------- +# _walk_to_dataclass +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class _ProbeDataclass: + x: list[int] = dataclasses.field(default_factory=list) + y: dict[str, int] = dataclasses.field(default_factory=dict) + z: int = 5 + + +class _PlainClass: + def __init__(self, x: int = 0) -> None: + self.x = x + + +def test_walk_to_dataclass_returns_class_when_passed_class() -> None: + """Passing the dataclass itself returns it.""" + assert _walk_to_dataclass(_ProbeDataclass) is _ProbeDataclass + + +def test_walk_to_dataclass_returns_class_for_init_method() -> None: + """Passing the __init__ resolves to the owning dataclass.""" + assert _walk_to_dataclass(_ProbeDataclass.__init__) is _ProbeDataclass + + +def test_walk_to_dataclass_returns_none_for_non_dataclass() -> None: + """A regular class is not a dataclass; returns None.""" + assert _walk_to_dataclass(_PlainClass) is None + assert _walk_to_dataclass(_PlainClass.__init__) is None + + +def test_walk_to_dataclass_returns_none_for_arbitrary_function() -> None: + """A free function is not a dataclass init.""" + + def some_function() -> None: + return None + + assert _walk_to_dataclass(some_function) is None + + +# --------------------------------------------------------------------------- +# update_synthetic_defvalues +# --------------------------------------------------------------------------- + + +class _FakeConfig: + gp_typehints_curate_param_defaults: bool = True + + +class _FakeApp: + def __init__(self, *, enabled: bool = True) -> None: + self.config = _FakeConfig() + self.config.gp_typehints_curate_param_defaults = enabled + + +def test_update_synthetic_defvalues_wraps_factory_defaults_in_shim() -> None: + """A dataclass with default_factory fields gets DefaultValue shims.""" + app = t.cast("t.Any", _FakeApp()) + + @dataclasses.dataclass + class _Local: + items: list[int] = dataclasses.field(default_factory=list) + mapping: dict[str, int] = dataclasses.field(default_factory=dict) + + update_synthetic_defvalues(app, _Local, bound_method=False) + sig = inspect.signature(_Local) + items_default = sig.parameters["items"].default + mapping_default = sig.parameters["mapping"].default + assert isinstance(items_default, DefaultValue) + assert repr(items_default) == "[]" + assert isinstance(mapping_default, DefaultValue) + assert repr(mapping_default) == "{}" + + +def test_update_synthetic_defvalues_leaves_direct_value_defaults_alone() -> None: + """Fields with `default=` (not factory) are not modified.""" + app = t.cast("t.Any", _FakeApp()) + + @dataclasses.dataclass + class _Local: + items: list[int] = dataclasses.field(default_factory=list) + count: int = 5 + + update_synthetic_defvalues(app, _Local, bound_method=False) + sig = inspect.signature(_Local) + # count has direct default; should remain int 5 + assert sig.parameters["count"].default == 5 + assert not isinstance(sig.parameters["count"].default, DefaultValue) + + +def test_update_synthetic_defvalues_skips_when_flag_disabled() -> None: + """Setting gp_typehints_curate_param_defaults=False is a hard kill-switch.""" + app = t.cast("t.Any", _FakeApp(enabled=False)) + + @dataclasses.dataclass + class _Local: + items: list[int] = dataclasses.field(default_factory=list) + + update_synthetic_defvalues(app, _Local, bound_method=False) + sig = inspect.signature(_Local) + assert not isinstance(sig.parameters["items"].default, DefaultValue) + + +def test_update_synthetic_defvalues_skips_non_dataclass() -> None: + """A regular class's __init__ is untouched.""" + app = t.cast("t.Any", _FakeApp()) + + class _Plain: + def __init__(self, x: int = 0) -> None: + self.x = x + + update_synthetic_defvalues(app, _Plain, bound_method=False) + sig = inspect.signature(_Plain) + assert sig.parameters["x"].default == 0 + assert not isinstance(sig.parameters["x"].default, DefaultValue) + + +def test_update_synthetic_defvalues_idempotent_with_existing_shim() -> None: + """Pre-existing DefaultValue shims (from Sphinx update_defvalue) survive.""" + app = t.cast("t.Any", _FakeApp()) + + @dataclasses.dataclass + class _Local: + items: list[int] = dataclasses.field(default_factory=list) + + update_synthetic_defvalues(app, _Local, bound_method=False) + first = inspect.signature(_Local).parameters["items"].default + update_synthetic_defvalues(app, _Local, bound_method=False) + second = inspect.signature(_Local).parameters["items"].default + assert isinstance(first, DefaultValue) + assert isinstance(second, DefaultValue) + assert repr(first) == repr(second) == "[]" diff --git a/tests/ext/typehints_gp/test_param_defaults_integration.py b/tests/ext/typehints_gp/test_param_defaults_integration.py new file mode 100644 index 00000000..457dccc9 --- /dev/null +++ b/tests/ext/typehints_gp/test_param_defaults_integration.py @@ -0,0 +1,125 @@ +"""Integration tests for synthetic-init parameter-default rendering.""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +_MODULE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + + import dataclasses + + + @dataclasses.dataclass + class HookCounters: + \"\"\"Synthetic-init dataclass exercising default_factory shapes.\"\"\" + + items: list[int] = dataclasses.field(default_factory=list) + mapping: dict[str, int] = dataclasses.field(default_factory=dict) + names: set[str] = dataclasses.field(default_factory=set) + count: int = 5 + """ +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + import sys + + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = [ + "sphinx.ext.autodoc", + "sphinx_autodoc_typehints_gp", + ] + + autodoc_preserve_defaults = True + """ +) + +_INDEX_RST = textwrap.dedent( + """\ + Demo + ==== + + .. autoclass:: param_defaults_demo.HookCounters + :members: + """ +) + + +@pytest.fixture(scope="module") +def factory_defaults_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a Sphinx project exercising dataclass default_factory rendering.""" + cache_root = tmp_path_factory.mktemp("param-defaults-html") + scenario = SphinxScenario( + files=( + ScenarioFile("param_defaults_demo.py", _MODULE_SOURCE), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile("index.rst", _INDEX_RST), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("param_defaults_demo",), + ) + + +@pytest.mark.integration +def test_dataclass_factory_defaults_render_as_source_text( + factory_defaults_html_result: SharedSphinxResult, +) -> None: + """Dataclass default_factory params render source text, not .""" + import re + + html = read_output(factory_defaults_html_result, "index.html") + + # Extract plain text of every default_value span so we don't depend on + # the span/whitespace wrapping Sphinx applies between ``=`` and the value. + rendered = { + re.sub(r"<[^>]+>", "", m).strip() + for m in re.findall( + r'class="default_value">([^<]*(?:<[^>]+>[^<]*)*?)', + html, + ) + } + assert "[]" in rendered, f"missing list factory rendering; got {rendered!r}" + assert "{}" in rendered, f"missing dict factory rendering; got {rendered!r}" + assert "set()" in rendered, f"missing set factory rendering; got {rendered!r}" + # The direct-value default also lands in a default_value span + assert "5" in rendered, f"missing direct default; got {rendered!r}" + # The raw sentinel must not appear in the rendered output + assert "<factory>" not in html + assert "" not in html + + +@pytest.mark.integration +def test_dataclass_factory_defaults_use_default_value_span( + factory_defaults_html_result: SharedSphinxResult, +) -> None: + """The arglist parses cleanly so default_value spans exist after the fix.""" + html = read_output(factory_defaults_html_result, "index.html") + + # Stage A success criterion: the AST path produced default_value spans + # rather than _pseudo_parse_arglist gluing name=value into one span. + assert 'class="default_value"' in html or "default_value" in html diff --git a/tests/ext/typehints_gp/test_unit.py b/tests/ext/typehints_gp/test_unit.py index 9a2f64ed..7894a5de 100644 --- a/tests/ext/typehints_gp/test_unit.py +++ b/tests/ext/typehints_gp/test_unit.py @@ -1213,6 +1213,7 @@ def test_setup_registers_builder_inited_cache_clearing() -> None: Sphinx, types.SimpleNamespace( connect=lambda event, handler, **kw: connections.append((event, handler)), + add_config_value=lambda *a, **kw: None, ), ) From 9c49ffb7e34a91528908119cb3cfdc798517ab5c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 9 May 2026 17:55:32 -0500 Subject: [PATCH 04/44] feat(typehints-gp[data-defaults]) Truncate long :value: text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: For module-level constants like libvcs's DEFAULT_RULES (688 chars) and class data like GitURL.rule_map (5 738 chars in the original audit), Sphinx emits a `:value: ` line that explodes the signature block. The built-in `:no-value:` baseline (D2) suppresses every value indiscriminately — including useful short ones like `'/'` and `OptionScope.Pane` — so it can't ship as a workspace default. This commit adds the curated alternative: a resolver chain that truncates only long values and leaves short ones untouched. what: - Add `_data_defaults.py` with `TruncateLongRepr` resolver, `_curate_value_line` helper, and `GpDataDocumenter` / `GpAttributeDocumenter` subclasses. The subclasses override `add_line` to route `:value:` lines through the curator; everything else passes through. Reuses `ResolveContext` and `_run_chain` from D3's `_param_defaults` module so the resolver catalog grows uniformly across Site A and Site B. - Register the documenters via `app.add_autodocumenter(..., override=True)` and add the kill-switch config flag `gp_typehints_curate_data_defaults` (default `True`). - Tests: 9 unit tests parametrised in Style A NamedTuple form (TruncateLongRepr threshold edges, kill-switch, kind-filtering, pass-through) plus 2 integration tests via `tests/_sphinx_scenarios.py` that build fixture projects with a short and a long module constant and assert the HTML contains the truncated marker only on the long one. - Resolver scope is intentionally conservative for the prototype (truncation only). Richer resolvers (list-of-dataclasses summary, compiled-regex repr) are deferred to D5 where the shared catalog factoring decision lands. --- .../_data_defaults.py | 171 ++++++++++++++++++ .../sphinx_autodoc_typehints_gp/extension.py | 12 ++ tests/ext/typehints_gp/test_data_defaults.py | 115 ++++++++++++ .../test_data_defaults_integration.py | 131 ++++++++++++++ tests/ext/typehints_gp/test_unit.py | 1 + 5 files changed, 430 insertions(+) create mode 100644 packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py create mode 100644 tests/ext/typehints_gp/test_data_defaults.py create mode 100644 tests/ext/typehints_gp/test_data_defaults_integration.py diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py new file mode 100644 index 00000000..640bc2b8 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py @@ -0,0 +1,171 @@ +"""Custom Documenter classes that curate data/attribute ``:value:`` text. + +Sphinx's stock :class:`DataDocumenter` and :class:`AttributeDocumenter` +emit a ``:value: `` line where ```` comes from +:func:`sphinx.util.inspect.object_description` — the raw ``repr()`` +with memory addresses stripped. For large module-level constants +(libvcs's ``DEFAULT_RULES`` is the canonical example: a 5 738-char +list of dataclasses) this produces unreadable signature blocks. + +This module overrides the documenters to run a resolver chain over +the ``:value:`` text. Each resolver may: + +- return ``None`` to defer to the next resolver (chain falls through + to Sphinx's stock ``:value: ``); +- return an empty string to suppress the ``:value:`` line entirely + (equivalent to ``:no-value:`` for that one attribute); +- return a non-empty string to replace the value text (e.g. + ``<…truncated, 5738 chars>``). + +The built-in catalog (seeded by D1 evidence) ships +:class:`TruncateLongRepr` only; richer resolvers +(``ListOfDataclassesSummary``, ``CompiledRegexRepr``) belong to D5 +once the framework decision is made. +""" + +from __future__ import annotations + +import typing as t + +from sphinx.ext.autodoc import AttributeDocumenter, DataDocumenter + +from sphinx_autodoc_typehints_gp._param_defaults import ( + ResolveContext, + Resolver, + _run_chain, +) + +_VALUE_PREFIX: t.Final = " :value: " + + +class TruncateLongRepr: + """Truncate long ``:value:`` text. + + Returns ``None`` (defer) for parameter contexts and for short + reprs; returns ``"<...truncated, N chars>"`` for reprs over the + threshold. + + Examples + -------- + >>> r = TruncateLongRepr(threshold=10) + >>> r(ResolveContext( + ... value=None, + ... kind='data', + ... qualname='mod.X', + ... param_name=None, + ... default_repr='[1, 2]', + ... )) is None + True + >>> r(ResolveContext( + ... value=None, + ... kind='data', + ... qualname='mod.X', + ... param_name=None, + ... default_repr='this string is longer than ten characters', + ... )) + '<...truncated, 41 chars>' + """ + + def __init__(self, threshold: int = 200) -> None: + self.threshold = threshold + + def __call__(self, ctx: ResolveContext) -> str | None: + """Return a truncated marker if *ctx.default_repr* is too long.""" + if ctx.kind not in {"data", "attribute"}: + return None + text = ctx.default_repr + if not text or len(text) <= self.threshold: + return None + return f"<...truncated, {len(text)} chars>" + + +_DATA_RESOLVERS: tuple[Resolver, ...] = (TruncateLongRepr(),) + + +def _curate_value_line( + documenter: DataDocumenter | AttributeDocumenter, + line: str, +) -> str | None: + """Decide what to do with a ``:value: …`` directive line. + + Returns + ------- + - ``None`` to keep the original line. + - ``""`` to suppress (do not emit any ``:value:`` line). + - A new line string starting with ``" :value: "`` to replace. + + Examples + -------- + >>> import types + >>> stub = types.SimpleNamespace( + ... config=types.SimpleNamespace(gp_typehints_curate_data_defaults=True), + ... object='admin', + ... objtype='data', + ... fullname='mod.SHORT', + ... ) + >>> _curate_value_line(stub, " :module: mod") is None + True + >>> _curate_value_line(stub, " :value: 'admin'") is None + True + >>> long_repr = repr(['x' * 50] * 10) + >>> stub.object = ['x' * 50] * 10 + >>> stub.fullname = 'mod.LONG' + >>> _curate_value_line(stub, f" :value: {long_repr}") + ' :value: <...truncated, 540 chars>' + >>> stub.config.gp_typehints_curate_data_defaults = False + >>> _curate_value_line(stub, f" :value: {long_repr}") is None + True + """ + if not line.startswith(_VALUE_PREFIX): + return None + config_flag = getattr(documenter.config, "gp_typehints_curate_data_defaults", True) + if not config_flag: + return None + raw_repr = line[len(_VALUE_PREFIX) :] + ctx = ResolveContext( + value=documenter.object, + kind=documenter.objtype, + qualname=documenter.fullname or "", + param_name=None, + default_repr=raw_repr, + ) + text = _run_chain(ctx, _DATA_RESOLVERS) + if text is None: + return None + if text == "": + return "" + return f"{_VALUE_PREFIX}{text}" + + +class GpDataDocumenter(DataDocumenter): + """``DataDocumenter`` that curates ``:value:`` text via the resolver chain.""" + + objtype = "data" + priority = DataDocumenter.priority + 1 + + def add_line(self, line: str, source: str, *lineno: int) -> None: + """Curate ``:value:`` lines; pass everything else through unchanged.""" + result = _curate_value_line(self, line) + if result is None: + super().add_line(line, source, *lineno) + elif result == "": + return + else: + super().add_line(result, source, *lineno) + + +class GpAttributeDocumenter(AttributeDocumenter): + """``AttributeDocumenter`` that curates ``:value:`` text via the resolver chain.""" + + objtype = "attribute" + priority = AttributeDocumenter.priority + 1 + + def add_line(self, line: str, source: str, *lineno: int) -> None: + """Curate ``:value:`` lines; pass everything else through unchanged.""" + result = _curate_value_line(self, line) + if result is None: + super().add_line(line, source, *lineno) + elif result == "": + return + else: + super().add_line(result, source, *lineno) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index ae5ac213..e7815cc3 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -581,6 +581,10 @@ def setup(app: Sphinx) -> dict[str, t.Any]: >>> setup # doctest: +ELLIPSIS """ + from sphinx_autodoc_typehints_gp._data_defaults import ( + GpAttributeDocumenter, + GpDataDocumenter, + ) from sphinx_autodoc_typehints_gp._param_defaults import ( update_synthetic_defvalues, ) @@ -591,6 +595,14 @@ def setup(app: Sphinx) -> dict[str, t.Any]: rebuild="env", types=frozenset({bool}), ) + app.add_config_value( + "gp_typehints_curate_data_defaults", + default=True, + rebuild="env", + types=frozenset({bool}), + ) + app.add_autodocumenter(GpDataDocumenter, override=True) + app.add_autodocumenter(GpAttributeDocumenter, override=True) app.connect("builder-inited", _clear_caches) try: app.connect("autodoc-process-docstring", process_docstring) diff --git a/tests/ext/typehints_gp/test_data_defaults.py b/tests/ext/typehints_gp/test_data_defaults.py new file mode 100644 index 00000000..cb7ff1a7 --- /dev/null +++ b/tests/ext/typehints_gp/test_data_defaults.py @@ -0,0 +1,115 @@ +"""Unit tests for sphinx_autodoc_typehints_gp._data_defaults.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from sphinx_autodoc_typehints_gp._data_defaults import ( + TruncateLongRepr, + _curate_value_line, +) +from sphinx_autodoc_typehints_gp._param_defaults import ResolveContext + + +def _ctx(default_repr: str, kind: str = "data") -> ResolveContext: + return ResolveContext( + value=None, + kind=kind, + qualname="mod.X", + param_name=None, + default_repr=default_repr, + ) + + +# --------------------------------------------------------------------------- +# TruncateLongRepr +# --------------------------------------------------------------------------- + + +class _TruncateFixture(t.NamedTuple): + test_id: str + default_repr: str + threshold: int + expected: str | None + + +_TRUNCATE_FIXTURES: list[_TruncateFixture] = [ + _TruncateFixture("short", "[1, 2]", 10, None), + _TruncateFixture("at_threshold", "0123456789", 10, None), + _TruncateFixture("over_threshold", "x" * 50, 10, "<...truncated, 50 chars>"), + _TruncateFixture("empty", "", 10, None), +] + + +@pytest.mark.parametrize( + list(_TruncateFixture._fields), + _TRUNCATE_FIXTURES, + ids=[f.test_id for f in _TRUNCATE_FIXTURES], +) +def test_truncate_long_repr_decides_per_length( + test_id: str, + default_repr: str, + threshold: int, + expected: str | None, +) -> None: + """TruncateLongRepr returns the truncated marker only above threshold.""" + del test_id + assert TruncateLongRepr(threshold=threshold)(_ctx(default_repr)) == expected + + +def test_truncate_long_repr_defers_for_param_kind() -> None: + """TruncateLongRepr is for data/attribute only; param contexts defer.""" + long_text = "x" * 500 + assert TruncateLongRepr(threshold=200)(_ctx(long_text, kind="param")) is None + + +# --------------------------------------------------------------------------- +# _curate_value_line +# --------------------------------------------------------------------------- + + +class _FakeConfig: + gp_typehints_curate_data_defaults: bool = True + + +class _FakeDocumenter: + """Minimal stand-in for DataDocumenter / AttributeDocumenter.""" + + def __init__(self, *, value: object, fullname: str, objtype: str = "data") -> None: + self.object = value + self.fullname = fullname + self.objtype = objtype + self.config = _FakeConfig() + + +def test_curate_value_line_passes_through_non_value_lines() -> None: + """Lines that aren't `:value: …` are not touched.""" + documenter = t.cast("t.Any", _FakeDocumenter(value=None, fullname="mod.X")) + assert _curate_value_line(documenter, " :type: int") is None + + +def test_curate_value_line_keeps_short_values() -> None: + """Short values fall through the resolver chain (returns None).""" + documenter = t.cast("t.Any", _FakeDocumenter(value=None, fullname="mod.X")) + assert _curate_value_line(documenter, " :value: 42") is None + + +def test_curate_value_line_truncates_long_values() -> None: + """Long values are replaced with a truncated marker.""" + long_text = "[1, 2, 3" + ", 4" * 200 + "]" + documenter = t.cast("t.Any", _FakeDocumenter(value=None, fullname="mod.X")) + line = f" :value: {long_text}" + result = _curate_value_line(documenter, line) + assert result is not None + assert result.startswith(" :value: <...truncated,") + assert result.endswith(" chars>") + + +def test_curate_value_line_skipped_when_flag_disabled() -> None: + """Setting the kill-switch makes the curator a no-op for value lines.""" + long_text = "x" * 500 + documenter = t.cast("t.Any", _FakeDocumenter(value=None, fullname="mod.X")) + documenter.config.gp_typehints_curate_data_defaults = False + assert _curate_value_line(documenter, f" :value: {long_text}") is None diff --git a/tests/ext/typehints_gp/test_data_defaults_integration.py b/tests/ext/typehints_gp/test_data_defaults_integration.py new file mode 100644 index 00000000..fad6b31b --- /dev/null +++ b/tests/ext/typehints_gp/test_data_defaults_integration.py @@ -0,0 +1,131 @@ +"""Integration tests for data/attribute :value: rendering.""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +# A module with one short and one long module-level constant. The long +# one's `repr()` will exceed the 200-char threshold and trigger truncation. +_MODULE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + + + SHORT_VALUE = 42 + \"\"\"A short module-level constant.\"\"\" + + + LONG_VALUE = [ + ('one', 1), + ('two', 2), + ('three', 3), + ('four', 4), + ('five', 5), + ('six', 6), + ('seven', 7), + ('eight', 8), + ('nine', 9), + ('ten', 10), + ('eleven', 11), + ('twelve', 12), + ('thirteen', 13), + ('fourteen', 14), + ('fifteen', 15), + ('sixteen', 16), + ('seventeen', 17), + ('eighteen', 18), + ('nineteen', 19), + ('twenty', 20), + ] + \"\"\"A long module-level constant whose repr exceeds 200 chars.\"\"\" + """ +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + import sys + + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = [ + "sphinx.ext.autodoc", + "sphinx_autodoc_typehints_gp", + ] + """ +) + +_INDEX_RST = textwrap.dedent( + """\ + Demo + ==== + + .. autodata:: data_defaults_demo.SHORT_VALUE + + .. autodata:: data_defaults_demo.LONG_VALUE + """ +) + + +@pytest.fixture(scope="module") +def data_defaults_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a Sphinx project with one short and one long data attribute.""" + cache_root = tmp_path_factory.mktemp("data-defaults-html") + scenario = SphinxScenario( + files=( + ScenarioFile("data_defaults_demo.py", _MODULE_SOURCE), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile("index.rst", _INDEX_RST), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("data_defaults_demo",), + ) + + +@pytest.mark.integration +def test_short_data_value_renders_unchanged( + data_defaults_html_result: SharedSphinxResult, +) -> None: + """Short data values fall through the resolver chain unchanged.""" + html = read_output(data_defaults_html_result, "index.html") + + # SHORT_VALUE = 42 should render its value as-is + assert "SHORT_VALUE" in html + assert ">42<" in html + + +@pytest.mark.integration +def test_long_data_value_is_truncated( + data_defaults_html_result: SharedSphinxResult, +) -> None: + """Long data values are replaced with a `<...truncated, N chars>` marker.""" + html = read_output(data_defaults_html_result, "index.html") + + assert "LONG_VALUE" in html + # Curated text rendered (note: HTML-escaped < and >) + assert "...truncated," in html + assert " chars" in html + # The original sprawling list contents must NOT appear + assert "'fourteen'" not in html + assert "'twenty'" not in html diff --git a/tests/ext/typehints_gp/test_unit.py b/tests/ext/typehints_gp/test_unit.py index 7894a5de..512b0dc3 100644 --- a/tests/ext/typehints_gp/test_unit.py +++ b/tests/ext/typehints_gp/test_unit.py @@ -1214,6 +1214,7 @@ def test_setup_registers_builder_inited_cache_clearing() -> None: types.SimpleNamespace( connect=lambda event, handler, **kw: connections.append((event, handler)), add_config_value=lambda *a, **kw: None, + add_autodocumenter=lambda *a, **kw: None, ), ) From 081f566d5b57d7bf8ae3f27d9890ea1ef7cf11f8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 9 May 2026 18:04:18 -0500 Subject: [PATCH 05/44] feat(typehints-gp[default-xref]) Cross-reference identifiers in default values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: After Stage A makes parameter arglists parseable (D2 + D3), Sphinx emits clean `default_value` spans containing source text — but the identifiers inside (`Foo`, `mod.Foo`, `libtmux_exc.LibTmuxException`) render as plain text. Sphinx never creates `pending_xref` nodes for default values; only for type annotations. The user asked for parameter defaults to render with the same `` HTML shape that inline `:py:class:`Foo`` produces in body prose. This commit ships that. what: - Add `_default_xref_transform.py` with `DefaultValueXrefTransform`, a SphinxPostTransform at priority 5 (below ReferencesResolver's 10 so emitted pending_xref nodes still resolve). Walks every `desc_parameter`'s `default_value` span, AST-parses the text, and replaces children with mixed Text + pending_xref output. The pending_xref's contnode is `nodes.literal(classes=['xref','py', 'py-class'])` — exactly the wrapping XRefRole produces. - Recursive `_ast_to_nodes` walker handles `Name`, `Attribute` (dotted chains), `Constant`, `Tuple`, `List`, `Set`, `Call`, and unary minus. Unsupported shapes (lambdas, comprehensions, dicts) raise SyntaxError so the caller falls back to the original text. - `_enclosing_signode_context` extracts `module` / `class` from the surrounding `desc_signature` and forwards them as `py:module` / `py:class` keys on each `pending_xref`, so unqualified targets like `Foo` in `def f(x=Foo)` resolve against the enclosing module — same machinery type annotations use. - Wire into extension.setup() via `app.add_post_transform`. - Reuses D3's `gp_typehints_curate_param_defaults` flag as the kill-switch (Stage C is meaningless without Stage A). - 22 unit tests covering each AST shape (Style A NamedTuple) plus 3 integration tests via _sphinx_scenarios.py asserting the rendered HTML contains `…` for class identifier defaults and falls back to plain text for lambdas. - Update D3's integration test to use a span-structure-agnostic plain-text reduction; D6's xref wrapping changes the local HTML layout but the rendered text content is unchanged. --- .../_default_xref_transform.py | 359 ++++++++++++++++++ .../sphinx_autodoc_typehints_gp/extension.py | 4 + tests/ext/typehints_gp/test_default_xref.py | 168 ++++++++ .../test_default_xref_integration.py | 166 ++++++++ .../test_param_defaults_integration.py | 31 +- tests/ext/typehints_gp/test_unit.py | 1 + 6 files changed, 712 insertions(+), 17 deletions(-) create mode 100644 packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py create mode 100644 tests/ext/typehints_gp/test_default_xref.py create mode 100644 tests/ext/typehints_gp/test_default_xref_integration.py diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py new file mode 100644 index 00000000..c3d3c2e8 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py @@ -0,0 +1,359 @@ +"""Cross-reference identifiers inside parameter default values. + +After Stage A (Sphinx's ``autodoc_preserve_defaults`` plus the +synthetic-init listener in :mod:`._param_defaults`) the arglist is +parseable and Sphinx emits ``nodes.inline(classes=['default_value'])`` +spans containing the source text of each default. Sphinx never +creates :class:`~sphinx.addnodes.pending_xref` nodes for default +values — only for type annotations. + +This module ships :class:`DefaultValueXrefTransform`, a +:class:`~sphinx.transforms.post_transforms.SphinxPostTransform` that +walks every ``default_value`` span inside an +:class:`~sphinx.addnodes.desc_parameter`, ``ast.parse``s its text, +and replaces the span's plain-text children with a mix of +``nodes.Text`` and ``pending_xref`` nodes — using the same +``:py:class:``-styled ``nodes.literal`` wrapping that Sphinx's +``XRefRole`` produces in inline prose: + +.. code-block:: html + + + + Foo + + + +Priority is **5** — strictly below +:class:`~sphinx.transforms.post_transforms.ReferencesResolver`'s +priority of 10 — so the ``pending_xref`` nodes we create are still +unresolved when the resolver runs. + +For unsupported AST shapes (lambdas, comprehensions, generator +expressions) the transform leaves the span untouched, which keeps +the existing plain-text rendering. +""" + +from __future__ import annotations + +import ast +import logging +import typing as t + +from docutils import nodes +from sphinx import addnodes +from sphinx.transforms.post_transforms import SphinxPostTransform + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + + +def _xref( + target: str, + title: str, + *, + py_module: str | None = None, + py_class: str | None = None, +) -> addnodes.pending_xref: + """Build a ``:py:class:``-styled pending_xref for *target*. + + The contnode is ``nodes.literal('', '', nodes.Text(title), + classes=['xref', 'py', 'py-class'])`` — exactly the shape an + inline ``:py:class:`target``` role would produce, so the + rendered HTML matches that of normal class references. + + *py_module* and *py_class* mirror the ``ref_context`` keys + Sphinx's Python domain reads when resolving references. Passing + them lets unqualified targets like ``Foo`` resolve against the + surrounding ``desc_signature``'s module. + + Examples + -------- + >>> n = _xref('mod.Foo', 'Foo') + >>> n['reftarget'] + 'mod.Foo' + >>> n['reftype'] + 'class' + >>> isinstance(n.children[0], nodes.literal) + True + """ + literal = nodes.literal( + "", + "", + nodes.Text(title), + classes=["xref", "py", "py-class"], + ) + xref = addnodes.pending_xref( + "", + literal, + refdomain="py", + reftype="class", + reftarget=target, + refexplicit=False, + refwarn=False, + ) + if py_module is not None: + xref["py:module"] = py_module + if py_class is not None: + xref["py:class"] = py_class + return xref + + +def _ast_to_nodes( + node: ast.AST, + *, + py_module: str | None = None, + py_class: str | None = None, +) -> list[nodes.Node]: + """Convert an ``ast`` expression node into docutils inline nodes. + + Identifier-emitting branches (``ast.Name``, ``ast.Attribute``) + produce ``pending_xref`` nodes whose contnode is a + ``:py:class:``-styled ``nodes.literal``. Constants emit + ``nodes.Text`` matching ``repr(value)``. Containers + (Tuple/List/Set) and Call expressions emit punctuation as + ``nodes.Text``. + + *py_module* / *py_class* are forwarded to every ``pending_xref`` + so that unqualified targets resolve against the surrounding + ``desc_signature``'s module/class context. + + Raises ``SyntaxError`` for unsupported shapes (lambdas, + comprehensions, generator expressions, dict/set literals, + operators we haven't taught it about). Callers catch and fall + back to the original text. + + Examples + -------- + >>> _ast_to_nodes(ast.parse('Foo', mode='eval').body)[0]['reftarget'] + 'Foo' + >>> _ast_to_nodes(ast.parse('mod.Foo', mode='eval').body)[0]['reftarget'] + 'mod.Foo' + >>> [n.astext() for n in _ast_to_nodes(ast.parse('42', mode='eval').body)] + ['42'] + """ + if isinstance(node, ast.Name): + return [_xref(node.id, node.id, py_module=py_module, py_class=py_class)] + if isinstance(node, ast.Attribute): + path = _attr_chain(node) + if path is None: + msg = f"unsupported attribute base: {ast.dump(node)}" + raise SyntaxError(msg) + return [_xref(path, path, py_module=py_module, py_class=py_class)] + if isinstance(node, ast.Constant): + if node.value is Ellipsis: + return [nodes.Text("...")] + return [nodes.Text(repr(node.value))] + if isinstance(node, ast.Tuple): + return _wrap_seq( + "(", + ")", + node.elts, + force_trailing_comma=len(node.elts) == 1, + py_module=py_module, + py_class=py_class, + ) + if isinstance(node, ast.List): + return _wrap_seq("[", "]", node.elts, py_module=py_module, py_class=py_class) + if isinstance(node, ast.Set): + if not node.elts: + msg = "empty set literal cannot be parsed" # ast won't yield this + raise SyntaxError(msg) + return _wrap_seq("{", "}", node.elts, py_module=py_module, py_class=py_class) + if isinstance(node, ast.Call): + result: list[nodes.Node] = [] + result.extend(_ast_to_nodes(node.func, py_module=py_module, py_class=py_class)) + result.append(nodes.Text("(")) + first = True + for arg in node.args: + if not first: + result.append(nodes.Text(", ")) + result.extend(_ast_to_nodes(arg, py_module=py_module, py_class=py_class)) + first = False + for kw in node.keywords: + if not first: + result.append(nodes.Text(", ")) + result.append(nodes.Text(f"{kw.arg}=")) + result.extend( + _ast_to_nodes(kw.value, py_module=py_module, py_class=py_class) + ) + first = False + result.append(nodes.Text(")")) + return result + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + return [ + nodes.Text("-"), + *_ast_to_nodes(node.operand, py_module=py_module, py_class=py_class), + ] + msg = f"unsupported expression: {ast.dump(node)}" + raise SyntaxError(msg) + + +def _wrap_seq( + opener: str, + closer: str, + elts: list[ast.expr], + *, + force_trailing_comma: bool = False, + py_module: str | None = None, + py_class: str | None = None, +) -> list[nodes.Node]: + """Render ``[a, b, c]`` / ``(a, b, c)`` style sequences. + + *force_trailing_comma* renders a trailing comma after the only + element of a 1-tuple to disambiguate ``(x,)`` from ``(x)``. + """ + result: list[nodes.Node] = [nodes.Text(opener)] + first = True + for elt in elts: + if not first: + result.append(nodes.Text(", ")) + result.extend(_ast_to_nodes(elt, py_module=py_module, py_class=py_class)) + first = False + if force_trailing_comma: + result.append(nodes.Text(",")) + result.append(nodes.Text(closer)) + return result + + +def _attr_chain(node: ast.Attribute) -> str | None: + """Reduce ``a.b.c`` Attribute chains to a dotted string. + + Returns ``None`` if the leftmost base isn't a Name (e.g. a + function-call result like ``foo().bar`` — too dynamic to + cross-reference statically). + """ + parts: list[str] = [node.attr] + current: ast.expr = node.value + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if not isinstance(current, ast.Name): + return None + parts.append(current.id) + return ".".join(reversed(parts)) + + +def _transform_default_value_span( + span: nodes.inline, + *, + py_module: str | None = None, + py_class: str | None = None, +) -> bool: + """Mutate *span*'s children with cross-referenced AST nodes. + + *py_module* / *py_class* are forwarded to each ``pending_xref`` + so unqualified identifiers resolve against the surrounding + ``desc_signature``'s module/class. + + Returns ``True`` if children were rewritten, ``False`` if the + span was left untouched (unsupported AST shape, parse failure, + or empty content). + + Examples + -------- + >>> from docutils import nodes + >>> span = nodes.inline("", "Foo", classes=["default_value"]) + >>> _transform_default_value_span(span) + True + >>> span = nodes.inline("", "lambda: 1", classes=["default_value"]) + >>> _transform_default_value_span(span) # unparseable -> untouched + False + >>> span = nodes.inline("", " ", classes=["default_value"]) + >>> _transform_default_value_span(span) # whitespace-only + False + """ + text = span.astext() + if not text.strip(): + return False + try: + tree = ast.parse(text, mode="eval") + new_children = _ast_to_nodes(tree.body, py_module=py_module, py_class=py_class) + except SyntaxError: + return False + if not new_children: + return False + span.clear() + span.extend(new_children) + return True + + +def _enclosing_signode_context( + parameter: addnodes.desc_parameter, +) -> tuple[str | None, str | None]: + """Read ``module``/``class`` attributes off the enclosing ``desc_signature``. + + Returns ``(None, None)`` when *parameter* has no ``desc_signature`` + ancestor — typically because the test harness builds a bare + ``desc_parameter`` outside a real Sphinx tree. + + Examples + -------- + >>> from sphinx import addnodes + >>> sig = addnodes.desc_signature("", "", module="libtmux.session", + ... class_=None) + >>> sig["class"] = "Session" + >>> param = addnodes.desc_parameter() + >>> sig.append(param) + >>> _enclosing_signode_context(param) + ('libtmux.session', 'Session') + >>> _enclosing_signode_context(addnodes.desc_parameter()) + (None, None) + """ + parent = parameter.parent + while parent is not None and not isinstance(parent, addnodes.desc_signature): + parent = parent.parent + if parent is None: + return None, None + return parent.get("module"), parent.get("class") + + +class DefaultValueXrefTransform(SphinxPostTransform): + """Convert identifier text inside ``default_value`` spans to live xrefs. + + Walks every ``nodes.inline`` whose ``classes`` includes + ``'default_value'`` *inside* a + :class:`~sphinx.addnodes.desc_parameter`, AST-parses the text, + and replaces it with mixed-node output that includes + :class:`~sphinx.addnodes.pending_xref` for each identifier. + + Hand-written ``.. py:function:: foo(x=Bar)`` directives are + handled identically because they emit the same span structure. + """ + + default_priority = 5 + + def run(self, **kwargs: t.Any) -> None: + """Walk every desc_parameter's default_value span and rewrite it.""" + del kwargs + config_flag = getattr( + self.app.config, + "gp_typehints_curate_param_defaults", + True, + ) + if not config_flag: + return + for parameter in self.document.findall(addnodes.desc_parameter): + py_module, py_class = _enclosing_signode_context(parameter) + for span in parameter.findall(nodes.inline): + classes = span.get("classes") or [] + if "default_value" not in classes: + continue + _transform_default_value_span( + span, + py_module=py_module, + py_class=py_class, + ) + + +def register(app: Sphinx) -> None: + """Register the transform with the Sphinx app. + + Examples + -------- + >>> register # doctest: +ELLIPSIS + + """ + app.add_post_transform(DefaultValueXrefTransform) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index e7815cc3..2f603392 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -585,6 +585,9 @@ def setup(app: Sphinx) -> dict[str, t.Any]: GpAttributeDocumenter, GpDataDocumenter, ) + from sphinx_autodoc_typehints_gp._default_xref_transform import ( + register as register_default_xref_transform, + ) from sphinx_autodoc_typehints_gp._param_defaults import ( update_synthetic_defvalues, ) @@ -603,6 +606,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: ) app.add_autodocumenter(GpDataDocumenter, override=True) app.add_autodocumenter(GpAttributeDocumenter, override=True) + register_default_xref_transform(app) app.connect("builder-inited", _clear_caches) try: app.connect("autodoc-process-docstring", process_docstring) diff --git a/tests/ext/typehints_gp/test_default_xref.py b/tests/ext/typehints_gp/test_default_xref.py new file mode 100644 index 00000000..4a4a2bb8 --- /dev/null +++ b/tests/ext/typehints_gp/test_default_xref.py @@ -0,0 +1,168 @@ +"""Unit tests for sphinx_autodoc_typehints_gp._default_xref_transform.""" + +from __future__ import annotations + +import ast +import typing as t + +import pytest +from docutils import nodes +from sphinx import addnodes + +from sphinx_autodoc_typehints_gp._default_xref_transform import ( + _ast_to_nodes, + _attr_chain, + _transform_default_value_span, + _xref, +) + +# --------------------------------------------------------------------------- +# _xref shape +# --------------------------------------------------------------------------- + + +def test_xref_builds_pending_xref_with_literal_contnode() -> None: + """The pending_xref wraps a `:py:class:`-styled literal.""" + n = _xref("mod.Foo", "Foo") + assert n["refdomain"] == "py" + assert n["reftype"] == "class" + assert n["reftarget"] == "mod.Foo" + assert isinstance(n.children[0], nodes.literal) + literal = n.children[0] + assert literal["classes"] == ["xref", "py", "py-class"] + assert literal.astext() == "Foo" + + +# --------------------------------------------------------------------------- +# _attr_chain +# --------------------------------------------------------------------------- + + +class _AttrChainFixture(t.NamedTuple): + test_id: str + source: str + expected: str | None + + +_ATTR_CHAIN_FIXTURES: list[_AttrChainFixture] = [ + _AttrChainFixture("two_parts", "a.b", "a.b"), + _AttrChainFixture("three_parts", "a.b.c", "a.b.c"), + _AttrChainFixture("call_base", "a().b", None), +] + + +@pytest.mark.parametrize( + list(_AttrChainFixture._fields), + _ATTR_CHAIN_FIXTURES, + ids=[f.test_id for f in _ATTR_CHAIN_FIXTURES], +) +def test_attr_chain_dot_joins_static_chains_only( + test_id: str, + source: str, + expected: str | None, +) -> None: + """_attr_chain reduces dotted chains anchored at a Name.""" + del test_id + tree = ast.parse(source, mode="eval") + attr = tree.body + assert isinstance(attr, ast.Attribute) + assert _attr_chain(attr) == expected + + +# --------------------------------------------------------------------------- +# _ast_to_nodes shapes +# --------------------------------------------------------------------------- + + +class _AstShapeFixture(t.NamedTuple): + test_id: str + source: str + xref_targets: list[str] + + +_AST_SHAPE_FIXTURES: list[_AstShapeFixture] = [ + _AstShapeFixture("bare_name", "Foo", ["Foo"]), + _AstShapeFixture("attribute", "mod.Foo", ["mod.Foo"]), + _AstShapeFixture("nested_attribute", "a.b.Foo", ["a.b.Foo"]), + _AstShapeFixture("tuple_of_one", "(Foo,)", ["Foo"]), + _AstShapeFixture("tuple_of_two", "(Foo, Bar)", ["Foo", "Bar"]), + _AstShapeFixture("list_of_classes", "[Foo, Bar]", ["Foo", "Bar"]), + _AstShapeFixture("call_no_args", "Foo()", ["Foo"]), + _AstShapeFixture("call_with_kw", "Foo(x=Bar)", ["Foo", "Bar"]), + _AstShapeFixture("constant_int", "42", []), + _AstShapeFixture("constant_str", "'hello'", []), + _AstShapeFixture("constant_none", "None", []), # ast.Constant — no xref + _AstShapeFixture("ellipsis", "...", []), +] + + +@pytest.mark.parametrize( + list(_AstShapeFixture._fields), + _AST_SHAPE_FIXTURES, + ids=[f.test_id for f in _AST_SHAPE_FIXTURES], +) +def test_ast_to_nodes_emits_expected_xref_targets( + test_id: str, + source: str, + xref_targets: list[str], +) -> None: + """_ast_to_nodes emits one pending_xref per identifier reference.""" + del test_id + tree = ast.parse(source, mode="eval") + out = _ast_to_nodes(tree.body) + actual = [n["reftarget"] for n in out if isinstance(n, addnodes.pending_xref)] + assert actual == xref_targets + + +def test_ast_to_nodes_raises_on_lambda() -> None: + """Lambdas are unsupported and raise SyntaxError so the caller falls back.""" + tree = ast.parse("lambda: 1", mode="eval") + with pytest.raises(SyntaxError): + _ast_to_nodes(tree.body) + + +def test_ast_to_nodes_raises_on_dict_literal() -> None: + """Dict literals are unsupported (deferred to a future resolver).""" + tree = ast.parse("{'a': 1}", mode="eval") + with pytest.raises(SyntaxError): + _ast_to_nodes(tree.body) + + +# --------------------------------------------------------------------------- +# _transform_default_value_span +# --------------------------------------------------------------------------- + + +def _make_default_value_span(text: str) -> nodes.inline: + return nodes.inline("", text, classes=["default_value"]) + + +def test_transform_rewrites_identifier_to_xref() -> None: + """A bare identifier becomes a pending_xref with the right target.""" + span = _make_default_value_span("Foo") + assert _transform_default_value_span(span) is True + xrefs = list(span.findall(addnodes.pending_xref)) + assert len(xrefs) == 1 + assert xrefs[0]["reftarget"] == "Foo" + + +def test_transform_rewrites_tuple_of_classes() -> None: + """A tuple-of-classes default produces one xref per class.""" + span = _make_default_value_span("(Foo, Bar)") + assert _transform_default_value_span(span) is True + xrefs = list(span.findall(addnodes.pending_xref)) + assert [n["reftarget"] for n in xrefs] == ["Foo", "Bar"] + + +def test_transform_leaves_unsupported_text_alone() -> None: + """Unparseable text leaves the span untouched.""" + span = _make_default_value_span("lambda: 1") + assert _transform_default_value_span(span) is False + # Still has the original text content + assert span.astext() == "lambda: 1" + + +def test_transform_skips_empty_span() -> None: + """Whitespace-only spans are ignored.""" + span = _make_default_value_span(" ") + assert _transform_default_value_span(span) is False diff --git a/tests/ext/typehints_gp/test_default_xref_integration.py b/tests/ext/typehints_gp/test_default_xref_integration.py new file mode 100644 index 00000000..7044467c --- /dev/null +++ b/tests/ext/typehints_gp/test_default_xref_integration.py @@ -0,0 +1,166 @@ +"""Integration tests for cross-referenced parameter defaults.""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +# A documented Foo class plus a function whose default references it. +# Sphinx's autodoc_preserve_defaults captures `Foo` as the source-text +# default for `bar`, which Stage C then turns into a pending_xref to +# the documented Foo class. +_MODULE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + + + class Foo: + \"\"\"A documented sentinel class used as a default.\"\"\" + + + def bar(x: int = 0, sentinel: object = Foo) -> None: + \"\"\"Function whose `sentinel` default references the Foo class.\"\"\" + """ +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + import sys + + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = [ + "sphinx.ext.autodoc", + "sphinx_autodoc_typehints_gp", + ] + + autodoc_preserve_defaults = True + """ +) + +_INDEX_RST = textwrap.dedent( + """\ + Demo + ==== + + .. autoclass:: default_xref_demo.Foo + + .. autofunction:: default_xref_demo.bar + """ +) + + +@pytest.fixture(scope="module") +def default_xref_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a Sphinx project where one default references a documented class.""" + cache_root = tmp_path_factory.mktemp("default-xref-html") + scenario = SphinxScenario( + files=( + ScenarioFile("default_xref_demo.py", _MODULE_SOURCE), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile("index.rst", _INDEX_RST), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("default_xref_demo",), + ) + + +@pytest.mark.integration +def test_default_value_class_renders_as_xref_link( + default_xref_html_result: SharedSphinxResult, +) -> None: + """An identifier in a default value renders as the same xref shape as :py:class:.""" + html = read_output(default_xref_html_result, "index.html") + + # The bar() signature must contain a :py:class:-styled xref to Foo, + # not plain text. The exact HTML shape from the user's brief: + # Foo + assert 'href="#default_xref_demo.Foo"' in html # resolved link target + assert 'class="reference internal"' in html # the wrapping + assert 'class="xref py py-class' in html # the wrapping + # The literal Foo span text appears wrapped in the xref code block + assert ">Foo<" in html + + +@pytest.mark.integration +def test_short_literal_default_remains_text( + default_xref_html_result: SharedSphinxResult, +) -> None: + """The integer default `0` is a Constant; no xref is created for it.""" + html = read_output(default_xref_html_result, "index.html") + + # The literal `0` should appear as a default_value span without an xref + # wrapping. We check that the constant text is present. + assert ">0<" in html + + +@pytest.mark.integration +def test_unsupported_default_falls_back_to_plain_text( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Unparseable defaults (lambdas) leave the span as plain text.""" + module_source = textwrap.dedent( + """\ + from __future__ import annotations + + + def has_lambda_default(callback=lambda: 1) -> None: + \"\"\"Function with a lambda default.\"\"\" + """ + ) + cache_root = tmp_path_factory.mktemp("default-xref-lambda-html") + scenario = SphinxScenario( + files=( + ScenarioFile("lambda_demo.py", module_source), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile( + "index.rst", + textwrap.dedent( + """\ + Demo + ==== + + .. autofunction:: lambda_demo.has_lambda_default + """ + ), + ), + ), + ) + result = build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("lambda_demo",), + ) + html = read_output(result, "index.html") + + # The lambda text appears in the rendered output but not wrapped in an xref + assert "lambda" in html + # And the build did not fail (no warnings escalated) + assert "callback" in html diff --git a/tests/ext/typehints_gp/test_param_defaults_integration.py b/tests/ext/typehints_gp/test_param_defaults_integration.py index 457dccc9..b262c5dc 100644 --- a/tests/ext/typehints_gp/test_param_defaults_integration.py +++ b/tests/ext/typehints_gp/test_param_defaults_integration.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import textwrap import pytest @@ -90,28 +91,24 @@ def test_dataclass_factory_defaults_render_as_source_text( factory_defaults_html_result: SharedSphinxResult, ) -> None: """Dataclass default_factory params render source text, not .""" - import re - html = read_output(factory_defaults_html_result, "index.html") - # Extract plain text of every default_value span so we don't depend on - # the span/whitespace wrapping Sphinx applies between ``=`` and the value. - rendered = { - re.sub(r"<[^>]+>", "", m).strip() - for m in re.findall( - r'class="default_value">([^<]*(?:<[^>]+>[^<]*)*?)', - html, - ) - } - assert "[]" in rendered, f"missing list factory rendering; got {rendered!r}" - assert "{}" in rendered, f"missing dict factory rendering; got {rendered!r}" - assert "set()" in rendered, f"missing set factory rendering; got {rendered!r}" - # The direct-value default also lands in a default_value span - assert "5" in rendered, f"missing direct default; got {rendered!r}" - # The raw sentinel must not appear in the rendered output + # The raw sentinel must not appear anywhere — the contract + # of this fix. assert "<factory>" not in html assert "" not in html + # The chosen source-text fragments do appear in the page (each one + # may be split across xref / Text nodes by D6's post-transform, so + # we look at the plain-text reduction). + plain = re.sub(r"<[^>]+>", "", html) + plain = plain.replace("<", "<").replace(">", ">").replace(" ", " ") + plain = re.sub(r"\s+", " ", plain) + assert "items: list[int] = []" in plain + assert "mapping: dict[str, int] = {}" in plain + assert "names: set[str] = set()" in plain + assert "count: int = 5" in plain + @pytest.mark.integration def test_dataclass_factory_defaults_use_default_value_span( diff --git a/tests/ext/typehints_gp/test_unit.py b/tests/ext/typehints_gp/test_unit.py index 512b0dc3..ff33f56a 100644 --- a/tests/ext/typehints_gp/test_unit.py +++ b/tests/ext/typehints_gp/test_unit.py @@ -1215,6 +1215,7 @@ def test_setup_registers_builder_inited_cache_clearing() -> None: connect=lambda event, handler, **kw: connections.append((event, handler)), add_config_value=lambda *a, **kw: None, add_autodocumenter=lambda *a, **kw: None, + add_post_transform=lambda *a, **kw: None, ), ) From ed2d7a1cb5d58322b1cafee3d475d3c4e50c2c03 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 9 May 2026 18:07:35 -0500 Subject: [PATCH 06/44] refactor(typehints-gp[resolvers]) Factor shared catalog into _resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: After D3 and D4 each landed with their own copies of ResolveContext, Resolver Protocol, and run_chain, the duplication was ready to be factored into one home. The empirical inventory in notes/defaults-discovery-d1.md shows the workspace's resolver needs are homogeneous (factory + truncation), so this is also the right moment to decide on a public API: hold private behind the underscore-prefixed module name pending external demand. Migrating to a public surface later is mechanical (one app.add_default_resolver call exposed via extension.setup) and the evidence doesn't justify shipping that maintenance surface speculatively. what: - Add `_resolvers.py` hosting `ResolveContext`, `Resolver`, `run_chain`, `DataclassFactoryRepr`, and `TruncateLongRepr`. - Slim `_param_defaults.py` down to its listener (`update_synthetic_defvalues`, `_walk_to_dataclass`) and its resolver tuple (`_DEFAULT_RESOLVERS = (DataclassFactoryRepr(),)`) — imports the shared classes. - Slim `_data_defaults.py` similarly: keeps `Gp{Data,Attribute}Documenter`, `_curate_value_line`, and its resolver tuple; imports `TruncateLongRepr` from the shared module. - Update tests to import from `_resolvers` so the assertions exercise the canonical home. - Add `notes/defaults-discovery-d5.md` documenting the factoring outcome plus the "hold public API private" decision and the triggers that should make us revisit it. - Behavior unchanged. Same 1 536 tests pass; same docs build. --- .../_data_defaults.py | 49 +---- .../_param_defaults.py | 131 +----------- .../sphinx_autodoc_typehints_gp/_resolvers.py | 187 ++++++++++++++++++ tests/ext/typehints_gp/test_data_defaults.py | 6 +- tests/ext/typehints_gp/test_param_defaults.py | 6 +- 5 files changed, 207 insertions(+), 172 deletions(-) create mode 100644 packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_resolvers.py diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py index 640bc2b8..7dbbd01b 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py @@ -29,56 +29,15 @@ from sphinx.ext.autodoc import AttributeDocumenter, DataDocumenter -from sphinx_autodoc_typehints_gp._param_defaults import ( +from sphinx_autodoc_typehints_gp._resolvers import ( ResolveContext, Resolver, - _run_chain, + TruncateLongRepr, + run_chain, ) _VALUE_PREFIX: t.Final = " :value: " - -class TruncateLongRepr: - """Truncate long ``:value:`` text. - - Returns ``None`` (defer) for parameter contexts and for short - reprs; returns ``"<...truncated, N chars>"`` for reprs over the - threshold. - - Examples - -------- - >>> r = TruncateLongRepr(threshold=10) - >>> r(ResolveContext( - ... value=None, - ... kind='data', - ... qualname='mod.X', - ... param_name=None, - ... default_repr='[1, 2]', - ... )) is None - True - >>> r(ResolveContext( - ... value=None, - ... kind='data', - ... qualname='mod.X', - ... param_name=None, - ... default_repr='this string is longer than ten characters', - ... )) - '<...truncated, 41 chars>' - """ - - def __init__(self, threshold: int = 200) -> None: - self.threshold = threshold - - def __call__(self, ctx: ResolveContext) -> str | None: - """Return a truncated marker if *ctx.default_repr* is too long.""" - if ctx.kind not in {"data", "attribute"}: - return None - text = ctx.default_repr - if not text or len(text) <= self.threshold: - return None - return f"<...truncated, {len(text)} chars>" - - _DATA_RESOLVERS: tuple[Resolver, ...] = (TruncateLongRepr(),) @@ -129,7 +88,7 @@ def _curate_value_line( param_name=None, default_repr=raw_repr, ) - text = _run_chain(ctx, _DATA_RESOLVERS) + text = run_chain(ctx, _DATA_RESOLVERS) if text is None: return None if text == "": diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py index cceb4098..97aa8152 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py @@ -35,136 +35,21 @@ from sphinx.util.inspect import DefaultValue +from sphinx_autodoc_typehints_gp._resolvers import ( + DataclassFactoryRepr, + ResolveContext, + Resolver, + run_chain, +) + if t.TYPE_CHECKING: from sphinx.application import Sphinx logger = logging.getLogger(__name__) - -class ResolveContext(t.NamedTuple): - """Context passed to each :class:`Resolver` in the chain. - - Parameters - ---------- - value : object - The live Python object (a callable for ``default_factory`` - cases, or the raw default for direct-value cases). - kind : str - One of ``'param'``, ``'data'``, ``'attribute'``. Stage B - listeners only emit ``'param'``; Stage A's - ``GpDataDocumenter`` (D4) emits ``'data'`` / ``'attribute'``. - qualname : str - Fully qualified name of the documented object, e.g. - ``'libtmux.constants.HookEventDataclass.__init__'``. - param_name : str | None - Set when ``kind == 'param'``; the parameter name being - resolved. - default_repr : str - Sphinx's stock ``object_description`` of ``value``, available - as a fallback for resolvers that want to compose with the - default rendering. - """ - - value: object - kind: str - qualname: str - param_name: str | None - default_repr: str - - -class Resolver(t.Protocol): - """Compute a symbolic source-text string for a default value. - - Resolvers are run in priority order; the first non-``None`` - result wins. Return ``None`` to defer to the next resolver. - Return an empty string to suppress the default (Site A only; - parameter defaults cannot be suppressed). - """ - - def __call__(self, ctx: ResolveContext) -> str | None: - """Return the chosen text or ``None`` to defer.""" - ... - - -class DataclassFactoryRepr: - """Render :func:`dataclasses.field` ``default_factory`` symbolically. - - Recognises stdlib container constructors (``list``, ``dict``, - ``set``, ``frozenset``, ``tuple``) and named callable types. - Defers (returns ``None``) on lambdas and unrecognised factories, - leaving Sphinx's stock ```` rendering in place. - - Examples - -------- - >>> r = DataclassFactoryRepr() - >>> ctx = ResolveContext( - ... value=list, - ... kind='param', - ... qualname='Foo.__init__', - ... param_name='items', - ... default_repr='', - ... ) - >>> r(ctx) - '[]' - >>> r(ctx._replace(value=dict)) - '{}' - >>> r(ctx._replace(value=set)) - 'set()' - >>> r(ctx._replace(value=lambda: 1)) is None - True - """ - - _BUILTIN_LITERALS: t.ClassVar[dict[type, str]] = { - list: "[]", - dict: "{}", - set: "set()", - frozenset: "frozenset()", - tuple: "()", - } - - def __call__(self, ctx: ResolveContext) -> str | None: - """Resolve a ``default_factory`` callable to its source text.""" - if ctx.kind != "param": - return None - factory = ctx.value - if isinstance(factory, type): - literal = self._BUILTIN_LITERALS.get(factory) - if literal is not None: - return literal - name = getattr(factory, "__name__", None) - if name and name != "": - return f"{name}()" - return None - - _DEFAULT_RESOLVERS: tuple[Resolver, ...] = (DataclassFactoryRepr(),) -def _run_chain( - ctx: ResolveContext, - resolvers: tuple[Resolver, ...], -) -> str | None: - """Run *resolvers* in order and return the first non-``None`` result. - - Examples - -------- - >>> ctx = ResolveContext( - ... value=list, - ... kind='param', - ... qualname='Foo.__init__', - ... param_name='items', - ... default_repr='', - ... ) - >>> _run_chain(ctx, _DEFAULT_RESOLVERS) - '[]' - """ - for resolver in resolvers: - result = resolver(ctx) - if result is not None: - return result - return None - - def _walk_to_dataclass(obj: t.Any) -> type | None: """Find the dataclass that owns *obj*'s synthetic ``__init__``. @@ -275,7 +160,7 @@ def update_synthetic_defvalues( param_name=param.name, default_repr="", ) - text = _run_chain(ctx, _DEFAULT_RESOLVERS) + text = run_chain(ctx, _DEFAULT_RESOLVERS) if text is None: new_parameters.append(param) continue diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_resolvers.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_resolvers.py new file mode 100644 index 00000000..2de3f4f9 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_resolvers.py @@ -0,0 +1,187 @@ +"""Shared resolver catalog for parameter and data/attribute defaults. + +This module hosts the resolver Protocol and the built-in resolver +catalog used by both Site B (parameter-default rewriting in +:mod:`._param_defaults`) and Site A (data/attribute ``:value:`` +curation in :mod:`._data_defaults`). Stage C's xref transform +(:mod:`._default_xref_transform`) does *not* consume these +resolvers — it is a node-tree pass that operates on already-rendered +text — so the catalog stays scoped to the two text-replacement +sites. + +The surface is **private** (the leading underscore in the module +name): consumers should import from +:mod:`sphinx_autodoc_typehints_gp` re-exports if a public API is +ever needed. The empirical inventory in +``notes/defaults-discovery-d1.md`` shows the workspace's resolver +needs are homogeneous (factory-sentinel + truncation), so no +external registration mechanism is shipped today. If a downstream +consumer surfaces a divergent need, expose ``add_default_resolver`` +through ``extension.setup()`` then. +""" + +from __future__ import annotations + +import typing as t + + +class ResolveContext(t.NamedTuple): + """Context passed to each :class:`Resolver` in the chain. + + Attributes + ---------- + value : object + The live Python object: a callable for ``default_factory`` + cases, the raw default for direct-value cases, or the + documented attribute's value for Site A. + kind : str + One of ``'param'``, ``'data'``, ``'attribute'``. Resolvers + check this to scope themselves to the right site. + qualname : str + Fully qualified name of the documented object, e.g. + ``'libtmux.constants.HookEventDataclass.__init__'``. + param_name : str | None + Set when ``kind == 'param'``; ``None`` for Site A. + default_repr : str + Sphinx's stock ``object_description`` of ``value`` (Site A) + or the literal string ``''`` (Site B's + :class:`DataclassFactoryRepr` path). Resolvers can use this + as a fallback or input string. + """ + + value: object + kind: str + qualname: str + param_name: str | None + default_repr: str + + +class Resolver(t.Protocol): + """Compute a symbolic source-text string for a default value. + + Resolvers are run in priority order; the first non-``None`` + result wins. Return ``None`` to defer. Return ``""`` to suppress + (Site A only — parameter defaults cannot be suppressed). + """ + + def __call__(self, ctx: ResolveContext) -> str | None: + """Return the chosen text or ``None`` to defer.""" + ... + + +def run_chain( + ctx: ResolveContext, + resolvers: tuple[Resolver, ...], +) -> str | None: + """Run *resolvers* in order and return the first non-``None`` result. + + Examples + -------- + >>> ctx = ResolveContext( + ... value=list, + ... kind='param', + ... qualname='Foo.__init__', + ... param_name='items', + ... default_repr='', + ... ) + >>> run_chain(ctx, (DataclassFactoryRepr(),)) + '[]' + """ + for resolver in resolvers: + result = resolver(ctx) + if result is not None: + return result + return None + + +class DataclassFactoryRepr: + """Render :func:`dataclasses.field` ``default_factory`` symbolically. + + Recognises stdlib container constructors (``list``, ``dict``, + ``set``, ``frozenset``, ``tuple``) and named callable types. + Defers (returns ``None``) on lambdas and unrecognised + factories, leaving Sphinx's stock ```` rendering in + place. + + Examples + -------- + >>> r = DataclassFactoryRepr() + >>> ctx = ResolveContext( + ... value=list, + ... kind='param', + ... qualname='Foo.__init__', + ... param_name='items', + ... default_repr='', + ... ) + >>> r(ctx) + '[]' + >>> r(ctx._replace(value=dict)) + '{}' + >>> r(ctx._replace(value=set)) + 'set()' + >>> r(ctx._replace(value=lambda: 1)) is None + True + """ + + _BUILTIN_LITERALS: t.ClassVar[dict[type, str]] = { + list: "[]", + dict: "{}", + set: "set()", + frozenset: "frozenset()", + tuple: "()", + } + + def __call__(self, ctx: ResolveContext) -> str | None: + """Resolve a ``default_factory`` callable to its source text.""" + if ctx.kind != "param": + return None + factory = ctx.value + if isinstance(factory, type): + literal = self._BUILTIN_LITERALS.get(factory) + if literal is not None: + return literal + name = getattr(factory, "__name__", None) + if name and name != "": + return f"{name}()" + return None + + +class TruncateLongRepr: + """Truncate long Site A ``:value:`` text. + + Returns ``None`` (defer) for parameter contexts and for short + reprs; returns ``"<...truncated, N chars>"`` for reprs over the + threshold. + + Examples + -------- + >>> r = TruncateLongRepr(threshold=10) + >>> r(ResolveContext( + ... value=None, + ... kind='data', + ... qualname='mod.X', + ... param_name=None, + ... default_repr='[1, 2]', + ... )) is None + True + >>> r(ResolveContext( + ... value=None, + ... kind='data', + ... qualname='mod.X', + ... param_name=None, + ... default_repr='this string is longer than ten characters', + ... )) + '<...truncated, 41 chars>' + """ + + def __init__(self, threshold: int = 200) -> None: + self.threshold = threshold + + def __call__(self, ctx: ResolveContext) -> str | None: + """Return a truncated marker if *ctx.default_repr* is too long.""" + if ctx.kind not in {"data", "attribute"}: + return None + text = ctx.default_repr + if not text or len(text) <= self.threshold: + return None + return f"<...truncated, {len(text)} chars>" diff --git a/tests/ext/typehints_gp/test_data_defaults.py b/tests/ext/typehints_gp/test_data_defaults.py index cb7ff1a7..bf9d163f 100644 --- a/tests/ext/typehints_gp/test_data_defaults.py +++ b/tests/ext/typehints_gp/test_data_defaults.py @@ -7,10 +7,12 @@ import pytest from sphinx_autodoc_typehints_gp._data_defaults import ( - TruncateLongRepr, _curate_value_line, ) -from sphinx_autodoc_typehints_gp._param_defaults import ResolveContext +from sphinx_autodoc_typehints_gp._resolvers import ( + ResolveContext, + TruncateLongRepr, +) def _ctx(default_repr: str, kind: str = "data") -> ResolveContext: diff --git a/tests/ext/typehints_gp/test_param_defaults.py b/tests/ext/typehints_gp/test_param_defaults.py index e198b1b1..3146beb8 100644 --- a/tests/ext/typehints_gp/test_param_defaults.py +++ b/tests/ext/typehints_gp/test_param_defaults.py @@ -10,11 +10,13 @@ from sphinx.util.inspect import DefaultValue from sphinx_autodoc_typehints_gp._param_defaults import ( - DataclassFactoryRepr, - ResolveContext, _walk_to_dataclass, update_synthetic_defvalues, ) +from sphinx_autodoc_typehints_gp._resolvers import ( + DataclassFactoryRepr, + ResolveContext, +) # --------------------------------------------------------------------------- # DataclassFactoryRepr From 340796d83fd5ed39c77a6facf396dfe38031420d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 9 May 2026 18:45:33 -0500 Subject: [PATCH 07/44] fix(typehints-gp[default-xref]) Use reftype=obj so data attrs resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: libtmux's `_show_option(scope=DEFAULT_OPTION_SCOPE, …)` rendered the identifier with `xref py py-class` styling but no clickable `` wrapping. Cause: `DEFAULT_OPTION_SCOPE` is a module-level data attribute (libtmux/constants.py:61) declared as `DEFAULT_OPTION_SCOPE: _DefaultOptionScope = _DefaultOptionScope()`, not a class. The Python domain's `class` reftype only resolves documented class definitions; the data-attribute target was looked up under the wrong key, the resolution silently failed, and Sphinx kept the contnode while dropping the `` wrapper. Default values reference arbitrary identifiers (classes, data, functions, enum members), so a class-typed xref is too narrow. what: - Switch `_default_xref_transform._xref` to build `pending_xref(reftype="obj")` — the catch-all reftype that resolves any documented Python identifier. - Update the literal contnode classes from `["xref", "py", "py-class"]` to `["xref", "py", "py-obj"]` so the rendered HTML honestly reflects what's being linked (``); the rendered link shape is still ``, identical to inline `:py:obj:` roles. - Update unit + integration tests to assert the new reftype and styling. - Add a regression test (`test_data_attribute_default_links_to_ documented_constant`) that builds a fixture project where a default references a module-level data attribute and asserts the `href="#…"` resolves and the `` wrapping appears. This pins the libtmux `DEFAULT_OPTION_SCOPE`-shaped case so a future reftype change doesn't silently regress it. --- .../_default_xref_transform.py | 49 ++++++++--- tests/ext/typehints_gp/test_default_xref.py | 12 ++- .../test_default_xref_integration.py | 87 ++++++++++++++++++- 3 files changed, 127 insertions(+), 21 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py index c3d3c2e8..e7679038 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py @@ -13,17 +13,25 @@ :class:`~sphinx.addnodes.desc_parameter`, ``ast.parse``s its text, and replaces the span's plain-text children with a mix of ``nodes.Text`` and ``pending_xref`` nodes — using the same -``:py:class:``-styled ``nodes.literal`` wrapping that Sphinx's -``XRefRole`` produces in inline prose: +``:py:obj:``-styled ``nodes.literal`` wrapping that Sphinx's +``XRefRole`` produces for the generic ``:py:obj:`` role: .. code-block:: html - + Foo +Why ``:py:obj:`` rather than ``:py:class:``: defaults reference +arbitrary Python identifiers — classes (``Foo``), module-level data +constants (libtmux's ``DEFAULT_OPTION_SCOPE``), enum members, +functions. The Python domain's ``class`` reftype only resolves +documented classes, so a class-typed xref would leave data-attribute +targets unlinked (the visible bug behind this design choice). The +``obj`` reftype resolves any documented Python identifier. + Priority is **5** — strictly below :class:`~sphinx.transforms.post_transforms.ReferencesResolver`'s priority of 10 — so the ``pending_xref`` nodes we create are still @@ -57,12 +65,22 @@ def _xref( py_module: str | None = None, py_class: str | None = None, ) -> addnodes.pending_xref: - """Build a ``:py:class:``-styled pending_xref for *target*. + """Build a ``:py:obj:``-style pending_xref for *target*. + + Default values reference arbitrary Python identifiers — classes, + module-level data attributes (libtmux's + ``DEFAULT_OPTION_SCOPE``), enum members, functions. The Python + domain's ``class`` reftype only resolves documented classes, so + using it here would leave data-attribute targets unlinked. The + ``obj`` reftype resolves any documented Python identifier and + matches the semantics of the inline ``:py:obj:`target``` role. The contnode is ``nodes.literal('', '', nodes.Text(title), - classes=['xref', 'py', 'py-class'])`` — exactly the shape an - inline ``:py:class:`target``` role would produce, so the - rendered HTML matches that of normal class references. + classes=['xref', 'py', 'py-obj'])`` — same shape as the + ``:py:obj:`` role; the rendered HTML is + `` + ``. *py_module* and *py_class* mirror the ``ref_context`` keys Sphinx's Python domain reads when resolving references. Passing @@ -75,21 +93,23 @@ def _xref( >>> n['reftarget'] 'mod.Foo' >>> n['reftype'] - 'class' + 'obj' >>> isinstance(n.children[0], nodes.literal) True + >>> n.children[0]['classes'] + ['xref', 'py', 'py-obj'] """ literal = nodes.literal( "", "", nodes.Text(title), - classes=["xref", "py", "py-class"], + classes=["xref", "py", "py-obj"], ) xref = addnodes.pending_xref( "", literal, refdomain="py", - reftype="class", + reftype="obj", reftarget=target, refexplicit=False, refwarn=False, @@ -111,10 +131,11 @@ def _ast_to_nodes( Identifier-emitting branches (``ast.Name``, ``ast.Attribute``) produce ``pending_xref`` nodes whose contnode is a - ``:py:class:``-styled ``nodes.literal``. Constants emit - ``nodes.Text`` matching ``repr(value)``. Containers - (Tuple/List/Set) and Call expressions emit punctuation as - ``nodes.Text``. + ``:py:obj:``-styled ``nodes.literal`` — the canonical XRefRole + shape (``classes=['xref', 'py', 'py-obj']``) emitted by + :func:`_xref`. Constants emit ``nodes.Text`` matching + ``repr(value)``. Containers (Tuple/List/Set) and Call expressions + emit punctuation as ``nodes.Text``. *py_module* / *py_class* are forwarded to every ``pending_xref`` so that unqualified targets resolve against the surrounding diff --git a/tests/ext/typehints_gp/test_default_xref.py b/tests/ext/typehints_gp/test_default_xref.py index 4a4a2bb8..2ccef2a7 100644 --- a/tests/ext/typehints_gp/test_default_xref.py +++ b/tests/ext/typehints_gp/test_default_xref.py @@ -22,14 +22,20 @@ def test_xref_builds_pending_xref_with_literal_contnode() -> None: - """The pending_xref wraps a `:py:class:`-styled literal.""" + """The pending_xref wraps a `:py:obj:`-styled literal. + + `obj` is the Python domain's catch-all reftype — it resolves + classes, module-level data, functions, and attributes uniformly. + Restricting to `class` would leave data-attribute targets like + libtmux's `DEFAULT_OPTION_SCOPE` silently unlinked. + """ n = _xref("mod.Foo", "Foo") assert n["refdomain"] == "py" - assert n["reftype"] == "class" + assert n["reftype"] == "obj" assert n["reftarget"] == "mod.Foo" assert isinstance(n.children[0], nodes.literal) literal = n.children[0] - assert literal["classes"] == ["xref", "py", "py-class"] + assert literal["classes"] == ["xref", "py", "py-obj"] assert literal.astext() == "Foo" diff --git a/tests/ext/typehints_gp/test_default_xref_integration.py b/tests/ext/typehints_gp/test_default_xref_integration.py index 7044467c..abf95ead 100644 --- a/tests/ext/typehints_gp/test_default_xref_integration.py +++ b/tests/ext/typehints_gp/test_default_xref_integration.py @@ -93,14 +93,16 @@ def test_default_value_class_renders_as_xref_link( """An identifier in a default value renders as the same xref shape as :py:class:.""" html = read_output(default_xref_html_result, "index.html") - # The bar() signature must contain a :py:class:-styled xref to Foo, - # not plain text. The exact HTML shape from the user's brief: + # The bar() signature must contain an :py:obj:-styled xref to Foo, + # not plain text. The exact HTML shape: # Foo + # Using py-obj rather than py-class so module-level data + # attributes (e.g. libtmux's DEFAULT_OPTION_SCOPE) also resolve. assert 'href="#default_xref_demo.Foo"' in html # resolved link target assert 'class="reference internal"' in html # the wrapping - assert 'class="xref py py-class' in html # the wrapping + assert 'class="xref py py-obj' in html # the wrapping # The literal Foo span text appears wrapped in the xref code block assert ">Foo<" in html @@ -117,6 +119,83 @@ def test_short_literal_default_remains_text( assert ">0<" in html +_DATA_ATTRIBUTE_MODULE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + + + class _DefaultScope: + \"\"\"Sentinel type whose lone instance below is used as a default.\"\"\" + + + DEFAULT_SCOPE: _DefaultScope = _DefaultScope() + \"\"\"Module-level sentinel referenced by `using_default_scope`.\"\"\" + + + def using_default_scope(scope: object = DEFAULT_SCOPE) -> None: + \"\"\"Function whose `scope` default references DEFAULT_SCOPE data.\"\"\" + """ +) + + +@pytest.fixture(scope="module") +def data_attribute_default_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a project where a default references a module-level data attr.""" + cache_root = tmp_path_factory.mktemp("default-xref-data-html") + scenario = SphinxScenario( + files=( + ScenarioFile("data_xref_demo.py", _DATA_ATTRIBUTE_MODULE_SOURCE), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile( + "index.rst", + textwrap.dedent( + """\ + Demo + ==== + + .. autoclass:: data_xref_demo._DefaultScope + + .. autodata:: data_xref_demo.DEFAULT_SCOPE + + .. autofunction:: data_xref_demo.using_default_scope + """ + ), + ), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("data_xref_demo",), + ) + + +@pytest.mark.integration +def test_data_attribute_default_links_to_documented_constant( + data_attribute_default_html_result: SharedSphinxResult, +) -> None: + """A default that references a module-level data attribute resolves. + + Reftype ``obj`` (rather than ``class``) is what lets data + attributes resolve. Using ``class`` here would silently drop the + ```` wrapping (the original bug behind libtmux's + ``DEFAULT_OPTION_SCOPE`` not being linked). + """ + html = read_output(data_attribute_default_html_result, "index.html") + + # The internal link to the documented data attribute resolves + assert 'href="#data_xref_demo.DEFAULT_SCOPE"' in html + assert 'class="reference internal"' in html + # py-obj (not py-class) styling + assert 'class="xref py py-obj' in html + + @pytest.mark.integration def test_unsupported_default_falls_back_to_plain_text( tmp_path_factory: pytest.TempPathFactory, From 81ee8426eb5c60b6bf79281d667dd9cfbcf9e2cb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 9 May 2026 19:18:09 -0500 Subject: [PATCH 08/44] fix(typehints-gp[default-xref]) Resolve cross-module + skip undocumented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: After the previous reftype=obj fix landed, libtmux's `_show_option(scope=DEFAULT_OPTION_SCOPE)` still didn't render as a clickable link — and the rendering inside the dt header looked like a broken inline-code chip with a heavy background. Two distinct issues drove the visible regression: 1. The Python domain only ran `searchmode=1` cross-module fuzzy lookup when the `pending_xref` had a `refspecific` attribute set (presence-based check at `sphinx/domains/python/__init__.py:942`, not value-based). The default-value xref didn't set it, so the resolver only tried exact `.` matches and failed for any default whose target lived in a sibling module (libtmux's constant lives in `libtmux.constants` while the method is documented under `libtmux.session`). 2. When the target was genuinely undocumented (e.g. an instance that autodoc skipped because it had no docstring), Sphinx correctly dropped the `` wrapping, but the `` contnode survived. The result was a styled element that looked clickable but wasn't — worse than plain text. Plus the `` contnode hits Furo's `code.literal` rule (chip background, smaller font, padding) which clashes visually with the bold sig font, the dt:hover background, and adjacent default-value text spans. what: - Set `xref["refspecific"] = True` in `_xref()` so the Python domain's fuzzy cross-module search runs. Comment cites the searchmode-flipping line in upstream Sphinx so the trick is discoverable next time. - Add `_is_documented(env, target, py_module)` and call it from `_ast_to_nodes` for both `ast.Name` and `ast.Attribute` branches. When the target isn't in `env.domaindata['py']['objects']` (after trying bare, prefixed, and `.endswith` lookups) emit plain `nodes.Text` instead of a `pending_xref` — no misleading xref styling on unresolvable identifiers. - Add `_static/css/typehints_gp.css` neutralising the inline-code chip styling for `code.literal` nested inside `.default_value` spans. Specificity 0,2,1 beats Furo's `code.literal` rule (0,1,1) without `!important`. Wire it via `app.add_css_file` plus a `builder-inited` static-path injection — same pattern `sphinx-autodoc-fastmcp` uses. - Add two integration tests (`test_default_xref_integration.py`): - `test_data_attribute_default_links_to_documented_constant` pins the libtmux `DEFAULT_OPTION_SCOPE`-shape: a default referencing a module-level data attribute resolves and renders the `` wrapping. - `test_cross_module_default_resolves_via_refspecific` builds a project where the default's target lives in a sibling module of the function being documented, asserting the cross-module resolution path stays wired (regression guard for the refspecific flip). - Update `test_setup_registers_builder_inited_cache_clearing` to assert `_clear_caches` is *among* the registered handlers rather than the only one (the new static-path injection is also connected to `builder-inited`). --- .../_default_xref_transform.py | 87 +++++++++++++++-- .../_static/css/typehints_gp.css | 30 ++++++ .../sphinx_autodoc_typehints_gp/extension.py | 11 +++ tests/ext/typehints_gp/test_default_xref.py | 28 ++++++ .../test_default_xref_integration.py | 95 +++++++++++++++++++ tests/ext/typehints_gp/test_unit.py | 9 +- 6 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py index e7679038..95caafd5 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py @@ -58,6 +58,44 @@ logger = logging.getLogger(__name__) +def _is_documented(env: t.Any, target: str, py_module: str | None) -> bool: + """Return True if *target* resolves to a documented Python object. + + Mirrors the searchmode=1 lookup paths in + :meth:`sphinx.domains.python.PythonDomain.find_obj` so we can + decide *before* emitting a ``pending_xref`` whether Sphinx will + successfully resolve it. When nothing matches, the caller emits + plain text instead of leaving a misleading + ```` styling without an ```` + wrapper around it. + + Examples + -------- + >>> import types + >>> env = types.SimpleNamespace( + ... domaindata={"py": {"objects": {"mod.Foo": object()}}} + ... ) + >>> _is_documented(env, "Foo", "mod") # exact py_module.target match + True + >>> _is_documented(env, "Foo", None) # fuzzy `.Foo` suffix match + True + >>> _is_documented(env, "NotThere", None) + False + >>> _is_documented(types.SimpleNamespace(domaindata={}), "X", None) + False + """ + try: + objects = env.domaindata["py"]["objects"] + except (AttributeError, KeyError): + return False + if target in objects: + return True + if py_module and f"{py_module}.{target}" in objects: + return True + suffix = f".{target}" + return any(name.endswith(suffix) for name in objects) + + def _xref( target: str, title: str, @@ -114,6 +152,16 @@ def _xref( refexplicit=False, refwarn=False, ) + # `refspecific` triggers `searchmode=1` in + # `PythonDomain.find_obj`, which fuzzy-matches the short name + # across every documented module. Without it, the resolver only + # tries exact `.` matches and fails for defaults + # whose target lives in a sibling module — e.g. libtmux's + # `_show_option(scope=DEFAULT_OPTION_SCOPE)` documented under + # `libtmux.session` while the constant is in `libtmux.constants`. + # The presence of the key (not its value) is what flips + # searchmode; see `sphinx/domains/python/__init__.py:942`. + xref["refspecific"] = True if py_module is not None: xref["py:module"] = py_module if py_class is not None: @@ -126,6 +174,7 @@ def _ast_to_nodes( *, py_module: str | None = None, py_class: str | None = None, + env: t.Any = None, ) -> list[nodes.Node]: """Convert an ``ast`` expression node into docutils inline nodes. @@ -156,12 +205,16 @@ def _ast_to_nodes( ['42'] """ if isinstance(node, ast.Name): + if env is not None and not _is_documented(env, node.id, py_module): + return [nodes.Text(node.id)] return [_xref(node.id, node.id, py_module=py_module, py_class=py_class)] if isinstance(node, ast.Attribute): path = _attr_chain(node) if path is None: msg = f"unsupported attribute base: {ast.dump(node)}" raise SyntaxError(msg) + if env is not None and not _is_documented(env, path, py_module): + return [nodes.Text(path)] return [_xref(path, path, py_module=py_module, py_class=py_class)] if isinstance(node, ast.Constant): if node.value is Ellipsis: @@ -175,30 +228,39 @@ def _ast_to_nodes( force_trailing_comma=len(node.elts) == 1, py_module=py_module, py_class=py_class, + env=env, ) if isinstance(node, ast.List): - return _wrap_seq("[", "]", node.elts, py_module=py_module, py_class=py_class) + return _wrap_seq( + "[", "]", node.elts, py_module=py_module, py_class=py_class, env=env + ) if isinstance(node, ast.Set): if not node.elts: msg = "empty set literal cannot be parsed" # ast won't yield this raise SyntaxError(msg) - return _wrap_seq("{", "}", node.elts, py_module=py_module, py_class=py_class) + return _wrap_seq( + "{", "}", node.elts, py_module=py_module, py_class=py_class, env=env + ) if isinstance(node, ast.Call): result: list[nodes.Node] = [] - result.extend(_ast_to_nodes(node.func, py_module=py_module, py_class=py_class)) + result.extend( + _ast_to_nodes(node.func, py_module=py_module, py_class=py_class, env=env) + ) result.append(nodes.Text("(")) first = True for arg in node.args: if not first: result.append(nodes.Text(", ")) - result.extend(_ast_to_nodes(arg, py_module=py_module, py_class=py_class)) + result.extend( + _ast_to_nodes(arg, py_module=py_module, py_class=py_class, env=env) + ) first = False for kw in node.keywords: if not first: result.append(nodes.Text(", ")) result.append(nodes.Text(f"{kw.arg}=")) result.extend( - _ast_to_nodes(kw.value, py_module=py_module, py_class=py_class) + _ast_to_nodes(kw.value, py_module=py_module, py_class=py_class, env=env) ) first = False result.append(nodes.Text(")")) @@ -206,7 +268,9 @@ def _ast_to_nodes( if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): return [ nodes.Text("-"), - *_ast_to_nodes(node.operand, py_module=py_module, py_class=py_class), + *_ast_to_nodes( + node.operand, py_module=py_module, py_class=py_class, env=env + ), ] msg = f"unsupported expression: {ast.dump(node)}" raise SyntaxError(msg) @@ -220,6 +284,7 @@ def _wrap_seq( force_trailing_comma: bool = False, py_module: str | None = None, py_class: str | None = None, + env: t.Any = None, ) -> list[nodes.Node]: """Render ``[a, b, c]`` / ``(a, b, c)`` style sequences. @@ -231,7 +296,9 @@ def _wrap_seq( for elt in elts: if not first: result.append(nodes.Text(", ")) - result.extend(_ast_to_nodes(elt, py_module=py_module, py_class=py_class)) + result.extend( + _ast_to_nodes(elt, py_module=py_module, py_class=py_class, env=env) + ) first = False if force_trailing_comma: result.append(nodes.Text(",")) @@ -262,6 +329,7 @@ def _transform_default_value_span( *, py_module: str | None = None, py_class: str | None = None, + env: t.Any = None, ) -> bool: """Mutate *span*'s children with cross-referenced AST nodes. @@ -291,7 +359,9 @@ def _transform_default_value_span( return False try: tree = ast.parse(text, mode="eval") - new_children = _ast_to_nodes(tree.body, py_module=py_module, py_class=py_class) + new_children = _ast_to_nodes( + tree.body, py_module=py_module, py_class=py_class, env=env + ) except SyntaxError: return False if not new_children: @@ -366,6 +436,7 @@ def run(self, **kwargs: t.Any) -> None: span, py_module=py_module, py_class=py_class, + env=self.env, ) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css new file mode 100644 index 00000000..c78bcfb6 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -0,0 +1,30 @@ +/* sphinx-autodoc-typehints-gp — neutralise inline-code styling for + * cross-referenced default values inside parameter signatures. + * + * Stage C of the default-rendering pipeline (see _default_xref_transform.py) + * wraps identifier defaults in a pending_xref whose contnode is + * ``. That + * matches Furo's inline-code rule (`code.literal, .sig-inline { background: + * var(--color-inline-code-background); padding: 0.1em 0.2em; font-size: + * var(--font-size--small--2); }` in gp-furo-theme/components/code.css), which + * paints a chip-shaped background, a smaller font, and padding around each + * identifier — visually clashing with the bold signature font, the dt:hover + * background on the API card header, and adjacent default_value text spans. + * + * Default values are *signature content*, not body prose. Strip the + * inline-code chip styling for code.literal nested inside .default_value + * so the xref renders as a same-size, transparent-background, clickable + * link that flows with the rest of the signature. + * + * Specificity is 0,2,1 (vs the conflicting rule's 0,1,1) so no !important is + * needed. + */ + +@layer components { + .default_value code.literal { + background: transparent; + border: none; + padding: 0; + font-size: inherit; + } +} diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index 2f603392..608dc8be 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -581,6 +581,8 @@ def setup(app: Sphinx) -> dict[str, t.Any]: >>> setup # doctest: +ELLIPSIS """ + import pathlib + from sphinx_autodoc_typehints_gp._data_defaults import ( GpAttributeDocumenter, GpDataDocumenter, @@ -592,6 +594,15 @@ def setup(app: Sphinx) -> dict[str, t.Any]: update_synthetic_defvalues, ) + 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/typehints_gp.css") + app.add_config_value( "gp_typehints_curate_param_defaults", default=True, diff --git a/tests/ext/typehints_gp/test_default_xref.py b/tests/ext/typehints_gp/test_default_xref.py index 2ccef2a7..798dcb46 100644 --- a/tests/ext/typehints_gp/test_default_xref.py +++ b/tests/ext/typehints_gp/test_default_xref.py @@ -160,6 +160,34 @@ def test_transform_rewrites_tuple_of_classes() -> None: assert [n["reftarget"] for n in xrefs] == ["Foo", "Bar"] +def test_transform_tuple_branch_propagates_env_to_elements() -> None: + """Tuple-element xrefs honour the ``env`` documentation gate. + + Regression for the bug where the ``ast.Tuple`` arm of + ``_ast_to_nodes`` forgot to forward ``env=env`` into + ``_wrap_seq``. Without the forward, ``_is_documented`` was bypassed + for tuple elements and undocumented targets emitted misleading + ```` styling without an ```` + wrapper. With ``env`` carrying an empty ``objects`` table every + target is "undocumented", so each tuple element should fall back + to a plain ``nodes.Text`` rather than a ``pending_xref``. + """ + import types + + span = _make_default_value_span("(Foo, Bar)") + env = t.cast( + "t.Any", + types.SimpleNamespace(domaindata={"py": {"objects": {}}}), + ) + assert _transform_default_value_span(span, env=env) is True + xrefs = list(span.findall(addnodes.pending_xref)) + assert xrefs == [] + # Both element names survive as plain text inside the span. + plain = span.astext() + assert "Foo" in plain + assert "Bar" in plain + + def test_transform_leaves_unsupported_text_alone() -> None: """Unparseable text leaves the span untouched.""" span = _make_default_value_span("lambda: 1") diff --git a/tests/ext/typehints_gp/test_default_xref_integration.py b/tests/ext/typehints_gp/test_default_xref_integration.py index abf95ead..6ffda680 100644 --- a/tests/ext/typehints_gp/test_default_xref_integration.py +++ b/tests/ext/typehints_gp/test_default_xref_integration.py @@ -196,6 +196,101 @@ def test_data_attribute_default_links_to_documented_constant( assert 'class="xref py py-obj' in html +_CROSS_MODULE_PACKAGE_INIT = textwrap.dedent( + """\ + \"\"\"Package init re-exporting api.\"\"\" + from cross_module_demo.api import use_default + """ +) + +_CROSS_MODULE_CONSTANTS = textwrap.dedent( + """\ + \"\"\"Module-level sentinel that the api function defaults to.\"\"\" + from __future__ import annotations + + + DEFAULT_SCOPE: object = object() + \"\"\"Module-level default referenced from a sibling module.\"\"\" + """ +) + +_CROSS_MODULE_API = textwrap.dedent( + """\ + \"\"\"Function whose default lives in a sibling module.\"\"\" + from __future__ import annotations + + from cross_module_demo.constants import DEFAULT_SCOPE + + + def use_default(scope: object = DEFAULT_SCOPE) -> None: + \"\"\"Function whose `scope` default references a sibling-module value.\"\"\" + """ +) + + +@pytest.fixture(scope="module") +def cross_module_default_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a project where a default references a sibling module's constant.""" + cache_root = tmp_path_factory.mktemp("default-xref-cross-html") + scenario = SphinxScenario( + files=( + ScenarioFile("cross_module_demo/__init__.py", _CROSS_MODULE_PACKAGE_INIT), + ScenarioFile("cross_module_demo/constants.py", _CROSS_MODULE_CONSTANTS), + ScenarioFile("cross_module_demo/api.py", _CROSS_MODULE_API), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile( + "index.rst", + textwrap.dedent( + """\ + Demo + ==== + + .. autodata:: cross_module_demo.constants.DEFAULT_SCOPE + + .. autofunction:: cross_module_demo.api.use_default + """ + ), + ), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=( + "cross_module_demo", + "cross_module_demo.api", + "cross_module_demo.constants", + ), + ) + + +@pytest.mark.integration +def test_cross_module_default_resolves_via_refspecific( + cross_module_default_html_result: SharedSphinxResult, +) -> None: + """A default that references a sibling module's constant still resolves. + + The function `use_default` is documented under + `cross_module_demo.api`; its default `DEFAULT_SCOPE` is + documented under `cross_module_demo.constants`. Without + `refspecific=True` on the pending_xref the Python domain only + tries `cross_module_demo.api.DEFAULT_SCOPE` (exact match in the + surrounding module) and the link silently fails. This pins the + cross-module fuzzy search. + """ + html = read_output(cross_module_default_html_result, "index.html") + + assert 'href="#cross_module_demo.constants.DEFAULT_SCOPE"' in html + assert 'class="reference internal"' in html + assert 'class="xref py py-obj' in html + + @pytest.mark.integration def test_unsupported_default_falls_back_to_plain_text( tmp_path_factory: pytest.TempPathFactory, diff --git a/tests/ext/typehints_gp/test_unit.py b/tests/ext/typehints_gp/test_unit.py index ff33f56a..b7d35e99 100644 --- a/tests/ext/typehints_gp/test_unit.py +++ b/tests/ext/typehints_gp/test_unit.py @@ -1216,11 +1216,14 @@ def test_setup_registers_builder_inited_cache_clearing() -> None: add_config_value=lambda *a, **kw: None, add_autodocumenter=lambda *a, **kw: None, add_post_transform=lambda *a, **kw: None, + add_css_file=lambda *a, **kw: None, ), ) setup(app) - event_map = dict(connections) - assert "builder-inited" in event_map - assert event_map["builder-inited"] is _clear_caches + # Multiple handlers may register on builder-inited (e.g. CSS static + # path injection alongside the cache clear), so check membership + # rather than asserting exactly one entry. + builder_inited_handlers = [h for ev, h in connections if ev == "builder-inited"] + assert _clear_caches in builder_inited_handlers From 9acd3538f308fc2ec9d4f139287bc70f750a7c01 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 05:22:15 -0500 Subject: [PATCH 09/44] feat(typehints-gp[field-xref]) Canonicalise Python xrefs in autodoc field lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Field-list rendering across libtmux/libvcs/gp-sphinx mixed three inconsistent shapes for Python identifier references, none matching the inline `:py:class:`-role HTML the user identified as the canonical "code link" target. Sphinx's `TypedField.make_field` wraps parameter types in `addnodes.literal_emphasis` (``); its `GroupedField.make_field` wraps `:raises:` exception names in `addnodes.literal_strong` (``); typehints-gp's `_annotation_to_nodes` emits `pending_xref(contnode=Text)` (no wrapping). The visible result was `Server` for param types, `exc.X` for raises, and `None` for intersphinx return types — none of them looking like the inline `Pane` shape used in body prose. The whole `name (str, optional)` prefix on each parameter row also rendered in the body sans-serif font instead of monospace. what: - Add `_field_xref_transform.py` with two SphinxPostTransforms: - `FieldListXrefStyleTransform` (priority 5, before `ReferencesResolver`) walks every Python-domain `pending_xref` inside any `nodes.field_list` ancestor and replaces its contnode with a single `nodes.literal('', '', Text(title), classes=['xref','py', 'py-class'|'py-exc'])`. Role class is `py-exc` for fields matching `raise`/`except` (Napoleon's `Raises` heading included), `py-class` everywhere else. Also sets `refspecific=True` so the Python domain's cross-module fuzzy lookup runs (same fix logic as the default-value xref transform). - `FieldListPrefixWrapTransform` (priority 6) wraps each field body's first paragraph prefix (everything before the Sphinx-emitted en-dash separator, U+2013) in `nodes.inline(classes=['gp-sphinx-field-prefix'])` so a single CSS rule can render the prefix in monospace. Skipped on prose-style fields (`Returns`, `Yields`, `Notes`, `Examples`, `Warning`, `See Also`, `Tip`, `Summary`, `Description`) — those bodies are free-form description text and would clash with embedded inline `` spans like `:any:`None``. `Return type` / `rtype` / `ytype` are explicitly distinguished by the trailing `type` token so they DO get wrapped. - Wire both transforms via `app.add_post_transform` in `extension.setup()`. - Extend `_static/css/typehints_gp.css` with two rules: a `.gp-sphinx-field-prefix { font-family: var(--font-stack-- monospace); }` for the wrapper and a `.field-list code.literal { background: transparent; ... }` to neutralise Furo's chip styling on the canonical `` nested inside the prefix. - Add a unit + integration suite covering every transformation pathway — Style A NamedTuple parametrization for each contnode shape and field-name mapping, plus _sphinx_scenarios fixture projects exercising the prose-skip behavior and idempotency. - Refresh sphinx-pytest-fixtures doctree snapshots that captured the pre-transform shape; the new rendering matches the canonical HTML the user has been aligning to. --- .../_field_xref_transform.py | 412 ++++++++++++++++++ .../_static/css/typehints_gp.css | 26 ++ .../sphinx_autodoc_typehints_gp/extension.py | 4 + .../test_sphinx_pytest_fixtures_doctree.ambr | 45 +- tests/ext/typehints_gp/test_field_xref.py | 286 ++++++++++++ .../test_field_xref_integration.py | 208 +++++++++ 6 files changed, 962 insertions(+), 19 deletions(-) create mode 100644 packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_field_xref_transform.py create mode 100644 tests/ext/typehints_gp/test_field_xref.py create mode 100644 tests/ext/typehints_gp/test_field_xref_integration.py diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_field_xref_transform.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_field_xref_transform.py new file mode 100644 index 00000000..8e3e218b --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_field_xref_transform.py @@ -0,0 +1,412 @@ +"""Canonicalise Python xref styling inside autodoc field lists. + +Sphinx's ``util/docfields.py`` and gp-sphinx's own +:func:`~sphinx_autodoc_typehints_gp.extension._annotation_to_nodes` +produce three different contnode shapes inside the +:class:`~sphinx.addnodes.pending_xref` nodes used by autodoc field +lists (Parameters, Returns, Return type, Raises, Yields): + +- ``TypedField.make_field`` wraps **param types** in + :class:`sphinx.addnodes.literal_emphasis` (````). +- ``GroupedField.make_field`` wraps **raises exception names** in + :class:`sphinx.addnodes.literal_strong` (````). +- typehints-gp's ``_annotation_to_nodes`` emits ``pending_xref`` + whose contnode is a bare :class:`docutils.nodes.Text`. + +None of these match the inline ``:py:class:`` / ``:py:obj:`` HTML +shape produced by :class:`~sphinx.roles.XRefRole`: + +.. code-block:: html + + + + Server + + + +This module ships :class:`FieldListXrefStyleTransform`, a +:class:`~sphinx.transforms.post_transforms.SphinxPostTransform` +running at priority **5** (below +:class:`~sphinx.transforms.post_transforms.ReferencesResolver`'s 10, +identical to :mod:`._default_xref_transform`'s priority slot) that +walks every Python-domain ``pending_xref`` inside a +:class:`docutils.nodes.field_list` ancestor and rewrites the +contnode children to a single +``nodes.literal('', '', nodes.Text(title), classes=['xref', 'py', +''])`` — the canonical XRefRole shape. + +The role class is chosen from the enclosing field's name: + +- ``type X`` / ``rtype`` / ``ytype`` / ``yieldtype`` → ``py-class``. +- ``raises`` / ``raise`` / ``except`` → ``py-exc``. +- Anything else → ``py-obj`` (matches the default-value transform's + generic fallback). + +It also sets ``refspecific=True`` so the Python domain's +``searchmode=1`` cross-module fuzzy lookup runs (otherwise +``Server`` documented under ``libtmux.server`` wouldn't resolve from +a method documented under ``libtmux.session``; same fix logic as +:mod:`._default_xref_transform`). + +A second transform :class:`FieldListPrefixWrapTransform` (priority +**6**, runs after the xref normalisation) wraps the prefix portion +of each field-list ``
`` paragraph (everything before the +em-dash separator) in a +``nodes.inline(classes=['gp-sphinx-field-prefix'])`` so the CSS in +``_static/css/typehints_gp.css`` can render the prefix in monospace +without disturbing the description text after the em-dash. +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from sphinx import addnodes +from sphinx.transforms.post_transforms import SphinxPostTransform + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + + +_EXC_FIELD_TOKENS: t.Final = ("raise", "except") + + +def _role_class_for_field_name(field_name: str) -> str: + """Map a field-list field name to the canonical xref role class. + + Defaults to ``py-class`` because field-list xrefs almost always + reference Python identifier types (parameter types, return types, + yield types, prose references inside descriptions). Carves out + ``py-exc`` for ``:raises:`` / ``:except:`` field labels — including + Napoleon's merged ``Raises`` heading — so the rendered ```` + role advertises the correct semantic. + + Examples + -------- + >>> _role_class_for_field_name('type server') + 'py-class' + >>> _role_class_for_field_name('rtype') + 'py-class' + >>> _role_class_for_field_name('Parameters') + 'py-class' + >>> _role_class_for_field_name('Returns') + 'py-class' + >>> _role_class_for_field_name('raises') + 'py-exc' + >>> _role_class_for_field_name('Raises') + 'py-exc' + >>> _role_class_for_field_name('except OSError') + 'py-exc' + >>> _role_class_for_field_name('') + 'py-class' + """ + name = field_name.strip().lower() + if any(token in name for token in _EXC_FIELD_TOKENS): + return "py-exc" + return "py-class" + + +def _enclosing_field_name(node: nodes.Element) -> str: + """Return the text of the ``field_name`` for *node*'s enclosing field. + + Walks up the ancestor chain until a :class:`nodes.field` is found, + then reads its first child (the ``field_name`` element). Returns + an empty string if no enclosing field is present. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> field = nodes.field() + >>> field += nodes.field_name('', 'type server') + >>> body = nodes.field_body() + >>> xref = addnodes.pending_xref('', refdomain='py') + >>> body += xref + >>> field += body + >>> _enclosing_field_name(xref) + 'type server' + >>> _enclosing_field_name(addnodes.pending_xref()) + '' + """ + parent: t.Any = node.parent + while parent is not None and not isinstance(parent, nodes.field): + parent = parent.parent + if parent is None: + return "" + if not parent.children: + return "" + name_node = parent.children[0] + if isinstance(name_node, nodes.field_name): + return name_node.astext() + return "" + + +def _normalize_xref_contnode(xref: addnodes.pending_xref) -> bool: + """Replace *xref*'s children with a role-class-aware ```` literal. + + The role class is chosen by :func:`_role_class_for_field_name` + from the enclosing field's name — ``py-class`` for type/return- + type/yield-type fields, ``py-exc`` for ``:raises:`` fields. The + rewritten literal carries ``classes=['xref', 'py', '']`` so + Sphinx's ``XRefRole`` HTML shape is reproduced. + + Returns ``True`` if children were rewritten, ``False`` if the + xref was left untouched (non-Python domain or empty title). + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> field = nodes.field() + >>> field += nodes.field_name('', 'type server') + >>> body = nodes.field_body() + >>> xref = addnodes.pending_xref( + ... '', nodes.Text('Server'), refdomain='py', reftarget='Server' + ... ) + >>> body += xref + >>> field += body + >>> _normalize_xref_contnode(xref) + True + >>> xref.children[0]['classes'] + ['xref', 'py', 'py-class'] + >>> xref['refspecific'] + True + >>> exc_field = nodes.field() + >>> exc_field += nodes.field_name('', 'raises OSError') + >>> exc_body = nodes.field_body() + >>> exc_xref = addnodes.pending_xref( + ... '', nodes.Text('OSError'), refdomain='py', reftarget='OSError' + ... ) + >>> exc_body += exc_xref + >>> exc_field += exc_body + >>> _normalize_xref_contnode(exc_xref) + True + >>> exc_xref.children[0]['classes'] + ['xref', 'py', 'py-exc'] + >>> _normalize_xref_contnode(addnodes.pending_xref('', refdomain='c')) + False + """ + if xref.get("refdomain") != "py": + return False + title = xref.astext() + if not title: + return False + role_class = _role_class_for_field_name(_enclosing_field_name(xref)) + literal = nodes.literal( + "", + "", + nodes.Text(title), + classes=["xref", "py", role_class], + ) + xref.clear() + xref.append(literal) + # `refspecific` triggers `searchmode=1` in `PythonDomain.find_obj` + # so unqualified targets match across documented modules. Same + # rationale as the default-value xref transform; without it, + # cross-module identifiers like libtmux's `Server` referenced + # from a `libtmux.session.Session.method` field would silently + # fail to resolve. + xref["refspecific"] = True + return True + + +class FieldListXrefStyleTransform(SphinxPostTransform): + """Normalise contnodes of Python xrefs inside autodoc field lists. + + Runs before :class:`~sphinx.transforms.post_transforms.ReferencesResolver` + (priority 10) so the rewritten contnodes pass through reference + resolution intact. Scoped to ``pending_xref`` nodes inside a + :class:`docutils.nodes.field_list` ancestor — does not touch xrefs + in body prose, signatures, or other contexts (those have their + own canonicalised shapes already). + """ + + default_priority = 5 + + def run(self, **kwargs: t.Any) -> None: + """Rewrite every Python-domain xref inside any field_list.""" + del kwargs + for field_list in self.document.findall(nodes.field_list): + for xref in field_list.findall(addnodes.pending_xref): + _normalize_xref_contnode(xref) + + +_EN_DASH = "\N{EN DASH}" + + +def _is_em_dash_separator(text: str) -> bool: + """Detect Sphinx's prefix/description em-dash separator. + + Sphinx renders the boundary between a field-list prefix and its + description as a Text node containing the en-dash character + (U+2013) surrounded by spaces, or the ASCII fallback ``" -- "``. + This predicate matches both shapes so the wrapper transform can + locate the split. + + Examples + -------- + >>> _is_em_dash_separator(f' {chr(0x2013)} ') + True + >>> _is_em_dash_separator(' -- description') + True + >>> _is_em_dash_separator('item: ') + False + """ + stripped = text.lstrip() + return stripped.startswith(_EN_DASH + " ") or stripped.startswith("-- ") + + +_PROSE_FIELD_TOKENS: t.Final = ( + "return", + "yield", + "note", + "example", + "warning", + "see also", + "see-also", + "tip", + "summary", + "description", +) + + +def _is_prose_field(field_name: str) -> bool: + """Return True if *field_name* labels a prose-style description field. + + These fields hold free-form description text rather than typed + parameter rows or identifier-only bodies, so wrapping their + paragraphs in the monospace prefix would re-style ordinary body + copy. Detected as ``Returns`` / ``Yields`` / ``Notes`` / + ``Examples`` / ``Warning`` / ``See Also`` / ``Tip`` / ``Summary`` + / ``Description``. ``Return type`` (which DOES want wrapping) is + explicitly distinguished by the trailing ``type`` token — + callers see ``rtype`` / ``return type`` for that variant. + + Examples + -------- + >>> _is_prose_field('Returns') + True + >>> _is_prose_field('returns') + True + >>> _is_prose_field('Yields') + True + >>> _is_prose_field('Notes') + True + >>> _is_prose_field('Return type') + False + >>> _is_prose_field('rtype') + False + >>> _is_prose_field('ytype') + False + >>> _is_prose_field('Parameters') + False + >>> _is_prose_field('Raises') + False + """ + name = field_name.strip().lower() + if not name: + return False + if "type" in name: + return False # 'rtype' / 'return type' / 'ytype' / 'yieldtype' + return any(token in name for token in _PROSE_FIELD_TOKENS) + + +def _wrap_prefix_in_paragraph( + paragraph: nodes.paragraph, + *, + field_name: str = "", +) -> bool: + """Wrap the prefix children of *paragraph* in a monospace inline. + + The prefix is the run of children before the first em-dash text + separator. If no separator exists (e.g. ``:rtype:`` / + ``:raises:`` rows whose entire content is a single identifier), + wraps the full child list. + + Skipped for prose-style fields (``Returns`` / ``Yields`` / + ``Notes`` / ``Examples`` etc.) where the body is free-form + description text — wrapping those paragraphs would re-style + ordinary body copy and clash with embedded inline ```` + spans like ``:any:`None```. + + Returns ``True`` if a wrapper was added, ``False`` if the + paragraph was already wrapped, lives inside a prose field, or + otherwise had no eligible content. + """ + if not paragraph.children: + return False + if _is_prose_field(field_name): + return False + # Skip paragraphs that already have our wrapper as the first child. + first = paragraph.children[0] + if isinstance(first, nodes.inline) and "gp-sphinx-field-prefix" in ( + first.get("classes") or [] + ): + return False + split_index = len(paragraph.children) + for index, child in enumerate(paragraph.children): + if isinstance(child, nodes.Text) and _is_em_dash_separator(str(child)): + split_index = index + break + prefix_children = list(paragraph.children[:split_index]) + rest_children = list(paragraph.children[split_index:]) + if not prefix_children: + return False + wrapper = nodes.inline("", "", classes=["gp-sphinx-field-prefix"]) + wrapper.extend(prefix_children) + paragraph.clear() + paragraph.append(wrapper) + paragraph.extend(rest_children) + return True + + +class FieldListPrefixWrapTransform(SphinxPostTransform): + """Wrap the field-list prefix portion in a monospace inline. + + For each ``
`` (``nodes.field_body``) of every + ``nodes.field_list``, wraps the leading children of the first + paragraph (everything before the em-dash separator) in + ``nodes.inline(classes=['gp-sphinx-field-prefix'])`` so a single + CSS rule can render the prefix in monospace without affecting + the description text. + + Bullet-list field bodies (e.g. ``:raises:`` lists) are walked + one item at a time so each ``
  • ``'s paragraph gets its own + wrapper. Field bodies with no em-dash (no description portion) + get the entire paragraph wrapped. + + Runs after :class:`FieldListXrefStyleTransform` so the wrapper + contains the canonicalised xref nodes. + """ + + default_priority = 6 + + def run(self, **kwargs: t.Any) -> None: + """Walk every field_body's paragraphs and wrap their prefixes.""" + del kwargs + for field_list in self.document.findall(nodes.field_list): + for field in field_list.findall(nodes.field): + if not field.children: + continue + name_node = field.children[0] + field_name = ( + name_node.astext() + if isinstance(name_node, nodes.field_name) + else "" + ) + for body in field.findall(nodes.field_body): + for paragraph in body.findall(nodes.paragraph): + _wrap_prefix_in_paragraph(paragraph, field_name=field_name) + + +def register(app: Sphinx) -> None: + """Register both field-list transforms with the Sphinx app. + + Examples + -------- + >>> register # doctest: +ELLIPSIS + + """ + app.add_post_transform(FieldListXrefStyleTransform) + app.add_post_transform(FieldListPrefixWrapTransform) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css index c78bcfb6..9efbe4cc 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -27,4 +27,30 @@ padding: 0; font-size: inherit; } + + /* Field-list prefix portion — `name (str, optional)` on each + * `Parameters` row, the lone identifier on `Return type` / + * `Raises` rows. Wrapper added by FieldListPrefixWrapTransform. + * The monospace font matches the surrounding signature font and + * makes typed names read as code; the description text after the + * em-dash sits outside this wrapper and keeps the body font. */ + .gp-sphinx-field-prefix { + font-family: var(--font-stack--monospace); + } + + /* Same chip-styling neutralisation as the default_value rule + * above, scoped to field-list context. After + * FieldListXrefStyleTransform replaces `Text`/`literal_strong`/ + * `literal_emphasis` contnodes with `nodes.literal(['xref','py', + * 'py-X'])`, the rendered `` matches Furo's `code.literal` rule and + * picks up a chip background, smaller font, and padding — all + * unwanted inside the inline prefix. Specificity 0,2,1 beats + * Furo's `code.literal` (0,1,1) without `!important`. */ + .field-list code.literal { + background: transparent; + border: none; + padding: 0; + font-size: inherit; + } } diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index 608dc8be..170422cf 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -590,6 +590,9 @@ def setup(app: Sphinx) -> dict[str, t.Any]: from sphinx_autodoc_typehints_gp._default_xref_transform import ( register as register_default_xref_transform, ) + from sphinx_autodoc_typehints_gp._field_xref_transform import ( + register as register_field_xref_transform, + ) from sphinx_autodoc_typehints_gp._param_defaults import ( update_synthetic_defvalues, ) @@ -618,6 +621,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_autodocumenter(GpDataDocumenter, override=True) app.add_autodocumenter(GpAttributeDocumenter, override=True) register_default_xref_transform(app) + register_field_xref_transform(app) app.connect("builder-inited", _clear_caches) try: app.connect("autodoc-process-docstring", process_docstring) diff --git a/tests/ext/pytest_fixtures/__snapshots__/test_sphinx_pytest_fixtures_doctree.ambr b/tests/ext/pytest_fixtures/__snapshots__/test_sphinx_pytest_fixtures_doctree.ambr index 48e7692c..1030b707 100644 --- a/tests/ext/pytest_fixtures/__snapshots__/test_sphinx_pytest_fixtures_doctree.ambr +++ b/tests/ext/pytest_fixtures/__snapshots__/test_sphinx_pytest_fixtures_doctree.ambr @@ -80,9 +80,10 @@ Depends on - - - my_server + + + + my_server @@ -149,9 +150,10 @@ Depends on - - - my_server + + + + my_server @@ -183,7 +185,8 @@ Autouse - yes — runs automatically for every test + + yes — runs automatically for every test No request needed — this fixture runs automatically for every test. @@ -272,9 +275,10 @@ Depends on - - - tmp_path + + + + tmp_path ''' # --- # name: test_dependency_rendering_snapshot[external_dependency_link] @@ -302,9 +306,10 @@ Depends on - - - special_dep + + + + special_dep ''' # --- # name: test_dependency_rendering_snapshot[hidden_dependencies] @@ -433,9 +438,10 @@ Depends on - - - my_server + + + + my_server @@ -693,9 +699,10 @@ Depends on - - - request + + + + request Parametrized diff --git a/tests/ext/typehints_gp/test_field_xref.py b/tests/ext/typehints_gp/test_field_xref.py new file mode 100644 index 00000000..429a9b48 --- /dev/null +++ b/tests/ext/typehints_gp/test_field_xref.py @@ -0,0 +1,286 @@ +"""Unit tests for sphinx_autodoc_typehints_gp._field_xref_transform.""" + +from __future__ import annotations + +import typing as t + +import pytest +from docutils import nodes +from sphinx import addnodes + +from sphinx_autodoc_typehints_gp._field_xref_transform import ( + _is_em_dash_separator, + _normalize_xref_contnode, + _role_class_for_field_name, + _wrap_prefix_in_paragraph, +) + +# --------------------------------------------------------------------------- +# _role_class_for_field_name +# --------------------------------------------------------------------------- + + +class _RoleClassFixture(t.NamedTuple): + test_id: str + field_name: str + expected: str + + +_ROLE_CLASS_FIXTURES: list[_RoleClassFixture] = [ + _RoleClassFixture("type_param", "type server", "py-class"), + _RoleClassFixture("rtype", "rtype", "py-class"), + _RoleClassFixture("ytype", "ytype", "py-class"), + _RoleClassFixture("yieldtype", "yieldtype value", "py-class"), + _RoleClassFixture("parameters_label", "Parameters", "py-class"), + _RoleClassFixture("returns_label", "Returns", "py-class"), + _RoleClassFixture("return_type_label", "Return type", "py-class"), + _RoleClassFixture("raises_lowercase", "raises", "py-exc"), + _RoleClassFixture("raises_label", "Raises", "py-exc"), + _RoleClassFixture("raises_named", "raises ValueError", "py-exc"), + _RoleClassFixture("except_named", "except OSError", "py-exc"), + _RoleClassFixture("empty", "", "py-class"), + _RoleClassFixture("uppercase_raises", "RAISES", "py-exc"), +] + + +@pytest.mark.parametrize( + list(_RoleClassFixture._fields), + _ROLE_CLASS_FIXTURES, + ids=[f.test_id for f in _ROLE_CLASS_FIXTURES], +) +def test_role_class_picks_per_field_name( + test_id: str, + field_name: str, + expected: str, +) -> None: + """_role_class_for_field_name maps field names to xref role classes.""" + del test_id + assert _role_class_for_field_name(field_name) == expected + + +# --------------------------------------------------------------------------- +# _is_em_dash_separator +# --------------------------------------------------------------------------- + + +class _DashFixture(t.NamedTuple): + test_id: str + text: str + expected: bool + + +_EN_DASH = chr(0x2013) + +_DASH_FIXTURES: list[_DashFixture] = [ + _DashFixture("plain_en_dash", f" {_EN_DASH} ", True), + _DashFixture("en_dash_then_text", f" {_EN_DASH} desc", True), + _DashFixture("ascii_double_hyphen", " -- description", True), + _DashFixture("colon_marker", "item: ", False), + _DashFixture("plain_text", "Description text", False), + _DashFixture("single_hyphen_isnt_separator", " - text", False), + _DashFixture("empty", "", False), +] + + +@pytest.mark.parametrize( + list(_DashFixture._fields), + _DASH_FIXTURES, + ids=[f.test_id for f in _DASH_FIXTURES], +) +def test_is_em_dash_separator( + test_id: str, + text: str, + expected: bool, +) -> None: + """_is_em_dash_separator detects Sphinx's prefix/description boundary.""" + del test_id + assert _is_em_dash_separator(text) is expected + + +# --------------------------------------------------------------------------- +# _normalize_xref_contnode (docutils-tree unit, no Sphinx app) +# --------------------------------------------------------------------------- + + +def _make_field_with_xref( + field_name: str, + contnode: nodes.Node, + *, + refdomain: str = "py", + reftarget: str = "Foo", +) -> tuple[nodes.field, addnodes.pending_xref]: + """Build a minimal `field/field_name + field_body/paragraph/xref` tree.""" + xref = addnodes.pending_xref( + "", + contnode, + refdomain=refdomain, + reftype="class", + reftarget=reftarget, + ) + paragraph = nodes.paragraph("", "", xref) + body = nodes.field_body("", paragraph) + field = nodes.field("", nodes.field_name("", field_name), body) + return field, xref + + +def test_normalize_replaces_text_contnode_with_literal() -> None: + """A pending_xref with a plain Text contnode gets a literal wrap.""" + _, xref = _make_field_with_xref("type x", nodes.Text("Server")) + assert _normalize_xref_contnode(xref) is True + assert len(xref.children) == 1 + literal = xref.children[0] + assert isinstance(literal, nodes.literal) + assert literal["classes"] == ["xref", "py", "py-class"] + assert literal.astext() == "Server" + assert xref["refspecific"] is True + + +def test_normalize_replaces_literal_strong_with_canonical_literal() -> None: + """A literal_strong contnode (raises) gets replaced with py-exc literal.""" + _, xref = _make_field_with_xref( + "raises", + addnodes.literal_strong("OSError", "OSError"), + ) + assert _normalize_xref_contnode(xref) is True + literal = xref.children[0] + assert isinstance(literal, nodes.literal) + assert literal["classes"] == ["xref", "py", "py-exc"] + assert literal.astext() == "OSError" + + +def test_normalize_replaces_literal_emphasis_with_canonical_literal() -> None: + """A literal_emphasis contnode (param type) gets replaced with py-class.""" + _, xref = _make_field_with_xref( + "type x", + addnodes.literal_emphasis("int", "int"), + ) + assert _normalize_xref_contnode(xref) is True + literal = xref.children[0] + assert isinstance(literal, nodes.literal) + assert literal["classes"] == ["xref", "py", "py-class"] + + +def test_normalize_skips_non_python_domain() -> None: + """A pending_xref whose refdomain is not 'py' is left alone.""" + _, xref = _make_field_with_xref( + "type x", + nodes.Text("X"), + refdomain="std", + ) + assert _normalize_xref_contnode(xref) is False + # Original Text contnode still present + assert isinstance(xref.children[0], nodes.Text) + + +def test_normalize_skips_empty_title() -> None: + """A pending_xref whose contnode renders no text is left alone.""" + _, xref = _make_field_with_xref("type x", nodes.Text("")) + assert _normalize_xref_contnode(xref) is False + + +# --------------------------------------------------------------------------- +# _wrap_prefix_in_paragraph +# --------------------------------------------------------------------------- + + +def test_wrap_prefix_splits_at_en_dash() -> None: + """The wrapper captures children before the en-dash separator.""" + paragraph = nodes.paragraph( + "", + "", + nodes.strong("name", "name"), + nodes.Text(" "), + nodes.Text(f" {_EN_DASH} description text"), + ) + assert _wrap_prefix_in_paragraph(paragraph) is True + assert len(paragraph.children) == 2 + wrapper = paragraph.children[0] + assert isinstance(wrapper, nodes.inline) + assert "gp-sphinx-field-prefix" in (wrapper.get("classes") or []) + # Wrapper has the strong + space + assert len(wrapper.children) == 2 + # Em-dash text remains as a sibling + assert _EN_DASH in str(paragraph.children[1]) + + +def test_wrap_prefix_handles_no_separator_with_identifier() -> None: + """A no-separator field body containing an identifier gets wrapped. + + Covers ``:rtype:`` / ``:raises:`` rows whose entire content is a + single ``pending_xref`` (the type or exception name). + """ + xref = addnodes.pending_xref( + "", + nodes.Text("Pane"), + refdomain="py", + reftype="class", + reftarget="Pane", + ) + paragraph = nodes.paragraph("", "", xref) + assert _wrap_prefix_in_paragraph(paragraph) is True + assert len(paragraph.children) == 1 + wrapper = paragraph.children[0] + assert isinstance(wrapper, nodes.inline) + assert "gp-sphinx-field-prefix" in (wrapper.get("classes") or []) + assert wrapper.astext() == "Pane" + + +def test_wrap_prefix_skips_returns_field() -> None: + """The ``Returns`` field body's prose stays unwrapped. + + ``Returns`` is a prose-style field — its body holds free-form + description text rather than a typed parameter / identifier + prefix. Wrapping it would re-style ordinary body copy to + monospace and clash with embedded ``:any:`None```-style inline + code spans inside the prose. + """ + paragraph = nodes.paragraph( + "", + "", + nodes.Text("Formatted result with "), + nodes.literal("", "None"), + nodes.Text(" embedded."), + ) + assert _wrap_prefix_in_paragraph(paragraph, field_name="Returns") is False + # Children unchanged + assert len(paragraph.children) == 3 + + +def test_wrap_prefix_skips_yields_and_notes() -> None: + """Other prose-style fields (Yields, Notes) are also skipped.""" + for field_name in ("Yields", "Notes", "Examples", "Warning"): + paragraph = nodes.paragraph("", "", nodes.Text("description text")) + assert _wrap_prefix_in_paragraph(paragraph, field_name=field_name) is False + + +def test_wrap_prefix_applies_for_return_type_field() -> None: + """``Return type`` is NOT prose — it gets wrapped despite no separator.""" + xref = addnodes.pending_xref( + "", + nodes.Text("Pane"), + refdomain="py", + reftype="class", + reftarget="Pane", + ) + paragraph = nodes.paragraph("", "", xref) + assert _wrap_prefix_in_paragraph(paragraph, field_name="Return type") is True + + +def test_wrap_prefix_idempotent() -> None: + """A paragraph already containing the wrapper is not re-wrapped.""" + inner = nodes.inline( + "", + "", + nodes.Text("Pane"), + classes=["gp-sphinx-field-prefix"], + ) + paragraph = nodes.paragraph("", "", inner) + assert _wrap_prefix_in_paragraph(paragraph) is False + # Single wrapper, not nested + assert len(paragraph.children) == 1 + + +def test_wrap_prefix_skips_empty_paragraph() -> None: + """A paragraph with no children returns False without mutating.""" + paragraph = nodes.paragraph() + assert _wrap_prefix_in_paragraph(paragraph) is False diff --git a/tests/ext/typehints_gp/test_field_xref_integration.py b/tests/ext/typehints_gp/test_field_xref_integration.py new file mode 100644 index 00000000..b3e9b220 --- /dev/null +++ b/tests/ext/typehints_gp/test_field_xref_integration.py @@ -0,0 +1,208 @@ +"""Integration tests for field-list xref styling and prefix wrapping.""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +# Documents a class with parameter types (internal class + intersphinx +# str/None), an optional parameter, a return type, and a Raises section +# whose exception is a documented internal class. Each rendering case +# the user called out is exercised on a single page. +_MODULE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + + + class CustomError(Exception): + \"\"\"A documented exception used in the Raises section.\"\"\" + + + class Server: + \"\"\"Documented internal class used as a parameter type.\"\"\" + + + def configure(server: Server, name: str, target: str = '') -> None: + \"\"\"Configure something on the server. + + Parameters + ---------- + server : Server + The server instance. + name : str + The item name. + target : str, optional + Optional custom target override. + + Returns + ------- + None + Nothing returned. + + Raises + ------ + CustomError + Raised when configuration fails. + \"\"\" + """ +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + import sys + + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx_autodoc_typehints_gp", + ] + + intersphinx_mapping = { + "py": ("https://docs.python.org/3", None), + } + autodoc_typehints = "description" + """ +) + +_INDEX_RST = textwrap.dedent( + """\ + Demo + ==== + + .. autoclass:: field_xref_demo.Server + + .. autoexception:: field_xref_demo.CustomError + + .. autofunction:: field_xref_demo.configure + """ +) + + +@pytest.fixture(scope="module") +def field_xref_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a fixture project exercising every field-list rendering case.""" + cache_root = tmp_path_factory.mktemp("field-xref-html") + scenario = SphinxScenario( + files=( + ScenarioFile("field_xref_demo.py", _MODULE_SOURCE), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile("index.rst", _INDEX_RST), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("field_xref_demo",), + ) + + +@pytest.mark.integration +def test_param_internal_type_renders_canonical_xref( + field_xref_html_result: SharedSphinxResult, +) -> None: + """Internal parameter type renders ....""" + html = read_output(field_xref_html_result, "index.html") + + # The param's internal class link wraps Server in the canonical shape. + assert 'href="#field_xref_demo.Server"' in html + # The xref-styled wrapping is present somewhere on the page. + assert "xref py py-class" in html + + +@pytest.mark.integration +def test_param_intersphinx_type_gets_code_wrapping( + field_xref_html_result: SharedSphinxResult, +) -> None: + """Intersphinx-resolved `str` gets the canonical wrap inside .""" + html = read_output(field_xref_html_result, "index.html") + + # The link-to-stdlib for `str` exists (intersphinx may or may not actually + # resolve in CI; either way the link target string is the canonical one). + # The contnode rewrite means the internal node tree carries the + # `xref py py-class` literal even when intersphinx fails to resolve. + assert "xref py py-class" in html + + +@pytest.mark.integration +def test_raises_exception_uses_xref_py_exc( + field_xref_html_result: SharedSphinxResult, +) -> None: + """Raises section renders Exc.""" + html = read_output(field_xref_html_result, "index.html") + + # A py-exc literal class appears for the raises exception + assert "xref py py-exc" in html + # The bold-only wrapping no longer dominates the raises + # rendering — explicitly assert the canonical link target is there. + assert 'href="#field_xref_demo.CustomError"' in html + + +@pytest.mark.integration +def test_field_prefix_is_wrapped_for_monospace( + field_xref_html_result: SharedSphinxResult, +) -> None: + """The `name (type, optional)` prefix sits inside gp-sphinx-field-prefix.""" + html = read_output(field_xref_html_result, "index.html") + + # The wrapper class appears at least once for the parameter rows. + assert 'class="gp-sphinx-field-prefix"' in html + + +@pytest.mark.integration +def test_typehints_css_is_referenced_in_built_page( + field_xref_html_result: SharedSphinxResult, +) -> None: + """The CSS file with our overrides is linked from the built page.""" + html = read_output(field_xref_html_result, "index.html") + + assert "typehints_gp.css" in html + + +@pytest.mark.integration +def test_returns_prose_body_not_monospaced( + field_xref_html_result: SharedSphinxResult, +) -> None: + """The `Returns` field body's prose stays in the body font. + + `Returns` has no identifier (no parameter name, no type + cross-reference), so the prefix wrapper is not applied — the + description stays in the default body font instead of being + rendered in the monospace prefix font. + """ + import re + + html = read_output(field_xref_html_result, "index.html") + + # Locate the Returns field's
    + m = re.search( + r']*>Returns:\s*]*>(.*?)
    ', + html, + re.DOTALL, + ) + assert m is not None, "Returns dd not found in built HTML" + returns_dd = m.group(1) + + # The prose-only Returns body must not have the prefix wrapper. + assert "gp-sphinx-field-prefix" not in returns_dd, ( + f"Returns body unexpectedly carries the prefix wrapper: {returns_dd!r}" + ) From 74decb8f68322f4abb228c13ec6a89fbd4f19698 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 05:30:37 -0500 Subject: [PATCH 10/44] docs(typehints-gp[examples]) Vibrant showcase for branch coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The existing examples page rendered a single `autofunction:: compact_function` and didn't exercise any of the six rendering improvements landed on `improved-defaults-reprs` (`autodoc_preserve_defaults` flip, dataclass `default_factory` rendering, long-data truncation, default-value cross-references, field-list xref styling, prefix monospace wrapper). Browsing http://localhost:3124/packages/sphinx-autodoc-typehints-gp/examples/ gave no visible signal of what this extension does that stock Sphinx + autodoc doesn't. what: - Add `docs/_ext/api_demo_typehints_gp.py`, a self-contained demo module that exercises every improvement with realistic-feeling content: a `CacheScope` enum, a `_DefaultRetry` sentinel + `DEFAULT_RETRY` instance with attribute docstring, a `Transport` class, a `ConnectionFailure` exception, `SHORT_DEFAULT` / `LONG_DEFAULT_RULES` data attributes, a `HookCounters` dataclass with five `field(default_factory=…)` shapes (list/dict/set/tuple/Foo), an `open_session` function whose `scope=` and `retry=` defaults link to the documented enum member and sentinel, and `with_lambda_default` exercising the plain-text fallback for unparseable defaults. - Rewrite `docs/packages/sphinx-autodoc-typehints-gp/examples.md` from 18 lines to a multi-section showcase: each section opens with a one-line "what's interesting here", autodocs the relevant demo, then ends with a "what to look for" callout pointing at the specific HTML shape worth noticing (factory text without ``, `<...truncated, N chars>`, `` wrapping for parameter / return / raises types, `gp-sphinx-field-prefix` monospace wrapper for the prefix portion, plain-text fallback for lambda defaults). - The `:noindex:` flags I initially used were dropped: with them the demo classes / data don't register in `env.domaindata['py']['objects']`, so my Stage C transform's `_is_documented` pre-resolution check returns False and falls back to plain text. Without `:noindex:` the targets register cleanly (no duplicate-id warnings — these names appear only on the examples page) and the `scope=CacheScope.Session` / `retry=DEFAULT_RETRY` defaults render as live cross-references pointing at the documented enum member / sentinel a few paragraphs down on the same page. --- docs/_ext/api_demo_typehints_gp.py | 127 ++++++++++++++++++ .../sphinx-autodoc-typehints-gp/examples.md | 108 ++++++++++++++- 2 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 docs/_ext/api_demo_typehints_gp.py diff --git a/docs/_ext/api_demo_typehints_gp.py b/docs/_ext/api_demo_typehints_gp.py new file mode 100644 index 00000000..25649082 --- /dev/null +++ b/docs/_ext/api_demo_typehints_gp.py @@ -0,0 +1,127 @@ +"""Demo module for sphinx-autodoc-typehints-gp showcase. + +Exercises every rendering improvement landed on the +``improved-defaults-reprs`` branch — source-text defaults, dataclass +factory rendering, long-data truncation, cross-referenced default +values, field-list xref styling, and the prefix monospace wrapper. +Each documented object below is referenced from +``docs/packages/sphinx-autodoc-typehints-gp/examples.md`` so the HTML +output renders with all transforms active. +""" + +from __future__ import annotations + +import dataclasses +import enum + + +class CacheScope(enum.Enum): + """Where a cached entry lives in the storage hierarchy.""" + + Process = "process" + Session = "session" + Global = "global" + + +class _DefaultRetry: + """Sentinel type for the ``retry=`` parameter's default value.""" + + +DEFAULT_RETRY: _DefaultRetry = _DefaultRetry() +"""Sentinel default for ``retry=`` parameters on connection helpers. + +When ``retry is DEFAULT_RETRY`` the helper picks a transport-aware +retry policy from the bound transport's ``retry_policy`` attribute. +""" + + +SHORT_DEFAULT: str = "admin" +"""A short, readable module-level constant — renders as-is.""" + + +LONG_DEFAULT_RULES: list[tuple[str, str]] = [ + (f"rule-{i:02d}", f"description for rule number {i}") for i in range(20) +] +"""A long ``list[tuple[str, str]]`` used as a documented constant. + +The ``repr()`` exceeds the 200-char threshold so the rendered +``:value:`` collapses to ``<...truncated, N chars>`` instead of +sprawling across the page. +""" + + +class ConnectionFailure(Exception): + """Raised when a connection attempt fails after exhausting retries.""" + + +class Transport: + """Documented internal transport — referenced as a parameter type.""" + + +@dataclasses.dataclass +class HookCounters: + """Dataclass exercising every default-factory shape. + + Each field uses ``field(default_factory=...)`` with a stdlib + container type or a custom callable. After the synthetic-init + listener runs, the rendered ``__init__`` signature shows the + factory call source text instead of ``=``. + """ + + alerts: list[str] = dataclasses.field(default_factory=list) + index: dict[str, int] = dataclasses.field(default_factory=dict) + names: set[str] = dataclasses.field(default_factory=set) + tags: tuple[str, ...] = dataclasses.field(default_factory=tuple) + transports: list[Transport] = dataclasses.field(default_factory=list) + + +def open_session( + transport: Transport, + *, + scope: CacheScope = CacheScope.Session, + retry: _DefaultRetry = DEFAULT_RETRY, + label: str = "default", +) -> Transport: + """Open a session against *transport*. + + The ``scope`` and ``retry`` defaults both reference documented + targets on this same page; Stage C's xref transform turns each + documented identifier inside a default-value span into a + clickable cross-reference using the canonical + ``:py:obj:``-styled HTML shape (````). + + Parameters + ---------- + transport : Transport + The documented transport instance. + scope : CacheScope + Scope at which session state is cached. + retry : _DefaultRetry + Retry sentinel; pass an explicit policy to override. + label : str + Optional label propagated to log records. + + Returns + ------- + Transport + The same transport, now bound to the session. + + Raises + ------ + ConnectionFailure + Raised when the transport cannot be opened after the retry + policy is exhausted. + """ + return transport + + +def with_lambda_default(callback: object = lambda: None) -> None: + """Demonstrate Stage C's plain-text fallback for lambda defaults. + + ``ast.parse`` of the default succeeds but the lambda branch isn't + handled by the xref transform; the rendered default sits as plain + text inside the ``default_value`` span (no spurious ```` styling that would imply a missing link target). + """ + del callback diff --git a/docs/packages/sphinx-autodoc-typehints-gp/examples.md b/docs/packages/sphinx-autodoc-typehints-gp/examples.md index 884036b5..b91c01ed 100644 --- a/docs/packages/sphinx-autodoc-typehints-gp/examples.md +++ b/docs/packages/sphinx-autodoc-typehints-gp/examples.md @@ -2,15 +2,111 @@ # Examples -## Live demos +```{eval-rst} +.. py:module:: api_demo_typehints_gp +``` + +The demos below exercise every rendering improvement +`sphinx-autodoc-typehints-gp` ships beyond stock Sphinx + autodoc. +Each section opens with a one-line "what's interesting here", then +shows the rendered output from a small demo module +([`docs/_ext/api_demo_typehints_gp.py`](https://github.com/git-pull/gp-sphinx/blob/main/docs/_ext/api_demo_typehints_gp.py)), +then a callout pointing at the specific HTML shape worth noticing. + +## Source-text parameter defaults + +`autodoc_preserve_defaults=True` is on by default in +`gp_sphinx.defaults`, so each method's `=…` default renders as the +literal source text rather than the runtime `repr()`. Sentinel +instances like `` become the symbolic name `DEFAULT_RETRY` instead. + +## Dataclass `field(default_factory=…)` rendering + +The synthetic-init listener walks `dataclasses.fields(...)` after +Sphinx introspects the dataclass and substitutes the factory call's +source text. Stdlib container types render as their literal forms +(`[]`, `{}`, `set()`, `frozenset()`, `()`); named callable factories +render as `Name()`. + +```{eval-rst} +.. autoclass:: api_demo_typehints_gp.HookCounters +``` + +What to look for: the `__init__` signature shows +`alerts=[], index={}, names=set(), tags=(), transports=[]` — no +`` placeholders. + +## Long module-level constants + +`GpDataDocumenter` / `GpAttributeDocumenter` route every `:value:` +line through a resolver chain. `TruncateLongRepr(threshold=200)` +collapses long values to `<...truncated, N chars>`; short values +render unchanged. + +```{eval-rst} +.. autodata:: api_demo_typehints_gp.SHORT_DEFAULT + +.. autodata:: api_demo_typehints_gp.LONG_DEFAULT_RULES +``` -Type annotations are cross-referenced automatically. The function below uses -`str`, `int`, and `str` — each becomes a clickable `py:class` link in the -rendered output. +What to look for: `SHORT_DEFAULT` shows `'admin'` directly; +`LONG_DEFAULT_RULES` shows `<...truncated, N chars>` instead of the +20-tuple list blob. + +## Cross-referenced default values + +Stage C's `DefaultValueXrefTransform` walks every +`` inside a `
    ` signature, AST-parses +the text, and turns documented identifier references into clickable +cross-references in the same ` +` shape that inline +`:py:obj:` roles produce. Undocumented or unparseable defaults +fall back to plain text. ```{eval-rst} -.. autofunction:: api_demo_layout.compact_function - :noindex: +.. autofunction:: api_demo_typehints_gp.open_session +``` + +What to look for: the `scope=` and `retry=` defaults link to +{py:attr}`~api_demo_typehints_gp.CacheScope.Session` and +{py:data}`~api_demo_typehints_gp.DEFAULT_RETRY` respectively. Hover +the rendered defaults — they're real anchors, not just styled text. + +```{eval-rst} +.. autofunction:: api_demo_typehints_gp.with_lambda_default +``` + +What to look for: the `callback=lambda: None` default falls back to +plain text inside the `default_value` span — no broken-looking +`` styling on something that can't link. + +## Field-list xref styling + +`FieldListXrefStyleTransform` normalises every Python-domain +`pending_xref` inside a field list to a single +`` shape — +the same HTML inline `:py:class:` roles produce. Parameter types, +return types, and raises exception names all match. The whole +`name (type, optional)` prefix on each parameter row is wrapped in +`` so a single CSS rule renders +the prefix in monospace; `Returns` prose stays in body font. + +The `open_session` autodoc above already demonstrates this — its +`Parameters`, `Return`, and `Raises` sections each render the +canonical shape on every Python identifier reference. + +## Documented targets used by the demos + +```{eval-rst} +.. autoclass:: api_demo_typehints_gp.CacheScope + :members: + +.. autoclass:: api_demo_typehints_gp.Transport + +.. autoexception:: api_demo_typehints_gp.ConnectionFailure + +.. autodata:: api_demo_typehints_gp.DEFAULT_RETRY ``` ```{package-reference} sphinx-autodoc-typehints-gp From cf032fc09ba44391ca8de884fe8040113d4c08b9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 06:08:14 -0500 Subject: [PATCH 11/44] test(typehints-gp[field-xref]) Module-scope lambda-default fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: PR #36 review caught a function-scoped Sphinx build inside `test_unsupported_default_falls_back_to_plain_text` — CLAUDE.md requires every integration test's Sphinx build to live in a module- or session-scoped fixture so the build cost is shared across runs. The three sibling tests in the file already follow the rule; this one was the outlier because it was the last test added and used a one-off scenario. what: - Hoist the lambda fixture project's module source into a module-level `_LAMBDA_MODULE_SOURCE` constant alongside the existing `_DATA_ATTRIBUTE_MODULE_SOURCE` and `_CROSS_MODULE_*` constants. - Add `lambda_default_html_result` as a `@pytest.fixture(scope="module")` wrapping `build_shared_sphinx_result`, mirroring the existing three fixtures in shape (parameter list, scenario construction, `purge_modules=("lambda_demo",)` argument). - Convert `test_unsupported_default_falls_back_to_plain_text` to consume the fixture parameter and call `read_output` directly; the two assertions are unchanged so the test still pins the plain-text-fallback contract for unparseable lambda defaults. --- .../test_default_xref_integration.py | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/tests/ext/typehints_gp/test_default_xref_integration.py b/tests/ext/typehints_gp/test_default_xref_integration.py index 6ffda680..9c2f4fe4 100644 --- a/tests/ext/typehints_gp/test_default_xref_integration.py +++ b/tests/ext/typehints_gp/test_default_xref_integration.py @@ -291,24 +291,26 @@ def test_cross_module_default_resolves_via_refspecific( assert 'class="xref py py-obj' in html -@pytest.mark.integration -def test_unsupported_default_falls_back_to_plain_text( - tmp_path_factory: pytest.TempPathFactory, -) -> None: - """Unparseable defaults (lambdas) leave the span as plain text.""" - module_source = textwrap.dedent( - """\ - from __future__ import annotations +_LAMBDA_MODULE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations - def has_lambda_default(callback=lambda: 1) -> None: - \"\"\"Function with a lambda default.\"\"\" - """ - ) + def has_lambda_default(callback=lambda: 1) -> None: + \"\"\"Function with a lambda default.\"\"\" + """ +) + + +@pytest.fixture(scope="module") +def lambda_default_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a project with a lambda default exercising the plain-text fallback.""" cache_root = tmp_path_factory.mktemp("default-xref-lambda-html") scenario = SphinxScenario( files=( - ScenarioFile("lambda_demo.py", module_source), + ScenarioFile("lambda_demo.py", _LAMBDA_MODULE_SOURCE), ScenarioFile( "conf.py", _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), @@ -327,12 +329,19 @@ def has_lambda_default(callback=lambda: 1) -> None: ), ), ) - result = build_shared_sphinx_result( + return build_shared_sphinx_result( cache_root, scenario, purge_modules=("lambda_demo",), ) - html = read_output(result, "index.html") + + +@pytest.mark.integration +def test_unsupported_default_falls_back_to_plain_text( + lambda_default_html_result: SharedSphinxResult, +) -> None: + """Unparseable defaults (lambdas) leave the span as plain text.""" + html = read_output(lambda_default_html_result, "index.html") # The lambda text appears in the rendered output but not wrapped in an xref assert "lambda" in html From 90a47aff6d235ec5399ceee3cacd0547449c2bba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 07:22:04 -0500 Subject: [PATCH 12/44] typehints-gp(polish[review-residue]) Address residual code-review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: post-autosquash review surfaced a doctest gap and several docstring-drift items; deep-dive against ~/study/{sphinx,docutils, myst-parser,pytest} confirmed the doctest gap is a real CLAUDE.md violation, the dataclass scope is narrower than documented, and the "em-dash" naming should match the `_EN_DASH` constant. what: - _field_xref_transform.py: add Examples block to `_wrap_prefix_in_paragraph`; fix em-dash → en-dash naming in `_is_em_dash_separator`, `_wrap_prefix_in_paragraph`, and the module/`FieldListPrefixWrapTransform` docstrings; add a Limitations paragraph explaining the cosmetic homogenisation of py-fixture and other sibling-package reftypes (reftype is preserved on the xref; only contnode classes are normalised). - _default_xref_transform.py: add Examples blocks to `_wrap_seq` and `_attr_chain` so every helper in the module has a function-level doctest matching the rest of the file. - _param_defaults.py: narrow module docstring to "dataclass synthetic __init__ specifically" — `_walk_to_dataclass` only handles dataclasses, NamedTuple defaults need no substitution (their repr is already source text), and attrs is not handled today; clarify that the resolver chain only runs over `default_factory` (plain `default` values pass through with their own correct repr). - scripts/audit_defaults.py: fix label inference from `path.parent.name` (parent dir name) to `path.name` (basename) to match the argparse help text. --- .../scripts/audit_defaults.py | 2 +- .../_default_xref_transform.py | 20 +++++++ .../_field_xref_transform.py | 60 +++++++++++++++---- .../_param_defaults.py | 38 ++++++++---- 4 files changed, 97 insertions(+), 23 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py b/packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py index b1f3a29f..5c8e0633 100644 --- a/packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py +++ b/packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py @@ -227,7 +227,7 @@ def main(argv: list[str]) -> int: path = pathlib.Path(raw_path) else: path = pathlib.Path(spec) - label = path.parent.name or str(path) + label = path.name or str(path) trees.append((label, path)) return _report( trees, diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py index 95caafd5..d6064f88 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py @@ -290,6 +290,16 @@ def _wrap_seq( *force_trailing_comma* renders a trailing comma after the only element of a 1-tuple to disambiguate ``(x,)`` from ``(x)``. + + Examples + -------- + >>> import ast + >>> elts = ast.parse('1, 2', mode='eval').body.elts + >>> [n.astext() for n in _wrap_seq('[', ']', elts)] + ['[', '1', ', ', '2', ']'] + >>> [n.astext() for n in _wrap_seq('(', ')', [elts[0]], + ... force_trailing_comma=True)] + ['(', '1', ',', ')'] """ result: list[nodes.Node] = [nodes.Text(opener)] first = True @@ -312,6 +322,16 @@ def _attr_chain(node: ast.Attribute) -> str | None: Returns ``None`` if the leftmost base isn't a Name (e.g. a function-call result like ``foo().bar`` — too dynamic to cross-reference statically). + + Examples + -------- + >>> import ast + >>> _attr_chain(ast.parse('a.b', mode='eval').body) + 'a.b' + >>> _attr_chain(ast.parse('mod.sub.Cls', mode='eval').body) + 'mod.sub.Cls' + >>> _attr_chain(ast.parse('foo().bar', mode='eval').body) is None + True """ parts: list[str] = [node.attr] current: ast.expr = node.value diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_field_xref_transform.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_field_xref_transform.py index 8e3e218b..3bfdaaf8 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_field_xref_transform.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_field_xref_transform.py @@ -51,10 +51,28 @@ A second transform :class:`FieldListPrefixWrapTransform` (priority **6**, runs after the xref normalisation) wraps the prefix portion of each field-list ``
    `` paragraph (everything before the -em-dash separator) in a +en-dash separator) in a ``nodes.inline(classes=['gp-sphinx-field-prefix'])`` so the CSS in ``_static/css/typehints_gp.css`` can render the prefix in monospace -without disturbing the description text after the em-dash. +without disturbing the description text after the separator. + +Sphinx renders the prefix/description boundary as ASCII ``" -- "`` +in the doctree (see :mod:`sphinx.util.docfields`); docutils' smart- +quotes pass converts that to U+2014 (em dash) at HTML render time. +The defensive constant :data:`_EN_DASH` (U+2013) covers an alternate +form that some upstream paths emit. ``_is_em_dash_separator`` matches +both shapes; the variable name reflects the single-codepoint form, +not Sphinx's typographic intent. + +The class :class:`FieldListXrefStyleTransform` is intentionally a +**cosmetic canonicaliser**: every Python-domain xref inside a field +list is rewritten to either ``py-class`` or ``py-exc``, regardless of +the originating role's ``reftype``. This homogenises sibling-package +roles like ``py:fixture`` (from sphinx-autodoc-pytest-fixtures) into +``py-class`` styling inside field lists; the ``reftype`` itself is +preserved on the ``pending_xref``, so cross-reference resolution is +unaffected. If a future package wants distinct field-list styling for +a custom reftype, this transform is the seam to extend. """ from __future__ import annotations @@ -236,13 +254,15 @@ def run(self, **kwargs: t.Any) -> None: def _is_em_dash_separator(text: str) -> bool: - """Detect Sphinx's prefix/description em-dash separator. + """Detect Sphinx's prefix/description dash separator. Sphinx renders the boundary between a field-list prefix and its - description as a Text node containing the en-dash character - (U+2013) surrounded by spaces, or the ASCII fallback ``" -- "``. - This predicate matches both shapes so the wrapper transform can - locate the split. + description as a Text node containing the ASCII fallback + ``" -- "`` (the canonical form, see ``sphinx.util.docfields``); + docutils' smart-quotes converts it to U+2014 (em dash) at HTML + render time. This predicate also matches the U+2013 (en dash) + single-codepoint form some upstream paths emit, so the wrapper + transform locates the split regardless of which shape arrives. Examples -------- @@ -319,7 +339,7 @@ def _wrap_prefix_in_paragraph( ) -> bool: """Wrap the prefix children of *paragraph* in a monospace inline. - The prefix is the run of children before the first em-dash text + The prefix is the run of children before the first en-dash text separator. If no separator exists (e.g. ``:rtype:`` / ``:raises:`` rows whose entire content is a single identifier), wraps the full child list. @@ -333,6 +353,26 @@ def _wrap_prefix_in_paragraph( Returns ``True`` if a wrapper was added, ``False`` if the paragraph was already wrapped, lives inside a prose field, or otherwise had no eligible content. + + Examples + -------- + >>> from docutils import nodes + >>> p = nodes.paragraph() + >>> p += nodes.Text('foo (int) ') + >>> p += nodes.Text(' -- description text') + >>> _wrap_prefix_in_paragraph(p, field_name='Parameters') + True + >>> first = p.children[0] + >>> isinstance(first, nodes.inline) and 'gp-sphinx-field-prefix' in first['classes'] + True + >>> # Prose-style fields are skipped. + >>> q = nodes.paragraph() + >>> q += nodes.Text('Returns the result.') + >>> _wrap_prefix_in_paragraph(q, field_name='Returns') + False + >>> # Empty paragraph is a no-op. + >>> _wrap_prefix_in_paragraph(nodes.paragraph()) + False """ if not paragraph.children: return False @@ -366,14 +406,14 @@ class FieldListPrefixWrapTransform(SphinxPostTransform): For each ``
    `` (``nodes.field_body``) of every ``nodes.field_list``, wraps the leading children of the first - paragraph (everything before the em-dash separator) in + paragraph (everything before the dash separator) in ``nodes.inline(classes=['gp-sphinx-field-prefix'])`` so a single CSS rule can render the prefix in monospace without affecting the description text. Bullet-list field bodies (e.g. ``:raises:`` lists) are walked one item at a time so each ``
  • ``'s paragraph gets its own - wrapper. Field bodies with no em-dash (no description portion) + wrapper. Field bodies with no separator (no description portion) get the entire paragraph wrapped. Runs after :class:`FieldListXrefStyleTransform` so the wrapper diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py index 97aa8152..8dac2284 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py @@ -3,22 +3,36 @@ Sphinx's ``autodoc_preserve_defaults`` flag handles regular function and method signatures via :func:`inspect.getsource` plus AST source slicing, wrapping each default in a ``DefaultValue`` shim whose -``__repr__`` returns the literal source text. It explicitly bails -out on synthetic ``__init__`` signatures (dataclass / attrs / -NamedTuple) — see -``sphinx/ext/autodoc/_dynamic/_preserve_defaults.py:107-110``. - -This module fills that gap. -:func:`update_synthetic_defvalues` is connected to the -``autodoc-before-process-signature`` event and runs after Sphinx's -own ``update_defvalue``. For each parameter whose default is still a -raw Python object (not a ``DefaultValue`` shim), it walks -:func:`dataclasses.fields` on the parent class, runs a resolver -chain over the field's ``default`` / ``default_factory``, and +``__repr__`` returns the literal source text. It bails out when +``inspect.getsource`` cannot recover the source — see the early +return at +``sphinx/ext/autodoc/_dynamic/_preserve_defaults.py:107-110`` (the +upstream comment names dataclass synthetic ``__init__``; the same +bailout fires generically for any object without retrievable source, +which incidentally covers ``attrs``-generated and NamedTuple +``__new__`` synthesis paths too). + +This module fills that gap **for dataclass synthetic ``__init__`` +specifically**. :func:`update_synthetic_defvalues` is connected to +the ``autodoc-before-process-signature`` event and runs after +Sphinx's own ``update_defvalue``. For each parameter whose default +is still a raw Python object (not a ``DefaultValue`` shim), it walks +:func:`dataclasses.fields` on the parent class and runs a resolver +chain over the field's ``default_factory`` (the only path that +otherwise renders as ````; plain ``default`` values already +have a correct ``repr`` and are passed through unchanged), then replaces ``Parameter.default`` with ``DefaultValue()``. After that, all downstream stringifiers emit the chosen text verbatim, the directive arglist parses, and rendering is clean. +NamedTuple defaults are always primitive immutable values whose +``repr`` is already the source text, so no substitution is needed — +:func:`_walk_to_dataclass` correctly returns ``None`` for them and +the function is a no-op. ``attrs`` ``Factory`` defaults are not +handled today; if needed, add a sibling ``_walk_to_attrs_class`` +helper and an ``AttrsFactoryRepr`` resolver alongside the existing +``DataclassFactoryRepr``. + The resolver chain is the seam for future extension. The built-in catalog is seeded by the empirical inventory in ``notes/defaults-discovery-d1.md`` (libtmux's 90 ```` From 381e4143018741fed4af9acd5cec3f08a6225d7c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 07:57:16 -0500 Subject: [PATCH 13/44] typehints-gp(css[field-prefix]) Clamp field-list prefix to 0.8125rem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: field-list parameter rows rendered visually heavier than body prose because the monospace prefix wrapper picked up the cascade's default 16px instead of Furo's inline-code size; some responsive layers further nudge that to 110%. Pinning to a root-relative rem keeps the prefix at ≤13px regardless of any percentage scaling upstream while still respecting browser-level accessibility zoom. what: - .gp-sphinx-field-prefix: add `font-size: 0.8125rem` so the prefix matches Furo's inline-code chip size (~13px at default 16px root) without compounding against any cascade-level percentage scaling. - .field-list code.literal: `font-size: inherit` becomes load- bearing — without it, Furo's `var(--font-size--small--2)` (81.25%) would shrink type names to ~10.5px against the new 13px wrapper; document the dependency in the rule's comment. --- .../_static/css/typehints_gp.css | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css index 9efbe4cc..3f1b2763 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -33,9 +33,20 @@ * `Raises` rows. Wrapper added by FieldListPrefixWrapTransform. * The monospace font matches the surrounding signature font and * makes typed names read as code; the description text after the - * em-dash sits outside this wrapper and keeps the body font. */ + * em-dash sits outside this wrapper and keeps the body font. + * + * `font-size` is pinned to a root-relative `0.8125rem` (~13px at + * default 16px root) so the prefix never renders bigger than + * Furo's inline-code chips in body prose. Furo's + * `--font-size--small--2` (used by `code.literal`) is `81.25%`, a + * percentage that compounds against any cascade scaling — e.g. the + * 110% nudge some responsive layers inject — making field-list + * code visually heavier than equivalent prose code at certain + * widths. Using `rem` blocks the compounding while still scaling + * with browser-level accessibility zoom on the root. */ .gp-sphinx-field-prefix { font-family: var(--font-stack--monospace); + font-size: 0.8125rem; } /* Same chip-styling neutralisation as the default_value rule @@ -46,7 +57,12 @@ * literal notranslate">` matches Furo's `code.literal` rule and * picks up a chip background, smaller font, and padding — all * unwanted inside the inline prefix. Specificity 0,2,1 beats - * Furo's `code.literal` (0,1,1) without `!important`. */ + * Furo's `code.literal` (0,1,1) without `!important`. + * + * `font-size: inherit` is load-bearing: the prefix wrapper above + * pins the prefix to `0.8125rem`, and Furo's percentage-based + * `--font-size--small--2` would otherwise compound to ~81% of + * 13px (≈10.5px), shrinking type names below readability. */ .field-list code.literal { background: transparent; border: none; From 6000978817d783efb38121005e4b3aa284a93ae9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 08:01:07 -0500 Subject: [PATCH 14/44] typehints-gp(css[field-list]) Clamp dd body to 0.8125rem too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: previous commit pinned only the prefix wrapper to ~13px; the description text after the en-dash and the entire Returns/Raises bodies still rendered at the inherited 16px, so a Parameters row jumped from 13px prefix to 16px description and the field-list visually outsized the body prose around the autodoc card. what: - .field-list > dd: pin the dd content to `0.8125rem` so every field-list row reads at one size (13px sans for descriptions, 13px mono for the prefix). dt labels keep their 0.85em rule from api_style.css (~14px), preserving the prose 16 → label 14 → body 13 hierarchy. --- .../_static/css/typehints_gp.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css index 3f1b2763..2976657a 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -69,4 +69,17 @@ padding: 0; font-size: inherit; } + + /* Field-list description body — the prose after the en-dash on + * each `Parameters` / `Raises` row, plus the entire `Returns` and + * `Return type` field bodies. Pinned to the same `0.8125rem` + * (~13px) as the prefix so a `Parameters` row reads as one + * uniformly-sized line — the prefix at 13px monospace, the + * description at 13px sans — instead of jumping from a 13px + * prefix to a 16px description and visually outsizing the body + * prose around the autodoc block. The same rule covers `Returns` + * and `Raises` bodies so every field-list row matches. */ + .field-list > dd { + font-size: 0.8125rem; + } } From c797297f8c9788ce130169279ecc57f66eb3135d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:09:10 -0500 Subject: [PATCH 15/44] gp-furo-tokens(feat[type-roles]) Add four gp-sphinx-type-* role aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Workspace CSS overrides scattered numeric values like 0.85em and 0.8125rem, with no named vocabulary. Reviewers asked for a "coherent type-system architecture." Four light role aliases give a small named vocabulary, defined as aliases of Furo's existing scale so values stay traceable to one place — no parallel typography system. what: - Add packages/gp-furo-tokens/src/roles.ts with GP_SPHINX_ROLE_TOKENS mapping --gp-sphinx-type-{body, metadata, code-inline, icon-glyph} to var(--font-size--normal/small/small--2) plus 0.625rem. - Add GP_SPHINX_ROLE_NAMES, GpSphinxRoleName, GpSphinxRoleNameSchema to contract.ts as a separate const so the upstream Furo contract test stays clean ("does not invent CSS custom properties Furo does not declare"). - Merge GP_SPHINX_ROLE_TOKENS into the body emission in plugin.ts alongside FURO_LIGHT_TOKENS. - Re-export new symbols from index.ts. - Add __tests__/roles.test.ts and update plugin.test.ts to assert the role tokens emit on body alongside Furo's tokens. --- .../gp-furo-tokens/__tests__/plugin.test.ts | 17 +++++++++-- .../gp-furo-tokens/__tests__/roles.test.ts | 29 +++++++++++++++++++ packages/gp-furo-tokens/src/contract.ts | 19 ++++++++++++ packages/gp-furo-tokens/src/index.ts | 6 ++++ packages/gp-furo-tokens/src/plugin.ts | 6 +++- packages/gp-furo-tokens/src/roles.ts | 22 ++++++++++++++ 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 packages/gp-furo-tokens/__tests__/roles.test.ts create mode 100644 packages/gp-furo-tokens/src/roles.ts diff --git a/packages/gp-furo-tokens/__tests__/plugin.test.ts b/packages/gp-furo-tokens/__tests__/plugin.test.ts index 24a2a876..3015842c 100644 --- a/packages/gp-furo-tokens/__tests__/plugin.test.ts +++ b/packages/gp-furo-tokens/__tests__/plugin.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; -import { FURO_TOKEN_NAMES } from "../src/contract.js"; +import { FURO_TOKEN_NAMES, GP_SPHINX_ROLE_NAMES } from "../src/contract.js"; import { FURO_DARK_TOKENS } from "../src/dark.js"; import { FURO_LIGHT_TOKENS } from "../src/light.js"; import furoTokensPlugin from "../src/plugin.js"; +import { GP_SPHINX_ROLE_TOKENS } from "../src/roles.js"; // addBase accepts nested CssInJs — at-rule keys (`@media (...)`) hold // nested selector→declaration maps; selector keys hold flat @@ -51,11 +52,23 @@ describe("plugin", () => { expect(root, "body rule missing").toBeDefined(); if (!root) return; - const expected = FURO_TOKEN_NAMES.filter((name) => FURO_LIGHT_TOKENS[name] !== "").sort(); + const expected = [ + ...FURO_TOKEN_NAMES.filter((name) => FURO_LIGHT_TOKENS[name] !== ""), + ...GP_SPHINX_ROLE_NAMES.filter((name) => GP_SPHINX_ROLE_TOKENS[name] !== ""), + ].sort(); const got = Object.keys(root).sort(); expect(got).toEqual(expected); }); + it("emits gp-sphinx role tokens on body alongside Furo's tokens", () => { + const rules = runPlugin(); + const root = rules[0]?.["body"] as Declarations | undefined; + expect(root?.["--gp-sphinx-type-body"]).toBe("var(--font-size--normal)"); + expect(root?.["--gp-sphinx-type-metadata"]).toBe("var(--font-size--small)"); + expect(root?.["--gp-sphinx-type-code-inline"]).toBe("var(--font-size--small--2)"); + expect(root?.["--gp-sphinx-type-icon-glyph"]).toBe("0.625rem"); + }); + it("emits a body[data-theme='dark'] rule for every dark delta", () => { const rules = runPlugin(); const dark = rules[0]?.['body[data-theme="dark"]'] as Declarations | undefined; diff --git a/packages/gp-furo-tokens/__tests__/roles.test.ts b/packages/gp-furo-tokens/__tests__/roles.test.ts new file mode 100644 index 00000000..becbac26 --- /dev/null +++ b/packages/gp-furo-tokens/__tests__/roles.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { GP_SPHINX_ROLE_NAMES, GpSphinxRoleNameSchema } from "../src/contract.js"; +import { GP_SPHINX_ROLE_TOKENS } from "../src/roles.js"; + +const roleNames = new Set(GP_SPHINX_ROLE_NAMES); + +describe("gp-sphinx role contract", () => { + it("declares a string for every role name", () => { + const missing = GP_SPHINX_ROLE_NAMES.filter( + (name) => !(name in GP_SPHINX_ROLE_TOKENS), + ).sort(); + expect(missing, `${missing.length} role names missing values`).toEqual([]); + }); + + it("does not declare values for names not in the role contract", () => { + const extra = Object.keys(GP_SPHINX_ROLE_TOKENS) + .filter((name) => !roleNames.has(name)) + .sort(); + expect(extra, `${extra.length} role keys not in GP_SPHINX_ROLE_NAMES`).toEqual([]); + }); + + it("emits names that match the gp-sphinx-type-* convention", () => { + for (const name of GP_SPHINX_ROLE_NAMES) { + expect(name).toMatch(/^--gp-sphinx-type-[a-z][a-z0-9-]*$/); + expect(GpSphinxRoleNameSchema.safeParse(name).success).toBe(true); + } + }); +}); diff --git a/packages/gp-furo-tokens/src/contract.ts b/packages/gp-furo-tokens/src/contract.ts index 2c0062af..8cac3869 100644 --- a/packages/gp-furo-tokens/src/contract.ts +++ b/packages/gp-furo-tokens/src/contract.ts @@ -167,3 +167,22 @@ export const FURO_TOKEN_NAMES = [ export type FuroTokenName = (typeof FURO_TOKEN_NAMES)[number]; export const FuroTokenNameSchema = z.enum(FURO_TOKEN_NAMES); + +/** + * gp-sphinx semantic type-role names — workspace additions, distinct from + * Furo's contract. + * + * Kept as a separate const so the Furo contract test's "does not invent + * CSS custom properties Furo does not declare" assertion still passes. + * Values live in {@link GP_SPHINX_ROLE_TOKENS} (`./roles.js`). + */ +export const GP_SPHINX_ROLE_NAMES = [ + "--gp-sphinx-type-body", + "--gp-sphinx-type-code-inline", + "--gp-sphinx-type-icon-glyph", + "--gp-sphinx-type-metadata", +] as const; + +export type GpSphinxRoleName = (typeof GP_SPHINX_ROLE_NAMES)[number]; + +export const GpSphinxRoleNameSchema = z.enum(GP_SPHINX_ROLE_NAMES); diff --git a/packages/gp-furo-tokens/src/index.ts b/packages/gp-furo-tokens/src/index.ts index f313bff7..95ff409f 100644 --- a/packages/gp-furo-tokens/src/index.ts +++ b/packages/gp-furo-tokens/src/index.ts @@ -1,5 +1,11 @@ export { FURO_TOKEN_NAMES, FuroTokenNameSchema, type FuroTokenName } from "./contract.js"; +export { + GP_SPHINX_ROLE_NAMES, + GpSphinxRoleNameSchema, + type GpSphinxRoleName, +} from "./contract.js"; export { FURO_LIGHT_TOKENS } from "./light.js"; export { FURO_DARK_TOKENS } from "./dark.js"; +export { GP_SPHINX_ROLE_TOKENS } from "./roles.js"; export const FURO_TOKENS_VERSION = "0.0.1-alpha.12"; diff --git a/packages/gp-furo-tokens/src/plugin.ts b/packages/gp-furo-tokens/src/plugin.ts index 3c77e672..b2c13694 100644 --- a/packages/gp-furo-tokens/src/plugin.ts +++ b/packages/gp-furo-tokens/src/plugin.ts @@ -2,6 +2,7 @@ import plugin from "tailwindcss/plugin"; import { FURO_DARK_TOKENS } from "./dark.js"; import { FURO_LIGHT_TOKENS } from "./light.js"; +import { GP_SPHINX_ROLE_TOKENS } from "./roles.js"; /** * Convert a token map to a CSS rule body, skipping empty values. @@ -62,7 +63,10 @@ function declarations( export default plugin((api) => { const darkDeclarations = declarations(FURO_DARK_TOKENS); api.addBase({ - body: declarations(FURO_LIGHT_TOKENS), + body: { + ...declarations(FURO_LIGHT_TOKENS), + ...declarations(GP_SPHINX_ROLE_TOKENS), + }, 'body[data-theme="dark"]': darkDeclarations, "@media (prefers-color-scheme: dark)": { 'body:not([data-theme="light"])': darkDeclarations, diff --git a/packages/gp-furo-tokens/src/roles.ts b/packages/gp-furo-tokens/src/roles.ts new file mode 100644 index 00000000..0db44416 --- /dev/null +++ b/packages/gp-furo-tokens/src/roles.ts @@ -0,0 +1,22 @@ +import type { GpSphinxRoleName } from "./contract.js"; + +/** + * gp-sphinx semantic type-role tokens. + * + * Defined as aliases of Furo's existing scale — no new pixel values, no + * parallel scale — so the workspace gets a small named vocabulary + * (`--gp-sphinx-type-body`, `--gp-sphinx-type-metadata`, ...) without + * fighting Furo upstream. Future workspace CSS picks a role name; the + * value stays traceable to one place. + * + * Emitted onto `body` alongside `FURO_LIGHT_TOKENS` so a downstream + * consumer can override either Furo's tokens or these role aliases via + * `html_theme_options["light_css_variables"]` and the override actually + * shadows (descendants of body inherit body's value, not :root's). + */ +export const GP_SPHINX_ROLE_TOKENS: Readonly> = { + "--gp-sphinx-type-body": "var(--font-size--normal)", + "--gp-sphinx-type-metadata": "var(--font-size--small)", + "--gp-sphinx-type-code-inline": "var(--font-size--small--2)", + "--gp-sphinx-type-icon-glyph": "0.625rem", +}; From 5046274a8b925e49e7605b0fd645f85ee472ad2e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:10:54 -0500 Subject: [PATCH 16/44] gp-furo-theme(css[layer-order]) Declare gp-sphinx cascade layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Workspace package CSS (api-style, pytest-fixtures, typehints-gp, custom.css, sphinx_ux_badges) has been winning over Furo's @layer components by virtue of being unlayered, not by intent expressed in specificity. Reviewers flagged this as a maintenance hazard: a future contributor adds a sensible-looking @layer components rule and watches it get silently overridden by an unlayered selector. Declaring the gp-sphinx layer up front gives subsequent commits a declarative precedence target. what: - Prepend `@layer theme, base, components, gp-sphinx, utilities;` before `@import "tailwindcss";` in the theme entrypoint. - Empty layer for now; subsequent commits in this refactor wrap their CSS in @layer gp-sphinx { ... } and stop relying on unlayered precedence. verification: compiled furo-tw.css's @layer order shows theme → base → components → gp-sphinx → utilities, with `gp-sphinx` between `components` and `utilities`. --- packages/gp-furo-theme/web/src/styles/index.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/gp-furo-theme/web/src/styles/index.css b/packages/gp-furo-theme/web/src/styles/index.css index 47671e6d..9b59b5b3 100644 --- a/packages/gp-furo-theme/web/src/styles/index.css +++ b/packages/gp-furo-theme/web/src/styles/index.css @@ -22,7 +22,16 @@ * dark-mode body-attribute swap works at runtime. A @theme inline * block here would inline literal values into utility classes, * defeating runtime CSS-variable theme swaps. + * + * Cascade-layer order: declare a `gp-sphinx` layer between Tailwind's + * `components` and `utilities` so workspace package CSS (api-style, + * pytest-fixtures, typehints-gp, sphinx-gp-theme custom.css, …) can + * land in `@layer gp-sphinx` and win over Furo's `@layer components` + * declaratively, without relying on accidental "unlayered wins" + * precedence. The declaration must precede `@import "tailwindcss"` + * so Tailwind v4 slots its native layers around the gp-sphinx layer. */ +@layer theme, base, components, gp-sphinx, utilities; @import "tailwindcss"; From 5b5e7af9409e018259c0142d5bc4b52db6809491 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:17:03 -0500 Subject: [PATCH 17/44] sphinx-gp-theme(css[toc]) Fix inert :root TOC override; move to body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: gp-furo-tokens emits Furo's --toc-font-size on body, so a :root override is silently shadowed for descendants of body. Live measurement before this fix: TOC at 1600px viewport rendered at 13.2px (Furo's 75% × 17.6 root) regardless of the :root override. After: 15.4px (87.5% × 17.6 root) via the gp-sphinx-type-metadata role token, which lands on body alongside Furo's tokens. what: - Replace :root { --toc-font-size … } with body { … } using var(--gp-sphinx-type-metadata) in BOTH copies of custom.css: - packages/sphinx-gp-theme/.../static/css/custom.css (theme, shipped to sibling consumers) - docs/_static/css/custom.css (gp-sphinx's own docs override) - Wrap both files in @layer gp-sphinx { … } so precedence becomes declarative against Furo's @layer components rather than relying on accidental "unlayered wins." - Leave :root { --gp-sphinx-fastmcp-safety-* } alone (those tokens don't shadow any Furo token, so the body-scope rule doesn't apply). verification: in DevTools at 1600px, getComputedStyle(.toc-tree li > a).fontSize is 15.4px (was 13.2px). --toc-font-size on body resolves to 87.5%. --- docs/_static/css/custom.css | 19 +++++++++++++++---- .../theme/static/css/custom.css | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index c9d18837..5a30f20e 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -1,3 +1,9 @@ +/* All workspace overrides land in @layer gp-sphinx so precedence is + * declarative against Furo's @layer components rather than relying + * on accidental "unlayered wins." Layer order is established in + * gp-furo-theme/web/src/styles/index.css. */ +@layer gp-sphinx { + .sidebar-tree p.indented-block { padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 var(--sidebar-item-spacing-horizontal); @@ -109,10 +115,13 @@ article h6 { * Uses Furo CSS variable overrides where possible. * ────────────────────────────────────────────────────────── */ -/* TOC font sizes: override Furo defaults (75% → 87.5%) */ -:root { - --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ - --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ +/* TOC font sizes: drive from the gp-sphinx-type-metadata role token + * (87.5%, ~14px). Declared on `body` because gp-furo-tokens emits + * Furo's own --toc-font-size on `body` too — a `:root` override is + * silently shadowed for descendants of body. */ +body { + --toc-font-size: var(--gp-sphinx-type-metadata); + --toc-title-font-size: var(--gp-sphinx-type-metadata); } /* More generous line-height for wrapped TOC entries */ @@ -553,3 +562,5 @@ article > section > blockquote:has(+ .gp-sphinx-package__landing-grid) { color: var(--color-foreground-secondary); margin: 0.5rem 0 1rem 0; } + +} /* end @layer gp-sphinx */ diff --git a/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/custom.css b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/custom.css index da24c080..e1d2a5bb 100644 --- a/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/custom.css +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/custom.css @@ -1,3 +1,9 @@ +/* All workspace overrides land in @layer gp-sphinx so precedence is + * declarative against Furo's @layer components rather than relying + * on accidental "unlayered wins." Layer order is established in + * gp-furo-theme/web/src/styles/index.css. */ +@layer gp-sphinx { + .sidebar-tree p.indented-block { padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 var(--sidebar-item-spacing-horizontal); @@ -109,10 +115,13 @@ article h6 { * Uses Furo CSS variable overrides where possible. * ────────────────────────────────────────────────────────── */ -/* TOC font sizes: override Furo defaults (75% → 87.5%) */ -:root { - --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ - --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ +/* TOC font sizes: drive from the gp-sphinx-type-metadata role token + * (87.5%, ~14px). Declared on `body` because gp-furo-tokens emits + * Furo's own --toc-font-size on `body` too — a `:root` override is + * silently shadowed for descendants of body. */ +body { + --toc-font-size: var(--gp-sphinx-type-metadata); + --toc-title-font-size: var(--gp-sphinx-type-metadata); } /* More generous line-height for wrapped TOC entries */ @@ -375,3 +384,5 @@ a.reference:has(.sd-badge[role="note"][aria-label^="Safety tier:"]):hover code { .sig:not(.sig-inline) { transition: none; } + +} /* end @layer gp-sphinx */ From 399afe6c0e66c8ddf13122cc0654733f5e589422 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:18:51 -0500 Subject: [PATCH 18/44] sphinx-ux-badges(css[icon]) Replace 10px absolute with role token; layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The icon-only badge ::before was the only absolute-px font-size in the workspace, an outlier reviewers flagged as a smell ("even tiny icon-only badges should follow the scale"). Same pixel result at the default 16px root via the gp-sphinx-type-icon-glyph role alias (0.625rem). Wrapping the file in @layer gp-sphinx joins the project's declarative-precedence convention. what: - .gp-sphinx-badge--icon-only::before: font-size 10px → var(--gp-sphinx-type-icon-glyph). Scales with browser-level accessibility zoom on the root, unchanged at default settings. - Wrap entire file in @layer gp-sphinx { … }. The :root tokens at the top stay where they are — the layer wrap is for selectors that competed with Furo, not for the brand-new --gp-sphinx-badge-* tokens which don't shadow any Furo token. --- .../sphinx_ux_badges/_static/css/sphinx_ux_badges.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sphinx_ux_badges.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sphinx_ux_badges.css index faf40e95..594118f9 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sphinx_ux_badges.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sphinx_ux_badges.css @@ -16,6 +16,12 @@ inset 0 -1px 2px rgba(0, 0, 0, 0.28); } +/* All workspace badge rules land in @layer gp-sphinx so precedence is + * declarative against Furo's @layer components rather than relying on + * accidental "unlayered wins." Layer order is established in + * gp-furo-theme/web/src/styles/index.css. */ +@layer gp-sphinx { + /* ── Base badge ─────────────────────────────────────────── */ .gp-sphinx-badge { display: inline-flex; @@ -196,7 +202,7 @@ body[data-theme="dark"] .gp-sphinx-badge:not(.gp-sphinx-badge--outline):not(.gp- } .gp-sphinx-badge.gp-sphinx-badge--icon-only::before { - font-size: 10px; + font-size: var(--gp-sphinx-type-icon-glyph); line-height: 1; font-style: normal; font-weight: normal; @@ -335,3 +341,5 @@ a.gp-sphinx-badge:hover { margin-right: 0.08rem; flex-shrink: 0; } + +} /* end @layer gp-sphinx */ From 062874a9da29ce28c750662ec9c395abf9280d1e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:21:34 -0500 Subject: [PATCH 19/44] ux-autodoc-layout(css[field-list]) Authoritative wrapper-aware grid + label rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: After sphinx-ux-autodoc-layout's _wrap_content_runs post-transform inserts a
    between
    and , the api-style/pytest-fixtures direct-child rules `dl.py … > dd > dl.field-list > dt` no longer match. The labels were falling through to Furo's descendant rule (var(--font-size--small), sentence-case) instead of getting the intended uppercase eyebrow at gp-sphinx-type-metadata size with muted color. The package that owns the `gp-sphinx-api-parameters` wrapper class is the right place to own its styling, per CLAUDE.md "Package self-containment." what: - Augment the existing display:grid block at lines 160-178 to add the same border-top/padding-top/margin-top the api-style block had, unify gap to 0.25rem 1rem, and consolidate the parameters and facts variants under a single rule (their grid shape is identical). - Add new typography rules for `> dt` (grid-column 1, uppercase letter-spaced eyebrow at gp-sphinx-type-metadata, color --color-foreground-muted, font-weight: normal — matches today's api-style value, no stealth design change) and `> dd` (grid-column 2, margin-left: 0). - Add the `@media (max-width: 52rem)` breakpoint that stacks dt/dd to a single column on narrow viewports — moved here from api_style.css (where the broken direct-child selector made it inert). - Wrap the entire file in `@layer gp-sphinx { … }` so precedence becomes declarative. verification: at 1600px viewport, getComputedStyle on
    Parameters:
    returns fontSize 15.4px, textTransform uppercase, color rgb(107, 111, 118) (matches --color-foreground-muted). Note: field-list
    still inherits the 0.8125rem pin from typehints_gp.css until commit 8 — kept the change small per the rollout sequence so each commit's blast radius is bounded. --- .../_static/css/layout.css | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 20be3d2e..705937cc 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -1,7 +1,13 @@ /* sphinx_ux_autodoc_layout — layout.css * Stable api-* component wrappers and disclosure styling. + * + * All rules land in @layer gp-sphinx so precedence is declarative + * against Furo's @layer components. Layer order is established in + * gp-furo-theme/web/src/styles/index.css. */ +@layer gp-sphinx { + /* ── Content sections ───────────────────────────────── */ .gp-sphinx-api-region + .gp-sphinx-api-region { margin-top: 1rem; @@ -156,27 +162,54 @@ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-link:focus-v color: var(--color-link); } -/* ── Field-list grid ────────────────────────────────── */ -dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 0 1rem; -} - +/* ── Field-list grid + label typography (authoritative) ───── + * `sphinx-ux-autodoc-layout` owns the `.gp-sphinx-api-parameters` + * and `.gp-sphinx-api-facts` wrapper classes (added by + * `_wrap_content_runs`), so the styling for the transformed + * autodoc-card field-list lives here. Mirror rules in api-style / + * pytest-fixtures stay direct-child (`> dd > dl.field-list`) + * intentionally so they only fire in standalone installs without + * sphinx-ux-autodoc-layout — see those packages' "standalone + * fallback" comments. */ +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list, dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.25rem 1rem; + border-top: 1px solid var(--color-background-border); + padding-top: 0.5rem; + margin-top: 0.5rem; } +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dt, dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dt { - font-weight: 600; + grid-column: 1; + font-size: var(--gp-sphinx-type-metadata); + font-weight: normal; + text-transform: uppercase; + letter-spacing: 0.025em; + color: var(--color-foreground-muted); } +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dd, dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd { + grid-column: 2; margin-left: 0; } +@media (max-width: 52rem) { + dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list, + dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list { + grid-template-columns: 1fr; + } + dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dt, + dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dd, + dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dt, + dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd { + grid-column: 1; + } +} + dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-options > .rst.directive-option + .rst.directive-option { margin-top: 1rem; } @@ -347,3 +380,6 @@ dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-conta flex-wrap: wrap; } } + + +} /* end @layer gp-sphinx */ From 55f8da0642c5786e84f9dfeea1d14e30b26fa92f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:23:25 -0500 Subject: [PATCH 20/44] sphinx-autodoc-api-style(css[field-list]) Relabel direct-child rules as standalone fallbacks; layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: When sphinx-autodoc-api-style is installed alongside sphinx-ux-autodoc-layout (the workspace default), _wrap_content_runs inserts a `
    ` between `
    ` and `
    `, breaking the direct-child chain `dl.py … > dd > dl.field-list > …` in this file. The same selectors DO match in a standalone install of sphinx-autodoc-api-style without the layout package, where the wrapper isn't present. Per CLAUDE.md "Package self-containment," api-style's CSS must render the classes its Python emits even when consumed alone, so the rules stay — relabel them as explicit fallbacks and align the values on the gp-sphinx-type-metadata role token so a standalone consumer gets the same eyebrow look as a workspace install (where layout.css wins). what: - Replace the unlabelled direct-child block (lines 63-93) with the same shape, now explicitly commented as a "STANDALONE FALLBACK" and aligned on `var(--gp-sphinx-type-metadata)` for `dt` font-size plus `color: var(--color-foreground-muted)` for parity with layout.css. - Wrap the file in `@layer gp-sphinx { … }` so precedence is declarative. - font-weight stays normal (matches today's value, no stealth design change). Visual change in the workspace install (with layout package): zero. The rules don't match the rendered DOM here — layout.css wins. Standalone install (api-style only): same eyebrow/grid shape as workspace, driven by the role token. --- .../_static/css/api_style.css | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css index 88dbe1b7..214e523a 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css @@ -4,8 +4,14 @@ * Badge colour tokens have moved to sab_palettes.css in * sphinx-ux-badges. This file only contains the * card-level and field-list layout rules. + * + * All rules land in @layer gp-sphinx so precedence is declarative + * against Furo's @layer components. Layer order is established in + * gp-furo-theme/web/src/styles/index.css. * ────────────────────────────────────────────────────────── */ +@layer gp-sphinx { + /* ── Deprecated entry muting ───────────────────────────── */ dl.py.gp-sphinx-badge--state-deprecated > dt { opacity: 0.7; @@ -59,7 +65,20 @@ dl.py:not(.fixture) dd dl.py:not(.fixture) > dt:hover { background: var(--color-api-background-hover); } -/* ── Metadata fields (compact grid) ────────────────────── */ +/* ── Metadata fields (compact grid) — STANDALONE FALLBACK ── + * These rules use a direct-child chain `> dd > dl.field-list` that + * intentionally only fires when sphinx-autodoc-api-style is installed + * WITHOUT sphinx-ux-autodoc-layout. When the layout package is + * present, _wrap_content_runs inserts a `
    ` between `
    ` and `
    `, which + * breaks this direct-child path; the authoritative styling for the + * transformed DOM lives in + * `packages/sphinx-ux-autodoc-layout/.../layout.css`. + * + * Per CLAUDE.md "Package self-containment," sphinx-autodoc-api-style + * must render the classes its Python emits even when consumed alone, + * so this fallback stays. Keep it visually identical to the + * authoritative layout.css block. */ dl.py:not(.fixture) > dd > dl.field-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); @@ -71,10 +90,11 @@ dl.py:not(.fixture) > dd > dl.field-list { dl.py:not(.fixture) > dd > dl.field-list > dt { grid-column: 1; + font-size: var(--gp-sphinx-type-metadata); font-weight: normal; text-transform: uppercase; - font-size: 0.85em; letter-spacing: 0.025em; + color: var(--color-foreground-muted); } dl.py:not(.fixture) > dd > dl.field-list > dd { @@ -91,3 +111,6 @@ dl.py:not(.fixture) > dd > dl.field-list > dd { grid-column: 1; } } + + +} /* end @layer gp-sphinx */ From 33f4638e4f336a30d5f47b363bbb83d838c1f390 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:25:08 -0500 Subject: [PATCH 21/44] sphinx-autodoc-pytest-fixtures(css[field-list]) Standalone fallback for fixture field-lists; layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Mirror of commit 6 for the dl.py.fixture variant. Same root cause: _wrap_content_runs in sphinx-ux-autodoc-layout breaks the direct-child `> dd > dl.field-list` selector chain in workspace installs. CLAUDE.md "Package self-containment" requires the rules stay so a standalone consumer of this extension still gets the intended grid layout and eyebrow labels. what: - Replace the unlabelled direct-child block (lines 53-79) with the same shape, explicitly commented as "STANDALONE FALLBACK". - dt typography: align on `var(--gp-sphinx-type-metadata)` plus `color: var(--color-foreground-muted)` for parity with layout.css. - font-weight stays normal. - Wrap entire file in `@layer gp-sphinx { … }`. Visual change in workspace install (with layout): zero. The rules don't match the rendered DOM here. Standalone install: same eyebrow shape as workspace, driven by the role token. --- .../css/sphinx_autodoc_pytest_fixtures.css | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css index 0ad5af0f..c8ccdc6d 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css @@ -4,8 +4,14 @@ * Badge colour tokens have moved to sab_palettes.css in * sphinx-ux-badges. This file only contains fixture-card * layout rules and the index-table scroll wrapper. + * + * All rules land in @layer gp-sphinx so precedence is declarative + * against Furo's @layer components. Layer order is established in + * gp-furo-theme/web/src/styles/index.css. * ────────────────────────────────────────────────────────── */ +@layer gp-sphinx { + /* "fixture" keyword prefix — keep Furo's default keyword colour */ dl.py.fixture.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-signature em.property { color: var(--color-api-keyword); @@ -49,7 +55,18 @@ dl.py.fixture > dd { margin-left: 0 !important; } -/* Metadata fields: compact grid */ +/* Metadata fields: compact grid — STANDALONE FALLBACK + * These rules use a direct-child chain `> dd > dl.field-list` that + * intentionally only fires when sphinx-autodoc-pytest-fixtures is + * installed WITHOUT sphinx-ux-autodoc-layout. When the layout + * package is present, _wrap_content_runs inserts a `
    ` between `
    ` and + * `
    `, which breaks this direct-child path; + * the authoritative styling for the transformed DOM lives in + * `packages/sphinx-ux-autodoc-layout/.../layout.css`. + * + * Per CLAUDE.md "Package self-containment," this package's CSS must + * render the classes its Python emits even when consumed alone. */ dl.py.fixture > dd > dl.field-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); @@ -60,10 +77,11 @@ dl.py.fixture > dd > dl.field-list { } dl.py.fixture > dd > dl.field-list > dt { grid-column: 1; + font-size: var(--gp-sphinx-type-metadata); font-weight: normal; text-transform: uppercase; - font-size: 0.85em; letter-spacing: 0.025em; + color: var(--color-foreground-muted); } dl.py.fixture > dd > dl.field-list > dt .colon { display: none; } dl.py.fixture > dd > dl.field-list > dd { grid-column: 2; margin-left: 0; } @@ -106,3 +124,6 @@ dl.py.fixture.gp-sphinx-badge--state-deprecated > dt { min-width: 40rem; width: 100%; } + + +} /* end @layer gp-sphinx */ From 9b82e7502043d281e4370782763d5cf581725ba0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:27:09 -0500 Subject: [PATCH 22/44] sphinx-autodoc-typehints-gp(css[field-prefix]) Field prefix inherits body size; re-layer to gp-sphinx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Removes the contested 81.25% field-list pin. Reviewers 1 and 4 argued the body of an API parameter row is primary content, not metadata, and should read at body size with hierarchy carried by label treatment (uppercase eyebrow at smaller metadata size, muted color) — not by shrinking the value text. With the layout.css authoritative grid + label rules from commit 5 in place, dropping this file's `.field-list > dd { font-size: 0.8125rem }` pin lets the dd content inherit body size. The .gp-sphinx-field-prefix span similarly drops its 0.8125rem pin and inherits, since the dd it lives in is now body-sized. what: - Rename @layer components → @layer gp-sphinx so precedence is declarative against Furo's @layer components. - .gp-sphinx-field-prefix: font-size 0.8125rem → font-size: inherit. Replace the four-paragraph load-bearing comment with one line — the load was load-bearing only because of a pin that's now gone. - Delete .field-list > dd { font-size: 0.8125rem } entirely. The authoritative size now flows from the row's natural body context. - Trim the .field-list code.literal { font-size: inherit } comment to one line; the rule is documented intent, not load-bearing accident. - Drop the historical "Stage C" / "load-bearing" prose from the module header in favour of a brief description of the actual surface and its layer choice. verification: at 1600px viewport on the typehints-gp examples page, field-list
    reports computed font-size 17.6px (was 14.3px), matching prose

    ;

    "Parameters:" reports 15.4px uppercase eyebrow, smaller than body — typographic hierarchy now reads upright (label < body), not backwards. --- .../_static/css/typehints_gp.css | 85 +++++-------------- 1 file changed, 22 insertions(+), 63 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css index 2976657a..cf3a7604 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -1,26 +1,22 @@ -/* sphinx-autodoc-typehints-gp — neutralise inline-code styling for - * cross-referenced default values inside parameter signatures. +/* sphinx-autodoc-typehints-gp — neutralise inline-code chip styling + * for cross-referenced default values and field-list xrefs. * - * Stage C of the default-rendering pipeline (see _default_xref_transform.py) - * wraps identifier defaults in a pending_xref whose contnode is - * ``. That - * matches Furo's inline-code rule (`code.literal, .sig-inline { background: - * var(--color-inline-code-background); padding: 0.1em 0.2em; font-size: - * var(--font-size--small--2); }` in gp-furo-theme/components/code.css), which - * paints a chip-shaped background, a smaller font, and padding around each - * identifier — visually clashing with the bold signature font, the dt:hover - * background on the API card header, and adjacent default_value text spans. + * Stage C of the default-rendering pipeline wraps identifier + * defaults in ``, which matches Furo's + * `code.literal { background, padding, font-size: 81.25% }` chip + * rule. In a signature default-value or a field-list parameter + * row, that chip styling clashes with the surrounding text. These + * rules strip the chip back to plain inline content while keeping + * the xref clickable. * - * Default values are *signature content*, not body prose. Strip the - * inline-code chip styling for code.literal nested inside .default_value - * so the xref renders as a same-size, transparent-background, clickable - * link that flows with the rest of the signature. - * - * Specificity is 0,2,1 (vs the conflicting rule's 0,1,1) so no !important is - * needed. + * All rules land in @layer gp-sphinx so precedence is declarative + * against Furo's @layer components. Layer order is established in + * gp-furo-theme/web/src/styles/index.css. */ -@layer components { +@layer gp-sphinx { + /* Default-value spans inside
    signatures. */ .default_value code.literal { background: transparent; border: none; @@ -28,58 +24,21 @@ font-size: inherit; } - /* Field-list prefix portion — `name (str, optional)` on each - * `Parameters` row, the lone identifier on `Return type` / - * `Raises` rows. Wrapper added by FieldListPrefixWrapTransform. - * The monospace font matches the surrounding signature font and - * makes typed names read as code; the description text after the - * em-dash sits outside this wrapper and keeps the body font. - * - * `font-size` is pinned to a root-relative `0.8125rem` (~13px at - * default 16px root) so the prefix never renders bigger than - * Furo's inline-code chips in body prose. Furo's - * `--font-size--small--2` (used by `code.literal`) is `81.25%`, a - * percentage that compounds against any cascade scaling — e.g. the - * 110% nudge some responsive layers inject — making field-list - * code visually heavier than equivalent prose code at certain - * widths. Using `rem` blocks the compounding while still scaling - * with browser-level accessibility zoom on the root. */ + /* Field-list prefix wrapper added by FieldListPrefixWrapTransform. + * Inherits field-body size; monospace family makes + * `name (type)` read as code without forcing a separate scale. */ .gp-sphinx-field-prefix { font-family: var(--font-stack--monospace); - font-size: 0.8125rem; + font-size: inherit; } - /* Same chip-styling neutralisation as the default_value rule - * above, scoped to field-list context. After - * FieldListXrefStyleTransform replaces `Text`/`literal_strong`/ - * `literal_emphasis` contnodes with `nodes.literal(['xref','py', - * 'py-X'])`, the rendered `` matches Furo's `code.literal` rule and - * picks up a chip background, smaller font, and padding — all - * unwanted inside the inline prefix. Specificity 0,2,1 beats - * Furo's `code.literal` (0,1,1) without `!important`. - * - * `font-size: inherit` is load-bearing: the prefix wrapper above - * pins the prefix to `0.8125rem`, and Furo's percentage-based - * `--font-size--small--2` would otherwise compound to ~81% of - * 13px (≈10.5px), shrinking type names below readability. */ + /* Defensive: blocks Furo's `code.literal { font-size: 81.25% }` + * from compounding inside field-list rows. Keeps inline xref + * code at the row's inherited size instead of shrinking on top. */ .field-list code.literal { background: transparent; border: none; padding: 0; font-size: inherit; } - - /* Field-list description body — the prose after the en-dash on - * each `Parameters` / `Raises` row, plus the entire `Returns` and - * `Return type` field bodies. Pinned to the same `0.8125rem` - * (~13px) as the prefix so a `Parameters` row reads as one - * uniformly-sized line — the prefix at 13px monospace, the - * description at 13px sans — instead of jumping from a 13px - * prefix to a 16px description and visually outsizing the body - * prose around the autodoc block. The same rule covers `Returns` - * and `Raises` bodies so every field-list row matches. */ - .field-list > dd { - font-size: 0.8125rem; - } } From 2d100d00f8b968999125dd2e2fdb352141da5dc7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:29:18 -0500 Subject: [PATCH 23/44] gp-furo-theme(css[scaffold]) Replace 110% root cliff with clamp() ramp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Reviewers 1, 3, and 4 all flagged the single-pixel discontinuity at 1552px viewport — Furo's `@media (min-width: 97em) { html { font-size: 110% } }` made every measurement on the page jump 10% when a user dragged a window from 1551 to 1553. MBP 14 (1512), MBP 16 scaled (1496), and 1440 monitors sit just below; 1600+ external displays sit just above. That's a QA surface that didn't need to exist. The clamp() form preserves the wide-viewport intent (gentle breathing room on big monitors) while removing the cliff: - 1280px viewport → 100% root (16px) - 1551px viewport → ~104.2% root (~16.68px) - 1600px viewport → ~105% root (~16.8px) - 1920px viewport → 110% ceiling (17.6px) Endpoints match today's behaviour at 1280 and 1920 px; the bump just becomes continuous between. what: - Delete `@media (min-width: 97em) { html { font-size: 110%; } }` in scaffold.css. - Add `:root { font-size: clamp(100%, calc(80% + 0.25vw), 110%); }` inside the existing @layer components block. verification: at 1551px viewport, getComputedStyle(html).fontSize returns 16.6775px (was 16px). At 1553px it remains in the ramp, not a cliff. --- .../web/src/styles/components/scaffold.css | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/gp-furo-theme/web/src/styles/components/scaffold.css b/packages/gp-furo-theme/web/src/styles/components/scaffold.css index 12965c64..ea4683e0 100644 --- a/packages/gp-furo-theme/web/src/styles/components/scaffold.css +++ b/packages/gp-furo-theme/web/src/styles/components/scaffold.css @@ -449,11 +449,23 @@ align-items: center; } - /* Responsive layouting — mobile-last per upstream comment. */ - @media (min-width: 97em) { - html { - font-size: 110%; - } + /* Responsive root font-size — smooth ramp instead of cliff. + * + * Furo upstream uses `@media (min-width: 97em) { html { font-size: + * 110% } }`, which means a single-pixel viewport difference at + * 1552px causes every measurement on the page to jump 10%. + * Reviewers flagged this as a QA surface that didn't need to + * exist (MBP 14 at 1512, MBP 16 scaled at 1496, common 1440 + * monitors all sit just below; 1600+ external displays sit just + * above). + * + * The clamp() form preserves the wide-viewport ceiling while + * removing the cliff: at 1280px the resolved root is 100% (16px), + * at 1600px it ramps to ~105% (~16.8px), at 1920px it reaches the + * 110% ceiling (17.6px). Endpoints match today's behaviour at + * 1280 and 1920px; the bump just becomes continuous between. */ + :root { + font-size: clamp(100%, calc(80% + 0.25vw), 110%); } @media (max-width: 82em) { From 66e1bec29704ccf3ab61ed654b2c4a6cdf98ebb2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:51:11 -0500 Subject: [PATCH 24/44] ux-autodoc-layout(css[field-list]) Drop dd to metadata size; flex-gap rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The post-refactor field-body at body size (16/17.6 px) read as visually exploded — the monospace prefix wrapper inside each row carries more optical weight than equivalent sans prose, and the docutils `
      ` convention collapses inner-paragraph margins to 4/4 px (only ~8 px between adjacent rows against a 25.6 px line-height). External CSS expert confirmed: drop the row content to metadata size so it sits in the same data-band register as the eyebrow label, and override the simple-list spacing convention so adjacent rows get ~12 px breathing room. what: - layout.css (authoritative, with-layout install): augment the `dl.gp-sphinx-api-container > dd > … > dl.field-list > dd` block with `font-size: var(--gp-sphinx-type-metadata)` (14 px / 87.5%) and `line-height: 1.5`. Apply to both `.gp-sphinx-api-parameters` and `.gp-sphinx-api-facts` regions. - layout.css: convert the inner `
        ` to flex column with `gap: 0.75rem` (12 px) and zero out the inner `

        ` margins so flex-gap is the only inter-row spacer. - api_style.css standalone fallback (no-layout install): mirror the same shape on the direct-child path. - pytest_fixtures.css standalone fallback: mirror on the fixture variant. verification: at 1280px viewport on the typehints-gp examples page, field-list

        .fontSize is 14 px; ul.simple is display:flex with gap 12 px;
      • .margin is 0/0; visible gap between adjacent

      • elements measures 12 px. Visual change: parameter rows shrink from body-size to metadata-size (16→14 px at default viewport, 17.6→15.4 px at the wide-viewport clamp ceiling); rows gain 12 px breathing room. Both visible complaints ("exploded", "skin close") addressed in this commit. --- .../_static/css/api_style.css | 14 +++++++++++ .../css/sphinx_autodoc_pytest_fixtures.css | 19 +++++++++++++- .../_static/css/layout.css | 25 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css index 214e523a..e4a7ac64 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css @@ -100,6 +100,20 @@ dl.py:not(.fixture) > dd > dl.field-list > dt { dl.py:not(.fixture) > dd > dl.field-list > dd { grid-column: 2; margin-left: 0; + font-size: var(--gp-sphinx-type-metadata); + line-height: 1.5; +} + +dl.py:not(.fixture) > dd > dl.field-list > dd > ul.simple { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.25rem; + margin-bottom: 0; +} + +dl.py:not(.fixture) > dd > dl.field-list > dd > ul.simple > li > p { + margin: 0; } @media (max-width: 52rem) { diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css index c8ccdc6d..f041e846 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css @@ -84,7 +84,24 @@ dl.py.fixture > dd > dl.field-list > dt { color: var(--color-foreground-muted); } dl.py.fixture > dd > dl.field-list > dt .colon { display: none; } -dl.py.fixture > dd > dl.field-list > dd { grid-column: 2; margin-left: 0; } +dl.py.fixture > dd > dl.field-list > dd { + grid-column: 2; + margin-left: 0; + font-size: var(--gp-sphinx-type-metadata); + line-height: 1.5; +} + +dl.py.fixture > dd > dl.field-list > dd > ul.simple { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.25rem; + margin-bottom: 0; +} + +dl.py.fixture > dd > dl.field-list > dd > ul.simple > li > p { + margin: 0; +} @media (max-width: 52rem) { dl.py.fixture > dd > dl.field-list { diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 705937cc..2e76cd54 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -191,10 +191,35 @@ dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.fi color: var(--color-foreground-muted); } +/* Field body reads at metadata size so the monospace prefix wrapper + * inside doesn't visually outsize the surrounding sans prose. The + * eyebrow
        (also metadata size) and the body now sit in the same + * data-band register; hierarchy is carried by uppercase + muted color + * on the label, not by a size delta. */ dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dd, dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd { grid-column: 2; margin-left: 0; + font-size: var(--gp-sphinx-type-metadata); + line-height: 1.5; +} + +/* Override docutils' tight `simple` list convention. ` + * > li > p` collapses to `margin: 4px 0`, leaving only ~8px between + * adjacent rows against a 25.6px line-height. Flex-gap delivers a + * cleaner ~12px without fighting per-row margin collapse. */ +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dd > ul.simple, +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd > ul.simple { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.25rem; + margin-bottom: 0; +} + +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dd > ul.simple > li > p, +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd > ul.simple > li > p { + margin: 0; } @media (max-width: 52rem) { From d097048a1612d934234503245470f283a830a811 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:52:47 -0500 Subject: [PATCH 25/44] typehints-gp(css[field-prefix]) Reserve monospace for code identifiers only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: External CSS expert recommendation #2. The previous prefix wrapper rule re-fonted the entire `name (type)` portion to monospace — including parens, brackets, commas, and ellipsis. The expert noted: only the parameter name and type cross-references are truly "code"; structural punctuation around them should stay sans so the syntax-vs-text boundary is clearer. Reduces the optical mass of each row without changing what's actually monospace. what: - .gp-sphinx-field-prefix: change `font-family: var(--font-stack --monospace)` to `font-family: inherit`. The wrapper becomes a presentational container for the prefix text run; its children pick their own font-family. - Add `.gp-sphinx-field-prefix > strong` rule pinning the bold parameter name to monospace — it's the row's primary identifier and reads as code alongside the type. - Inline `` xref children continue to pick up monospace from Furo's `code.literal` rule in gp-furo-theme/.../code.css — unchanged, no new rule needed here. - Trim the docstring on the wrapper rule to reflect the new intent. verification: at 1280 px on the typehints-gp examples page, getComputedStyle('.gp-sphinx-field-prefix').fontFamily returns "IBM Plex Sans" (was "IBM Plex Mono"); strong child returns "IBM Plex Mono"; code.literal child returns "IBM Plex Mono"; the inline span.p (bracket/comma punctuation) returns "IBM Plex Sans". --- .../_static/css/typehints_gp.css | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css index cf3a7604..10c5ed94 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -25,13 +25,24 @@ } /* Field-list prefix wrapper added by FieldListPrefixWrapTransform. - * Inherits field-body size; monospace family makes - * `name (type)` read as code without forcing a separate scale. */ + * Defaults to inherit so structural punctuation (parens, + * brackets, commas, ellipsis) flows in the body sans font. Code + * identifiers inside the wrapper get monospace via the nested + * rules below — keeps the syntax-vs-text boundary clear. */ .gp-sphinx-field-prefix { - font-family: var(--font-stack--monospace); + font-family: inherit; font-size: inherit; } + /* The bold parameter name is a code identifier — render it in + * monospace alongside the type cross-references it labels. */ + .gp-sphinx-field-prefix > strong { + font-family: var(--font-stack--monospace); + } + /* Inline `` xref children of the prefix wrapper still pick + * up monospace from Furo's `code.literal` rule in + * gp-furo-theme/.../code.css; no extra rule needed here. */ + /* Defensive: blocks Furo's `code.literal { font-size: 81.25% }` * from compounding inside field-list rows. Keeps inline xref * code at the row's inherited size instead of shrinking on top. */ From 70c6ebedf8ac1ffacc46e964f45301ba5a438ec8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 10:54:39 -0500 Subject: [PATCH 26/44] typehints-gp(css[code-chip]) Restore Furo chip styling on field-list code; pin to 0.8125rem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: External CSS expert recommendation #4. The previous neutralisation rule (`.field-list code.literal { background: transparent; padding: 0; border: none; font-size: inherit }`) existed to stop Furo's percentage chip rule from compounding to ~10.5 px when the field body was pinned to 13 px. Now that field-body is back at metadata-size (14 px) — see commit FE1 — a 13 px chip with light-gray background and padding **is** the right size: it acts as a cognitive container around each type identifier without overwhelming the row. what: - Delete the chip-strip block (background/padding/border/font-size all reset to inherit). Furo's `code.literal { background: var(...); padding: 0.1em 0.2em; border-radius: 0.2em; font-size: 81.25% }` rule now fires unmodified for inline code in field-list rows. - Add a single-line size pin: `.field-list code.literal { font-size: 0.8125rem }`. This overrides Furo's percentage size with a root- relative rem so chips render at ≈13 px regardless of the row's metadata-size parent. Without the pin, 14 px parent × 81.25 % = ~11.4 px (too small). - Keep `.default_value code.literal { font-size: inherit; ...}` — signature default-value surface, different cascade, unaffected. verification: at 1280 px on the typehints-gp examples page, getComputedStyle('dl.field-list code.literal') returns fontSize 13 px, fontFamily "IBM Plex Mono", backgroundColor rgb(248, 249, 251) (Furo's --color-background-secondary), borderTopLeftRadius 2.6 px, paddingTop 1.3 px, paddingLeft 2.6 px (Furo's 0.1em 0.2em). --- .../_static/css/typehints_gp.css | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css index 10c5ed94..c26281c3 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -43,13 +43,16 @@ * up monospace from Furo's `code.literal` rule in * gp-furo-theme/.../code.css; no extra rule needed here. */ - /* Defensive: blocks Furo's `code.literal { font-size: 81.25% }` - * from compounding inside field-list rows. Keeps inline xref - * code at the row's inherited size instead of shrinking on top. */ + /* Furo's `code.literal` chip styling (background, padding, + * border-radius) acts as a cognitive container around each type + * identifier — preferred to a transparent inline link. Pin the + * size to a root-relative `0.8125rem` so chips render at ≈ 13 px + * regardless of the row's metadata-size parent. Furo's own + * `code.literal { font-size: 81.25% }` would otherwise compound + * to ~11.4 px against a 14 px parent (too small); using `rem` + * blocks the compounding while still scaling with the root clamp + * ramp on wide viewports. */ .field-list code.literal { - background: transparent; - border: none; - padding: 0; - font-size: inherit; + font-size: 0.8125rem; } } From 362b3fd8dcc2346d32a96255a54fffaa3626bf41 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 11:09:27 -0500 Subject: [PATCH 27/44] typehints-gp(css[code-chip]) Drop chip side-padding inside field lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo's `code.literal { padding: 0.1em 0.2em }` adds visible horizontal whitespace inside parameterized generic types. Brackets and commas live OUTSIDE the chip with no padding of their own, so every type expression reads gappy: `list[ str ]` instead of `list[str]`, `dict[ str, int ]` instead of `dict[str, int]`. External expert flagged this as the highest-impact remaining artifact — visible on every API page with a parameterized generic. what: - .field-list code.literal: add `padding-inline: 0`. Cancels the horizontal `0.2em` Furo applies; vertical `0.1em` stays so the chip retains visible block height. Background and border-radius unchanged. verification: at 1280 px viewport on the typehints-gp examples page, getComputedStyle on a field-list `code.literal`: paddingLeft = 0px (was 2.6px) paddingRight = 0px (was 2.6px) paddingTop = 1.3px (unchanged — Furo's 0.1em retained) Visual scan: parameterized generics now read as flush tokens — list[str], dict[str, int], tuple[str, ...], list[Transport]. --- .../_static/css/typehints_gp.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css index c26281c3..e48dc1ae 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -51,8 +51,17 @@ * `code.literal { font-size: 81.25% }` would otherwise compound * to ~11.4 px against a 14 px parent (too small); using `rem` * blocks the compounding while still scaling with the root clamp - * ramp on wide viewports. */ + * ramp on wide viewports. + * + * `padding-inline: 0` cancels Furo's `padding: 0.1em 0.2em` + * horizontal component so the chip background sits flush against + * the bracket characters around it. Without this, generic types + * read with visible whitespace inside the brackets — `list[ str ]` + * instead of `list[str]` — because the brackets and commas live + * outside the chip and have no padding of their own. Vertical + * `0.1em` padding stays so the chip retains visible block height. */ .field-list code.literal { font-size: 0.8125rem; + padding-inline: 0; } } From 9f28a521691ea68e623d065c5255ebcaeca67ab0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 11:11:31 -0500 Subject: [PATCH 28/44] typehints-gp(css[field-prefix]) Drop mono on ; sans-bold param name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: External expert refinement #2. Pre-FE5 the row carried two mono treatments: (param name) in mono bold black, and (type chip) in mono regular blue with chip background. Two distinct mono runs in the same row read busier than necessary. Sans-bold for the param name lets it read as a definition term; mono is then cleanly scoped to "code identifier inside chip" — semantic match, single mono treatment per row. what: - Delete `.gp-sphinx-field-prefix > strong { font-family: var( --font-stack--monospace) }`. now inherits font-family from its .gp-sphinx-field-prefix parent (which is `inherit`, resolves to body sans). The browser's default ` { font-weight: 700 }` keeps the bold weight. - Trim the surrounding comment to reflect the new policy: the prefix wrapper now leaves all family resolution to inheritance; only the inline children pick up monospace (via Furo's code.literal rule, no extra rule on our side). verification: at 1280 px, getComputedStyle('.gp-sphinx-field-prefix > strong').fontFamily returns "IBM Plex Sans" (was "IBM Plex Mono"); fontWeight stays 700. Type chips still render in IBM Plex Mono with chip background (Furo's rule unaffected). If the live preview reads as off (e.g. param names lose their "identifier" feel without mono), revert this single commit; FE4's chip-padding fix and the rest of the round-2 work stand on their own. --- .../_static/css/typehints_gp.css | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css index e48dc1ae..3f20e851 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -25,24 +25,18 @@ } /* Field-list prefix wrapper added by FieldListPrefixWrapTransform. - * Defaults to inherit so structural punctuation (parens, - * brackets, commas, ellipsis) flows in the body sans font. Code - * identifiers inside the wrapper get monospace via the nested - * rules below — keeps the syntax-vs-text boundary clear. */ + * Defaults to inherit so structural content — parameter name, + * parens, brackets, commas, ellipsis — flows in the body sans + * font. Only the inline `` type-name children get + * monospace, via Furo's `code.literal` rule in + * gp-furo-theme/.../code.css. Result: parameter name reads as + * sans-bold definition term; type expressions stay mono inside + * chips. One mono treatment per row, scoped to "this is code." */ .gp-sphinx-field-prefix { font-family: inherit; font-size: inherit; } - /* The bold parameter name is a code identifier — render it in - * monospace alongside the type cross-references it labels. */ - .gp-sphinx-field-prefix > strong { - font-family: var(--font-stack--monospace); - } - /* Inline `` xref children of the prefix wrapper still pick - * up monospace from Furo's `code.literal` rule in - * gp-furo-theme/.../code.css; no extra rule needed here. */ - /* Furo's `code.literal` chip styling (background, padding, * border-radius) acts as a cognitive container around each type * identifier — preferred to a transparent inline link. Pin the From 6330ea7df3c03a974d90ef841876456e750d6500 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 11:14:10 -0500 Subject: [PATCH 29/44] ux-autodoc-layout(css[field-list]) Margin symmetry on direct
        >

        MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Latent bug flagged by external expert. The FE1 zeroing rule covered

        elements inside (parameter rows) but not direct

        children of field

        . Field bodies that render a single direct

        — Return type, single-line Yields, single-paragraph Raises — kept docutils' default `margin: 4px 0`. The asymmetry is imperceptible on the HookCounters demo (Return value is one short line) but will show the moment a function returns a multi-line description: the Return body row would gain top/bottom padding while Parameters items butt up against each other. what: - layout.css (authoritative): extend the existing zeroing selector list to include `.gp-sphinx-api-{parameters,facts} dl.field-list > dd > p` alongside the existing `> dd > ul.simple > li > p` paths. One four-line selector handles both cases for both autodoc region variants. - api_style.css standalone fallback: same extension on the direct-child path (`dl.py:not(.fixture) > dd > dl.field-list > dd > p`). - pytest_fixtures.css standalone fallback: same extension on the fixture variant (`dl.py.fixture > dd > dl.field-list > dd > p`). verification: at 1280 px on the typehints-gp examples page, getComputedStyle on the Return-type field-even

        >

        reports `marginTop: 0px; marginBottom: 0px` (was 4px/4px); Parameters list inner

        stays at 0/0 (unchanged from FE1). --- .../sphinx_autodoc_api_style/_static/css/api_style.css | 1 + .../_static/css/sphinx_autodoc_pytest_fixtures.css | 1 + .../src/sphinx_ux_autodoc_layout/_static/css/layout.css | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css index e4a7ac64..5c31496b 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css @@ -112,6 +112,7 @@ dl.py:not(.fixture) > dd > dl.field-list > dd > ul.simple { margin-bottom: 0; } +dl.py:not(.fixture) > dd > dl.field-list > dd > p, dl.py:not(.fixture) > dd > dl.field-list > dd > ul.simple > li > p { margin: 0; } diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css index f041e846..2cb11ce9 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css @@ -99,6 +99,7 @@ dl.py.fixture > dd > dl.field-list > dd > ul.simple { margin-bottom: 0; } +dl.py.fixture > dd > dl.field-list > dd > p, dl.py.fixture > dd > dl.field-list > dd > ul.simple > li > p { margin: 0; } diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 2e76cd54..590afcc4 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -217,6 +217,14 @@ dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.fi margin-bottom: 0; } +/* Zero `

        ` margins both inside `` (parameter rows) + * and on direct children of `

        ` (single-paragraph Returns, + * Yields, Raises). Without the latter selector the Return body + * keeps docutils' default `margin: 4px 0`, producing asymmetric + * spacing the moment the Return description spans more than one + * line. Symmetric here keeps the field-list a coherent data band. */ +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dd > p, +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd > p, dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dd > ul.simple > li > p, dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd > ul.simple > li > p { margin: 0; From 15abf03c542e86dbde4ed18f00b56d74d96f3a65 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 11:53:33 -0500 Subject: [PATCH 30/44] ux-autodoc-layout+api-style(css[field-list-bullets]) Cancel Furo's negative
          margin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo's `api.css` applies `margin-left: -1.2rem` to every `.field-list dd > ul` inside an unfiltered `dl[class]`, intending to pull the markers into the column gap. In our `
          > ` flex column, that combination leaks the FIRST `
        • ` marker into the column gap (flush against the `PARAMETERS:` eyebrow) while subsequent markers render at the dd's edge — producing a visibly mis-aligned first row that the user spotted on `HookCounters.__init__`. what: - Add `margin-left: 0` on the authoritative `ul.simple` rule in `layout.css` (active when sphinx-ux-autodoc-layout's `_wrap_content_runs` is in play and the field-list sits inside `.gp-sphinx-api-parameters` / `.gp-sphinx-api-facts`) - Mirror the same `margin-left: 0` on the standalone-fallback rule in `sphinx-autodoc-api-style`'s `api_style.css` so packages installed without sphinx-ux-autodoc-layout still get consistent bullet alignment - Document the Furo collision and the fix rationale alongside both rules --- .../_static/css/api_style.css | 7 +++++++ .../sphinx_ux_autodoc_layout/_static/css/layout.css | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css index 5c31496b..39421ce4 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css @@ -104,12 +104,19 @@ dl.py:not(.fixture) > dd > dl.field-list > dd { line-height: 1.5; } +/* `margin-left: 0` cancels Furo's `api.css` rule that pulls the + * `
            ` 1.2rem to the left (`dl[class]:not(...).field-list dd > ul + * { margin-left: -1.2rem }`). Combined with the inherited + * `padding-left: 1.2rem`, that negative margin would leak the first + * `
          • ` marker into the column gap while subsequent markers stay at + * the dd's edge. */ dl.py:not(.fixture) > dd > dl.field-list > dd > ul.simple { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.25rem; margin-bottom: 0; + margin-left: 0; } dl.py:not(.fixture) > dd > dl.field-list > dd > p, diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 590afcc4..14a49607 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -207,7 +207,15 @@ dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.fi /* Override docutils' tight `simple` list convention. ` * > li > p` collapses to `margin: 4px 0`, leaving only ~8px between * adjacent rows against a 25.6px line-height. Flex-gap delivers a - * cleaner ~12px without fighting per-row margin collapse. */ + * cleaner ~12px without fighting per-row margin collapse. + * + * `margin-left: 0` cancels Furo's `api.css` rule that pulls the + * `
              ` 1.2rem to the left (`dl[class]:not(...).field-list dd > ul + * { margin-left: -1.2rem }`). Combined with the inherited + * `padding-left: 1.2rem`, that negative margin would leak the first + * `
            • ` marker into the column gap while subsequent markers stay at + * the dd's edge, leaving the first bullet flush against the eyebrow + * label and breaking row-to-row alignment. */ dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dd > ul.simple, dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd > ul.simple { display: flex; @@ -215,6 +223,7 @@ dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.fi gap: 0.75rem; margin-top: 0.25rem; margin-bottom: 0; + margin-left: 0; } /* Zero `

              ` margins both inside `` (parameter rows) From 215c99c10f6d083a3e72fad25916c6d1c1a43ea8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 11:53:53 -0500 Subject: [PATCH 31/44] ux-autodoc-layout(css[mobile-header]) Pin type badge left of signature on narrow viewports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The previous mobile rule stacked `.gp-sphinx-api-layout` as `flex-direction: column`, inheriting `align-items: center` from the desktop block. That centered the signature in its own row and pushed the type badge ("attribute", "method", …) onto a second row at the left — wasting vertical space, hiding the badge below long signatures, and turning every API entry into a centered orphan. The badge is the single most useful at-a-glance classifier; pinning it to the left as the row's anchor lets the signature reflow naturally in the remaining width, no centering, no wasted line. what: - Switch the mobile `.gp-sphinx-api-layout` and `.gp-sphinx-api-card-shell … .gp-sphinx-api-layout` to `flex-direction: row` with `align-items: flex-start` - Visually reorder the badge (`.gp-sphinx-api-layout-right`) to the left via `order: -1`, keeping the DOM order (signature first) intact for screen readers - Size the badge column with `flex: 0 0 auto; width: auto; white-space: nowrap` so it stays compact and doesn't shrink under signature pressure - Size the signature column with `flex: 1 1 0; min-width: 0` so it consumes the remaining width and wraps cleanly when long - Document the rationale (visual anchor vs. mutable signature, source-order preservation) in the section header --- .../_static/css/layout.css | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 14a49607..6ae0d2df 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -395,31 +395,59 @@ dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-conta background: var(--color-api-background-hover); } -/* ── Mobile adjustments ─────────────────────────────── */ +/* ── Mobile adjustments ─────────────────────────────── * + * + * Below the 52rem breakpoint the desktop "signature left, badge + * right" row no longer reads cleanly: long signatures wrap onto + * three or four lines and the badge ends up floating beside the + * tail of that wrap, or the whole row collapses and centers + * because `align-items: center` carries over from the desktop + * rule. Instead, pin the badge column to the LEFT (single anchor + * point that the eye returns to) and let the signature reflow into + * the remaining width. The badge tells you what kind of API entry + * this is at a glance; the signature is the mutable thing that + * wraps. Reordering with `order: -1` keeps source-order + * untouched (badge stays after signature in the DOM for + * accessibility) while flipping visual order on narrow viewports. */ @media (max-width: 52rem) { dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout { - flex-direction: column; + flex-direction: row; + align-items: flex-start; gap: 0.5rem; } + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-left { + flex: 1 1 0; + min-width: 0; + } + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-right { + order: -1; + flex: 0 0 auto; margin-left: 0; - white-space: normal; - width: 100%; + width: auto; justify-content: flex-start; flex-wrap: wrap; + white-space: nowrap; } .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout { - flex-direction: column; + flex-direction: row; align-items: flex-start; } + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-left { + flex: 1 1 0; + min-width: 0; + } + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-right { + order: -1; + flex: 0 0 auto; margin-left: 0; - white-space: normal; - width: 100%; + width: auto; flex-wrap: wrap; + white-space: nowrap; } } From ddfe5fa8af56955993231db20f20740b07d14322 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 12:49:24 -0500 Subject: [PATCH 32/44] ux-autodoc-layout+api-style+pytest-fixtures(css[field-list]) Move dd margin-top from children to dd itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo's lists.css gives every .field-list dt `margin-top: 0.25rem` but has no matching rule for dd. Previous commits compensated on dd's children (ul.simple got 0.25rem; p got zeroed to 0) — one workaround per content type. The RETURN TYPE row broke because its dd holds a direct

              that was zeroed, leaving nothing to push the cell content down to match the dt baseline. what: - Add `margin-top: 0.25rem` to the `> dd` rule in all three files (authoritative layout.css, api_style.css standalone fallback, pytest_fixtures.css standalone fallback) - Drop the now-redundant `margin-top: 0.25rem` from `> dd > ul.simple` in all three files (set to 0) so the offsets don't compound - `> dd > p { margin: 0 }` rules stay unchanged; the parent dd now carries the single offset regardless of inner content type --- .../src/sphinx_autodoc_api_style/_static/css/api_style.css | 3 ++- .../_static/css/sphinx_autodoc_pytest_fixtures.css | 3 ++- .../src/sphinx_ux_autodoc_layout/_static/css/layout.css | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css index 39421ce4..a616efab 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css @@ -99,6 +99,7 @@ dl.py:not(.fixture) > dd > dl.field-list > dt { dl.py:not(.fixture) > dd > dl.field-list > dd { grid-column: 2; + margin-top: 0.25rem; margin-left: 0; font-size: var(--gp-sphinx-type-metadata); line-height: 1.5; @@ -114,7 +115,7 @@ dl.py:not(.fixture) > dd > dl.field-list > dd > ul.simple { display: flex; flex-direction: column; gap: 0.75rem; - margin-top: 0.25rem; + margin-top: 0; margin-bottom: 0; margin-left: 0; } diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css index 2cb11ce9..01eea360 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css @@ -86,6 +86,7 @@ dl.py.fixture > dd > dl.field-list > dt { dl.py.fixture > dd > dl.field-list > dt .colon { display: none; } dl.py.fixture > dd > dl.field-list > dd { grid-column: 2; + margin-top: 0.25rem; margin-left: 0; font-size: var(--gp-sphinx-type-metadata); line-height: 1.5; @@ -95,7 +96,7 @@ dl.py.fixture > dd > dl.field-list > dd > ul.simple { display: flex; flex-direction: column; gap: 0.75rem; - margin-top: 0.25rem; + margin-top: 0; margin-bottom: 0; } diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 6ae0d2df..1b5f1260 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -199,6 +199,7 @@ dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.fi dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list > dd, dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd { grid-column: 2; + margin-top: 0.25rem; margin-left: 0; font-size: var(--gp-sphinx-type-metadata); line-height: 1.5; @@ -221,7 +222,7 @@ dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.fi display: flex; flex-direction: column; gap: 0.75rem; - margin-top: 0.25rem; + margin-top: 0; margin-bottom: 0; margin-left: 0; } From c431d4f2dcce61e4e348f76231cad22813fbb39a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 13:50:31 -0500 Subject: [PATCH 33/44] docs(CHANGES) Curated default rendering and autodoc typography polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Log the user-visible surface of the improved-defaults-reprs branch — default-value rendering, field-list xrefs, autodoc typography, and two visual fixes — before merging. what: - What's new: curated default rendering (preserve-defaults flip, __init__ synthesis fill, value truncation, identifier xrefs) - What's new: canonical Python xrefs in autodoc field lists - What's new: autodoc typography polish (unified metadata band, flush chips, sans-bold names, clamp() root ramp, gp-sphinx cascade layer) - Bug fix: TOC font-size override applies (moved off :root) - Bug fix: mobile-header type badge stays beside signature --- CHANGES | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/CHANGES b/CHANGES index 0d4829ce..c01f9013 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,55 @@ $ uv add gp-sphinx --prerelease allow +### What's new + +#### `gp-sphinx`: Curated default-value rendering for autodoc + +`autodoc_preserve_defaults=True` is now the workspace default, and a +new listener fills the synthetic `__init__` gap so dataclass +parameters render as `=[]` instead of `=`, and sentinel +constants render as their source name (e.g. +`scope=DEFAULT_OPTION_SCOPE`) instead of `<...object at 0x...>`. +Long `:value:` text is collapsed so multi-KB constants no longer +dominate API pages while short useful values stay intact. +Identifier defaults inside parameter signatures become live +cross-references to their documented class — the resulting HTML +matches an inline `:py:class:` role, so hand-written +`.. py:function::` directives benefit too. (#36) + +#### `sphinx-autodoc-typehints-gp`: Canonical Python xrefs in field lists + +Type expressions in autodoc field lists route through the same +xref pipeline as the signature, so links resolve consistently and +nested types render with chip styling that matches the rest of the +page. (#36) + +#### Autodoc typography polish across the docs surface + +Parameter and field-list rows share a unified metadata-sized +typography band across the autodoc stack. Code chips sit flush +against generic-type brackets, parameter names use sans-bold while +monospace is reserved for code identifiers, and root-level scaling +on wide viewports follows a `clamp()` ramp instead of stepping up +at a single breakpoint. A new `gp-sphinx` cascade layer makes +workspace overrides win over Furo declaratively rather than via +unlayered precedence. (#36) + +### Bug fixes + +#### `sphinx-gp-theme`: TOC font-size override now applies + +The override for `--toc-font-size` and `--toc-title-font-size` was +inert because `gp-furo-tokens` emits the default values on `body`, +not `:root`. The sidebar TOC now picks up the configured larger +size. (#36) + +#### `sphinx-ux-autodoc-layout`: Mobile header keeps the type badge beside the signature + +On narrow viewports the type badge previously wrapped below the +signature, breaking the eyebrow-style layout. It now stays pinned +to the left of the signature row. (#36) + ## gp-sphinx 0.0.1a17 (2026-05-09) ### What's new From ae1fb1a9dc9461647900f84e7410c000dae741f0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 14:10:54 -0500 Subject: [PATCH 34/44] docs+fix(sphinx-vite-builder) Drop cache: pnpm from default CI hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: setup-node's cache:pnpm option requires pnpm-lock.yaml at the consumer repo root. Most downstream consumers pulling a sphinx-vite-builder-backed package from a transitive git source do not have a lockfile at root — the lockfile lives inside the source-built package's checkout. Following the runtime hint verbatim made consumer CI fail with "Dependencies lock file is not found", caught while wiring libtmux + libtmux-mcp preview branches against the improved-defaults-reprs source pin. what: - vite.py: drop cache: pnpm from the github-actions setup recipe emitted in PnpmMissingError hints - README.md: drop cache: pnpm from the sample error output and the search-discoverable GitHub Actions recipe; add a paragraph explaining when the cache option is safe to add back - AGENTS.md: same recipe edit + a note that the workspace's own release.yml may still keep cache: pnpm because the lockfile lives at this repo's root --- packages/sphinx-vite-builder/AGENTS.md | 9 ++++++++- packages/sphinx-vite-builder/README.md | 9 +++++++-- .../src/sphinx_vite_builder/_internal/vite.py | 3 +-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/sphinx-vite-builder/AGENTS.md b/packages/sphinx-vite-builder/AGENTS.md index a9cd6f34..8526087e 100644 --- a/packages/sphinx-vite-builder/AGENTS.md +++ b/packages/sphinx-vite-builder/AGENTS.md @@ -168,13 +168,20 @@ motivated this whole package). The required steps are: - uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm ``` If you find yourself removing those, ask: "is the source-build path still going to produce a populated `static/` in the wheel?" The answer must be yes. +`cache: pnpm` is intentionally omitted from the recipe so it stays +correct in *consumer* CI as well — setup-node's pnpm cache lookup +expects a `pnpm-lock.yaml` at the repo root, which only exists in +this workspace, not in downstream repos that pull a gp-sphinx-family +package transitively from a git source. The workspace's own +`release.yml` may keep `cache: pnpm` for a small speed-up since the +lockfile lives at the repo root here. + ## When in doubt - Read the full plan at gp-sphinx issue #28. diff --git a/packages/sphinx-vite-builder/README.md b/packages/sphinx-vite-builder/README.md index 5f2f790a..59879755 100644 --- a/packages/sphinx-vite-builder/README.md +++ b/packages/sphinx-vite-builder/README.md @@ -76,7 +76,6 @@ config (before the Python build step that triggers this backend): - uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm ``` The error includes the resolved vite-root path, the platform-specific @@ -227,9 +226,15 @@ search-discoverability. - uses: actions/setup-node@v6 with: node-version: 22 - cache: pnpm ``` +`cache: pnpm` is intentionally omitted: setup-node's pnpm cache +lookup expects a `pnpm-lock.yaml` at the consumer repo root, which +fails when sphinx-vite-builder runs from a transitive git source +(the lockfile lives inside the source-built package's checkout, not +the consumer repo). Workspaces that own their own `pnpm-lock.yaml` +at the repo root may add `cache: pnpm` for a small speed-up. + ### CircleCI (`CIRCLECI=true`) ```yaml diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py index 317461f4..60580f5a 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/_internal/vite.py @@ -125,8 +125,7 @@ def _detect_ci_provider() -> str | None: version: 10 - uses: actions/setup-node@v6 with: - node-version: 22 - cache: pnpm""", + node-version: 22""", ), "circleci": textwrap.dedent( """\ From 018f4e3c053584b87c78a72b4c8ac8e7cb45d739 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 14:36:36 -0500 Subject: [PATCH 35/44] gp-sphinx(feat[linkcode]) Add make_workspace_linkcode_resolve for monorepos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The workspace docs site was the only build missing [source] links; make_linkcode_resolve assumes ///… which doesn't fit the /packages//src//… workspace layout. what: - Add make_workspace_linkcode_resolve() to gp_sphinx.config — resolves any module under repo_root by relpath from inspect.getsourcefile, always pointing to source_branch (no per-package v{version} tag) - Wire into docs/conf.py via make_workspace_linkcode_resolve(repo_root, github_url); merge_sphinx_config auto-registers sphinx.ext.linkcode - Add 6 tests (callable, non-py-domain, workspace-URL, custom-branch, outside-repo-returns-None, auto-extension) --- docs/conf.py | 10 +- packages/gp-sphinx/src/gp_sphinx/config.py | 103 +++++++++++++++ tests/test_config.py | 140 ++++++++++++++++++++- 3 files changed, 251 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 6912f4d8..0431927a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,7 +61,10 @@ sys.path.insert(0, str(cwd / "_ext")) # docs demo modules import gp_sphinx # noqa: E402 -from gp_sphinx.config import merge_sphinx_config # noqa: E402 +from gp_sphinx.config import ( # noqa: E402 + make_workspace_linkcode_resolve, + merge_sphinx_config, +) intersphinx_mapping = { "py": ("https://docs.python.org/3/", None), @@ -75,6 +78,11 @@ source_repository=f"{gp_sphinx.__github__}/", docs_url=gp_sphinx.__docs__, source_branch="main", + linkcode_resolve=make_workspace_linkcode_resolve( + repo_root=project_root, + github_url=gp_sphinx.__github__, + source_branch="main", + ), extra_extensions=[ "inline_highlight", "package_reference", diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 497c4555..f57478cb 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -32,6 +32,7 @@ import logging import os.path import pathlib +import sys import typing as t from gp_sphinx.defaults import ( @@ -207,6 +208,108 @@ def linkcode_resolve(domain: str, info: dict[str, str]) -> str | None: return linkcode_resolve +def make_workspace_linkcode_resolve( + *, + repo_root: pathlib.Path | str, + github_url: str, + source_branch: str = "main", +) -> Callable[[str, dict[str, str]], str | None]: + """Create a ``linkcode_resolve`` function for a uv/pnpm workspace monorepo. + + Unlike :func:`make_linkcode_resolve`, which assumes a single-package layout + rooted at ``///…``, this resolver computes URLs by + taking the absolute path returned by :func:`inspect.getsourcefile` and + making it relative to *repo_root*. This works uniformly across all packages + in a workspace layout such as ``/packages//src//…`` + without requiring per-package registration. + + The URL always points to *source_branch* — there is no per-package version + tag branching because each workspace package carries its own independent + version string while the docs site tracks the live monorepo tip. + + Returns ``None`` when the domain is not ``"py"``, the module is not + imported, the attribute cannot be resolved, ``inspect.getsourcefile`` + returns a falsy value, or the source file lives outside *repo_root*. + + Parameters + ---------- + repo_root : pathlib.Path or str + Absolute path to the repository root (the directory that contains + ``pyproject.toml`` and the ``packages/`` tree). + github_url : str + Base GitHub repository URL, e.g. + ``"https://github.com/git-pull/gp-sphinx"``. + source_branch : str + Branch used in all generated URLs (default ``"main"``). + + Returns + ------- + Callable[[str, dict[str, str]], str | None] + A function suitable for ``linkcode_resolve`` in a Sphinx config. + + Examples + -------- + >>> import pathlib + >>> resolver = make_workspace_linkcode_resolve( + ... repo_root=pathlib.Path("/tmp/repo"), + ... github_url="https://github.com/git-pull/gp-sphinx", + ... ) + >>> callable(resolver) + True + >>> resolver("c", {"module": "x", "fullname": "y"}) is None + True + """ + root = pathlib.Path(repo_root).resolve() + + def linkcode_resolve(domain: str, info: dict[str, str]) -> str | None: + if domain != "py": + return None + + modname = info["module"] + fullname = info["fullname"] + + submod = sys.modules.get(modname) + if submod is None: + return None + + obj: object = submod + for part in fullname.split("."): + try: + obj = getattr(obj, part) + except Exception: # noqa: PERF203 + return None + + try: + unwrap = inspect.unwrap + except AttributeError: + pass + else: + if callable(obj): + obj = unwrap(obj) + + try: + fn = inspect.getsourcefile(obj) # type: ignore[arg-type] + except Exception: + fn = None + if not fn: + return None + + try: + source, lineno = inspect.getsourcelines(obj) # type: ignore[arg-type] + except Exception: + lineno = None + + linespec = f"#L{lineno}-L{lineno + len(source) - 1}" if lineno else "" + + rel = os.path.relpath(fn, start=root) + if rel.startswith(".."): + return None + + return f"{github_url}/blob/{source_branch}/{rel}{linespec}" + + return linkcode_resolve + + def merge_sphinx_config( *, project: str, diff --git a/tests/test_config.py b/tests/test_config.py index ac89dd1a..81d0b337 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,10 +2,17 @@ from __future__ import annotations +import pathlib + import pytest import gp_sphinx -from gp_sphinx.config import deep_merge, make_linkcode_resolve, merge_sphinx_config +from gp_sphinx.config import ( + deep_merge, + make_linkcode_resolve, + make_workspace_linkcode_resolve, + merge_sphinx_config, +) from gp_sphinx.defaults import DEFAULT_EXTENSIONS, DEFAULT_MYST_EXTENSIONS @@ -538,3 +545,134 @@ def dummy() -> None: url = resolver("py", {"module": "fake", "fullname": "dummy"}) assert url is not None assert "/blob/custom-branch/src/" in url + + +# --------------------------------------------------------------------------- +# make_workspace_linkcode_resolve +# --------------------------------------------------------------------------- + + +def test_make_workspace_linkcode_resolve_returns_callable( + tmp_path: pathlib.Path, +) -> None: + """make_workspace_linkcode_resolve returns a callable.""" + resolver = make_workspace_linkcode_resolve( + repo_root=tmp_path, + github_url="https://github.com/git-pull/gp-sphinx", + ) + assert callable(resolver) + + +def test_make_workspace_linkcode_resolve_non_py_domain_returns_none( + tmp_path: pathlib.Path, +) -> None: + """Resolver returns None for non-Python domains.""" + resolver = make_workspace_linkcode_resolve( + repo_root=tmp_path, + github_url="https://github.com/git-pull/gp-sphinx", + ) + assert resolver("c", {"module": "gp_sphinx", "fullname": "foo"}) is None + + +def test_make_workspace_linkcode_resolve_url_for_workspace_module() -> None: + """Resolver produces a correct GitHub URL for a workspace package module.""" + repo_root = pathlib.Path(__file__).resolve().parent.parent + resolver = make_workspace_linkcode_resolve( + repo_root=repo_root, + github_url="https://github.com/git-pull/gp-sphinx", + source_branch="main", + ) + url = resolver( + "py", + {"module": "gp_sphinx.config", "fullname": "merge_sphinx_config"}, + ) + assert url is not None + assert "/blob/main/" in url + assert "packages/gp-sphinx/src/gp_sphinx/config.py" in url + assert "#L" in url + + +def test_make_workspace_linkcode_resolve_uses_source_branch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Resolver uses the provided source_branch in the generated URL.""" + import sys + import types + + repo_root = pathlib.Path("/tmp/ws_repo") + src_file = repo_root / "packages" / "mypkg" / "src" / "mypkg" / "_mod.py" + + fake_module = types.ModuleType("mypkg._mod") + + def dummy_fn() -> None: + pass + + fake_module.dummy_fn = dummy_fn # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "mypkg._mod", fake_module) + monkeypatch.setattr( + "inspect.getsourcefile", + lambda obj: str(src_file) if obj is dummy_fn else None, + ) + monkeypatch.setattr( + "inspect.getsourcelines", + lambda obj: (["def dummy_fn(): ...\n"], 42), + ) + + resolver = make_workspace_linkcode_resolve( + repo_root=repo_root, + github_url="https://github.com/git-pull/gp-sphinx", + source_branch="release/0.1", + ) + url = resolver("py", {"module": "mypkg._mod", "fullname": "dummy_fn"}) + assert url is not None + assert "/blob/release/0.1/" in url + + +def test_make_workspace_linkcode_resolve_outside_repo_returns_none( + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """Resolver returns None when source file is outside repo_root.""" + import sys + import types + + outside_file = "/tmp/other_project/src/mod.py" + + fake_module = types.ModuleType("other_mod") + + def dummy_fn() -> None: + pass + + fake_module.dummy_fn = dummy_fn # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "other_mod", fake_module) + monkeypatch.setattr( + "inspect.getsourcefile", + lambda obj: outside_file if obj is dummy_fn else None, + ) + monkeypatch.setattr( + "inspect.getsourcelines", + lambda obj: (["def dummy_fn(): ...\n"], 1), + ) + + resolver = make_workspace_linkcode_resolve( + repo_root=tmp_path, + github_url="https://github.com/git-pull/gp-sphinx", + ) + assert resolver("py", {"module": "other_mod", "fullname": "dummy_fn"}) is None + + +def test_merge_sphinx_config_linkcode_auto_added_with_workspace_resolver( + tmp_path: pathlib.Path, +) -> None: + """sphinx.ext.linkcode auto-added when workspace resolver is provided.""" + resolver = make_workspace_linkcode_resolve( + repo_root=tmp_path, + github_url="https://github.com/git-pull/gp-sphinx", + ) + result = merge_sphinx_config( + project="test", + version="1.0", + copyright="2026", + linkcode_resolve=resolver, + ) + assert "sphinx.ext.linkcode" in result["extensions"] From 526b9ae4eb47107ff4df9e072c20c2f6280499fe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 15:10:24 -0500 Subject: [PATCH 36/44] ux-autodoc-layout(css[mobile-stack]) Stack header rows below 52rem instead of squeezing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The prior mobile rule from 4bcb4c6d pinned the toolbar left and let the signature flex 1 1 0 with min-width: 0 in the remaining horizontal space. At narrow widths the signature column ended up below the natural width of an unbreakable identifier like `demo_session`, and Furo's inherited `overflow-wrap: break-word` fell back to per-character wrapping — producing a one-letter-per-line column at ~340px viewports. The toolbar-anchor mode also inverted the desktop reading hierarchy (signature primary-left → toolbar primary-left), so badges landed in the slot the eye expects the signature to occupy. The new rule stacks vertically at the same 52rem breakpoint the field-list grid already collapses at, so the whole card snaps to single-column at one consistent point. Reading-order hierarchy maps 1:1 from desktop (signature primary-left, toolbar secondary-right) to narrow (toolbar above, signature below at full width). The signature always has the full container row to wrap into; longest identifier wraps at word/identifier boundaries, never per-character. what: - Switch `@media (max-width: 52rem)` for `.gp-sphinx-api-layout` to `flex-direction: column; align-items: stretch; gap: 0.25rem` on both the `dl.gp-sphinx-api-container > dt.gp-sphinx-api-header` (Python managed entries) and `.gp-sphinx-api-card-shell` (rst: directive / role, std:confval, mcp:tool) variants in one selector. - Keep `order: -1` on `.gp-sphinx-api-layout-right` so the toolbar is visually above the signature while DOM order (signature first) stays intact for screen readers, copy-paste, and keyboard nav. Add `align-self: flex-start` so the toolbar doesn't stretch to row width and `flex-wrap: wrap` so badge clusters of three or more plus [source] wrap to a second toolbar row when needed. - `.gp-sphinx-api-layout-left { width: 100%; min-width: 0 }` makes the signature claim the full container row. - Add `.gp-sphinx-api-signature { overflow-wrap: anywhere }` as a safety net for sub-300px viewports where even a full row can't hold `demo_session_factory` intact — prefer breaking long identifiers cleanly over Furo's per-character fallback. - Rewrite the section header comment to document the stack rationale (squeeze failure mode, hierarchy mapping, DOM-order preservation). --- .../_static/css/layout.css | 75 +++++++++---------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 1b5f1260..709f8478 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -398,57 +398,54 @@ dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-conta /* ── Mobile adjustments ─────────────────────────────── * * - * Below the 52rem breakpoint the desktop "signature left, badge - * right" row no longer reads cleanly: long signatures wrap onto - * three or four lines and the badge ends up floating beside the - * tail of that wrap, or the whole row collapses and centers - * because `align-items: center` carries over from the desktop - * rule. Instead, pin the badge column to the LEFT (single anchor - * point that the eye returns to) and let the signature reflow into - * the remaining width. The badge tells you what kind of API entry - * this is at a glance; the signature is the mutable thing that - * wraps. Reordering with `order: -1` keeps source-order - * untouched (badge stays after signature in the DOM for - * accessibility) while flipping visual order on narrow viewports. */ + * Below the 52rem breakpoint the desktop "signature left, toolbar + * right" row stops fitting: the toolbar (badges + [source]) keeps + * its full natural width via `white-space: nowrap`, so the + * signature column gets squeezed below the width of an unbreakable + * identifier like `demo_session`. Once that happens, Furo's + * inherited `overflow-wrap: break-word` is the only thing keeping + * the text inside the column — it breaks the identifier at every + * character, producing a one-letter-per-line column. + * + * Stack vertically instead, matching the same 52rem breakpoint the + * field-list grid uses (see line 243): + * row 1: toolbar (badges + source link) + * row 2: signature, full container width + * + * Reading-order hierarchy maps 1:1 from desktop (signature + * primary-left, toolbar secondary-right) to narrow (toolbar above, + * signature below at full width). `order: -1` flips visual order + * while keeping the DOM order (signature first) intact for + * screen readers, copy-paste, and keyboard nav. */ @media (max-width: 52rem) { - dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout { - flex-direction: row; - align-items: flex-start; - gap: 0.5rem; - } - - dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-left { - flex: 1 1 0; - min-width: 0; + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout, + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout { + flex-direction: column; + align-items: stretch; + gap: 0.25rem; } - dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-right { + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-right, + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-right { order: -1; - flex: 0 0 auto; margin-left: 0; - width: auto; - justify-content: flex-start; + align-self: flex-start; flex-wrap: wrap; - white-space: nowrap; - } - - .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout { - flex-direction: row; - align-items: flex-start; } + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-left, .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-left { - flex: 1 1 0; + width: 100%; min-width: 0; } - .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-right { - order: -1; - flex: 0 0 auto; - margin-left: 0; - width: auto; - flex-wrap: wrap; - white-space: nowrap; + /* Safety net for sub-300px viewports: prefer breaking long + * identifiers at any point over per-character wrapping that + * Furo's default overflow-wrap produces. At the 280px low end + * this lets `demo_session_factory` wrap once or twice instead of + * one character per line. */ + .gp-sphinx-api-signature { + overflow-wrap: anywhere; } } From 43954b2ccb68b79f6a01c9f8994809130be5240b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 15:31:43 -0500 Subject: [PATCH 37/44] ux-autodoc-layout(css[narrow-source-right]): Right-anchor source link in toolbar below 52rem why: At narrow widths the toolbar row reads as a left-flushed [badges] [source] cluster with no right anchor, losing the desktop split-aesthetic where the action sits opposite the content anchor. what: - Drop align-self: flex-start on .gp-sphinx-api-layout-right so the toolbar fills the row width (inherits align-items: stretch from the parent .gp-sphinx-api-layout). - Add margin-left: auto on .gp-sphinx-api-source-link inside the 52rem block so [source] floats to the right edge while badges remain flush-left. - Rule survives flex-wrap: if the badge cluster overflows the row, [source] lands on its own line with the right anchor intact. --- .../sphinx_ux_autodoc_layout/_static/css/layout.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 709f8478..7dbe8e47 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -429,10 +429,19 @@ dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-conta .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-right { order: -1; margin-left: 0; - align-self: flex-start; flex-wrap: wrap; } + /* Mirror the desktop split (signature-left, toolbar-right) inside the + * toolbar row itself: badges remain flush-left as the type/scope + * anchor; the source link floats to the right edge as the secondary + * action. Falls back gracefully when the row wraps — the source link + * lands on its own line with the right anchor preserved. */ + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-source-link, + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-source-link { + margin-left: auto; + } + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-left, .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-left { width: 100%; From 8179bc0e1a21c1cc42f5140c89f3cddf3eb49d8d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 15:32:42 -0500 Subject: [PATCH 38/44] ux-autodoc-layout(css[narrow-permalink]): Reveal hover-only permalink for touch users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The ¶ permalink is keyed to :hover on desktop, which leaves mobile users with no way to grab a stable link to a specific autodoc entry — there is no hover event on touch devices. what: - Inside the existing @media (max-width: 52rem) block, override the desktop visibility: hidden rule on .gp-sphinx-api-link to visibility: visible so the glyph becomes a tap target. - Desktop behavior is unchanged: the override is scoped to the same narrow tier already used for the stacked layout. --- .../src/sphinx_ux_autodoc_layout/_static/css/layout.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 7dbe8e47..05810614 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -448,6 +448,15 @@ dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-conta min-width: 0; } + /* Touch devices have no hover, so the permalink anchor (¶) is + * unreachable on phones under the desktop visibility:hidden rule. + * Promote it to always-visible at narrow widths so users can tap + * to copy a stable link to the entry. */ + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-link, + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-link { + visibility: visible; + } + /* Safety net for sub-300px viewports: prefer breaking long * identifiers at any point over per-character wrapping that * Furo's default overflow-wrap produces. At the 280px low end From b03e3d60d117ed568323e18f4108c95baae7ce1a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 15:35:10 -0500 Subject: [PATCH 39/44] ux-autodoc-layout(css[tiny-hscroll]): Swap character-wrap for horizontal scroll below 30rem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: At phone widths the safety-net overflow-wrap: anywhere from the 52rem block breaks unbreakable identifiers like `transport` into `tra` / `nsport`. Keeping the identifier whole and letting the signature scroll horizontally inside its card is the readable alternative — a horizontal swipe reveals the rest of the signature without mangling token boundaries. what: - Add a new @media (max-width: 30rem) block scoped to .gp-sphinx-api-signature inside both the dl-shell and card-shell selectors. - Set overflow-wrap: normal + word-break: keep-all to disable character-level wrap below 30rem. - Set overflow-x: auto so overlong signatures scroll horizontally inside the card. - Set scrollbar-width: thin to keep the scrollbar visually quiet on platforms that honour the property. --- .../_static/css/layout.css | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 05810614..85d3999a 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -467,5 +467,28 @@ dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-conta } } +/* ── Tiny viewports (< 30rem) ────────────────────────── * + * + * Below ~480px the safety-net `overflow-wrap: anywhere` from the + * 52rem block starts breaking unbreakable identifiers like + * `transport` mid-token (`tra` / `nsport`), which is uglier than + * the alternative: keep the identifier whole and let the + * signature scroll horizontally inside its card. At phone widths + * a horizontal swipe to read the rest of a signature is preferable + * to a column of character-by-character wraps. + * + * `scrollbar-width: thin` keeps the scrollbar visually quiet on + * platforms that honour the property (Firefox, Chromium-on-Linux + * with overlay scrollbars disabled). */ +@media (max-width: 30rem) { + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-signature, + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-signature { + overflow-wrap: normal; + word-break: keep-all; + overflow-x: auto; + scrollbar-width: thin; + } +} + } /* end @layer gp-sphinx */ From ce92d7aa125c3c9a4d37a94b893776d40d620631 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 20:15:09 -0500 Subject: [PATCH 40/44] ux-autodoc-layout(feat[dual-variant]) Emit desktop+mobile headers under one dt why: Single-variant headers fell back to flex-wrap whenever the article column couldn't fit the signature alongside the toolbar, splitting visual order from accessibility tree order via an `order: -1` hack. Two siblings under the same `

              ` lets each variant own its natural DOM order; the cascade picks one per `dl.gp-sphinx-api-container` inline-size. what: - Build two `gp-sphinx-api-layout--{desktop,mobile}` subtrees in `_rebuild_signature_layout` via `node.deepcopy()` (avoiding `copy.deepcopy` recursion through parent pointers); desktop keeps signature-left/toolbar-right, mobile is toolbar-top/signature-bottom with disjoint expanded-panel ids per variant - Stamp `data-domain`, `data-objtype`, `data-has-source`, `data-has-badges`, `data-badge-count`, `data-has-fold` on the managed `
              ` plus `gp-sphinx-api-header--has-{source,badges,fold}` modifier classes so theme CSS can branch on facts the cascade alone cannot compute - Mirror dual-variant + metadata in `_cards.build_api_card_entry` for non-Python card-shell entries (rst directives, FastMCP tools, etc.) - Add `LAYOUT_{DESKTOP,MOBILE,TOP,BOTTOM}` + `HEADER_HAS_{SOURCE, BADGES,FOLD}` constants and `header_modifier`/`layout_variant` factories to `_css.API` - Toggle variants via `@container gp-sphinx-api-entry (max-width: 36rem)` placed AFTER all default `display: flex` rules so the cascade flips correctly (container queries don't bump specificity so source order matters); 36rem sits below Furo's 46em article column so a typical article gets desktop while sidebar embeds and narrow viewports get mobile - `layout.js` switches to `querySelectorAll` so both variants' signature-expanded panels open together when navigating to a hash - Update layout tests for dual-variant emission, deepcopy isolation, panel id uniqueness, container query toggle at 36rem, mobile top/bottom axes, and managed-vs-generic header `data-*` metadata --- .../src/sphinx_ux_autodoc_layout/_cards.py | 137 ++- .../src/sphinx_ux_autodoc_layout/_css.py | 47 ++ .../_static/css/layout.css | 237 ++++-- .../_static/js/layout.js | 13 +- .../sphinx_ux_autodoc_layout/_transforms.py | 392 +++++++-- .../test_autodoc_sphinx_integration.py | 3 +- tests/ext/fastmcp/test_fastmcp_integration.py | 3 +- .../layout/__snapshots__/test_snapshots.ambr | 785 ++++++++++++++++-- tests/ext/layout/test_css.py | 66 +- tests/ext/layout/test_integration.py | 18 +- tests/ext/layout/test_render.py | 17 + tests/ext/layout/test_transforms.py | 252 +++++- tests/ext/layout/test_visitors.py | 15 +- ...test_sphinx_pytest_fixtures_integration.py | 3 +- 14 files changed, 1701 insertions(+), 287 deletions(-) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_cards.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_cards.py index 1b758d4d..407f042a 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_cards.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_cards.py @@ -19,6 +19,7 @@ from sphinx_ux_autodoc_layout._css import API from sphinx_ux_autodoc_layout._nodes import ( + api_component, api_permalink, build_api_component, build_api_inline_component, @@ -26,6 +27,87 @@ from sphinx_ux_badges import SAB +def _clone_node(node: nodes.Node) -> nodes.Node: + """Return an independent copy of *node* via docutils' own ``deepcopy``. + + Stdlib ``copy.deepcopy`` is unsafe for docutils nodes: ``Node`` does + not override ``__deepcopy__``, so the default machinery follows + ``.parent`` upward and clones every ancestor. Docutils' + ``Element.deepcopy`` walks only descendants — what we want when + duplicating header content for the desktop and mobile variants. + """ + if isinstance(node, nodes.Node): + return node.deepcopy() + return node + + +def _build_card_signature_column( + signature_children: t.Sequence[nodes.Node], + permalink: api_permalink | None, + *, + signature_classes: tuple[str, ...], +) -> tuple[api_component, api_permalink | None]: + """Build a fresh signature column (signature + permalink) for one variant.""" + signature = build_api_component(API.SIGNATURE, classes=signature_classes) + for child in signature_children: + signature += _clone_node(child) + cloned_permalink = permalink.deepcopy() if permalink is not None else None + return signature, cloned_permalink + + +def _build_card_toolbar_column( + badge_group: nodes.Node | None, + *, + name: str, +) -> api_component: + """Build a fresh toolbar column for one variant.""" + column = build_api_component(name, classes=(SAB.TOOLBAR,)) + if badge_group is not None: + badge_container = build_api_inline_component(API.BADGE_CONTAINER) + badge_container += _clone_node(badge_group) + column += badge_container + return column + + +def _build_card_layout_variant( + *, + variant: str, + signature_children: t.Sequence[nodes.Node], + permalink: api_permalink | None, + badge_group: nodes.Node | None, + signature_classes: tuple[str, ...], +) -> api_component: + """Build a complete card layout variant (desktop or mobile).""" + layout = build_api_component( + API.LAYOUT, + classes=(API.layout_variant(variant),), + ) + signature, cloned_permalink = _build_card_signature_column( + signature_children, + permalink, + signature_classes=signature_classes, + ) + + if variant == "desktop": + left = build_api_component(API.LAYOUT_LEFT) + left += signature + if cloned_permalink is not None: + left += cloned_permalink + right = _build_card_toolbar_column(badge_group, name=API.LAYOUT_RIGHT) + layout += left + layout += right + return layout + + top = _build_card_toolbar_column(badge_group, name=API.LAYOUT_TOP) + bottom = build_api_component(API.LAYOUT_BOTTOM) + bottom += signature + if cloned_permalink is not None: + bottom += cloned_permalink + layout += top + layout += bottom + return layout + + def build_api_card_entry( *, profile_class: str, @@ -39,6 +121,13 @@ def build_api_card_entry( ) -> nodes.Element: """Build a shared ``gp-sphinx-api-*`` card entry for non-``desc`` consumers. + The header emits both desktop and mobile layout variants side-by-side + so theme CSS can container-query between them just like the managed + ``desc_signature`` path does in ``_transforms``. Header metadata + (``data-has-source``, ``data-has-badges``, ``data-badge-count``, + ``data-has-fold``) is also added so styling can branch on facts the + cascade can't compute. + Parameters ---------- profile_class : str @@ -67,28 +156,38 @@ def build_api_card_entry( API.ENTRY, classes=(API.CARD_ENTRY, profile_class, *entry_classes), ) - header = build_api_component(API.HEADER) - layout = build_api_component(API.LAYOUT) - left = build_api_component(API.LAYOUT_LEFT) - signature = build_api_component( - API.SIGNATURE, - classes=signature_classes, + + header_classes: list[str] = [] + has_source = False # cards never own a source link today; reserved for future use + has_badges = badge_group is not None + has_fold = False # cards do not currently fold their signatures + if has_source: + header_classes.append(API.HEADER_HAS_SOURCE) + if has_badges: + header_classes.append(API.HEADER_HAS_BADGES) + if has_fold: + header_classes.append(API.HEADER_HAS_FOLD) + + header = build_api_component( + API.HEADER, + classes=tuple(header_classes), + html_attrs={ + "data-has-source": "true" if has_source else "false", + "data-has-badges": "true" if has_badges else "false", + "data-badge-count": "1" if has_badges else "0", + "data-has-fold": "true" if has_fold else "false", + }, ) - for child in signature_children: - signature += child - left += signature - if permalink is not None: - left += permalink - right = build_api_component(API.LAYOUT_RIGHT, classes=(SAB.TOOLBAR,)) - if badge_group is not None: - badge_container = build_api_inline_component(API.BADGE_CONTAINER) - badge_container += badge_group - right += badge_container + for variant in ("desktop", "mobile"): + header += _build_card_layout_variant( + variant=variant, + signature_children=signature_children, + permalink=permalink, + badge_group=badge_group, + signature_classes=signature_classes, + ) - layout += left - layout += right - header += layout entry += header content = build_api_component(API.CONTENT, classes=content_classes) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_css.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_css.py index 80025b17..4e4861b6 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_css.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_css.py @@ -38,13 +38,28 @@ class API: HEADER = "gp-sphinx-api-header" CONTENT = "gp-sphinx-api-content" LAYOUT = "gp-sphinx-api-layout" + # Desktop variant: signature-left, toolbar-right (single row, ≥ 52rem) + LAYOUT_DESKTOP = "gp-sphinx-api-layout--desktop" + # Mobile variant: toolbar-top, signature-bottom (stacked, < 52rem) + LAYOUT_MOBILE = "gp-sphinx-api-layout--mobile" + # Desktop slots (horizontal axis) LAYOUT_LEFT = "gp-sphinx-api-layout-left" LAYOUT_RIGHT = "gp-sphinx-api-layout-right" + # Mobile slots (vertical axis): toolbar above, signature below + LAYOUT_TOP = "gp-sphinx-api-layout-top" + LAYOUT_BOTTOM = "gp-sphinx-api-layout-bottom" SIGNATURE = "gp-sphinx-api-signature" LINK = "gp-sphinx-api-link" BADGE_CONTAINER = "gp-sphinx-api-badge-container" SOURCE_LINK = "gp-sphinx-api-source-link" + # ── Header boolean modifiers (mirrored as data-has-*) ─ + # Each modifier doubles a corresponding data-has- attribute on + # the rendered
              ; CSS can target either selector form. + HEADER_HAS_SOURCE = "gp-sphinx-api-header--has-source" + HEADER_HAS_BADGES = "gp-sphinx-api-header--has-badges" + HEADER_HAS_FOLD = "gp-sphinx-api-header--has-fold" + # ── Signature expand/collapse (long signatures) ────── SIGNATURE_TOGGLE = "gp-sphinx-api-signature-toggle" SIGNATURE_PREVIEW = "gp-sphinx-api-signature-preview" @@ -129,3 +144,35 @@ def slot_modifier(name: str) -> str: 'gp-sphinx-api-slot--badges' """ return f"gp-sphinx-api-slot--{name}" + + @staticmethod + def header_modifier(name: str) -> str: + """Return the header modifier class for ``name``. + + Header modifiers describe styling-relevant metadata (whether the + signature has a source link, badge count > 0, a fold toggle, ...). + They mirror ``data-has-`` attributes for selector flexibility. + + Examples + -------- + >>> API.header_modifier("has-source") + 'gp-sphinx-api-header--has-source' + + >>> API.header_modifier("has-badges") + 'gp-sphinx-api-header--has-badges' + """ + return f"gp-sphinx-api-header--{name}" + + @staticmethod + def layout_variant(variant: str) -> str: + """Return the layout variant modifier class for ``variant``. + + Examples + -------- + >>> API.layout_variant("desktop") + 'gp-sphinx-api-layout--desktop' + + >>> API.layout_variant("mobile") + 'gp-sphinx-api-layout--mobile' + """ + return f"gp-sphinx-api-layout--{variant}" diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 85d3999a..7e467088 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -4,6 +4,28 @@ * All rules land in @layer gp-sphinx so precedence is declarative * against Furo's @layer components. Layer order is established in * gp-furo-theme/web/src/styles/index.css. + * + * Dual-variant header + * ------------------- + * Every managed `
              ` ships two sibling + * subtrees: a `gp-sphinx-api-layout--desktop` (signature-left, + * toolbar-right) and a `gp-sphinx-api-layout--mobile` (toolbar-top, + * signature-bottom). The `dl.gp-sphinx-api-container` shell sets up a + * size container so a `@container` rule can switch between the two by + * inline-size, regardless of viewport width — this lets a Furo article + * column get the desktop variant while a narrow sidebar embed gets the + * mobile variant on the same page. Each variant carries its own natural + * DOM order so we never rely on flex order overrides that would + * otherwise split visual order from accessibility tree order. + * + * Header metadata + * --------------- + * The Python builder emits ``data-domain``, ``data-objtype``, + * ``data-has-source``, ``data-has-badges``, ``data-badge-count``, and + * ``data-has-fold`` attributes on the rendered ``
              ``, mirrored as + * ``gp-sphinx-api-header--has-{source,badges,fold}`` modifier classes. + * Theme CSS can branch on facts the cascade alone cannot compute (e.g. + * `dt[data-has-source="true"][data-has-badges="false"]`). */ @layer gp-sphinx { @@ -22,13 +44,49 @@ margin-top: 0; } +/* ── Container query setup ──────────────────────────── * + * + * `inline-size` measures the inline (column) axis only, which is what + * we want — the entry width is what dictates whether the toolbar + * fits beside the signature. Both managed `dl` shells and the + * `gp-sphinx-api-card-shell` wrapper become size containers so non-Python + * card consumers (FastMCP tools, etc.) inherit the same query basis. */ +dl.gp-sphinx-api-container, +.gp-sphinx-api-card-shell { + container-type: inline-size; + container-name: gp-sphinx-api-entry; +} + +/* ── Default mobile visibility ───────────────────────── * + * + * The mobile variant is hidden by default. The matching toggle that + * swaps the variants when the entry's inline-size drops below 36rem + * lives at the bottom of this file (after every default layout rule) + * so its `@container` overrides win the cascade — `@container` does + * not bump specificity, so source order matters: the toggle has to + * appear AFTER the `--desktop { display: flex }` defaults below or + * those defaults would override the toggle's `display: none`. + * + * The 36rem threshold is deliberately below Furo's `.content { width: + * 46em }` article column so a typical Furo article gets the desktop + * variant; only genuinely narrow embeds (sidebar widgets, sub-1em + * card shells, viewports < ~600px once Furo's content goes fluid) + * cross into mobile. Going higher (e.g. 52rem) would have mobile win + * unconditionally inside Furo articles since their inline-size is + * always ~46rem. */ +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mobile, +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout--mobile { + display: none; +} + /* ── API shell ──────────────────────────────────────── */ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header { display: flex; align-items: center; + flex-wrap: wrap; } -dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout { +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--desktop { display: flex; align-items: center; gap: 1rem; @@ -57,7 +115,7 @@ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header[data-signature-expanded="tr align-items: flex-start; } -dl.gp-sphinx-api-container > dt.gp-sphinx-api-header[data-signature-expanded="true"] > .gp-sphinx-api-layout { +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header[data-signature-expanded="true"] > .gp-sphinx-api-layout--desktop { align-items: flex-start; } @@ -78,6 +136,35 @@ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-source-link align-items: center; } +/* ── Mobile variant: toolbar-top, signature-bottom ──── */ +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-layout-top { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + width: 100%; + min-width: 0; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-layout-bottom { + display: flex; + align-items: center; + gap: 0.25rem; + width: 100%; + min-width: 0; +} + +/* Mirror the desktop split inside the mobile toolbar row itself: badges + * remain flush-left as the type/scope anchor; the source link floats to + * the right edge as the secondary action. */ +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-source-link { + margin-left: auto; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-layout-top:empty { + display: none; +} + /* ── Signature row ──────────────────────────────────── */ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-signature { flex: 1 1 auto; @@ -96,6 +183,13 @@ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-link:focus-v visibility: visible; } +/* The mobile variant has no hover affordance on touch devices, so the + * permalink anchor is always reachable when the mobile layout is the + * one being shown. */ +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-link { + visibility: visible; +} + .gp-sphinx-api-signature-toggle { appearance: none; border: 0; @@ -240,7 +334,10 @@ dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.fi margin: 0; } -@media (max-width: 52rem) { +/* Field-list grid collapse mirrors the header's mobile breakpoint so + * the parameters band reflows at the same width the toolbar/signature + * stack does. Container-query basis matches the variant toggle below. */ +@container gp-sphinx-api-entry (max-width: 36rem) { dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-parameters dl.field-list, dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list { grid-template-columns: 1fr; @@ -343,7 +440,7 @@ dl.gp-sphinx-api-container:not(.py) > dd.gp-sphinx-api-content { visibility: visible; } -.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout { +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout--desktop { display: flex; align-items: center; gap: 0.45rem; @@ -371,6 +468,27 @@ dl.gp-sphinx-api-container:not(.py) > dd.gp-sphinx-api-content { white-space: nowrap; } +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-layout-top { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + width: 100%; + min-width: 0; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-layout-bottom { + display: flex; + align-items: center; + gap: 0.35rem; + width: 100%; + min-width: 0; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-source-link { + margin-left: auto; +} + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-content { padding: 0.75rem 1rem; } @@ -396,93 +514,52 @@ dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-conta background: var(--color-api-background-hover); } -/* ── Mobile adjustments ─────────────────────────────── * +/* ── Variant visibility toggle ──────────────────────── * * - * Below the 52rem breakpoint the desktop "signature left, toolbar - * right" row stops fitting: the toolbar (badges + [source]) keeps - * its full natural width via `white-space: nowrap`, so the - * signature column gets squeezed below the width of an unbreakable - * identifier like `demo_session`. Once that happens, Furo's - * inherited `overflow-wrap: break-word` is the only thing keeping - * the text inside the column — it breaks the identifier at every - * character, producing a one-letter-per-line column. + * Placed AFTER every default layout rule above so the cascade flips + * from desktop-default to mobile-default once the entry's container + * inline-size drops below 36rem. `@container` does not bump + * specificity, so this block must appear last in source order — the + * `--desktop { display: flex }` defaults higher in this file would + * otherwise win and the toggle would be a no-op. * - * Stack vertically instead, matching the same 52rem breakpoint the - * field-list grid uses (see line 243): - * row 1: toolbar (badges + source link) - * row 2: signature, full container width - * - * Reading-order hierarchy maps 1:1 from desktop (signature - * primary-left, toolbar secondary-right) to narrow (toolbar above, - * signature below at full width). `order: -1` flips visual order - * while keeping the DOM order (signature first) intact for - * screen readers, copy-paste, and keyboard nav. */ -@media (max-width: 52rem) { - dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout, - .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout { - flex-direction: column; - align-items: stretch; - gap: 0.25rem; - } - - dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-right, - .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-right { - order: -1; - margin-left: 0; - flex-wrap: wrap; - } - - /* Mirror the desktop split (signature-left, toolbar-right) inside the - * toolbar row itself: badges remain flush-left as the type/scope - * anchor; the source link floats to the right edge as the secondary - * action. Falls back gracefully when the row wraps — the source link - * lands on its own line with the right anchor preserved. */ - dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-source-link, - .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-source-link { - margin-left: auto; + * Threshold rationale: Furo's `.content { width: 46em }` keeps the + * article inline-size at ~46rem regardless of viewport, so any + * threshold ≥ 46rem makes mobile win unconditionally inside Furo. + * 36rem leaves headroom for the article (mobile only triggers in + * narrower embeds) and aligns with the field-list collapse above. */ +@container gp-sphinx-api-entry (max-width: 36rem) { + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--desktop, + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout--desktop { + display: none; } - - dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-left, - .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-left { + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mobile, + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout--mobile { + display: flex; + flex-direction: column; width: 100%; - min-width: 0; - } - - /* Touch devices have no hover, so the permalink anchor (¶) is - * unreachable on phones under the desktop visibility:hidden rule. - * Promote it to always-visible at narrow widths so users can tap - * to copy a stable link to the entry. */ - dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-link, - .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-link { - visibility: visible; + gap: 0.25rem; } - - /* Safety net for sub-300px viewports: prefer breaking long - * identifiers at any point over per-character wrapping that - * Furo's default overflow-wrap produces. At the 280px low end - * this lets `demo_session_factory` wrap once or twice instead of - * one character per line. */ - .gp-sphinx-api-signature { + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-signature, + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-signature { overflow-wrap: anywhere; } } /* ── Tiny viewports (< 30rem) ────────────────────────── * * - * Below ~480px the safety-net `overflow-wrap: anywhere` from the - * 52rem block starts breaking unbreakable identifiers like - * `transport` mid-token (`tra` / `nsport`), which is uglier than - * the alternative: keep the identifier whole and let the - * signature scroll horizontally inside its card. At phone widths - * a horizontal swipe to read the rest of a signature is preferable - * to a column of character-by-character wraps. - * - * `scrollbar-width: thin` keeps the scrollbar visually quiet on - * platforms that honour the property (Firefox, Chromium-on-Linux - * with overlay scrollbars disabled). */ -@media (max-width: 30rem) { - dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-signature, - .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-signature { + * Below ~480px even the mobile variant's signature row needs help: we + * prefer to break long identifiers at any point over per-character + * wrapping that Furo's default `overflow-wrap: break-word` produces. + * At the 280px low end this lets `demo_session_factory` wrap once or + * twice instead of one character per line. Below ~480px we drop the + * safety net in favour of horizontal scroll, since `overflow-wrap: + * anywhere` starts breaking unbreakable identifiers like `transport` + * mid-token (`tra` / `nsport`). `scrollbar-width: thin` keeps the + * scrollbar visually quiet on platforms that honour the property. */ +@container gp-sphinx-api-entry (max-width: 30rem) { + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-signature, + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout--mobile .gp-sphinx-api-signature { overflow-wrap: normal; word-break: keep-all; overflow-x: auto; diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/js/layout.js b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/js/layout.js index 7077353b..4f477ac3 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/js/layout.js +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/js/layout.js @@ -74,10 +74,15 @@ if (!header) return; - var expandedPanel = header.querySelector('.gp-sphinx-api-signature-expanded'); - if (!expandedPanel || !expandedPanel.id) return; - - setSignatureExpandedById(expandedPanel.id, true); + // The managed header carries both desktop and mobile layout variants + // side-by-side; only one is visible at a time per container query. + // Expand every panel we find so whichever variant the cascade picks + // displays in its open state without a flash of collapsed content. + var expandedPanels = header.querySelectorAll('.gp-sphinx-api-signature-expanded'); + expandedPanels.forEach(function (panel) { + if (!panel.id) return; + setSignatureExpandedById(panel.id, true); + }); } function expandForHash() { diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py index b926d63d..30de0148 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -501,13 +501,32 @@ def _count_signature_parameters( return first, len(params) -def _signature_expanded_id(desc_sig: addnodes.desc_signature) -> str: - """Return the stable DOM id for an expanded signature wrapper.""" +_LAYOUT_VARIANTS: t.Final[tuple[str, ...]] = ("desktop", "mobile") +"""Stable variant tags emitted side-by-side in every managed header. + +CSS in ``layout.css`` toggles visibility between the two via container +queries on ``dl.gp-sphinx-api-container``; only one variant is rendered at +a time, but both ship in the DOM so the cascade can pick the right one +per containing column without JS measurement. +""" + + +def _signature_expanded_id( + desc_sig: addnodes.desc_signature, variant: str | None = None +) -> str: + """Return the stable DOM id for an expanded signature wrapper. + + When ``variant`` is provided it is appended as a suffix so each layout + variant (desktop / mobile) ships with a distinct, addressable panel id. + """ 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-expanded" + suffix = "--signature-expanded" + if variant is not None: + suffix = f"{suffix}-{variant}" + return f"{base}{suffix}" def _parameter_has_annotation(parameter: addnodes.desc_parameter) -> bool: @@ -784,18 +803,37 @@ def _extract_slot_content( return slot_children -def _rebuild_signature_layout( +@dataclasses.dataclass(frozen=True, slots=True) +class _HeaderInputs: + """Parsed material for one or more header layout variants. + + ``signature_row_children`` is the list of inline nodes that belong in + the signature column (operating from the original ``desc_signature`` + children minus toolbar/source/headerlink siblings). ``badge_children`` + and ``source_children`` populate the toolbar; ``param_count`` / + ``first_param`` are pre-computed so metadata callers don't re-walk + the parameter list. + """ + + signature_row_children: tuple[nodes.Node, ...] + badge_children: tuple[nodes.Node, ...] + source_children: tuple[nodes.Node, ...] + parameter_types: dict[str, list[nodes.Node]] + first_param: str + param_count: int + has_parameter_list: bool + + +def _parse_signature_inputs( 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"): - return +) -> _HeaderInputs: + """Detach the signature's children and partition them by destination. + Slots, the legacy badge toolbar, viewcode source link, headerlink, and + everything else are split apart so each layout variant can rebuild a + fresh subtree from independently deepcopied parts. + """ slot_children = _extract_slot_content(desc_sig) original = list(desc_sig.children) desc_sig.children = [] @@ -826,76 +864,282 @@ def _rebuild_signature_layout( if not source_children and fallback_source_ref is not None: source_children = [fallback_source_ref] - layout = build_api_component(API.LAYOUT) - left = build_api_component(API.LAYOUT_LEFT) + first_param = "" + param_count = 0 + has_parameter_list = False + for child in row_children: + if isinstance(child, addnodes.desc_parameterlist): + has_parameter_list = True + first_param, param_count = _count_signature_parameters(child) + break + + return _HeaderInputs( + signature_row_children=tuple(row_children), + badge_children=tuple(badge_children), + source_children=tuple(source_children), + parameter_types=_extract_parameter_types(desc_node), + first_param=first_param, + param_count=param_count, + has_parameter_list=has_parameter_list, + ) + + +@dataclasses.dataclass(frozen=True, slots=True) +class _HeaderMetadata: + """Builder-driven metadata about a managed header. + + Both the boolean ``classes`` (CSS modifier list) and the ``data_attrs`` + (data-* attributes on the rendered ``
              ``) describe styling-relevant + facts CSS can't compute on its own — number of badges, presence of a + source link, whether the signature folds, the Sphinx domain/objtype. + """ + + classes: tuple[str, ...] + data_attrs: dict[str, str] + + +def _compute_header_metadata( + desc_node: addnodes.desc, + inputs: _HeaderInputs, + *, + threshold: int, +) -> _HeaderMetadata: + """Derive class modifiers and data-* attributes for the managed header.""" + domain = str(desc_node.get("domain", "") or "") + objtype = str(desc_node.get("objtype", "") or "") + badge_count = len(inputs.badge_children) + has_source = bool(inputs.source_children) + has_badges = badge_count > 0 + has_fold = inputs.has_parameter_list and inputs.param_count >= threshold + + data_attrs: dict[str, str] = {"data-signature-expanded": "false"} + if domain: + data_attrs["data-domain"] = domain + if objtype: + data_attrs["data-objtype"] = objtype + data_attrs["data-has-source"] = "true" if has_source else "false" + data_attrs["data-has-badges"] = "true" if has_badges else "false" + data_attrs["data-badge-count"] = str(badge_count) + data_attrs["data-has-fold"] = "true" if has_fold else "false" + + classes: list[str] = [] + if has_source: + classes.append(API.HEADER_HAS_SOURCE) + if has_badges: + classes.append(API.HEADER_HAS_BADGES) + if has_fold: + classes.append(API.HEADER_HAS_FOLD) + + return _HeaderMetadata(classes=tuple(classes), data_attrs=data_attrs) + + +def _build_signature_column( + desc_sig: addnodes.desc_signature, + inputs: _HeaderInputs, + *, + variant: str, + threshold: int, + show_annotations: bool, + include_permalink: bool, +) -> tuple[api_component, api_permalink | None]: + """Build a fresh signature column (signature + permalink) for one variant. + + Every node is deepcopied from ``inputs`` so each variant owns an + independent subtree — docutils requires single parentage and the + expanded panel id needs to be variant-specific. + """ signature = build_api_component(API.SIGNATURE) - right = build_api_component(API.LAYOUT_RIGHT, classes=(SAB.TOOLBAR,)) - 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_expanded_id(desc_sig) - signature += api_sig_fold( - first_param=first_param, - param_count=param_count, - panel_id=panel_id, - ) - _prepare_folded_parameter_list( - child, - parameter_types=parameter_types, - show_annotations=show_annotations, - ) - expanded = build_api_component( - API.SIGNATURE_EXPANDED, - classes=(API.SIG_EXPANDED,), - html_attrs={ - "aria-hidden": "true", - "data-expanded": "false", - "hidden": "hidden", - "id": panel_id, - }, - ) - expanded += child - collapse = build_api_inline_component( - API.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 + for child in inputs.signature_row_children: + cloned = child.deepcopy() if isinstance(child, nodes.Node) else child + if ( + not folded + and isinstance(cloned, addnodes.desc_parameterlist) + and inputs.has_parameter_list + and inputs.param_count >= threshold + ): + panel_id = _signature_expanded_id(desc_sig, variant=variant) + signature += api_sig_fold( + first_param=inputs.first_param, + param_count=inputs.param_count, + panel_id=panel_id, + ) + _prepare_folded_parameter_list( + cloned, + parameter_types=inputs.parameter_types, + show_annotations=show_annotations, + ) + expanded = build_api_component( + API.SIGNATURE_EXPANDED, + classes=(API.SIG_EXPANDED,), + html_attrs={ + "aria-hidden": "true", + "data-expanded": "false", + "hidden": "hidden", + "id": panel_id, + }, + ) + expanded += cloned + collapse = build_api_inline_component( + API.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 += cloned + + permalink = _make_api_permalink(desc_sig) if include_permalink else None + return signature, permalink + + +def _clone_node(node: nodes.Node) -> nodes.Node: + """Return an independent copy of *node* via docutils' own ``deepcopy``. + + Stdlib ``copy.deepcopy`` is unsafe for docutils nodes: ``Node`` does not + override ``__deepcopy__``, so the default machinery follows ``.parent`` + upward and clones every ancestor. Docutils' ``Element.deepcopy`` / + ``Text.deepcopy`` walk only descendants, which is what we want when + duplicating header content for the desktop and mobile variants. + """ + if isinstance(node, nodes.Node): + return node.deepcopy() + return node + + +def _build_toolbar_column( + inputs: _HeaderInputs, + *, + classes: tuple[str, ...], + name: str, +) -> api_component: + """Build a fresh toolbar column (badges + source) for one variant. + + Each toolbar gets a docutils-native deep copy of the badge / source + nodes so the two layout variants are independent subtrees. + """ + column = build_api_component(name, classes=(SAB.TOOLBAR, *classes)) + if inputs.badge_children: + badge_container = build_api_inline_component(API.BADGE_CONTAINER) + for child in inputs.badge_children: + badge_container += _clone_node(child) + column += badge_container + if inputs.source_children: + source_container = build_api_inline_component(API.SOURCE_LINK) + for child in inputs.source_children: + source_container += _clone_node(child) + column += source_container + return column + + +def _build_layout_variant( + desc_sig: addnodes.desc_signature, + inputs: _HeaderInputs, + *, + variant: str, + threshold: int, + show_annotations: bool, + include_permalink: bool, +) -> api_component: + """Build a complete ``gp-sphinx-api-layout--`` subtree. + + Desktop variant: ``[ left(signature, permalink), right(toolbar) ]``. + Mobile variant: ``[ top(toolbar), bottom(signature, permalink) ]``. + + The horizontal/vertical naming reflects each variant's intended layout + axis (desktop = inline row, mobile = block stack); CSS uses container + queries on ``dl.gp-sphinx-api-container`` to toggle which one is + visible. + """ + layout = build_api_component(API.LAYOUT, classes=(API.layout_variant(variant),)) + signature, permalink = _build_signature_column( + desc_sig, + inputs, + variant=variant, + threshold=threshold, + show_annotations=show_annotations, + include_permalink=include_permalink, + ) - left += signature - if include_permalink: - permalink = _make_api_permalink(desc_sig) + if variant == "desktop": + left = build_api_component(API.LAYOUT_LEFT) + left += signature if permalink is not None: left += permalink + right = _build_toolbar_column( + inputs, + classes=(), + name=API.LAYOUT_RIGHT, + ) + layout += left + layout += right + return layout + + # variant == "mobile" — toolbar on top, signature on the bottom. This + # avoids the desktop's `order: -1` flex hack: each variant owns the + # natural DOM order it needs, and CSS picks one to display per + # container width. + top = _build_toolbar_column( + inputs, + classes=(), + name=API.LAYOUT_TOP, + ) + bottom = build_api_component(API.LAYOUT_BOTTOM) + bottom += signature + if permalink is not None: + bottom += permalink + layout += top + layout += bottom + return layout - if badge_children: - badge_container = build_api_inline_component(API.BADGE_CONTAINER) - for child in badge_children: - badge_container += child - right += badge_container - if source_children: - source_container = build_api_inline_component(API.SOURCE_LINK) - for child in source_children: - source_container += child - right += source_container +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 desktop + mobile API header variants. + + Each variant is a fully independent subtree (deepcopied content) so + docutils' single-parent invariant is preserved and CSS can hide one + variant entirely without leaving stale shared state. The desc + signature also receives builder-driven ``data-*`` metadata and + boolean modifier classes so theme CSS can branch on facts that the + cascade alone cannot derive (badge count, source-link presence, + fold availability). + """ + if desc_sig.get("is_multiline"): + return - layout += left - layout += right - desc_sig += layout + inputs = _parse_signature_inputs(desc_node, desc_sig) + metadata = _compute_header_metadata(desc_node, inputs, threshold=threshold) + + existing_attrs = t.cast(dict[str, str], desc_sig.get("html_attrs", {}) or {}) + merged_attrs: dict[str, str] = {**existing_attrs, **metadata.data_attrs} + desc_sig["html_attrs"] = merged_attrs + for class_name in metadata.classes: + _append_class(desc_sig, class_name) + + for variant in _LAYOUT_VARIANTS: + desc_sig += _build_layout_variant( + desc_sig, + inputs, + variant=variant, + threshold=threshold, + show_annotations=show_annotations, + include_permalink=include_permalink, + ) def on_doctree_resolved( @@ -951,8 +1195,6 @@ 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/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py index 78bd9494..3939b640 100644 --- a/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py +++ b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py @@ -100,7 +100,8 @@ def test_autodoc_sphinx_confvals_use_shared_layout( 'class="std confval gp-sphinx-api-container gp-sphinx-api-profile--confval"' in html ) - assert 'class="gp-sphinx-api-layout"' in html + assert "gp-sphinx-api-layout--desktop" in html + assert "gp-sphinx-api-layout--mobile" in html assert 'class="gp-sphinx-api-badge-container"' in html assert ( 'class="gp-sphinx-api-facts gp-sphinx-api-region gp-sphinx-api-region--facts"' diff --git a/tests/ext/fastmcp/test_fastmcp_integration.py b/tests/ext/fastmcp/test_fastmcp_integration.py index e0d7ece2..be45f2ee 100644 --- a/tests/ext/fastmcp/test_fastmcp_integration.py +++ b/tests/ext/fastmcp/test_fastmcp_integration.py @@ -111,7 +111,8 @@ def test_fastmcp_tool_cards_use_shared_layout( 'class="gp-sphinx-api-entry gp-sphinx-api-card-entry gp-sphinx-api-profile--fastmcp-tool gp-sphinx-fastmcp__tool-entry"' in html ) - assert 'class="gp-sphinx-api-layout"' in html + assert "gp-sphinx-api-layout--desktop" in html + assert "gp-sphinx-api-layout--mobile" in html assert 'class="gp-sphinx-api-badge-container"' in html assert ( 'class="gp-sphinx-api-facts gp-sphinx-api-region gp-sphinx-api-region--facts gp-sphinx-fastmcp__body-section"' diff --git a/tests/ext/layout/__snapshots__/test_snapshots.ambr b/tests/ext/layout/__snapshots__/test_snapshots.ambr index 3cb6a9c2..2e02e4e9 100644 --- a/tests/ext/layout/__snapshots__/test_snapshots.ambr +++ b/tests/ext/layout/__snapshots__/test_snapshots.ambr @@ -2,8 +2,8 @@ # name: test_confval_snapshot[confval_entry] ''' - - + + @@ -22,6 +22,25 @@ stable + + + + + + config + + + + env + + + + stable + + + + demo_option + @@ -45,14 +64,14 @@ # name: test_fastmcp_tool_prototype_snapshot[fastmcp_tool_prototype] ''' - - + + list_sessions - - + + @@ -250,7 +269,7 @@ None - + [collapse] @@ -261,6 +280,221 @@ tool + + + + + + readonly + + + tool + + + + list_sessions + + + + + + server + + : + + + + str + + + limit + + : + + + + int + + + + = + + + + 20 + + + cursor + + : + + + + str | None + + + + = + + + + None + + + project + + : + + + + str | None + + + + = + + + + None + + + status + + : + + + + 'open' | 'closed' + + + + = + + + + 'open' + + + owner + + : + + + + str | None + + + + = + + + + None + + + region + + : + + + + str | None + + + + = + + + + None + + + updated_after + + : + + + + str | None + + + + = + + + + None + + + updated_before + + : + + + + str | None + + + + = + + + + None + + + include_archived + + : + + + + bool + + + + = + + + + False + + + expand + + : + + + + str | None + + + + = + + + + None + + + request_id + + : + + + + str | None + + + + = + + + + None + + [collapse] + @@ -535,14 +769,14 @@ # name: test_large_signature_snapshot_annotated[large_signature_annotated] ''' - - + + __init__ - - + + @@ -794,7 +1028,7 @@ None - + [collapse] @@ -806,37 +1040,307 @@ [source] - - - - Create a richly configurable session. - - - - - - Parameters - - - - - - session_name - ( - - str - ) - - - - window_name - ( - - str - ) - - - + + + + + + method + + + + [source] + + + + __init__ + + + + + + session_name + + : + + + + + str + + + window_name + + + + : + + + + + str + + + + = + + + + "main" + + + start_directory + + + + : + + + + + str + + + + = + + + + "/tmp" + + + attach + + + + : + + + + + bool + + + + = + + + + True + + + kill_session + + + + : + + + + + bool + + + + = + + + + False + + + environment + + + + : + + + + + dict[str, str] | None + + + + = + + + + None + + + x + + + + : + + + + + int + + + + = + + + + 80 + + + y + + + + : + + + + + int + + + + = + + + + 24 + + + command + + + + : + + + + + str + + + + = + + + + "bash" + + + shell + + + + : + + + + + str + + + + = + + + + "zsh" + + + socket_name + + + + : + + + + + str + + + + = + + + + "default" + + + socket_path + + + + : + + + + + str | None + + + + = + + + + None + + + config_file + + + + : + + + + + str | None + + + + = + + + + None + + [collapse] + + + + + Create a richly configurable session. + + + + + + Parameters + + + + + + session_name + ( + + str + ) + + + + window_name + ( + + str + ) + + + start_directory ( @@ -927,14 +1431,14 @@ # name: test_large_signature_snapshot_annotation_disabled[large_signature_annotation_disabled] ''' - - + + __init__ - - + + @@ -1071,7 +1575,7 @@ None - + [collapse] @@ -1083,6 +1587,161 @@ [source] + + + + + + method + + + + [source] + + + + __init__ + + + + + + session_name + + + window_name + + + + = + + + + "main" + + + start_directory + + + + = + + + + "/tmp" + + + attach + + + + = + + + + True + + + kill_session + + + + = + + + + False + + + environment + + + + = + + + + None + + + x + + + + = + + + + 80 + + + y + + + + = + + + + 24 + + + command + + + + = + + + + "bash" + + + shell + + + + = + + + + "zsh" + + + socket_name + + + + = + + + + "default" + + + socket_path + + + + = + + + + None + + + config_file + + + + = + + + + None + + [collapse] + @@ -1204,8 +1863,8 @@ # name: test_rst_directive_snapshot[rst_directive_entry] ''' - - + + @@ -1216,14 +1875,25 @@ directive + + + + + + directive + + + + demo-directive + A demo directive. - - + + @@ -1234,6 +1904,17 @@ option + + + + + + option + + + + demo-opt + diff --git a/tests/ext/layout/test_css.py b/tests/ext/layout/test_css.py index f9aeb9f5..3ba54e31 100644 --- a/tests/ext/layout/test_css.py +++ b/tests/ext/layout/test_css.py @@ -18,14 +18,14 @@ def test_signature_expanded_uses_contents_layout() -> None: def test_api_header_defaults_to_center_alignment() -> None: css = _LAYOUT_CSS.read_text(encoding="utf-8") + assert "display: block;" not in css assert ( "dl.gp-sphinx-api-container > dt.gp-sphinx-api-header {\n" " display: flex;\n align-items: center;\n" ) in css - assert "display: block;" not in css assert ( "dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > " - ".gp-sphinx-api-layout {\n" + ".gp-sphinx-api-layout--desktop {\n" " display: flex;\n" " align-items: center;\n" ) in css @@ -55,7 +55,7 @@ def test_expanded_api_header_switches_back_to_top_alignment() -> None: ) in css assert ( '> dt.gp-sphinx-api-header[data-signature-expanded="true"] > ' - ".gp-sphinx-api-layout {\n" + ".gp-sphinx-api-layout--desktop {\n" " align-items: flex-start;\n}" in css ) assert ( @@ -70,6 +70,66 @@ def test_expanded_api_header_switches_back_to_top_alignment() -> None: ) +def test_layout_uses_container_query_for_variant_toggle() -> None: + """Container query toggles desktop/mobile variant per inline-size. + + The toggle selectors are written with the same specificity as the + layout rules (``dl.gp-sphinx-api-container > dt.gp-sphinx-api-header + > .gp-sphinx-api-layout--{desktop,mobile}``) so the cascade order + is preserved — ``@container`` does not bump specificity. + """ + css = _LAYOUT_CSS.read_text(encoding="utf-8") + + assert "container-type: inline-size;" in css + assert "container-name: gp-sphinx-api-entry;" in css + assert "@container gp-sphinx-api-entry (max-width: 36rem) {" in css + assert ( + "dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > " + ".gp-sphinx-api-layout--mobile,\n" + ".gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > " + ".gp-sphinx-api-header > .gp-sphinx-api-layout--mobile {\n" + " display: none;\n}" + ) in css + assert ( + " dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > " + ".gp-sphinx-api-layout--desktop,\n" + " .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > " + ".gp-sphinx-api-header > .gp-sphinx-api-layout--desktop {\n" + " display: none;\n }" + ) in css + assert ( + " dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > " + ".gp-sphinx-api-layout--mobile,\n" + " .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > " + ".gp-sphinx-api-header > .gp-sphinx-api-layout--mobile {\n" + " display: flex;" + ) in css + + +def test_layout_mobile_variant_uses_top_bottom_axes() -> None: + """Mobile variant has its own top (toolbar) / bottom (signature) slots.""" + css = _LAYOUT_CSS.read_text(encoding="utf-8") + + assert ( + ".gp-sphinx-api-layout--mobile .gp-sphinx-api-layout-top {\n display: flex;\n" + ) in css + assert ( + ".gp-sphinx-api-layout--mobile .gp-sphinx-api-layout-bottom {\n" + " display: flex;\n" + ) in css + assert ( + ".gp-sphinx-api-layout--mobile .gp-sphinx-api-source-link {\n" + " margin-left: auto;\n}" + ) in css + + +def test_layout_drops_legacy_order_minus_one_hack() -> None: + """Mobile variant has its own DOM order; no `order: -1` flex hack remains.""" + css = _LAYOUT_CSS.read_text(encoding="utf-8") + + assert "order: -1" not in css + + def test_signature_multiline_list_uses_padding_indent() -> None: css = _LAYOUT_CSS.read_text(encoding="utf-8") diff --git a/tests/ext/layout/test_integration.py b/tests/ext/layout/test_integration.py index 80555709..096c9b1e 100644 --- a/tests/ext/layout/test_integration.py +++ b/tests/ext/layout/test_integration.py @@ -48,7 +48,8 @@ def test_layout_demo_renders_api_component_contract(layout_default_html: str) -> r'
              ]*class="[^"]*gp-sphinx-api-header[^"]*"[^>]*data-signature-expanded="false"', init_html, ) - assert 'class="gp-sphinx-api-layout"' in init_html + assert "gp-sphinx-api-layout--desktop" in init_html + assert "gp-sphinx-api-layout--mobile" in init_html assert 'class="gp-sphinx-api-layout-left"' in init_html assert 'class="gp-sphinx-api-layout-right gp-sphinx-toolbar"' in init_html assert 'class="gp-sphinx-api-signature"' in init_html @@ -60,10 +61,21 @@ def test_layout_demo_renders_api_component_contract(layout_default_html: str) -> in init_html ) assert ( - 'aria-controls="api_demo_layout.LayoutDemo.__init__--signature-expanded"' + 'aria-controls="api_demo_layout.LayoutDemo.__init__--signature-expanded-desktop"' + in init_html + ) + assert ( + 'aria-controls="api_demo_layout.LayoutDemo.__init__--signature-expanded-mobile"' + in init_html + ) + assert ( + 'id="api_demo_layout.LayoutDemo.__init__--signature-expanded-desktop"' + in init_html + ) + assert ( + 'id="api_demo_layout.LayoutDemo.__init__--signature-expanded-mobile"' in init_html ) - assert 'id="api_demo_layout.LayoutDemo.__init__--signature-expanded"' in init_html assert "
              " in init_html assert '(' in init_html assert ')' in init_html diff --git a/tests/ext/layout/test_render.py b/tests/ext/layout/test_render.py index 68f0089b..ab4b5f5b 100644 --- a/tests/ext/layout/test_render.py +++ b/tests/ext/layout/test_render.py @@ -72,6 +72,23 @@ def test_build_api_card_entry_uses_shared_component_shell() -> None: assert "badge" in header.astext() assert "Demo body." in content.astext() + layout_classes: list[list[str]] = [] + for child in header.children: + if ( + isinstance(child, nodes.Element) + and child.get("name") == "gp-sphinx-api-layout" + ): + layout_classes.append(child.get("classes", [])) + assert any("gp-sphinx-api-layout--desktop" in classes for classes in layout_classes) + assert any("gp-sphinx-api-layout--mobile" in classes for classes in layout_classes) + + header_attrs = t.cast(dict[str, str], header.get("html_attrs", {})) + assert header_attrs.get("data-has-badges") == "true" + assert header_attrs.get("data-badge-count") == "1" + assert header_attrs.get("data-has-source") == "false" + assert header_attrs.get("data-has-fold") == "false" + assert "gp-sphinx-api-header--has-badges" in header.get("classes", []) + def test_build_api_summary_section_uses_shared_summary_region() -> None: """Shared summary content uses a dedicated public summary wrapper.""" diff --git a/tests/ext/layout/test_transforms.py b/tests/ext/layout/test_transforms.py index 77ea3af0..65830fa2 100644 --- a/tests/ext/layout/test_transforms.py +++ b/tests/ext/layout/test_transforms.py @@ -456,13 +456,18 @@ def test_rebuild_signature_layout_splits_slots_and_permalink() -> None: show_annotations=True, ) - assert len(sig.children) == 1 - layout = sig.children[0] - assert isinstance(layout, api_component) - assert layout.get("name") == "gp-sphinx-api-layout" - assert layout.get("html_attrs") is None - - left, right = layout.children + assert len(sig.children) == 2 + desktop, mobile = sig.children + assert isinstance(desktop, api_component) + assert isinstance(mobile, api_component) + assert desktop.get("name") == "gp-sphinx-api-layout" + assert mobile.get("name") == "gp-sphinx-api-layout" + assert "gp-sphinx-api-layout--desktop" in desktop.get("classes", []) + assert "gp-sphinx-api-layout--mobile" in mobile.get("classes", []) + assert desktop.get("html_attrs") is None + assert mobile.get("html_attrs") is None + + left, right = desktop.children assert isinstance(left, api_component) assert left.get("name") == "gp-sphinx-api-layout-left" assert isinstance(right, api_component) @@ -481,6 +486,124 @@ def test_rebuild_signature_layout_splits_slots_and_permalink() -> None: "gp-sphinx-api-source-link", ] + top, bottom = mobile.children + assert isinstance(top, api_component) + assert top.get("name") == "gp-sphinx-api-layout-top" + assert isinstance(bottom, api_component) + assert bottom.get("name") == "gp-sphinx-api-layout-bottom" + assert _child_component_names(top) == [ + "gp-sphinx-api-badge-container", + "gp-sphinx-api-source-link", + ] + mobile_signature = bottom.children[0] + assert isinstance(mobile_signature, api_component) + assert mobile_signature.get("name") == "gp-sphinx-api-signature" + assert isinstance(bottom.children[1], api_permalink) + + +def test_rebuild_signature_layout_emits_data_metadata_on_signature() -> None: + """Builder-derived metadata lands on the desc_signature as data-* attrs.""" + 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_badge_slot() + sig += _make_source_slot() + + _rebuild_signature_layout( + desc, + sig, + threshold=10, + include_permalink=False, + show_annotations=True, + ) + + attrs = t.cast(dict[str, str], sig.get("html_attrs", {})) + assert attrs == { + "data-signature-expanded": "false", + "data-domain": "py", + "data-objtype": "function", + "data-has-source": "true", + "data-has-badges": "true", + "data-badge-count": "1", + "data-has-fold": "false", + } + classes: list[str] = sig.get("classes", []) + assert "gp-sphinx-api-header--has-source" in classes + assert "gp-sphinx-api-header--has-badges" in classes + assert "gp-sphinx-api-header--has-fold" not in classes + + +def test_rebuild_signature_layout_metadata_marks_fold_when_threshold_met() -> None: + """data-has-fold + modifier class flips when the parameter list folds.""" + 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) + + _rebuild_signature_layout( + desc, + sig, + threshold=10, + include_permalink=False, + show_annotations=True, + ) + + attrs = t.cast(dict[str, str], sig.get("html_attrs", {})) + assert attrs.get("data-has-fold") == "true" + assert attrs.get("data-has-badges") == "false" + assert attrs.get("data-has-source") == "false" + assert attrs.get("data-badge-count") == "0" + assert "gp-sphinx-api-header--has-fold" in sig.get("classes", []) + + +def test_rebuild_signature_layout_variant_subtrees_share_no_state() -> None: + """Mutating one variant's subtree never leaks into the other.""" + 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_badge_slot() + sig += _make_source_slot() + + _rebuild_signature_layout( + desc, + sig, + threshold=10, + include_permalink=False, + show_annotations=True, + ) + + desktop, mobile = sig.children + assert isinstance(desktop, api_component) + assert isinstance(mobile, api_component) + + desktop_left = desktop.children[0] + mobile_bottom = mobile.children[1] + assert isinstance(desktop_left, api_component) + assert isinstance(mobile_bottom, api_component) + assert desktop_left is not mobile_bottom + + desktop_signature = desktop_left.children[0] + mobile_signature = mobile_bottom.children[0] + assert isinstance(desktop_signature, api_component) + assert isinstance(mobile_signature, api_component) + assert desktop_signature is not mobile_signature + assert desktop_signature.children[0] is not mobile_signature.children[0] + + desktop_right = desktop.children[1] + mobile_top = mobile.children[0] + assert isinstance(desktop_right, api_component) + assert isinstance(mobile_top, api_component) + desktop_right.clear() + assert _child_component_names(mobile_top) == [ + "gp-sphinx-api-badge-container", + "gp-sphinx-api-source-link", + ] + def test_rebuild_signature_layout_uses_expanded_wrapper_for_large_signature() -> None: desc = _make_desc(ids=("demo.LayoutDemo.__init__",)) @@ -499,32 +622,62 @@ def test_rebuild_signature_layout_uses_expanded_wrapper_for_large_signature() -> show_annotations=True, ) - layout = sig.children[0] - assert isinstance(layout, api_component) - assert layout.get("html_attrs") is None - left = layout.children[0] - assert isinstance(left, api_component) - assert isinstance(left.children[0], api_component) - assert isinstance(left.children[1], api_permalink) - - signature = left.children[0] - assert isinstance(signature, api_component) - assert any(isinstance(child, api_sig_fold) for child in signature.children) - expanded = _find_component(signature, "gp-sphinx-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") == "gp-sphinx-api-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]" + desktop, mobile = sig.children + assert isinstance(desktop, api_component) + assert isinstance(mobile, api_component) + assert desktop.get("html_attrs") is None + assert mobile.get("html_attrs") is None + + desktop_left = desktop.children[0] + assert isinstance(desktop_left, api_component) + assert isinstance(desktop_left.children[0], api_component) + assert isinstance(desktop_left.children[1], api_permalink) + + desktop_signature = desktop_left.children[0] + assert isinstance(desktop_signature, api_component) + assert any(isinstance(child, api_sig_fold) for child in desktop_signature.children) + desktop_expanded = _find_component( + desktop_signature, "gp-sphinx-api-signature-expanded" + ) + desktop_html_attrs = t.cast(dict[str, str], desktop_expanded.get("html_attrs", {})) + assert desktop_html_attrs.get("id") == ( + "demo.LayoutDemo.__init__--signature-expanded-desktop" + ) + desktop_plist = desktop_expanded.children[0] + assert isinstance(desktop_plist, addnodes.desc_parameterlist) + assert desktop_plist.get("multi_line_parameter_list") is True + assert desktop_plist.get("multi_line_trailing_comma") is False + desktop_collapse = desktop_expanded.children[1] + assert isinstance(desktop_collapse, api_inline_component) + assert desktop_collapse.get("name") == "gp-sphinx-api-sig-collapse" + desktop_collapse_attrs = t.cast( + dict[str, str], desktop_collapse.get("html_attrs", {}) + ) + assert desktop_collapse_attrs.get("aria-controls") == ( + "demo.LayoutDemo.__init__--signature-expanded-desktop" + ) + assert desktop_collapse.astext() == "[collapse]" + + mobile_bottom = mobile.children[1] + assert isinstance(mobile_bottom, api_component) + mobile_signature = mobile_bottom.children[0] + assert isinstance(mobile_signature, api_component) + mobile_expanded = _find_component( + mobile_signature, "gp-sphinx-api-signature-expanded" + ) + mobile_html_attrs = t.cast(dict[str, str], mobile_expanded.get("html_attrs", {})) + assert mobile_html_attrs.get("id") == ( + "demo.LayoutDemo.__init__--signature-expanded-mobile" + ) + assert mobile_html_attrs.get("id") != desktop_html_attrs.get("id") + mobile_collapse = mobile_expanded.children[1] + assert isinstance(mobile_collapse, api_inline_component) + mobile_collapse_attrs = t.cast( + dict[str, str], mobile_collapse.get("html_attrs", {}) + ) + assert mobile_collapse_attrs.get("aria-controls") == ( + "demo.LayoutDemo.__init__--signature-expanded-mobile" + ) def test_rebuild_signature_layout_enriches_annotations_from_field_list() -> None: @@ -549,9 +702,9 @@ def test_rebuild_signature_layout_enriches_annotations_from_field_list() -> None show_annotations=True, ) - layout = sig.children[0] - assert isinstance(layout, api_component) - left = layout.children[0] + desktop = sig.children[0] + assert isinstance(desktop, api_component) + left = desktop.children[0] assert isinstance(left, api_component) signature = left.children[0] assert isinstance(signature, api_component) @@ -582,9 +735,9 @@ def test_rebuild_signature_layout_strips_annotations_when_disabled() -> None: show_annotations=False, ) - layout = sig.children[0] - assert isinstance(layout, api_component) - left = layout.children[0] + desktop = sig.children[0] + assert isinstance(desktop, api_component) + left = desktop.children[0] assert isinstance(left, api_component) signature = left.children[0] assert isinstance(signature, api_component) @@ -642,7 +795,14 @@ def test_on_doctree_resolved_marks_managed_headers_with_initial_state() -> None: on_doctree_resolved(app, doctree, "index") - assert sig.get("html_attrs") == {"data-signature-expanded": "false"} + attrs = t.cast(dict[str, str], sig.get("html_attrs", {})) + assert attrs.get("data-signature-expanded") == "false" + assert attrs.get("data-domain") == "py" + assert attrs.get("data-objtype") == "function" + assert attrs.get("data-has-source") == "false" + assert attrs.get("data-has-badges") == "false" + assert attrs.get("data-badge-count") == "0" + assert attrs.get("data-has-fold") == "false" def test_on_doctree_resolved_manages_slot_backed_headers_without_gal_enabled() -> None: @@ -670,9 +830,9 @@ def test_on_doctree_resolved_manages_slot_backed_headers_without_gal_enabled() - on_doctree_resolved(app, doctree, "index") assert "gp-sphinx-api-container" in desc.get("classes", []) - layout = sig.children[0] - assert isinstance(layout, api_component) - right = layout.children[1] + desktop = sig.children[0] + assert isinstance(desktop, api_component) + right = desktop.children[1] assert isinstance(right, api_component) assert _child_component_names(right) == ["gp-sphinx-api-badge-container"] @@ -703,8 +863,8 @@ def test_on_doctree_resolved_manages_confval_entries_with_profile_classes() -> N assert "gp-sphinx-api-container" in desc.get("classes", []) assert "gp-sphinx-api-profile--confval" in desc.get("classes", []) - layout = sig.children[0] - assert isinstance(layout, api_component) - right = layout.children[1] + desktop = sig.children[0] + assert isinstance(desktop, api_component) + right = desktop.children[1] assert isinstance(right, api_component) assert _child_component_names(right) == ["gp-sphinx-api-badge-container"] diff --git a/tests/ext/layout/test_visitors.py b/tests/ext/layout/test_visitors.py index f106ca7b..6420f4a9 100644 --- a/tests/ext/layout/test_visitors.py +++ b/tests/ext/layout/test_visitors.py @@ -36,13 +36,24 @@ def test_visit_desc_signature_html_emits_managed_header_attrs() -> None: sig = addnodes.desc_signature(ids=["demo.func"]) sig["classes"] = ["sig", "gp-sphinx-api-header"] sig["api_managed"] = True - sig["html_attrs"] = {"data-signature-expanded": "false"} + sig["html_attrs"] = { + "data-signature-expanded": "false", + "data-domain": "py", + "data-objtype": "function", + "data-has-source": "true", + "data-has-badges": "true", + "data-badge-count": "2", + "data-has-fold": "false", + } translator = _DummyTranslator() visit_desc_signature_html(t.cast(t.Any, translator), sig) - assert translator.calls == [("dt", {"data-signature-expanded": "false"})] + assert len(translator.calls) == 1 + tag, attributes = translator.calls[0] + assert tag == "dt" + assert attributes == sig["html_attrs"] assert translator.body == ["
              \n"] assert translator.protect_literal_text == 1 diff --git a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py index 49c447a4..5515308d 100644 --- a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py +++ b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py @@ -188,7 +188,8 @@ def test_default_html_outputs_smoke(default_html_result) -> None: ): assert css_class in index_html assert 'tabindex="0"' in index_html - assert 'class="gp-sphinx-api-layout"' in index_html + assert "gp-sphinx-api-layout--desktop" in index_html + assert "gp-sphinx-api-layout--mobile" in index_html assert 'class="gp-sphinx-api-layout-left"' in index_html assert 'class="gp-sphinx-api-layout-right gp-sphinx-toolbar"' in index_html assert 'class="gp-sphinx-api-signature"' in index_html From 817364bbb24a49f72df841e30d3d7e7fa14d07d9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 20:24:56 -0500 Subject: [PATCH 41/44] ux-autodoc-layout(css[no-header-transitions]): Cancel Furo's .sig transition on managed headers why: Furo's `.sig:not(.sig-inline)` rule in @layer components declares `transition: background .1s ease-out` for hover smoothing. Because the managed `
              ` retains the upstream `sig sig-object py` classes, that same transition cascades onto every gp-sphinx-api-header and runs on every theme swap, producing a visible mid-blend during the toggle. Card headers (`
              `) inherit it too. Per the package-CSS self-containment rule, the layout extension owns the visual contract for the classes it emits, so the cancel must live in layout.css rather than relying on the docs site's or sphinx-gp-theme's custom.css to scrub it for downstream consumers. what: - Add `.gp-sphinx-api-header { transition: none; animation: none; }` in @layer gp-sphinx so it beats Furo's @layer components regardless of selector specificity; descendants intentionally left untouched so future signature/fold animations can opt in - Add `test_api_header_suppresses_inherited_transitions` regression test asserting the literal rule is present in the shipped CSS --- .../_static/css/layout.css | 19 +++++++++++++++++++ tests/ext/layout/test_css.py | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css index 7e467088..2383c2fa 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -79,6 +79,25 @@ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout--mo display: none; } +/* ── Suppress inherited transitions/animations ─────── * + * + * Furo's `.sig:not(.sig-inline)` rule (in @layer components) declares + * `transition: background .1s ease-out` for hover smoothing. Our + * managed `
              ` retains the upstream `sig sig-object py` classes, so + * that transition cascades onto every header and runs on every theme + * swap, producing a visible mid-blend during the toggle. The same + * applies to card-mode headers (`
              `). + * + * Kill any inherited transitions/animations on the managed header + * itself. Lives in @layer gp-sphinx so it beats Furo's @layer + * components regardless of selector specificity. Descendants are + * intentionally left untouched so future signature/fold animations + * can opt in. */ +.gp-sphinx-api-header { + transition: none; + animation: none; +} + /* ── API shell ──────────────────────────────────────── */ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header { display: flex; diff --git a/tests/ext/layout/test_css.py b/tests/ext/layout/test_css.py index 3ba54e31..91716a10 100644 --- a/tests/ext/layout/test_css.py +++ b/tests/ext/layout/test_css.py @@ -147,3 +147,19 @@ def test_signature_css_does_not_force_sig_param_block_layout() -> None: css = _LAYOUT_CSS.read_text(encoding="utf-8") assert ".gp-sphinx-api-signature-expanded em.sig-param" not in css + + +def test_api_header_suppresses_inherited_transitions() -> None: + """Furo's `.sig` `transition: background .1s ease-out` is cancelled. + + Furo ships a hover-smoothing transition on `.sig:not(.sig-inline)` in + `@layer components`. Our managed `
              ` retains the upstream `sig` + class, so the same transition would otherwise run on every theme swap + and produce a visible mid-blend. The suppression rule lives in + `@layer gp-sphinx` so it wins regardless of selector specificity. + """ + css = _LAYOUT_CSS.read_text(encoding="utf-8") + + assert ( + ".gp-sphinx-api-header {\n transition: none;\n animation: none;\n}" + ) in css From 5055f06c50669c93194b5b911038fd9e6bbcaf73 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 20:26:56 -0500 Subject: [PATCH 42/44] docs(CHANGES) Workspace linkcode helper, responsive autodoc layout, and vite-builder CI hint fix why: Cover the three changes landed after the existing unreleased entry was written so the changelog catches up to the branch tip. what: - What's new: `gp-sphinx.make_workspace_linkcode_resolve` factory for uv/pnpm monorepos - What's new: `sphinx-ux-autodoc-layout` responsive header treatment at 52rem / 30rem breakpoints - Bug fixes: `sphinx-vite-builder` CI setup hint no longer requires a root `pnpm-lock.yaml` --- CHANGES | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGES b/CHANGES index c01f9013..77529d58 100644 --- a/CHANGES +++ b/CHANGES @@ -52,6 +52,23 @@ at a single breakpoint. A new `gp-sphinx` cascade layer makes workspace overrides win over Furo declaratively rather than via unlayered precedence. (#36) +#### `gp-sphinx`: Workspace-wide `linkcode_resolve` factory + +`make_workspace_linkcode_resolve()` is a drop-in `linkcode_resolve` +for uv/pnpm monorepos — one registration covers every package under +`packages/` by computing GitHub URLs relative to a `repo_root`. The +generated URLs always point at a single configurable branch +(default `main`), since workspace packages carry independent +versions while the docs site tracks live tip. (#36) + +#### `sphinx-ux-autodoc-layout`: Responsive autodoc headers on narrow viewports + +Below 52rem the header row stacks instead of squeezing, with the +type badge pinned beside the signature and the source link +right-anchored in its toolbar. Below 30rem long signatures scroll +horizontally rather than wrapping mid-identifier, and the permalink +reveals on tap for touch users. (#36) + ### Bug fixes #### `sphinx-gp-theme`: TOC font-size override now applies @@ -67,6 +84,14 @@ On narrow viewports the type badge previously wrapped below the signature, breaking the eyebrow-style layout. It now stays pinned to the left of the signature row. (#36) +#### `sphinx-vite-builder`: CI setup hint no longer requires a root lockfile + +The recipe printed by `PnpmMissingError` (and the matching +README/AGENTS samples) used to include `cache: pnpm`, which fails +on consumer CI with "Dependencies lock file is not found" when the +consumer repo has no root `pnpm-lock.yaml`. The hint now omits that +line, with a note explaining when it is safe to add back. (#36) + ## gp-sphinx 0.0.1a17 (2026-05-09) ### What's new From c2a9ac6a2caba1df200e7b604d0a6b35ede8ac8b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 20:52:19 -0500 Subject: [PATCH 43/44] docs(CHANGES) Add 0.0.1a18 lead paragraphs and pin target version why: AGENTS.md prescribes a multi-sentence lead paragraph opening each release entry, and the unreleased block was jumping straight into section headings. Pinning the version (was bare `0.0.1`) keeps the heading self-locating alongside the established a13-a17 lineage. what: - Rename heading to `## gp-sphinx 0.0.1a18 (unreleased)` - Add three-paragraph lead aligned to deliverable clusters: autodoc defaults + xrefs, responsive layout + cascade layering, and the workspace `linkcode_resolve` factory --- CHANGES | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 77529d58..fc793a48 100644 --- a/CHANGES +++ b/CHANGES @@ -14,10 +14,29 @@ $ pip install --user --upgrade --pre gp-sphinx $ uv add gp-sphinx --prerelease allow ``` -## gp-sphinx 0.0.1 (unreleased) +## gp-sphinx 0.0.1a18 (unreleased) +gp-sphinx 0.0.1a18 ships curated autodoc rendering across the docs +surface. Parameter signatures now resolve default values to their +source text — including dataclass `__init__` synthetic defaults, +sentinel constants by name, and identifier defaults wired up as +live cross-references to their documented class. Type expressions +in autodoc field lists route through the same xref pipeline so +links and chip styling stay consistent with the signature. + +The autodoc layout adapts to narrow viewports with a dual-variant +header: below 52rem the header row stacks instead of squeezing, and +below 30rem long signatures scroll horizontally instead of wrapping +mid-identifier. A new `gp-sphinx` cascade layer makes workspace +overrides win over Furo declaratively, and parameter and field-list +typography unify into a single metadata-sized band. + +A workspace-wide `linkcode_resolve` factory now covers every +uv/pnpm monorepo package with one registration, pointing at the +configured live tip branch rather than per-package version tags. + ### What's new #### `gp-sphinx`: Curated default-value rendering for autodoc From 6dd623ebe4973ac1c818037f5013edbab6210dec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 10 May 2026 20:54:32 -0500 Subject: [PATCH 44/44] =?UTF-8?q?release(workspace):=20bump=20v0.0.1a17=20?= =?UTF-8?q?=E2=86=92=20v0.0.1a18.dev0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/gp-furo-theme/pyproject.toml | 2 +- .../src/gp_furo_theme/__init__.py | 2 +- packages/gp-sphinx/pyproject.toml | 14 ++++---- packages/gp-sphinx/src/gp_sphinx/__init__.py | 2 +- .../sphinx-autodoc-api-style/pyproject.toml | 6 ++-- .../sphinx-autodoc-argparse/pyproject.toml | 2 +- .../src/sphinx_autodoc_argparse/__init__.py | 2 +- .../sphinx-autodoc-docutils/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_docutils/__init__.py | 2 +- .../sphinx-autodoc-fastmcp/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_fastmcp/__init__.py | 2 +- .../pyproject.toml | 8 ++--- packages/sphinx-autodoc-sphinx/pyproject.toml | 8 ++--- .../src/sphinx_autodoc_sphinx/__init__.py | 2 +- .../pyproject.toml | 2 +- .../sphinx_autodoc_typehints_gp/extension.py | 2 +- packages/sphinx-fonts/pyproject.toml | 2 +- .../sphinx-fonts/src/sphinx_fonts/__init__.py | 2 +- packages/sphinx-gp-opengraph/pyproject.toml | 2 +- .../src/sphinx_gp_opengraph/__init__.py | 2 +- packages/sphinx-gp-sitemap/pyproject.toml | 2 +- .../src/sphinx_gp_sitemap/__init__.py | 2 +- packages/sphinx-gp-theme/pyproject.toml | 4 +-- .../src/sphinx_gp_theme/__init__.py | 2 +- .../sphinx-ux-autodoc-layout/pyproject.toml | 2 +- packages/sphinx-ux-badges/pyproject.toml | 2 +- .../src/sphinx_ux_badges/__init__.py | 2 +- packages/sphinx-vite-builder/pyproject.toml | 2 +- .../src/sphinx_vite_builder/__init__.py | 2 +- pyproject.toml | 4 +-- tests/ci/test_package_tools.py | 8 +++-- tests/test_sphinx_vite_builder.py | 2 +- uv.lock | 34 +++++++++---------- 33 files changed, 75 insertions(+), 73 deletions(-) diff --git a/packages/gp-furo-theme/pyproject.toml b/packages/gp-furo-theme/pyproject.toml index 2270f7a7..1ee5615a 100644 --- a/packages/gp-furo-theme/pyproject.toml +++ b/packages/gp-furo-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-furo-theme" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Tailwind v4 port of the Furo Sphinx theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py index 187af9a3..a0a677fd 100644 --- a/packages/gp-furo-theme/src/gp_furo_theme/__init__.py +++ b/packages/gp-furo-theme/src/gp_furo_theme/__init__.py @@ -39,7 +39,7 @@ from .navigation import get_navigation_tree -__version__ = "0.0.1a17" +__version__ = "0.0.1a18.dev0" THEME_NAME = "gp-furo" THEME_PATH = (pathlib.Path(__file__).parent / "theme" / THEME_NAME).resolve() diff --git a/packages/gp-sphinx/pyproject.toml b/packages/gp-sphinx/pyproject.toml index 3c870cfe..9015604c 100644 --- a/packages/gp-sphinx/pyproject.toml +++ b/packages/gp-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Shared Sphinx documentation platform for git-pull projects" requires-python = ">=3.10,<4.0" authors = [ @@ -26,15 +26,15 @@ readme = "README.md" keywords = ["sphinx", "documentation", "configuration"] dependencies = [ "sphinx>=8.1,<9", - "sphinx-gp-theme==0.0.1a17", - "sphinx-fonts==0.0.1a17", + "sphinx-gp-theme==0.0.1a18.dev0", + "sphinx-fonts==0.0.1a18.dev0", "myst-parser", "docutils", - "sphinx-autodoc-typehints-gp==0.0.1a17", + "sphinx-autodoc-typehints-gp==0.0.1a18.dev0", "sphinx-inline-tabs", "sphinx-copybutton", - "sphinx-gp-opengraph==0.0.1a17", - "sphinx-gp-sitemap==0.0.1a17", + "sphinx-gp-opengraph==0.0.1a18.dev0", + "sphinx-gp-sitemap==0.0.1a18.dev0", "sphinxext-rediraffe", "sphinx-design", "linkify-it-py", @@ -43,7 +43,7 @@ dependencies = [ [project.optional-dependencies] argparse = [ - "sphinx-autodoc-argparse==0.0.1a17", + "sphinx-autodoc-argparse==0.0.1a18.dev0", ] [project.urls] diff --git a/packages/gp-sphinx/src/gp_sphinx/__init__.py b/packages/gp-sphinx/src/gp_sphinx/__init__.py index ab326aa0..3fec0340 100644 --- a/packages/gp-sphinx/src/gp_sphinx/__init__.py +++ b/packages/gp-sphinx/src/gp_sphinx/__init__.py @@ -7,7 +7,7 @@ __title__ = "gp-sphinx" __package_name__ = "gp_sphinx" __description__ = "Shared Sphinx documentation platform for git-pull projects" -__version__ = "0.0.1a17" +__version__ = "0.0.1a18.dev0" __author__ = "Tony Narlock" __github__ = "https://github.com/git-pull/gp-sphinx" __docs__ = "https://gp-sphinx.git-pull.com" diff --git a/packages/sphinx-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml index 920d083b..bf60b006 100644 --- a/packages/sphinx-autodoc-api-style/pyproject.toml +++ b/packages/sphinx-autodoc-api-style/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-api-style" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Sphinx extension for enhanced autodoc API entry styling (badges and cards)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,8 +27,8 @@ readme = "README.md" keywords = ["sphinx", "autodoc", "documentation", "api", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a17", - "sphinx-ux-autodoc-layout==0.0.1a17", + "sphinx-ux-badges==0.0.1a18.dev0", + "sphinx-ux-autodoc-layout==0.0.1a18.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-argparse/pyproject.toml b/packages/sphinx-autodoc-argparse/pyproject.toml index 25d4cc5d..7b233a7b 100644 --- a/packages/sphinx-autodoc-argparse/pyproject.toml +++ b/packages/sphinx-autodoc-argparse/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-argparse" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Modern Sphinx extension for documenting argparse-based CLI tools" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py index 8fe7896c..2bc7c9c6 100644 --- a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py @@ -44,7 +44,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a17" +__version__ = "0.0.1a18.dev0" class SetupDict(t.TypedDict): diff --git a/packages/sphinx-autodoc-docutils/pyproject.toml b/packages/sphinx-autodoc-docutils/pyproject.toml index 6689225f..62ec8061 100644 --- a/packages/sphinx-autodoc-docutils/pyproject.toml +++ b/packages/sphinx-autodoc-docutils/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-docutils" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Sphinx extension for documenting docutils directives and roles as first-class reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "docutils", "directives", "roles", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a17", - "sphinx-ux-autodoc-layout==0.0.1a17", - "sphinx-autodoc-typehints-gp==0.0.1a17", + "sphinx-ux-badges==0.0.1a18.dev0", + "sphinx-ux-autodoc-layout==0.0.1a18.dev0", + "sphinx-autodoc-typehints-gp==0.0.1a18.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 575070e3..b39872fe 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -77,7 +77,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_docutils.css") return { - "version": "0.0.1a17", + "version": "0.0.1a18.dev0", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml index fef2ffd2..272aaa3a 100644 --- a/packages/sphinx-autodoc-fastmcp/pyproject.toml +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Sphinx extension for documenting FastMCP tools (cards, badges, cross-refs)" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "fastmcp", "mcp", "documentation", "badges"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a17", - "sphinx-ux-autodoc-layout==0.0.1a17", - "sphinx-autodoc-typehints-gp==0.0.1a17", + "sphinx-ux-badges==0.0.1a18.dev0", + "sphinx-ux-autodoc-layout==0.0.1a18.dev0", + "sphinx-autodoc-typehints-gp==0.0.1a18.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 13c04783..43b86305 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -51,7 +51,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a17" +_EXTENSION_VERSION = "0.0.1a18.dev0" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml index badffe0c..7ae85149 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Sphinx extension for documenting pytest fixtures as first-class objects" requires-python = ">=3.10,<4.0" authors = [ @@ -30,9 +30,9 @@ keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", "pytest", - "sphinx-ux-badges==0.0.1a17", - "sphinx-ux-autodoc-layout==0.0.1a17", - "sphinx-autodoc-typehints-gp==0.0.1a17", + "sphinx-ux-badges==0.0.1a18.dev0", + "sphinx-ux-autodoc-layout==0.0.1a18.dev0", + "sphinx-autodoc-typehints-gp==0.0.1a18.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/pyproject.toml b/packages/sphinx-autodoc-sphinx/pyproject.toml index 0efd57a0..cad56c6d 100644 --- a/packages/sphinx-autodoc-sphinx/pyproject.toml +++ b/packages/sphinx-autodoc-sphinx/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-autodoc-sphinx" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Sphinx extension for documenting extension config values as first-class conf.py reference entries" requires-python = ">=3.10,<4.0" authors = [ @@ -27,9 +27,9 @@ readme = "README.md" keywords = ["sphinx", "configuration", "conf.py", "documentation", "autodoc"] dependencies = [ "sphinx>=8.1", - "sphinx-ux-badges==0.0.1a17", - "sphinx-ux-autodoc-layout==0.0.1a17", - "sphinx-autodoc-typehints-gp==0.0.1a17", + "sphinx-ux-badges==0.0.1a18.dev0", + "sphinx-ux-autodoc-layout==0.0.1a18.dev0", + "sphinx-autodoc-typehints-gp==0.0.1a18.dev0", ] [project.urls] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py index ada05786..0085a26c 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -61,7 +61,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_autodoc_sphinx.css") return { - "version": "0.0.1a17", + "version": "0.0.1a18.dev0", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-autodoc-typehints-gp/pyproject.toml b/packages/sphinx-autodoc-typehints-gp/pyproject.toml index 7fbcffc5..760394ab 100644 --- a/packages/sphinx-autodoc-typehints-gp/pyproject.toml +++ b/packages/sphinx-autodoc-typehints-gp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Cross-referenced type annotations for Sphinx autodoc" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py index 170422cf..8e9c4a37 100644 --- a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -638,7 +638,7 @@ def _add_static_path(app: Sphinx) -> None: # are skipped by the built-in handler. app.connect("object-description-transform", merge_typehints, priority=499) return { - "version": "0.0.1a17", + "version": "0.0.1a18.dev0", "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/packages/sphinx-fonts/pyproject.toml b/packages/sphinx-fonts/pyproject.toml index b72b120c..d51ab90d 100644 --- a/packages/sphinx-fonts/pyproject.toml +++ b/packages/sphinx-fonts/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-fonts" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Sphinx extension for self-hosted fonts via Fontsource CDN" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py index 1aec4ff1..c6a564b6 100644 --- a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py +++ b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -__version__ = "0.0.1a17" +__version__ = "0.0.1a18.dev0" CDN_TEMPLATE = ( "https://cdn.jsdelivr.net/npm/{package}@{version}" diff --git a/packages/sphinx-gp-opengraph/pyproject.toml b/packages/sphinx-gp-opengraph/pyproject.toml index 4f8da53a..d621a4d8 100644 --- a/packages/sphinx-gp-opengraph/pyproject.toml +++ b/packages/sphinx-gp-opengraph/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-opengraph" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "OpenGraph and Twitter meta-tag emission for Sphinx — matplotlib-free" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index 80731444..81f037b2 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a17" +_EXTENSION_VERSION = "0.0.1a18.dev0" DEFAULT_DESCRIPTION_LENGTH = 200 diff --git a/packages/sphinx-gp-sitemap/pyproject.toml b/packages/sphinx-gp-sitemap/pyproject.toml index 9d0af92f..c557d150 100644 --- a/packages/sphinx-gp-sitemap/pyproject.toml +++ b/packages/sphinx-gp-sitemap/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-sitemap" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Sitemap generator for Sphinx — Sphinx 8.1+ idioms, parallel-build safe" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index d77d1152..3cc97e60 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -47,7 +47,7 @@ from sphinx.util.typing import ExtensionMetadata -_EXTENSION_VERSION = "0.0.1a17" +_EXTENSION_VERSION = "0.0.1a18.dev0" _SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" _XHTML_NS = "http://www.w3.org/1999/xhtml" diff --git a/packages/sphinx-gp-theme/pyproject.toml b/packages/sphinx-gp-theme/pyproject.toml index 4bcbd20c..1dd12b13 100644 --- a/packages/sphinx-gp-theme/pyproject.toml +++ b/packages/sphinx-gp-theme/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-gp-theme" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Furo child theme for git-pull project documentation" requires-python = ">=3.10,<4.0" authors = [ @@ -27,7 +27,7 @@ readme = "README.md" keywords = ["sphinx", "theme", "furo", "documentation"] dependencies = [ "sphinx>=8.1", - "gp-furo-theme==0.0.1a17", + "gp-furo-theme==0.0.1a18.dev0", ] [project.entry-points."sphinx.html_themes"] diff --git a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py index ad4f08d6..18cab214 100644 --- a/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py @@ -23,7 +23,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a17" +__version__ = "0.0.1a18.dev0" def get_theme_path() -> pathlib.Path: diff --git a/packages/sphinx-ux-autodoc-layout/pyproject.toml b/packages/sphinx-ux-autodoc-layout/pyproject.toml index fc5c6fd2..422e250a 100644 --- a/packages/sphinx-ux-autodoc-layout/pyproject.toml +++ b/packages/sphinx-ux-autodoc-layout/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Componentized layout for Sphinx autodoc output" readme = "README.md" requires-python = ">=3.10" diff --git a/packages/sphinx-ux-badges/pyproject.toml b/packages/sphinx-ux-badges/pyproject.toml index 1d1fb5e4..4e7aa26f 100644 --- a/packages/sphinx-ux-badges/pyproject.toml +++ b/packages/sphinx-ux-badges/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-ux-badges" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Shared badge node and CSS for Sphinx autodoc extensions" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py index 137d8dff..84585954 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py @@ -48,7 +48,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) -_EXTENSION_VERSION = "0.0.1a17" +_EXTENSION_VERSION = "0.0.1a18.dev0" def setup(app: Sphinx) -> dict[str, t.Any]: diff --git a/packages/sphinx-vite-builder/pyproject.toml b/packages/sphinx-vite-builder/pyproject.toml index 88e05cee..41f0229c 100644 --- a/packages/sphinx-vite-builder/pyproject.toml +++ b/packages/sphinx-vite-builder/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sphinx-vite-builder" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "PEP 517 build backend + Sphinx extension that orchestrates Vite via pnpm" requires-python = ">=3.10,<4.0" authors = [ diff --git a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py index 0ad05626..79004407 100644 --- a/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py +++ b/packages/sphinx-vite-builder/src/sphinx_vite_builder/__init__.py @@ -24,7 +24,7 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx -__version__ = "0.0.1a17" +__version__ = "0.0.1a18.dev0" logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/pyproject.toml b/pyproject.toml index bb0281a5..9f0234e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gp-sphinx-workspace" -version = "0.0.1a17" +version = "0.0.1a18.dev0" description = "Workspace root for gp-sphinx packages" requires-python = ">=3.10,<4.0" authors = [ @@ -9,7 +9,7 @@ authors = [ license = { text = "MIT" } readme = "README.md" dependencies = [ - "gp-sphinx==0.0.1a17", + "gp-sphinx==0.0.1a18.dev0", ] [tool.uv.workspace] diff --git a/tests/ci/test_package_tools.py b/tests/ci/test_package_tools.py index bb593cae..74c6a554 100644 --- a/tests/ci/test_package_tools.py +++ b/tests/ci/test_package_tools.py @@ -13,7 +13,7 @@ def test_workspace_version_is_lockstep() -> None: """All workspace packages share the same version.""" - assert package_tools.workspace_version() == "0.0.1a17" + assert package_tools.workspace_version() == "0.0.1a18.dev0" def test_check_versions_passes_for_repo() -> None: @@ -30,10 +30,12 @@ def test_smoke_targets_cover_workspace_packages() -> None: def test_release_metadata_accepts_repo_tag() -> None: """Repo-wide release tags resolve to the shared version.""" - assert package_tools.release_metadata("v0.0.1a17") == {"version": "0.0.1a17"} + assert package_tools.release_metadata("v0.0.1a18.dev0") == { + "version": "0.0.1a18.dev0" + } def test_release_metadata_rejects_package_tag() -> None: """Package-scoped tags are no longer valid release inputs.""" with pytest.raises(SystemExit, match="invalid release tag format"): - package_tools.release_metadata("gp-sphinx@v0.0.1a17") + package_tools.release_metadata("gp-sphinx@v0.0.1a18.dev0") diff --git a/tests/test_sphinx_vite_builder.py b/tests/test_sphinx_vite_builder.py index 581014c2..ace8b77e 100644 --- a/tests/test_sphinx_vite_builder.py +++ b/tests/test_sphinx_vite_builder.py @@ -15,7 +15,7 @@ def test_version_matches_workspace_lock() -> None: """Version follows the gp-sphinx workspace lockstep.""" - assert __version__ == "0.0.1a17" + assert __version__ == "0.0.1a18.dev0" class _FakeApp: diff --git a/uv.lock b/uv.lock index ef4d9dc7..3cb354aa 100644 --- a/uv.lock +++ b/uv.lock @@ -425,7 +425,7 @@ wheels = [ [[package]] name = "gp-furo-theme" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/gp-furo-theme" } dependencies = [ { name = "accessible-pygments" }, @@ -461,7 +461,7 @@ wheels = [ [[package]] name = "gp-sphinx" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/gp-sphinx" } dependencies = [ { name = "docutils" }, @@ -510,7 +510,7 @@ provides-extras = ["argparse"] [[package]] name = "gp-sphinx-workspace" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "." } dependencies = [ { name = "gp-sphinx" }, @@ -1604,7 +1604,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-api-style" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-autodoc-api-style" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1622,7 +1622,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-argparse" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-autodoc-argparse" } dependencies = [ { name = "docutils" }, @@ -1640,7 +1640,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-docutils" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-autodoc-docutils" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1660,7 +1660,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-fastmcp" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-autodoc-fastmcp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1680,7 +1680,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-pytest-fixtures" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-autodoc-pytest-fixtures" } dependencies = [ { name = "pytest" }, @@ -1702,7 +1702,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-sphinx" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-autodoc-sphinx" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1722,7 +1722,7 @@ requires-dist = [ [[package]] name = "sphinx-autodoc-typehints-gp" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-autodoc-typehints-gp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1791,7 +1791,7 @@ wheels = [ [[package]] name = "sphinx-fonts" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-fonts" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1803,7 +1803,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-opengraph" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-gp-opengraph" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1815,7 +1815,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-sitemap" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-gp-sitemap" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1827,7 +1827,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-gp-theme" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-gp-theme" } dependencies = [ { name = "gp-furo-theme" }, @@ -1856,7 +1856,7 @@ wheels = [ [[package]] name = "sphinx-ux-autodoc-layout" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-ux-autodoc-layout" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1868,7 +1868,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-ux-badges" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-ux-badges" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1880,7 +1880,7 @@ requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] [[package]] name = "sphinx-vite-builder" -version = "0.0.1a17" +version = "0.0.1a18.dev0" source = { editable = "packages/sphinx-vite-builder" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },