Skip to content

Curate Sphinx default-value rendering: source text + xref links#36

Open
tony wants to merge 14 commits intomainfrom
improved-defaults-reprs
Open

Curate Sphinx default-value rendering: source text + xref links#36
tony wants to merge 14 commits intomainfrom
improved-defaults-reprs

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented May 9, 2026

Summary

  • Site B (parameter defaults): flips autodoc_preserve_defaults=True workspace-wide and adds an autodoc-before-process-signature listener that fills the synthetic-__init__ gap Sphinx itself bails out of (sphinx/ext/autodoc/_dynamic/_preserve_defaults.py:107-110). libtmux's scope=<libtmux.constants._DefaultOptionScope object> becomes scope=DEFAULT_OPTION_SCOPE; dataclass field(default_factory=list) becomes =[] instead of =<factory>.
  • Site A (data/attribute values): a GpDataDocumenter / GpAttributeDocumenter subclass routes :value: lines through a resolver chain. TruncateLongRepr(threshold=200) collapses libvcs's 5 738-char DEFAULT_RULES line to <...truncated, N chars> while leaving short useful values ('/', 'HEAD') untouched.
  • Stage C (cross-references): a priority-5 SphinxPostTransform walks every nodes.inline.default_value inside desc_parameter, AST-parses the text, and replaces identifier nodes with pending_xref(reftype='class') wrapped in nodes.literal(classes=['xref','py','py-class']) — matching the exact <a class=\"reference internal\"><code class=\"xref py py-class…\">…</code></a> HTML shape of an inline :py:class: role. Hand-written .. py:function:: directives benefit too because the span structure is identical.

Background, codepath traces, and per-consumer audit numbers live in notes/defaults-discovery-d{1..6}.md. The architecture is a single Resolver Protocol consumed by both sites; the public add_default_resolver API is held private behind _resolvers.py until external demand surfaces (rationale in defaults-discovery-d5.md).

Empirical impact (libtmux baseline 171 ugly parameter defaults):

  • After Site B: 81 sentinel-instance + 90 dataclass-factory cases → 0.
  • After Stage C: identifier defaults render as live cross-references to documented classes.

