diff --git a/CHANGES b/CHANGES index 0d4829ce..fc793a48 100644 --- a/CHANGES +++ b/CHANGES @@ -14,10 +14,103 @@ $ 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 + +`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) + +#### `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 + +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) + +#### `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 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/_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/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/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 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-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) { 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"; 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", +}; 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/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 4963ad66..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 ( @@ -39,6 +40,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, @@ -206,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, @@ -442,6 +546,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.""" 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-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..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 @@ -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,17 +90,41 @@ 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 { grid-column: 2; + margin-top: 0.25rem; + margin-left: 0; + font-size: var(--gp-sphinx-type-metadata); + 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; + margin-bottom: 0; margin-left: 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; +} + @media (max-width: 52rem) { dl.py:not(.fixture) > dd > dl.field-list { grid-template-columns: 1fr; @@ -91,3 +134,6 @@ dl.py:not(.fixture) > dd > dl.field-list > dd { grid-column: 1; } } + + +} /* end @layer gp-sphinx */ 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-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..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 @@ -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,13 +77,33 @@ 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; } +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; +} + +dl.py.fixture > dd > dl.field-list > dd > ul.simple { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 0; + 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; +} @media (max-width: 52rem) { dl.py.fixture > dd > dl.field-list { @@ -106,3 +143,6 @@ dl.py.fixture.gp-sphinx-badge--state-deprecated > dt { min-width: 40rem; width: 100%; } + + +} /* end @layer gp-sphinx */ 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/scripts/audit_defaults.py b/packages/sphinx-autodoc-typehints-gp/scripts/audit_defaults.py new file mode 100644 index 00000000..5c8e0633 --- /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.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:])) 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..7dbbd01b --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_data_defaults.py @@ -0,0 +1,130 @@ +"""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._resolvers import ( + ResolveContext, + Resolver, + TruncateLongRepr, + run_chain, +) + +_VALUE_PREFIX: t.Final = " :value: " + +_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/_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..d6064f88 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_default_xref_transform.py @@ -0,0 +1,471 @@ +"""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: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 +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 _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, + *, + py_module: str | None = None, + py_class: str | None = None, +) -> addnodes.pending_xref: + """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-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 + 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'] + '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-obj"], + ) + xref = addnodes.pending_xref( + "", + literal, + refdomain="py", + reftype="obj", + reftarget=target, + 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: + xref["py:class"] = py_class + return xref + + +def _ast_to_nodes( + node: ast.AST, + *, + 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. + + Identifier-emitting branches (``ast.Name``, ``ast.Attribute``) + produce ``pending_xref`` nodes whose contnode is a + ``: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 + ``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): + 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: + 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, + env=env, + ) + if isinstance(node, ast.List): + 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, 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, 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, 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, env=env) + ) + 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, env=env + ), + ] + 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, + env: t.Any = 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)``. + + 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 + 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, env=env) + ) + 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). + + 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 + 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, + env: t.Any = 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, env=env + ) + 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, + env=self.env, + ) + + +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/_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..3bfdaaf8 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_field_xref_transform.py @@ -0,0 +1,452 @@ +"""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 +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 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 + +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 dash separator. + + Sphinx renders the boundary between a field-list prefix and its + 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 + -------- + >>> _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 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. + + 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. + + 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 + 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 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 separator (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/_param_defaults.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py new file mode 100644 index 00000000..8dac2284 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_param_defaults.py @@ -0,0 +1,193 @@ +"""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 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 ```` +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 + +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__) + +_DEFAULT_RESOLVERS: tuple[Resolver, ...] = (DataclassFactoryRepr(),) + + +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/_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/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..3f20e851 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_static/css/typehints_gp.css @@ -0,0 +1,61 @@ +/* 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 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. + * + * 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 { + /* Default-value spans inside
    signatures. */ + .default_value code.literal { + background: transparent; + border: none; + padding: 0; + font-size: inherit; + } + + /* Field-list prefix wrapper added by FieldListPrefixWrapTransform. + * 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; + } + + /* 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. + * + * `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; + } +} 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..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 @@ -581,10 +581,55 @@ def setup(app: Sphinx) -> dict[str, t.Any]: >>> setup # doctest: +ELLIPSIS """ + import pathlib + + from sphinx_autodoc_typehints_gp._data_defaults import ( + GpAttributeDocumenter, + GpDataDocumenter, + ) + 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, + ) + + 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, + 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) + register_default_xref_transform(app) + register_field_xref_transform(app) 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 @@ -593,7 +638,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: # 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-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 */ 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-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 20be3d2e..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 @@ -1,7 +1,35 @@ /* 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. + * + * 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 { + /* ── Content sections ───────────────────────────────── */ .gp-sphinx-api-region + .gp-sphinx-api-region { margin-top: 1rem; @@ -16,13 +44,68 @@ 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; +} + +/* ── 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; 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; @@ -51,7 +134,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; } @@ -72,6 +155,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; @@ -90,6 +202,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; @@ -156,25 +275,98 @@ 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); } +/* 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-top: 0.25rem; 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. + * + * `margin-left: 0` cancels Furo's `api.css` rule that pulls the + * `
  • ', + 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}" + ) 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..3146beb8 --- /dev/null +++ b/tests/ext/typehints_gp/test_param_defaults.py @@ -0,0 +1,214 @@ +"""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 ( + _walk_to_dataclass, + update_synthetic_defvalues, +) +from sphinx_autodoc_typehints_gp._resolvers import ( + DataclassFactoryRepr, + ResolveContext, +) + +# --------------------------------------------------------------------------- +# 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..b262c5dc --- /dev/null +++ b/tests/ext/typehints_gp/test_param_defaults_integration.py @@ -0,0 +1,122 @@ +"""Integration tests for synthetic-init parameter-default rendering.""" + +from __future__ import annotations + +import re +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 .""" + html = read_output(factory_defaults_html_result, "index.html") + + # 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( + 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..b7d35e99 100644 --- a/tests/ext/typehints_gp/test_unit.py +++ b/tests/ext/typehints_gp/test_unit.py @@ -1213,11 +1213,17 @@ 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, + 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 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"] 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'" },