Test plan

  • uv run ruff check . --fix --show-fixes clean
  • uv run ruff format . no diff
  • uv run mypy — 0 issues across 232 source files
  • uv run pytest --reruns 0 -vvv — 1 536 passed, 161 skipped (61 new tests added in tests/ext/typehints_gp/test_{param_defaults,data_defaults,default_xref}{,_integration}.py)
  • rm -rf docs/_build && just build-docs succeeds with no new warnings
  • D6 integration test asserts the exact target HTML shape (<a class=\"reference internal\" href=\"#…\"><code class=\"xref py py-class docutils literal notranslate\"><span class=\"pre\">…</span></code></a>)
  • Smoke against libtmux/libvcs/libtmux-mcp after a release bump propagates — those consumers pin gp-sphinx==0.0.1a17, so the win surfaces only once the version + sibling pins are updated downstream

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 9, 2026

Codecov Report

❌ Patch coverage is 95.01134% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.75%. Comparing base (3e26941) to head (3f669e1).

Files with missing lines Patch % Lines
...nx_autodoc_typehints_gp/_default_xref_transform.py 88.05% 16 Missing ⚠️
...src/sphinx_autodoc_typehints_gp/_param_defaults.py 80.82% 14 Missing ⚠️
.../src/sphinx_autodoc_typehints_gp/_data_defaults.py 87.17% 5 Missing ⚠️
...hinx_autodoc_typehints_gp/_field_xref_transform.py 95.65% 4 Missing ⚠️
tests/ext/typehints_gp/test_param_defaults.py 96.96% 3 Missing ⚠️
docs/_ext/api_demo_typehints_gp.py 92.30% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #36      +/-   ##
==========================================
+ Coverage   91.57%   91.75%   +0.18%     
==========================================
  Files         205      219      +14     
  Lines       16814    17693     +879     
==========================================
+ Hits        15398    16235     +837     
- Misses       1416     1458      +42     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony
Copy link
Copy Markdown
Member Author

tony commented May 10, 2026

Code review

Found 1 issue:

  1. test_unsupported_default_falls_back_to_plain_text is @pytest.mark.integration but calls build_shared_sphinx_result(...) inline in the test function body rather than via a module-scoped fixture, triggering a fresh Sphinx build per invocation. CLAUDE.md says "No function-scoped Sphinx build fixtures — always module- or session-scoped". The three sibling integration tests in the same file (test_default_value_class_renders_as_xref_link, test_data_attribute_default_links_to_documented_constant, test_cross_module_default_resolves_via_refspecific) all use module-scoped @pytest.fixture(scope="module") wrappers; this one is the outlier.

@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")

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added a commit that referenced this pull request May 10, 2026
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.
tony added a commit that referenced this pull request May 10, 2026
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.
@tony tony force-pushed the improved-defaults-reprs branch from 49ed000 to d08129a Compare May 10, 2026 11:42
@tony
Copy link
Copy Markdown
Member Author

tony commented May 10, 2026

Code review

Found 1 issue:

  1. _wrap_prefix_in_paragraph lacks an Examples doctest block (CLAUDE.md says "All functions and methods MUST have working doctests"). Sibling helpers in the same file (_role_class_for_field_name, _enclosing_field_name, _normalize_xref_contnode, _is_em_dash_separator, _is_prose_field) all have doctests; this one was missed. Same omission shape as the F6 batch addressed earlier on this branch.

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 ``<code>``
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.
"""

CLAUDE.md rule: https://github.com/git-pull/gp-sphinx/blob/d08129a03d466a5246e51ebbfaabad2519c09c0f/CLAUDE.md#L520-L526

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added a commit to tmux-python/libtmux that referenced this pull request May 10, 2026
…ources

why: gp-sphinx PR git-pull/gp-sphinx#36 ships curated parameter and
data-attribute default rendering (source-text reprs, dataclass
factory resolution, long-value truncation, and `:py:class:`-styled
cross-reference links inside default values). Pinning the
gp-sphinx-family deps to that branch via `[tool.uv.sources]` lets
this repo's docs preview the user-visible win before the workspace
release bump propagates the changes via PyPI. After the audit,
libtmux's `<libtmux.constants._DefaultOptionScope object>` defaults
across `Pane._show_option`, `Window._show_option`,
`Session._show_option`, hook dataclasses, etc. drop from 171 ugly
sig-params to 0; `scope=` renders as `DEFAULT_OPTION_SCOPE` linked
to the documented constant.

what:
- Add `[tool.uv.sources]` overrides for gp-sphinx,
  sphinx-autodoc-typehints-gp, sphinx-autodoc-api-style, and
  sphinx-autodoc-pytest-fixtures, all pointing at the
  `improved-defaults-reprs` branch with the appropriate
  monorepo subdirectory.
- Regenerate `uv.lock`; uv resolves all transitive workspace
  siblings (sphinx-fonts, sphinx-gp-opengraph,
  sphinx-ux-autodoc-layout, etc.) from the same commit.
- Revert this commit when gp-sphinx>=0.0.1a18 lands and the
  per-package version pins move forward in the usual workspace
  bump.
tony added 14 commits May 10, 2026 08:32
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 `<em class="sig-param">…
</em>` 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.
why: Across libtmux, libvcs, vcspull, gp-libs, and gp-sphinx,
parameter defaults that hold non-primitive Python objects render as
`<libtmux.constants._DefaultOptionScope object>` 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 `<libtmux.constants._Default
  OptionScope object>`; 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.
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 `<factory>`
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 `<factory>`
  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 `<factory>` no longer appears.
- gp-sphinx own docs audit: 1 ugly factory_sentinel → 0 after this
  change.
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: <objrepr>` 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.
…lt values

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 `<a class="reference internal"><code class="xref py
py-class"><span class="pre">…</span></code></a>` 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 `<a class="reference internal"
  href="#…"><code class="xref py py-class">…` 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.
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.
why: libtmux's `_show_option(scope=DEFAULT_OPTION_SCOPE, …)`
rendered the identifier with `xref py py-class` styling but no
clickable `<a>` 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 `<a>` 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
  (`<code class="xref py py-obj …">`); the rendered link
  shape is still
  `<a class="reference internal" href="…"><code class="xref py
  py-obj …">…</code></a>`, 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 `<a class="reference internal"
  …><code class="xref py py-obj …">` wrapping appears. This pins
  the libtmux `DEFAULT_OPTION_SCOPE`-shaped case so a future
  reftype change doesn't silently regress it.
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 `<surrounding-module>.<name>` 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 `<a>` wrapping, but the `<code class="xref
   py py-obj">` contnode survived. The result was a styled element
   that looked clickable but wasn't — worse than plain text.

Plus the `<code class="xref py py-obj">` 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 `<a class="reference internal"><code class="xref py py-obj">`
    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`).
…ield lists

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` (`<em>`); its
`GroupedField.make_field` wraps `:raises:` exception names in
`addnodes.literal_strong` (`<strong>`); typehints-gp's
`_annotation_to_nodes` emits `pending_xref(contnode=Text)` (no
wrapping). The visible result was `<a>Server</a>` for param types,
`<a><strong>exc.X</strong></a>` for raises, and `<a>None</a>` for
intersphinx return types — none of them looking like the inline
`<a><code class="xref py py-class">Pane</code></a>` 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 `<code>` 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 `<code class="xref py py-X">` 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.
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 `<factory>`, `<...truncated, N chars>`,
  `<a><code class="xref py py-class">…</code></a>` 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.
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.
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.
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.
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.
@tony tony force-pushed the improved-defaults-reprs branch from 2d22ead to 3f669e1 Compare May 10, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants