diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index af6a861b..3b302ff0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -124,13 +124,17 @@ jobs: target: - root-install - gp-sphinx - - sphinx-gptheme + - sphinx-gp-theme - sphinx-fonts - - sphinx-argparse-neo + - sphinx-autodoc-argparse - sphinx-autodoc-docutils - sphinx-autodoc-sphinx - sphinx-autodoc-pytest-fixtures - sphinx-autodoc-api-style + - sphinx-autodoc-fastmcp + - sphinx-autodoc-typehints-gp + - sphinx-ux-badges + - sphinx-ux-autodoc-layout steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index e583f9d4..359e8839 100644 --- a/.gitignore +++ b/.gitignore @@ -218,6 +218,9 @@ docs/_static/css/fonts.css # Playwright MCP .playwright-mcp/ +# Repo-local pytest mirror (do not track — validator-only) +out/ + # Misc .vim/ *.lprof diff --git a/AGENTS.md b/AGENTS.md index 1fb7908b..4555c2ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Key features: This project uses: - Python 3.10+ +- Sphinx 8.1+ (required for the typed `env.domains._domain` accessors) - [uv](https://github.com/astral-sh/uv) for dependency management - [ruff](https://github.com/astral-sh/ruff) for linting and formatting - [mypy](https://github.com/python/mypy) for type checking @@ -140,21 +141,331 @@ gp_sphinx/ - `DEFAULT_FONT_FAMILIES` dict - Shared sidebar configuration +### Package CSS self-containment + +A workspace package's own CSS must style every class its Python code +emits. If a directive appends `SAB.X` (or any gp-sphinx-* class) to a +node, the package's own CSS file carries a rule targeting `SAB.X`. +Cross-package **reuse** of a shared class (e.g., `gp-sphinx-badge` +styled in `sphinx-ux-badges`) is fine; cross-package **dependence** — +where your feature only renders correctly because a sibling package +happens to be loaded — is not. A downstream user installing a single +extension standalone must get the correct visual result. + ## Testing Strategy -gp-sphinx uses pytest for testing. Tests verify that configuration merging, default values, and override behavior work correctly. +All tests are plain functions (`def test_*`). No `class TestFoo:` groupings. Every test +function and every `NamedTuple` fixture class must be fully type-annotated; mypy runs as +part of CI. + +Run continuously while developing: + +```console +$ uv run ptw . +``` + +Include doctests: + +```console +$ uv run ptw . --now --doctest-modules +``` + +### Test Level Hierarchy + +Pick the **lightest** level that exercises the behavior. Never reach for a full Sphinx +build when a docutils node test suffices — an integration build takes 2–10 s, a node +test runs in microseconds. + +| Level | When to use | +|---|---| +| **Pure unit** | Transforming strings, dicts, dataclasses — no nodes, no Sphinx | +| **Docutils tree unit** | Testing transforms/visitors/renderers by constructing `nodes.*` directly | +| **Snapshot unit** | Same as docutils tree, but output is large or complex — assert via `snapshot_doctree` | +| **Sphinx integration** (`@pytest.mark.integration`) | **Any test that constructs a `Sphinx` app.** `build_shared_sphinx_result` / `build_isolated_sphinx_result` with any builder — *including `buildername="dummy"`* — counts. If the test touches `env.domains.*`, walks a built doctree, or asserts on `result.warnings`, it is integration. | + +### Type Annotations (required everywhere) + +Every test function must annotate all parameters and the return type: + +```python +def test_something(value: str, expected: int) -> None: + assert compute(value) == expected +``` + +Every `NamedTuple` fixture class must annotate all fields. + +### NamedTuple Parametrization + +Use `t.NamedTuple` for any parametrized test with three or more inputs. Two wiring +styles are in use — pick whichever reads more clearly for the case at hand. + +**Style A — unpack all fields** (dominant; used in `test_unit.py`, lexer tests, etc.) + +Each field becomes a typed parameter in the test function, which makes the signature +self-documenting: + +```python +import typing as t + +import pytest + + +class FooFixture(t.NamedTuple): + """Test case for foo().""" -### Testing Guidelines + test_id: str # always the first field + input: str + expected: str -1. **Use functional tests only**: Write tests as standalone functions, not classes. Avoid `class TestFoo:` groupings - use descriptive function names and file organization instead. -2. **Preferred pytest patterns** - - Use `tmp_path` (pathlib.Path) fixture over Python's `tempfile` - - Use `monkeypatch` fixture over `unittest.mock` +_FOO_FIXTURES: list[FooFixture] = [ + FooFixture(test_id="basic", input="a", expected="A"), + FooFixture(test_id="empty", input="", expected=""), +] -3. **Running tests continuously** - - Use pytest-watcher during development: `uv run ptw .` - - For doctests: `uv run ptw . --now --doctest-modules` + +@pytest.mark.parametrize( + list(FooFixture._fields), + _FOO_FIXTURES, + ids=[f.test_id for f in _FOO_FIXTURES], +) +def test_foo(test_id: str, input: str, expected: str) -> None: + """foo() uppercases its input.""" + assert foo(input) == expected +``` + +**Style B — pass whole struct as `case`** (used in `test_directives.py`, +`test_nodes.py`, when the struct is reused in assertion messages or has many fields): + +```python +@pytest.mark.parametrize( + "case", + _FOO_FIXTURES, + ids=lambda c: c.test_id, +) +def test_foo(case: FooFixture) -> None: + """foo() uppercases its input.""" + assert foo(case.input) == case.expected +``` + +Naming conventions: + +- `test_id: str` is **always the first field** +- Fixture list: `_FOO_FIXTURES` (module-private, all-caps) +- Fixture class: `FooFixture` or `FooCase` — never `TestFoo` + +### Docutils Tree Unit Tests (no Sphinx build) + +Test transforms, visitors, and renderers by constructing `docutils.nodes` and +`sphinx.addnodes` objects directly. Follow the pattern in +`tests/ext/layout/test_transforms.py`: + +```python +from docutils import nodes +from sphinx import addnodes + + +def _make_desc( + *content_children: nodes.Node, + domain: str = "py", + objtype: str = "function", +) -> addnodes.desc: + desc = addnodes.desc(domain=domain, objtype=objtype) + desc += addnodes.desc_signature() + content = addnodes.desc_content() + for child in content_children: + content += child + desc += content + return desc + + +def test_transform_wraps_content_runs() -> None: + """_wrap_content_runs groups consecutive content nodes.""" + desc = _make_desc(nodes.paragraph("", "summary"), nodes.field_list()) + _wrap_content_runs(desc) + assert any(isinstance(n, ContentGroup) for n in desc[1]) +``` + +- Put `_make_*()` builder helpers at the top of the test file, near the tests that use + them. +- Never import `sphinx.application.Sphinx` in a pure tree test. +- Use `nodes.document()` (with a minimal `settings` object from + `docutils.frontend.OptionParser`) only when the transform requires a real document + root. + +### Snapshot Tests (syrupy) + +Use when the expected output is too large or fragile to inline. The three fixtures +(from `tests/_snapshots.py`, loaded automatically via `pytest_plugins`) normalize their +inputs before asserting so that build-path churn and docutils version noise do not cause +spurious failures: + +- `snapshot_doctree(doctree, *, name=None, roots=())` — normalizes a `nodes.Node` +- `snapshot_html_fragment(fragment, *, name=None, roots=())` — strips ANSI, normalizes whitespace +- `snapshot_warnings(warnings, *, name=None, roots=())` — strips noise lines and ANSI codes + +```python +import typing as t + + +def test_layout_render( + snapshot_doctree: t.Callable[..., None], +) -> None: + """Transform produces a stable doctree.""" + desc = _make_large_signature_desc() + on_doctree_resolved(desc) + snapshot_doctree(desc) +``` + +Update stored snapshots after intentional output changes: + +```console +$ uv run pytest --snapshot-update +``` + +### Integration Tests (full Sphinx build) + +Use the harness in `tests/_sphinx_scenarios.py`. The key types and helpers: + +- `SphinxScenario(files=(...), confoverrides={}, buildername="html")` — describes the + synthetic project; `buildername` defaults to `"html"`, override for text builds +- `ScenarioFile(relative_path, contents, substitute_srcdir=False)` — one source file +- `build_shared_sphinx_result(cache_root, scenario, *, purge_modules=())` — builds + once per content-hash digest; `purge_modules` removes named modules from `sys.modules` + before the initial build to prevent stale import cache — required when scenario files + inject a Python module into `sys.path` +- `build_isolated_sphinx_result(cache_root, tmp_path, scenario, *, purge_modules=())` + — fresh build per test, for mutating assertions +- `derive_sphinx_scenario_cache_root(tmp_path)` — derives a stable per-session cache + root from any `tmp_path` by using its parent directory +- `copy_scenario_tree(cache_root, scenario, destination_root)` — materialize source + files into a directory without running a Sphinx build +- `get_doctree(result, docname, post_transforms=False)` — deep-copied doctree from + the built environment +- `read_output(result, filename)` — reads a built output file as a string + +Always use a **module-scoped** (or session-scoped) fixture for the build — never +function-scoped — so the expensive Sphinx build is shared across all tests in the +module. Follow the pattern in `tests/ext/typehints_gp/test_integration.py`: + +```python +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +_CONF_PY = textwrap.dedent( + """\ + import sys + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + extensions = ["sphinx.ext.autodoc", "my_extension"] + """ +) + +_INDEX_RST = textwrap.dedent( + """\ + Demo + ==== + + .. autofunction:: my_module.my_function + """ +) + + +@pytest.fixture(scope="module") +def my_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a minimal Sphinx project using my_extension.""" + cache_root = tmp_path_factory.mktemp("my-ext-html") + scenario = SphinxScenario( + files=( + ScenarioFile("index.rst", _INDEX_RST), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("my_module", "my_extension"), + ) + + +@pytest.mark.integration +def test_my_feature_appears_in_html(my_html_result: SharedSphinxResult) -> None: + """Extension renders the expected markup.""" + html = read_output(my_html_result, "index.html") + assert "my-feature" in html +``` + +Rules: +- Always mark with `@pytest.mark.integration` +- Always `scope="module"` or `scope="session"` on the build fixture — never + `scope="function"` +- Use `textwrap.dedent("""...""")` for inline source strings +- Use `SCENARIO_SRCDIR_TOKEN` + `substitute_srcdir=True` for `sys.path` injection in + `conf.py` + +> **See also:** `notes/test-analysis.md` — profiling data, 9.5x speedup rationale, +> and the per-package migration history for the shared autodoc stack. + +### Available Fixtures Reference + +| Fixture | Source | When to use | +|---|---|---| +| `tmp_path` | pytest built-in | Per-test temp directory | +| `tmp_path_factory` | pytest built-in | Session/module fixtures that create temp dirs | +| `monkeypatch` | pytest built-in | Env vars, module attributes, `sys.modules` patching | +| `caplog` | pytest built-in | Log assertions; use `caplog.records`, not `caplog.text` | +| `snapshot_doctree` | `tests/_snapshots.py` | Normalized doctree snapshot assertion | +| `snapshot_html_fragment` | `tests/_snapshots.py` | Normalized HTML string snapshot assertion | +| `snapshot_warnings` | `tests/_snapshots.py` | Normalized Sphinx warning snapshot assertion | +| `spf_suite_root`, `spf_doctree_root`, `spf_html_root` | `tests/ext/pytest_fixtures/conftest.py` | Session roots for sphinx-pytest-fixture ext tests | +| `simple_parser`, `parser_with_groups`, … | `tests/ext/argparse/conftest.py` | `ArgumentParser` permutations for argparse tests | + +### Anti-Patterns + +- **No `class TestFoo:` groupings** — use descriptive function names and file + organization instead +- **No `unittest.mock.patch`** — use `monkeypatch` +- **No `tempfile.mkdtemp()`** — use `tmp_path` +- **No `Sphinx()` instantiation in a unit test** — build docutils nodes directly +- **No unannotated test functions** — every parameter and `-> None` must be typed +- **No `# doctest: +SKIP`** in module doctests (see Doctests section) +- **No inline tuples in `parametrize`** when there are three or more fields — use + `NamedTuple` +- **No function-scoped Sphinx build fixtures** — always module- or session-scoped + +## CSS Standards + +All CSS classes, custom properties, and MyST directive names added by a +workspace package live under the `gp-sphinx-*` namespace: + +- **Tier A (shared concepts)** — `gp-sphinx-` (e.g., + `gp-sphinx-badge`, `gp-sphinx-toolbar`). Used by multiple packages. +- **Tier B (package-owned)** — `gp-sphinx-__` BEM-style + (e.g., `gp-sphinx-fastmcp__safety-readonly`, + `gp-sphinx-pytest-fixtures__fixture-index`). +- **Modifiers** — axis-value pairs `---` (e.g., + `gp-sphinx-badge--size-xs`, `gp-sphinx-badge--type-function`). +- **Custom properties** — mirror the class namespace: + `--gp-sphinx--`. Furo-owned variables (`--color-api-*`, + `--font-stack--*`, etc.) stay untouched. +- **Specificity** — prefer chained class selectors + (`.gp-sphinx-badge.gp-sphinx-badge--dense`); keep selectors at 0,3,0 + max. ## Coding Standards @@ -168,6 +479,22 @@ Key highlights: - **For typing**, use `import typing as t` and access via namespace: `t.NamedTuple`, etc. - **Use `from __future__ import annotations`** at the top of all Python files +### Sphinx domain access + +Prefer the typed accessors on `env.domains` over `env.get_domain()`: + +- `env.domains.standard_domain` — not `env.get_domain("std")` +- `env.domains.python_domain` — not `env.get_domain("py")` +- Similarly: `c_domain`, `cpp_domain`, `javascript_domain`, + `restructuredtext_domain`, `changeset_domain`, `citation_domain`, + `index_domain`, `math_domain` + +The typed accessors return the concrete domain subclass +(`StandardDomain`, `PythonDomain`, etc.), so mypy sees subclass-specific +attributes (`progoptions`, `add_program_option`, `data["objects"]`, …) +without `t.cast` or `# type: ignore`. The accessors were added in Sphinx +8.1 (`_DomainsContainer`), which is the workspace floor. + ### Docstrings Follow NumPy docstring style for all functions and methods: diff --git a/CHANGES b/CHANGES index 163b3733..5d269aed 100644 --- a/CHANGES +++ b/CHANGES @@ -18,15 +18,26 @@ $ uv add gp-sphinx --prerelease allow +### Breaking changes + +- Package renames: `sphinx-argparse-neo` → `sphinx-autodoc-argparse`, + `sphinx-gptheme` → `sphinx-gp-theme`, `sphinx-autodoc-badges` → + `sphinx-ux-badges`, `sphinx-autodoc-layout` → `sphinx-ux-autodoc-layout`, + `sphinx-typehints-gp` → `sphinx-autodoc-typehints-gp` (#18) +- Sphinx floor bumped to 8.1 across the workspace (#18) +- CSS classes and custom properties unified under a single `gp-sphinx-*` + BEM namespace (retires the per-package `sab-`, `smf-`, `spf-`, `api-`, + `gas-`, `gal-` prefixes and collapses their duplicate palettes) (#18) + ### Features - `sphinx-autodoc-fastmcp`: new Sphinx extension for FastMCP tool docs (card-style `desc` layouts, safety badges, MyST directives, cross-reference roles) -- `sphinx-autodoc-badges`: shared badge node (`BadgeNode`), builder API +- `sphinx-ux-badges`: shared badge node (`BadgeNode`), builder API (`build_badge`, `build_badge_group`, `build_toolbar`), and base CSS layer shared by `sphinx-autodoc-fastmcp`, `sphinx-autodoc-api-style`, and `sphinx-autodoc-pytest-fixtures` (#13) -- `sphinx-autodoc-badges`: explicit size variants `xs` / `sm` / `lg` / `xl` via +- `sphinx-ux-badges`: explicit size variants `xs` / `sm` / `lg` / `xl` via `build_badge(size=...)` and `BadgeNode(badge_size=...)` — compose with any fill, style, or color class (#13) - Initial release of `gp_sphinx` shared documentation platform @@ -36,6 +47,21 @@ $ uv add gp-sphinx --prerelease allow - `sphinx-autodoc-pytest-fixtures`: Fixture tables now resolve `TypeAlias` return annotations — alias names are preserved and linked rather than expanding to the underlying union or generic type (#9) +- Integrated autodoc design system: twelve workspace packages in three + tiers — shared infrastructure, domain autodocumenters, theme/coordinator — + sharing one badge palette, one layout pipeline, and one typehint + renderer (#18) +- New `sphinx-ux-autodoc-layout` package — componentized autodoc output + with card containers, parameter folding, and managed signatures (#18) +- New `sphinx-autodoc-typehints-gp` package — single-package replacement + for `sphinx-autodoc-typehints` + `sphinx.ext.napoleon`; resolves + annotations statically at build time with no monkey-patching (#18) +- `sphinx-autodoc-argparse` now ships a real `argparse` Sphinx domain — + `program` / `option` / `subcommand` / `positional` ObjTypes, + `:argparse:*` xref roles, and two auto-generated indices + (`argparse-programsindex`, `argparse-optionsindex`). + `Framework :: Sphinx :: Domain` classifier added. `:option:` / + `std:cmdoption` continues to resolve for intersphinx consumers (#18) ### Bug fixes @@ -45,13 +71,13 @@ $ uv add gp-sphinx --prerelease allow surrounding context — previously only Sans had the full set) - Replace `font-weight: 650` with `700` in badge CSS across `sphinx-autodoc-api-style`, `sphinx-autodoc-pytest-fixtures`, - `sphinx-gptheme`, and docs (650 is not a standard Fontsource weight, + `sphinx-gp-theme`, and docs (650 is not a standard Fontsource weight, so browsers were synthesizing bold instead of using the real font file) - Badge background colors, border colors, and dotted-underline tooltips lost after `BadgeNode` (``) replaced `` in `sphinx-autodoc-api-style` and `sphinx-autodoc-pytest-fixtures`; restored via element-agnostic CSS selectors and correct fill defaults (#13) -- `sphinx-argparse-neo`: Namespace implicit section targets (`section["names"]`) +- `sphinx-autodoc-argparse`: Namespace implicit section targets (`section["names"]`) by `id_prefix` in `render_usage_section`, `render_group_section`, and `_create_example_section` so multi-page docs that embed `.. argparse::` via MyST `{eval-rst}` no longer emit `duplicate label` warnings for `usage`, @@ -59,7 +85,7 @@ $ uv add gp-sphinx --prerelease allow ### Workspace packages -- `sphinx-autodoc-badges` — Shared badge node, builders, and base CSS for +- `sphinx-ux-badges` — Shared badge node, builders, and base CSS for safety tiers, scope, and kind labels. Extensions add color layers on top; TOC sidebar shows compact badges with emoji icons and subtle inset depth on solid pills (#13) @@ -70,7 +96,7 @@ $ uv add gp-sphinx --prerelease allow badges, classified dependency lists, reverse-dep tracking, and auto-generated usage snippets. Frozen dataclasses for pickle-safe incremental builds, parallel-safe, WCAG AA badge contrast, and pytest 9+ compatible. - New: `doc-pytest-plugin` directive generates a standard pytest plugin page + New: `auto-pytest-plugin` directive generates a standard pytest plugin page (install block, `pytest11` autodiscovery note, fixture summary and reference) from a single directive call. Optional `:project:`, `:summary:`, `:tests-url:`, and `:install-command:` options; body is free-form. Use `autofixture-index` + @@ -78,8 +104,8 @@ $ uv add gp-sphinx --prerelease allow - `sphinx-fonts` — Self-hosted web fonts via Fontsource CDN. Downloads at build time, caches locally, and injects `@font-face` CSS with preload hints and fallback font-metric overrides for zero-CLS loading. -- `sphinx-gptheme` — Furo child theme for git-pull projects. Custom sidebar, footer +- `sphinx-gp-theme` — Furo child theme for git-pull projects. Custom sidebar, footer icons, SPA navigation, and CSS variable-driven IBM Plex typography. -- `sphinx-argparse-neo` — Argparse CLI documentation extension with `.. argparse::` +- `sphinx-autodoc-argparse` — Argparse CLI documentation extension with `.. argparse::` directive, epilog-to-section transformation, and Pygments lexers for argparse help/usage output. diff --git a/README.md b/README.md index 2639ce5b..598b92ba 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ -# gp-sphinx · [![Python Package](https://img.shields.io/pypi/v/gp-sphinx.svg)](https://pypi.org/project/gp-sphinx/) [![License](https://img.shields.io/github/license/git-pull/gp-sphinx.svg)](https://github.com/git-pull/gp-sphinx/blob/master/LICENSE) +# gp-sphinx · [![Python Package](https://img.shields.io/pypi/v/gp-sphinx.svg)](https://pypi.org/project/gp-sphinx/) [![License](https://img.shields.io/github/license/git-pull/gp-sphinx.svg)](https://github.com/git-pull/gp-sphinx/blob/main/LICENSE) -Shared Sphinx documentation platform for [git-pull](https://github.com/git-pull) projects. +Integrated autodoc design system for Sphinx. Twelve packages in three tiers +that replace ~300 lines of duplicated `docs/conf.py` with ~10 lines and +produce beautiful, consistent API documentation. -Consolidates duplicated docs configuration, extensions, theme settings, and workarounds from 14+ repositories into a single reusable package. +## Requirements + +- Python 3.10+ +- Sphinx 8.1+ ## Install @@ -16,7 +21,7 @@ $ uv add gp-sphinx ## Usage -Replace ~300 lines of duplicated `docs/conf.py` with ~10 lines: +Replace your `docs/conf.py` with: ```python """Sphinx configuration for my-project.""" @@ -38,19 +43,34 @@ conf = merge_sphinx_config( globals().update(conf) ``` -## Features +## The autodoc design system + +Out of the box, `merge_sphinx_config()` activates: + +- **Componentized layouts** (`sphinx-ux-autodoc-layout`) — card containers, parameter folding, managed signatures +- **Clean type hints** (`sphinx-autodoc-typehints-gp`) — simplified annotations with cross-referenced links, replacing `sphinx-autodoc-typehints` and `sphinx.ext.napoleon` +- **Unified badge system** (`sphinx-ux-badges`) — type and modifier badges with a shared colour palette +- **Six domain autodocumenters** — Python API, argparse CLIs, pytest fixtures, FastMCP tools, docutils directives, Sphinx config values +- **IBM Plex fonts** via Fontsource with preloaded web fonts +- **Full dark mode** theming via CSS custom properties + +See the [Gallery](https://gp-sphinx.git-pull.com/gallery.html) for live demos of every component. + +## Three-tier architecture + +The workspace is organized into three tiers — lower layers never depend on higher ones: + +- **Shared infrastructure**: `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, `sphinx-autodoc-typehints-gp` +- **Domain packages**: `sphinx-autodoc-api-style`, `sphinx-autodoc-docutils`, `sphinx-autodoc-fastmcp`, `sphinx-autodoc-pytest-fixtures`, `sphinx-autodoc-sphinx` +- **Theme and coordinator**: `gp-sphinx`, `sphinx-gp-theme`, `sphinx-fonts`, `sphinx-autodoc-argparse` -- `merge_sphinx_config()` API for shared defaults with per-project overrides -- Shared extension list (autodoc, intersphinx, myst_parser, sphinx_design, etc.) -- Shared Furo theme configuration (CSS variables, fonts, sidebar, footer) -- Bundled workarounds (tabs.js removal, spa-nav.js injection) -- Shared font configuration (IBM Plex via Fontsource) +See the [Architecture](https://gp-sphinx.git-pull.com/architecture.html) page for the full package map. ## More information - Documentation: - Source: -- Changelog: +- Changelog: - Issues: - PyPI: -- License: [MIT](https://github.com/git-pull/gp-sphinx/blob/master/LICENSE) +- License: [MIT](https://github.com/git-pull/gp-sphinx/blob/main/LICENSE) diff --git a/docs/_ext/api_demo_layout.py b/docs/_ext/api_demo_layout.py new file mode 100644 index 00000000..ffcd09bf --- /dev/null +++ b/docs/_ext/api_demo_layout.py @@ -0,0 +1,120 @@ +"""Demo module for sphinx-ux-autodoc-layout live showcase. + +Provides classes with varying parameter counts to demonstrate +region wrapping and parameter folding. +""" + +from __future__ import annotations + + +def compact_function(name: str, value: int = 0) -> str: + """Format a name-value pair. + + This should render without any fold -- just a narrative region + followed by a fields region. + + Parameters + ---------- + name : str + The item name. + value : int + The item value. + + Returns + ------- + str + Formatted result. + """ + return f"{name}={value}" + + +class LayoutDemo: + """A class demonstrating all layout regions. + + The class docstring forms the **narrative** region. The parameter + field list below forms the **fields** region (folded if large + enough). Nested methods form the **members** region. + + Parameters + ---------- + host : str + Server hostname. + port : int + Server port number. + username : str + Authentication username. + password : str + Authentication password. + database : str + Database name. + timeout : float + Connection timeout in seconds. + retries : int + Number of connection retries. + ssl : bool + Enable SSL/TLS. + pool_size : int + Connection pool size. + pool_timeout : float + Pool checkout timeout. + echo : bool + Log all SQL statements. + encoding : str + Character encoding. + isolation_level : str + Transaction isolation level. + """ + + def __init__( + self, + host: str, + port: int = 5432, + *, + username: str = "admin", + password: str = "", + database: str = "default", + timeout: float = 30.0, + retries: int = 3, + ssl: bool = True, + pool_size: int = 5, + pool_timeout: float = 10.0, + echo: bool = False, + encoding: str = "utf-8", + isolation_level: str = "READ COMMITTED", + ) -> None: + self.host = host + self.port = port + + def connect(self) -> bool: + """Open a connection to the server. + + Returns + ------- + bool + True if connection succeeded. + """ + return True + + def execute( + self, + query: str, + params: dict[str, str] | None = None, + ) -> list[dict[str, str]]: + """Execute a query and return results. + + Parameters + ---------- + query : str + SQL query string. + params : dict[str, str] | None + Query parameters. + + Returns + ------- + list[dict[str, str]] + Query result rows. + """ + return [] + + def close(self) -> None: + """Close the connection.""" diff --git a/docs/_ext/argparse_neo_demo.py b/docs/_ext/argparse_demo.py similarity index 93% rename from docs/_ext/argparse_neo_demo.py rename to docs/_ext/argparse_demo.py index 7fa56858..743607c8 100644 --- a/docs/_ext/argparse_neo_demo.py +++ b/docs/_ext/argparse_demo.py @@ -1,4 +1,4 @@ -"""Demo parser factories for the sphinx-argparse-neo docs page.""" +"""Demo parser factories for the sphinx-autodoc-argparse docs page.""" from __future__ import annotations @@ -35,7 +35,7 @@ def build_parser() -> argparse.ArgumentParser: """ examples: gp-demo sync packages/sphinx-fonts - gp-demo sync packages/sphinx-gptheme + gp-demo sync packages/sphinx-gp-theme Machine-readable output examples: gp-demo sync --format json packages/sphinx-fonts diff --git a/docs/_ext/demo_cli.py b/docs/_ext/demo_cli.py index fdc3de70..2ac05c69 100644 --- a/docs/_ext/demo_cli.py +++ b/docs/_ext/demo_cli.py @@ -25,7 +25,7 @@ def create_parser() -> argparse.ArgumentParser: """ parser = argparse.ArgumentParser( prog="myapp", - description="Example CLI showing how sphinx-argparse-neo renders parsers.", + description="Example CLI showing how sphinx-autodoc-argparse renders parsers.", ) parser.add_argument( "--verbose", diff --git a/docs/_ext/fastmcp_demo_tools.py b/docs/_ext/fastmcp_demo_tools.py new file mode 100644 index 00000000..6832c845 --- /dev/null +++ b/docs/_ext/fastmcp_demo_tools.py @@ -0,0 +1,113 @@ +"""Synthetic FastMCP tools for the documentation page live demos. + +Examples +-------- +>>> list_sessions("prod") +['prod:0', 'prod:1'] +>>> create_session("demo")["name"] +'demo' +>>> delete_session("old-session") +True +""" + +from __future__ import annotations + +import types +import typing as t + + +def list_sessions(server: str, limit: int = 20) -> list[str]: + """List active sessions for one server. + + Parameters + ---------- + server : str + Server name to inspect. + limit : int + Maximum number of sessions to return. + + Returns + ------- + list[str] + Session identifiers for the server. + + Examples + -------- + >>> list_sessions("prod") + ['prod:0', 'prod:1'] + """ + return [f"{server}:{index}" for index in range(min(limit, 2))] + + +t.cast(t.Any, list_sessions).__fastmcp__ = types.SimpleNamespace( + name="list_sessions", title="List Sessions", tags={"readonly"}, annotations=None +) + + +def create_session( + name: str, + window_count: int = 1, + detached: bool = False, +) -> dict[str, str | int | bool]: + """Create one session and return the created record. + + Parameters + ---------- + name : str + Session name to create. + window_count : int + Initial number of windows. + detached : bool + Whether to create the session detached from the current client. + + Returns + ------- + dict[str, str | int | bool] + Created session metadata. + + Examples + -------- + >>> create_session("demo") + {'name': 'demo', 'windows': 1, 'detached': False} + """ + return { + "name": name, + "windows": window_count, + "detached": detached, + } + + +t.cast(t.Any, create_session).__fastmcp__ = types.SimpleNamespace( + name="create_session", title="Create Session", tags={"mutating"}, annotations=None +) + + +def delete_session(name: str, force: bool = False) -> bool: + """Delete one session from the server. + + Parameters + ---------- + name : str + Session name to delete. + force : bool + Whether to skip confirmation checks. + + Returns + ------- + bool + ``True`` when the session was removed. + + Examples + -------- + >>> delete_session("old-session") + True + """ + return True + + +t.cast(t.Any, delete_session).__fastmcp__ = types.SimpleNamespace( + name="delete_session", + title="Delete Session", + tags={"destructive"}, + annotations=None, +) diff --git a/docs/_ext/gas_demo_api.py b/docs/_ext/gp_demo_api.py similarity index 100% rename from docs/_ext/gas_demo_api.py rename to docs/_ext/gp_demo_api.py diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 05746b58..0943fa03 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -11,10 +11,10 @@ its name, version, description, classifiers, and GitHub URL. 2. **Surface extraction** (``collect_extension_surface()``) — imports the - module and monkey-patches ``app.add_*`` methods on a lightweight mock - ``Sphinx`` object to intercept calls that ``setup()`` makes. Each - registered item (config value, directive, role, lexer, theme) is captured - into a ``SurfaceDict``. + module and passes a lightweight ``RecorderApp`` to its ``setup()``. + ``RecorderApp.__getattr__`` captures every ``app.add_*`` call so each + registered item (config value, directive, role, lexer, theme) flows + into a ``SurfaceDict`` without monkey-patching docutils globals. 3. **Rendering** (``package_reference_markdown()``) — converts the collected surface into a Markdown fragment (config snippet + tables), which the @@ -38,8 +38,8 @@ >>> package["name"] in { ... "gp-sphinx", ... "sphinx-fonts", -... "sphinx-gptheme", -... "sphinx-argparse-neo", +... "sphinx-gp-theme", +... "sphinx-autodoc-argparse", ... "sphinx-autodoc-docutils", ... "sphinx-autodoc-fastmcp", ... "sphinx-autodoc-pytest-fixtures", @@ -64,7 +64,6 @@ import sys import typing as t -from docutils.parsers.rst import roles from sphinx.util.docutils import SphinxDirective if t.TYPE_CHECKING: @@ -172,9 +171,9 @@ def extension_modules(module_name: str) -> list[str]: Examples -------- - >>> "sphinx_argparse_neo" in extension_modules("sphinx_argparse_neo") + >>> "sphinx_autodoc_argparse" in extension_modules("sphinx_autodoc_argparse") True - >>> "sphinx_argparse_neo.exemplar" in extension_modules("sphinx_argparse_neo") + >>> "sphinx_autodoc_argparse.exemplar" in extension_modules("sphinx_autodoc_argparse") True """ ensure_workspace_imports() @@ -314,26 +313,8 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: themes=[], ) app = RecorderApp() - registered_roles: list[tuple[str, object]] = [] - original_local = roles.register_local_role - original_canonical = roles.register_canonical_role - - def _record_local(name: str, role: object) -> None: - registered_roles.append((name, role)) - - # Temporarily replace the two docutils global role-registration functions so - # that any role registered by setup(app) is captured in registered_roles. - # The try/finally guarantees restoration even if setup() raises. - # Limitation: this mutates process-global state and is not safe for - # parallel Sphinx builds (sphinx -j N); single-threaded builds only. - try: - roles.register_local_role = t.cast("t.Any", _record_local) - roles.register_canonical_role = t.cast("t.Any", _record_local) - setup = t.cast("t.Callable[[object], object]", getattr(module, "setup")) - setup(app) - finally: - roles.register_local_role = original_local - roles.register_canonical_role = original_canonical + setup = t.cast("t.Callable[[object], object]", getattr(module, "setup")) + setup(app) config_values: list[dict[str, str]] = [] directives: list[dict[str, str]] = [] @@ -440,16 +421,6 @@ def _record_local(name: str, role: object) -> None: }, ) - for role_name, role_fn in registered_roles: - role_items.append( - { - "name": role_name, - "kind": "docutils role", - "callable": object_path(role_fn), - "summary": summarize(getattr(role_fn, "__doc__", None)), - }, - ) - return { "module": module_name, "config_values": unique_by_name(config_values), @@ -499,7 +470,7 @@ def directive_options_markdown(directive_cls: object) -> str: Examples -------- - >>> from sphinx_argparse_neo.directive import ArgparseDirective + >>> from sphinx_autodoc_argparse.directive import ArgparseDirective >>> "module" in directive_options_markdown(ArgparseDirective) True """ @@ -521,10 +492,10 @@ def theme_options(package_dir: pathlib.Path) -> list[str]: Examples -------- - >>> "light_logo" in theme_options(workspace_root() / "packages" / "sphinx-gptheme") + >>> "light_logo" in theme_options(workspace_root() / "packages" / "sphinx-gp-theme") True """ - theme_conf = package_dir / "src" / "sphinx_gptheme" / "theme" / "theme.conf" + theme_conf = package_dir / "src" / "sphinx_gp_theme" / "theme" / "theme.conf" if not theme_conf.exists(): return [] parser = configparser.ConfigParser() @@ -693,7 +664,7 @@ def package_reference_markdown(package_name: str) -> str: lines.append(f"| `{row['name']}` | {row['path']} |") lines.append("") - if module_name == "sphinx_gptheme": + if module_name == "sphinx_gp_theme": options = theme_options(package_dir) lines.extend( [ @@ -711,7 +682,11 @@ def package_reference_markdown(package_name: str) -> str: def maturity_badge(maturity: str) -> str: - """Return a sphinx-design badge role matching a package maturity label. + """Return a sphinx-design badge role for use in grid markdown output. + + Used only in :func:`workspace_package_grid_markdown` which produces raw + MyST markdown strings. Per-page package headers use the ``gp-sphinx-package-meta`` + directive (see ``docs/_ext/sab_meta.py``) which emits SAB-native badges. Examples -------- @@ -802,26 +777,10 @@ def _register_extension_objects( continue recorder = RecorderApp() - docutils_roles: list[tuple[str, object]] = [] - original_local = roles.register_local_role - original_canonical = roles.register_canonical_role - - def _capture( - role_name: str, - role_fn: object, - _roles: list[tuple[str, object]] = docutils_roles, - ) -> None: - _roles.append((role_name, role_fn)) - try: - roles.register_local_role = t.cast("t.Any", _capture) - roles.register_canonical_role = t.cast("t.Any", _capture) setup_fn(recorder) except Exception: continue - finally: - roles.register_local_role = original_local - roles.register_canonical_role = original_canonical raw_objs: list[tuple[object, str]] = [] # (obj, objtype) for call_name, args, _kwargs in recorder.calls: @@ -841,10 +800,6 @@ def _capture( ) elif call_name == "add_lexer" and len(args) >= 2: raw_objs.append((args[1], "class")) - for _role_name, role_fn in docutils_roles: - raw_objs.append( - (role_fn, "function" if not inspect.isclass(role_fn) else "class"), - ) for obj, objtype in raw_objs: mod = getattr(obj, "__module__", None) or type(obj).__module__ diff --git a/docs/_ext/sab_demo.py b/docs/_ext/sab_demo.py index 0a010d29..faf0a5ad 100644 --- a/docs/_ext/sab_demo.py +++ b/docs/_ext/sab_demo.py @@ -1,4 +1,4 @@ -"""Live badge demo directive for the sphinx-autodoc-badges docs page. +"""Live badge demo directive for the sphinx-ux-badges docs page. Renders every badge variant using the real ``build_badge`` / ``build_badge_group`` / ``build_toolbar`` API so the page exercises @@ -10,9 +10,11 @@ import typing as t from docutils import nodes +from sab_meta import _link_badge, _maturity_badge from sphinx.application import Sphinx from sphinx.util.docutils import SphinxDirective -from sphinx_autodoc_badges import build_badge, build_badge_group, build_toolbar + +from sphinx_ux_badges import SAB, build_badge, build_badge_group, build_toolbar class BadgeDemoDirective(SphinxDirective): @@ -39,75 +41,511 @@ def _row(*badge_nodes: nodes.Node, label: str = "") -> nodes.paragraph: p += nodes.literal(text=label) return p - result.append(_section("Size variants (xs / sm / default / lg / xl)")) + # ── All structural variants ────────────────────────────── + + result.append(_section("No icon")) result.append( _row( - build_badge("xs", size="xs", tooltip="Extra small"), - build_badge("sm", size="sm", tooltip="Small"), - build_badge("md", tooltip="Default (no size class)"), - build_badge("lg", size="lg", tooltip="Large"), - build_badge("xl", size="xl", tooltip="Extra large"), - label='build_badge("lg", size="lg")', + build_badge("label", tooltip="Plain filled badge"), + label='build_badge("label")', ) ) - result.append(_section("Filled (default)")) result.append( - _row( - build_badge("label", tooltip="Default filled badge"), - label='build_badge("label")', - ) + _section("With icon — left (default, gp-sphinx-badge[data-icon])") ) result.append( _row( + build_badge("readonly", icon="\U0001f50d", tooltip="Icon on left"), build_badge( - "with icon", - icon="\U0001f50d", - tooltip="Badge with emoji icon", + "mutating", + icon="\u270f\ufe0f", + tooltip="Icon on left", + classes=[SAB.TYPE_CLASS], ), - label='build_badge("with icon", icon="\\U0001f50d")', + label='build_badge("readonly", icon="🔍")', ) ) - result.append(_section("Outline")) + result.append(_section("With icon — right (gp-sphinx-badge--icon-right)")) result.append( _row( - build_badge("outline", fill="outline", tooltip="Outline variant"), - label='build_badge("outline", fill="outline")', + build_badge( + "readonly", + icon="\U0001f50d", + tooltip="Icon on right", + classes=[SAB.ICON_RIGHT], + ), + build_badge( + "mutating", + icon="\u270f\ufe0f", + tooltip="Icon on right", + classes=[SAB.ICON_RIGHT, SAB.TYPE_CLASS], + ), + label='build_badge("readonly", icon="🔍", classes=[SAB.ICON_RIGHT])', ) ) - result.append(_section("Icon-only")) + result.append( + _section("Icon-only — 16x16 coloured box (gp-sphinx-badge--icon-only)") + ) result.append( _row( build_badge( "", style="icon-only", icon="\U0001f50d", - tooltip="Icon-only badge", + tooltip="Icon-only (no text)", + ), + build_badge( + "", + style="icon-only", + icon="\u270f\ufe0f", + tooltip="Icon-only mutating", + classes=[SAB.TYPE_CLASS], ), - label='build_badge("", style="icon-only", icon="\\U0001f50d")', + build_badge( + "", + style="icon-only", + icon="\U0001f4a3", + tooltip="Icon-only destructive", + classes=[SAB.TYPE_EXCEPTION], + ), + label='build_badge("", style="icon-only", icon="🔍")', ) ) - result.append(_section("Inline-icon (inside code chips)")) - code = nodes.literal(text="some_function()") - inline_icon = build_badge( + result.append( + _section( + "Inline-icon — bare emoji inside code chip" + " (gp-sphinx-badge--inline-icon)" + ) + ) + wrapper = nodes.paragraph() + wrapper += build_badge( "", style="inline-icon", icon="\u270f\ufe0f", tooltip="Inline icon", tabindex="", ) - wrapper = nodes.paragraph() - wrapper += inline_icon - wrapper += code - wrapper += nodes.Text(" ") - wrapper += nodes.literal( - text='build_badge("", style="inline-icon", icon="\\u270f\\ufe0f")' + wrapper += nodes.literal(text="some_function()") + wrapper += nodes.Text(" ") + wrapper += build_badge( + "", + style="inline-icon", + icon="\U0001f50d", + tooltip="Inline icon search", + tabindex="", ) + wrapper += nodes.literal(text="other_func()") + wrapper += nodes.Text(" ") + wrapper += nodes.literal(text='build_badge("", style="inline-icon", icon="✏️")') result.append(wrapper) + result.append(_section("Outline (gp-sphinx-badge--outline)")) + result.append( + _row( + build_badge("outline", fill="outline", tooltip="Outline, no bg"), + build_badge( + "outline + icon", + icon="\U0001f50d", + fill="outline", + tooltip="Outline with icon left", + ), + build_badge( + "outline + icon right", + icon="\U0001f50d", + fill="outline", + classes=[SAB.ICON_RIGHT], + tooltip="Outline with icon right", + ), + label='build_badge("outline", fill="outline")', + ) + ) + + result.append( + _section( + "Dense (gp-sphinx-badge--dense) — compact bordered, dotted underline" + ) + ) + result.append( + _row( + build_badge( + "dense", + tooltip="Dense, no icon", + classes=[SAB.DENSE, SAB.TYPE_FUNCTION], + ), + build_badge( + "dense + icon left", + icon="\U0001f50d", + tooltip="Dense with icon left", + classes=[SAB.DENSE, SAB.TYPE_FUNCTION], + ), + build_badge( + "dense + icon right", + icon="\U0001f50d", + tooltip="Dense with icon right", + classes=[SAB.DENSE, SAB.ICON_RIGHT, SAB.TYPE_FUNCTION], + ), + build_badge( + "", + style="icon-only", + icon="\U0001f50d", + tooltip="Dense icon-only (icon-only overrides display)", + classes=[SAB.TYPE_FUNCTION], + ), + label="SAB.DENSE + icon variants", + ) + ) + + result.append(_section("Dense + outline")) + result.append( + _row( + build_badge( + "dense outline", + fill="outline", + classes=[SAB.DENSE], + tooltip="Dense outline", + ), + build_badge( + "dense outline + icon left", + icon="\U0001f50d", + fill="outline", + classes=[SAB.DENSE], + tooltip="Dense outline with icon left", + ), + build_badge( + "dense outline + icon right", + icon="\U0001f50d", + fill="outline", + classes=[SAB.DENSE, SAB.ICON_RIGHT], + tooltip="Dense outline with icon right", + ), + label="SAB.DENSE + outline", + ) + ) + + # ── Underline control ──────────────────────────────────── + + result.append(_section("Underline control modifiers")) + result.append( + _row( + build_badge( + "dense default", + icon="\U0001f50d", + tooltip="Dense default — underline dotted (icon has no underline)", + classes=[SAB.DENSE, SAB.TYPE_FUNCTION], + ), + build_badge( + "dense icon right", + icon="\U0001f50d", + tooltip="Dense icon-right — icon has no underline", + classes=[SAB.DENSE, SAB.ICON_RIGHT, SAB.TYPE_FUNCTION], + ), + build_badge( + "no underline", + icon="\U0001f50d", + tooltip="Dense + gp-sphinx-badge--underline-none", + classes=[SAB.DENSE, SAB.NO_UNDERLINE, SAB.TYPE_CLASS], + ), + build_badge( + "solid", + icon="\U0001f50d", + tooltip="Dense + gp-sphinx-badge--underline-solid", + classes=[SAB.DENSE, SAB.UNDERLINE_SOLID, SAB.TYPE_FIXTURE], + ), + build_badge( + "dotted (opt-in)", + icon="\U0001f50d", + tooltip="Standard pill + gp-sphinx-badge--underline-dotted", + classes=[SAB.UNDERLINE_DOTTED, SAB.TYPE_CONFIG], + ), + build_badge( + "solid (opt-in)", + icon="\U0001f50d", + tooltip="Standard pill + gp-sphinx-badge--underline-solid", + classes=[SAB.UNDERLINE_SOLID, SAB.TYPE_DIRECTIVE], + ), + label=("SAB.NO_UNDERLINE / SAB.UNDERLINE_SOLID / SAB.UNDERLINE_DOTTED"), + ) + ) + + # ── All sizes ──────────────────────────────────────────── + + result.append(_section("All sizes — standard pill")) + result.append( + _row( + build_badge( + "xxs", + size="xxs", + tooltip="Extra-extra small", + classes=[SAB.TYPE_FUNCTION], + ), + build_badge( + "xxs+icon", + size="xxs", + icon="\U0001f50d", + tooltip="Extra-extra small with icon", + classes=[SAB.TYPE_FUNCTION], + ), + build_badge( + "xs", + size="xs", + tooltip="Extra small", + classes=[SAB.TYPE_FUNCTION], + ), + build_badge( + "xs+icon", + size="xs", + icon="\U0001f50d", + tooltip="Extra small with icon", + classes=[SAB.TYPE_FUNCTION], + ), + build_badge( + "sm", + size="sm", + tooltip="Small", + classes=[SAB.TYPE_CLASS], + ), + build_badge( + "sm+icon", + size="sm", + icon="\U0001f50d", + tooltip="Small with icon", + classes=[SAB.TYPE_CLASS], + ), + build_badge( + "md", + size="md", + tooltip="Medium (alias: default)", + classes=[SAB.TYPE_METHOD], + ), + build_badge( + "md+icon", + size="md", + icon="\U0001f50d", + tooltip="Medium with icon", + classes=[SAB.TYPE_METHOD], + ), + build_badge( + "default", tooltip="Default size (= md)", classes=[SAB.TYPE_METHOD] + ), + build_badge( + "default+icon", + icon="\U0001f50d", + tooltip="Default with icon (= md)", + classes=[SAB.TYPE_METHOD], + ), + build_badge( + "lg", + size="lg", + tooltip="Large", + classes=[SAB.TYPE_FIXTURE], + ), + build_badge( + "lg+icon", + size="lg", + icon="\U0001f50d", + tooltip="Large with icon", + classes=[SAB.TYPE_FIXTURE], + ), + build_badge( + "xl", + size="xl", + tooltip="Extra large", + classes=[SAB.TYPE_CONFIG], + ), + build_badge( + "xl+icon", + size="xl", + icon="\U0001f50d", + tooltip="Extra large with icon", + classes=[SAB.TYPE_CONFIG], + ), + label="xxs / xs / sm / md / default / lg / xl", + ) + ) + + result.append(_section("All sizes — dense")) + result.append( + _row( + build_badge( + "xxs", + size="xxs", + tooltip="Extra-extra small dense", + classes=[SAB.DENSE, SAB.TYPE_FUNCTION], + ), + build_badge( + "xxs+icon", + size="xxs", + icon="\U0001f50d", + tooltip="Extra-extra small dense + icon", + classes=[SAB.DENSE, SAB.TYPE_FUNCTION], + ), + build_badge( + "xs", + size="xs", + tooltip="Extra small dense", + classes=[SAB.DENSE, SAB.TYPE_FUNCTION], + ), + build_badge( + "xs+icon", + size="xs", + icon="\U0001f50d", + tooltip="Extra small dense + icon", + classes=[SAB.DENSE, SAB.TYPE_FUNCTION], + ), + build_badge( + "sm", + size="sm", + tooltip="Small dense", + classes=[SAB.DENSE, SAB.TYPE_CLASS], + ), + build_badge( + "sm+icon", + size="sm", + icon="\U0001f50d", + tooltip="Small dense + icon", + classes=[SAB.DENSE, SAB.TYPE_CLASS], + ), + build_badge( + "md", + size="md", + tooltip="Medium dense (alias: default)", + classes=[SAB.DENSE, SAB.TYPE_METHOD], + ), + build_badge( + "md+icon", + size="md", + icon="\U0001f50d", + tooltip="Medium dense + icon", + classes=[SAB.DENSE, SAB.TYPE_METHOD], + ), + build_badge( + "default", + tooltip="Default dense (= md)", + classes=[SAB.DENSE, SAB.TYPE_METHOD], + ), + build_badge( + "default+icon", + icon="\U0001f50d", + tooltip="Default dense + icon (= md)", + classes=[SAB.DENSE, SAB.TYPE_METHOD], + ), + build_badge( + "lg", + size="lg", + tooltip="Large dense", + classes=[SAB.DENSE, SAB.TYPE_FIXTURE], + ), + build_badge( + "lg+icon", + size="lg", + icon="\U0001f50d", + tooltip="Large dense + icon", + classes=[SAB.DENSE, SAB.TYPE_FIXTURE], + ), + build_badge( + "xl", + size="xl", + tooltip="Extra large dense", + classes=[SAB.DENSE, SAB.TYPE_CONFIG], + ), + build_badge( + "xl+icon", + size="xl", + icon="\U0001f50d", + tooltip="Extra large dense + icon", + classes=[SAB.DENSE, SAB.TYPE_CONFIG], + ), + label="xxs / xs / sm / md / default / lg / xl (gp-sphinx-badge--dense)", + ) + ) + + # ── Icon positions with colour — representative rows ────── + + result.append( + _section("Icon positions — standard (no icon / left / right / icon-only)") + ) + for colour_label, colour_class, icon, colour_tooltip in [ + ("function", SAB.TYPE_FUNCTION, "\U0001f4e6", "Python function"), + ("fixture", SAB.TYPE_FIXTURE, "\U0001f9ea", "pytest fixture"), + ("config", SAB.TYPE_CONFIG, "\u2699\ufe0f", "Sphinx config"), + ("directive", SAB.TYPE_DIRECTIVE, "\U0001f4d1", "Docutils directive"), + ]: + row = nodes.paragraph() + row += build_badge( + colour_label, + tooltip=f"{colour_tooltip} — no icon", + classes=[SAB.BADGE_TYPE, colour_class], + ) + row += nodes.Text(" ") + row += build_badge( + colour_label, + icon=icon, + tooltip=f"{colour_tooltip} — icon left", + classes=[SAB.BADGE_TYPE, colour_class], + ) + row += nodes.Text(" ") + row += build_badge( + colour_label, + icon=icon, + tooltip=f"{colour_tooltip} — icon right", + classes=[SAB.BADGE_TYPE, colour_class, SAB.ICON_RIGHT], + ) + row += nodes.Text(" ") + row += build_badge( + "", + style="icon-only", + icon=icon, + tooltip=f"{colour_tooltip} — icon-only", + classes=[colour_class], + ) + row += nodes.Text(" ") + row += nodes.literal( + text=f"{colour_label}: none / left / right / icon-only" + ) + result.append(row) + + result.append(_section("Icon positions — dense")) + for colour_label, colour_class, icon, colour_tooltip in [ + ("function", SAB.TYPE_FUNCTION, "\U0001f4e6", "Python function"), + ("fixture", SAB.TYPE_FIXTURE, "\U0001f9ea", "pytest fixture"), + ("config", SAB.TYPE_CONFIG, "\u2699\ufe0f", "Sphinx config"), + ("directive", SAB.TYPE_DIRECTIVE, "\U0001f4d1", "Docutils directive"), + ]: + row = nodes.paragraph() + row += build_badge( + colour_label, + tooltip=f"{colour_tooltip} dense — no icon", + classes=[SAB.DENSE, SAB.BADGE_TYPE, colour_class], + ) + row += nodes.Text(" ") + row += build_badge( + colour_label, + icon=icon, + tooltip=f"{colour_tooltip} dense — icon left", + classes=[SAB.DENSE, SAB.BADGE_TYPE, colour_class], + ) + row += nodes.Text(" ") + row += build_badge( + colour_label, + icon=icon, + tooltip=f"{colour_tooltip} dense — icon right", + classes=[ + SAB.DENSE, + SAB.BADGE_TYPE, + colour_class, + SAB.ICON_RIGHT, + ], + ) + row += nodes.Text(" ") + row += nodes.literal(text=f"{colour_label} (dense): none / left / right") + result.append(row) + + # ── Badge group ────────────────────────────────────────── + result.append(_section("Badge group")) group = build_badge_group( [ @@ -131,7 +569,9 @@ def _row(*badge_nodes: nodes.Node, label: str = "") -> nodes.paragraph: ] ) ) - heading_container = nodes.container(classes=["sab-demo-toolbar-heading"]) + heading_container = nodes.container( + classes=["gp-sphinx-badge-demo-toolbar-heading"] + ) heading_p = nodes.paragraph() heading_p += nodes.strong(text="Example heading ") heading_p += tb @@ -139,11 +579,301 @@ def _row(*badge_nodes: nodes.Node, label: str = "") -> nodes.paragraph: result.append(heading_container) result.append(_row(label="build_toolbar(build_badge_group([...]))")) + # ── Python API type palette (standard + dense) ─────────── + + result.append(_section("Python API types (gp-sphinx-badge--type-*)")) + py_types = [ + ("function", SAB.TYPE_FUNCTION, "Python function"), + ("class", SAB.TYPE_CLASS, "Python class"), + ("method", SAB.TYPE_METHOD, "Instance method"), + ("property", SAB.TYPE_PROPERTY, "Python property"), + ("attribute", SAB.TYPE_ATTRIBUTE, "Class or instance attribute"), + ("data", SAB.TYPE_DATA, "Module-level data"), + ("exception", SAB.TYPE_EXCEPTION, "Exception class"), + ("type alias", SAB.TYPE_TYPEALIAS, "Type alias"), + ("module", SAB.TYPE_MODULE, "Python module"), + ] + type_row = nodes.paragraph() + for label, css_class, tooltip in py_types: + type_row += build_badge( + label, + tooltip=tooltip, + classes=[SAB.BADGE_TYPE, css_class], + ) + type_row += nodes.Text(" ") + result.append(type_row) + + result.append( + _section("Python API types — dense variant (gp-sphinx-badge--dense)") + ) + type_dense_row = nodes.paragraph() + for label, css_class, tooltip in py_types: + type_dense_row += build_badge( + label, + tooltip=tooltip, + classes=[SAB.DENSE, SAB.BADGE_TYPE, css_class], + ) + type_dense_row += nodes.Text(" ") + result.append(type_dense_row) + + result.append( + _section("Python API modifiers (gp-sphinx-badge--mod-*, outlined)") + ) + py_mods = [ + ("async", SAB.MOD_ASYNC, "Asynchronous"), + ("classmethod", SAB.MOD_CLASSMETHOD, "Class method"), + ("staticmethod", SAB.MOD_STATICMETHOD, "Static method"), + ("abstract", SAB.MOD_ABSTRACT, "Abstract"), + ("final", SAB.MOD_FINAL, "Final"), + ] + mod_row = nodes.paragraph() + for label, css_class, tooltip in py_mods: + mod_row += build_badge( + label, + tooltip=tooltip, + classes=[SAB.BADGE_MOD, css_class], + fill="outline", + ) + mod_row += nodes.Text(" ") + result.append(mod_row) + + result.append(_section("Python API modifiers — dense variant")) + mod_dense_row = nodes.paragraph() + for label, css_class, tooltip in py_mods: + mod_dense_row += build_badge( + label, + tooltip=tooltip, + classes=[SAB.DENSE, SAB.BADGE_MOD, css_class], + fill="outline", + ) + mod_dense_row += nodes.Text(" ") + result.append(mod_dense_row) + + # ── pytest fixture palette ─────────────────────────────── + + result.append(_section("pytest fixture types (gp-sphinx-badge--type-fixture)")) + result.append( + _row( + build_badge( + "fixture", + tooltip="pytest fixture", + classes=[SAB.BADGE_FIXTURE, SAB.TYPE_FIXTURE], + ), + label="SAB.TYPE_FIXTURE — standard", + ) + ) + result.append( + _row( + build_badge( + "fixture", + tooltip="pytest fixture", + classes=[SAB.DENSE, SAB.BADGE_FIXTURE, SAB.TYPE_FIXTURE], + ), + label="SAB.TYPE_FIXTURE — dense", + ) + ) + + result.append(_section("pytest fixture scopes (gp-sphinx-badge--scope-*)")) + scope_row = nodes.paragraph() + scope_dense_row = nodes.paragraph() + for scope in ("session", "module", "class"): + scope_row += build_badge( + scope, + tooltip=f"Scope: {scope}", + classes=[SAB.BADGE_SCOPE, SAB.scope(scope)], + ) + scope_row += nodes.Text(" ") + scope_dense_row += build_badge( + scope, + tooltip=f"Scope: {scope}", + classes=[SAB.DENSE, SAB.BADGE_SCOPE, SAB.scope(scope)], + ) + scope_dense_row += nodes.Text(" ") + result.append(scope_row) + result.append(scope_dense_row) + + result.append(_section("pytest fixture kinds / states (outlined)")) + state_row = nodes.paragraph() + state_dense_row = nodes.paragraph() + states = [ + ("factory", SAB.STATE_FACTORY, "Factory"), + ("override", SAB.STATE_OVERRIDE, "Override hook"), + ("auto", SAB.STATE_AUTOUSE, "Autouse"), + ("deprecated", SAB.STATE_DEPRECATED, "Deprecated"), + ] + for label, css_class, tooltip in states: + fill: t.Literal["filled", "outline"] = ( + "filled" if label == "deprecated" else "outline" + ) + state_row += build_badge( + label, + tooltip=tooltip, + classes=[SAB.BADGE_STATE, css_class], + fill=fill, + ) + state_row += nodes.Text(" ") + state_dense_row += build_badge( + label, + tooltip=tooltip, + classes=[SAB.DENSE, SAB.BADGE_STATE, css_class], + fill=fill, + ) + state_dense_row += nodes.Text(" ") + result.append(state_row) + result.append(state_dense_row) + + # ── Sphinx config palette ──────────────────────────────── + + result.append( + _section( + "Sphinx config" + " (gp-sphinx-badge--type-config / gp-sphinx-badge--mod-rebuild)" + ) + ) + result.append( + _row( + build_badge( + "config", + tooltip="Sphinx config value", + classes=[SAB.TYPE_CONFIG], + ), + build_badge( + "env", + tooltip="Rebuild mode: env", + classes=[SAB.MOD_REBUILD], + fill="outline", + ), + build_badge( + "html", + tooltip="Rebuild mode: html", + classes=[SAB.MOD_REBUILD], + fill="outline", + ), + label="standard", + ) + ) + result.append( + _row( + build_badge( + "config", + tooltip="Sphinx config value", + classes=[SAB.DENSE, SAB.TYPE_CONFIG], + ), + build_badge( + "env", + tooltip="Rebuild mode: env", + classes=[SAB.DENSE, SAB.MOD_REBUILD], + fill="outline", + ), + build_badge( + "html", + tooltip="Rebuild mode: html", + classes=[SAB.DENSE, SAB.MOD_REBUILD], + fill="outline", + ), + label="dense", + ) + ) + + # ── docutils palette ───────────────────────────────────── + + result.append( + _section("docutils (gp-sphinx-badge--type-directive / role / option)") + ) + result.append( + _row( + build_badge( + "directive", + tooltip="Docutils directive", + classes=[SAB.TYPE_DIRECTIVE], + ), + build_badge( + "role", + tooltip="Docutils role", + classes=[SAB.TYPE_ROLE], + ), + build_badge( + "option", + tooltip="Docutils option", + classes=[SAB.TYPE_OPTION], + ), + label="standard", + ) + ) + result.append( + _row( + build_badge( + "directive", + tooltip="Docutils directive", + classes=[SAB.DENSE, SAB.TYPE_DIRECTIVE], + ), + build_badge( + "role", + tooltip="Docutils role", + classes=[SAB.DENSE, SAB.TYPE_ROLE], + ), + build_badge( + "option", + tooltip="Docutils option", + classes=[SAB.DENSE, SAB.TYPE_OPTION], + ), + label="dense", + ) + ) + + # ── Package metadata badges ────────────────────────────── + + result.append(_section("Package metadata — maturity")) + result.append( + _row( + _maturity_badge("Alpha"), + _maturity_badge("Beta"), + label=( + "gp-sphinx-badge--meta-alpha / gp-sphinx-badge--meta-beta (filled)" + ), + ) + ) + + result.append(_section("Package metadata — link badges (outline)")) + link_row = nodes.paragraph() + link_row += _link_badge( + "GitHub", + "https://github.com/git-pull/gp-sphinx", + ) + link_row += nodes.Text(" ") + link_row += _link_badge( + "PyPI", + "https://pypi.org/project/sphinx-ux-badges/", + ) + link_row += nodes.Text(" ") + link_row += nodes.literal( + text=( + "_link_badge(label, url) — gp-sphinx-badge" + " gp-sphinx-badge--outline gp-sphinx-badge--meta-link " + ), + ) + result.append(link_row) + + result.append(_section("Package metadata — full header row")) + full_row = nodes.paragraph() + full_row += _maturity_badge("Alpha") + full_row += nodes.Text(" ") + full_row += _link_badge( + "GitHub", + "https://github.com/git-pull/gp-sphinx", + ) + full_row += nodes.Text(" ") + full_row += _link_badge( + "PyPI", + "https://pypi.org/project/sphinx-ux-badges/", + ) + result.append(full_row) + return result def setup(app: Sphinx) -> dict[str, t.Any]: - """Register the ``sab-badge-demo`` directive.""" - app.add_directive("sab-badge-demo", BadgeDemoDirective) + """Register the ``gp-sphinx-badge-demo`` directive.""" + app.add_directive("gp-sphinx-badge-demo", BadgeDemoDirective) app.add_css_file("css/sab_demo.css") return {"version": "0.1", "parallel_read_safe": True} diff --git a/docs/_ext/sab_meta.py b/docs/_ext/sab_meta.py new file mode 100644 index 00000000..521f9421 --- /dev/null +++ b/docs/_ext/sab_meta.py @@ -0,0 +1,103 @@ +"""SAB metadata badge directive for package docs pages. + +Replaces sphinx-design ``{bdg-warning-line}`Alpha``` and +``{bdg-link-secondary-line}`GitHub ``` roles with SAB-native +``BadgeNode`` badges so the entire badge system is unified. + +Usage +----- +In any ``docs/packages/*.md`` page, replace the sphinx-design role line:: + + {bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` … + +with:: + + ```{gp-sphinx-package-meta} sphinx-autodoc-api-style + ``` + +The directive looks up the package's maturity, GitHub URL, and PyPI URL +from the workspace ``pyproject.toml`` data, then emits three SAB badges +as an inline paragraph. +""" + +from __future__ import annotations + +import typing as t + +import package_reference +from docutils import nodes +from sphinx.application import Sphinx +from sphinx.util.docutils import SphinxDirective + +from sphinx_ux_badges import SAB, BadgeNode, build_badge + +_MATURITY_CLASS: dict[str, str] = { + "Alpha": SAB.META_ALPHA, + "Beta": SAB.META_BETA, +} + + +def _maturity_badge(maturity: str) -> BadgeNode: + """Return a filled maturity BadgeNode (Alpha = amber, Beta = green).""" + colour = _MATURITY_CLASS.get(maturity, SAB.META_LINK) + return build_badge(maturity, tooltip=f"Maturity: {maturity}", classes=[colour]) + + +def _link_badge(label: str, url: str) -> nodes.reference: + """Return an anchor node styled as an outline SAB badge.""" + ref = nodes.reference("", label, refuri=url, internal=False) + # Apply badge classes directly on the anchor node so it renders as + # label + ref["classes"].extend([SAB.BADGE, SAB.OUTLINE, SAB.META_LINK]) + return ref + + +def _package_meta_nodes(package_name: str) -> list[nodes.Node]: + """Return inline badge nodes for a workspace package.""" + packages = {p["name"]: p for p in package_reference.workspace_packages()} + pkg = packages.get(package_name) + if pkg is None: + msg = nodes.inline(text=f"[unknown package: {package_name!r}]") + return [msg] + + maturity = pkg.get("maturity", "Unknown") + repo = pkg.get("repository", "") + github_url = repo if repo else "https://github.com/git-pull/gp-sphinx" + pypi_url = f"https://pypi.org/project/{package_name}/" + + badge_nodes: list[nodes.Node] = [ + _maturity_badge(maturity), + nodes.Text(" "), + _link_badge("GitHub", github_url), + nodes.Text(" "), + _link_badge("PyPI", pypi_url), + ] + return badge_nodes + + +class PackageMetaBadgesDirective(SphinxDirective): + """Emit maturity + GitHub + PyPI SAB badges for a workspace package. + + The single required argument is the distribution name as it appears in + ``pyproject.toml``, e.g. ``sphinx-autodoc-api-style``. + """ + + has_content = False + required_arguments = 1 + optional_arguments = 0 + + def run(self) -> list[nodes.Node]: + """Build badge nodes from workspace package metadata.""" + package_name = self.arguments[0].strip() + badge_nodes = _package_meta_nodes(package_name) + para = nodes.paragraph() + for n in badge_nodes: + para += n + return [para] + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the ``gp-sphinx-package-meta`` directive.""" + app.add_directive("gp-sphinx-package-meta", PackageMetaBadgesDirective) + return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 8bd6b6e4..804573e7 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -374,15 +374,15 @@ body[data-theme="dark"] { * packaged theme CSS so previews match downstream builds. * ────────────────────────────────────────────────────────── */ :root { - --badge-safety-readonly-bg: #1f7a3f; - --badge-safety-readonly-border: #2a8d4d; - --badge-safety-readonly-text: #f3fff7; - --badge-safety-mutating-bg: #b96a1a; - --badge-safety-mutating-border: #cf7a23; - --badge-safety-mutating-text: #fff8ef; - --badge-safety-destructive-bg: #b4232c; - --badge-safety-destructive-border: #cb3640; - --badge-safety-destructive-text: #fff5f5; + --gp-sphinx-fastmcp-safety-readonly-bg: #1f7a3f; + --gp-sphinx-fastmcp-safety-readonly-border: #2a8d4d; + --gp-sphinx-fastmcp-safety-readonly-text: #f3fff7; + --gp-sphinx-fastmcp-safety-mutating-bg: #b96a1a; + --gp-sphinx-fastmcp-safety-mutating-border: #cf7a23; + --gp-sphinx-fastmcp-safety-mutating-text: #fff8ef; + --gp-sphinx-fastmcp-safety-destructive-bg: #b4232c; + --gp-sphinx-fastmcp-safety-destructive-border: #cb3640; + --gp-sphinx-fastmcp-safety-destructive-text: #fff5f5; } h2:has(> .sd-badge[role="note"][aria-label^="Safety tier:"]), @@ -409,21 +409,21 @@ h4:has(> .sd-badge[role="note"][aria-label^="Safety tier:"]) { } .sd-badge.sd-bg-success[role="note"][aria-label^="Safety tier:"] { - background-color: var(--badge-safety-readonly-bg) !important; - color: var(--badge-safety-readonly-text) !important; - border-color: var(--badge-safety-readonly-border); + background-color: var(--gp-sphinx-fastmcp-safety-readonly-bg) !important; + color: var(--gp-sphinx-fastmcp-safety-readonly-text) !important; + border-color: var(--gp-sphinx-fastmcp-safety-readonly-border); } .sd-badge.sd-bg-warning[role="note"][aria-label^="Safety tier:"] { - background-color: var(--badge-safety-mutating-bg) !important; - color: var(--badge-safety-mutating-text) !important; - border-color: var(--badge-safety-mutating-border); + background-color: var(--gp-sphinx-fastmcp-safety-mutating-bg) !important; + color: var(--gp-sphinx-fastmcp-safety-mutating-text) !important; + border-color: var(--gp-sphinx-fastmcp-safety-mutating-border); } .sd-badge.sd-bg-danger[role="note"][aria-label^="Safety tier:"] { - background-color: var(--badge-safety-destructive-bg) !important; - color: var(--badge-safety-destructive-text) !important; - border-color: var(--badge-safety-destructive-border); + background-color: var(--gp-sphinx-fastmcp-safety-destructive-bg) !important; + color: var(--gp-sphinx-fastmcp-safety-destructive-text) !important; + border-color: var(--gp-sphinx-fastmcp-safety-destructive-border); } .sd-badge.sd-bg-success[role="note"][aria-label^="Safety tier:"]::before { diff --git a/docs/_static/css/sab_demo.css b/docs/_static/css/sab_demo.css index 6c78b185..045917e2 100644 --- a/docs/_static/css/sab_demo.css +++ b/docs/_static/css/sab_demo.css @@ -1,4 +1,4 @@ -.sab-demo-toolbar-heading > p { +.gp-sphinx-badge-demo-toolbar-heading > p { display: flex; align-items: center; gap: 0.45rem; diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..d540dff4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,81 @@ +(architecture)= + +# Architecture + +Twelve workspace packages in three tiers. Lower layers never depend on +higher ones — domain packages consume shared infrastructure, and the +presentation layer wires everything together for downstream projects. + +The sidebar groups these twelve packages into four navigation buckets +(Domain Packages, UX, Utils, Internal) — a reader-facing grouping that +is orthogonal to the dependency-ordered tier map below. + +## Tier 1: Shared infrastructure + +The rendering pipeline that all domain packages consume: + +::::{grid} 1 1 3 3 +:gutter: 2 + +:::{grid-item-card} sphinx-ux-badges +:link: packages/sphinx-ux-badges +:link-type: doc + +Badge primitives, colour palette, and CSS infrastructure. +All badge colours live in one place (`SAB.*` constants). +::: + +:::{grid-item-card} sphinx-ux-autodoc-layout +:link: packages/sphinx-ux-autodoc-layout +:link-type: doc + +Structural presenter for `api-*` entry components. +Parameter folding, managed signatures, card regions. +::: + +:::{grid-item-card} sphinx-autodoc-typehints-gp +:link: packages/sphinx-autodoc-typehints-gp +:link-type: doc + +Annotation normalization and type rendering. +Replaces `sphinx-autodoc-typehints` + `sphinx.ext.napoleon`. +::: + +:::: + +## Tier 2: Domain packages + +Domain-specific autodoc extensions that consume Tier 1 and add +project-specific rendering logic: + +| Package | Domain | Directives | +|---------|--------|------------| +| {doc}`sphinx-autodoc-api-style ` | Standard Python | `autofunction`, `autoclass`, `automodule` | +| {doc}`sphinx-autodoc-argparse ` | Custom `argparse` domain — programs, options, subcommands, positionals | `argparse` | +| {doc}`sphinx-autodoc-docutils ` | docutils | `autodirective`, `autorole` | +| {doc}`sphinx-autodoc-fastmcp ` | FastMCP tools | `fastmcp-tool`, `fastmcp-tool-summary` | +| {doc}`sphinx-autodoc-pytest-fixtures ` | pytest fixtures (extends `py` domain) | `autofixture`, `autofixture-index` | +| {doc}`sphinx-autodoc-sphinx ` | Sphinx config | `autoconfigvalue`, `autoconfigvalues` | + +Each domain package calls `app.setup_extension()` to auto-register its +infrastructure dependencies — downstream projects only need to add the +domain package to their `extensions` list. + +## Tier 3: Theme and coordinator + +| Package | Role | +|---------|------| +| {doc}`gp-sphinx ` | Coordinator. `merge_sphinx_config()` wires up the full stack. | +| {doc}`sphinx-gp-theme ` | Furo-based theme with CSS variables and SPA navigation. | +| {doc}`sphinx-fonts ` | IBM Plex via Fontsource — preloaded web fonts. | + +## How the tiers connect + +Every domain package shares the same badge palette, the same componentized +HTML output structure, and the same type annotation pipeline — so Python +APIs, pytest fixtures, Sphinx config values, docutils directives, and +FastMCP tools all look like they belong together. + +This is the **one autodoc design system** principle: a change to the shared +infrastructure propagates instantly and consistently across all six +domain packages. diff --git a/docs/conf.py b/docs/conf.py index 7fb6cb20..c5c0ee8f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,8 +10,8 @@ project_root = cwd.parent sys.path.insert(0, str(project_root / "packages" / "gp-sphinx" / "src")) sys.path.insert(0, str(project_root / "packages" / "sphinx-fonts" / "src")) -sys.path.insert(0, str(project_root / "packages" / "sphinx-gptheme" / "src")) -sys.path.insert(0, str(project_root / "packages" / "sphinx-argparse-neo" / "src")) +sys.path.insert(0, str(project_root / "packages" / "sphinx-gp-theme" / "src")) +sys.path.insert(0, str(project_root / "packages" / "sphinx-autodoc-argparse" / "src")) sys.path.insert( 0, str(project_root / "packages" / "sphinx-autodoc-pytest-fixtures" / "src"), @@ -21,7 +21,19 @@ sys.path.insert(0, str(project_root / "packages" / "sphinx-autodoc-api-style" / "src")) sys.path.insert( 0, - str(project_root / "packages" / "sphinx-autodoc-badges" / "src"), + str(project_root / "packages" / "sphinx-ux-badges" / "src"), +) +sys.path.insert( + 0, + str(project_root / "packages" / "sphinx-autodoc-fastmcp" / "src"), +) +sys.path.insert( + 0, + str(project_root / "packages" / "sphinx-ux-autodoc-layout" / "src"), +) +sys.path.insert( + 0, + str(project_root / "packages" / "sphinx-autodoc-typehints-gp" / "src"), ) sys.path.insert(0, str(cwd / "_ext")) # docs demo modules @@ -43,13 +55,21 @@ extra_extensions=[ "package_reference", "sab_demo", - "sphinx_autodoc_badges", + "sab_meta", + "sphinx_ux_badges", "sphinx_autodoc_api_style", "sphinx_autodoc_pytest_fixtures", "sphinx_autodoc_docutils", + "sphinx_autodoc_fastmcp", "sphinx_autodoc_sphinx", - "sphinx_argparse_neo.exemplar", + "sphinx_autodoc_argparse.exemplar", + "sphinx_ux_autodoc_layout", ], + fastmcp_tool_modules=["fastmcp_demo_tools"], + fastmcp_area_map={"fastmcp_demo_tools": "packages/sphinx-autodoc-fastmcp"}, + fastmcp_collector_mode="introspect", + api_layout_enabled=True, + api_collapsed_threshold=10, pytest_fixture_lint_level="none", rediraffe_redirects="redirects.txt", intersphinx_mapping=intersphinx_mapping, diff --git a/docs/configuration.md b/docs/configuration.md index f3bcb791..d90f9477 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -76,7 +76,7 @@ The returned config includes a `setup(app)` function from | Action | Effect | | --- | --- | -| `app.add_js_file("js/spa-nav.js", loading_method="defer")` | Registers the bundled SPA navigation script from `sphinx-gptheme` | +| `app.add_js_file("js/spa-nav.js", loading_method="defer")` | Registers the bundled SPA navigation script from `sphinx-gp-theme` | | `app.connect("build-finished", remove_tabs_js)` | Removes `_static/tabs.js` after HTML builds as a `sphinx-inline-tabs` workaround | ## Always-set coordinator values @@ -100,7 +100,7 @@ These are injected even though they are not exposed as `DEFAULT_*` constants: | Constant | Value | | --- | --- | -| `DEFAULT_EXTENSIONS` | `["sphinx.ext.autodoc", "sphinx_fonts", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", "sphinx.ext.todo", "sphinx.ext.napoleon", "sphinx_inline_tabs", "sphinx_copybutton", "sphinxext.opengraph", "sphinxext.rediraffe", "sphinx_design", "myst_parser", "linkify_issues"]` | +| `DEFAULT_EXTENSIONS` | `["sphinx.ext.autodoc", "sphinx_fonts", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints_gp", "sphinx.ext.todo", "sphinx_inline_tabs", "sphinx_copybutton", "sphinxext.opengraph", "sphinxext.rediraffe", "sphinx_design", "myst_parser", "linkify_issues"]` | | `DEFAULT_SOURCE_SUFFIX` | `{".rst": "restructuredtext", ".md": "markdown"}` | | `DEFAULT_MYST_EXTENSIONS` | `["colon_fence", "substitution", "replacements", "strikethrough", "linkify"]` | | `DEFAULT_MYST_HEADING_ANCHORS` | `4` | @@ -111,7 +111,7 @@ These are injected even though they are not exposed as `DEFAULT_*` constants: | Constant | Value | | --- | --- | -| `DEFAULT_THEME` | `"sphinx-gptheme"` | +| `DEFAULT_THEME` | `"sphinx-gp-theme"` | | `DEFAULT_THEME_OPTIONS` | footer GitHub icon, `source_repository=""`, `source_branch="main"`, `source_directory="docs/"` | ### Font defaults @@ -138,20 +138,18 @@ These are injected even though they are not exposed as `DEFAULT_*` constants: | Constant | Value | | --- | --- | -| `DEFAULT_AUTOCLASS_CONTENT` | `"both"` | +| `DEFAULT_AUTOCLASS_CONTENT` | `"class"` | | `DEFAULT_AUTODOC_MEMBER_ORDER` | `"bysource"` | | `DEFAULT_AUTODOC_CLASS_SIGNATURE` | `"separated"` | | `DEFAULT_AUTODOC_TYPEHINTS` | `"description"` | | `DEFAULT_TOC_OBJECT_ENTRIES_SHOW_PARENTS` | `"hide"` | | `DEFAULT_AUTODOC_OPTIONS` | `{"undoc-members": True, "members": True, "private-members": True, "show-inheritance": True, "member-order": "bysource"}` | -### Napoleon and warning defaults +### Warning defaults | Constant | Value | | --- | --- | -| `DEFAULT_NAPOLEON_GOOGLE_DOCSTRING` | `True` | -| `DEFAULT_NAPOLEON_INCLUDE_INIT_WITH_DOC` | `False` | -| `DEFAULT_SUPPRESS_WARNINGS` | `["sphinx_autodoc_typehints.forward_reference"]` | +| `DEFAULT_SUPPRESS_WARNINGS` | `[]` | ## Parameter interactions diff --git a/docs/gallery.md b/docs/gallery.md new file mode 100644 index 00000000..4c7170f5 --- /dev/null +++ b/docs/gallery.md @@ -0,0 +1,188 @@ +(gallery)= + +# Gallery + +Every example on this page is **rendered live** from the same extensions and +theme your project gets out of the box. Nothing is mocked — the output below +is the real autodoc pipeline. + +--- + +## Python API + +Badges, type hints, and card layout working together on standard Python domain +directives. + +```{py:module} gp_demo_api +:no-index: +``` + +### Functions + +```{eval-rst} +.. autofunction:: gp_demo_api.demo_function + :noindex: +``` + +```{eval-rst} +.. autofunction:: gp_demo_api.demo_async_function + :noindex: +``` + +```{eval-rst} +.. autofunction:: gp_demo_api.demo_deprecated_function + :noindex: +``` + +### Module data + +```{eval-rst} +.. autodata:: gp_demo_api.DEMO_CONSTANT + :noindex: +``` + +### Exceptions + +```{eval-rst} +.. autoexception:: gp_demo_api.DemoError + :noindex: +``` + +### Classes + +```{eval-rst} +.. autoclass:: gp_demo_api.DemoClass + :members: + :undoc-members: + :noindex: +``` + +### Abstract base classes + +```{eval-rst} +.. autoclass:: gp_demo_api.DemoAbstractBase + :members: + :noindex: +``` + +--- + +## Layout regions and parameter folding + +Large parameter lists fold automatically. The class below has 13 parameters +(above the default threshold of 10), so its field list is collapsed into a +disclosure widget. + +```{py:module} api_demo_layout +:no-index: +``` + +### Class with members (regions + fold) + +```{eval-rst} +.. autoclass:: api_demo_layout.LayoutDemo + :members: + :noindex: +``` + +### Small function (no fold) + +```{eval-rst} +.. autofunction:: api_demo_layout.compact_function + :noindex: +``` + +--- + +## Badge palette + +The full badge system — types, modifiers, sizes, and variants — rendered by +the real `build_badge` / `build_badge_group` / `build_toolbar` API: + +```{gp-sphinx-badge-demo} +``` + +--- + +## FastMCP tool cards + +Tool documentation with safety badges and parameter tables. + +```{eval-rst} +.. fastmcp-tool:: fastmcp_demo_tools.list_sessions + +.. fastmcp-tool:: fastmcp_demo_tools.create_session + +.. fastmcp-tool:: fastmcp_demo_tools.delete_session +``` + +### Parameter table + +```{eval-rst} +.. fastmcp-tool-input:: fastmcp_demo_tools.create_session +``` + +### Tool summary + +```{eval-rst} +.. fastmcp-tool-summary:: +``` + +--- + +## pytest fixtures + +```{py:module} spf_demo_fixtures +:no-index: +``` + +### Fixture index + +```{autofixture-index} spf_demo_fixtures +``` + +### Fixture reference + +```{eval-rst} +.. autofixtures:: spf_demo_fixtures + :no-index: +``` + +--- + +## Sphinx config values + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_config_demo +``` + +```{eval-rst} +.. autoconfigvalues:: sphinx_config_demo + :no-index: +``` + +--- + +## docutils directives and roles + +### Directives + +```{eval-rst} +.. autodirective-index:: docutils_demo +``` + +```{eval-rst} +.. autodirective:: docutils_demo.DemoBadgeDirective + :no-index: +``` + +### Roles + +```{eval-rst} +.. autorole-index:: docutils_demo +``` + +```{eval-rst} +.. autorole:: docutils_demo.demo_badge_role + :no-index: +``` diff --git a/docs/index.md b/docs/index.md index e6c747b4..53f34129 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,11 +2,29 @@ # gp-sphinx -Shared Sphinx documentation platform for [git-pull](https://github.com/git-pull) projects. +Integrated autodoc design system for [git-pull](https://github.com/git-pull) Sphinx projects. ::::{grid} 1 1 2 3 :gutter: 2 2 3 3 +:::{grid-item-card} What's New +:link: whats-new +:link-type: doc +The unified autodoc design system — seven major advancements. +::: + +:::{grid-item-card} Gallery +:link: gallery +:link-type: doc +Visual showcase of the autodoc design system in action. +::: + +:::{grid-item-card} Architecture +:link: architecture +:link-type: doc +Three-tier package organization — infrastructure, domain, and presentation. +::: + :::{grid-item-card} Quickstart :link: quickstart :link-type: doc @@ -16,7 +34,7 @@ Install and get started in minutes. :::{grid-item-card} Packages :link: packages/index :link-type: doc -Eight workspace packages — coordinator, extensions, and theme. +Twelve workspace packages — coordinator, extensions, and theme. ::: :::{grid-item-card} Configuration @@ -53,9 +71,25 @@ conf = merge_sphinx_config( globals().update(conf) ``` +## What you get + +Out of the box, {py:func}`~gp_sphinx.config.merge_sphinx_config` activates: + +- **Unified badge system** — type and modifier badges for functions, classes, fixtures, tools +- **Componentized layout** — card containers, parameter folding, managed signatures +- **Clean type hints** — simplified annotations with cross-referenced links +- **Five domain autodocumenters** — Python API, pytest fixtures, FastMCP tools, docutils, Sphinx config +- **IBM Plex fonts** — professional typography with preloaded web fonts +- **Dark mode** — full light/dark theming via CSS custom properties + +See the {doc}`gallery` to see these in action. + ```{toctree} :hidden: +whats-new +gallery +architecture quickstart configuration packages/index @@ -63,3 +97,39 @@ api project/index history ``` + +```{toctree} +:caption: Domain Packages +:hidden: + +packages/sphinx-autodoc-api-style +packages/sphinx-autodoc-argparse +packages/sphinx-autodoc-docutils +packages/sphinx-autodoc-fastmcp +packages/sphinx-autodoc-pytest-fixtures +packages/sphinx-autodoc-sphinx +``` + +```{toctree} +:caption: UX +:hidden: + +packages/sphinx-fonts +packages/sphinx-ux-autodoc-layout +packages/sphinx-ux-badges +``` + +```{toctree} +:caption: Utils +:hidden: + +packages/sphinx-autodoc-typehints-gp +``` + +```{toctree} +:caption: Internal +:hidden: + +packages/gp-sphinx +packages/sphinx-gp-theme +``` diff --git a/docs/justfile b/docs/justfile index ff2d0cbf..d887d221 100644 --- a/docs/justfile +++ b/docs/justfile @@ -21,42 +21,36 @@ default: @just --list # Build HTML documentation -[group: 'build'] html: {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/html @echo "" @echo "Build finished. The HTML pages are in {{ builddir }}/html." # Build directory HTML files -[group: 'build'] dirhtml: {{ sphinxbuild }} -b dirhtml {{ allsphinxopts }} {{ builddir }}/dirhtml @echo "" @echo "Build finished. The HTML pages are in {{ builddir }}/dirhtml." # Build single HTML file -[group: 'build'] singlehtml: {{ sphinxbuild }} -b singlehtml {{ allsphinxopts }} {{ builddir }}/singlehtml @echo "" @echo "Build finished. The HTML page is in {{ builddir }}/singlehtml." # Build EPUB -[group: 'build'] epub: {{ sphinxbuild }} -b epub {{ allsphinxopts }} {{ builddir }}/epub @echo "" @echo "Build finished. The epub file is in {{ builddir }}/epub." # Build LaTeX files -[group: 'build'] latex: {{ sphinxbuild }} -b latex {{ allsphinxopts }} {{ builddir }}/latex @echo "" @echo "Build finished; the LaTeX files are in {{ builddir }}/latex." # Build PDF via LaTeX -[group: 'build'] latexpdf: {{ sphinxbuild }} -b latex {{ allsphinxopts }} {{ builddir }}/latex @echo "Running LaTeX files through pdflatex..." @@ -64,62 +58,53 @@ latexpdf: @echo "pdflatex finished; the PDF files are in {{ builddir }}/latex." # Build plain text files -[group: 'build'] text: {{ sphinxbuild }} -b text {{ allsphinxopts }} {{ builddir }}/text @echo "" @echo "Build finished. The text files are in {{ builddir }}/text." # Build man pages -[group: 'build'] man: {{ sphinxbuild }} -b man {{ allsphinxopts }} {{ builddir }}/man @echo "" @echo "Build finished. The manual pages are in {{ builddir }}/man." # Build JSON output -[group: 'build'] json: {{ sphinxbuild }} -b json {{ allsphinxopts }} {{ builddir }}/json @echo "" @echo "Build finished; now you can process the JSON files." # Clean build directory -[group: 'misc'] [confirm] clean: rm -rf {{ builddir }}/* # Build HTML help files -[group: 'misc'] htmlhelp: {{ sphinxbuild }} -b htmlhelp {{ allsphinxopts }} {{ builddir }}/htmlhelp @echo "" @echo "Build finished; now you can run HTML Help Workshop with the .hhp project file in {{ builddir }}/htmlhelp." # Build Qt help files -[group: 'misc'] qthelp: {{ sphinxbuild }} -b qthelp {{ allsphinxopts }} {{ builddir }}/qthelp @echo "" @echo "Build finished; now you can run 'qcollectiongenerator' with the .qhcp project file in {{ builddir }}/qthelp." # Build Devhelp files -[group: 'misc'] devhelp: {{ sphinxbuild }} -b devhelp {{ allsphinxopts }} {{ builddir }}/devhelp @echo "" @echo "Build finished." # Build Texinfo files -[group: 'misc'] texinfo: {{ sphinxbuild }} -b texinfo {{ allsphinxopts }} {{ builddir }}/texinfo @echo "" @echo "Build finished. The Texinfo files are in {{ builddir }}/texinfo." # Build Info files from Texinfo -[group: 'misc'] info: {{ sphinxbuild }} -b texinfo {{ allsphinxopts }} {{ builddir }}/texinfo @echo "Running Texinfo files through makeinfo..." @@ -127,47 +112,40 @@ info: @echo "makeinfo finished; the Info files are in {{ builddir }}/texinfo." # Build gettext catalogs -[group: 'misc'] gettext: {{ sphinxbuild }} -b gettext {{ sphinxopts }} . {{ builddir }}/locale @echo "" @echo "Build finished. The message catalogs are in {{ builddir }}/locale." # Check all external links -[group: 'validate'] linkcheck: {{ sphinxbuild }} -b linkcheck {{ allsphinxopts }} {{ builddir }}/linkcheck @echo "" @echo "Link check complete; look for any errors in the above output or in {{ builddir }}/linkcheck/output.txt." # Run doctests embedded in documentation -[group: 'validate'] doctest: {{ sphinxbuild }} -b doctest {{ allsphinxopts }} {{ builddir }}/doctest @echo "Testing of doctests in the sources finished, look at the results in {{ builddir }}/doctest/output.txt." # Check build from scratch -[group: 'validate'] checkbuild: rm -rf {{ builddir }} {{ sphinxbuild }} -n -q ./ {{ builddir }} # Build redirects configuration -[group: 'misc'] redirects: {{ sphinxbuild }} -b rediraffewritediff {{ allsphinxopts }} {{ builddir }}/redirects @echo "" @echo "Build finished. The redirects are in rediraffe_redirects." # Show changes overview -[group: 'misc'] changes: {{ sphinxbuild }} -b changes {{ allsphinxopts }} {{ builddir }}/changes @echo "" @echo "The overview file is in {{ builddir }}/changes." # Watch files and rebuild on change -[group: 'dev'] watch: #!/usr/bin/env bash set -euo pipefail @@ -178,7 +156,6 @@ watch: fi # Serve documentation via Python http.server -[group: 'dev'] serve: @echo '==============================================================' @echo '' @@ -188,7 +165,6 @@ serve: python -m http.server {{ http_port }} --directory {{ builddir }}/html # Watch and serve simultaneously -[group: 'dev'] dev: #!/usr/bin/env bash set -euo pipefail @@ -196,11 +172,9 @@ dev: just serve # Start sphinx-autobuild server -[group: 'dev'] start: uv run sphinx-autobuild -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} # Design mode: watch static files and disable incremental builds -[group: 'dev'] design: uv run sphinx-autobuild -b dirhtml "{{ sourcedir }}" "{{ builddir }}" {{ sphinxopts }} --port {{ http_port }} --watch "." -a diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx.md index 2e346993..d1662eb5 100644 --- a/docs/packages/gp-sphinx.md +++ b/docs/packages/gp-sphinx.md @@ -1,6 +1,15 @@ # gp-sphinx -{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` +```{gp-sphinx-package-meta} gp-sphinx +``` + +:::{admonition} Alpha +:class: warning + +Rendered output is stable. The Python API, CSS class names, and Sphinx +config value names may change without a major version bump. Pin your +dependency to a specific version range in production. +::: Shared configuration coordinator for Sphinx projects. {py:func}`~gp_sphinx.config.merge_sphinx_config` builds a complete `conf.py` namespace from the workspace defaults and leaves diff --git a/docs/packages/index.md b/docs/packages/index.md index 26a21a98..4bb3faf5 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,21 +1,34 @@ # Packages -Ten workspace packages, each independently installable. +Twelve workspace packages in three tiers. -```{workspace-package-grid} -``` +**Shared infrastructure** — the rendering pipeline that all domain packages consume: +- `sphinx-ux-badges` — badge primitives and colour palette +- `sphinx-ux-autodoc-layout` — structural presenter for `api-*` entry components +- `sphinx-autodoc-typehints-gp` — annotation normalization and type rendering + +**Domain packages** — domain-specific autodoc extensions. Each either +ships its own Sphinx domain or extends an existing one with new +directives, roles, and per-domain indices: +- `sphinx-autodoc-api-style`, `sphinx-autodoc-argparse`, + `sphinx-autodoc-docutils`, `sphinx-autodoc-fastmcp`, + `sphinx-autodoc-pytest-fixtures`, `sphinx-autodoc-sphinx` + +**Theme and coordinator** — shared Sphinx configuration and presentation +assets: +- `gp-sphinx`, `sphinx-gp-theme`, `sphinx-fonts` -```{toctree} -:hidden: - -gp-sphinx -sphinx-autodoc-api-style -sphinx-autodoc-badges -sphinx-autodoc-docutils -sphinx-autodoc-fastmcp -sphinx-autodoc-sphinx -sphinx-autodoc-pytest-fixtures -sphinx-fonts -sphinx-gptheme -sphinx-argparse-neo +`gp-sphinx` is the umbrella entry point: `merge_sphinx_config()` wires up the +full stack for downstream projects. + +Each domain package is independently installable but automatically loads its +infrastructure dependencies. + +Together, the shared infrastructure provides **one autodoc design system**: +every domain package shares the same badge palette, the same componentized +HTML output structure, and the same static type annotation pipeline — so +Python APIs, pytest fixtures, Sphinx config values, docutils directives, +and FastMCP tools all look like they belong together. + +```{workspace-package-grid} ``` diff --git a/docs/packages/sphinx-argparse-neo.md b/docs/packages/sphinx-argparse-neo.md deleted file mode 100644 index 0b477cfb..00000000 --- a/docs/packages/sphinx-argparse-neo.md +++ /dev/null @@ -1,115 +0,0 @@ -# sphinx-argparse-neo - -{bdg-success-line}`Beta` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` - -Modern Sphinx extension for documenting `argparse` CLIs. The base package -registers the `argparse` directive plus renderer config values; the -`sphinx_argparse_neo.exemplar` layer adds example extraction, lexers, and CLI -inline roles. - -```console -$ pip install sphinx-argparse-neo -``` - -## Downstream `conf.py` - -```python -extensions = [ - "sphinx_argparse_neo", - "sphinx_argparse_neo.exemplar", -] - -argparse_examples_section_title = "Examples" -argparse_reorder_usage_before_examples = True -``` - -## Live directive demos - -### Base parser rendering - -```{argparse} -:module: demo_cli -:func: create_parser -:prog: myapp -``` - -### Subcommand rendering - -Drill into a single subcommand with `:path:`: - -```{argparse} -:module: demo_cli -:func: create_parser -:path: mysubcommand -:prog: myapp -``` - -### Inline roles - -The exemplar layer also registers live inline roles for CLI prose: -{cli-command}`myapp`, {cli-option}`--verbose`, {cli-choice}`json`, -{cli-metavar}`DIR`, and {cli-default}`text`. - -## Configuration values - -### Base extension - -```{eval-rst} -.. autoconfigvalue-index:: sphinx_argparse_neo -.. autoconfigvalues:: sphinx_argparse_neo -``` - -### Exemplar layer - -```{eval-rst} -.. autoconfigvalue-index:: sphinx_argparse_neo.exemplar -.. autoconfigvalues:: sphinx_argparse_neo.exemplar -``` - -## Registered directives and roles - -### Base `argparse` directive - -```{eval-rst} -.. autodirective:: sphinx_argparse_neo.directive.ArgparseDirective - :no-index: -``` - -### Exemplar override - -```{eval-rst} -.. autodirective:: sphinx_argparse_neo.exemplar.CleanArgParseDirective -``` - -### CLI role callables - -```{eval-rst} -.. autorole-index:: sphinx_argparse_neo.roles -.. autoroles:: sphinx_argparse_neo.roles -``` - -## Downstream usage snippets - -Use native MyST directives in Markdown: - -````myst -```{argparse} -:module: myproject.cli -:func: create_parser -:prog: myproject -``` -```` - -Or reStructuredText: - -```rst -.. argparse:: - :module: myproject.cli - :func: create_parser - :prog: myproject -``` - -```{package-reference} sphinx-argparse-neo -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-argparse-neo) · [PyPI](https://pypi.org/project/sphinx-argparse-neo/) diff --git a/docs/packages/sphinx-autodoc-api-style.md b/docs/packages/sphinx-autodoc-api-style.md index 90b2860f..407ed114 100644 --- a/docs/packages/sphinx-autodoc-api-style.md +++ b/docs/packages/sphinx-autodoc-api-style.md @@ -2,7 +2,16 @@ # sphinx-autodoc-api-style -{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` +```{gp-sphinx-package-meta} sphinx-autodoc-api-style +``` + +:::{admonition} Alpha +:class: warning + +Rendered output is stable. The Python API, CSS class names, and Sphinx +config value names may change without a major version bump. Pin your +dependency to a specific version range in production. +::: Sphinx extension that adds type and modifier badges to standard Python domain entries (functions, classes, methods, properties, attributes, data, @@ -25,7 +34,7 @@ $ pip install sphinx-autodoc-api-style - **Accessibility**: keyboard-focusable badges with tooltip popups - **Non-invasive**: hooks into `doctree-resolved` without replacing directives -## How it works +## Downstream `conf.py` Add `sphinx_autodoc_api_style` to your Sphinx extensions. With `gp-sphinx`, use `extra_extensions`: @@ -46,44 +55,67 @@ Or without `merge_sphinx_config`: extensions = ["sphinx_autodoc_api_style"] ``` +`sphinx_autodoc_api_style` automatically registers `sphinx_ux_badges` and +`sphinx_ux_autodoc_layout` via `app.setup_extension()`. You do not need to add +them separately to your `extensions` list. + +## Working usage examples + No special directives are needed — existing `.. autofunction::`, `.. autoclass::`, `.. automodule::` directives automatically receive badges. -## Live demo +Render one function: + +````myst +```{eval-rst} +.. autofunction:: my_project.api.demo_function +``` +```` + +Render one class and its members: + +````myst +```{eval-rst} +.. autoclass:: my_project.api.DemoClass + :members: +``` +```` + +## Live demos -```{py:module} gas_demo_api +```{py:module} gp_demo_api ``` ### Functions ```{eval-rst} -.. autofunction:: gas_demo_api.demo_function +.. autofunction:: gp_demo_api.demo_function ``` ```{eval-rst} -.. autofunction:: gas_demo_api.demo_async_function +.. autofunction:: gp_demo_api.demo_async_function ``` ```{eval-rst} -.. autofunction:: gas_demo_api.demo_deprecated_function +.. autofunction:: gp_demo_api.demo_deprecated_function ``` ### Module data ```{eval-rst} -.. autodata:: gas_demo_api.DEMO_CONSTANT +.. autodata:: gp_demo_api.DEMO_CONSTANT ``` ### Exceptions ```{eval-rst} -.. autoexception:: gas_demo_api.DemoError +.. autoexception:: gp_demo_api.DemoError ``` ### Classes ```{eval-rst} -.. autoclass:: gas_demo_api.DemoClass +.. autoclass:: gp_demo_api.DemoClass :members: :undoc-members: ``` @@ -91,39 +123,41 @@ No special directives are needed — existing `.. autofunction::`, ### Abstract base classes ```{eval-rst} -.. autoclass:: gas_demo_api.DemoAbstractBase +.. autoclass:: gp_demo_api.DemoAbstractBase :members: ``` ## Badge reference -### Type badges - -| Object type | CSS class | Color | -|-------------|-----------|-------| -| `function` | `gas-type-function` | Blue | -| `class` | `gas-type-class` | Indigo | -| `method` | `gas-type-method` | Cyan | -| `property` | `gas-type-property` | Teal | -| `attribute` | `gas-type-attribute` | Slate | -| `data` | `gas-type-data` | Grey | -| `exception` | `gas-type-exception` | Rose | - -### Modifier badges - -| Modifier | CSS class | Style | -|----------|-----------|-------| -| `async` | `gas-mod-async` | Purple outlined | -| `classmethod` | `gas-mod-classmethod` | Amber outlined | -| `staticmethod` | `gas-mod-staticmethod` | Grey outlined | -| `abstract` | `gas-mod-abstract` | Indigo outlined | -| `final` | `gas-mod-final` | Emerald outlined | -| `deprecated` | `gas-deprecated` | Red/grey outlined | +All badge classes are drawn from the shared `sphinx_ux_badges.SAB` palette. +This extension uses: + +| Object type | `SAB` constant | CSS class | +|---|---|---| +| `function` | `SAB.TYPE_FUNCTION` | `gp-sphinx-badge--type-function` | +| `class` | `SAB.TYPE_CLASS` | `gp-sphinx-badge--type-class` | +| `method` | `SAB.TYPE_METHOD` | `gp-sphinx-badge--type-method` | +| `property` | `SAB.TYPE_PROPERTY` | `gp-sphinx-badge--type-property` | +| `attribute` | `SAB.TYPE_ATTRIBUTE` | `gp-sphinx-badge--type-attribute` | +| `data` | `SAB.TYPE_DATA` | `gp-sphinx-badge--type-data` | +| `exception` | `SAB.TYPE_EXCEPTION` | `gp-sphinx-badge--type-exception` | + +| Modifier | `SAB` constant | CSS class | +|---|---|---| +| `async` | `SAB.MOD_ASYNC` | `gp-sphinx-badge--mod-async` | +| `classmethod` | `SAB.MOD_CLASSMETHOD` | `gp-sphinx-badge--mod-classmethod` | +| `staticmethod` | `SAB.MOD_STATICMETHOD` | `gp-sphinx-badge--mod-staticmethod` | +| `abstract` | `SAB.MOD_ABSTRACT` | `gp-sphinx-badge--mod-abstract` | +| `final` | `SAB.MOD_FINAL` | `gp-sphinx-badge--mod-final` | +| `deprecated` | `SAB.STATE_DEPRECATED` | `gp-sphinx-badge--state-deprecated` | + +See {doc}`sphinx-ux-badges` for the full shared palette. ## CSS prefix -All CSS classes use the `gas-` prefix (**g**p-sphinx **a**pi **s**tyle) to avoid -collision with `spf-` (sphinx pytest fixtures) or other extensions. +All badge CSS classes use the `sab-` prefix from {doc}`sphinx-ux-badges`. +Layout card classes (borders, headers, field-list rules) are local to this package +and use `dl.py-*` and `.api-*` selectors. ```{package-reference} sphinx-autodoc-api-style ``` diff --git a/docs/packages/sphinx-autodoc-argparse.md b/docs/packages/sphinx-autodoc-argparse.md new file mode 100644 index 00000000..27b8e46b --- /dev/null +++ b/docs/packages/sphinx-autodoc-argparse.md @@ -0,0 +1,153 @@ +# sphinx-autodoc-argparse + +```{gp-sphinx-package-meta} sphinx-autodoc-argparse +``` + +Modern Sphinx extension for documenting `argparse` CLIs. The base package +registers the `argparse` directive plus renderer config values; the +`sphinx_autodoc_argparse.exemplar` layer adds example extraction, lexers, and CLI +inline roles. + +```console +$ pip install sphinx-autodoc-argparse +``` + +## Working usage examples + +```python +extensions = [ + "sphinx_autodoc_argparse", + "sphinx_autodoc_argparse.exemplar", +] + +argparse_examples_section_title = "Examples" +argparse_reorder_usage_before_examples = True +``` + +## Live demos + +### Base parser rendering + +```{argparse} +:module: demo_cli +:func: create_parser +:prog: myapp +``` + +### Subcommand rendering + +Drill into a single subcommand with `:path:`: + +```{argparse} +:module: demo_cli +:func: create_parser +:path: mysubcommand +:prog: myapp +``` + +### Inline roles + +The exemplar layer also registers live inline roles for CLI prose: +{cli-command}`myapp`, {cli-option}`--verbose`, {cli-choice}`json`, +{cli-metavar}`DIR`, and {cli-default}`text`. + +## Cross-reference roles + +Every `.. argparse::` block populates a dedicated `argparse` domain +alongside the existing `std:cmdoption` entries. Use these roles to +link to programs, options, subcommands, and positional arguments +declared anywhere in the project: + +| Role | Resolves to | Example | +|------|-------------|---------| +| `:argparse:program:` | A top-level program | `` :argparse:program:`myapp` `` | +| `:argparse:option:` | An optional flag, scoped by program | `` :argparse:option:`myapp --verbose` `` or `` :argparse:option:`myapp sync --force` `` | +| `:argparse:subcommand:` | A subcommand under a parent program | `` :argparse:subcommand:`myapp sync` `` | +| `:argparse:positional:` | A positional argument, scoped by program | `` :argparse:positional:`myapp FILE` `` | + +Whitespace-joined targets (`myapp sync --force`) are split on the final +space to match the stored `(program, name)` tuple. Bare forms +(`--verbose`) also resolve when only one registration matches, though +the fully-qualified form is preferred for multi-program sites. + +### Auto-generated indices + +Two domain indices are built into every project that loads the +extension: + +- `argparse-programsindex` — alphabetised list of every registered + program; link via `` :ref:`argparse-programsindex` ``. +- `argparse-optionsindex` — options grouped by program, alphabetised + within each group; link via `` :ref:`argparse-optionsindex` ``. + +### Intersphinx compatibility + +The classic `:option:` / `std:cmdoption` emission is preserved — both +roles resolve and both appear in `objects.inv`. Downstream consumers +linking via intersphinx continue to work; new authoring inside +projects using this extension can prefer the `:argparse:*` namespace +for program-scoped clarity. + +## Configuration values + +### Base extension + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_autodoc_argparse +.. autoconfigvalues:: sphinx_autodoc_argparse +``` + +### Exemplar layer + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_autodoc_argparse.exemplar +.. autoconfigvalues:: sphinx_autodoc_argparse.exemplar +``` + +## Registered directives and roles + +### Base `argparse` directive + +```{eval-rst} +.. autodirective:: sphinx_autodoc_argparse.directive.ArgparseDirective + :no-index: +``` + +### Exemplar override + +```{eval-rst} +.. autodirective:: sphinx_autodoc_argparse.exemplar.CleanArgParseDirective +``` + +### CLI role callables + +```{eval-rst} +.. autorole-index:: sphinx_autodoc_argparse.roles +.. autoroles:: sphinx_autodoc_argparse.roles +``` + +## Downstream usage snippets + +Use native MyST directives in Markdown: + +````myst +```{argparse} +:module: myproject.cli +:func: create_parser +:prog: myproject +``` +```` + +Or reStructuredText: + +```rst +.. argparse:: + :module: myproject.cli + :func: create_parser + :prog: myproject +``` + +```{package-reference} sphinx-autodoc-argparse +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-argparse) · [PyPI](https://pypi.org/project/sphinx-autodoc-argparse/) diff --git a/docs/packages/sphinx-autodoc-badges.md b/docs/packages/sphinx-autodoc-badges.md deleted file mode 100644 index 2687bb07..00000000 --- a/docs/packages/sphinx-autodoc-badges.md +++ /dev/null @@ -1,253 +0,0 @@ -(sphinx-autodoc-badges)= - -# sphinx-autodoc-badges - -{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` - -Shared badge node, HTML visitors, and CSS infrastructure for Sphinx autodoc -extensions. Provides a single `BadgeNode` and builder API that -{doc}`sphinx-autodoc-api-style`, {doc}`sphinx-autodoc-pytest-fixtures`, and -{doc}`sphinx-autodoc-fastmcp` share instead of reimplementing badges -independently. - -```console -$ pip install sphinx-autodoc-badges -``` - -## How it works - -`setup()` registers the extension with Sphinx: - -1. {py:meth}`~sphinx.application.Sphinx.add_node` registers `BadgeNode` with - HTML visitors (`visit_badge_html` / `depart_badge_html`). -2. {py:meth}`~sphinx.application.Sphinx.add_css_file` injects the shared - `sphinx_autodoc_badges.css` stylesheet. -3. Downstream extensions call - {py:meth}`~sphinx.application.Sphinx.setup_extension` to load the badge - layer: - -```python -def setup(app: Sphinx) -> dict[str, Any]: - app.setup_extension("sphinx_autodoc_badges") -``` - -`BadgeNode` subclasses {py:class}`docutils.nodes.inline`, so unregistered -builders (text, LaTeX, man) fall back to `visit_inline` via Sphinx's -MRO-based dispatch — no special handling needed. - -## Live badge demos - -Every variant rendered by the real `build_badge` / `build_badge_group` / -`build_toolbar` API: - -```{sab-badge-demo} -``` - -## API reference - -```{eval-rst} -.. autofunction:: sphinx_autodoc_badges.build_badge - -.. autofunction:: sphinx_autodoc_badges.build_badge_group - -.. autofunction:: sphinx_autodoc_badges.build_toolbar - -.. autoclass:: sphinx_autodoc_badges.BadgeNode - :no-members: - - .. rubric:: Constructor parameters - - .. list-table:: - :header-rows: 1 - :widths: 20 15 65 - - * - Parameter - - Default - - Description - * - ``text`` - - ``""`` - - Visible label. Empty string for icon-only badges. - * - ``badge_tooltip`` - - ``""`` - - Hover text and ``aria-label``. - * - ``badge_icon`` - - ``""`` - - Emoji character rendered via CSS ``::before``. - * - ``badge_style`` - - ``"full"`` - - Structural variant: ``"full"``, ``"icon-only"``, ``"inline-icon"``. - * - ``badge_size`` - - ``""`` - - Optional size: ``"xs"``, ``"sm"``, ``"lg"``, or ``"xl"``. Empty means default. - * - ``tabindex`` - - ``"0"`` - - ``"0"`` for keyboard-focusable, ``""`` to skip. - * - ``classes`` - - ``None`` - - Additional CSS classes (plugin prefix + color class). - -.. autoclass:: sphinx_autodoc_badges._css.SAB - :members: - :undoc-members: - -.. autofunction:: sphinx_autodoc_badges.setup -``` - -## CSS custom properties - -All colors and metrics are exposed as CSS custom properties on `:root`. -Override them in your project's `custom.css` or via -{py:meth}`~sphinx.application.Sphinx.add_css_file`. - -### Defaults - -```css -:root { - /* ── Color hooks (set by downstream extensions) ────── */ - --sab-bg: transparent; /* badge background */ - --sab-fg: inherit; /* badge text color */ - --sab-border: none; /* badge border shorthand */ - - /* ── Metrics ───────────────────────────────────────── */ - --sab-font-size: 0.75em; - --sab-font-weight: 700; - --sab-padding-v: 0.35em; /* vertical padding */ - --sab-padding-h: 0.65em; /* horizontal padding */ - --sab-radius: 0.25rem; /* border-radius */ - --sab-icon-gap: 0.28rem; /* gap between icon and label */ - - /* ── Depth (inset shadow on solid badges) ──────────── */ - --sab-buff-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.2), - inset 0 -1px 2px rgba(0, 0, 0, 0.12); - --sab-buff-shadow-dark-ui: - inset 0 1px 0 rgba(255, 255, 255, 0.1), - inset 0 -1px 2px rgba(0, 0, 0, 0.28); -} -``` - -### Property reference - -```{list-table} -:header-rows: 1 -:widths: 30 70 - -* - Property - - Purpose -* - `--sab-bg` - - Badge background color. Extensions set this per badge class (e.g. green for "readonly"). -* - `--sab-fg` - - Badge text color. Falls back to `inherit` when unset. -* - `--sab-border` - - Border shorthand (`1px solid #...`). Defaults to `none`. -* - `--sab-font-size` - - Font size. Context-aware sizing (headings, body, TOC) overrides this. -* - `--sab-font-weight` - - Font weight. Default `700` (bold). -* - `--sab-padding-v` / `--sab-padding-h` - - Vertical and horizontal padding. -* - `--sab-radius` - - Border radius for pill shape. -* - `--sab-icon-gap` - - Gap between the `::before` icon and the label text. -* - `--sab-buff-shadow` - - Subtle inset highlight + shadow for depth on light backgrounds. -* - `--sab-buff-shadow-dark-ui` - - Stronger inset shadow variant for dark theme / `prefers-color-scheme: dark`. -``` - -## CSS class reference - -All classes use the `sab-` prefix (**s**phinx **a**utodoc **b**adges). - -```{list-table} -:header-rows: 1 -:widths: 25 15 60 - -* - Class - - Applied by - - Description -* - `sab-badge` - - `BadgeNode` - - Base class. Always present on every badge. -* - `sab-outline` - - `build_badge(fill="outline")` - - Transparent background, inherits text color. -* - `sab-icon-only` - - `build_badge(style="icon-only")` - - 16 × 16 colored box with emoji `::before`. -* - `sab-inline-icon` - - `build_badge(style="inline-icon")` - - Bare emoji inside a code chip, no background. -* - `sab-badge-group` - - `build_badge_group()` - - Flex container with `gap: 0.3rem` between badges. -* - `sab-toolbar` - - `build_toolbar()` - - Flex push-right (`margin-left: auto`) for title rows. -* - `sab-xs` - - `build_badge(size="xs")` / `BadgeNode(..., badge_size="xs")` - - Extra small (dense tables, tight UI). -* - `sab-sm` - - `build_badge(size="sm")` - - Small inline badges. -* - `sab-lg` - - `build_badge(size="lg")` - - Large (section titles, callouts). -* - `sab-xl` - - `build_badge(size="xl")` - - Extra large (hero / landing emphasis). -``` - -## Context-aware sizing - -Badge size adapts automatically based on where it appears in the document. -CSS selectors handle it. Explicit size classes (`sab-xs` … `sab-xl`) override -contextual sizing when present (higher specificity than context rules). - -```{list-table} -:header-rows: 1 -:widths: 25 20 55 - -* - Context - - Font size - - Selectors -* - Heading (`h2`, `h3`) - - `0.68rem` - - `.body h2 .sab-badge`, `[role="main"] h3 .sab-badge` -* - Body (`p`, `li`, `td`, `a`) - - `0.62rem` - - `.body p .sab-badge`, `[role="main"] li .sab-badge`, etc. -* - TOC sidebar - - `0.58rem` - - `.toc-tree .sab-badge` (compact, with emoji icons) -``` - -## Downstream extensions - -Each extension adds its own CSS color layer on top of the shared base: - -```{list-table} -:header-rows: 1 -:widths: 30 15 55 - -* - Extension - - Prefix - - Badge types -* - {doc}`sphinx-autodoc-fastmcp` - - `smf-` - - Safety tiers (readonly / mutating / destructive), MCP tool type -* - {doc}`sphinx-autodoc-api-style` - - `gas-` - - Python object types (function, class, method, ...), modifiers (async, deprecated, ...) -* - {doc}`sphinx-autodoc-pytest-fixtures` - - `spf-` - - Fixture scopes (session, module, function), kind badges (autouse, yield) -``` - -## Package reference - -```{package-reference} sphinx-autodoc-badges -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-badges) · [PyPI](https://pypi.org/project/sphinx-autodoc-badges/) diff --git a/docs/packages/sphinx-autodoc-docutils.md b/docs/packages/sphinx-autodoc-docutils.md index 27f11e7e..fac49ed1 100644 --- a/docs/packages/sphinx-autodoc-docutils.md +++ b/docs/packages/sphinx-autodoc-docutils.md @@ -1,12 +1,25 @@ # sphinx-autodoc-docutils -{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` +```{gp-sphinx-package-meta} sphinx-autodoc-docutils +``` + +:::{admonition} Alpha +:class: warning + +Rendered output is stable. The Python API, CSS class names, and Sphinx +config value names may change without a major version bump. Pin your +dependency to a specific version range in production. +::: Experimental Sphinx extension for documenting docutils directives and role callables as reference material. The extension does not invent a new domain; instead it introspects Python modules and renders copyable `rst:directive` and `rst:role` reference blocks from the live objects. +Those rendered entries now share the same badge, layout, and type-display +stack as the rest of the autodoc packages even though the package still keeps +its semantic `rst:*` generation path. + ```console $ pip install sphinx-autodoc-docutils ``` @@ -17,6 +30,10 @@ $ pip install sphinx-autodoc-docutils extensions = ["sphinx_autodoc_docutils"] ``` +`sphinx_autodoc_docutils` automatically registers `sphinx_ux_badges`, +`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. +You do not need to add them separately to your `extensions` list. + ## Working usage examples Use a single-object directive when you want one rendered reference entry: diff --git a/docs/packages/sphinx-autodoc-fastmcp.md b/docs/packages/sphinx-autodoc-fastmcp.md index 64b08414..cb80fb88 100644 --- a/docs/packages/sphinx-autodoc-fastmcp.md +++ b/docs/packages/sphinx-autodoc-fastmcp.md @@ -2,22 +2,118 @@ # sphinx-autodoc-fastmcp -{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` +```{gp-sphinx-package-meta} sphinx-autodoc-fastmcp +``` + +:::{admonition} Alpha +:class: warning + +Rendered output is stable. The Python API, CSS class names, and Sphinx +config value names may change without a major version bump. Pin your +dependency to a specific version range in production. +::: -Sphinx extension for documenting **FastMCP** tools: card-style `desc` layouts -(aligned with {doc}`sphinx-autodoc-api-style`), safety badges, parameter tables, -and cross-reference roles (`:tool:`, `:toolref:`, `:badge:`, etc.). +Sphinx extension for documenting **FastMCP** tools: section cards built from +shared `api-*` layout regions, safety badges, parameter tables, and +cross-reference roles (`:tool:`, `:toolref:`, `:badge:`, etc.). + +The shipped output intentionally keeps the outer `section` wrapper so table of +contents labels and tool references stay stable. Inside that wrapper, shared +layout, badge, and typehint helpers now own the visible card structure. ```console $ pip install sphinx-autodoc-fastmcp ``` -## Features +## Downstream `conf.py` + +```python +extensions = ["sphinx_autodoc_fastmcp"] + +fastmcp_tool_modules = [ + "my_project.docs.fastmcp_tools", +] +fastmcp_area_map = { + "fastmcp_tools": "api/tools", +} +fastmcp_collector_mode = "introspect" +``` + +`sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, +`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. +You do not need to add them separately to your `extensions` list. + +## Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| `fastmcp_tool_modules` | `[]` | Python module paths that expose tool callables | +| `fastmcp_area_map` | `{}` | Maps module stem to area path for ToC labels | +| `fastmcp_collector_mode` | `"introspect"` | `"introspect"` or `"register"` — how tools are discovered | +| `fastmcp_model_module` | `None` | Module containing Pydantic model classes | +| `fastmcp_model_classes` | `set()` | Set of model class names to cross-reference | +| `fastmcp_section_badge_map` | `{}` | Maps section names to safety badge labels | +| `fastmcp_section_badge_pages` | `set()` | Pages where section safety badges are injected | + +## Working usage examples + +Render one tool card: + +````myst +```{eval-rst} +.. fastmcp-tool:: my_project.docs.fastmcp_tools.list_sessions +``` +```` + +Render one tool's parameter table: + +````myst +```{eval-rst} +.. fastmcp-tool-input:: my_project.docs.fastmcp_tools.list_sessions +``` +```` + +Render a summary table grouped by safety tier: + +````myst +```{eval-rst} +.. fastmcp-tool-summary:: +``` +```` + +Add inline cross-references in prose: + +````myst +Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` +for a plain inline reference. +```` + +## Live demos + +Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` +for a plain inline reference. + +### Tool cards + +```{eval-rst} +.. fastmcp-tool:: fastmcp_demo_tools.list_sessions -- **Tool cards**: `mcp` / `tool` domain `desc` nodes with toolbar badges -- **Collectors**: `register(mcp)`-style modules or `introspect` mode for `@mcp.tool` -- **Configuration**: module list, area map, model classes for type cross-refs -- **MyST directives**: `fastmcp-tool`, `fastmcp-tool-input`, `fastmcp-toolsummary` +.. fastmcp-tool:: fastmcp_demo_tools.create_session + +.. fastmcp-tool:: fastmcp_demo_tools.delete_session +``` + +### Parameter table + +```{eval-rst} +.. fastmcp-tool-input:: fastmcp_demo_tools.create_session +``` + +### Tool summary + +```{eval-rst} +.. fastmcp-tool-summary:: +``` ## Package reference diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index 465a09b0..8ca3dcca 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -1,12 +1,26 @@ # sphinx-autodoc-pytest-fixtures -{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` +```{gp-sphinx-package-meta} sphinx-autodoc-pytest-fixtures +``` + +:::{admonition} Alpha +:class: warning + +Rendered output is stable. The Python API, CSS class names, and Sphinx +config value names may change without a major version bump. Pin your +dependency to a specific version range in production. +::: Sphinx extension for documenting pytest fixtures as first-class objects. It registers a Python-domain fixture directive and role, autodoc helpers for bulk fixture discovery, a higher-level pytest plugin page helper, and the badge/index UI used throughout the page below. +Fixture pages now use the shared stack end-to-end: badge output comes from +`sphinx-ux-badges`, visible `api-*` structure comes from +`sphinx-ux-autodoc-layout`, and fixture return types use the shared +`sphinx-autodoc-typehints-gp` rendering helpers. + ```console $ pip install sphinx-autodoc-pytest-fixtures ``` @@ -17,11 +31,15 @@ $ pip install sphinx-autodoc-pytest-fixtures extensions = ["sphinx_autodoc_pytest_fixtures"] pytest_fixture_lint_level = "warning" -pytest_external_fixture_links = { +pytest_fixture_external_links = { "db": "https://docs.example.com/testing#db", } ``` +`sphinx_autodoc_pytest_fixtures` automatically registers `sphinx_ux_badges`, +`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. +You do not need to add them separately to your `extensions` list. + ## Registered configuration values ```{eval-rst} @@ -36,6 +54,23 @@ pytest_external_fixture_links = { .. autorole-index:: sphinx_autodoc_pytest_fixtures ``` +## Working usage examples + +Render one fixture index: + +````myst +```{autofixture-index} my_project.pytest_plugin +``` +```` + +Render a standard pytest plugin page: + +````myst +:::{auto-pytest-plugin} my_project.pytest_plugin +:package: my-project +::: +```` + ## Live demos ```{py:module} spf_demo_fixtures @@ -55,14 +90,14 @@ pytest_external_fixture_links = { ### Plugin page helper -:::{doc-pytest-plugin} spf_demo_fixtures +:::{auto-pytest-plugin} spf_demo_fixtures :package: sphinx-autodoc-pytest-fixtures Add project-specific usage notes here. The helper renders the install section, autodiscovery note, and full fixture summary/reference. ::: -#### When to use `doc-pytest-plugin` +#### When to use `auto-pytest-plugin` Use this directive for a standard pytest plugin page where you want consistent house-style: an install section, the `pytest11` autodiscovery note, and a diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md index e293505e..393fcc0b 100644 --- a/docs/packages/sphinx-autodoc-sphinx.md +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -1,12 +1,26 @@ # sphinx-autodoc-sphinx -{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` +```{gp-sphinx-package-meta} sphinx-autodoc-sphinx +``` + +:::{admonition} Alpha +:class: warning + +Rendered output is stable. The Python API, CSS class names, and Sphinx +config value names may change without a major version bump. Pin your +dependency to a specific version range in production. +::: Experimental Sphinx extension for documenting config values registered by extension `setup()` hooks. It takes the repetitive part of `conf.py` reference-writing, records {py:meth}`sphinx:~sphinx.application.Sphinx.add_config_value` calls, and renders them as live `confval` entries and summary indexes. +Config entries now share the same badge, layout, and type-rendering stack as +the rest of the autodoc family: badges come from `sphinx-ux-badges`, +entry structure comes from `sphinx-ux-autodoc-layout`, and displayed config types +come from `sphinx-autodoc-typehints-gp`. + ```console $ pip install sphinx-autodoc-sphinx ``` @@ -17,6 +31,10 @@ $ pip install sphinx-autodoc-sphinx extensions = ["sphinx_autodoc_sphinx"] ``` +`sphinx_autodoc_sphinx` automatically registers `sphinx_ux_badges`, +`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. +You do not need to add them separately to your `extensions` list. + ## Working usage examples Render one config value: diff --git a/docs/packages/sphinx-autodoc-typehints-gp.md b/docs/packages/sphinx-autodoc-typehints-gp.md new file mode 100644 index 00000000..97539e0f --- /dev/null +++ b/docs/packages/sphinx-autodoc-typehints-gp.md @@ -0,0 +1,130 @@ +(sphinx-autodoc-typehints-gp)= + +# sphinx-autodoc-typehints-gp + +```{gp-sphinx-package-meta} sphinx-autodoc-typehints-gp +``` + +:::{admonition} Alpha +:class: warning + +Rendered output is stable. The Python API, CSS class names, and Sphinx +config value names may change without a major version bump. Pin your +dependency to a specific version range in production. +::: + +Single-package replacement for `sphinx-autodoc-typehints` and `sphinx.ext.napoleon` +— resolves annotations statically at build time, no monkey-patching required. + +It is also the shared type-rendering layer for the `sphinx-autodoc-*` family: +annotation normalization, xref-node generation, and late-safe annotation +paragraph helpers all live here. + +## Installation + +```console +$ pip install sphinx-autodoc-typehints-gp +``` + +## Working usage examples + +Add `sphinx_autodoc_typehints_gp` to your `extensions` list in `conf.py`: + +```python +extensions = [ + "sphinx.ext.autodoc", + "sphinx_autodoc_typehints_gp", +] + +# Required: makes autodoc insert type annotations into parameter descriptions. +# Without this, the type cross-referencing pipeline fires but has nothing to attach to. +autodoc_typehints = "description" +``` + +## Pipeline position + +Two hooks run independently: + +| Event | Hook | Priority | +|-------|------|----------| +| `autodoc-process-docstring` | NumPy section parser | default (not priority-controlled) | +| `object-description-transform` | `merge_typehints` | **499** — before Sphinx's built-in `_merge_typehints` at 500 | + +Running at priority 499 means cross-referenced `:type:`/`:rtype:` fields are +already in place before Sphinx's built-in handler runs. The built-in sees them +and skips its own plain-text duplicates — cooperation, not conflict. + +## Features + +- Resolves type hints statically without `exec()` or `typing.get_type_hints()`. +- Works perfectly with `TYPE_CHECKING` blocks. +- No text-level race conditions with Napoleon. +- Exposes reusable helpers for annotation display classification and rendered + type paragraphs used by the other autodoc packages. + +## Shared layer + +`sphinx_autodoc_typehints_gp` serves as the shared internal annotation normalization +layer for the `sphinx-autodoc-*` family. The symbols exported in `__all__` +are intended for use by other `gp-sphinx` packages and by extension authors +who want to reuse the same rendering pipeline. The API is stable within a +`gp-sphinx` version range but does not carry the same backward-compatibility +guarantees as `gp_sphinx.merge_sphinx_config()`. + +## Choosing the right helper + +Four `build_*` functions span two axes: + +| | Resolved (`env` available) | Unresolved (annotation text only) | +|---|---|---| +| Raw paragraph | `build_resolved_annotation_paragraph` | `build_annotation_paragraph` | +| Display-classified | `build_resolved_annotation_display_paragraph` | `build_annotation_display_paragraph` | + +Use `build_resolved_*` inside `doctree-resolved` event handlers where a +`BuildEnvironment` is available. Use `build_*` when you have only the +annotation string. + +## Annotation display classification + +`classify_annotation_display()` returns an `AnnotationDisplay` with structured +metadata for UI renderers. All values below are verified against the installed +package: + +| Annotation input | `text` | `is_literal_enum` | `literal_members` | +|---|---|---|---| +| `str` | `"str"` | `False` | `()` | +| `str \| None` | `"str \| None"` | `False` | `()` | +| `str \| None` (`strip_none=True`) | `"str"` | `False` | `()` | +| `Literal['open', 'closed']` | `"'open', 'closed'"` | `True` | `("'open'", "'closed'")` | +| `int \| bool` | `"int \| bool"` | `False` | `()` | + +`is_literal_enum=True` lets rendering code produce individual badge chips for +each member rather than a monolithic code string. This decision used to live +in each consumer (FastMCP, pytest-fixtures, api-style); now it lives in +`classify_annotation_display()` so no downstream package re-implements enum +detection heuristics. + +## Static resolution + +| Approach | `TYPE_CHECKING` block safe | Napoleon text-processing race | +|---|---|---| +| `typing.get_type_hints()` | No — resolves at import time | Yes — depends on import order | +| `sphinx_stringify_annotation()` | Yes — resolves at Sphinx build time | No — no text processing | + +This extension uses `sphinx_stringify_annotation()` to resolve annotations at +build time, making it safe with `TYPE_CHECKING` blocks and eliminating +text-processing races with Napoleon. + +## Live demos + +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. + +```{eval-rst} +.. autofunction:: api_demo_layout.compact_function + :noindex: +``` + +```{package-reference} sphinx-autodoc-typehints-gp +``` diff --git a/docs/packages/sphinx-fonts.md b/docs/packages/sphinx-fonts.md index 0f303133..18fb5a75 100644 --- a/docs/packages/sphinx-fonts.md +++ b/docs/packages/sphinx-fonts.md @@ -1,6 +1,7 @@ # sphinx-fonts -{bdg-success-line}`Beta` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` +```{gp-sphinx-package-meta} sphinx-fonts +``` Sphinx extension for self-hosted web fonts via Fontsource. It downloads font assets during the HTML build, caches them locally, copies them into @@ -76,7 +77,7 @@ The extension injects these values during `html-page-context`: - Fonts are cached under `~/.cache/sphinx-fonts`. - Non-HTML builders return early and do not download assets. -- `sphinx-gptheme` consumes this template context automatically; `gp-sphinx` preconfigures IBM Plex defaults for it. +- `sphinx-gp-theme` consumes this template context automatically; `gp-sphinx` preconfigures IBM Plex defaults for it. ```{package-reference} sphinx-fonts ``` diff --git a/docs/packages/sphinx-gptheme.md b/docs/packages/sphinx-gp-theme.md similarity index 86% rename from docs/packages/sphinx-gptheme.md rename to docs/packages/sphinx-gp-theme.md index 390a8ed6..67b016a2 100644 --- a/docs/packages/sphinx-gptheme.md +++ b/docs/packages/sphinx-gp-theme.md @@ -1,20 +1,21 @@ -# sphinx-gptheme +# sphinx-gp-theme -{bdg-success-line}`Beta` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` +```{gp-sphinx-package-meta} sphinx-gp-theme +``` Furo child theme for git-pull documentation sites. It keeps Furo’s responsive layout and dark mode, then layers in shared sidebars, typography, source-link controls, metadata toggles, and SPA-style navigation. ```console -$ pip install sphinx-gptheme +$ pip install sphinx-gp-theme ``` ## Downstream `conf.py` ```python -extensions = ["sphinx_gptheme"] -html_theme = "sphinx-gptheme" +extensions = ["sphinx_gp_theme"] +html_theme = "sphinx-gp-theme" html_theme_options = { "project_name": "my-project", @@ -29,7 +30,7 @@ html_theme_options = { ## Live theme notes -- This site is rendered with `sphinx-gptheme`. +- This site is rendered with `sphinx-gp-theme`. - The package badges, cards, sidebar project list, and deferred page transitions on this page are live theme output. - Dark mode is inherited from Furo; the theme options below control the extra git-pull behavior layered on top. @@ -78,7 +79,7 @@ Options declared in `theme.conf` and accepted through `html_theme_options`: pre-populates `source_repository`, `source_branch`, `source_directory`, footer icons, and the IBM Plex font stacks consumed by the theme templates. -```{package-reference} sphinx-gptheme +```{package-reference} sphinx-gp-theme ``` -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gptheme) · [PyPI](https://pypi.org/project/sphinx-gptheme/) +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-theme) · [PyPI](https://pypi.org/project/sphinx-gp-theme/) diff --git a/docs/packages/sphinx-ux-autodoc-layout.md b/docs/packages/sphinx-ux-autodoc-layout.md new file mode 100644 index 00000000..aa45859c --- /dev/null +++ b/docs/packages/sphinx-ux-autodoc-layout.md @@ -0,0 +1,167 @@ +(sphinx-ux-autodoc-layout)= + +# sphinx-ux-autodoc-layout + +```{gp-sphinx-package-meta} sphinx-ux-autodoc-layout +``` + +:::{admonition} Alpha +:class: warning + +Rendered output is stable. The Python API, CSS class names, and Sphinx +config value names may change without a major version bump. Pin your +dependency to a specific version range in production. +::: + +Wraps contiguous `desc_content` runs into semantic `api_region` nodes +and rebuilds Python autodoc entries into stable `api-*` components. +Large field-list parameter sections still use native `
/`, +while inline signature expansion uses a custom disclosure that reveals +Sphinx's native multiline parameter-list rendering. + +It is now the shared presenter for the whole autodoc family. `desc`-backed +entries use it directly, and section-card consumers reuse the same inner shell +through the public `build_api_card_entry()` helper. + +```console +$ pip install sphinx-ux-autodoc-layout +``` + +## Pipeline position + +Hooks `doctree-resolved` at priority **600**, after `sphinx-autodoc-api-style` +at 500. Consumes the `api_slot` nodes that producer packages inject into +`desc_signature` during earlier transforms, and composes them into the final +`gp-sphinx-api-layout-right` subcomponent (badges, source link, permalink). + +The extension also overrides Sphinx's built-in `desc_signature` HTML visitor +(`app.add_node(addnodes.desc_signature, override=True, ...)`). This is a +deliberate platform decision: taking ownership of signature rendering allows +the `gp-sphinx-api-link` permalink to be placed inside the managed layout rather than +appended by Sphinx's default handler. + +| Event | Hook | Priority | +|-------|------|----------| +| `doctree-resolved` | `on_doctree_resolved` | 600 (after api-style at 500) | +| `object-description-transform` | — | not used | + +## Downstream `conf.py` + +With `gp-sphinx`: + +```python +conf = merge_sphinx_config( + project="my-project", + version="1.0.0", + copyright="2026, Your Name", + source_repository="https://github.com/your-org/my-project/", + extra_extensions=["sphinx_ux_autodoc_layout"], + api_layout_enabled=True, + api_collapsed_threshold=10, +) +``` + +Or without `merge_sphinx_config`: + +```python +extensions = ["sphinx.ext.autodoc", "sphinx_ux_autodoc_layout"] +api_layout_enabled = True +``` + +## Working usage examples + +Render one compact function: + +````myst +```{eval-rst} +.. autofunction:: my_project.api.compact_function +``` +```` + +Render a class with grouped content regions and member entries: + +````myst +```{eval-rst} +.. autoclass:: my_project.api.LayoutDemo + :members: +``` +```` + +## Live demos + +```{py:module} api_demo_layout +``` + +### Class with members (regions + fold) + +```{eval-rst} +.. autoclass:: api_demo_layout.LayoutDemo + :members: +``` + +The class above renders with: + +- **narrative** region (class docstring) +- **fields** region with fold (13 parameters > threshold of 10) +- **members** region (connect, execute, close methods) + +### Small function (no fold) + +```{eval-rst} +.. autofunction:: api_demo_layout.compact_function +``` + +## Configuration + +| Setting | Default | Meaning | +|---------|---------|---------| +| `api_layout_enabled` | `False` | Enables the transform | +| `api_fold_parameters` | `True` | Folds large field-list sections | +| `api_collapsed_threshold` | `10` | Minimum field count before folding | +| `api_signature_show_annotations` | `True` | Shows `name: type` in expanded folded signatures when type data is available | + +## Shared helper surface + +- `build_api_card_entry()` builds the shared inner `api-*` shell for + section-card consumers such as FastMCP. +- `build_api_summary_section()` wraps summary and index tables in the shared + `gp-sphinx-api-summary` region. + +## CSS classes + +| Class | Element | Purpose | +|-------|---------|---------| +| `gp-sphinx-api-container` | `
` | Managed autodoc shell | +| `gp-sphinx-api-header` | `
` | Signature row shell | +| `gp-sphinx-api-content` | `
` | Description/content shell | +| `gp-sphinx-api-layout` | `
` | Header split between left and right | +| `gp-sphinx-api-layout-left` | `
` | Signature text, custom disclosure, permalink | +| `gp-sphinx-api-layout-right` | `
` | Badge container and source link | +| `gp-sphinx-api-signature` | `
` | Compact signature row | +| `gp-sphinx-api-link` | `` | Managed permalink in the left layout | +| `gp-sphinx-api-badge-container` | `` | Wrapper for badge group output | +| `gp-sphinx-api-source-link` | `` | Wrapper for the `[source]` link | +| `gp-sphinx-api-description` | `
` | Wraps paragraphs, notes, examples | +| `gp-sphinx-api-parameters` | `
` | Wraps field lists (Parameters, Returns) | +| `gp-sphinx-api-footer` | `
` | Wraps nested method/attribute entries | +| `gp-sphinx-api-region` | `
` | Compatibility alias on content sections | +| `gp-sphinx-api-region--narrative` | `
` | Compatibility alias on narrative sections | +| `gp-sphinx-api-region--fields` | `
` | Compatibility alias on parameter sections | +| `gp-sphinx-api-region--members` | `
` | Compatibility alias on footer/member sections | +| `gp-sphinx-api-fold` | `
` | Disclosure wrapper for large sections | +| `gp-sphinx-api-fold-summary` | `` | Click target showing field count | + +## API reference + +```{eval-rst} +.. autofunction:: sphinx_ux_autodoc_layout.build_api_card_entry + +.. autofunction:: sphinx_ux_autodoc_layout.build_api_summary_section + +.. autofunction:: sphinx_ux_autodoc_layout.build_api_table_section + +.. autofunction:: sphinx_ux_autodoc_layout.build_api_facts_section +``` + +```{package-reference} sphinx-ux-autodoc-layout +``` diff --git a/docs/packages/sphinx-ux-badges.md b/docs/packages/sphinx-ux-badges.md new file mode 100644 index 00000000..73996aba --- /dev/null +++ b/docs/packages/sphinx-ux-badges.md @@ -0,0 +1,394 @@ +(sphinx-ux-badges)= + +# sphinx-ux-badges + +```{gp-sphinx-package-meta} sphinx-ux-badges +``` + +:::{admonition} Alpha +:class: warning + +Rendered output is stable. The Python API, CSS class names, and Sphinx +config value names may change without a major version bump. Pin your +dependency to a specific version range in production. +::: + +Shared badge node, HTML visitors, and CSS infrastructure for Sphinx autodoc +extensions. Provides a single `BadgeNode` and builder API that +{doc}`sphinx-autodoc-api-style`, {doc}`sphinx-autodoc-pytest-fixtures`, and +{doc}`sphinx-autodoc-fastmcp` share instead of reimplementing badges +independently. + +```console +$ pip install sphinx-ux-badges +``` + +## Live demos + +Every variant rendered by the real `build_badge` / `build_badge_group` / +`build_toolbar` API: + +```{gp-sphinx-badge-demo} +``` + +## Working usage examples + +`setup()` registers the extension with Sphinx: + +1. {py:meth}`~sphinx.application.Sphinx.add_node` registers `BadgeNode` with + HTML visitors (`visit_badge_html` / `depart_badge_html`). +2. {py:meth}`~sphinx.application.Sphinx.add_css_file` injects the shared + `sphinx_ux_badges.css` stylesheet. +3. Downstream extensions call + {py:meth}`~sphinx.application.Sphinx.setup_extension` to load the badge + layer: + +```python +def setup(app: Sphinx) -> dict[str, Any]: + app.setup_extension("sphinx_ux_badges") +``` + +`BadgeNode` subclasses {py:class}`docutils.nodes.inline`, so unregistered +builders (text, LaTeX, man) fall back to `visit_inline` via Sphinx's +MRO-based dispatch — no special handling needed. + +Build a grouped toolbar in your own directive or transform: + +```python +from sphinx_ux_badges import build_badge, build_badge_group, build_toolbar + +badge_group = build_badge_group( + [ + build_badge( + "readonly", + tooltip="Read-only operation", + classes=["gp-sphinx-fastmcp__safety-readonly"], + ), + build_badge( + "tool", + tooltip="FastMCP tool entry", + classes=["gp-sphinx-fastmcp__type-tool"], + ), + ], +) +toolbar = build_toolbar(badge_group, classes=["my-extension-toolbar"]) +``` + +## Colour palette + +All semantic badge colours live in `sab_palettes.css` (registered by +this extension). Every `sphinx-autodoc-*` package uses the `SAB.*` +constants instead of its own colour classes. The live demo below shows +every variant. + +```{list-table} +:header-rows: 1 +:widths: 30 30 40 + +* - Colour class + - `SAB` constant + - Used for +* - `gp-sphinx-badge--type-function` + - `SAB.TYPE_FUNCTION` + - Python functions (blue) +* - `gp-sphinx-badge--type-class` + - `SAB.TYPE_CLASS` + - Python classes (indigo) +* - `gp-sphinx-badge--type-method` + - `SAB.TYPE_METHOD` + - Instance / class / static methods (cyan) +* - `gp-sphinx-badge--type-property` + - `SAB.TYPE_PROPERTY` + - Properties (teal) +* - `gp-sphinx-badge--type-attribute` + - `SAB.TYPE_ATTRIBUTE` + - Attributes (slate) +* - `gp-sphinx-badge--type-data` + - `SAB.TYPE_DATA` + - Module-level data (grey) +* - `gp-sphinx-badge--type-exception` + - `SAB.TYPE_EXCEPTION` + - Exceptions (rose/red) +* - `gp-sphinx-badge--type-typealias` + - `SAB.TYPE_TYPEALIAS` + - Type aliases (violet) +* - `gp-sphinx-badge--type-module` + - `SAB.TYPE_MODULE` + - Modules (green) +* - `gp-sphinx-badge--mod-async` + - `SAB.MOD_ASYNC` + - async modifier (purple outline) +* - `gp-sphinx-badge--mod-classmethod` + - `SAB.MOD_CLASSMETHOD` + - classmethod modifier (amber outline) +* - `gp-sphinx-badge--mod-staticmethod` + - `SAB.MOD_STATICMETHOD` + - staticmethod modifier (grey outline) +* - `gp-sphinx-badge--mod-abstract` + - `SAB.MOD_ABSTRACT` + - abstract modifier (indigo outline) +* - `gp-sphinx-badge--mod-final` + - `SAB.MOD_FINAL` + - final modifier (emerald outline) +* - `gp-sphinx-badge--state-deprecated` + - `SAB.STATE_DEPRECATED` + - deprecated (muted red, shared across domains) +* - `gp-sphinx-badge--type-fixture` + - `SAB.TYPE_FIXTURE` + - pytest fixtures (green) +* - `gp-sphinx-badge--scope-session` + - `SAB.SCOPE_SESSION` + - session-scope fixtures (amber) +* - `gp-sphinx-badge--scope-module` + - `SAB.SCOPE_MODULE` + - module-scope fixtures (teal) +* - `gp-sphinx-badge--scope-class` + - `SAB.SCOPE_CLASS` + - class-scope fixtures (slate) +* - `gp-sphinx-badge--state-factory` + - `SAB.STATE_FACTORY` + - factory fixtures (amber outline) +* - `gp-sphinx-badge--state-override` + - `SAB.STATE_OVERRIDE` + - override hooks (violet outline) +* - `gp-sphinx-badge--state-autouse` + - `SAB.STATE_AUTOUSE` + - autouse fixtures (rose outline) +* - `gp-sphinx-badge--type-config` + - `SAB.TYPE_CONFIG` + - Sphinx config values (amber) +* - `gp-sphinx-badge--mod-rebuild` + - `SAB.MOD_REBUILD` + - Sphinx rebuild mode (grey outline) +* - `gp-sphinx-badge--type-directive` + - `SAB.TYPE_DIRECTIVE` + - docutils directives (violet) +* - `gp-sphinx-badge--type-role` + - `SAB.TYPE_ROLE` + - docutils roles (violet) +* - `gp-sphinx-badge--type-option` + - `SAB.TYPE_OPTION` + - docutils directive options (violet) +``` + +## API reference + +```{eval-rst} +.. autoclass:: sphinx_ux_badges.BadgeSpec + :members: + +.. autofunction:: sphinx_ux_badges.build_badge_from_spec + +.. autofunction:: sphinx_ux_badges.build_badge + +.. autofunction:: sphinx_ux_badges.build_badge_group + +.. autofunction:: sphinx_ux_badges.build_toolbar + +.. autoclass:: sphinx_ux_badges.BadgeNode + :no-members: + + .. rubric:: Constructor parameters + + .. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Parameter + - Default + - Description + * - ``text`` + - ``""`` + - Visible label. Empty string for icon-only badges. + * - ``badge_tooltip`` + - ``""`` + - Hover text and ``aria-label``. + * - ``badge_icon`` + - ``""`` + - Emoji character rendered via CSS ``::before``. + * - ``badge_style`` + - ``"full"`` + - Structural variant: ``"full"``, ``"icon-only"``, ``"inline-icon"``. + * - ``badge_size`` + - ``""`` + - Optional size: ``"xxs"``, ``"xs"``, ``"sm"``, ``"md"``, ``"lg"``, or ``"xl"``. Empty means default. + * - ``tabindex`` + - ``"0"`` + - ``"0"`` for keyboard-focusable, ``""`` to skip. + * - ``classes`` + - ``None`` + - Additional CSS classes (plugin prefix + color class). + +.. autoclass:: sphinx_ux_badges._css.SAB + :members: + :undoc-members: + +.. autofunction:: sphinx_ux_badges.setup +``` + +## CSS custom properties + +All colors and metrics are exposed as CSS custom properties on `:root`. +Override them in your project's `custom.css` or via +{py:meth}`~sphinx.application.Sphinx.add_css_file`. + +### Defaults + +```css +:root { + /* ── Color hooks (set by downstream extensions) ────── */ + --gp-sphinx-badge-bg: transparent; /* badge background */ + --gp-sphinx-badge-fg: inherit; /* badge text color */ + --gp-sphinx-badge-border: none; /* badge border shorthand */ + + /* ── Metrics ───────────────────────────────────────── */ + --gp-sphinx-badge-font-size: 0.75em; + --gp-sphinx-badge-font-weight: 700; + --gp-sphinx-badge-padding-v: 0.35em; /* vertical padding */ + --gp-sphinx-badge-padding-h: 0.65em; /* horizontal padding */ + --gp-sphinx-badge-radius: 0.25rem; /* border-radius */ + --gp-sphinx-badge-icon-gap: 0.28rem; /* gap between icon and label */ + + /* ── Depth (inset shadow on solid badges) ──────────── */ + --gp-sphinx-badge-buff-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.2), + inset 0 -1px 2px rgba(0, 0, 0, 0.12); + --gp-sphinx-badge-buff-shadow-dark-ui: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + inset 0 -1px 2px rgba(0, 0, 0, 0.28); +} +``` + +### Property reference + +```{list-table} +:header-rows: 1 +:widths: 30 70 + +* - Property + - Purpose +* - `--gp-sphinx-badge-bg` + - Badge background color. Extensions set this per badge class (e.g. green for "readonly"). +* - `--gp-sphinx-badge-fg` + - Badge text color. Falls back to `inherit` when unset. +* - `--gp-sphinx-badge-border` + - Border shorthand (`1px solid #...`). Defaults to `none`. +* - `--gp-sphinx-badge-font-size` + - Font size. Context-aware sizing (headings, body, TOC) overrides this. +* - `--gp-sphinx-badge-font-weight` + - Font weight. Default `700` (bold). +* - `--gp-sphinx-badge-padding-v` / `--gp-sphinx-badge-padding-h` + - Vertical and horizontal padding. +* - `--gp-sphinx-badge-radius` + - Border radius for pill shape. +* - `--gp-sphinx-badge-icon-gap` + - Gap between the `::before` icon and the label text. +* - `--gp-sphinx-badge-buff-shadow` + - Subtle inset highlight + shadow for depth on light backgrounds. +* - `--gp-sphinx-badge-buff-shadow-dark-ui` + - Stronger inset shadow variant for dark theme / `prefers-color-scheme: dark`. +``` + +## CSS class reference + +All classes use the `sab-` prefix (**s**phinx **a**utodoc **b**adges). + +```{list-table} +:header-rows: 1 +:widths: 25 15 60 + +* - Class + - Applied by + - Description +* - `gp-sphinx-badge` + - `BadgeNode` + - Base class. Always present on every badge. +* - `gp-sphinx-badge--outline` + - `build_badge(fill="outline")` + - Transparent background, inherits text color. +* - `gp-sphinx-badge--icon-only` + - `build_badge(style="icon-only")` + - 16 × 16 colored box with emoji `::before`. +* - `gp-sphinx-badge--inline-icon` + - `build_badge(style="inline-icon")` + - Bare emoji inside a code chip, no background. +* - `gp-sphinx-badge-group` + - `build_badge_group()` + - Flex container with `gap: 0.3rem` between badges. +* - `gp-sphinx-toolbar` + - `build_toolbar()` + - Flex push-right (`margin-left: auto`) for title rows. +* - `gp-sphinx-badge--size-xxs` + - `build_badge(size="xxs")` / `BadgeNode(..., badge_size="xxs")` + - Minimum size (status dots, very tight layouts). +* - `gp-sphinx-badge--size-xs` + - `build_badge(size="xs")` / `BadgeNode(..., badge_size="xs")` + - Extra small (dense tables, tight UI). +* - `gp-sphinx-badge--size-sm` + - `build_badge(size="sm")` + - Small inline badges. +* - `gp-sphinx-badge--size-md` + - `build_badge(size="md")` + - Medium — larger than the default but smaller than `lg`. +* - `gp-sphinx-badge--size-lg` + - `build_badge(size="lg")` + - Large (section titles, callouts). +* - `gp-sphinx-badge--size-xl` + - `build_badge(size="xl")` + - Extra large (hero / landing emphasis). +``` + +## Context-aware sizing + +Badge size adapts automatically based on where it appears in the document. +CSS selectors handle it. Explicit size classes (`gp-sphinx-badge--size-xs` … `gp-sphinx-badge--size-xl`) override +contextual sizing when present (higher specificity than context rules). + +```{list-table} +:header-rows: 1 +:widths: 25 20 55 + +* - Context + - Font size + - Selectors +* - Heading (`h2`, `h3`) + - `0.68rem` + - `.body h2 .gp-sphinx-badge`, `[role="main"] h3 .gp-sphinx-badge` +* - Body (`p`, `li`, `td`, `a`) + - `0.62rem` + - `.body p .gp-sphinx-badge`, `[role="main"] li .gp-sphinx-badge`, etc. +* - TOC sidebar + - `0.58rem` + - `.toc-tree .gp-sphinx-badge` (compact, with emoji icons) +``` + +## Downstream extensions + +All colour variants are provided by the shared palette above. Downstream +extensions reference `SAB.*` constants instead of maintaining their own +`sab-*` / `spf-*` / `sas-*` / `sadoc-*` colour classes. + +```{list-table} +:header-rows: 1 +:widths: 35 65 + +* - Extension + - Badge types used +* - {doc}`sphinx-autodoc-fastmcp` + - Safety tiers (readonly / mutating / destructive), MCP tool type (`smf-*` — FastMCP-specific colours not in shared palette) +* - {doc}`sphinx-autodoc-api-style` + - `SAB.TYPE_FUNCTION`, `SAB.TYPE_CLASS`, `SAB.TYPE_METHOD`, modifiers, `SAB.STATE_DEPRECATED` +* - {doc}`sphinx-autodoc-pytest-fixtures` + - `SAB.TYPE_FIXTURE`, `SAB.SCOPE_*`, `SAB.STATE_FACTORY`, `SAB.STATE_OVERRIDE`, `SAB.STATE_AUTOUSE` +* - {doc}`sphinx-autodoc-sphinx` + - `SAB.TYPE_CONFIG`, `SAB.MOD_REBUILD` +* - {doc}`sphinx-autodoc-docutils` + - `SAB.TYPE_DIRECTIVE`, `SAB.TYPE_ROLE`, `SAB.TYPE_OPTION` +``` + +## Package reference + +```{package-reference} sphinx-ux-badges +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-ux-badges) · [PyPI](https://pypi.org/project/sphinx-ux-badges/) diff --git a/docs/project/contributing.md b/docs/project/contributing.md index 6b5e3e60..97dfdd06 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -20,14 +20,49 @@ $ uv sync --all-packages --all-extras --group dev ## Tests +Preferred local commands use a fixed pytest temp root under `.cache/` and disable +tmp-path retention for speed. `just test` keeps full coverage, while +`just test-fast` is a feedback loop only and intentionally excludes +`integration` tests: + +```console +$ just test +``` + ```console -$ uv run py.test +$ uv run pytest ``` +Use raw `uv run pytest` when you want the conservative direct runner without the +local temp-dir optimization. + +Fast local loop without doctest-modules or integration tests: + +```console +$ just test-fast +``` + +Canonical direct pytest command for the same fast lane: + +```console +$ uv run pytest \ + -o "addopts=--tb=short --no-header --showlocals" \ + -o tmp_path_retention_policy=none \ + --basetemp="$(pwd)/.cache/pytest-fast-direct" \ + -q \ + --capture=tee-sys \ + tests \ + -m "not integration" +``` + +Do not use the fast lane to reason about full-suite coverage or total suite +performance; it is intentionally deselected for local iteration. + ### Automatically run tests on file save -1. `just start` (via [pytest-watcher]) -2. `just watch-test` (requires installing [entr(1)]) +1. `just start` (via [pytest-watcher], full local lane) +2. `just start-fast` for the fast local loop +3. `just watch-test` (requires installing [entr(1)]) [pytest-watcher]: https://github.com/olzhasar/pytest-watcher @@ -53,6 +88,51 @@ Rebuild docs on file change: `just watch-docs` (requires [entr(1)]) Rebuild docs and run server via one terminal: `just dev-docs` (requires above) +## Test hierarchy + +Pick the **lightest** level that exercises the behavior: + +| Level | When to use | Speed | +|---|---|---| +| **Pure unit** | Strings, dicts, dataclasses — no nodes, no Sphinx | microseconds | +| **Docutils tree unit** | Constructing `docutils.nodes.*` or `sphinx.addnodes.*` directly | microseconds | +| **Snapshot unit** | Large or complex output — assert via `snapshot_doctree` | microseconds | +| **Sphinx integration** (`@pytest.mark.integration`) | Must verify actual HTML output or Sphinx event wiring | 2–10 s | + +The `just test-fast` lane skips integration tests for rapid feedback. +The full `just test` lane runs everything. + +### Scenario caching + +Integration tests use the harness in `tests/_sphinx_scenarios.py`. +`build_shared_sphinx_result()` caches builds by a SHA-256 content-hash +digest, achieving a **9.5x speedup** (~40 s to ~4.2 s for 916 tests). + +Key rules: + +- Always `scope="module"` or `scope="session"` on build fixtures — never + `scope="function"` +- Use `purge_modules` to remove synthetic Python modules from `sys.modules` + before the initial build +- Use `SCENARIO_SRCDIR_TOKEN` + `substitute_srcdir=True` for `sys.path` + injection in scenario `conf.py` files + +### Snapshot testing + +The project uses [syrupy](https://github.com/toptal/syrupy) for snapshot +assertions. Three custom fixtures (from `tests/_snapshots.py`) normalize +their inputs before asserting: + +- `snapshot_doctree(doctree)` — normalizes a `nodes.Node` +- `snapshot_html_fragment(html)` — strips ANSI, normalizes whitespace +- `snapshot_warnings(warnings)` — strips noise lines and ANSI codes + +Update stored snapshots after intentional output changes: + +```console +$ uv run pytest --snapshot-update +``` + [git]: https://git-scm.com/ [uv]: https://github.com/astral-sh/uv [entr(1)]: http://eradman.com/entrproject/ diff --git a/docs/quickstart.md b/docs/quickstart.md index 8c302049..6bea04ba 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -85,7 +85,7 @@ globals().update(conf) ```python conf = merge_sphinx_config( # ... - extra_extensions=["sphinx_argparse_neo.exemplar", "sphinx_click"], + extra_extensions=["sphinx_autodoc_argparse.exemplar", "sphinx_click"], ) ``` @@ -135,6 +135,75 @@ $ uv run sphinx-build -b html docs docs/_build/html Open `docs/_build/html/index.html` in your browser to see the result. +## Seeing the autodoc design system + +The build above renders a Furo-themed page with IBM Plex fonts. To see the +full autodoc stack — badges, type hints, and card layout — document a Python +module. + +Create a file `my_module.py` next to your `docs/` directory: + +```python +"""Demo module for the autodoc design system.""" +from __future__ import annotations + +from typing import Any + + +def get_user( + *, + user_id: int, + use_cache: bool = True, +) -> dict[str, Any]: + """Fetch a user from the database. + + Parameters + ---------- + user_id : int + The ID of the user to fetch. + use_cache : bool + If ``True``, attempts to use a cache. + + Returns + ------- + dict[str, Any] + A dictionary of user properties. + """ + return {"id": user_id, "name": "Demo User"} +``` + +Enable the API style extension in your `docs/conf.py`: + +```python +conf = merge_sphinx_config( + # ... existing parameters ... + extra_extensions=["sphinx_autodoc_api_style"], +) +``` + +Create `docs/api.md`: + +````markdown +# API Reference + +```{eval-rst} +.. automodule:: my_module + :members: +``` +```` + +Rebuild: + +```console +$ uv run sphinx-build -b html docs docs/_build/html +``` + +Open `docs/_build/html/api.html`. The function renders with type and modifier +**badges**, clean **type hints** with cross-referenced links, and a **card +layout** with parameter sections. + +See the {doc}`gallery` for a full showcase of every component. + [pip]: https://pip.pypa.io/en/stable/ [pipx]: https://pypa.github.io/pipx/docs/ [uv]: https://docs.astral.sh/uv/ diff --git a/docs/redirects.txt b/docs/redirects.txt index 62b2fdcd..b982b86d 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -1,11 +1,23 @@ extensions/gp-sphinx packages/gp-sphinx extensions/index packages/index -extensions/sphinx-argparse-neo packages/sphinx-argparse-neo +extensions/sphinx-autodoc-argparse packages/sphinx-autodoc-argparse extensions/sphinx-autodoc-pytest-fixtures packages/sphinx-autodoc-pytest-fixtures extensions/sphinx-autodoc-docutils packages/sphinx-autodoc-docutils extensions/sphinx-autodoc-sphinx packages/sphinx-autodoc-sphinx extensions/sphinx-autodoc-api-style packages/sphinx-autodoc-api-style -extensions/sphinx-autodoc-badges packages/sphinx-autodoc-badges +extensions/sphinx-ux-badges packages/sphinx-ux-badges extensions/sphinx-autodoc-fastmcp packages/sphinx-autodoc-fastmcp +extensions/sphinx-ux-autodoc-layout packages/sphinx-ux-autodoc-layout extensions/sphinx-fonts packages/sphinx-fonts -extensions/sphinx-gptheme packages/sphinx-gptheme +extensions/sphinx-gp-theme packages/sphinx-gp-theme +extensions/sphinx-autodoc-typehints-gp packages/sphinx-autodoc-typehints-gp +extensions/sphinx-argparse-neo packages/sphinx-autodoc-argparse +extensions/sphinx-gptheme packages/sphinx-gp-theme +extensions/sphinx-typehints-gp packages/sphinx-autodoc-typehints-gp +extensions/sphinx-autodoc-badges packages/sphinx-ux-badges +packages/sphinx-autodoc-badges packages/sphinx-ux-badges +extensions/sphinx-autodoc-layout packages/sphinx-ux-autodoc-layout +packages/sphinx-autodoc-layout packages/sphinx-ux-autodoc-layout +packages/sphinx-argparse-neo packages/sphinx-autodoc-argparse +packages/sphinx-gptheme packages/sphinx-gp-theme +packages/sphinx-typehints-gp packages/sphinx-autodoc-typehints-gp diff --git a/docs/whats-new.md b/docs/whats-new.md new file mode 100644 index 00000000..20d4971c --- /dev/null +++ b/docs/whats-new.md @@ -0,0 +1,78 @@ +(whats-new)= + +# What's new + +The `autodoc-improvements` branch introduces a unified **autodoc design +system** — eight major advancements that transform how the documentation +stack works. See the {doc}`gallery` for a visual showcase. + +## New packages + +Two new foundational packages form the core of the rendering pipeline: + +- {doc}`sphinx-ux-autodoc-layout ` — componentized + autodoc output with semantic regions, parameter folding, managed signatures, + and card containers. +- {doc}`sphinx-autodoc-typehints-gp ` — single-package + replacement for `sphinx-autodoc-typehints` and `sphinx.ext.napoleon`. + Resolves annotations statically at build time with no monkey-patching. + +## Unified badge system + +All badge colours have been consolidated into +{doc}`sphinx-ux-badges `. Every +downstream package references `SAB.*` constants instead of maintaining its +own colour classes — one palette, thirty-plus colour variants, full +light/dark theming. + +## Shared layout stack + +The six domain packages +({doc}`api-style `, +{doc}`argparse `, +{doc}`docutils `, +{doc}`fastmcp `, +{doc}`pytest-fixtures `, +{doc}`sphinx `) +now all share the same layout, badge, and typehint infrastructure. A +change in the foundational layout package propagates instantly and +consistently. + +## argparse Sphinx domain + +{doc}`sphinx-autodoc-argparse ` now +ships a real Sphinx `Domain` subclass. Programs, options, subcommands, +and positional arguments are individually addressable via +`:argparse:program:`, `:argparse:option:`, `:argparse:subcommand:`, and +`:argparse:positional:` xref roles. Two auto-generated indices — +`argparse-programsindex` (alphabetised programs) and +`argparse-optionsindex` (options grouped by program) — give a workspace +overview. `:option:` / `std:cmdoption` continues to resolve for +intersphinx consumers. + +## Three-tier package organization + +The workspace has been restructured into a clear {doc}`three-tier +architecture `: shared infrastructure at the bottom, domain +packages in the middle, and the theme/coordinator at the top. Lower +layers never depend on higher ones. + +## 9.5x test speedup + +Shared Sphinx scenario caching via `tests/_sphinx_scenarios.py` reduced +full-suite runtime from ~40 s to ~4.2 s for 916 tests. Builds are keyed +by a SHA-256 content-hash digest and reused across all tests that share +the same scenario. + +## Snapshot testing + +[Syrupy](https://github.com/toptal/syrupy) snapshot assertions lock in +doctree structure, rendered HTML, and warning output. Three custom +fixtures normalize build-path churn and docutils version noise so that +snapshots stay stable across environments. + +## Doctree-first testing + +The majority of tests now operate directly on the docutils doctree — +constructing `nodes.*` objects in Python — instead of running full Sphinx +builds. This makes tests faster, more precise, and easier to debug. diff --git a/justfile b/justfile index 3d3be0d6..e1493622 100644 --- a/justfile +++ b/justfile @@ -7,27 +7,77 @@ set shell := ["bash", "-uc"] py_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]py$' 2> /dev/null" doc_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]rst$\\|.*[.]md$\\|.*[.]css$\\|.*[.]py$\\|mkdocs\\.yml\\|CHANGES\\|TODO\\|.*conf\\.py' 2> /dev/null" all_files := "find . -type f -not -path '*/\\.*' | grep -i '.*[.]py$\\|.*[.]rst$\\|.*[.]md$\\|.*[.]css$\\|.*[.]py$\\|mkdocs\\.yml\\|CHANGES\\|TODO\\|.*conf\\.py' 2> /dev/null" +fast_test_addopts := "--tb=short --no-header --showlocals" +pytest_local_opts := "-o tmp_path_retention_policy=none" +pytest_full_basetemp := ".cache/pytest-full" +pytest_fast_basetemp := ".cache/pytest-fast" +pytest_watch_basetemp := ".cache/pytest-watch" +pytest_fast_watch_basetemp := ".cache/pytest-fast-watch" # List all available commands default: @just --list # Run tests with pytest -[group: 'test'] test *args: - uv run py.test {{ args }} + #!/usr/bin/env bash + set -euo pipefail + mkdir -p .cache + cache_root="$(pwd)/{{ pytest_full_basetemp }}" + recipe_args="{{ args }}" + if [[ -n "${recipe_args// }" ]]; then + uv run pytest \ + -s \ + {{ pytest_local_opts }} \ + --basetemp="${cache_root}" \ + {{ args }} + else + uv run pytest \ + -s \ + {{ pytest_local_opts }} \ + --basetemp="${cache_root}" + fi + +# Run the fast local test lane without doctest-modules or integration tests +test-fast: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p .cache + cache_root="$(pwd)/{{ pytest_fast_basetemp }}" + uv run pytest \ + {{ pytest_local_opts }} \ + -o "addopts={{ fast_test_addopts }}" \ + --basetemp="${cache_root}" \ + -q \ + --capture=tee-sys \ + tests \ + -m "not integration" # Run tests then start continuous testing with pytest-watcher -[group: 'test'] start: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p .cache + cache_root="$(pwd)/{{ pytest_watch_basetemp }}" just test - uv run ptw . + uv run ptw . \ + --runner "uv run pytest -s {{ pytest_local_opts }} --basetemp=${cache_root}" + +# Run the fast local test lane continuously with pytest-watcher +start-fast: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p .cache + cache_root="$(pwd)/{{ pytest_fast_watch_basetemp }}" + just test-fast + uv run ptw . \ + --runner "uv run pytest {{ pytest_local_opts }} -o \"addopts={{ fast_test_addopts }}\" --basetemp=${cache_root} -q --capture=tee-sys tests -m \"not integration\"" # Watch files and run tests on change (requires entr) -[group: 'test'] watch-test: #!/usr/bin/env bash set -euo pipefail + mkdir -p .cache if command -v entr > /dev/null; then {{ all_files }} | entr -c just test else @@ -36,12 +86,10 @@ watch-test: fi # Build documentation -[group: 'docs'] build-docs: just -f docs/justfile html # Watch files and rebuild docs on change -[group: 'docs'] watch-docs: #!/usr/bin/env bash set -euo pipefail @@ -53,12 +101,10 @@ watch-docs: fi # Serve documentation -[group: 'docs'] serve-docs: just -f docs/justfile serve # Watch and serve docs simultaneously -[group: 'docs'] dev-docs: #!/usr/bin/env bash set -euo pipefail @@ -66,27 +112,22 @@ dev-docs: just serve-docs # Start documentation server with auto-reload -[group: 'docs'] start-docs: just -f docs/justfile start # Start documentation design mode (watches static files) -[group: 'docs'] design-docs: just -f docs/justfile design # Format code with ruff -[group: 'lint'] ruff-format: uv run ruff format . # Run ruff linter -[group: 'lint'] ruff: uv run ruff check . # Watch files and run ruff on change -[group: 'lint'] watch-ruff: #!/usr/bin/env bash set -euo pipefail @@ -98,12 +139,10 @@ watch-ruff: fi # Run mypy type checker -[group: 'lint'] mypy: uv run mypy $({{ py_files }}) # Watch files and run mypy on change -[group: 'lint'] watch-mypy: #!/usr/bin/env bash set -euo pipefail @@ -122,3 +161,20 @@ _entr-warn: @echo "Install entr(1) to automatically run tasks on file change." @echo "See https://eradman.com/entrproject/ " @echo "----------------------------------------------------------" + +# Bump the workspace-wide version string in all pyproject.toml files. +# Usage: just bump-version 0.0.1a8 +bump-version new_version: + @echo "Bumping workspace version to {{new_version}}..." + uv run python -c "\ + import pathlib, re; \ + old = re.search(r'version\s*=\s*\"([^\"]+)\"', \ + pathlib.Path('pyproject.toml').read_text()).group(1); \ + print(f' {old} -> {{new_version}}'); \ + [p.write_text(p.read_text().replace(old, '{{new_version}}')) \ + for p in sorted(pathlib.Path('.').glob('**/pyproject.toml')) \ + if old in p.read_text()]; \ + " + uv lock + uv run python scripts/ci/package_tools.py check-versions + @echo "Done. Review with: git diff '**/pyproject.toml' uv.lock" diff --git a/notes/test-analysis.md b/notes/test-analysis.md new file mode 100644 index 00000000..1d16dc21 --- /dev/null +++ b/notes/test-analysis.md @@ -0,0 +1,506 @@ +# Test And Autodoc Analysis + +This note records the current post-shared-stack state of the autodoc +extensions, the test harnesses in use, and the measured runtime profile of the +suite. + +Two baselines remain non-negotiable: + +- no test was deselected to make the suite appear faster +- timing claims below are based on the full suite or on an explicitly named + slice, not on a reduced-signal shortcut + +Current measured suite status on 2026-04-10: + +- raw full suite: `916 passed, 3 skipped in 40.22s` +- optimized full suite: `916 passed, 3 skipped in 4.24s`, wall `5.44s` + +The dominant conclusions did not change in this wave: + +- honest repo-owned cost is still concentrated in a small set of cached Sphinx + scenario builds +- the largest avoidable raw-runner cost is still pytest tempdir and path + resolution churn +- the new shared layout, shared badge builders, and shared typehint renderers + are not runtime hotspots + +## Final shared-stack architecture + +The shipped `sphinx-autodoc-*` packages now converge on three shared layers for +the responsibilities they actually share: + +- `sphinx_ux_badges` is the only badge primitive and badge-group renderer +- `sphinx_ux_autodoc_layout` is the only shared presenter for `api-*` entry + structure and shared body sections +- `sphinx_autodoc_typehints_gp` is the only owner of canonical annotation + normalization, type text generation, and cross-reference node rendering + +The deliberately preserved low-risk parts of the pipeline are: + +- `confval` and `rst:*` entries still originate as semantic markup and still + flow through `parse_text_to_nodes()` / `parse_generated_markup()` +- final source-link, permalink, and header composition still happens in + `doctree-resolved` +- FastMCP still ships on the section-card outer wrapper path so its table of + contents labels and `:tool:` / `:toolref:` behavior stay stable + +### Shared contracts + +The stable producer handoff remains intentionally small: + +- `api_slot(slot="badges")` +- `api_slot(slot="source-link")` + +The stable visible structure is: + +- `api-container` +- `api-header` +- `api-layout` +- `api-layout-left` +- `api-signature` +- `api-link` +- `api-layout-right` +- `api-badge-container` +- `api-source-link` +- `api-content` +- `api-description` +- `api-facts` +- `api-parameters` +- `api-options` +- `api-footer` + +`.sab-toolbar` remains only as a compatibility shim. The ownership point is +still `api-layout-right`. + +### Shared gaps closed in this wave + +This migration closed the remaining foundation gaps that were still forcing +package-local duplication: + +- `sphinx_ux_autodoc_layout` now exposes a public shared non-`desc` + card-shell builder for section-card consumers +- shared section builders now cover description, facts, parameters, options, + footer, and summary/index table wrappers +- generic card-shell CSS now lives in `sphinx_ux_autodoc_layout` instead of being + repeated in FastMCP +- `sphinx_ux_badges.BadgeSpec` and the shared badge-group builder are now + the canonical badge pipeline +- `sphinx_autodoc_typehints_gp` now provides reusable annotation helpers instead of + requiring package-local stringification and xref rendering +- `sphinx_autodoc_typehints_gp.AnnotationDisplay` and + `classify_annotation_display()` now cover literal-only enum displays so + FastMCP and other consumers no longer need local enum heuristics + +## Rendering hook points and extension ownership + +The current rendering path is: + +1. Sphinx or a package directive creates semantic nodes such as `desc`, + `desc_signature`, `desc_content`, tables, field lists, and literal blocks. +2. Producer packages discover package-specific metadata. +3. Producer packages attach shared slots and shared body sections. +4. `sphinx_ux_autodoc_layout` performs final structural composition in + `doctree-resolved`. +5. HTML visitors and shared CSS provide the final visual layout. + +### What each shared layer owns + +`sphinx_ux_badges` + +- `BadgeSpec` +- badge node construction +- badge-group rendering +- badge CSS primitives + +`sphinx_ux_autodoc_layout` + +- profile-driven desc layout policy +- shared body section wrappers +- shared summary/index presentation wrapper +- desc header composition +- non-`desc` shared card-entry builder +- generic layout CSS for `api-*` regions + +`sphinx_autodoc_typehints_gp` + +- canonical annotation normalization +- structured annotation display classification +- type collection normalization +- annotation-to-node rendering +- paragraph helpers for embedding typed content in facts and tables +- private Sphinx annotation parsing isolation + +### What each producer still owns + +`sphinx_autodoc_api_style` + +- Python metadata discovery +- badge spec production +- source-link metadata production + +`sphinx_autodoc_pytest_fixtures` + +- fixture discovery and store/index ownership +- fixture reference repair +- fixture-specific metadata + +`sphinx_autodoc_sphinx` + +- config value discovery +- semantic `confval` markup generation + +`sphinx_autodoc_docutils` + +- semantic `rst:directive`, `rst:role`, and `rst:directive:option` markup + generation + +`sphinx_autodoc_fastmcp` + +- tool discovery and section wrapper ownership +- tool-specific parameter and return semantics +- current `:tool:` / `:toolref:` behavior + +## Package-by-package migration status + +### `sphinx_autodoc_api_style` + +Now fully moved onto the shared badge and type stack for the parts it owns: + +- badge creation uses `BadgeSpec` +- `setup()` auto-loads `sphinx_autodoc_typehints_gp` +- layout composition remains entirely in `sphinx_ux_autodoc_layout` + +### `sphinx_autodoc_pytest_fixtures` + +Now uses the shared stack for badge, layout, and type rendering: + +- `setup()` auto-loads `sphinx_autodoc_typehints_gp` +- fixture return metadata stores one canonical annotation form +- fixture index type cells use shared annotation paragraph rendering +- top-level metadata wraps into shared `api-facts`, `api-parameters`, and + shared summary/index presentation + +Removed package-local type duplication: + +- removed `return_xref_target` +- removed dead local `_format_type_short` + +### `sphinx_autodoc_sphinx` + +Now uses the shared stack for layout and type text: + +- `setup()` auto-loads `sphinx_autodoc_typehints_gp` +- config type text now comes from shared type normalization +- config indexes now use the shared summary/index wrapper +- config facts now use shared fact sections +- complex defaults still render as real literal blocks + +Removed package-local type duplication: + +- package-local `_render_types` +- package-local `_type_text` + +### `sphinx_autodoc_docutils` + +Still keeps the semantic markup path, but its visible structure is now shared: + +- `setup()` auto-loads `sphinx_autodoc_typehints_gp` +- directives, roles, and options normalize into shared section wrappers +- index tables now use the shared summary/index wrapper + +### `sphinx_autodoc_fastmcp` + +FastMCP remains on the shipped section-card path, but its inner rendering is now +shared: + +- `setup()` auto-loads `sphinx_autodoc_typehints_gp` +- inner card shell uses the shared non-`desc` card-entry builder +- return and parameter type rendering use shared type helpers +- summary tables use the shared summary/index wrapper + +Removed package-local type duplication: + +- local `format_annotation` +- local `make_type_xref` + +The desc-backed prototype remains test-only. + +## Test categories and harnesses in use + +The suite remains mostly surgical. Honest runtime is concentrated in cached +builder-backed scenarios plus raw pytest path handling. + +| Category | Main locations | Harness | Current verdict | +| --- | --- | --- | --- | +| Pure helper and parser tests | `tests/ext/api_style`, `tests/ext/autodoc_sphinx`, `tests/ext/autodoc_docutils`, `tests/ext/fastmcp`, `tests/ext/typehints_gp`, root tests | Direct unit tests for strings, dataclasses, parsers, stores, builders | Already light-weight | +| Shared stack unit tests | `tests/ext/layout/test_render.py`, `tests/ext/test_shared_stack_setup.py`, `tests/ext/typehints_gp/test_unit.py` | Tiny synthetic nodes and direct helper assertions | New wave coverage; fast and precise | +| Doctree and transform tests | `tests/ext/layout/test_transforms.py`, `tests/ext/fastmcp/test_transforms.py`, fixture doctree tests | Synthetic trees, targeted transform calls, tiny dummy builders | Preferred structure harness | +| Visitor/render-node tests | `tests/ext/layout/test_visitors.py` | Direct visitor assertions with translator stubs | Light and precise | +| Snapshot unit tests | `tests/ext/layout/test_snapshots.py`, fixture doctree snapshots | Normalized doctree and HTML-fragment snapshots | Expanded in this wave; preferred over duplicate HTML builds | +| Cached emitted-HTML integration tests | `tests/ext/layout/test_integration.py`, `tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py`, `tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py`, `tests/ext/fastmcp/test_fastmcp_integration.py` | Shared cached Sphinx scenarios | Required for emitted HTML contracts | +| Cross-document, inventory, and text-builder tests | `tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py` | Real HTML and text builds plus `objects.inv` checks | Honest expensive coverage; keep | +| Docs page smoke | `tests/test_docs_package_pages.py` | Focused docs build smoke only where live demo output matters | Narrow and acceptable | +| Doctests | selected package modules and docs helpers | `--doctest-modules` capable helpers | Required by repo rules | + +## Which tests moved from full builds to lighter harnesses + +This wave continued the policy of moving structure assertions off full builds +when builder output is not the contract. + +### Moved to lighter harnesses + +- reusable type rendering now has direct unit coverage in + `tests/ext/typehints_gp/test_unit.py` +- shared non-`desc` card-shell composition now has direct unit coverage in + `tests/ext/layout/test_render.py` +- shared stack auto-loading now has direct unit coverage in + `tests/ext/test_shared_stack_setup.py` +- `api-style` badge-spec adoption has direct unit coverage instead of relying + on HTML inspection +- fixture index type-cell rendering now has unit coverage for shared type node + usage instead of depending on full emitted HTML +- FastMCP shared parsing and shared card structure continue to be checked + primarily by doctree and visitor-level tests + +### Intentionally builder-backed + +These remain on real builders because the builder is the contract: + +- final emitted Python API HTML +- emitted `confval` HTML and config index output +- emitted `rst:*` HTML +- fixture inventory output, genindex output, cross-document HTML links, and + text-builder behavior +- FastMCP section-card HTML plus `:tool:` / `:toolref:` behavior +- docs live-demo smoke where the page itself is the product + +## Benchmark matrix + +### Full suite + +| Command | Result | What it shows | +| --- | --- | --- | +| `uv run py.test --reruns 0 -vvv` | `916 passed, 3 skipped in 40.22s` | Current conservative baseline with full signal and the required validation command | +| `/usr/bin/time -p uv run pytest -q -o tmp_path_retention_policy=none --basetemp=/home/d/work/python/gp-sphinx/.cache/pytest-full-wave6` | `916 passed, 3 skipped in 4.24s`, wall `5.44s` | Same coverage with most raw tempdir/path overhead removed | + +### Expanded autodoc slice + +This slice is: + +- `tests/ext/layout` +- `tests/ext/api_style` +- `tests/ext/autodoc_sphinx` +- `tests/ext/autodoc_docutils` +- `tests/ext/pytest_fixtures` +- `tests/ext/fastmcp` +- `tests/ext/typehints_gp` + +| Command | Result | What it shows | +| --- | --- | --- | +| `/usr/bin/time -p uv run pytest -q tests/ext/layout tests/ext/api_style tests/ext/autodoc_sphinx tests/ext/autodoc_docutils tests/ext/pytest_fixtures tests/ext/fastmcp tests/ext/typehints_gp` | `339 passed, 3 skipped in 36.74s`, wall `37.06s` | Honest cost of the shared autodoc surface in raw mode | +| `/usr/bin/time -p uv run pytest -q -o tmp_path_retention_policy=none --basetemp=/home/d/work/python/gp-sphinx/.cache/pytest-autodoc-wave6 tests/ext/layout tests/ext/api_style tests/ext/autodoc_sphinx tests/ext/autodoc_docutils tests/ext/pytest_fixtures tests/ext/fastmcp tests/ext/typehints_gp` | `339 passed, 3 skipped in 2.40s`, wall `2.30s` | Same slice with runner overhead mostly removed | +| `uv run python -m cProfile -o /tmp/gp_sphinx_wave6_autodoc.prof -m pytest -q tests/ext/layout tests/ext/api_style tests/ext/autodoc_sphinx tests/ext/autodoc_docutils tests/ext/pytest_fixtures tests/ext/fastmcp tests/ext/typehints_gp` | `339 passed, 3 skipped in 38.93s` | Profile source for the shared-stack slice | + +## Long-running tests and causes + +The slow tail is still dominated by honest builder-backed scenarios. + +| Test | Measured runtime | Cause | Verdict | +| --- | --- | --- | --- | +| `tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py::test_cross_document_fixture_reference_html_resolves` | `3.9s`-class setup | Real multi-page HTML build with cross-document links and inventory data | Keep | +| `tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py::test_default_html_outputs_smoke` | `3.4s`-class setup | Real HTML build for badge markup, genindex, and inventory output | Keep | +| `tests/ext/layout/test_integration.py::test_layout_demo_renders_api_component_contract` | `3.2s`-class setup | Real HTML build for final emitted Python layout contract | Keep | +| `tests/test_docs_package_pages.py::test_fastmcp_docs_page_renders_live_demo_output` | `2.9s`-class setup | Focused docs build smoke for live rendered output | Keep | +| `tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py::test_autodoc_docutils_entries_use_shared_layout` | `2.5s`-class setup | Real `rst:*` HTML build with nested directive options | Keep | +| `tests/ext/fastmcp/test_fastmcp_integration.py::test_fastmcp_tool_cards_use_shared_layout` | `2.0s`-class setup | Real FastMCP HTML build with section refs and shared-card wrappers | Keep | +| `tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py::test_autodoc_sphinx_confvals_use_shared_layout` | `1.9s`-class setup | Real `confval` HTML build | Keep | +| `tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_doctree.py::test_doc_pytest_plugin_myst_smoke` | `0.7s`-class setup | Shared MyST dummy-builder scenario | Already cached; acceptable | +| `tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_doctree.py::test_autofixture_index_resolution_smoke` | `0.6s`-class call | Dense fixture index scenario with real reference resolution | Acceptable | +| `tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py::test_text_builder_does_not_crash` | `0.5s`-class call | Real text-builder smoke | Keep | + +No currently slow test looks like a hidden hang or a synthetic timeout. + +## Profiling findings + +### Expanded autodoc slice cProfile + +Top cumulative costs: + +| Function | Cumulative time | +| --- | --- | +| `tests._sphinx_scenarios.build_shared_sphinx_result` | `36.611s` | +| `posixpath.realpath` | `16.561s` | +| `_pytest.tmpdir.mktemp` | `0.313s` | +| `_pytest.pathlib.make_numbered_dir` | `0.155s` | +| `sphinx_ux_autodoc_layout._transforms.on_doctree_resolved` | negligible compared with builder setup | +| `sphinx_autodoc_typehints_gp.rendering.render_annotation_nodes` | negligible compared with builder setup | + +Profile total: + +- `8813847` function calls (`8224448` primitive calls) in `39.724s` + +### What that means + +- the dominant repo-owned cost is still cached Sphinx scenario setup +- the dominant raw-runner overhead is still path resolution and `realpath()` +- the new shared typehint helpers are not hotspots +- the new annotation-display classifier is not a hotspot +- the shared layout transform and shared card builder are not hotspots + +## Where time is spent and where execution appears to stall + +There is no evidence of a true hang in the current suite. + +The apparent stall points are: + +- front-loaded setup for the remaining shared HTML scenarios +- raw pytest tempdir and path resolution before test logic starts + +The profile does not show hot loops in: + +- shared badge-group rendering +- shared typehint normalization +- shared annotation node rendering +- shared slot injection +- shared layout composition +- shared FastMCP card-shell composition + +## Suspected bugs, fixed bugs, and remaining risks + +### Fixed during this migration + +- duplicated badge-group and slot-injection logic across producer packages +- duplicated type normalization and xref rendering across fixtures, Sphinx + config docs, and FastMCP +- package-specific inner card-shell assembly in FastMCP +- package-specific summary/index table presentation drift + +### Remaining risks to watch + +- FastMCP still uses the shipped section-card outer wrapper, so it is aligned + structurally inside the card but not yet a real shipped `desc` object +- non-autodoc consumers now auto-load `sphinx_autodoc_typehints_gp`, so the extension + must continue to tolerate missing autodoc hooks and missing autodoc config + without raising + +### Real bug versus overhead + +No current long-running test looks like a correctness bug disguised as a slow +test. The dominant slow cases are honest integration coverage plus raw pytest +path handling. + +## Caching failures and missing caching + +### Working caching + +The current caching strategy is still effective: + +- shared build results still flow through `tests/_sphinx_scenarios.py` +- expensive layout, `confval`, `rst:*`, FastMCP, and fixture integration tests + still build exactly one cached scenario each +- the new shared-stack coverage did not add a new builder-backed scenario + +### Missing or ineffective caching + +No repo-owned missing cache stands out as the next major runtime win. + +The biggest unresolved overhead remains outside the scenario helpers: + +- pytest path resolution +- `realpath()` +- tempdir bookkeeping + +## Additional upstream API study + +This pass re-checked the local study trees under: + +- `~/study/python/sphinx` +- `~/study/python/docutils` +- `~/study/python/myst-parser` +- `~/study/python/sphinx-design` +- `~/study/python/pytest` +- `~/study/python/pytest-asyncio` +- `~/study/python/syrupy` +- `~/study/python/furo` + +Useful confirmations: + +- `SphinxDirective.parse_text_to_nodes()` remains the right low-cost parsing + abstraction for markup-producing directives +- `doctree-resolved` remains the correct late hook for source-link and header + composition +- custom nodes plus visitors remain the right layer for `api-*` wrappers; HTML + template overrides are still unnecessary +- the current Amber snapshot workflow already covers these doctrees well enough +- Furo compatibility is best preserved by staying node and CSS based instead of + patching theme templates + +## Syrupy customization audit + +No custom Syrupy extension is warranted in this wave. + +Reasons: + +- snapshot serialization is not a measurable hotspot +- `tests/_snapshots.py` already normalizes the unstable bits that matter +- the new shared-stack doctree and HTML-fragment snapshots fit cleanly into the + existing Amber workflow + +## Tradeoffs considered + +- keeping `.sab-toolbar` for one compatibility wave adds minor markup clutter + but avoids downstream CSS breakage while `api-*` remains the real contract +- keeping FastMCP on section cards preserves labels and `:tool:` / `:toolref:` + behavior with low migration risk +- keeping `confval` and `rst:*` on the semantic-markup path avoids a + high-risk domain rewrite while still allowing shared visual structure + +## Recommendations and follow-up + +### Keep + +- `api_slot` as the only cross-package header handoff +- `sphinx_ux_autodoc_layout` as the sole shared presenter +- `sphinx_ux_badges` as the sole badge DOM owner +- `sphinx_autodoc_typehints_gp` as the sole annotation rendering layer +- shared scenario caching in `tests/_sphinx_scenarios.py` + +### Defer + +- a shipped FastMCP desc/domain migration +- Jinja template overrides for API entries +- a custom Syrupy serializer +- any attempt to infer body sections from signature parsing + +### Good next follow-up + +- add one small shared helper for rendering a managed subtree to an HTML + fragment if more packages need translator-level assertions +- continue auditing builder-backed tests with the same rule used here: keep the + build only when the builder output is the contract +- if FastMCP ever moves onto a shipped desc path, add a dedicated tool domain + instead of overloading current section-label behavior + +## Validation commands + +The required validation commands for this change are: + +```console +$ uv run ruff check . --fix --show-fixes +``` + +```console +$ uv run ruff format . +``` + +```console +$ uv run mypy +``` + +```console +$ uv run py.test --reruns 0 -vvv +``` + +```console +$ just build-docs +``` diff --git a/packages/gp-sphinx/README.md b/packages/gp-sphinx/README.md index 2639ce5b..00a31de4 100644 --- a/packages/gp-sphinx/README.md +++ b/packages/gp-sphinx/README.md @@ -1,4 +1,4 @@ -# gp-sphinx · [![Python Package](https://img.shields.io/pypi/v/gp-sphinx.svg)](https://pypi.org/project/gp-sphinx/) [![License](https://img.shields.io/github/license/git-pull/gp-sphinx.svg)](https://github.com/git-pull/gp-sphinx/blob/master/LICENSE) +# gp-sphinx · [![Python Package](https://img.shields.io/pypi/v/gp-sphinx.svg)](https://pypi.org/project/gp-sphinx/) [![License](https://img.shields.io/github/license/git-pull/gp-sphinx.svg)](https://github.com/git-pull/gp-sphinx/blob/main/LICENSE) Shared Sphinx documentation platform for [git-pull](https://github.com/git-pull) projects. @@ -53,4 +53,4 @@ globals().update(conf) - Changelog: - Issues: - PyPI: -- License: [MIT](https://github.com/git-pull/gp-sphinx/blob/master/LICENSE) +- License: [MIT](https://github.com/git-pull/gp-sphinx/blob/main/LICENSE) diff --git a/packages/gp-sphinx/pyproject.toml b/packages/gp-sphinx/pyproject.toml index 87c31b1a..f004ae68 100644 --- a/packages/gp-sphinx/pyproject.toml +++ b/packages/gp-sphinx/pyproject.toml @@ -25,12 +25,12 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "documentation", "configuration"] dependencies = [ - "sphinx<9", - "sphinx-gptheme==0.0.1a7", + "sphinx>=8.1,<9", + "sphinx-gp-theme==0.0.1a7", "sphinx-fonts==0.0.1a7", "myst-parser", "docutils", - "sphinx-autodoc-typehints", + "sphinx-autodoc-typehints-gp==0.0.1a7", "sphinx-inline-tabs", "sphinx-copybutton", "sphinxext-opengraph", @@ -42,7 +42,7 @@ dependencies = [ [project.optional-dependencies] argparse = [ - "sphinx-argparse-neo==0.0.1a7", + "sphinx-autodoc-argparse==0.0.1a7", ] [project.urls] diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index ec5ea1c9..f7e15847 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -17,7 +17,7 @@ 'my-project' >>> conf["html_theme"] -'sphinx-gptheme' +'sphinx-gp-theme' >>> "myst_parser" in conf["extensions"] True @@ -47,8 +47,6 @@ DEFAULT_HTML_STATIC_PATH, DEFAULT_MYST_EXTENSIONS, DEFAULT_MYST_HEADING_ANCHORS, - DEFAULT_NAPOLEON_GOOGLE_DOCSTRING, - DEFAULT_NAPOLEON_INCLUDE_INIT_WITH_DOC, DEFAULT_PYGMENTS_DARK_STYLE, DEFAULT_PYGMENTS_STYLE, DEFAULT_SOURCE_SUFFIX, @@ -226,7 +224,7 @@ def merge_sphinx_config( Returns a flat dictionary suitable for injection into a ``docs/conf.py`` module namespace via ``globals().update(conf)``. - The default theme is ``sphinx-gptheme`` (a Furo child theme bundled in this + The default theme is ``sphinx-gp-theme`` (a Furo child theme bundled in this package). Sidebars, templates, CSS, and JS are provided by the theme automatically. @@ -247,7 +245,7 @@ def merge_sphinx_config( extensions : list[str] | None Replace the default extension list entirely. Usually not needed. extra_extensions : list[str] | None - Add extensions to the defaults (e.g., ``["sphinx_argparse_neo.exemplar"]``). + Add extensions to the defaults (e.g., ``["sphinx_autodoc_argparse.exemplar"]``). remove_extensions : list[str] | None Remove specific defaults (e.g., ``["sphinx_design"]``). theme_options : dict | None @@ -288,9 +286,9 @@ def merge_sphinx_config( '1.0' >>> conf["html_theme"] - 'sphinx-gptheme' + 'sphinx-gp-theme' - >>> len(conf["extensions"]) >= 13 + >>> len(conf["extensions"]) >= 12 True >>> callable(conf["setup"]) @@ -399,9 +397,6 @@ def merge_sphinx_config( "autodoc_typehints": DEFAULT_AUTODOC_TYPEHINTS, "toc_object_entries_show_parents": DEFAULT_TOC_OBJECT_ENTRIES_SHOW_PARENTS, "autodoc_default_options": dict(DEFAULT_AUTODOC_OPTIONS), - # Napoleon - "napoleon_google_docstring": DEFAULT_NAPOLEON_GOOGLE_DOCSTRING, - "napoleon_include_init_with_doc": DEFAULT_NAPOLEON_INCLUDE_INIT_WITH_DOC, # Copybutton "copybutton_prompt_text": DEFAULT_COPYBUTTON_PROMPT_TEXT, "copybutton_prompt_is_regexp": DEFAULT_COPYBUTTON_PROMPT_IS_REGEXP, diff --git a/packages/gp-sphinx/src/gp_sphinx/defaults.py b/packages/gp-sphinx/src/gp_sphinx/defaults.py index 69fc7bf3..110d4a3f 100644 --- a/packages/gp-sphinx/src/gp_sphinx/defaults.py +++ b/packages/gp-sphinx/src/gp_sphinx/defaults.py @@ -80,9 +80,8 @@ class FontConfig(_FontConfigRequired, total=False): "sphinx.ext.autodoc", "sphinx_fonts", "sphinx.ext.intersphinx", - "sphinx_autodoc_typehints", + "sphinx_autodoc_typehints_gp", "sphinx.ext.todo", - "sphinx.ext.napoleon", "sphinx_inline_tabs", "sphinx_copybutton", "sphinxext.opengraph", @@ -96,13 +95,13 @@ class FontConfig(_FontConfigRequired, total=False): Examples -------- >>> len(DEFAULT_EXTENSIONS) -13 +12 >>> DEFAULT_EXTENSIONS[0] 'sphinx.ext.autodoc' """ -DEFAULT_THEME: str = "sphinx-gptheme" +DEFAULT_THEME: str = "sphinx-gp-theme" """Default Sphinx HTML theme (Furo child theme bundled in this package).""" DEFAULT_THEME_OPTIONS: FuroThemeOptions = { @@ -126,7 +125,7 @@ class FontConfig(_FontConfigRequired, total=False): Examples -------- >>> DEFAULT_THEME_OPTIONS["source_branch"] -'master' +'main' >>> DEFAULT_THEME_OPTIONS["source_directory"] 'docs/' @@ -306,8 +305,13 @@ class FontConfig(_FontConfigRequired, total=False): 'bysource' """ -DEFAULT_AUTOCLASS_CONTENT: str = "both" -"""Default autodoc autoclass_content setting (show __init__ and class docstring).""" +DEFAULT_AUTOCLASS_CONTENT: str = "class" +"""Default autodoc autoclass_content setting. + +Uses ``"class"`` (class docstring only) with ``autodoc_class_signature = +"separated"`` so ``__init__`` is documented as a separate member and its +parameters appear only once -- not duplicated in the class body. +""" DEFAULT_AUTODOC_MEMBER_ORDER: str = "bysource" """Default autodoc member ordering.""" @@ -330,16 +334,6 @@ class FontConfig(_FontConfigRequired, total=False): 'description' """ -DEFAULT_NAPOLEON_GOOGLE_DOCSTRING: bool = True -"""Enable Google-style docstring parsing in napoleon.""" - -DEFAULT_NAPOLEON_INCLUDE_INIT_WITH_DOC: bool = False -"""Include __init__ docstring in class documentation. - -Default is ``False`` to match napoleon's built-in default. Most downstream -projects never set this explicitly, so ``True`` would change rendered output. -""" - DEFAULT_COPYBUTTON_LINE_CONTINUATION_CHARACTER: str = "\\" """Line continuation character for sphinx-copybutton.""" @@ -355,13 +349,11 @@ class FontConfig(_FontConfigRequired, total=False): 'hide' """ -DEFAULT_SUPPRESS_WARNINGS: list[str] = [ - "sphinx_autodoc_typehints.forward_reference", -] +DEFAULT_SUPPRESS_WARNINGS: list[str] = [] """Warnings to suppress by default. Examples -------- >>> len(DEFAULT_SUPPRESS_WARNINGS) -1 +0 """ diff --git a/packages/sphinx-autodoc-api-style/README.md b/packages/sphinx-autodoc-api-style/README.md index f3beff24..d26cf209 100644 --- a/packages/sphinx-autodoc-api-style/README.md +++ b/packages/sphinx-autodoc-api-style/README.md @@ -4,12 +4,19 @@ Sphinx extension that adds type and modifier badges and card-style containers to standard Python domain autodoc entries (functions, classes, methods, properties, attributes, data, exceptions). +Internally it is now a thin metadata producer on top of the shared stack: +`sphinx_ux_badges` owns badge rendering and `sphinx_ux_autodoc_layout` owns +the `api-*` entry structure and type rendering. + ## Install ```console $ pip install sphinx-autodoc-api-style ``` +Installing this package also installs `sphinx-ux-badges` and +`sphinx-ux-autodoc-layout` as declared dependencies. + ## Usage ```python @@ -19,6 +26,10 @@ extensions = ["sphinx_autodoc_api_style"] No special directives are required — existing `.. autofunction::`, `.. autoclass::`, and related directives receive badges automatically. +`sphinx_autodoc_api_style` automatically registers `sphinx_ux_badges` and +`sphinx_ux_autodoc_layout` via `app.setup_extension()`. You do not need to add +them separately to your `extensions` list. + ## Documentation See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-autodoc-api-style/) for diff --git a/packages/sphinx-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml index 92b9e80b..462f6be1 100644 --- a/packages/sphinx-autodoc-api-style/pyproject.toml +++ b/packages/sphinx-autodoc-api-style/pyproject.toml @@ -26,8 +26,9 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "autodoc", "documentation", "api", "badges"] dependencies = [ - "sphinx", - "sphinx-autodoc-badges==0.0.1a7", + "sphinx>=8.1", + "sphinx-ux-badges==0.0.1a7", + "sphinx-ux-autodoc-layout==0.0.1a7", ] [project.urls] diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/__init__.py b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/__init__.py index 82681431..4b0827dc 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/__init__.py +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/__init__.py @@ -21,10 +21,6 @@ >>> from sphinx_autodoc_api_style import setup >>> callable(setup) True - ->>> from sphinx_autodoc_api_style._css import _CSS ->>> _CSS.BADGE_GROUP -'gas-badge-group' """ from __future__ import annotations @@ -34,14 +30,12 @@ import typing as t from sphinx_autodoc_api_style._badges import build_badge_group -from sphinx_autodoc_api_style._css import _CSS from sphinx_autodoc_api_style._transforms import on_doctree_resolved if t.TYPE_CHECKING: from sphinx.application import Sphinx __all__ = [ - "_CSS", "build_badge_group", "setup", ] @@ -78,7 +72,8 @@ def setup(app: Sphinx) -> _SetupDict: True """ app.setup_extension("sphinx.ext.autodoc") - app.setup_extension("sphinx_autodoc_badges") + app.setup_extension("sphinx_ux_badges") + app.setup_extension("sphinx_ux_autodoc_layout") _static_dir = str(pathlib.Path(__file__).parent / "_static") diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py index 43a06493..3223f268 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py @@ -1,21 +1,22 @@ """Badge group rendering helpers for sphinx_autodoc_api_style. -Uses shared ``BadgeNode`` from ``sphinx_autodoc_badges`` instead of +Uses shared ``BadgeNode`` from ``sphinx_ux_badges`` instead of ``nodes.abbreviation`` -- avoids global abbreviation visitor override. Examples -------- >>> group = build_badge_group("function", modifiers=frozenset()) ->>> "gas-badge-group" in group["classes"] +>>> "gp-sphinx-badge-group" in group["classes"] True """ from __future__ import annotations +import typing as t + from docutils import nodes -from sphinx_autodoc_badges import BadgeNode, build_badge -from sphinx_autodoc_api_style._css import _CSS +from sphinx_ux_badges import SAB, BadgeSpec, build_badge_group_from_specs _TYPE_TOOLTIPS: dict[str, str] = { "function": "Python function", @@ -55,12 +56,12 @@ } _MOD_CSS: dict[str, str] = { - "async": _CSS.MOD_ASYNC, - "classmethod": _CSS.MOD_CLASSMETHOD, - "staticmethod": _CSS.MOD_STATICMETHOD, - "abstract": _CSS.MOD_ABSTRACT, - "final": _CSS.MOD_FINAL, - "deprecated": _CSS.DEPRECATED, + "async": SAB.MOD_ASYNC, + "classmethod": SAB.MOD_CLASSMETHOD, + "staticmethod": SAB.MOD_STATICMETHOD, + "abstract": SAB.MOD_ABSTRACT, + "final": SAB.MOD_FINAL, + "deprecated": SAB.STATE_DEPRECATED, } _MOD_LABELS: dict[str, str] = { @@ -113,10 +114,11 @@ def build_badge_group( Examples -------- >>> group = build_badge_group("function", modifiers=frozenset()) - >>> "gas-badge-group" in group["classes"] + >>> "gp-sphinx-badge-group" in group["classes"] True >>> group = build_badge_group("method", modifiers=frozenset({"async"})) + >>> from sphinx_ux_badges import BadgeNode >>> len(list(group.findall(BadgeNode))) == 2 True @@ -124,39 +126,37 @@ def build_badge_group( ... "class", ... modifiers=frozenset({"abstract", "deprecated"}), ... ) + >>> from sphinx_ux_badges import BadgeNode >>> labels = [n.astext() for n in group.findall(BadgeNode)] >>> "deprecated" in labels and "abstract" in labels and "class" in labels True """ - group = nodes.inline(classes=[_CSS.BADGE_GROUP]) - badges: list[BadgeNode] = [] + badge_specs: list[BadgeSpec] = [] for mod in _MOD_ORDER: if mod not in modifiers: continue - badges.append( - build_badge( + fill: t.Literal["filled", "outline"] = ( + "filled" if mod == "deprecated" else "outline" + ) + badge_specs.append( + BadgeSpec( _MOD_LABELS[mod], tooltip=_MOD_TOOLTIPS[mod], - classes=[_CSS.BADGE, _CSS.BADGE_MOD, _MOD_CSS[mod]], - fill="outline", - ), + classes=(SAB.BADGE, SAB.BADGE_MOD, _MOD_CSS[mod]), + fill=fill, + ) ) if show_type_badge: label = _TYPE_LABELS.get(objtype, objtype) tooltip = _TYPE_TOOLTIPS.get(objtype, f"Python {objtype}") - badges.append( - build_badge( + badge_specs.append( + BadgeSpec( label, tooltip=tooltip, - classes=[_CSS.BADGE, _CSS.BADGE_TYPE, _CSS.obj_type(objtype)], - ), + classes=(SAB.BADGE, SAB.BADGE_TYPE, SAB.obj_type(objtype)), + ) ) - for i, badge in enumerate(badges): - group += badge - if i < len(badges) - 1: - group += nodes.Text(" ") - - return group + return build_badge_group_from_specs(badge_specs, classes=[SAB.BADGE_GROUP]) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_css.py b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_css.py deleted file mode 100644 index 0b64d07f..00000000 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_css.py +++ /dev/null @@ -1,80 +0,0 @@ -"""CSS class name constants for sphinx_autodoc_api_style. - -Centralises every ``gas-*`` class name so the extension and stylesheet -stay in sync. Tests import this class to assert on rendered output. - -Examples --------- ->>> _CSS.BADGE_GROUP -'gas-badge-group' - ->>> _CSS.BADGE -'gas-badge' - ->>> _CSS.obj_type("function") -'gas-type-function' -""" - -from __future__ import annotations - - -class _CSS: - """CSS class name constants for API style badges. - - All class names use the ``gas-`` prefix (gp-sphinx api style) to avoid - collision with ``spf-`` (sphinx pytest fixtures) or other extensions. - - Examples - -------- - >>> _CSS.PREFIX - 'gas' - - >>> _CSS.BADGE_GROUP - 'gas-badge-group' - - >>> _CSS.TOOLBAR - 'gas-toolbar' - - >>> _CSS.obj_type("class") - 'gas-type-class' - """ - - PREFIX = "gas" - BADGE_GROUP = f"{PREFIX}-badge-group" - BADGE = f"{PREFIX}-badge" - - BADGE_TYPE = f"{PREFIX}-badge--type" - BADGE_MOD = f"{PREFIX}-badge--mod" - - MOD_ASYNC = f"{PREFIX}-mod-async" - MOD_CLASSMETHOD = f"{PREFIX}-mod-classmethod" - MOD_STATICMETHOD = f"{PREFIX}-mod-staticmethod" - MOD_ABSTRACT = f"{PREFIX}-mod-abstract" - MOD_FINAL = f"{PREFIX}-mod-final" - DEPRECATED = f"{PREFIX}-deprecated" - - TOOLBAR = f"{PREFIX}-toolbar" - - @staticmethod - def obj_type(name: str) -> str: - """Return the type-specific CSS class, e.g. ``gas-type-function``. - - Parameters - ---------- - name : str - Python domain object type name. - - Returns - ------- - str - CSS class string. - - Examples - -------- - >>> _CSS.obj_type("method") - 'gas-type-method' - - >>> _CSS.obj_type("exception") - 'gas-type-exception' - """ - return f"{_CSS.PREFIX}-type-{name}" 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 f3975753..97c24b2b 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 @@ -1,372 +1,13 @@ /* ── sphinx_autodoc_api_style ───────────────────────────── - * Badge system for Python API entries: functions, classes, - * methods, properties, attributes, data, exceptions. + * Card treatment and layout for Python API entries. * - * Uses the `gas-` prefix (gp-sphinx api style) to avoid - * collision with `spf-` (sphinx pytest fixtures). - * - * Design language matches sphinx_autodoc_pytest_fixtures: - * same badge metrics, border radius, tooltip pattern, - * card treatment, and Furo integration. - * ────────────────────────────────────────────────────────── */ - -/* ── Token system ──────────────────────────────────────── */ -:root { - /* Type: function — blue */ - --gas-type-function-bg: #e8f0fe; - --gas-type-function-fg: #1a56db; - --gas-type-function-border: #3b82f6; - - /* Type: class — indigo */ - --gas-type-class-bg: #eef2ff; - --gas-type-class-fg: #4338ca; - --gas-type-class-border: #6366f1; - - /* Type: method — cyan */ - --gas-type-method-bg: #ecfeff; - --gas-type-method-fg: #0e7490; - --gas-type-method-border: #06b6d4; - - /* Type: property — teal */ - --gas-type-property-bg: #f0fdfa; - --gas-type-property-fg: #0f766e; - --gas-type-property-border: #14b8a6; - - /* Type: attribute — slate */ - --gas-type-attribute-bg: #f1f5f9; - --gas-type-attribute-fg: #475569; - --gas-type-attribute-border: #94a3b8; - - /* Type: data — neutral grey */ - --gas-type-data-bg: #f5f5f5; - --gas-type-data-fg: #525252; - --gas-type-data-border: #a3a3a3; - - /* Type: exception — rose/red */ - --gas-type-exception-bg: #fff1f2; - --gas-type-exception-fg: #be123c; - --gas-type-exception-border: #f43f5e; - - /* Type: type alias — violet */ - --gas-type-type-bg: #f5f3ff; - --gas-type-type-fg: #6d28d9; - --gas-type-type-border: #8b5cf6; - - /* Modifier: async — purple (outlined) */ - --gas-mod-async-fg: #7c3aed; - --gas-mod-async-border: #a78bfa; - - /* Modifier: classmethod — amber (outlined) */ - --gas-mod-classmethod-fg: #b45309; - --gas-mod-classmethod-border: #f59e0b; - - /* Modifier: staticmethod — cool grey (outlined) */ - --gas-mod-staticmethod-fg: #475569; - --gas-mod-staticmethod-border: #94a3b8; - - /* Modifier: abstract — indigo (outlined) */ - --gas-mod-abstract-fg: #4338ca; - --gas-mod-abstract-border: #818cf8; - - /* Modifier: final — emerald (outlined) */ - --gas-mod-final-fg: #047857; - --gas-mod-final-border: #34d399; - - /* Modifier: deprecated — muted red/grey (matches spf-deprecated) */ - --gas-deprecated-bg: transparent; - --gas-deprecated-fg: #8a4040; - --gas-deprecated-border: #c07070; - - /* Shared badge metrics — match fixture extension */ - --gas-badge-font-size: 0.67rem; - --gas-badge-padding-v: 0.16rem; - --gas-badge-border-w: 1px; -} - -/* ── Dark mode (OS-level) ──────────────────────────────── */ -@media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) { - --gas-type-function-bg: #172554; - --gas-type-function-fg: #93c5fd; - --gas-type-function-border: #3b82f6; - - --gas-type-class-bg: #1e1b4b; - --gas-type-class-fg: #a5b4fc; - --gas-type-class-border: #6366f1; - - --gas-type-method-bg: #083344; - --gas-type-method-fg: #67e8f9; - --gas-type-method-border: #22d3ee; - - --gas-type-property-bg: #042f2e; - --gas-type-property-fg: #5eead4; - --gas-type-property-border: #2dd4bf; - - --gas-type-attribute-bg: #1e293b; - --gas-type-attribute-fg: #cbd5e1; - --gas-type-attribute-border: #64748b; - - --gas-type-data-bg: #262626; - --gas-type-data-fg: #d4d4d4; - --gas-type-data-border: #737373; - - --gas-type-exception-bg: #4c0519; - --gas-type-exception-fg: #fda4af; - --gas-type-exception-border: #fb7185; - - --gas-type-type-bg: #2e1065; - --gas-type-type-fg: #c4b5fd; - --gas-type-type-border: #a78bfa; - - --gas-mod-async-fg: #c4b5fd; - --gas-mod-async-border: #8b5cf6; - - --gas-mod-classmethod-fg: #fcd34d; - --gas-mod-classmethod-border: #f59e0b; - - --gas-mod-staticmethod-fg: #cbd5e1; - --gas-mod-staticmethod-border: #64748b; - - --gas-mod-abstract-fg: #a5b4fc; - --gas-mod-abstract-border: #818cf8; - - --gas-mod-final-fg: #6ee7b7; - --gas-mod-final-border: #34d399; - - --gas-deprecated-fg: #e08080; - --gas-deprecated-border: #c06060; - } -} - -/* ── Furo explicit dark toggle ─────────────────────────── */ -body[data-theme="dark"] { - --gas-type-function-bg: #172554; - --gas-type-function-fg: #93c5fd; - --gas-type-function-border: #3b82f6; - - --gas-type-class-bg: #1e1b4b; - --gas-type-class-fg: #a5b4fc; - --gas-type-class-border: #6366f1; - - --gas-type-method-bg: #083344; - --gas-type-method-fg: #67e8f9; - --gas-type-method-border: #22d3ee; - - --gas-type-property-bg: #042f2e; - --gas-type-property-fg: #5eead4; - --gas-type-property-border: #2dd4bf; - - --gas-type-attribute-bg: #1e293b; - --gas-type-attribute-fg: #cbd5e1; - --gas-type-attribute-border: #64748b; - - --gas-type-data-bg: #262626; - --gas-type-data-fg: #d4d4d4; - --gas-type-data-border: #737373; - - --gas-type-exception-bg: #4c0519; - --gas-type-exception-fg: #fda4af; - --gas-type-exception-border: #fb7185; - - --gas-type-type-bg: #2e1065; - --gas-type-type-fg: #c4b5fd; - --gas-type-type-border: #a78bfa; - - --gas-mod-async-fg: #c4b5fd; - --gas-mod-async-border: #8b5cf6; - - --gas-mod-classmethod-fg: #fcd34d; - --gas-mod-classmethod-border: #f59e0b; - - --gas-mod-staticmethod-fg: #cbd5e1; - --gas-mod-staticmethod-border: #64748b; - - --gas-mod-abstract-fg: #a5b4fc; - --gas-mod-abstract-border: #818cf8; - - --gas-mod-final-fg: #6ee7b7; - --gas-mod-final-border: #34d399; - - --gas-deprecated-fg: #e08080; - --gas-deprecated-border: #c06060; -} - -/* ── Signature flex layout ─────────────────────────────── */ -dl.py:not(.fixture) > dt { - display: flex; - align-items: center; - gap: 0.35rem; - flex-wrap: wrap; -} - -/* ── Toolbar: badges + [source] ────────────────────────── */ -dl.py:not(.fixture) > dt .gas-toolbar { - display: inline-flex; - align-items: center; - gap: 0.35rem; - flex-shrink: 0; - margin-left: auto; - white-space: nowrap; - text-indent: 0; - order: 99; -} - -dl.py:not(.fixture) > dt .gas-badge-group { - display: inline-flex; - align-items: center; - gap: 0.3rem; - white-space: nowrap; -} - -/* ── Shared badge base ─────────────────────────────────── */ -.gas-badge { - position: relative; - display: inline-block; - font-size: var(--gas-badge-font-size, 0.67rem); - font-weight: 700; - line-height: normal; - letter-spacing: 0.01em; - padding: var(--gas-badge-padding-v, 0.16rem) 0.5rem; - border-radius: 0.22rem; - border: var(--gas-badge-border-w, 1px) solid; - vertical-align: middle; - text-decoration: underline dotted; -} - -/* Touch/keyboard tooltip */ -.gas-badge[tabindex]:focus::after { - content: attr(title); - position: absolute; - bottom: calc(100% + 4px); - left: 50%; - transform: translateX(-50%); - background: var(--color-background-primary); - border: 1px solid var(--color-background-border); - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 400; - white-space: nowrap; - border-radius: 0.2rem; - z-index: 10; - pointer-events: none; -} - -.gas-badge[tabindex]:focus-visible { - outline: 2px solid var(--color-link); - outline-offset: 2px; -} - -/* ── Type badges (filled) ──────────────────────────────── */ -.gas-type-function { - background-color: var(--gas-type-function-bg); - color: var(--gas-type-function-fg); - border-color: var(--gas-type-function-border); -} - -.gas-type-class { - background-color: var(--gas-type-class-bg); - color: var(--gas-type-class-fg); - border-color: var(--gas-type-class-border); -} - -.gas-type-method, -.gas-type-classmethod, -.gas-type-staticmethod { - background-color: var(--gas-type-method-bg); - color: var(--gas-type-method-fg); - border-color: var(--gas-type-method-border); -} - -.gas-type-property { - background-color: var(--gas-type-property-bg); - color: var(--gas-type-property-fg); - border-color: var(--gas-type-property-border); -} - -.gas-type-attribute { - background-color: var(--gas-type-attribute-bg); - color: var(--gas-type-attribute-fg); - border-color: var(--gas-type-attribute-border); -} - -.gas-type-data { - background-color: var(--gas-type-data-bg); - color: var(--gas-type-data-fg); - border-color: var(--gas-type-data-border); -} - -.gas-type-exception { - background-color: var(--gas-type-exception-bg); - color: var(--gas-type-exception-fg); - border-color: var(--gas-type-exception-border); -} - -.gas-type-type { - background-color: var(--gas-type-type-bg); - color: var(--gas-type-type-fg); - border-color: var(--gas-type-type-border); -} - -/* ── Modifier badges (outlined, transparent bg) ────────── */ -.gas-mod-async { - background-color: transparent; - color: var(--gas-mod-async-fg); - border-color: var(--gas-mod-async-border); -} - -.gas-mod-classmethod { - background-color: transparent; - color: var(--gas-mod-classmethod-fg); - border-color: var(--gas-mod-classmethod-border); -} - -.gas-mod-staticmethod { - background-color: transparent; - color: var(--gas-mod-staticmethod-fg); - border-color: var(--gas-mod-staticmethod-border); -} - -.gas-mod-abstract { - background-color: transparent; - color: var(--gas-mod-abstract-fg); - border-color: var(--gas-mod-abstract-border); -} - -.gas-mod-final { - background-color: transparent; - color: var(--gas-mod-final-fg); - border-color: var(--gas-mod-final-border); -} - -.gas-deprecated { - background-color: var(--gas-deprecated-bg); - color: var(--gas-deprecated-fg); - border-color: var(--gas-deprecated-border); -} - -/* ── Border color reinforcement ──────────────────────────── - * BadgeNode renders ; legacy builds may still emit . - * Target both element-agnostically via class selectors. + * 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. * ────────────────────────────────────────────────────────── */ -.gas-type-function.gas-badge { border-color: var(--gas-type-function-border); } -.gas-type-class.gas-badge { border-color: var(--gas-type-class-border); } -.gas-type-method.gas-badge { border-color: var(--gas-type-method-border); } -.gas-type-classmethod.gas-badge { border-color: var(--gas-type-method-border); } -.gas-type-staticmethod.gas-badge { border-color: var(--gas-type-method-border); } -.gas-type-property.gas-badge { border-color: var(--gas-type-property-border); } -.gas-type-attribute.gas-badge { border-color: var(--gas-type-attribute-border); } -.gas-type-data.gas-badge { border-color: var(--gas-type-data-border); } -.gas-type-exception.gas-badge { border-color: var(--gas-type-exception-border); } -.gas-type-type.gas-badge { border-color: var(--gas-type-type-border); } -.gas-mod-async.gas-badge { border-color: var(--gas-mod-async-border); } -.gas-mod-classmethod.gas-badge { border-color: var(--gas-mod-classmethod-border); } -.gas-mod-staticmethod.gas-badge { border-color: var(--gas-mod-staticmethod-border); } -.gas-mod-abstract.gas-badge { border-color: var(--gas-mod-abstract-border); } -.gas-mod-final.gas-badge { border-color: var(--gas-mod-final-border); } -.gas-deprecated.gas-badge { border-color: var(--gas-deprecated-border); } /* ── Deprecated entry muting ───────────────────────────── */ -dl.py.gas-deprecated > dt { +dl.py.gp-sphinx-badge--state-deprecated > dt { opacity: 0.7; } diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py index 9a44fc77..5cc5736e 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py @@ -14,7 +14,7 @@ from sphinx.util import logging as sphinx_logging from sphinx_autodoc_api_style._badges import build_badge_group -from sphinx_autodoc_api_style._css import _CSS +from sphinx_ux_autodoc_layout import inject_signature_slots if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -132,13 +132,9 @@ def _detect_deprecated(desc_node: addnodes.desc) -> bool: def _inject_badges(sig_node: addnodes.desc_signature, objtype: str) -> None: - """Inject a toolbar containing badges, viewcode, and headerlink. + """Inject structured layout slots containing badges and source links. - Builds a toolbar container (``gas-toolbar``) that groups the badge - group, ``[source]`` link, and permalink into a single flex item so - they stay together on the right side of the signature header. - - Guarded by ``gas_badges_injected`` flag. + Guarded by ``sab_badges_injected`` flag. Parameters ---------- @@ -153,39 +149,20 @@ def _inject_badges(sig_node: addnodes.desc_signature, objtype: str) -> None: >>> sig = addnodes.desc_signature() >>> sig += addnodes.desc_name("", "my_func") >>> _inject_badges(sig, "function") - >>> sig.get("gas_badges_injected") + >>> sig.get("sab_badges_injected") True """ - if sig_node.get("gas_badges_injected"): - return - sig_node["gas_badges_injected"] = True - mods = _detect_modifiers(sig_node) parent = sig_node.parent if isinstance(parent, addnodes.desc) and _detect_deprecated(parent): mods = mods | {"deprecated"} badge_group = build_badge_group(objtype, modifiers=mods) - - viewcode_ref = None - for child in list(sig_node.children): - if ( - isinstance(child, nodes.reference) - and child.get("internal") is not True - and any( - "viewcode-link" in getattr(gc, "get", lambda *_: "")("classes", []) - for gc in child.children - if isinstance(gc, nodes.inline) - ) - ): - viewcode_ref = child - sig_node.remove(child) - - toolbar = nodes.inline(classes=[_CSS.TOOLBAR]) - toolbar += badge_group - if viewcode_ref is not None: - toolbar += viewcode_ref - sig_node += toolbar + inject_signature_slots( + sig_node, + marker_attr="sab_badges_injected", + badge_node=badge_group, + ) def _prune_empty_desc_content(desc_node: addnodes.desc) -> None: diff --git a/packages/sphinx-argparse-neo/README.md b/packages/sphinx-autodoc-argparse/README.md similarity index 86% rename from packages/sphinx-argparse-neo/README.md rename to packages/sphinx-autodoc-argparse/README.md index 13e4f8ea..65c0f345 100644 --- a/packages/sphinx-argparse-neo/README.md +++ b/packages/sphinx-autodoc-argparse/README.md @@ -1,4 +1,4 @@ -# sphinx-argparse-neo +# sphinx-autodoc-argparse Modern Sphinx extension for documenting argparse-based CLI tools. @@ -12,7 +12,7 @@ A modernized replacement for `sphinx-argparse` that: ## Install ```console -$ pip install sphinx-argparse-neo +$ pip install sphinx-autodoc-argparse ``` ## Usage @@ -20,7 +20,7 @@ $ pip install sphinx-argparse-neo In your `docs/conf.py`: ```python -extensions = ["sphinx_argparse_neo"] +extensions = ["sphinx_autodoc_argparse"] ``` Then use the `.. argparse::` directive: diff --git a/packages/sphinx-argparse-neo/pyproject.toml b/packages/sphinx-autodoc-argparse/pyproject.toml similarity index 87% rename from packages/sphinx-argparse-neo/pyproject.toml rename to packages/sphinx-autodoc-argparse/pyproject.toml index 16b5a77f..64c3e328 100644 --- a/packages/sphinx-argparse-neo/pyproject.toml +++ b/packages/sphinx-autodoc-argparse/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "sphinx-argparse-neo" +name = "sphinx-autodoc-argparse" version = "0.0.1a7" description = "Modern Sphinx extension for documenting argparse-based CLI tools" requires-python = ">=3.10,<4.0" @@ -8,9 +8,10 @@ authors = [ ] license = { text = "MIT" } classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Framework :: Sphinx", + "Framework :: Sphinx :: Domain", "Framework :: Sphinx :: Extension", "Intended Audience :: Developers", "Programming Language :: Python :: 3", @@ -27,7 +28,7 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "argparse", "cli", "documentation"] dependencies = [ - "sphinx", + "sphinx>=8.1", "pygments", "docutils", ] @@ -40,4 +41,4 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/sphinx_argparse_neo"] +packages = ["src/sphinx_autodoc_argparse"] diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/__init__.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py similarity index 82% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/__init__.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py index ec3c8b50..64607aa3 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/__init__.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/__init__.py @@ -1,4 +1,4 @@ -"""sphinx_argparse_neo - Modern sphinx-argparse replacement. +"""sphinx_autodoc_argparse - Modern sphinx-argparse replacement. A Sphinx extension for documenting argparse-based CLI tools that: - Works with Sphinx 8.x AND 9.x (no autodoc.mock dependency) @@ -12,8 +12,9 @@ import typing as t -from sphinx_argparse_neo.directive import ArgparseDirective -from sphinx_argparse_neo.nodes import ( +from sphinx_autodoc_argparse.directive import ArgparseDirective +from sphinx_autodoc_argparse.domain import ArgparseDomain +from sphinx_autodoc_argparse.nodes import ( argparse_argument, argparse_group, argparse_program, @@ -33,7 +34,7 @@ visit_argparse_subcommands_html, visit_argparse_usage_html, ) -from sphinx_argparse_neo.utils import strip_ansi +from sphinx_autodoc_argparse.utils import strip_ansi __all__ = [ "ArgparseDirective", @@ -119,6 +120,13 @@ def setup(app: Sphinx) -> SetupDict: html=(visit_argparse_subcommand_html, depart_argparse_subcommand_html), ) + # Register the argparse domain so :argparse:program: / :argparse:option: / + # :argparse:subcommand: / :argparse:positional: xrefs resolve and the two + # auto-generated indices (argparse-programsindex, argparse-optionsindex) + # are available. The renderer populates this domain via note_* helpers + # in parallel with the existing std:cmdoption emission. + app.add_domain(ArgparseDomain) + # Register directive app.add_directive("argparse", ArgparseDirective) diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/cli_usage_lexer.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/cli_usage_lexer.py similarity index 100% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/cli_usage_lexer.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/cli_usage_lexer.py diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/compat.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/compat.py similarity index 100% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/compat.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/compat.py diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/directive.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py similarity index 97% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/directive.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py index 2c05095f..172b3b4a 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/directive.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/directive.py @@ -11,9 +11,9 @@ from docutils.parsers.rst import directives from sphinx.util.docutils import SphinxDirective -from sphinx_argparse_neo.compat import get_parser_from_module -from sphinx_argparse_neo.parser import extract_parser -from sphinx_argparse_neo.renderer import ArgparseRenderer, RenderConfig +from sphinx_autodoc_argparse.compat import get_parser_from_module +from sphinx_autodoc_argparse.parser import extract_parser +from sphinx_autodoc_argparse.renderer import ArgparseRenderer, RenderConfig if t.TYPE_CHECKING: import argparse @@ -178,7 +178,7 @@ def run(self) -> list[nodes.Node]: ) # Render to nodes - renderer = ArgparseRenderer(config=config, state=self.state) + renderer = ArgparseRenderer(config=config, state=self.state, env=self.env) return renderer.render(parser_info) def _build_render_config(self) -> RenderConfig: diff --git a/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/domain.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/domain.py new file mode 100644 index 00000000..a5bc02d9 --- /dev/null +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/domain.py @@ -0,0 +1,419 @@ +"""The argparse Sphinx domain. + +Registers four object types (``program``, ``option``, ``subcommand``, +``positional``) with matching cross-reference roles, two auto-generated +indices (programs, options), and the standard lifecycle hooks Sphinx +expects from a parallel-safe domain. + +The renderer wires into this domain via ``note_program`` / +``note_option`` / ``note_subcommand`` / ``note_positional`` alongside +the existing ``std:cmdoption`` registration, so ``:argparse:option:`` +cross-references and the two domain indices work without breaking +``:option:`` intersphinx consumers. + +Examples +-------- +>>> from sphinx_autodoc_argparse.domain import ArgparseDomain +>>> ArgparseDomain.name +'argparse' +>>> sorted(ArgparseDomain.object_types) +['option', 'positional', 'program', 'subcommand'] +>>> sorted(ArgparseDomain.roles) +['option', 'positional', 'program', 'subcommand'] +>>> [cls.name for cls in ArgparseDomain.indices] +['programsindex', 'optionsindex'] +""" + +from __future__ import annotations + +import typing as t + +from sphinx.domains import Domain, Index, IndexEntry, ObjType +from sphinx.locale import _ +from sphinx.roles import XRefRole +from sphinx.util.nodes import make_refnode + +if t.TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Set + + from docutils import nodes + from docutils.nodes import Element + from sphinx.addnodes import pending_xref + from sphinx.builders import Builder + from sphinx.environment import BuildEnvironment + + +#: Object type name used for programs (top-level CLIs). +PROGRAM = "program" +#: Object type name used for options (optional flags like ``--verbose``). +OPTION = "option" +#: Object type name used for subcommands (e.g. ``myapp sub``). +SUBCOMMAND = "subcommand" +#: Object type name used for positional arguments (e.g. ``myapp FILE``). +POSITIONAL = "positional" + +#: All object type names in a single tuple for iteration. +OBJECT_TYPES: tuple[str, ...] = (PROGRAM, OPTION, SUBCOMMAND, POSITIONAL) + + +class ArgparseProgramsIndex(Index): + """Alphabetical index of every registered argparse program. + + The generated page lives at ``argparse-programsindex.html`` and can be + linked via ``:ref:`argparse-programsindex```. + + Examples + -------- + >>> ArgparseProgramsIndex.name + 'programsindex' + >>> str(ArgparseProgramsIndex.localname) + 'Argparse programs index' + """ + + name = "programsindex" + localname = _("Argparse programs index") + shortname = _("programs") + + def generate( + self, + docnames: Iterable[str] | None = None, + ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]: + """Build the programs index entries grouped by first-letter heading.""" + content: dict[str, list[IndexEntry]] = {} + allowed = set(docnames) if docnames is not None else None + + programs: dict[str, tuple[str, str]] = self.domain.data.get("programs", {}) + for name in sorted(programs): + docname, anchor = programs[name] + if allowed is not None and docname not in allowed: + continue + letter = (name[:1] or "_").lower() + content.setdefault(letter, []).append( + IndexEntry( + name=name, + subtype=0, + docname=docname, + anchor=anchor, + extra="", + qualifier="", + descr=_("program"), + ), + ) + + return ( + sorted(content.items()), + False, + ) + + +class ArgparseOptionsIndex(Index): + """Per-program grouped index of every registered argparse option. + + Each program heading groups the options that program defines, sorted + alphabetically. The generated page lives at + ``argparse-optionsindex.html`` and can be linked via + ``:ref:`argparse-optionsindex```. + + Examples + -------- + >>> ArgparseOptionsIndex.name + 'optionsindex' + >>> str(ArgparseOptionsIndex.localname) + 'Argparse options index' + """ + + name = "optionsindex" + localname = _("Argparse options index") + shortname = _("options") + + def generate( + self, + docnames: Iterable[str] | None = None, + ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]: + """Build the options index entries grouped by program heading.""" + content: dict[str, list[IndexEntry]] = {} + allowed = set(docnames) if docnames is not None else None + + options: dict[tuple[str, str], tuple[str, str]] = self.domain.data.get( + "options", + {}, + ) + for program, name in sorted(options): + docname, anchor = options[program, name] + if allowed is not None and docname not in allowed: + continue + heading = program or "-" + content.setdefault(heading, []).append( + IndexEntry( + name=name, + subtype=0, + docname=docname, + anchor=anchor, + extra="", + qualifier=program, + descr=_("option"), + ), + ) + + return ( + sorted(content.items()), + True, + ) + + +class ArgparseDomain(Domain): + """Sphinx domain for argparse-based CLI documentation. + + Stores four dictionaries under ``env.domaindata["argparse"]``: + + * ``programs[name] = (docname, anchor)`` + * ``options[(program, name)] = (docname, anchor)`` + * ``subcommands[(program, name)] = (docname, anchor)`` + * ``positionals[(program, name)] = (docname, anchor)`` + + Programs are keyed by their full dotted/space-joined name + (``"myapp"``, ``"myapp sub"``). Options, subcommands, and positionals + are keyed by ``(program, local_name)`` tuples so the same flag name + may exist under multiple programs without collision. + + Examples + -------- + >>> ArgparseDomain.name + 'argparse' + >>> ArgparseDomain.data_version + 0 + """ + + name = "argparse" + label = "Argparse CLI" + + object_types = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + PROGRAM: ObjType(_("program"), PROGRAM), + OPTION: ObjType(_("option"), OPTION), + SUBCOMMAND: ObjType(_("subcommand"), SUBCOMMAND), + POSITIONAL: ObjType(_("positional"), POSITIONAL), + } + + directives: dict[str, t.Any] = {} # noqa: RUF012 + + roles = { # noqa: RUF012 — XRefRole instances are safe to share across domains + PROGRAM: XRefRole(), + OPTION: XRefRole(), + SUBCOMMAND: XRefRole(), + POSITIONAL: XRefRole(), + } + + indices = [ # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + ArgparseProgramsIndex, + ArgparseOptionsIndex, + ] + + initial_data = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + "programs": {}, + "options": {}, + "subcommands": {}, + "positionals": {}, + } + + data_version = 0 + + @property + def programs(self) -> dict[str, tuple[str, str]]: + """Programs keyed by name: ``name -> (docname, anchor)``.""" + return t.cast( + "dict[str, tuple[str, str]]", self.data.setdefault("programs", {}) + ) + + @property + def options(self) -> dict[tuple[str, str], tuple[str, str]]: + """Options keyed by ``(program, name) -> (docname, anchor)``.""" + return t.cast( + "dict[tuple[str, str], tuple[str, str]]", + self.data.setdefault("options", {}), + ) + + @property + def subcommands(self) -> dict[tuple[str, str], tuple[str, str]]: + """Subcommands keyed by ``(program, name) -> (docname, anchor)``.""" + return t.cast( + "dict[tuple[str, str], tuple[str, str]]", + self.data.setdefault("subcommands", {}), + ) + + @property + def positionals(self) -> dict[tuple[str, str], tuple[str, str]]: + """Positionals keyed by ``(program, name) -> (docname, anchor)``.""" + return t.cast( + "dict[tuple[str, str], tuple[str, str]]", + self.data.setdefault("positionals", {}), + ) + + def note_program(self, name: str, docname: str, anchor: str) -> None: + """Record a program target in the domain data.""" + self.programs[name] = (docname, anchor) + + def note_option( + self, + program: str, + name: str, + docname: str, + anchor: str, + ) -> None: + """Record an option target in the domain data.""" + self.options[program, name] = (docname, anchor) + + def note_subcommand( + self, + program: str, + name: str, + docname: str, + anchor: str, + ) -> None: + """Record a subcommand target in the domain data.""" + self.subcommands[program, name] = (docname, anchor) + + def note_positional( + self, + program: str, + name: str, + docname: str, + anchor: str, + ) -> None: + """Record a positional argument target in the domain data.""" + self.positionals[program, name] = (docname, anchor) + + def clear_doc(self, docname: str) -> None: + """Drop every entry that came from *docname* so it can be re-built.""" + for program, (existing, _anchor) in list(self.programs.items()): + if existing == docname: + del self.programs[program] + for key, (existing, _anchor) in list(self.options.items()): + if existing == docname: + del self.options[key] + for key, (existing, _anchor) in list(self.subcommands.items()): + if existing == docname: + del self.subcommands[key] + for key, (existing, _anchor) in list(self.positionals.items()): + if existing == docname: + del self.positionals[key] + + def merge_domaindata( + self, + docnames: Set[str], + otherdata: dict[str, t.Any], + ) -> None: + """Merge sibling worker's ``domaindata`` under parallel builds.""" + for program, (docname, anchor) in otherdata.get("programs", {}).items(): + if docname in docnames: + self.programs[program] = (docname, anchor) + for key, (docname, anchor) in otherdata.get("options", {}).items(): + if docname in docnames: + self.options[key] = (docname, anchor) + for key, (docname, anchor) in otherdata.get("subcommands", {}).items(): + if docname in docnames: + self.subcommands[key] = (docname, anchor) + for key, (docname, anchor) in otherdata.get("positionals", {}).items(): + if docname in docnames: + self.positionals[key] = (docname, anchor) + + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: pending_xref, + contnode: Element, + ) -> nodes.reference | None: + """Resolve a single typed cross-reference to a docutils reference.""" + match = self._lookup(typ, target) + if match is None: + return None + todocname, anchor = match + return make_refnode( + builder, + fromdocname, + todocname, + anchor, + contnode, + target, + ) + + def resolve_any_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + target: str, + node: pending_xref, + contnode: Element, + ) -> list[tuple[str, nodes.reference]]: + """Resolve an untyped ``:any:`` cross-reference across object types.""" + results: list[tuple[str, nodes.reference]] = [] + for objtype in OBJECT_TYPES: + match = self._lookup(objtype, target) + if match is None: + continue + todocname, anchor = match + results.append( + ( + f"argparse:{objtype}", + make_refnode( + builder, + fromdocname, + todocname, + anchor, + contnode, + target, + ), + ), + ) + return results + + def get_objects(self) -> Iterator[tuple[str, str, str, str, str, int]]: + """Yield ``(name, dispname, type, docname, anchor, priority)`` rows.""" + for name, (docname, anchor) in self.programs.items(): + yield name, name, PROGRAM, docname, anchor, 1 + for (program, name), (docname, anchor) in self.options.items(): + dispname = f"{program} {name}" if program else name + yield dispname, dispname, OPTION, docname, anchor, 1 + for (program, name), (docname, anchor) in self.subcommands.items(): + dispname = f"{program} {name}" if program else name + yield dispname, dispname, SUBCOMMAND, docname, anchor, 1 + for (program, name), (docname, anchor) in self.positionals.items(): + dispname = f"{program} {name}" if program else name + yield dispname, dispname, POSITIONAL, docname, anchor, 1 + + def _lookup(self, typ: str, target: str) -> tuple[str, str] | None: + """Look up *target* in *typ*'s table, supporting composite option keys. + + Options, subcommands, and positionals are stored under + ``(program, name)`` keys. Authors commonly write the full + whitespace-joined form (e.g. ``myapp --verbose``); split the + rightmost whitespace token to map that back to the tuple key. + """ + if typ == PROGRAM: + if target in self.programs: + return self.programs[target] + return None + + table = { + OPTION: self.options, + SUBCOMMAND: self.subcommands, + POSITIONAL: self.positionals, + }.get(typ) + if table is None: + return None + + # Exact tuple key from a pre-split target like "myapp sub --verbose" + if " " in target: + program, _, name = target.rpartition(" ") + if (program, name) in table: + return table[program, name] + + # Fallback: search every program for an exact name hit + for (program, name), value in table.items(): + if target == name or target == f"{program} {name}": + return value + return None diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/exemplar.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/exemplar.py similarity index 98% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/exemplar.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/exemplar.py index 66be97d8..3cb88f52 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/exemplar.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/exemplar.py @@ -1,6 +1,6 @@ """Transform argparse epilog "examples:" definition lists into documentation sections. -This Sphinx extension post-processes sphinx_argparse_neo output to convert +This Sphinx extension post-processes sphinx_autodoc_argparse output to convert specially-formatted "examples:" definition lists in argparse epilogs into proper documentation sections with syntax-highlighted code blocks. @@ -109,9 +109,9 @@ def setup(app): from docutils import nodes -from sphinx_argparse_neo import __version__ -from sphinx_argparse_neo.directive import ArgparseDirective -from sphinx_argparse_neo.utils import strip_ansi +from sphinx_autodoc_argparse import __version__ +from sphinx_autodoc_argparse.directive import ArgparseDirective +from sphinx_autodoc_argparse.utils import strip_ansi if t.TYPE_CHECKING: import sphinx.config @@ -1153,7 +1153,7 @@ def _extract_sections_from_container( Examples -------- >>> from docutils import nodes - >>> from sphinx_argparse_neo.nodes import argparse_program + >>> from sphinx_autodoc_argparse.nodes import argparse_program >>> container = argparse_program() >>> para = nodes.paragraph(text="Description") >>> examples = nodes.section() @@ -1283,8 +1283,8 @@ def setup(app: Sphinx) -> SetupDict: dict Extension metadata. """ - # Load the base sphinx_argparse_neo extension first - app.setup_extension("sphinx_argparse_neo") + # Load the base sphinx_autodoc_argparse extension first + app.setup_extension("sphinx_autodoc_argparse") # Register configuration options app.add_config_value( @@ -1346,12 +1346,12 @@ def setup(app: Sphinx) -> SetupDict: app.add_directive("argparse", CleanArgParseDirective, override=True) # Register CLI usage lexer for usage block highlighting - from sphinx_argparse_neo.cli_usage_lexer import CLIUsageLexer + from sphinx_autodoc_argparse.cli_usage_lexer import CLIUsageLexer app.add_lexer("cli-usage", CLIUsageLexer) # Register argparse lexers for help output highlighting - from sphinx_argparse_neo.lexer import ( + from sphinx_autodoc_argparse.lexer import ( ArgparseHelpLexer, ArgparseLexer, ArgparseUsageLexer, @@ -1362,9 +1362,9 @@ def setup(app: Sphinx) -> SetupDict: app.add_lexer("argparse-help", ArgparseHelpLexer) # Register CLI inline roles for documentation - from sphinx_argparse_neo.roles import register_roles + from sphinx_autodoc_argparse.roles import register_roles - register_roles() + register_roles(app) return { "version": __version__, diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/highlight.css b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/highlight.css similarity index 99% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/highlight.css rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/highlight.css index f232c71c..8ec4e668 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/highlight.css +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/highlight.css @@ -176,7 +176,7 @@ /* * These styles apply to the argparse directive output which uses custom - * nodes rendered by sphinx_argparse_neo. The directive adds highlight spans + * nodes rendered by sphinx_autodoc_argparse. The directive adds highlight spans * directly to the HTML output. */ diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/lexer.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/lexer.py similarity index 100% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/lexer.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/lexer.py diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/nodes.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/nodes.py similarity index 99% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/nodes.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/nodes.py index 4d74ea47..14903f95 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/nodes.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/nodes.py @@ -10,8 +10,8 @@ from docutils import nodes -from sphinx_argparse_neo.lexer import ArgparseUsageLexer -from sphinx_argparse_neo.utils import strip_ansi +from sphinx_autodoc_argparse.lexer import ArgparseUsageLexer +from sphinx_autodoc_argparse.utils import strip_ansi if t.TYPE_CHECKING: from sphinx.writers.html5 import HTML5Translator diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/parser.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/parser.py similarity index 99% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/parser.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/parser.py index 5bccf1bc..a8069554 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/parser.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/parser.py @@ -10,7 +10,7 @@ import argparse import dataclasses -from sphinx_argparse_neo.utils import strip_ansi +from sphinx_autodoc_argparse.utils import strip_ansi # Sentinel for "no default" (distinct from None which is a valid default) NO_DEFAULT = object() diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/py.typed b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/py.typed similarity index 100% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/py.typed rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/py.typed diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/renderer.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py similarity index 67% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/renderer.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py index 63ecfa2e..d4724cfe 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/renderer.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/renderer.py @@ -12,7 +12,8 @@ from docutils import nodes from docutils.statemachine import StringList -from sphinx_argparse_neo.nodes import ( +from sphinx_autodoc_argparse.nodes import ( + _generate_argument_id, argparse_argument, argparse_group, argparse_program, @@ -20,13 +21,14 @@ argparse_subcommands, argparse_usage, ) -from sphinx_argparse_neo.utils import escape_rst_emphasis +from sphinx_autodoc_argparse.utils import escape_rst_emphasis if t.TYPE_CHECKING: from docutils.parsers.rst.states import RSTState from sphinx.config import Config + from sphinx.environment import BuildEnvironment - from sphinx_argparse_neo.parser import ( + from sphinx_autodoc_argparse.parser import ( ArgumentGroup, ArgumentInfo, MutuallyExclusiveGroup, @@ -87,10 +89,14 @@ class ArgparseRenderer: Rendering configuration. state : RSTState | None RST state for parsing nested RST content. + env : BuildEnvironment | None + Sphinx build environment. When provided the renderer registers + CLI options with the ``std`` domain so that ``:option:`` cross + references and ``objects.inv`` entries work. Examples -------- - >>> from sphinx_argparse_neo.parser import ParserInfo + >>> from sphinx_autodoc_argparse.parser import ParserInfo >>> config = RenderConfig() >>> renderer = ArgparseRenderer(config) >>> info = ParserInfo( @@ -112,10 +118,12 @@ def __init__( self, config: RenderConfig | None = None, state: RSTState | None = None, + env: BuildEnvironment | None = None, ) -> None: """Initialize the renderer.""" self.config = config or RenderConfig() self.state = state + self.env = env @staticmethod def _extract_id_prefix(prog: str) -> str: @@ -148,13 +156,137 @@ def _extract_id_prefix(prog: str) -> str: # Join remaining parts with hyphen for multi-level subcommands return "-".join(parts[1:]) - def render(self, parser_info: ParserInfo) -> list[nodes.Node]: + def _register_argument( + self, + prog_name: str, + names: list[str], + anchor_id: str, + ) -> None: + """Register a CLI argument with both the std and argparse domains. + + Dual-emits so that: + + * ``:option:`` (``std:cmdoption``) cross-references and the + ``objects.inv`` inventory keep working for intersphinx consumers. + * ``:argparse:option:`` or ``:argparse:positional:`` xrefs resolve + within the workspace, with program-scoped keys the new + per-domain index renders. + + Names that start with ``-`` are treated as options; anything else + is treated as a positional argument. + + Parameters + ---------- + prog_name : str + Program path (e.g. ``"myapp sync"``). Spaces are replaced + with hyphens for the std-domain key (Sphinx convention); + preserved for the argparse-domain key (human-readable). + names : list[str] + Argument name variants (e.g. ``["-v", "--verbose"]`` or + ``["FILE"]`` for a positional). + anchor_id : str + Existing HTML anchor ID generated by + ``_generate_argument_id``. + + Examples + -------- + >>> from sphinx_autodoc_argparse.renderer import ArgparseRenderer + >>> r = ArgparseRenderer() + >>> r._register_argument("myapp", ["--verbose"], "verbose") # no-op without env + """ + if self.env is None or not names: + return + std_domain = self.env.domains.standard_domain + std_program = prog_name.replace(" ", "-") if prog_name else None + argparse_domain = self.env.domains["argparse"] + docname = self.env.docname + for name in names: + std_domain.add_program_option(std_program, name, docname, anchor_id) + if name.startswith("-"): + argparse_domain.note_option( # type: ignore[attr-defined] + prog_name, + name, + docname, + anchor_id, + ) + else: + argparse_domain.note_positional( # type: ignore[attr-defined] + prog_name, + name, + docname, + anchor_id, + ) + + def _register_program(self, prog_name: str, anchor_id: str) -> None: + """Register a program with the argparse domain. + + Parameters + ---------- + prog_name : str + Full program path (e.g. ``"myapp"`` or ``"myapp sync"``). + anchor_id : str + HTML anchor for the program's heading. + + Examples + -------- + >>> from sphinx_autodoc_argparse.renderer import ArgparseRenderer + >>> r = ArgparseRenderer() + >>> r._register_program("myapp", "argparse-myapp") # no-op without env + """ + if self.env is None or not prog_name: + return + argparse_domain = self.env.domains["argparse"] + argparse_domain.note_program( # type: ignore[attr-defined] + prog_name, + self.env.docname, + anchor_id, + ) + + def _register_subcommand( + self, + parent_prog: str, + name: str, + anchor_id: str, + ) -> None: + """Register a subcommand with the argparse domain. + + Parameters + ---------- + parent_prog : str + Parent program path (e.g. ``"myapp"``). + name : str + Subcommand name (e.g. ``"sync"``). + anchor_id : str + HTML anchor for the subcommand's heading. + + Examples + -------- + >>> from sphinx_autodoc_argparse.renderer import ArgparseRenderer + >>> r = ArgparseRenderer() + >>> r._register_subcommand("myapp", "sync", "argparse-myapp-sync") + """ + if self.env is None or not name: + return + argparse_domain = self.env.domains["argparse"] + argparse_domain.note_subcommand( # type: ignore[attr-defined] + parent_prog, + name, + self.env.docname, + anchor_id, + ) + + def render( + self, parser_info: ParserInfo, *, prog_name: str = "" + ) -> list[nodes.Node]: """Render a complete parser to docutils nodes. Parameters ---------- parser_info : ParserInfo The parsed parser information. + prog_name : str + Full program path for domain registration (e.g. ``"myapp sync"``). + Derived from *parser_info.prog* when empty. Returns ------- @@ -178,6 +310,18 @@ def render(self, parser_info: ParserInfo) -> list[nodes.Node]: The "examples:" definition list in descriptions is left for argparse_exemplar.py to transform into a proper Examples section. """ + if not prog_name: + prog_name = parser_info.prog + + # Register the program with the argparse domain so + # :argparse:program:`prog_name` resolves and the programs + # index lists it. Use the program-slug for the anchor — it + # matches the convention established by argparse_program's + # HTML visitor. + if prog_name: + program_anchor = f"argparse-{prog_name.replace(' ', '-')}" + self._register_program(prog_name, program_anchor) + result: list[nodes.Node] = [] # Create program container for description only @@ -202,12 +346,19 @@ def render(self, parser_info: ParserInfo) -> list[nodes.Node]: # Add argument groups as sibling sections (for TOC visibility) for group in parser_info.argument_groups: - group_section = self.render_group_section(group, id_prefix=id_prefix) + group_section = self.render_group_section( + group, + id_prefix=id_prefix, + prog_name=prog_name, + ) result.append(group_section) # Add subcommands if parser_info.subcommands: - subcommands_node = self.render_subcommands(parser_info.subcommands) + subcommands_node = self.render_subcommands( + parser_info.subcommands, + parent_prog=prog_name, + ) result.append(subcommands_node) # Add epilog @@ -259,7 +410,7 @@ def render_usage_section( Examples -------- - >>> from sphinx_argparse_neo.parser import ParserInfo + >>> from sphinx_autodoc_argparse.parser import ParserInfo >>> renderer = ArgparseRenderer() >>> info = ParserInfo( ... prog="myapp", @@ -302,7 +453,11 @@ def render_usage_section( return section def render_group_section( - self, group: ArgumentGroup, *, id_prefix: str = "" + self, + group: ArgumentGroup, + *, + id_prefix: str = "", + prog_name: str = "", ) -> nodes.section: """Render an argument group wrapped in a section for TOC visibility. @@ -326,7 +481,7 @@ def render_group_section( Examples -------- - >>> from sphinx_argparse_neo.parser import ArgumentGroup + >>> from sphinx_autodoc_argparse.parser import ArgumentGroup >>> renderer = ArgparseRenderer() >>> group = ArgumentGroup( ... title="positional arguments", @@ -373,7 +528,12 @@ def render_group_section( # Create the styled group container (with empty title - section provides it) # Pass id_prefix to render_group so arguments get unique IDs - group_node = self.render_group(group, include_title=False, id_prefix=id_prefix) + group_node = self.render_group( + group, + include_title=False, + id_prefix=id_prefix, + prog_name=prog_name, + ) section += group_node return section @@ -384,6 +544,7 @@ def render_group( include_title: bool = True, *, id_prefix: str = "", + prog_name: str = "", ) -> argparse_group: """Render an argument group. @@ -420,18 +581,30 @@ def render_group( # Add individual arguments for arg in group.arguments: - arg_node = self.render_argument(arg, id_prefix=id_prefix) + arg_node = self.render_argument( + arg, + id_prefix=id_prefix, + prog_name=prog_name, + ) group_node.append(arg_node) # Add mutually exclusive groups for mutex in group.mutually_exclusive: - mutex_nodes = self.render_mutex_group(mutex, id_prefix=id_prefix) + mutex_nodes = self.render_mutex_group( + mutex, + id_prefix=id_prefix, + prog_name=prog_name, + ) group_node.extend(mutex_nodes) return group_node def render_argument( - self, arg: ArgumentInfo, *, id_prefix: str = "" + self, + arg: ArgumentInfo, + *, + id_prefix: str = "", + prog_name: str = "", ) -> argparse_argument: """Render a single argument. @@ -443,6 +616,8 @@ def render_argument( Optional prefix for the argument ID (e.g., "shell" -> "shell-L"). Used to ensure unique IDs when multiple argparse directives exist on the same page. + prog_name : str + Full program path for domain registration (e.g. ``"myapp sync"``). Returns ------- @@ -465,10 +640,20 @@ def render_argument( if self.config.show_types: arg_node["type_name"] = arg.type_name + # Dual-emit to std:cmdoption (intersphinx compat) + argparse domain. + if prog_name and arg.names: + anchor_id = _generate_argument_id(arg.names, id_prefix) + if anchor_id: + self._register_argument(prog_name, arg.names, anchor_id) + return arg_node def render_mutex_group( - self, mutex: MutuallyExclusiveGroup, *, id_prefix: str = "" + self, + mutex: MutuallyExclusiveGroup, + *, + id_prefix: str = "", + prog_name: str = "", ) -> list[argparse_argument]: """Render a mutually exclusive group. @@ -478,6 +663,8 @@ def render_mutex_group( The mutually exclusive group. id_prefix : str Optional prefix for argument IDs (e.g., "shell" -> "shell-h"). + prog_name : str + Full program path for domain registration. Returns ------- @@ -486,7 +673,11 @@ def render_mutex_group( """ result: list[argparse_argument] = [] for arg in mutex.arguments: - arg_node = self.render_argument(arg, id_prefix=id_prefix) + arg_node = self.render_argument( + arg, + id_prefix=id_prefix, + prog_name=prog_name, + ) # Mark as part of mutex group arg_node["mutex"] = True arg_node["mutex_required"] = mutex.required @@ -494,7 +685,10 @@ def render_mutex_group( return result def render_subcommands( - self, subcommands: list[SubcommandInfo] + self, + subcommands: list[SubcommandInfo], + *, + parent_prog: str = "", ) -> argparse_subcommands: """Render subcommands section. @@ -502,6 +696,8 @@ def render_subcommands( ---------- subcommands : list[SubcommandInfo] List of subcommand information. + parent_prog : str + Parent program path for building full subcommand paths. Returns ------- @@ -512,18 +708,25 @@ def render_subcommands( container["title"] = "Sub-commands" for subcmd in subcommands: - subcmd_node = self.render_subcommand(subcmd) + subcmd_node = self.render_subcommand(subcmd, parent_prog=parent_prog) container.append(subcmd_node) return container - def render_subcommand(self, subcmd: SubcommandInfo) -> argparse_subcommand: + def render_subcommand( + self, + subcmd: SubcommandInfo, + *, + parent_prog: str = "", + ) -> argparse_subcommand: """Render a single subcommand. Parameters ---------- subcmd : SubcommandInfo The subcommand information. + parent_prog : str + Parent program path (e.g. ``"myapp"``). Returns ------- @@ -535,9 +738,17 @@ def render_subcommand(self, subcmd: SubcommandInfo) -> argparse_subcommand: subcmd_node["aliases"] = subcmd.aliases subcmd_node["help"] = subcmd.help + # Register the subcommand with the argparse domain so + # :argparse:subcommand:`myapp sync` resolves. Use a parent-scoped + # anchor slug matching the program-anchor convention from render(). + if parent_prog and subcmd.name: + subcmd_anchor = f"argparse-{parent_prog.replace(' ', '-')}-{subcmd.name}" + self._register_subcommand(parent_prog, subcmd.name, subcmd_anchor) + # Recursively render the subcommand's parser if subcmd.parser: - nested_nodes = self.render(subcmd.parser) + new_prog = f"{parent_prog} {subcmd.name}".strip() + nested_nodes = self.render(subcmd.parser, prog_name=new_prog) subcmd_node.extend(nested_nodes) return subcmd_node @@ -597,6 +808,7 @@ def create_renderer( config: RenderConfig | None = None, state: RSTState | None = None, renderer_class: type[ArgparseRenderer] | None = None, + env: BuildEnvironment | None = None, ) -> ArgparseRenderer: """Create a renderer instance. @@ -608,6 +820,8 @@ def create_renderer( RST state for parsing. renderer_class : type[ArgparseRenderer] | None Custom renderer class to use. + env : BuildEnvironment | None + Sphinx build environment for domain registration. Returns ------- @@ -615,4 +829,4 @@ def create_renderer( Configured renderer instance. """ cls = renderer_class or ArgparseRenderer - return cls(config=config, state=state) + return cls(config=config, state=state, env=env) diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/roles.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/roles.py similarity index 91% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/roles.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/roles.py index 86e5459a..4058c125 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/roles.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/roles.py @@ -16,10 +16,10 @@ import typing as t from docutils import nodes -from docutils.parsers.rst import roles if t.TYPE_CHECKING: from docutils.parsers.rst.states import Inliner + from sphinx.application import Sphinx def normalize_options(options: dict[str, t.Any] | None) -> dict[str, t.Any]: @@ -348,8 +348,14 @@ def cli_choice_role( return [node], [] -def register_roles() -> None: - """Register all CLI roles with docutils. +def register_roles(app: Sphinx) -> None: + """Register all CLI roles with the Sphinx application. + + Uses :meth:`sphinx.application.Sphinx.add_role` (Sphinx-scoped) + rather than ``docutils.parsers.rst.roles.register_local_role`` + (docutils process-global). The Sphinx accessor is typed as + ``(name: str, role: Any, override: bool = False) -> None`` so no + ``# type: ignore`` is required. This function registers the following roles: - cli-option: For CLI options (--verbose, -h) @@ -358,13 +364,19 @@ def register_roles() -> None: - cli-default: For default values (None, "default") - cli-choice: For choice values (json, yaml) + Parameters + ---------- + app : Sphinx + The Sphinx application instance. Must be called from within + an extension ``setup(app)`` hook. + Examples -------- - >>> register_roles() - >>> # Roles are now available in docutils RST parsing + >>> register_roles # doctest: +ELLIPSIS + """ - roles.register_local_role("cli-option", cli_option_role) # type: ignore[arg-type] - roles.register_local_role("cli-metavar", cli_metavar_role) # type: ignore[arg-type] - roles.register_local_role("cli-command", cli_command_role) # type: ignore[arg-type] - roles.register_local_role("cli-default", cli_default_role) # type: ignore[arg-type] - roles.register_local_role("cli-choice", cli_choice_role) # type: ignore[arg-type] + app.add_role("cli-option", cli_option_role) + app.add_role("cli-metavar", cli_metavar_role) + app.add_role("cli-command", cli_command_role) + app.add_role("cli-default", cli_default_role) + app.add_role("cli-choice", cli_choice_role) diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/utils.py b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/utils.py similarity index 97% rename from packages/sphinx-argparse-neo/src/sphinx_argparse_neo/utils.py rename to packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/utils.py index c1a8275f..501d19a8 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/utils.py +++ b/packages/sphinx-autodoc-argparse/src/sphinx_autodoc_argparse/utils.py @@ -1,4 +1,4 @@ -"""Text processing utilities for sphinx_argparse_neo. +"""Text processing utilities for sphinx_autodoc_argparse. This module provides utilities for cleaning argparse output before rendering: - strip_ansi: Remove ANSI escape codes (for when FORCE_COLOR is set) diff --git a/packages/sphinx-autodoc-badges/README.md b/packages/sphinx-autodoc-badges/README.md deleted file mode 100644 index 9da4c4ce..00000000 --- a/packages/sphinx-autodoc-badges/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# sphinx-autodoc-badges - -Shared badge node and CSS for Sphinx autodoc extensions in the gp-sphinx ecosystem. - -Provides `BadgeNode`, HTML visitors, and builder helpers that `sphinx-autodoc-api-style`, -`sphinx-autodoc-pytest-fixtures`, and `sphinx-autodoc-fastmcp` share. diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py deleted file mode 100644 index b16e37b4..00000000 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Badge builder helpers -- typed API for creating badge nodes. - -Examples --------- ->>> b = build_badge("readonly", tooltip="Read-only", classes=["smf-safety-readonly"]) ->>> b.astext() -'readonly' - ->>> "sab-badge" in b["classes"] -True -""" - -from __future__ import annotations - -import typing as t - -from docutils import nodes - -from sphinx_autodoc_badges._nodes import BadgeNode - - -def build_badge( - text: str, - *, - tooltip: str = "", - icon: str = "", - classes: t.Sequence[str] = (), - style: str = "full", - fill: str = "filled", - size: str = "", - tabindex: str = "0", -) -> BadgeNode: - """Build a single badge node. - - Parameters - ---------- - text : str - Visible label. Empty string for icon-only badges. - tooltip : str - Hover text and ``aria-label``. - icon : str - Emoji character for CSS ``::before``. - classes : Sequence[str] - Additional CSS classes (plugin prefix + color class). - style : str - Structural variant: ``"full"``, ``"icon-only"``, ``"inline-icon"``. - fill : str - Visual fill: ``"filled"`` (default) or ``"outline"``. - size : str - Optional size tier: ``"xs"``, ``"sm"``, ``"lg"``, or ``"xl"``. - Empty string uses the default (no extra class). - tabindex : str - ``"0"`` for focusable, ``""`` to skip. - - Returns - ------- - BadgeNode - - Examples - -------- - >>> b = build_badge("async", tooltip="Asynchronous", classes=["gas-mod-async"]) - >>> b.astext() - 'async' - - >>> b = build_badge("", style="icon-only", classes=["smf-safety-readonly"]) - >>> "sab-icon-only" in b["classes"] - True - - >>> b = build_badge("big", size="lg") - >>> "sab-lg" in b["classes"] - True - """ - extra_classes = list(classes) - if fill == "outline": - extra_classes.append("sab-outline") - return BadgeNode( - text, - badge_tooltip=tooltip, - badge_icon=icon, - badge_style=style, - badge_size=size, - tabindex=tabindex, - classes=extra_classes, - ) - - -def build_badge_group( - badges: t.Sequence[BadgeNode], - *, - classes: t.Sequence[str] = (), -) -> nodes.inline: - """Wrap badges in a group container with inter-badge spacing. - - Parameters - ---------- - badges : Sequence[BadgeNode] - Badge nodes to group. - classes : Sequence[str] - Additional CSS classes on the group container. - - Returns - ------- - nodes.inline - - Examples - -------- - >>> from sphinx_autodoc_badges._nodes import BadgeNode - >>> g = build_badge_group([BadgeNode("a"), BadgeNode("b")]) - >>> "sab-badge-group" in g["classes"] - True - """ - group = nodes.inline(classes=["sab-badge-group", *classes]) - for i, badge in enumerate(badges): - if i > 0: - group += nodes.Text(" ") - group += badge - return group - - -def build_toolbar( - badge_group: nodes.inline, - *, - classes: t.Sequence[str] = (), -) -> nodes.inline: - """Wrap a badge group in a toolbar (``margin-left: auto`` for flex titles). - - Parameters - ---------- - badge_group : nodes.inline - Badge group from :func:`build_badge_group`. - classes : Sequence[str] - Additional CSS classes on the toolbar. - - Returns - ------- - nodes.inline - - Examples - -------- - >>> from sphinx_autodoc_badges._nodes import BadgeNode - >>> g = build_badge_group([BadgeNode("x")]) - >>> t = build_toolbar(g, classes=["smf-toolbar"]) - >>> "sab-toolbar" in t["classes"] - True - """ - toolbar = nodes.inline(classes=["sab-toolbar", *classes]) - toolbar += badge_group - return toolbar diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py deleted file mode 100644 index fb7d1283..00000000 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Shared CSS class name constants for sphinx_autodoc_badges. - -Examples --------- ->>> SAB.BADGE -'sab-badge' - ->>> SAB.BADGE_GROUP -'sab-badge-group' - ->>> SAB.TOOLBAR -'sab-toolbar' - ->>> SAB.ICON_ONLY -'sab-icon-only' - ->>> SAB.SM -'sab-sm' -""" - -from __future__ import annotations - - -class SAB: - """CSS class constants (``sab-`` = sphinx autodoc badges). - - Examples - -------- - >>> SAB.PREFIX - 'sab' - - >>> SAB.OUTLINE - 'sab-outline' - """ - - PREFIX = "sab" - BADGE = f"{PREFIX}-badge" - BADGE_GROUP = f"{PREFIX}-badge-group" - TOOLBAR = f"{PREFIX}-toolbar" - ICON_ONLY = f"{PREFIX}-icon-only" - INLINE_ICON = f"{PREFIX}-inline-icon" - OUTLINE = f"{PREFIX}-outline" - FILLED = f"{PREFIX}-filled" - XS = f"{PREFIX}-xs" - SM = f"{PREFIX}-sm" - LG = f"{PREFIX}-lg" - XL = f"{PREFIX}-xl" diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css deleted file mode 100644 index 65727c14..00000000 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css +++ /dev/null @@ -1,260 +0,0 @@ -/* sphinx_autodoc_badges — shared badge metrics, variants, and structural CSS. - * - * Base layer: metrics matching sphinx-design sd-badge (Bootstrap 5). - * Plugins add their own color classes via CSS custom properties - * (--sab-bg, --sab-fg, --sab-border). - */ - -:root { - /* Subtle “buffed pill”: top highlight + soft inner shade (light UI / white page bg) */ - --sab-buff-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), - inset 0 -1px 2px rgba(0, 0, 0, 0.12); - --sab-buff-shadow-dark-ui: inset 0 1px 0 rgba(255, 255, 255, 0.1), - inset 0 -1px 2px rgba(0, 0, 0, 0.28); -} - -/* ── Base badge ─────────────────────────────────────────── */ -.sab-badge { - display: inline-flex; - align-items: center; - gap: var(--sab-icon-gap, 0.28rem); - font-size: var(--sab-font-size, 0.75em); - font-weight: var(--sab-font-weight, 700); - line-height: 1; - letter-spacing: 0.01em; - padding: var(--sab-padding-v, 0.35em) var(--sab-padding-h, 0.65em); - border-radius: var(--sab-radius, 0.25rem); - border: var(--sab-border, none); - /* Use background-color + fallbacks so unset --sab-* does not interact badly - * with later theme shorthands (e.g. sphinx-design loaded after extensions). */ - background-color: var(--sab-bg, transparent); - color: var(--sab-fg, inherit); - vertical-align: middle; - white-space: nowrap; - text-align: center; - text-decoration: none; - user-select: none; - -webkit-user-select: none; - box-sizing: border-box; -} - -/* ── Size variants (explicit; compose with outline / icon-only / color classes) ─ */ -.sab-badge.sab-xs { - font-size: 0.58em; - padding: 0.2em 0.42em; -} - -.sab-badge.sab-sm { - font-size: 0.65em; - padding: 0.28em 0.52em; -} - -.sab-badge.sab-lg { - font-size: 0.88em; - padding: 0.4em 0.75em; -} - -.sab-badge.sab-xl { - font-size: 1.05em; - padding: 0.45em 0.85em; -} - -.sab-badge:not(.sab-outline):not(.sab-inline-icon) { - box-shadow: var(--sab-buff-shadow); -} - -@media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) .sab-badge:not(.sab-outline):not(.sab-inline-icon) { - box-shadow: var(--sab-buff-shadow-dark-ui); - } -} - -body[data-theme="dark"] .sab-badge:not(.sab-outline):not(.sab-inline-icon) { - box-shadow: var(--sab-buff-shadow-dark-ui); -} - -.sab-badge:focus-visible { - outline: 2px solid var(--color-link, #2962ff); - outline-offset: 2px; -} - -/* ── Outline fill variant ───────────────────────────────── */ -.sab-badge.sab-outline { - background: transparent; -} - -/* ── Icon system (::before pseudo-element) ──────────────── */ -.sab-badge::before { - font-style: normal; - font-weight: normal; - font-size: 1em; - line-height: 1; - flex-shrink: 0; -} - -.sab-badge[data-icon]::before { - content: attr(data-icon); -} - -/* ── Badge group ────────────────────────────────────────── */ -.sab-badge-group { - display: inline-flex; - align-items: center; - gap: 0.3rem; - white-space: nowrap; -} - -/* ── Toolbar (flex margin-left: auto for title rows) ────── */ -.sab-toolbar { - display: inline-flex; - align-items: center; - gap: 0.35rem; - flex-shrink: 0; - margin-left: auto; - white-space: nowrap; - text-indent: 0; - order: 99; -} - -/* ── Icon-only variant (outside code: 16x16 colored box) ── */ -.sab-badge.sab-icon-only { - display: inline-flex !important; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - padding: 0; - box-sizing: border-box; - border-radius: 3px; - gap: 0; - font-size: 0; - line-height: 1; - min-width: 0; - min-height: 0; - margin: 0; -} - -.sab-badge.sab-icon-only::before { - font-size: 10px; - line-height: 1; - font-style: normal; - font-weight: normal; - margin: 0; - display: block; - opacity: 0.9; -} - -/* Icon-only links: flexbox parent for consistent spacing */ -a.reference:has(> .sab-badge.sab-icon-only) { - display: inline-flex; - align-items: center; - gap: 3px; -} - -a.reference:has(> .sab-badge.sab-icon-only) > code { - margin: 0; -} - -/* ── Inline-icon variant (bare emoji inside code chip) ──── */ -.sab-badge.sab-inline-icon { - background: transparent !important; - border: none !important; - padding: 0; - width: auto; - height: auto; - border-radius: 0; - vertical-align: -0.01em; - margin-right: 0.12em; - margin-left: 0; -} - -.sab-badge.sab-inline-icon::before { - font-size: 0.78rem; - opacity: 0.85; -} - -code.docutils .sab-badge.sab-inline-icon:last-child { - margin-left: 0.1em; - margin-right: 0; -} - -/* ── Context-aware badge sizing ─────────────────────────── * - * Scoped to .document-content (Furo main body) to avoid - * applying in sidebar, TOC, or navigation contexts. - * Ancestors use :where() so explicit .sab-xs–.sab-xl size classes win. - */ -:where(.body h2, .body h3, [role="main"] h2, [role="main"] h3) .sab-badge { - font-size: 0.68rem; - padding: 0.17rem 0.4rem; -} - -:where( - .body p, - .body li, - .body td, - .body a, - [role="main"] p, - [role="main"] li, - [role="main"] td, - [role="main"] a - ) - .sab-badge { - font-size: 0.62rem; - padding: 0.12rem 0.32rem; -} - -/* ── Consistent code → badge spacing (body only) ────────── */ -:where(.body) code.docutils + .sab-badge, -:where(.body) .sab-badge + code.docutils, -:where([role="main"]) code.docutils + .sab-badge, -:where([role="main"]) .sab-badge + code.docutils { - margin-left: 0.4em; -} - -/* ── Link behavior: underline code only, on hover ───────── */ -:where(.body, [role="main"]) a.reference .sab-badge { - text-decoration: none; - vertical-align: middle; -} - -:where(.body, [role="main"]) a.reference:has(.sab-badge) code { - vertical-align: middle; - text-decoration: none; -} - -:where(.body, [role="main"]) a.reference:has(.sab-badge) { - text-decoration: none; -} - -:where(.body, [role="main"]) a.reference:has(.sab-badge):hover code { - text-decoration: underline; -} - -/* ── TOC sidebar: compact badges ───────────────────────── - * Smaller badges that still show text (matching production). - * Container wrappers collapse to inline flow. - * Emoji icons shown at compact size (data-icon / ::before). - */ -:where(.toc-tree) .sab-toolbar, -:where(.toc-tree) .sab-badge-group { - display: inline; - gap: 0; - margin: 0; - padding: 0; - border: none; - background: none; -} - -:where(.toc-tree) .sab-badge { - font-size: 0.58rem; - padding: 0.1rem 0.25rem; - gap: 0.06rem; - vertical-align: middle; - line-height: 1.1; -} - -:where(.toc-tree) .sab-badge::before { - font-size: 0.5rem; - margin-right: 0.08rem; - flex-shrink: 0; -} diff --git a/packages/sphinx-autodoc-docutils/README.md b/packages/sphinx-autodoc-docutils/README.md index 751a61f9..af92dbbe 100644 --- a/packages/sphinx-autodoc-docutils/README.md +++ b/packages/sphinx-autodoc-docutils/README.md @@ -3,6 +3,10 @@ Sphinx extension for turning docutils directives and roles into copyable reference entries inside your docs site. +The extension keeps its semantic `rst:*` parse path, but the rendered body +regions, badges, and shared type formatting now come from +`sphinx_ux_autodoc_layout`, `sphinx_ux_badges`, and `sphinx_autodoc_typehints_gp`. + ## Install ```console diff --git a/packages/sphinx-autodoc-docutils/pyproject.toml b/packages/sphinx-autodoc-docutils/pyproject.toml index fad0c877..9ba56ee0 100644 --- a/packages/sphinx-autodoc-docutils/pyproject.toml +++ b/packages/sphinx-autodoc-docutils/pyproject.toml @@ -26,7 +26,10 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "docutils", "directives", "roles", "documentation", "autodoc"] dependencies = [ - "sphinx", + "sphinx>=8.1", + "sphinx-ux-badges==0.0.1a7", + "sphinx-ux-autodoc-layout==0.0.1a7", + "sphinx-autodoc-typehints-gp==0.0.1a7", ] [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 6e4f178e..b6f6129e 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import pathlib import typing as t from sphinx_autodoc_docutils._directives import ( @@ -28,16 +29,29 @@ def setup(app: Sphinx) -> ExtensionMetadata: -------- >>> class FakeApp: ... def __init__(self) -> None: - ... self.calls: list[tuple[str, str]] = [] + ... self.calls: list[tuple[str, object]] = [] + ... def setup_extension(self, name: str) -> None: + ... self.calls.append(("setup_extension", name)) ... def add_directive(self, name: str, directive: object) -> None: ... self.calls.append(("add_directive", name)) + ... def connect(self, event: str, handler: object) -> None: + ... self.calls.append(("connect", event)) + ... def add_css_file(self, filename: str) -> None: + ... self.calls.append(("add_css_file", filename)) >>> fake = FakeApp() >>> metadata = setup(fake) # type: ignore[arg-type] >>> ("add_directive", "autodirective") in fake.calls True + >>> ("setup_extension", "sphinx_ux_autodoc_layout") in fake.calls + True + >>> ("add_css_file", "css/sphinx_autodoc_docutils.css") in fake.calls + True >>> metadata["parallel_read_safe"] True """ + app.setup_extension("sphinx_ux_badges") + app.setup_extension("sphinx_ux_autodoc_layout") + app.setup_extension("sphinx_autodoc_typehints_gp") app.add_directive("autodirective", AutoDirective) app.add_directive("autodirectives", AutoDirectives) app.add_directive("autodirective-index", AutoDirectiveIndex) @@ -45,6 +59,15 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_directive("autoroles", AutoRoles) app.add_directive("autorole-index", AutoRoleIndex) + _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/sphinx_autodoc_docutils.css") + return { "version": "0.0.1a7", "parallel_read_safe": True, diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py new file mode 100644 index 00000000..5bc062de --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py @@ -0,0 +1,41 @@ +"""Badge helpers for sphinx_autodoc_docutils reference entries.""" + +from __future__ import annotations + +from docutils import nodes + +from sphinx_ux_badges import SAB, BadgeSpec, build_badge_group_from_specs + +_GROUP_CLASS = SAB.BADGE_GROUP + +_KIND_CLASSES: dict[str, str] = { + "directive": SAB.TYPE_DIRECTIVE, + "role": SAB.TYPE_ROLE, + "option": SAB.TYPE_OPTION, +} + + +def build_kind_badge_group(kind: str) -> nodes.inline: + """Return header badges for one documented docutils object. + + Parameters + ---------- + kind : str + Entry kind such as ``"directive"``, ``"role"``, or ``"option"``. + + Returns + ------- + nodes.inline + Badge group for the entry header. + """ + colour_class = _KIND_CLASSES.get(kind, SAB.TYPE_DIRECTIVE) + return build_badge_group_from_specs( + [ + BadgeSpec( + kind, + tooltip=f"Docutils {kind}", + classes=(colour_class,), + ), + ], + classes=[_GROUP_CLASS], + ) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py index 6615009c..2248a53f 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -8,9 +8,21 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives -from docutils.statemachine import StringList +from sphinx import addnodes from sphinx.util.docutils import SphinxDirective +from sphinx_autodoc_docutils._badges import build_kind_badge_group +from sphinx_ux_autodoc_layout import ( + API, + ApiFactRow, + build_api_facts_section, + build_api_summary_section, + build_api_table_section, + inject_signature_slots, + iter_desc_nodes, + parse_generated_markup, +) + if t.TYPE_CHECKING: from sphinx.util.typing import OptionSpec @@ -78,7 +90,7 @@ def _role_callables( Examples -------- - >>> roles = _role_callables("sphinx_argparse_neo.roles") + >>> roles = _role_callables("sphinx_autodoc_argparse.roles") >>> any(name == "cli_option_role" for name, _value in roles) True """ @@ -132,43 +144,179 @@ def _option_rows(option_spec: OptionSpec | None) -> list[str]: return rows -# NOTE: This function is byte-for-byte identical to -# sphinx_autodoc_sphinx._directives._render_blocks. Both packages depend only -# on sphinx (not on each other), so a shared location would require a new -# dependency. If a third caller emerges, extract to gp_sphinx._render. -def _render_blocks(directive: SphinxDirective, markup: str) -> list[nodes.Node]: - """Parse generated markup through Sphinx when available. +def _literal_paragraph(text: str) -> nodes.paragraph: + """Return a paragraph containing one literal node.""" + paragraph = nodes.paragraph() + paragraph += nodes.literal(text, text) + return paragraph - Examples - -------- - >>> class DummyState: - ... def nested_parse( - ... self, - ... view_list: StringList, - ... offset: int, - ... node: nodes.Element, - ... ) -> None: - ... for line in view_list: - ... node += nodes.paragraph("", line) - >>> class DummyDirective: - ... state = DummyState() - ... content_offset = 0 - ... def get_source_info(self) -> tuple[str, int]: - ... return ("demo.md", 1) - >>> rendered = _render_blocks(DummyDirective(), "demo") # type: ignore[arg-type] - >>> rendered[0].astext() - 'demo' - """ - if hasattr(directive, "parse_text_to_nodes"): - return directive.parse_text_to_nodes(markup) - source, _line = directive.get_source_info() - view_list: StringList = StringList() - for line in markup.splitlines(): - view_list.append(line, source) - container = nodes.container() - directive.state.nested_parse(view_list, directive.content_offset, container) - return [container] if container.children else [] +def _option_field_list(option_spec: OptionSpec | None) -> nodes.field_list | None: + """Return a field-list representation of an option spec.""" + rows = _option_rows(option_spec) + if not rows: + return None + field_list = nodes.field_list() + for row in rows: + option_name, converter_name = row.split("|")[1:3] + clean_option_name = option_name.strip().strip("`") + clean_converter_name = converter_name.strip().strip("`") + field_list += nodes.field( + "", + nodes.field_name("", clean_option_name), + nodes.field_body("", _literal_paragraph(clean_converter_name)), + ) + return field_list + + +def _entry_kind(desc_node: addnodes.desc) -> str: + """Return the badge label for one parsed ``rst`` description node.""" + objtype = str(desc_node.get("objtype", "")) + if objtype == "directive:option": + return "option" + return objtype + + +def _inject_docutils_badges(node_list: list[nodes.Node]) -> None: + """Attach shared badge-slot metadata to parsed ``rst:*`` entries.""" + for desc_node in iter_desc_nodes(node_list): + if desc_node.get("domain") != "rst": + continue + badge_group = build_kind_badge_group(_entry_kind(desc_node)) + for sig_node in desc_node.children: + if not isinstance(sig_node, addnodes.desc_signature): + continue + inject_signature_slots( + sig_node, + marker_attr="sadoc_badges_injected", + badge_node=badge_group.deepcopy(), + extract_source_link=False, + ) + + +def _render_markup_nodes( + directive: SphinxDirective, + markup: str, +) -> list[nodes.Node]: + """Parse markup and attach layout metadata for docutils entries.""" + node_list = parse_generated_markup(directive, markup) + _inject_docutils_badges(node_list) + return node_list + + +def _content_node(desc_node: addnodes.desc) -> addnodes.desc_content | None: + """Return the first ``desc_content`` child for ``desc_node``.""" + return next( + ( + child + for child in desc_node.children + if isinstance(child, addnodes.desc_content) + ), + None, + ) + + +def _insert_after_summary( + content: addnodes.desc_content, + node: nodes.Node, +) -> None: + """Insert *node* after the leading summary paragraphs in ``content``.""" + insert_idx = 0 + while insert_idx < len(content.children) and isinstance( + content.children[insert_idx], + nodes.paragraph, + ): + insert_idx += 1 + content.insert(insert_idx, node) + + +def _directive_fact_rows( + path: str, + directive_cls: type[Directive], +) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented directive.""" + return [ + ApiFactRow("Python path", _literal_paragraph(path)), + ApiFactRow( + "Required arguments", + _literal_paragraph(str(directive_cls.required_arguments)), + ), + ApiFactRow( + "Optional arguments", + _literal_paragraph(str(directive_cls.optional_arguments)), + ), + ApiFactRow( + "Final argument whitespace", + _literal_paragraph(str(directive_cls.final_argument_whitespace)), + ), + ApiFactRow("Has content", _literal_paragraph(str(directive_cls.has_content))), + ] + + +def _role_fact_rows(path: str, role_fn: object) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented role.""" + rows = [ApiFactRow("Python path", _literal_paragraph(path))] + content_value = getattr(role_fn, "content", None) + if content_value is not None: + rows.append( + ApiFactRow("Accepts role content", _literal_paragraph(str(content_value))) + ) + return rows + + +def _normalize_directive_nodes( + node_list: list[nodes.Node], + *, + path: str, + directive_cls: type[Directive], +) -> None: + """Attach shared facts/options sections to parsed directive entries.""" + for desc_node in iter_desc_nodes(node_list): + if desc_node.get("domain") != "rst" or desc_node.get("objtype") != "directive": + continue + content = _content_node(desc_node) + if content is None: + continue + option_descs = [ + child + for child in list(content.children) + if isinstance(child, addnodes.desc) + and child.get("domain") == "rst" + and child.get("objtype") == "directive:option" + ] + for option_desc in option_descs: + content.remove(option_desc) + _insert_after_summary( + content, + build_api_facts_section(_directive_fact_rows(path, directive_cls)), + ) + if option_descs: + content += build_api_table_section(API.OPTIONS, *option_descs) + + +def _normalize_role_nodes( + node_list: list[nodes.Node], + *, + path: str, + role_fn: object, +) -> None: + """Attach shared facts/options sections to parsed role entries.""" + option_field_list = _option_field_list(getattr(role_fn, "options", None)) + for desc_node in iter_desc_nodes(node_list): + if desc_node.get("domain") != "rst" or desc_node.get("objtype") != "role": + continue + content = _content_node(desc_node) + if content is None: + continue + _insert_after_summary( + content, + build_api_facts_section(_role_fact_rows(path, role_fn)), + ) + if option_field_list is not None: + content += build_api_table_section( + API.OPTIONS, + option_field_list.deepcopy(), + ) def _directive_markup( @@ -191,16 +339,6 @@ def _directive_markup( " :no-index:" if no_index else "", "", f" {_summary(directive_cls) or 'Autodocumented directive class.'}", - "", - f" Python path: ``{path}``", - "", - f" Required arguments: ``{directive_cls.required_arguments}``", - "", - f" Optional arguments: ``{directive_cls.optional_arguments}``", - "", - f" Final argument whitespace: ``{directive_cls.final_argument_whitespace}``", - "", - f" Has content: ``{directive_cls.has_content}``", ] option_rows = _option_rows(getattr(directive_cls, "option_spec", None)) if option_rows: @@ -244,20 +382,7 @@ def _role_markup( " :no-index:" if no_index else "", "", f" {_summary(role_fn) or 'Autodocumented role callable.'}", - "", - f" Python path: ``{path}``", ] - option_rows = _option_rows(getattr(role_fn, "options", None)) - if option_rows: - lines.extend(["", " Options:", ""]) - for row in option_rows: - option_name, converter_name = row.split("|")[1:3] - clean_option_name = option_name.strip().strip("`") - clean_converter_name = converter_name.strip().strip("`") - lines.append(f" - ``{clean_option_name}``: ``{clean_converter_name}``") - content_value = getattr(role_fn, "content", None) - if content_value is not None: - lines.extend(["", f" Accepts role content: ``{content_value}``"]) return "\n".join(lines) @@ -304,7 +429,7 @@ def run(self) -> list[nodes.Node]: path = self.arguments[0] module_name, _, attr_name = path.rpartition(".") directive_cls = getattr(importlib.import_module(module_name), attr_name) - return _render_blocks( + rendered = _render_markup_nodes( self, _directive_markup( path, @@ -313,6 +438,8 @@ def run(self) -> list[nodes.Node]: no_index="no-index" in self.options, ), ) + _normalize_directive_nodes(rendered, path=path, directive_cls=directive_cls) + return rendered class AutoDirectives(SphinxDirective): @@ -324,16 +451,22 @@ class AutoDirectives(SphinxDirective): def run(self) -> list[nodes.Node]: module_name = self.arguments[0] - markup = "\n\n".join( - _directive_markup( - f"{module_name}.{name}", - directive_cls, - directive_name=_registered_name(name), - no_index="no-index" in self.options, + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for name, directive_cls in _directive_classes(module_name): + path = f"{module_name}.{name}" + rendered = _render_markup_nodes( + self, + _directive_markup( + path, + directive_cls, + directive_name=_registered_name(name), + no_index=no_index, + ), ) - for name, directive_cls in _directive_classes(module_name) - ) - return _render_blocks(self, markup) if markup else [] + _normalize_directive_nodes(rendered, path=path, directive_cls=directive_cls) + results.extend(rendered) + return results class AutoDirectiveIndex(SphinxDirective): @@ -349,7 +482,13 @@ def run(self) -> list[nodes.Node]: for name, directive_cls in _directive_classes(module_name) ] markup = _index_markup("Directive Index", rows) - return _render_blocks(self, markup) if markup else [] + if not markup: + return [] + rendered = parse_generated_markup(self, markup) + return [ + build_api_summary_section(node) if isinstance(node, nodes.table) else node + for node in rendered + ] class AutoRole(SphinxDirective): @@ -364,7 +503,7 @@ def run(self) -> list[nodes.Node]: module_name, _, attr_name = path.rpartition(".") role_fn = getattr(importlib.import_module(module_name), attr_name) role_name = _registered_name(attr_name) - return _render_blocks( + rendered = _render_markup_nodes( self, _role_markup( path, @@ -373,6 +512,8 @@ def run(self) -> list[nodes.Node]: no_index="no-index" in self.options, ), ) + _normalize_role_nodes(rendered, path=path, role_fn=role_fn) + return rendered class AutoRoles(SphinxDirective): @@ -384,16 +525,22 @@ class AutoRoles(SphinxDirective): def run(self) -> list[nodes.Node]: module_name = self.arguments[0] - markup = "\n\n".join( - _role_markup( - f"{module_name}.{name}", - _registered_name(name), - role_fn, - no_index="no-index" in self.options, + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for name, role_fn in _role_callables(module_name): + path = f"{module_name}.{name}" + rendered = _render_markup_nodes( + self, + _role_markup( + path, + _registered_name(name), + role_fn, + no_index=no_index, + ), ) - for name, role_fn in _role_callables(module_name) - ) - return _render_blocks(self, markup) if markup else [] + _normalize_role_nodes(rendered, path=path, role_fn=role_fn) + results.extend(rendered) + return results class AutoRoleIndex(SphinxDirective): @@ -413,4 +560,10 @@ def run(self) -> list[nodes.Node]: for name, role_fn in _role_callables(module_name) ] markup = _index_markup("Role Index", rows) - return _render_blocks(self, markup) if markup else [] + if not markup: + return [] + rendered = parse_generated_markup(self, markup) + return [ + build_api_summary_section(node) if isinstance(node, nodes.table) else node + for node in rendered + ] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_static/css/sphinx_autodoc_docutils.css b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_static/css/sphinx_autodoc_docutils.css new file mode 100644 index 00000000..1d3276a2 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_static/css/sphinx_autodoc_docutils.css @@ -0,0 +1,3 @@ +/* sphinx_autodoc_docutils — badge colours moved to sab_palettes.css. + * This file is intentionally empty; retained as a placeholder. + */ diff --git a/packages/sphinx-autodoc-fastmcp/README.md b/packages/sphinx-autodoc-fastmcp/README.md index 3df18892..0faf0acf 100644 --- a/packages/sphinx-autodoc-fastmcp/README.md +++ b/packages/sphinx-autodoc-fastmcp/README.md @@ -1,12 +1,21 @@ # sphinx-autodoc-fastmcp -Sphinx extension that documents **FastMCP** tools with card-style `desc` layouts (aligned with `sphinx-autodoc-api-style`), safety badges, parameter tables, and cross-reference roles. +Sphinx extension that documents **FastMCP** tools with card-style section +entries built from the shared `api-*` layout regions, plus safety badges, +parameter tables, and cross-reference roles. + +The shipped output intentionally keeps a section wrapper for stable ToC labels +and `:tool:` / `:toolref:` behavior, but the inner card, badges, and type +rendering now come from `sphinx_ux_autodoc_layout`, `sphinx_ux_badges`, and +`sphinx_autodoc_typehints_gp`. ## Features -- **`fastmcp-tool`**: Renders a tool entry as an `mcp` domain `desc` (definition list card) plus a section for ToC and `{ref}` labels. +- **`fastmcp-tool`**: Renders a tool entry as a section card with shared + `api-header` / `api-content` regions plus a section target for ToC and + `{ref}` labels. - **`fastmcp-tool-input`**: Parameter table for a tool (place after prose in MyST). -- **`fastmcp-toolsummary`**: Summary tables grouped by safety tier. +- **`fastmcp-tool-summary`**: Summary tables grouped by safety tier. - **Roles**: `:tool:`, `:toolref:`, `:toolicon` / `:tooliconl` / `:tooliconr` / `:tooliconil` / `:tooliconir:`, `:badge:` ## Configuration @@ -34,7 +43,18 @@ See the package docstrings and `sphinx_autodoc_fastmcp.setup()` for defaults. ## Dependencies - Python 3.10+ -- Sphinx +- Sphinx 8.1+ +- `sphinx-ux-badges`, `sphinx-ux-autodoc-layout`, and `sphinx-autodoc-typehints-gp` + are declared dependencies and installed automatically with this package. + +`sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, +`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. +You do not need to add them separately to your `extensions` list. + +## Documentation + +See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-autodoc-fastmcp/) +for directive options, role reference, and live tool card demos. ## License diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml index 2cac6d98..4329606c 100644 --- a/packages/sphinx-autodoc-fastmcp/pyproject.toml +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -26,8 +26,10 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "fastmcp", "mcp", "documentation", "badges"] dependencies = [ - "sphinx", - "sphinx-autodoc-badges==0.0.1a7", + "sphinx>=8.1", + "sphinx-ux-badges==0.0.1a7", + "sphinx-ux-autodoc-layout==0.0.1a7", + "sphinx-autodoc-typehints-gp==0.0.1a7", ] [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 cc37aa71..2b994951 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -66,7 +66,9 @@ def setup(app: Sphinx) -> dict[str, t.Any]: >>> callable(setup) True """ - app.setup_extension("sphinx_autodoc_badges") + app.setup_extension("sphinx_ux_badges") + app.setup_extension("sphinx_ux_autodoc_layout") + app.setup_extension("sphinx_autodoc_typehints_gp") app.add_config_value("fastmcp_tool_modules", [], "env") app.add_config_value("fastmcp_area_map", {}, "env") @@ -102,7 +104,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_directive("fastmcp-tool", FastMCPToolDirective) app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) - app.add_directive("fastmcp-toolsummary", FastMCPToolSummaryDirective) + app.add_directive("fastmcp-tool-summary", FastMCPToolSummaryDirective) return { "version": _EXTENSION_VERSION, diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py index 5a8f1a7e..841a4362 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -2,16 +2,20 @@ from __future__ import annotations +import typing as t + from docutils import nodes -from sphinx_autodoc_badges import ( + +from sphinx_autodoc_fastmcp._css import _CSS +from sphinx_ux_badges import ( + SAB, BadgeNode, + BadgeSpec, build_badge, - build_badge_group, + build_badge_group_from_specs, build_toolbar as _sab_build_toolbar, ) -from sphinx_autodoc_fastmcp._css import _CSS - _SAFETY_LABELS = ("readonly", "mutating", "destructive") _SAFETY_TOOLTIPS: dict[str, str] = { @@ -55,8 +59,15 @@ def build_safety_badge( """ label = safety if safety in _SAFETY_LABELS else safety text = "" if icon_only else label - style = "icon-only" if icon_only else "full" - classes = [_CSS.BADGE_SAFETY, _CSS.safety_class(safety)] + style: t.Literal["full", "icon-only", "inline-icon"] = ( + "icon-only" if icon_only else "full" + ) + classes = [ + SAB.DENSE, + SAB.NO_UNDERLINE, + _CSS.BADGE_SAFETY, + _CSS.safety_class(safety), + ] return build_badge( text, tooltip=_SAFETY_TOOLTIPS.get(safety, f"Safety: {safety}"), @@ -78,7 +89,7 @@ def build_type_tool_badge() -> BadgeNode: return build_badge( "tool", tooltip=_TYPE_TOOLTIP, - classes=[_CSS.BADGE_TYPE, _CSS.TYPE_TOOL], + classes=[SAB.DENSE, SAB.NO_UNDERLINE, SAB.BADGE_TYPE, _CSS.TYPE_TOOL], ) @@ -97,12 +108,28 @@ def build_tool_badge_group(safety: str) -> nodes.inline: Examples -------- >>> g = build_tool_badge_group("readonly") - >>> "sab-badge-group" in g["classes"] + >>> "gp-sphinx-badge-group" in g["classes"] True """ - return build_badge_group( - [build_safety_badge(safety), build_type_tool_badge()], - classes=[_CSS.BADGE_GROUP], + return build_badge_group_from_specs( + [ + BadgeSpec( + safety if safety in _SAFETY_LABELS else safety, + tooltip=_SAFETY_TOOLTIPS.get(safety, f"Safety: {safety}"), + icon=_SAFETY_ICONS.get(safety, ""), + classes=( + SAB.DENSE, + SAB.NO_UNDERLINE, + _CSS.BADGE_SAFETY, + _CSS.safety_class(safety), + ), + ), + BadgeSpec( + "tool", + tooltip=_TYPE_TOOLTIP, + classes=(SAB.DENSE, SAB.NO_UNDERLINE, SAB.BADGE_TYPE, _CSS.TYPE_TOOL), + ), + ], ) @@ -112,10 +139,7 @@ def build_toolbar(safety: str) -> nodes.inline: Examples -------- >>> t = build_toolbar("readonly") - >>> "sab-toolbar" in t["classes"] + >>> "gp-sphinx-toolbar" in t["classes"] True """ - return _sab_build_toolbar( - build_tool_badge_group(safety), - classes=[_CSS.TOOLBAR], - ) + return _sab_build_toolbar(build_tool_badge_group(safety)) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py index 214a7488..5be99dfb 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -10,7 +10,8 @@ from sphinx.application import Sphinx from sphinx_autodoc_fastmcp._models import ToolInfo -from sphinx_autodoc_fastmcp._parsing import extract_params, format_annotation +from sphinx_autodoc_fastmcp._parsing import extract_params +from sphinx_autodoc_typehints_gp import normalize_annotation_text logger = logging.getLogger(__name__) @@ -66,7 +67,7 @@ def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: func=func, docstring=func.__doc__ or "", params=extract_params(func), - return_annotation=format_annotation( + return_annotation=normalize_annotation_text( inspect.signature(func).return_annotation, ), ), @@ -120,7 +121,9 @@ def _tool_from_callable( func=func, docstring=func.__doc__ or "", params=extract_params(func), - return_annotation=format_annotation(inspect.signature(func).return_annotation), + return_annotation=normalize_annotation_text( + inspect.signature(func).return_annotation, + ), ) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py index 20df881d..fd25da47 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py @@ -1,41 +1,47 @@ """CSS class name constants for sphinx_autodoc_fastmcp. +All constants use the ``gp-sphinx-fastmcp`` namespace for FastMCP-specific +layout and safety semantics. For shared badge primitives, import ``SAB`` +from ``sphinx_ux_badges`` directly. + Examples -------- ->>> _CSS.PREFIX -'smf' - ->>> _CSS.BADGE_GROUP -'smf-badge-group' - ->>> _CSS.TOOLBAR -'smf-toolbar' - >>> _CSS.TOOL_SECTION -'smf-tool-section' +'gp-sphinx-fastmcp__tool-section' + +>>> _CSS.BADGE_SAFETY +'gp-sphinx-fastmcp__safety' """ from __future__ import annotations class _CSS: - """CSS class name constants (``smf-`` = sphinx autodoc fastmcp).""" - - PREFIX = "smf" - TOOL_SECTION = f"{PREFIX}-tool-section" - BADGE_GROUP = f"{PREFIX}-badge-group" - BADGE = f"{PREFIX}-badge" - BADGE_TYPE = f"{PREFIX}-badge--type" - BADGE_SAFETY = f"{PREFIX}-badge--safety" - TOOLBAR = f"{PREFIX}-toolbar" - SECTION_TITLE_HIDDEN = f"{PREFIX}-visually-hidden" - TYPE_TOOL = f"{PREFIX}-type-tool" - - SAFETY_READONLY = f"{PREFIX}-safety-readonly" - SAFETY_MUTATING = f"{PREFIX}-safety-mutating" - SAFETY_DESTRUCTIVE = f"{PREFIX}-safety-destructive" + """CSS class name constants (``gp-sphinx-fastmcp`` namespace).""" + + PREFIX = "gp-sphinx-fastmcp" + + # Layout + TOOL_SECTION = "gp-sphinx-fastmcp__tool-section" + SECTION_TITLE_HIDDEN = "gp-sphinx-fastmcp__visually-hidden" + TYPE_TOOL = "gp-sphinx-fastmcp__type-tool" + TOOL_ENTRY = "gp-sphinx-fastmcp__tool-entry" + TOOL_SIGNATURE = "gp-sphinx-fastmcp__tool-signature" + BODY_SECTION = "gp-sphinx-fastmcp__body-section" + + # Safety slot + tier values + BADGE_SAFETY = "gp-sphinx-fastmcp__safety" + SAFETY_READONLY = "gp-sphinx-fastmcp__safety-readonly" + SAFETY_MUTATING = "gp-sphinx-fastmcp__safety-mutating" + SAFETY_DESTRUCTIVE = "gp-sphinx-fastmcp__safety-destructive" @staticmethod def safety_class(safety: str) -> str: - """Return safety modifier class for badge styling.""" - return f"{_CSS.PREFIX}-safety-{safety}" + """Return safety modifier class for badge styling. + + Examples + -------- + >>> _CSS.safety_class("readonly") + 'gp-sphinx-fastmcp__safety-readonly' + """ + return f"gp-sphinx-fastmcp__safety-{safety}" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py index 811137bf..d299dfac 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -5,18 +5,30 @@ from docutils import nodes from sphinx.util.docutils import SphinxDirective -from sphinx_autodoc_fastmcp._badges import build_toolbar +from sphinx_autodoc_fastmcp._badges import build_tool_badge_group from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._models import ParamInfo, ToolInfo from sphinx_autodoc_fastmcp._parsing import ( - extract_enum_values as extract_enum_values_from_type, first_paragraph, make_para, make_table, - make_type_cell_smart, - make_type_xref, parse_rst_inline, ) +from sphinx_autodoc_typehints_gp import ( + build_annotation_display_paragraph, + build_annotation_paragraph, + classify_annotation_display, +) +from sphinx_ux_autodoc_layout import ( + API, + ApiFactRow, + api_permalink, + build_api_card_entry, + build_api_facts_section, + build_api_section, + build_api_summary_section, + build_api_table_section, +) class FastMCPToolDirective(SphinxDirective): @@ -47,36 +59,61 @@ def run(self) -> list[nodes.Node]: return self._build_tool_section(tool) def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: - """Build section card: title (literal + badges) + summary + returns.""" + """Build section card with shared API layout regions.""" document = self.state.document section_id = tool.name.replace("_", "-") section = nodes.section() section["ids"].append(section_id) - section["classes"].append(_CSS.TOOL_SECTION) + section["classes"].extend((_CSS.TOOL_SECTION, API.CARD_SHELL)) document.note_explicit_target(section) title_node = nodes.title("", "") title_node["classes"].append(f"{_CSS.PREFIX}-tool-title") + title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) title_node += nodes.literal("", tool.name) - title_node += nodes.Text(" ") - title_node += build_toolbar(tool.safety) section += title_node + link = api_permalink( + href=f"#{section_id}", + title="Link to this tool", + ) + link["classes"] = ["headerlink", API.LINK] first_para = first_paragraph(tool.docstring) - section += parse_rst_inline(first_para, self.state, self.lineno) + content_nodes: list[nodes.Node] = [ + build_api_section( + API.DESCRIPTION, + parse_rst_inline(first_para, self.state, self.lineno), + classes=(_CSS.BODY_SECTION,), + ) + ] if tool.return_annotation: - returns_para = nodes.paragraph("") - returns_para += nodes.strong("", "Returns: ") - type_para = make_type_xref( - tool.return_annotation, - model_module=str(self.config.fastmcp_model_module), - model_classes=frozenset(self.config.fastmcp_model_classes), + content_nodes.append( + build_api_facts_section( + [ + ApiFactRow( + "Returns", + build_annotation_paragraph( + tool.return_annotation, + self.env, + ), + ) + ], + classes=(_CSS.BODY_SECTION,), + ) ) - for child in type_para.children: - returns_para += child.deepcopy() - section += returns_para + + entry = build_api_card_entry( + profile_class=API.profile("fastmcp-tool"), + signature_children=(nodes.literal("", tool.name),), + content_children=tuple(content_nodes), + badge_group=build_tool_badge_group(tool.safety), + permalink=link, + entry_classes=(_CSS.TOOL_ENTRY,), + signature_classes=(_CSS.TOOL_SIGNATURE,), + ) + section += entry return [section] @@ -113,17 +150,23 @@ def run(self) -> list[nodes.Node]: for p in tool.params: desc_node = self._build_description(p) - type_cell, is_enum = make_type_cell_smart(p.type_str) + type_cell: str | nodes.Node = "—" + if p.type_str: + type_cell = build_annotation_display_paragraph( + p.type_str, + self.env, + ) - if is_enum and p.type_str: - enum_values = extract_enum_values_from_type(p.type_str) - if enum_values: - desc_node += nodes.Text(" One of: ") - for i, val in enumerate(enum_values): - if i > 0: - desc_node += nodes.Text(", ") - desc_node += nodes.literal("", val) - desc_node += nodes.Text(".") + type_display = ( + classify_annotation_display(p.type_str) if p.type_str else None + ) + if type_display and type_display.literal_members: + desc_node += nodes.Text(" One of: ") + for i, val in enumerate(type_display.literal_members): + if i > 0: + desc_node += nodes.Text(", ") + desc_node += nodes.literal("", val) + desc_node += nodes.Text(".") default_cell: str | nodes.Node = "—" if p.default and p.default != "None": @@ -139,7 +182,10 @@ def run(self) -> list[nodes.Node]: ], ) result.append( - make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]), + build_api_table_section( + API.PARAMETERS, + make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]), + ), ) return result @@ -169,7 +215,7 @@ def run(self) -> list[nodes.Node]: if not tools: return [ self.state.document.reporter.warning( - "fastmcp-toolsummary: no tools found.", + "fastmcp-tool-summary: no tools found.", line=self.lineno, ), ] @@ -214,7 +260,9 @@ def run(self) -> list[nodes.Node]: parse_rst_inline(first_line, self.state, self.lineno), ], ) - section += make_table(headers, rows, col_widths=[30, 70]) + section += build_api_summary_section( + make_table(headers, rows, col_widths=[30, 70]), + ) result_nodes.append(section) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_parsing.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_parsing.py index f1f2bba5..ddfb494c 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_parsing.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_parsing.py @@ -7,9 +7,9 @@ import typing as t from docutils import nodes -from sphinx import addnodes from sphinx_autodoc_fastmcp._models import ParamInfo +from sphinx_autodoc_typehints_gp import classify_annotation_display def parse_numpy_params(docstring: str) -> dict[str, str]: @@ -91,25 +91,6 @@ def first_paragraph(docstring: str) -> str: return paragraphs[0].strip().replace("\n", " ") -def format_annotation(ann: t.Any, *, strip_none: bool = False) -> str: - """Format a type annotation as a readable string.""" - if ann is inspect.Parameter.empty: - return "" - if isinstance(ann, str): - result = ann - result = re.sub( - r"(?:t\.)?Literal\[([^\]]+)\]", - lambda m: m.group(1), - result, - ) - if strip_none: - result = re.sub(r"\s*\|\s*None\b", "", result).strip() - return result - if hasattr(ann, "__name__"): - return str(ann.__name__) - return str(ann).replace("typing.", "") - - def extract_params(func: t.Callable[..., t.Any]) -> list[ParamInfo]: """Extract parameter info from function signature and docstring.""" sig = inspect.signature(func) @@ -118,7 +99,7 @@ def extract_params(func: t.Callable[..., t.Any]) -> list[ParamInfo]: for name, param in sig.parameters.items(): is_optional = param.default != inspect.Parameter.empty - type_str = format_annotation( + display = classify_annotation_display( param.annotation, strip_none=is_optional, ) @@ -140,7 +121,7 @@ def extract_params(func: t.Callable[..., t.Any]) -> list[ParamInfo]: params.append( ParamInfo( name=name, - type_str=type_str, + type_str=display.text, required=required, default=default_str, description=doc_params.get(name, ""), @@ -150,72 +131,11 @@ def extract_params(func: t.Callable[..., t.Any]) -> list[ParamInfo]: return params -def extract_enum_values(type_str: str) -> list[str]: - """Extract individual enum values from a Literal type string.""" - parts = [p.strip() for p in type_str.split("|")] - values: list[str] = [] - for part in parts: - for sub in part.split(","): - sub = sub.strip() - if re.match(r"^'[^']*'$", sub): - values.append(sub) - return values - - def make_literal(text: str) -> nodes.literal: """Create an inline code literal node.""" return nodes.literal("", text) -def single_type_xref( - name: str, - *, - model_module: str, - model_classes: frozenset[str], -) -> addnodes.pending_xref: - """Create a ``pending_xref`` for a single type name.""" - target = f"{model_module}.{name}" if name in model_classes else name - return addnodes.pending_xref( - "", - nodes.literal("", name), - refdomain="py", - reftype="class", - reftarget=target, - ) - - -def make_type_xref( - type_str: str, - *, - model_module: str, - model_classes: frozenset[str], -) -> nodes.paragraph: - """Render a return type annotation with cross-reference links.""" - para = nodes.paragraph("") - m = re.match(r"^(list|set|tuple)\[(.+)\]$", type_str) - if m: - container, inner = m.group(1), m.group(2) - para += single_type_xref( - container, - model_module=model_module, - model_classes=model_classes, - ) - para += nodes.Text("[") - para += single_type_xref( - inner, - model_module=model_module, - model_classes=model_classes, - ) - para += nodes.Text("]") - else: - para += single_type_xref( - type_str, - model_module=model_module, - model_classes=model_classes, - ) - return para - - def make_para(*children: nodes.Node | str) -> nodes.paragraph: """Create a paragraph from mixed text and node children.""" para = nodes.paragraph("") @@ -227,45 +147,6 @@ def make_para(*children: nodes.Node | str) -> nodes.paragraph: return para -def make_type_cell(type_str: str) -> nodes.paragraph: - """Render a type annotation as comma-separated code literals.""" - parts = [p.strip() for p in type_str.split("|")] - - expanded: list[str] = [] - for part in parts: - if re.match(r"^'[^']*'(\s*,\s*'[^']*')+$", part): - expanded.extend(v.strip() for v in part.split(",")) - else: - expanded.append(part) - - para = nodes.paragraph("") - for i, part in enumerate(expanded): - if i > 0: - para += nodes.Text(", ") - para += nodes.literal("", part) - return para - - -def make_type_cell_smart( - type_str: str, -) -> tuple[nodes.paragraph | str, bool]: - """Render a type annotation, detecting enum-only types.""" - if not type_str: - return ("", False) - - parts = [p.strip() for p in type_str.split("|")] - - all_quoted = all(re.match(r"^'[^']*'$", p) for p in parts) - if not all_quoted and len(parts) == 1: - sub = [s.strip() for s in parts[0].split(",")] - all_quoted = len(sub) > 1 and all(re.match(r"^'[^']*'$", s) for s in sub) - - if all_quoted: - return (make_para(make_literal("enum")), True) - - return (make_type_cell(type_str), False) - - def parse_rst_inline( text: str, state: t.Any, diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py new file mode 100644 index 00000000..b302ac82 --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_prototype.py @@ -0,0 +1,150 @@ +"""Test-only FastMCP ``desc`` prototype builders. + +These helpers are intentionally internal and non-shipping. They let the test +suite answer whether FastMCP tool metadata can fit a real ``addnodes.desc`` +shape without changing the public directive output. + +Examples +-------- +>>> from sphinx_autodoc_fastmcp._models import ParamInfo, ToolInfo +>>> tool = ToolInfo( +... name="list_sessions", +... title="List Sessions", +... module_name="demo_tools", +... area="api", +... safety="readonly", +... annotations={}, +... func=lambda server: "[]", +... docstring="List sessions for one server.", +... params=[ +... ParamInfo( +... name="server", +... type_str="str", +... required=True, +... default="", +... description="Server name.", +... ) +... ], +... return_annotation="str", +... ) +>>> desc = build_tool_desc_prototype(tool) +>>> desc.get("domain"), desc.get("objtype") +('mcp', 'tool') +""" + +from __future__ import annotations + +from docutils import nodes +from sphinx import addnodes + +from sphinx_autodoc_fastmcp._badges import build_tool_badge_group +from sphinx_autodoc_fastmcp._models import ParamInfo, ToolInfo +from sphinx_autodoc_fastmcp._parsing import ( + first_paragraph, + make_para, + make_table, +) +from sphinx_autodoc_typehints_gp import ( + build_annotation_display_paragraph, + normalize_annotation_text, +) +from sphinx_ux_autodoc_layout import inject_signature_slots + + +def _tool_desc_id(tool: ToolInfo) -> str: + """Return the stable prototype id for *tool*.""" + return tool.name.replace("_", "-") + + +def _build_tool_parameter(param: ParamInfo) -> addnodes.desc_parameter: + """Build one ``desc_parameter`` from FastMCP metadata.""" + node = addnodes.desc_parameter() + node += addnodes.desc_sig_name("", param.name) + if param.type_str: + node += addnodes.desc_sig_punctuation("", ":") + node += addnodes.desc_sig_space("", " ") + node += nodes.emphasis("", normalize_annotation_text(param.type_str)) + if param.default: + node += addnodes.desc_sig_space("", " ") + node += addnodes.desc_sig_operator("", "=") + node += addnodes.desc_sig_space("", " ") + node += nodes.inline("", param.default, classes=["default_value"]) + return node + + +def _build_parameter_table(tool: ToolInfo) -> nodes.table: + """Return the prototype parameter table for *tool*.""" + headers = ["Parameter", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for param in tool.params: + type_cell: str | nodes.Node = "—" + if param.type_str: + type_cell = build_annotation_display_paragraph(param.type_str, None) + default_cell: str | nodes.Node = "—" + if param.default: + default_cell = make_para(nodes.literal("", param.default)) + description = ( + make_para(param.description) + if param.description + else nodes.paragraph("", "—") + ) + rows.append( + [ + make_para(nodes.literal("", param.name)), + type_cell, + "yes" if param.required else "no", + default_cell, + description, + ] + ) + return make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]) + + +def _build_parameter_fields(tool: ToolInfo) -> nodes.field_list: + """Return a ``field_list`` wrapper around the parameter table.""" + field_list = nodes.field_list() + if tool.params: + field_list += nodes.field( + "", + nodes.field_name("", "Parameters"), + nodes.field_body("", _build_parameter_table(tool)), + ) + if tool.return_annotation: + field_list += nodes.field( + "", + nodes.field_name("", "Returns"), + nodes.field_body( + "", + build_annotation_display_paragraph(tool.return_annotation, None), + ), + ) + return field_list + + +def build_tool_desc_prototype(tool: ToolInfo) -> addnodes.desc: + """Build a non-shipping ``mcp:tool`` description node for *tool*.""" + desc = addnodes.desc(domain="mcp", objtype="tool") + signature = addnodes.desc_signature(ids=[_tool_desc_id(tool)]) + signature += addnodes.desc_name("", tool.name) + if tool.params: + parameter_list = addnodes.desc_parameterlist() + for param in tool.params: + parameter_list += _build_tool_parameter(param) + signature += parameter_list + inject_signature_slots( + signature, + marker_attr="smf_prototype_slots", + badge_node=build_tool_badge_group(tool.safety), + extract_source_link=False, + ) + desc += signature + + content = addnodes.desc_content() + summary = first_paragraph(tool.docstring) + if summary: + content += nodes.paragraph("", summary) + parameter_fields = _build_parameter_fields(tool) + if parameter_fields.children: + content += parameter_fields + desc += content + return desc diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css index 446ed9c6..bd090bc1 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css @@ -1,225 +1,129 @@ /* sphinx_autodoc_fastmcp — color layer for FastMCP tool badges. * - * Base metrics come from sphinx_autodoc_badges.css. This file matches - * sphinx-gptheme custom.css rules for .sd-badge[aria-label^="Safety tier:"] - * (font metrics, !important colors, theme --badge-safety-* variables). + * Base metrics and sizing come from sphinx_ux_badges.css (gp-sphinx-badge--dense class). + * This file matches sphinx-gp-theme custom.css rules for .sd-badge[aria-label^="Safety tier:"] + * (colors and theme --gp-sphinx-fastmcp-safety-* variables). * - * Safety palette: same tokens as sphinx_gptheme/theme/static/css/custom.css + * Safety palette: same tokens as sphinx_gp_theme/theme/static/css/custom.css * so readonly / mutating / destructive match production regardless of load order. */ :root { - --badge-safety-readonly-bg: #1f7a3f; - --badge-safety-readonly-border: #2a8d4d; - --badge-safety-readonly-text: #f3fff7; - --badge-safety-mutating-bg: #b96a1a; - --badge-safety-mutating-border: #cf7a23; - --badge-safety-mutating-text: #fff8ef; - --badge-safety-destructive-bg: #b4232c; - --badge-safety-destructive-border: #cb3640; - --badge-safety-destructive-text: #fff5f5; - --smf-type-tool-bg: #0e7490; - --smf-type-tool-fg: #fff; - --smf-type-tool-border: #0f766e; -} - -/* ── sphinx-gptheme parity: same box model as .sd-badge + safety tier ── - * See sphinx_gptheme/theme/static/css/custom.css (.sd-badge, h2/h3 .sd-badge, …) - */ -.sab-badge.smf-badge--safety { - display: inline-flex !important; - align-items: center; - vertical-align: middle; - font-size: 0.67rem; - font-weight: 700; - line-height: 1; - letter-spacing: 0.01em; - padding: 0.16rem 0.4rem; - border-radius: 0.22rem; - user-select: none; - -webkit-user-select: none; - gap: 0.28rem; - border: 1px solid transparent; - box-sizing: border-box; -} - -.sab-badge.smf-badge--safety::before { - font-style: normal; - font-weight: normal; - font-size: 1em; - line-height: 1; - flex-shrink: 0; -} - -/* Tool card titles: match h2/h3 .sd-badge[aria-label^="Safety tier:"] */ -h2.smf-tool-title .sab-badge.smf-badge--safety, -h3.smf-tool-title .sab-badge.smf-badge--safety, -h4.smf-tool-title .sab-badge.smf-badge--safety, -.smf-tool-title .sab-badge.smf-badge--safety { - font-size: 0.68rem; - padding: 0.17rem 0.4rem; -} - -/* Type badge: same base scale as .sd-badge (custom.css) */ -.sab-badge.smf-badge--type { + --gp-sphinx-fastmcp-safety-readonly-bg: #1f7a3f; + --gp-sphinx-fastmcp-safety-readonly-border: #2a8d4d; + --gp-sphinx-fastmcp-safety-readonly-text: #f3fff7; + --gp-sphinx-fastmcp-safety-mutating-bg: #b96a1a; + --gp-sphinx-fastmcp-safety-mutating-border: #cf7a23; + --gp-sphinx-fastmcp-safety-mutating-text: #fff8ef; + --gp-sphinx-fastmcp-safety-destructive-bg: #b4232c; + --gp-sphinx-fastmcp-safety-destructive-border: #cb3640; + --gp-sphinx-fastmcp-safety-destructive-text: #fff5f5; + --gp-sphinx-fastmcp-type-tool-bg: #0e7490; + --gp-sphinx-fastmcp-type-tool-fg: #fff; + --gp-sphinx-fastmcp-type-tool-border: #0f766e; +} + +/* ── Safety badges: gp-sphinx-badge--dense provides compact metrics; restore inline-flex for icon gap ── */ +.gp-sphinx-badge.gp-sphinx-badge--dense.gp-sphinx-fastmcp__safety { display: inline-flex !important; - align-items: center; - vertical-align: middle; - font-size: 0.67rem; - font-weight: 600; - line-height: 1; - letter-spacing: 0.02em; - padding: 0.16rem 0.4rem; - border-radius: 0.22rem; - user-select: none; - -webkit-user-select: none; - box-sizing: border-box; -} - -h2.smf-tool-title .sab-badge.smf-badge--type, -h3.smf-tool-title .sab-badge.smf-badge--type, -h4.smf-tool-title .sab-badge.smf-badge--type, -.smf-tool-title .sab-badge.smf-badge--type { - font-size: 0.68rem; - padding: 0.17rem 0.4rem; } /* * Matte safety colors: literal hex + !important so sphinx-design (loaded after * this file) cannot skew var() resolution or shorthands. Keeps parity with - * :root --badge-safety-* above; override there + copy here if you change the palette. + * :root --gp-sphinx-fastmcp-safety-* above; override there + copy here if you change the palette. */ -.sab-badge.smf-safety-readonly:not(.sab-inline-icon) { +.gp-sphinx-badge.gp-sphinx-fastmcp__safety-readonly:not(.gp-sphinx-badge--inline-icon) { background-color: #1f7a3f !important; color: #f3fff7 !important; border: 1px solid #2a8d4d !important; - box-shadow: var(--sab-buff-shadow) !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -.sab-badge.smf-safety-mutating:not(.sab-inline-icon) { +.gp-sphinx-badge.gp-sphinx-fastmcp__safety-mutating:not(.gp-sphinx-badge--inline-icon) { background-color: #b96a1a !important; color: #fff8ef !important; border: 1px solid #cf7a23 !important; - box-shadow: var(--sab-buff-shadow) !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } -.sab-badge.smf-safety-destructive:not(.sab-inline-icon) { +.gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon) { background-color: #b4232c !important; color: #fff5f5 !important; border: 1px solid #cb3640 !important; - box-shadow: var(--sab-buff-shadow) !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } /* MCP "tool": white label (never use --sd-color-info-text; it is dark on light teal) */ -.sab-badge.smf-type-tool:not(.sab-inline-icon) { +.gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { background-color: #0e7490 !important; color: #ffffff !important; border: 1px solid #0f766e !important; - box-shadow: var(--sab-buff-shadow) !important; + box-shadow: var(--gp-sphinx-badge-buff-shadow) !important; } @media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) .sab-badge.smf-safety-readonly:not(.sab-inline-icon), - body:not([data-theme="light"]) .sab-badge.smf-safety-mutating:not(.sab-inline-icon), - body:not([data-theme="light"]) .sab-badge.smf-safety-destructive:not(.sab-inline-icon), - body:not([data-theme="light"]) .sab-badge.smf-type-tool:not(.sab-inline-icon) { - box-shadow: var(--sab-buff-shadow-dark-ui) !important; + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-readonly:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-mutating:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon), + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { + box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } - body:not([data-theme="light"]) .sab-badge.smf-type-tool:not(.sab-inline-icon) { + body:not([data-theme="light"]) .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { background-color: #0d9488 !important; color: #ffffff !important; border: 1px solid #14b8a6 !important; } } -body[data-theme="dark"] .sab-badge.smf-safety-readonly:not(.sab-inline-icon), -body[data-theme="dark"] .sab-badge.smf-safety-mutating:not(.sab-inline-icon), -body[data-theme="dark"] .sab-badge.smf-safety-destructive:not(.sab-inline-icon), -body[data-theme="dark"] .sab-badge.smf-type-tool:not(.sab-inline-icon) { - box-shadow: var(--sab-buff-shadow-dark-ui) !important; +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-readonly:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-mutating:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__safety-destructive:not(.gp-sphinx-badge--inline-icon), +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { + box-shadow: var(--gp-sphinx-badge-buff-shadow-dark-ui) !important; } -body[data-theme="dark"] .sab-badge.smf-type-tool:not(.sab-inline-icon) { +body[data-theme="dark"] .gp-sphinx-badge.gp-sphinx-fastmcp__type-tool:not(.gp-sphinx-badge--inline-icon) { background-color: #0d9488 !important; color: #ffffff !important; border: 1px solid #14b8a6 !important; } /* ── Emoji when data-icon absent (unicode); data-icon wins via badges base ── */ -.smf-safety-readonly:not([data-icon])::before { +.gp-sphinx-fastmcp__safety-readonly:not([data-icon])::before { content: "\1F50D"; } -.smf-safety-mutating:not([data-icon])::before { +.gp-sphinx-fastmcp__safety-mutating:not([data-icon])::before { content: "\270F\FE0F"; } -.smf-safety-destructive:not([data-icon])::before { +.gp-sphinx-fastmcp__safety-destructive:not([data-icon])::before { content: "\1F4A3"; } /* ── Tool section card ──────────────────────────────────── */ -section.smf-tool-section { - border: 1px solid var(--color-background-border); - border-radius: 0.5rem; +section.gp-sphinx-fastmcp__tool-section { padding: 0; - margin-bottom: 1.5rem; - overflow: visible; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); -} - -section.smf-tool-section > h1, -section.smf-tool-section > h2, -section.smf-tool-section > h3, -section.smf-tool-section > h4, -section.smf-tool-section > h5, -section.smf-tool-section > h6 { - margin: 0; -} - -/* Match custom.css h2/h3:has(> .sd-badge[...]) gap between code and badges */ -section.smf-tool-section > .smf-tool-title { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.45rem; - background: var(--color-background-secondary); - border-bottom: 1px solid var(--color-background-border); - padding: 0.5rem 0.75rem 0.5rem 1rem; - min-height: 2rem; - text-indent: 0; - transition: background 100ms ease-out; -} - -section.smf-tool-section > .smf-tool-title:hover { - background: var(--color-api-background-hover); -} - -section.smf-tool-section > .smf-tool-title .sab-toolbar { - order: 99; -} - -section.smf-tool-section > .smf-tool-title ~ * { - padding-left: 1rem; - padding-right: 1rem; - margin-left: 0; - margin-right: 0; + overflow: clip; } -section.smf-tool-section > .smf-tool-title + * { - padding-top: 0.75rem; +section.gp-sphinx-fastmcp__tool-section > .gp-sphinx-fastmcp__tool-entry > .gp-sphinx-api-header .gp-sphinx-api-signature { + min-width: 0; + font-family: var(--font-stack--monospace); } -section.smf-tool-section > .smf-tool-title ~ *:last-child { - padding-bottom: 0.75rem; +section.gp-sphinx-fastmcp__tool-section > .gp-sphinx-fastmcp__tool-entry > .gp-sphinx-api-content > .gp-sphinx-fastmcp__body-section + .gp-sphinx-fastmcp__body-section { + margin-top: 0.75rem; } -.toc-tree .smf-badge--type { +/* Hide the "tool" type badge in TOC sidebar (redundant there). */ +.toc-tree .gp-sphinx-fastmcp__type-tool { display: none !important; } -.smf-visually-hidden { +.gp-sphinx-fastmcp__visually-hidden { position: absolute; width: 1px; height: 1px; diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py index f7ab6848..ec8edf2b 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -4,7 +4,6 @@ import logging import re -import typing as t from docutils import nodes from sphinx.application import Sphinx @@ -13,13 +12,25 @@ from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._models import ToolInfo from sphinx_autodoc_fastmcp._roles import _tool_ref_placeholder - -if t.TYPE_CHECKING: - from sphinx.domains.std import StandardDomain +from sphinx_ux_autodoc_layout import API, api_component +from sphinx_ux_badges import SAB logger = logging.getLogger(__name__) +def _tool_content_container(section: nodes.section) -> nodes.Element: + """Return the shared content wrapper for a FastMCP tool section.""" + for child in section.children: + if not isinstance(child, api_component) or child.get("name") != API.ENTRY: + continue + for grandchild in child.children: + if isinstance(grandchild, api_component) and grandchild.get("name") == ( + API.CONTENT + ): + return grandchild + return section + + def collect_tool_section_content(app: Sphinx, doctree: nodes.document) -> None: """Move siblings following each tool section into the section. @@ -35,6 +46,7 @@ def collect_tool_section_content(app: Sphinx, doctree: nodes.document) -> None: parent = section.parent if parent is None: continue + content = _tool_content_container(section) idx = parent.index(section) while idx + 1 < len(parent.children): sibling = parent.children[idx + 1] @@ -44,12 +56,12 @@ def collect_tool_section_content(app: Sphinx, doctree: nodes.document) -> None: if isinstance(sibling, nodes.section): break parent.remove(sibling) - section.append(sibling) + content.append(sibling) def register_tool_labels(app: Sphinx, doctree: nodes.document) -> None: """Mirror autosectionlabel for tool sections (``{ref}`tool-id```).""" - domain = t.cast("StandardDomain", app.env.get_domain("std")) + domain = app.env.domains.standard_domain docname = app.env.docname for section in doctree.findall(nodes.section): if not section["ids"]: @@ -105,7 +117,7 @@ def resolve_tool_refs( fromdocname: str, ) -> None: """Resolve ``:tool:`` / ``:toolref:`` / ``:toolicon*:`` placeholders.""" - domain = t.cast("StandardDomain", app.env.get_domain("std")) + domain = app.env.domains.standard_domain builder = app.builder tool_data: dict[str, ToolInfo] = getattr(app.env, "fastmcp_tools", {}) @@ -143,7 +155,7 @@ def resolve_tool_refs( style = "inline-icon" if icon_pos.startswith("inline") else "icon-only" badge = build_safety_badge(tool_info.safety, icon_only=True) if style == "inline-icon": - badge["classes"].append("sab-inline-icon") + badge["classes"].append(SAB.INLINE_ICON) if icon_pos == "left": if badge: diff --git a/packages/sphinx-autodoc-pytest-fixtures/README.md b/packages/sphinx-autodoc-pytest-fixtures/README.md index 313629d0..3ca41a6f 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/README.md +++ b/packages/sphinx-autodoc-pytest-fixtures/README.md @@ -4,12 +4,19 @@ Sphinx extension that documents pytest fixtures as first-class domain objects with scope badges, dependency tracking, reverse-dep graphs, and auto-generated usage snippets. +The extension auto-loads the shared stack: `sphinx_ux_badges` owns badge +rendering, `sphinx_ux_autodoc_layout` owns the shared `api-*` regions and summary +wrappers, and `sphinx_autodoc_typehints_gp` owns fixture return-type rendering. + ## Install ```console $ pip install sphinx-autodoc-pytest-fixtures ``` +Installing this package also installs `sphinx-ux-badges`, +`sphinx-ux-autodoc-layout`, and `sphinx-autodoc-typehints-gp` as declared dependencies. + ## Usage ```python @@ -25,7 +32,7 @@ Then document fixtures with: .. autofixture-index:: myproject.conftest -.. doc-pytest-plugin:: myproject.pytest_plugin +.. auto-pytest-plugin:: myproject.pytest_plugin :project: myproject :package: myproject :summary: Document your pytest plugin with generated install and fixture diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml index 0818c496..875a64c4 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -28,9 +28,11 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"] dependencies = [ - "sphinx", + "sphinx>=8.1", "pytest", - "sphinx-autodoc-badges==0.0.1a7", + "sphinx-ux-badges==0.0.1a7", + "sphinx-ux-autodoc-layout==0.0.1a7", + "sphinx-autodoc-typehints-gp==0.0.1a7", ] [project.urls] diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py index 8dc868a3..0e9a62c7 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py @@ -39,7 +39,6 @@ PYTEST_HIDDEN, SetupDict, ) -from sphinx_autodoc_pytest_fixtures._css import _CSS from sphinx_autodoc_pytest_fixtures._detection import ( _classify_deps, _get_fixture_fn, @@ -53,7 +52,7 @@ from sphinx_autodoc_pytest_fixtures._directives import ( AutofixtureIndexDirective, AutofixturesDirective, - DocPytestPluginDirective, + AutoPytestPluginDirective, PyFixtureDirective, ) from sphinx_autodoc_pytest_fixtures._documenter import FixtureDocumenter @@ -99,7 +98,9 @@ def setup(app: Sphinx) -> SetupDict: Extension metadata dict. """ app.setup_extension("sphinx.ext.autodoc") - app.setup_extension("sphinx_autodoc_badges") + app.setup_extension("sphinx_ux_badges") + app.setup_extension("sphinx_ux_autodoc_layout") + app.setup_extension("sphinx_autodoc_typehints_gp") import pathlib @@ -162,7 +163,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_directive("autofixtures", AutofixturesDirective) app.add_node(autofixture_index_node) app.add_directive("autofixture-index", AutofixtureIndexDirective) - app.add_directive("doc-pytest-plugin", DocPytestPluginDirective) + app.add_directive("auto-pytest-plugin", AutoPytestPluginDirective) app.connect("missing-reference", _on_missing_reference) app.connect("doctree-resolved", _on_doctree_resolved) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py index ae90c2f9..f3157225 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py @@ -3,10 +3,9 @@ from __future__ import annotations from docutils import nodes -from sphinx_autodoc_badges import BadgeNode, build_badge from sphinx_autodoc_pytest_fixtures._constants import _SUPPRESSED_SCOPES -from sphinx_autodoc_pytest_fixtures._css import _CSS +from sphinx_ux_badges import SAB, BadgeSpec, build_badge_group_from_specs _BADGE_TOOLTIPS: dict[str, str] = { "session": "Scope: session \u2014 created once per test session", @@ -20,100 +19,119 @@ } -def _build_badge_group_node( +def _fixture_badge_specs( scope: str, kind: str, autouse: bool, *, deprecated: bool = False, show_fixture_badge: bool = True, -) -> nodes.inline: - """Return a badge group with shared BadgeNode children. - - Badge slots (left-to-right in visual order): - - * Slot 0 (deprecated): shown when fixture is deprecated - * Slot 1 (scope): shown when ``scope != "function"`` - * Slot 2 (kind): shown for ``"factory"`` / ``"override_hook"``; or - state badge (``"autouse"``) when ``autouse=True`` - * Slot 3 (FIXTURE): shown when ``show_fixture_badge=True`` (default) - - Parameters - ---------- - scope : str - Fixture scope string. - kind : str - Fixture kind string. - autouse : bool - When True, renders AUTO state badge instead of a kind badge. - deprecated : bool - When True, renders a deprecated badge at slot 0 (leftmost). - show_fixture_badge : bool - When False, suppresses the FIXTURE badge at slot 3. - - Returns - ------- - nodes.inline - Badge group container with BadgeNode children. - """ - group = nodes.inline(classes=[_CSS.BADGE_GROUP]) - badges: list[BadgeNode] = [] +) -> list[BadgeSpec]: + """Return typed badge specs for one fixture entry.""" + badges: list[BadgeSpec] = [] if deprecated: badges.append( - build_badge( + BadgeSpec( "deprecated", tooltip=_BADGE_TOOLTIPS["deprecated"], - classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.DEPRECATED], + classes=(SAB.BADGE, SAB.BADGE_STATE, SAB.STATE_DEPRECATED), + fill="filled", ) ) if scope and scope not in _SUPPRESSED_SCOPES: badges.append( - build_badge( + BadgeSpec( scope, tooltip=_BADGE_TOOLTIPS.get(scope, f"Scope: {scope}"), - classes=[_CSS.BADGE, _CSS.BADGE_SCOPE, _CSS.scope(scope)], + classes=(SAB.BADGE, SAB.BADGE_SCOPE, SAB.scope(scope)), ) ) if autouse: badges.append( - build_badge( + BadgeSpec( "auto", tooltip=_BADGE_TOOLTIPS["autouse"], - classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.AUTOUSE], + classes=(SAB.BADGE, SAB.BADGE_STATE, SAB.STATE_AUTOUSE), + fill="outline", ) ) elif kind == "factory": badges.append( - build_badge( + BadgeSpec( "factory", tooltip=_BADGE_TOOLTIPS["factory"], - classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.FACTORY], + classes=(SAB.BADGE, SAB.BADGE_KIND, SAB.STATE_FACTORY), + fill="outline", ) ) elif kind == "override_hook": badges.append( - build_badge( + BadgeSpec( "override", tooltip=_BADGE_TOOLTIPS["override_hook"], - classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.OVERRIDE], + classes=(SAB.BADGE, SAB.BADGE_KIND, SAB.STATE_OVERRIDE), + fill="outline", ) ) if show_fixture_badge: badges.append( - build_badge( + BadgeSpec( "fixture", tooltip=_BADGE_TOOLTIPS["fixture"], - classes=[_CSS.BADGE, _CSS.BADGE_FIXTURE], + classes=(SAB.BADGE, SAB.BADGE_FIXTURE, SAB.TYPE_FIXTURE), ) ) - for i, badge in enumerate(badges): - group += badge - if i < len(badges) - 1: - group += nodes.Text(" ") + return badges - return group + +def _build_badge_group_node( + scope: str, + kind: str, + autouse: bool, + *, + deprecated: bool = False, + show_fixture_badge: bool = True, +) -> nodes.inline: + """Return a badge group with shared BadgeNode children. + + Badge slots (left-to-right in visual order): + + * Slot 0 (deprecated): shown when fixture is deprecated + * Slot 1 (scope): shown when ``scope != "function"`` + * Slot 2 (kind): shown for ``"factory"`` / ``"override_hook"``; or + state badge (``"autouse"``) when ``autouse=True`` + * Slot 3 (FIXTURE): shown when ``show_fixture_badge=True`` (default) + + Parameters + ---------- + scope : str + Fixture scope string. + kind : str + Fixture kind string. + autouse : bool + When True, renders AUTO state badge instead of a kind badge. + deprecated : bool + When True, renders a deprecated badge at slot 0 (leftmost). + show_fixture_badge : bool + When False, suppresses the FIXTURE badge at slot 3. + + Returns + ------- + nodes.inline + Badge group container with BadgeNode children. + """ + return build_badge_group_from_specs( + _fixture_badge_specs( + scope, + kind, + autouse, + deprecated=deprecated, + show_fixture_badge=show_fixture_badge, + ), + classes=[SAB.BADGE_GROUP], + ) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_constants.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_constants.py index 94190da7..4a9a66e9 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_constants.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_constants.py @@ -86,7 +86,7 @@ class SetupDict(t.TypedDict): _CONFIG_HIDDEN_DEPS = "pytest_fixture_hidden_dependencies" _CONFIG_BUILTIN_LINKS = "pytest_fixture_builtin_links" -_CONFIG_EXTERNAL_LINKS = "pytest_external_fixture_links" +_CONFIG_EXTERNAL_LINKS = "pytest_fixture_external_links" _CONFIG_LINT_LEVEL = "pytest_fixture_lint_level" # --------------------------------------------------------------------------- @@ -120,7 +120,7 @@ class SetupDict(t.TypedDict): FixtureKind = t.Literal["resource", "factory", "override_hook"] _KNOWN_KINDS: frozenset[str] = frozenset(t.get_args(FixtureKind)) -_STORE_VERSION = 5 +_STORE_VERSION = 6 """Bump whenever ``FixtureMeta`` or the store schema changes. Used both as the Sphinx ``env_version`` (triggers full cache invalidation) and diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_css.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_css.py index b24f781f..0c066c44 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_css.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_css.py @@ -1,28 +1,27 @@ +"""CSS class name constants for sphinx_autodoc_pytest_fixtures. + +All constants use the ``gp-sphinx-pytest-fixtures`` namespace for +fixture-index layout. Shared badge primitives come from ``SAB`` in +``sphinx-ux-badges``; shared card/region primitives come from ``API`` in +``sphinx-ux-autodoc-layout``. + +Examples +-------- +>>> SPF.FIXTURE_INDEX +'gp-sphinx-pytest-fixtures__fixture-index' + +>>> SPF.TABLE_SCROLL +'gp-sphinx-pytest-fixtures__table-scroll' +""" + from __future__ import annotations -class _CSS: - """CSS class name constants used in generated HTML. - - Centralises every ``spf-*`` class name so the extension and stylesheet - stay in sync. Tests import this class to assert on rendered output. - """ - - PREFIX = "spf" - BADGE_GROUP = f"{PREFIX}-badge-group" - BADGE = f"{PREFIX}-badge" - BADGE_SCOPE = f"{PREFIX}-badge--scope" - BADGE_KIND = f"{PREFIX}-badge--kind" - BADGE_STATE = f"{PREFIX}-badge--state" - BADGE_FIXTURE = f"{PREFIX}-badge--fixture" - FACTORY = f"{PREFIX}-factory" - OVERRIDE = f"{PREFIX}-override" - AUTOUSE = f"{PREFIX}-autouse" - DEPRECATED = f"{PREFIX}-deprecated" - FIXTURE_INDEX = f"{PREFIX}-fixture-index" - TABLE_SCROLL = f"{PREFIX}-table-scroll" - - @staticmethod - def scope(name: str) -> str: - """Return the scope-specific CSS class, e.g. ``spf-scope-session``.""" - return f"{_CSS.PREFIX}-scope-{name}" +class SPF: + """CSS class name constants (``gp-sphinx-pytest-fixtures`` namespace).""" + + PREFIX = "gp-sphinx-pytest-fixtures" + + FIXTURE_INDEX = "gp-sphinx-pytest-fixtures__fixture-index" + TABLE_SCROLL = "gp-sphinx-pytest-fixtures__table-scroll" + DEPRECATED = "gp-sphinx-pytest-fixtures__deprecated" diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_detection.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_detection.py index d12aa6f3..f10871e3 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_detection.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_detection.py @@ -7,7 +7,6 @@ import typing as t from sphinx.util import logging as sphinx_logging -from sphinx.util.typing import stringify_annotation from sphinx_autodoc_pytest_fixtures._constants import ( _CONFIG_BUILTIN_LINKS, @@ -288,27 +287,6 @@ def _get_return_annotation(obj: t.Any) -> t.Any: return ret -def _format_type_short(annotation: t.Any) -> str: - """Format *annotation* to a short display string for docs. - - Parameters - ---------- - annotation : Any - A type annotation, possibly ``inspect.Parameter.empty``. - - Returns - ------- - str - A human-readable type string, or ``"..."`` when annotation is absent. - """ - if annotation is inspect.Parameter.empty: - return "..." - try: - return stringify_annotation(annotation) - except Exception: - return str(annotation) - - def _is_factory(obj: t.Any) -> bool: """Return True if *obj* is a factory fixture. diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py index b5a09b89..fa9acce6 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py @@ -23,7 +23,6 @@ _KNOWN_KINDS, PYTEST_BUILTIN_LINKS, ) -from sphinx_autodoc_pytest_fixtures._css import _CSS from sphinx_autodoc_pytest_fixtures._detection import ( _get_fixture_fn, _get_fixture_marker, @@ -40,8 +39,10 @@ autofixture_index_node, ) from sphinx_autodoc_pytest_fixtures._store import _get_spf_store, _resolve_builtin_url +from sphinx_ux_badges import SAB logger = sphinx_logging.getLogger(__name__) +AutofixtureEntry: t.TypeAlias = tuple[str, str, t.Any] def _iter_public_fixture_entries( @@ -126,6 +127,119 @@ def _is_native_myst(directive: SphinxDirective) -> bool: return False +def _build_autofixtures_directive_text( + modname: str, + entries: list[AutofixtureEntry], + *, + order: str = "source", + no_index: bool = False, + wrap_eval_rst: bool = False, +) -> str: + """Return directive source text for generated ``autofixture`` blocks. + + Parameters + ---------- + modname : str + Imported fixture module name. + entries : list[AutofixtureEntry] + Public fixture entries as ``(attr_name, public_name, fixture_obj)``. + order : str, optional + ``"source"`` preserves discovery order and ``"alpha"`` sorts by + public fixture name. + no_index : bool, optional + Whether to emit ``:no-index:`` for each generated directive. + wrap_eval_rst : bool, optional + Whether to wrap the generated RST in a MyST ``{eval-rst}`` fence. + + Returns + ------- + str + Nested directive source ready for ``parse_text_to_nodes()``. + """ + ordered_entries = entries + if order == "alpha": + ordered_entries = sorted(entries, key=operator.itemgetter(1)) + + lines: list[str] = [] + for _attr_name, public_name, _value in ordered_entries: + lines.append(f".. autofixture:: {modname}.{public_name}") + if no_index: + lines.append(" :no-index:") + lines.append("") + + content = "\n".join(lines).strip() + if wrap_eval_rst: + return f"```{{eval-rst}}\n{content}\n```" + return content + + +def _build_doc_pytest_plugin_intro_nodes( + *, + project: str, + summary: str, + install_command: str, + tests_url: str | None, +) -> list[nodes.Node]: + """Build the generated auto-pytest-plugin intro nodes.""" + intro_nodes: list[nodes.Node] = [] + if summary: + intro_nodes.append(nodes.paragraph("", summary)) + intro_nodes.append(nodes.rubric("", "Install")) + + install_block = nodes.literal_block("", f"$ {install_command}") + install_block["language"] = "console" + intro_nodes.append(install_block) + + note = nodes.note() + note_para = nodes.paragraph() + note_para += nodes.Text("pytest auto-detects this plugin through the ") + note_para += nodes.literal("", "pytest11") + note_para += nodes.Text(" entry point. Its fixtures are available without extra ") + note_para += nodes.literal("", "conftest.py") + note_para += nodes.Text(" imports.") + note += note_para + intro_nodes.append(note) + + if tests_url: + link_text = f"{project} test suite" if project else "test suite" + tests_para = nodes.paragraph() + tests_para += nodes.Text("For real-world usage examples, see the ") + tests_para += nodes.reference( + "", + "", + nodes.Text(link_text), + refuri=tests_url, + ) + tests_para += nodes.Text(".") + intro_nodes.append(tests_para) + + return intro_nodes + + +def _build_doc_pytest_plugin_fixture_section_scaffold( + modname: str, +) -> list[nodes.Node]: + """Return the generated fixture-section scaffold nodes.""" + idx_node = autofixture_index_node() + idx_node["module"] = modname + idx_node["exclude"] = set() + return [ + nodes.rubric("", "Fixture Summary"), + idx_node, + nodes.rubric("", "Fixture Reference"), + ] + + +def _compose_doc_pytest_plugin_nodes( + *, + intro_nodes: list[nodes.Node], + body_nodes: list[nodes.Node], + fixture_section_nodes: list[nodes.Node], +) -> list[nodes.Node]: + """Compose the final auto-pytest-plugin page nodes in display order.""" + return [*intro_nodes, *body_nodes, *fixture_section_nodes] + + def _render_autofixtures_nodes( directive: SphinxDirective, *, @@ -156,19 +270,13 @@ def _render_autofixtures_nodes( list[nodes.Node] Parsed fixture reference nodes. """ - if order == "alpha": - entries = sorted(entries, key=operator.itemgetter(1)) - - lines: list[str] = [] - for _attr_name, public_name, _value in entries: - lines.append(f".. autofixture:: {modname}.{public_name}") - if no_index: - lines.append(" :no-index:") - lines.append("") - - content = "\n".join(lines).strip() - if _is_native_myst(directive): - content = f"```{{eval-rst}}\n{content}\n```" + content = _build_autofixtures_directive_text( + modname, + entries, + order=order, + no_index=no_index, + wrap_eval_rst=_is_native_myst(directive), + ) return directive.parse_text_to_nodes( content, @@ -424,12 +532,12 @@ def transform_content( dep_para.extend(ref_ns) dep_para += nodes.Text(" instead.") warning += dep_para - # Add spf-deprecated class to the parent desc node for CSS muting + # Add state-deprecated badge class to the parent desc node for CSS muting for parent in self.state.document.findall(addnodes.desc): for sig in parent.findall(addnodes.desc_signature): if sig.get("spf_deprecated"): - if _CSS.DEPRECATED not in parent["classes"]: - parent["classes"].append(_CSS.DEPRECATED) + if SAB.STATE_DEPRECATED not in parent["classes"]: + parent["classes"].append(SAB.STATE_DEPRECATED) break # --- Lifecycle callouts (session note + override hook tip) --- @@ -556,7 +664,6 @@ def add_target_and_index( autouse="autouse" in self.options, kind=self.options.get("kind", _DEFAULTS["kind"]), return_display=self.options.get("return-type", ""), - return_xref_target=None, deps=tuple(deps), param_reprs=tuple( p.strip() @@ -633,7 +740,7 @@ def run(self) -> list[nodes.Node]: ) -class DocPytestPluginDirective(SphinxDirective): +class AutoPytestPluginDirective(SphinxDirective): """Render a reusable pytest-plugin documentation page block. Always emits an install section, pytest11 autodiscovery note, and @@ -670,33 +777,31 @@ def run(self) -> list[nodes.Node]: f"pip install {package}", ) - children: list[nodes.Node] = [] - children.extend( - self._build_page_intro_nodes( - project=project, - summary=summary, - install_command=install_command, - tests_url=tests_url, - ), + intro_nodes = _build_doc_pytest_plugin_intro_nodes( + project=project, + summary=summary, + install_command=install_command, + tests_url=tests_url, ) - + body_nodes: list[nodes.Node] = [] if self.content: - children.extend( - self.parse_content_to_nodes( - allow_section_headings=True, - ), + body_nodes = self.parse_content_to_nodes( + allow_section_headings=True, ) + fixture_section_nodes: list[nodes.Node] = [] entries = self._get_module_fixture_entries(modname) if entries is not None: - children.extend( - self._build_fixture_section_nodes( - modname=modname, - entries=entries, - ), + fixture_section_nodes = self._build_fixture_section_nodes( + modname=modname, + entries=entries, ) - return children + return _compose_doc_pytest_plugin_nodes( + intro_nodes=intro_nodes, + body_nodes=body_nodes, + fixture_section_nodes=fixture_section_nodes, + ) def _require_option(self, name: str) -> str: """Return a required option value or raise a directive error.""" @@ -715,7 +820,7 @@ def _get_module_fixture_entries( module = importlib.import_module(modname) except ImportError: logger.warning( - "doc-pytest-plugin could not import module %r; " + "auto-pytest-plugin could not import module %r; " "skipping generated fixture sections", modname, ) @@ -727,7 +832,7 @@ def _get_module_fixture_entries( return entries logger.warning( - "doc-pytest-plugin found no pytest fixtures in %r; " + "auto-pytest-plugin found no pytest fixtures in %r; " "skipping generated fixture sections", modname, ) @@ -742,41 +847,12 @@ def _build_page_intro_nodes( tests_url: str | None, ) -> list[nodes.Node]: """Build the generated intro nodes.""" - intro_nodes: list[nodes.Node] = [] - if summary: - intro_nodes.append(nodes.paragraph("", summary)) - intro_nodes.append(nodes.rubric("", "Install")) - - install_block = nodes.literal_block("", f"$ {install_command}") - install_block["language"] = "console" - intro_nodes.append(install_block) - - note = nodes.note() - note_para = nodes.paragraph() - note_para += nodes.Text("pytest auto-detects this plugin through the ") - note_para += nodes.literal("", "pytest11") - note_para += nodes.Text( - " entry point. Its fixtures are available without extra " + return _build_doc_pytest_plugin_intro_nodes( + project=project, + summary=summary, + install_command=install_command, + tests_url=tests_url, ) - note_para += nodes.literal("", "conftest.py") - note_para += nodes.Text(" imports.") - note += note_para - intro_nodes.append(note) - - if tests_url: - link_text = f"{project} test suite" if project else "test suite" - tests_para = nodes.paragraph() - tests_para += nodes.Text("For real-world usage examples, see the ") - tests_para += nodes.reference( - "", - "", - nodes.Text(link_text), - refuri=tests_url, - ) - tests_para += nodes.Text(".") - intro_nodes.append(tests_para) - - return intro_nodes def _build_fixture_section_nodes( self, @@ -785,13 +861,8 @@ def _build_fixture_section_nodes( entries: list[tuple[str, str, t.Any]], ) -> list[nodes.Node]: """Build generated fixture summary/reference nodes.""" - idx_node = autofixture_index_node() - idx_node["module"] = modname - idx_node["exclude"] = set() return [ - nodes.rubric("", "Fixture Summary"), - idx_node, - nodes.rubric("", "Fixture Reference"), + *_build_doc_pytest_plugin_fixture_section_scaffold(modname), *_render_autofixtures_nodes( self, modname=modname, diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_documenter.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_documenter.py index 36a3574c..db997adc 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_documenter.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_documenter.py @@ -14,7 +14,6 @@ PYTEST_HIDDEN, ) from sphinx_autodoc_pytest_fixtures._detection import ( - _format_type_short, _get_fixture_fn, _get_fixture_marker, _get_return_annotation, @@ -26,6 +25,7 @@ _extract_teardown_summary, _register_fixture_meta, ) +from sphinx_autodoc_typehints_gp import normalize_annotation_text logger = sphinx_logging.getLogger(__name__) @@ -179,7 +179,7 @@ def format_signature(self, **kwargs: t.Any) -> str: ret = _get_return_annotation(self.object) if ret is inspect.Parameter.empty: return "()" - return f"() -> {_format_type_short(ret)}" + return f"() -> {normalize_annotation_text(ret)}" def format_args(self, **kwargs: t.Any) -> str: """Return empty string — no argument list is shown to users. @@ -240,7 +240,10 @@ def add_directive_header(self, sig: str) -> None: ret = _get_return_annotation(self.object) if ret is not inspect.Parameter.empty: - self.add_line(f" :return-type: {_format_type_short(ret)}", sourcename) + self.add_line( + f" :return-type: {normalize_annotation_text(ret)}", + sourcename, + ) explicit_kind = self.options.get("kind") kind = _infer_kind(self.object, explicit_kind=explicit_kind) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_index.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_index.py index 92993f32..16e37e6c 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_index.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_index.py @@ -11,11 +11,15 @@ from sphinx_autodoc_pytest_fixtures._badges import _build_badge_group_node from sphinx_autodoc_pytest_fixtures._constants import ( - _IDENTIFIER_PATTERN, _INDEX_TABLE_COLUMNS, _RST_INLINE_PATTERN, ) -from sphinx_autodoc_pytest_fixtures._css import _CSS +from sphinx_autodoc_pytest_fixtures._css import SPF +from sphinx_autodoc_typehints_gp import build_resolved_annotation_paragraph +from sphinx_ux_autodoc_layout import build_api_summary_section + +_FIXTURE_INDEX = SPF.FIXTURE_INDEX +_TABLE_SCROLL = SPF.TABLE_SCROLL if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -123,53 +127,73 @@ def _parse_rst_inline( return result_nodes -def _build_return_type_nodes( - meta: FixtureMeta, - py_domain: PythonDomain, - app: Sphinx, - docname: str, -) -> list[nodes.Node]: - """Build doctree nodes for the return type, with linked class/builtin names. +def _select_fixture_index_fixtures( + store: FixtureStoreDict, + modname: str, + exclude: set[str], +) -> list[FixtureMeta]: + """Return fixture metadata that should appear in the generated index.""" + return [ + meta + for canon, meta in sorted(store["fixtures"].items()) + if canon.startswith(f"{modname}.") and meta.public_name not in exclude + ] - Tokenises the ``return_display`` string and wraps every identifier in a - ``:class:`` cross-reference. ``env.resolve_references()`` then resolves - identifiers it knows (``str`` \u2192 Python docs via intersphinx, ``Server`` \u2192 - local API page) and leaves unknown ones as plain code literals. - Parameters - ---------- - meta : FixtureMeta - Fixture metadata containing ``return_display``. - py_domain : PythonDomain - Python domain for object lookup. - app : Sphinx - Sphinx application. - docname : str - Current document name. +def _build_fixture_index_table_structure( + fixtures: list[FixtureMeta], +) -> tuple[nodes.table, nodes.tbody]: + """Return the index table shell populated with plain fixture metadata.""" + table = nodes.table(classes=[_FIXTURE_INDEX]) + tgroup = nodes.tgroup(cols=len(_INDEX_TABLE_COLUMNS)) + table += tgroup + for _header, width in _INDEX_TABLE_COLUMNS: + tgroup += nodes.colspec(colwidth=width) - Returns - ------- - list[nodes.Node] - Nodes for the return type cell with cross-referenced identifiers. - """ - display = meta.return_display - if not display: - return [nodes.Text("")] - - # Tokenise: identifiers (including dotted) vs punctuation/whitespace. - # Every identifier gets wrapped in :class:`~name` so intersphinx and - # the Python domain can resolve it. Punctuation passes through as text. - rst_parts: list[str] = [] - for token in _IDENTIFIER_PATTERN.split(display): - if not token: - continue - if _IDENTIFIER_PATTERN.fullmatch(token): - rst_parts.append(f":class:`~{token}`") - else: - rst_parts.append(token) + thead = nodes.thead() + tgroup += thead + header_row = nodes.row() + thead += header_row + for header, _width in _INDEX_TABLE_COLUMNS: + entry = nodes.entry() + entry += nodes.paragraph("", header) + header_row += entry - rst_text = "".join(rst_parts) - return _parse_rst_inline(rst_text, app, docname) + tbody = nodes.tbody() + tgroup += tbody + for meta in fixtures: + row = nodes.row() + tbody += row + + name_entry = nodes.entry() + name_entry += nodes.paragraph( + "", + "", + nodes.literal(meta.public_name, meta.public_name), + ) + row += name_entry + + flags_entry = nodes.entry() + flags_para = nodes.paragraph() + flags_para += _build_badge_group_node( + scope=meta.scope, + kind=meta.kind, + autouse=meta.autouse, + deprecated=bool(meta.deprecated), + show_fixture_badge=True, + ) + flags_entry += flags_para + row += flags_entry + + ret_entry = nodes.entry() + ret_entry += nodes.paragraph("", meta.return_display) + row += ret_entry + + desc_entry = nodes.entry() + desc_entry += nodes.paragraph("", meta.summary) + row += desc_entry + + return table, tbody def _resolve_fixture_index( @@ -202,39 +226,21 @@ def _resolve_fixture_index( modname = node["module"] exclude: set[str] = node.get("exclude", set()) - fixtures = [ - meta - for canon, meta in sorted(store["fixtures"].items()) - if canon.startswith(f"{modname}.") and meta.public_name not in exclude - ] + fixtures = _select_fixture_index_fixtures(store, modname, exclude) if not fixtures: node.replace_self([]) return - table = nodes.table(classes=[_CSS.FIXTURE_INDEX]) - tgroup = nodes.tgroup(cols=len(_INDEX_TABLE_COLUMNS)) - table += tgroup - for _header, width in _INDEX_TABLE_COLUMNS: - tgroup += nodes.colspec(colwidth=width) - - thead = nodes.thead() - tgroup += thead - header_row = nodes.row() - thead += header_row - for header, _width in _INDEX_TABLE_COLUMNS: - entry = nodes.entry() - entry += nodes.paragraph("", header) - header_row += entry - - tbody = nodes.tbody() - tgroup += tbody - for meta in fixtures: - row = nodes.row() - tbody += row + table, tbody = _build_fixture_index_table_structure(fixtures) + for meta, row_node in zip(fixtures, tbody.children, strict=True): + row = t.cast(nodes.row, row_node) + name_entry, _flags_entry, ret_entry, desc_entry = t.cast( + tuple[nodes.entry, nodes.entry, nodes.entry, nodes.entry], + tuple(row.children), + ) # --- Fixture name: cross-ref link --- - name_entry = nodes.entry() obj_entry = py_domain.objects.get(meta.canonical_name) if obj_entry is not None: ref_node: nodes.Node = make_refnode( @@ -248,39 +254,24 @@ def _resolve_fixture_index( ref_node = nodes.literal(meta.public_name, meta.public_name) name_para = nodes.paragraph() name_para += ref_node - name_entry += name_para - row += name_entry - - # --- Flags: scope/kind/autouse/deprecated badges --- - flags_entry = nodes.entry() - flags_para = nodes.paragraph() - flags_para += _build_badge_group_node( - scope=meta.scope, - kind=meta.kind, - autouse=meta.autouse, - deprecated=bool(meta.deprecated), - show_fixture_badge=True, - ) - flags_entry += flags_para - row += flags_entry + name_entry[:] = [name_para] # --- Returns: linked type name --- - ret_entry = nodes.entry() - ret_para = nodes.paragraph() - for ret_node in _build_return_type_nodes(meta, py_domain, app, docname): - ret_para += ret_node - ret_entry += ret_para - row += ret_entry + ret_entry[:] = [ + build_resolved_annotation_paragraph( + meta.return_display, + app, + docname, + ) + ] # --- Description: parsed RST inline markup --- - desc_entry = nodes.entry() desc_para = nodes.paragraph() if meta.summary: for desc_node in _parse_rst_inline(meta.summary, app, docname): desc_para += desc_node - desc_entry += desc_para - row += desc_entry + desc_entry[:] = [desc_para] - scroll_wrapper = nodes.container(classes=[_CSS.TABLE_SCROLL]) + scroll_wrapper = nodes.container(classes=[_TABLE_SCROLL]) scroll_wrapper += table - node.replace_self([scroll_wrapper]) + node.replace_self([build_api_summary_section(scroll_wrapper)]) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py index 1389dd2a..f0d1489e 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py @@ -4,6 +4,7 @@ import ast import inspect +import logging import re import typing as t @@ -16,7 +17,6 @@ ) from sphinx_autodoc_pytest_fixtures._detection import ( _classify_deps, - _format_type_short, _get_fixture_fn, _get_fixture_marker, _get_return_annotation, @@ -24,11 +24,27 @@ ) from sphinx_autodoc_pytest_fixtures._models import FixtureDep, FixtureMeta from sphinx_autodoc_pytest_fixtures._store import _get_spf_store +from sphinx_autodoc_typehints_gp import normalize_annotation_text if t.TYPE_CHECKING: from sphinx import addnodes -logger = sphinx_logging.getLogger(__name__) +logger = logging.getLogger(__name__) +sphinx_logger = sphinx_logging.getLogger(__name__) + + +def _active_logger(app: t.Any) -> t.Any: + """Return the logger best suited to the current execution context. + + Examples + -------- + >>> import types + >>> _active_logger(types.SimpleNamespace()).name + 'sphinx_autodoc_pytest_fixtures._metadata' + """ + if hasattr(app, "_warning"): + return sphinx_logger + return logger def _is_type_checking_guard(node: ast.If) -> bool: @@ -259,25 +275,17 @@ def _register_fixture_meta( is_async = inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn) ret_ann = _get_return_annotation(obj) - return_display = ( - _format_type_short(ret_ann) if ret_ann is not inspect.Parameter.empty else "" - ) - # Simple class name for xref: only for bare names without special chars. - # When the annotation is a forward-reference string (from TYPE_CHECKING), - # try to qualify it via the module's TYPE_CHECKING imports so Sphinx can - # resolve cross-references (e.g. "Session" → "libtmux.session.Session"). - return_xref_target: str | None = None - if return_display and return_display.isidentifier(): - return_xref_target = return_display - if isinstance(ret_ann, str): - qualified = _qualify_forward_ref(return_display, fn) - if qualified: - return_xref_target = qualified - return_display = qualified + return_display = "" + if ret_ann is not inspect.Parameter.empty: + return_display = normalize_annotation_text( + ret_ann, + module_name=fn.__module__ if isinstance(ret_ann, str) else None, + qualify_unresolved=isinstance(ret_ann, str), + ) inferred_kind = _infer_kind(obj, kind or None) if inferred_kind not in _KNOWN_KINDS: - logger.warning( + _active_logger(app).warning( "unknown fixture kind %r for %r; expected one of %r", inferred_kind, canonical_name, @@ -309,7 +317,6 @@ def _register_fixture_meta( autouse=autouse, kind=inferred_kind, return_display=return_display, - return_xref_target=return_xref_target, deps=tuple(dep_list), param_reprs=param_reprs, has_teardown=has_teardown, diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_models.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_models.py index 793a600c..3803611b 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_models.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_models.py @@ -65,9 +65,6 @@ class FixtureMeta: return_display: str """Short type label, e.g. ``"Server"``.""" - return_xref_target: str | None - """Simple class name for cross-referencing, or ``None`` for complex types.""" - deps: tuple[FixtureDep, ...] """Classified fixture dependencies.""" 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 f35ed88a..0ad5af0f 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 @@ -1,304 +1,25 @@ /* ── sphinx_autodoc_pytest_fixtures ──────────────────────── - * Multi-badge group: scope + kind/state + FIXTURE - * Three slots, hard ceiling: at most 3 badges per fixture. + * Fixture card layout and index table. * - * Slot 1 (scope): session=amber, module=teal, class=slate - * function → suppressed (absence = function-scope) - * Slot 2 (kind): factory=amber/brown, override_hook=violet - * resource → suppressed (default, no badge needed) - * OR state: autouse=rose (replaces kind when autouse=True) - * Slot 3 (base): FIXTURE — always shown, green - * - * Uses nodes.inline (portable across all Sphinx builders). - * Class-based selectors replace data-* attribute selectors. - * Flex layout replaces float with mobile-responsive wrapping. + * 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. * ────────────────────────────────────────────────────────── */ -/* Token system */ -:root { - /* Base FIXTURE badge — outlined green. 7.2:1 fg-on-bg (WCAG AA+AAA) */ - --spf-fixture-bg: #e6f7ed; - --spf-fixture-fg: #1a5c2e; - --spf-fixture-border: #3aad65; - - /* Scope: session — amber/gold. 6.2:1 fg-on-bg (WCAG AA) */ - --spf-scope-session-bg: #fff3cd; - --spf-scope-session-fg: #7a5200; - --spf-scope-session-border: #d4a017; - - /* Scope: module — teal. 6.7:1 fg-on-bg (WCAG AA) */ - --spf-scope-module-bg: #e0f4f4; - --spf-scope-module-fg: #1a5c5c; - --spf-scope-module-border: #3aabab; - - /* Scope: class — slate. 9.3:1 fg-on-bg (WCAG AA+AAA) */ - --spf-scope-class-bg: #eeedf6; - --spf-scope-class-fg: #3c3670; - --spf-scope-class-border: #7b76c0; - - /* Kind: factory — amber/brown (outlined). 8.1:1 fg-on-white (WCAG AA+AAA) */ - --spf-kind-factory-bg: transparent; - --spf-kind-factory-fg: #7a4200; - --spf-kind-factory-border: #c87f35; - - /* Kind: override_hook — violet (outlined). 11.3:1 fg-on-white (WCAG AA+AAA) */ - --spf-kind-override-bg: transparent; - --spf-kind-override-fg: #5a1a7a; - --spf-kind-override-border: #9b59c8; - - /* State: autouse — rose (outlined). 10.5:1 fg-on-white (WCAG AA+AAA) */ - --spf-state-autouse-bg: transparent; - --spf-state-autouse-fg: #7a1a2a; - --spf-state-autouse-border: #c85070; - - /* State: deprecated — muted red/grey (outlined). 8.5:1 fg-on-white */ - --spf-deprecated-bg: transparent; - --spf-deprecated-fg: #8a4040; - --spf-deprecated-border: #c07070; - - /* Shared badge metrics */ - --spf-badge-font-size: 0.67rem; - --spf-badge-padding-v: 0.16rem; - --spf-badge-border-w: 1px; -} - -/* Dark mode — OS-level */ -@media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) { - --spf-fixture-bg: #0d2e1a; - --spf-fixture-fg: #70dd90; - --spf-fixture-border: #309050; - - --spf-scope-session-bg: #3a2800; - --spf-scope-session-fg: #f5d580; - --spf-scope-session-border: #c89030; - - --spf-scope-module-bg: #0d2a2a; - --spf-scope-module-fg: #70dddd; - --spf-scope-module-border: #309090; - - --spf-scope-class-bg: #1a1838; - --spf-scope-class-fg: #b0acee; - --spf-scope-class-border: #6060b0; - - --spf-kind-factory-fg: #f0b060; - --spf-kind-factory-border: #d08040; - - --spf-kind-override-fg: #d090f0; - --spf-kind-override-border: #a060d0; - - --spf-state-autouse-fg: #f080a0; - --spf-state-autouse-border: #d05070; - - --spf-deprecated-fg: #e08080; - --spf-deprecated-border: #c06060; - } -} - -/* Furo explicit dark toggle — must match OS-dark block above */ -body[data-theme="dark"] { - --spf-fixture-bg: #0d2e1a; /* 8.7:1 with fg #70dd90 */ - --spf-fixture-fg: #70dd90; - --spf-fixture-border: #309050; - - --spf-scope-session-bg: #3a2800; - --spf-scope-session-fg: #f5d580; - --spf-scope-session-border: #c89030; - - --spf-scope-module-bg: #0d2a2a; - --spf-scope-module-fg: #70dddd; - --spf-scope-module-border: #309090; - - --spf-scope-class-bg: #1a1838; - --spf-scope-class-fg: #b0acee; - --spf-scope-class-border: #6060b0; - - --spf-kind-factory-fg: #f0b060; - --spf-kind-factory-border: #d08040; - - --spf-kind-override-fg: #d090f0; - --spf-kind-override-border: #a060d0; - - --spf-state-autouse-fg: #f080a0; - --spf-state-autouse-border: #d05070; - - --spf-deprecated-fg: #e08080; - --spf-deprecated-border: #c06060; -} - /* "fixture" keyword prefix — keep Furo's default keyword colour */ -dl.py.fixture > dt em.property { +dl.py.fixture.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-signature em.property { color: var(--color-api-keyword); font-style: normal; } -/* Badge group container — flex layout for reliable multi-badge alignment. - * Flexbox replaces the float: right approach; the dt becomes a flex row - * (signature text + badge group side by side). - * On narrow viewports the badge group wraps below the signature. */ -dl.py.fixture > dt { - display: flex; - align-items: center; - gap: 0.35rem; - flex-wrap: wrap; +/* Fixture cards keep their package-specific chrome while layout owns header + * structure and positioning. */ +dl.py.fixture.gp-sphinx-api-container > dt.gp-sphinx-api-header { background: var(--color-background-secondary); border-bottom: 1px solid var(--color-background-border); padding: 0.5rem 0.75rem; } -/* Visual reorder: sig elements (0) → ¶ (1) → badges (2) → [source] (3). - * The ¶ headerlink is injected by Sphinx's HTML translator AFTER our - * doctree code runs, so CSS order is the only way to position it. */ -dl.py.fixture > dt > .headerlink { order: 1; } -dl.py.fixture > dt > .spf-badge-group { order: 2; } -dl.py.fixture > dt > a.reference.external { order: 3; } - -dl.py.fixture > dt .spf-badge-group { - display: inline-flex; - align-items: center; - gap: 0.3rem; - flex-shrink: 0; - margin-left: auto; - white-space: nowrap; - text-indent: 0; -} - -/* Mobile: badge group wraps below signature text, shares row with actions */ -@media (max-width: 52rem) { - dl.py.fixture > dt .spf-badge-group { - margin-left: 0; - white-space: normal; - flex-wrap: wrap; - } - dl.py.fixture > dt .spf-badge { - --spf-badge-font-size: 0.75rem; - } -} - -/* Shared badge base — component-scoped. - * Applies wherever .spf-badge appears: fixture cards, index tables, or any - * future context. Context-specific layout rules (flex container, tooltip - * positioning anchor, margin-left: auto in dt) remain on their containers. */ -.spf-badge { - position: relative; /* positioning context for the CSS-only tooltip */ - display: inline-block; - font-size: var(--spf-badge-font-size, 0.67rem); - font-weight: 700; - line-height: normal; - letter-spacing: 0.01em; - padding: var(--spf-badge-padding-v, 0.16rem) 0.5rem; - border-radius: 0.22rem; - border: var(--spf-badge-border-w, 1px) solid; - vertical-align: middle; -} - -/* Touch/keyboard tooltip — shows on focus (touch tap focuses the element). - * Sphinx's tooltips are invisible on touch devices; this CSS-only - * solution renders the title text as a positioned pseudo-element on :focus. - * Works in both fixture cards and index table cells since .spf-badge carries - * position: relative as the tooltip's containing block. */ -.spf-badge[tabindex]:focus::after { - content: attr(title); - position: absolute; - bottom: calc(100% + 4px); - left: 50%; - transform: translateX(-50%); - background: var(--color-background-primary); - border: 1px solid var(--color-background-border); - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 400; - white-space: nowrap; - border-radius: 0.2rem; - z-index: 10; - pointer-events: none; -} - -/* Restore visible focus outline for keyboard users. The tooltip still appears - * on any :focus, but the ring only appears for :focus-visible (keyboard nav). */ -.spf-badge[tabindex]:focus-visible { - outline: 2px solid var(--color-link); - outline-offset: 2px; -} - -/* FIXTURE badge (always shown, outlined green — same visual treatment - * as scope badges: light bg, dark text, colored border) */ -.spf-badge--fixture { - background-color: var(--spf-fixture-bg); - color: var(--spf-fixture-fg); - border-color: var(--spf-fixture-border); -} - -/* Scope badges — component-scoped (replaces data-* attribute selectors) */ -.spf-scope-session { - background-color: var(--spf-scope-session-bg); - color: var(--spf-scope-session-fg); - border-color: var(--spf-scope-session-border); - letter-spacing: 0.03em; -} -.spf-scope-module { - background-color: var(--spf-scope-module-bg); - color: var(--spf-scope-module-fg); - border-color: var(--spf-scope-module-border); -} -.spf-scope-class { - background-color: var(--spf-scope-class-bg); - color: var(--spf-scope-class-fg); - border-color: var(--spf-scope-class-border); -} - -/* Kind badges (outlined — behaviour, not lifecycle) */ -.spf-factory { - background-color: var(--spf-kind-factory-bg); - color: var(--spf-kind-factory-fg); - border-color: var(--spf-kind-factory-border); -} -.spf-override { - background-color: var(--spf-kind-override-bg); - color: var(--spf-kind-override-fg); - border-color: var(--spf-kind-override-border); -} - -/* State badge (autouse) */ -.spf-autouse { - background-color: var(--spf-state-autouse-bg); - color: var(--spf-state-autouse-fg); - border-color: var(--spf-state-autouse-border); -} - -/* Deprecated badge — component-scoped; card muting stays dt-scoped */ -.spf-deprecated { - background-color: var(--spf-deprecated-bg); - color: var(--spf-deprecated-fg); - border-color: var(--spf-deprecated-border); -} - -/* ── Border color reinforcement ──────────────────────────── - * BadgeNode renders ; legacy builds may still emit . - * Target both element-agnostically via class selectors. - * ─────────────────────────────────────────────────────────────────────── */ -.spf-badge--fixture.spf-badge { border-color: var(--spf-fixture-border); } -.spf-scope-session.spf-badge { border-color: var(--spf-scope-session-border); } -.spf-scope-module.spf-badge { border-color: var(--spf-scope-module-border); } -.spf-scope-class.spf-badge { border-color: var(--spf-scope-class-border); } -.spf-factory.spf-badge { border-color: var(--spf-kind-factory-border); } -.spf-override.spf-badge { border-color: var(--spf-kind-override-border); } -.spf-autouse.spf-badge { border-color: var(--spf-state-autouse-border); } -.spf-deprecated.spf-badge { border-color: var(--spf-deprecated-border); } - -dl.py.fixture.spf-deprecated > dt { - opacity: 0.7; -} - -/* Badge group inside fixture index table cell — keep badges inline. - * The card context has its own dl.py.fixture > dt .spf-badge-group rule - * (with margin-left: auto and flex layout tied to the dt flex row). - * Table cells need a simpler inline-flex without the card-specific overrides. */ -.spf-fixture-index .spf-badge-group { - display: inline-flex; - gap: 0.3rem; -} - /* Suppress module prefix (libtmux.pytest_plugin.) */ dl.py.fixture > dt .sig-prename.descclassname { display: none; @@ -314,11 +35,6 @@ dl.py.fixture { box-shadow: 0 1px 3px rgba(0,0,0,0.04); } -/* Reset Furo's hanging-indent and negative-margin on dt so content - * stays within the card border. Furo sets text-indent: -35px and - * margin: 0 -4px for wrapped signatures — both break our flex card. - * !important on padding overrides Furo's .sig:not(.sig-inline) rule which - * sets padding-top/bottom: 0.25rem and wins on specificity without it. */ dl.py.fixture > dt { text-indent: 0; margin: 0; @@ -330,10 +46,10 @@ dl.py.fixture > dt { dl.py.fixture > dd { padding: 0.75rem 1rem; - margin-left: 0 !important; /* override Furo's dd { margin-left: 32px } */ + margin-left: 0 !important; } -/* Metadata fields: compact grid that keeps dt/dd pairs together */ +/* Metadata fields: compact grid */ dl.py.fixture > dd > dl.field-list { display: grid; grid-template-columns: max-content minmax(0, 1fr); @@ -352,7 +68,6 @@ dl.py.fixture > dd > dl.field-list > dt { dl.py.fixture > dd > dl.field-list > dt .colon { display: none; } dl.py.fixture > dd > dl.field-list > dd { grid-column: 2; margin-left: 0; } -/* Mobile: metadata fields stack to single column */ @media (max-width: 52rem) { dl.py.fixture > dd > dl.field-list { grid-template-columns: 1fr; @@ -363,19 +78,31 @@ dl.py.fixture > dd > dl.field-list > dd { grid-column: 2; margin-left: 0; } } } -/* Suppress Rtype field-list on fixtures — return type is already in the - * signature (→ Type). sphinx_autodoc_typehints emits a separate field-list - * with only "Rtype:" when autodoc_typehints = "description". Hide it. */ +/* Suppress Rtype field-list on fixtures */ dl.py.fixture > dd > dl.field-list + dl.field-list { display: none; } -/* Horizontal scroll wrapper for the fixture index table on narrow viewports */ -.spf-table-scroll { +/* Deprecated fixture muting (self-contained so this extension works + * without sphinx-autodoc-api-style, which carries the same rule at + * the more general dl.py scope). Fires when _directives.py appends + * SAB.STATE_DEPRECATED ("gp-sphinx-badge--state-deprecated") to the + * parent `desc` node of a fixture flagged with :deprecated:. */ +dl.py.fixture.gp-sphinx-badge--state-deprecated > dt { + opacity: 0.7; +} + +/* Horizontal scroll wrapper for the fixture index table */ +.gp-sphinx-pytest-fixtures__fixture-index .gp-sphinx-badge-group { + display: inline-flex; + gap: 0.3rem; +} + +.gp-sphinx-pytest-fixtures__table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } -.spf-table-scroll table { +.gp-sphinx-pytest-fixtures__table-scroll table { min-width: 40rem; width: 100%; } diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py index 994ff9e3..bf7184d0 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py @@ -14,6 +14,11 @@ from sphinx_autodoc_pytest_fixtures._index import _resolve_fixture_index from sphinx_autodoc_pytest_fixtures._models import autofixture_index_node from sphinx_autodoc_pytest_fixtures._store import FixtureStoreDict, _get_spf_store +from sphinx_ux_autodoc_layout import ( + API, + build_api_table_section, + inject_signature_slots, +) if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -23,6 +28,10 @@ logger = sphinx_logging.getLogger(__name__) +_PARAMETER_FIELD_LABELS = frozenset( + {"parameter", "parameters", "return", "returns", "yield", "yields", "raises"} +) + def _on_missing_reference( app: t.Any, @@ -59,7 +68,7 @@ def _on_missing_reference( reftype = node.get("reftype") target = node.get("reftarget", "") - py_domain: PythonDomain = env.get_domain("py") + py_domain = env.domains.python_domain # Short-name :fixture: lookup via public_to_canon. if reftype == "fixture": @@ -105,53 +114,28 @@ def _on_missing_reference( def _inject_badges_and_reorder(sig_node: addnodes.desc_signature) -> None: - """Inject scope/kind/fixture badges and reorder signature children. - - Appends a badge group to *sig_node* and reorders the \u00b6 headerlink and - [source] viewcode link so the visual layout is: - ``name \u2192 return \u2192 \u00b6 \u2192 badges (right-aligned) \u2192 [source]``. + """Inject scope/kind/fixture badges into shared layout slots. Guarded by the ``spf_badges_injected`` flag \u2014 safe to call multiple times. """ - if sig_node.get("spf_badges_injected"): - return - sig_node["spf_badges_injected"] = True - scope = sig_node.get("spf_scope", "function") kind = sig_node.get("spf_kind", "resource") autouse = sig_node.get("spf_autouse", False) deprecated = sig_node.get("spf_deprecated", False) badge_group = _build_badge_group_node(scope, kind, autouse, deprecated=deprecated) - - # Detach [source] and \u00b6 links, then re-append in desired order. - viewcode_ref = None - headerlink_ref = None - for child in list(sig_node.children): - if isinstance(child, nodes.reference): - if child.get("internal") is not True and any( - "viewcode-link" in getattr(gc, "get", lambda *_: "")("classes", []) - for gc in child.children - if isinstance(gc, nodes.inline) - ): - viewcode_ref = child - sig_node.remove(child) - elif "headerlink" in child.get("classes", []): - headerlink_ref = child - sig_node.remove(child) - - if headerlink_ref is not None: - sig_node += headerlink_ref - sig_node += badge_group - if viewcode_ref is not None: - sig_node += viewcode_ref + inject_signature_slots( + sig_node, + marker_attr="spf_badges_injected", + badge_node=badge_group, + ) def _strip_rtype_fields(desc_node: addnodes.desc) -> None: """Remove redundant "Rtype" fields from fixture descriptions. - ``sphinx_autodoc_typehints`` emits these for all autodoc objects; for - fixtures the return type is already in the signature line (``\u2192 Type``). + ``sphinx_autodoc_typehints_gp`` emits these for all autodoc objects; for + fixtures the return type is already in the signature line (``→ Type``). """ for content_child in desc_node.findall(addnodes.desc_content): for fl in list(content_child.findall(nodes.field_list)): @@ -168,6 +152,27 @@ def _strip_rtype_fields(desc_node: addnodes.desc) -> None: content_child.remove(fl) +def _wrap_fixture_field_lists(desc_node: addnodes.desc) -> None: + """Wrap top-level fixture field lists in shared body sections.""" + for content_child in desc_node.findall(addnodes.desc_content): + for field_list in list(content_child.children): + if not isinstance(field_list, nodes.field_list): + continue + labels = { + field_name.astext().strip().lower() + for field_name in field_list.findall(nodes.field_name) + } + section_name = ( + API.PARAMETERS if labels & _PARAMETER_FIELD_LABELS else API.FACTS + ) + insert_idx = content_child.children.index(field_list) + content_child.remove(field_list) + content_child.insert( + insert_idx, + build_api_table_section(section_name, field_list), + ) + + def _inject_metadata_fields( desc_node: addnodes.desc, store: FixtureStoreDict, @@ -282,7 +287,7 @@ def _on_doctree_resolved( The name of the document being resolved. """ store = _get_spf_store(app.env) - py_domain: PythonDomain = app.env.get_domain("py") # type: ignore[assignment] + py_domain = app.env.domains.python_domain for desc_node in doctree.findall(addnodes.desc): if desc_node.get("objtype") != "fixture": @@ -292,6 +297,7 @@ def _on_doctree_resolved( _inject_badges_and_reorder(sig_node) _strip_rtype_fields(desc_node) _inject_metadata_fields(desc_node, store, py_domain, app, docname) + _wrap_fixture_field_lists(desc_node) # Resolve autofixture-index placeholders for idx_node in list(doctree.findall(autofixture_index_node)): diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py index 90997908..bfc80ce7 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t from sphinx.util import logging as sphinx_logging @@ -9,7 +10,22 @@ if t.TYPE_CHECKING: from sphinx_autodoc_pytest_fixtures._store import FixtureStoreDict -logger = sphinx_logging.getLogger(__name__) +logger = logging.getLogger(__name__) +sphinx_logger = sphinx_logging.getLogger(__name__) + + +def _active_logger(app: t.Any) -> t.Any: + """Return the logger best suited to the current execution context. + + Examples + -------- + >>> import types + >>> _active_logger(types.SimpleNamespace()).name + 'sphinx_autodoc_pytest_fixtures._validation' + """ + if hasattr(app, "_warning"): + return sphinx_logger + return logger def _validate_store(store: FixtureStoreDict, app: t.Any) -> None: @@ -40,7 +56,8 @@ def _validate_store(store: FixtureStoreDict, app: t.Any) -> None: if lint_level == "none": return - _emit = logger.error if lint_level == "error" else logger.warning + active_logger = _active_logger(app) + _emit = active_logger.error if lint_level == "error" else active_logger.warning emitted = False for canon, meta in store["fixtures"].items(): diff --git a/packages/sphinx-autodoc-sphinx/README.md b/packages/sphinx-autodoc-sphinx/README.md index 11c276d8..7611e997 100644 --- a/packages/sphinx-autodoc-sphinx/README.md +++ b/packages/sphinx-autodoc-sphinx/README.md @@ -3,6 +3,11 @@ Sphinx extension for documenting config values registered by `app.add_config_value()` as copyable `conf.py` reference entries. +Rendered entries use the shared stack: `sphinx_ux_autodoc_layout` owns the +visible `api-*` structure, `sphinx_ux_badges` owns badge output, and +`sphinx_autodoc_typehints_gp` is auto-loaded so displayed config types follow the same +annotation rules as the rest of the autodoc family. + ## Install ```console @@ -29,7 +34,7 @@ Or generate a full reference section for an extension module: .. autoconfigvalue-index:: sphinx_fonts .. autoconfigvalues:: sphinx_fonts -.. autosphinxconfig-index:: sphinx_argparse_neo.exemplar +.. autoconfigvalue-page:: sphinx_autodoc_argparse.exemplar ``` ## Documentation diff --git a/packages/sphinx-autodoc-sphinx/pyproject.toml b/packages/sphinx-autodoc-sphinx/pyproject.toml index 53dc0f3e..d907d58e 100644 --- a/packages/sphinx-autodoc-sphinx/pyproject.toml +++ b/packages/sphinx-autodoc-sphinx/pyproject.toml @@ -26,7 +26,10 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "configuration", "conf.py", "documentation", "autodoc"] dependencies = [ - "sphinx", + "sphinx>=8.1", + "sphinx-ux-badges==0.0.1a7", + "sphinx-ux-autodoc-layout==0.0.1a7", + "sphinx-autodoc-typehints-gp==0.0.1a7", ] [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 3688560a..73b84b9a 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -3,13 +3,14 @@ from __future__ import annotations import logging +import pathlib import typing as t from sphinx_autodoc_sphinx._directives import ( AutoconfigvalueDirective, AutoconfigvalueIndexDirective, + AutoconfigvaluePageDirective, AutoconfigvaluesDirective, - AutosphinxconfigIndexDirective, ) if t.TYPE_CHECKING: @@ -26,20 +27,43 @@ def setup(app: Sphinx) -> ExtensionMetadata: -------- >>> class FakeApp: ... def __init__(self) -> None: - ... self.calls: list[tuple[str, str]] = [] + ... self.calls: list[tuple[str, object]] = [] + ... def setup_extension(self, name: str) -> None: + ... self.calls.append(("setup_extension", name)) ... def add_directive(self, name: str, directive: object) -> None: ... self.calls.append(("add_directive", name)) + ... def connect(self, event: str, handler: object) -> None: + ... self.calls.append(("connect", event)) + ... def add_css_file(self, filename: str) -> None: + ... self.calls.append(("add_css_file", filename)) >>> fake = FakeApp() >>> metadata = setup(fake) # type: ignore[arg-type] >>> ("add_directive", "autoconfigvalue") in fake.calls True + >>> ("setup_extension", "sphinx_ux_autodoc_layout") in fake.calls + True + >>> ("add_css_file", "css/sphinx_autodoc_sphinx.css") in fake.calls + True >>> metadata["parallel_read_safe"] True """ + app.setup_extension("sphinx_ux_badges") + app.setup_extension("sphinx_ux_autodoc_layout") + app.setup_extension("sphinx_autodoc_typehints_gp") app.add_directive("autoconfigvalue", AutoconfigvalueDirective) app.add_directive("autoconfigvalues", AutoconfigvaluesDirective) + app.add_directive("autoconfigvalue-page", AutoconfigvaluePageDirective) app.add_directive("autoconfigvalue-index", AutoconfigvalueIndexDirective) - app.add_directive("autosphinxconfig-index", AutosphinxconfigIndexDirective) + + _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/sphinx_autodoc_sphinx.css") + return { "version": "0.0.1a7", "parallel_read_safe": True, diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py new file mode 100644 index 00000000..e33f181b --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py @@ -0,0 +1,46 @@ +"""Badge helpers for sphinx_autodoc_sphinx config-value entries.""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + +from sphinx_ux_badges import SAB, BadgeSpec, build_badge_group_from_specs + +if t.TYPE_CHECKING: + from sphinx_autodoc_sphinx._directives import SphinxConfigValue + +_GROUP_CLASS = SAB.BADGE_GROUP + + +def build_config_badge_group(value: SphinxConfigValue) -> nodes.inline: + """Return header badges for one documented config value. + + Parameters + ---------- + value : SphinxConfigValue + Config value metadata captured from the extension ``setup()`` hook. + + Returns + ------- + nodes.inline + Badge group containing the config kind and rebuild mode badges. + """ + rebuild = value.rebuild or "none" + return build_badge_group_from_specs( + [ + BadgeSpec( + "config", + tooltip="Sphinx config value", + classes=(SAB.TYPE_CONFIG,), + ), + BadgeSpec( + rebuild, + tooltip=f"Rebuild mode: {rebuild}", + classes=(SAB.MOD_REBUILD,), + fill="outline", + ), + ], + classes=[_GROUP_CLASS], + ) diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py index 91391a28..38c344e8 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -25,10 +25,20 @@ from docutils import nodes from docutils.parsers.rst import directives -from docutils.statemachine import StringList from sphinx import addnodes from sphinx.util.docutils import SphinxDirective +from sphinx_autodoc_sphinx._badges import build_config_badge_group +from sphinx_autodoc_typehints_gp import normalize_type_collection_text +from sphinx_ux_autodoc_layout import ( + ApiFactRow, + build_api_facts_section, + build_api_summary_section, + inject_signature_slots, + iter_desc_nodes, + parse_generated_markup, +) + if t.TYPE_CHECKING: from sphinx.util.typing import OptionSpec @@ -163,6 +173,13 @@ def _render_default(value: object) -> str: # object: only calls repr() return f"``{value!r}``" +def _literal_paragraph(text: str) -> nodes.paragraph: + """Return a paragraph containing one literal node.""" + paragraph = nodes.paragraph() + paragraph += nodes.literal(text, text) + return paragraph + + def _is_complex_default(value: object) -> bool: # object: only calls repr() """Return True when repr of value exceeds the inline display threshold. @@ -205,32 +222,6 @@ def _make_default_block(value: object) -> nodes.literal_block: # object: calls return block -def _render_types( - types: object, - default: object, -) -> str: # object: uses isinstance guards - """Render a readable type expression for ``:type:``. - - Examples - -------- - >>> _render_types((bool, str), False) - '``bool | str``' - >>> _render_types((), None) - '``None``' - """ - if isinstance(types, (list, tuple, set, frozenset)) and types: - names = sorted( - "None" if getattr(item, "__name__", "") == "NoneType" else item.__name__ - for item in t.cast("t.Iterable[type]", types) - ) - return f"``{' | '.join(names)}``" - if types: - return f"``{types!r}``" - if default is None: - return "``None``" - return f"``{type(default).__name__}``" - - def _config_values_from_calls( module_name: str, calls: list[tuple[str, tuple[object, ...], dict[str, object]]], @@ -291,7 +282,8 @@ def discover_config_values(module_name: str) -> list[SphinxConfigValue]: Examples -------- - >>> names = {value.name for value in discover_config_values("sphinx_argparse_neo")} + >>> vals = discover_config_values("sphinx_autodoc_argparse") + >>> names = {value.name for value in vals} >>> names == { ... "argparse_group_title_prefix", ... "argparse_show_defaults", @@ -340,26 +332,16 @@ def render_config_value_markup( >>> markup = render_config_value_markup(value) >>> ".. confval:: demo_option" in markup True - >>> ":default: ``True``" in markup - True + >>> ":default:" in markup + False """ lines = [ f".. confval:: {value.name}", " :no-index:" if no_index else "", - f" :type: {_render_types(value.types, value.default)}", + "", ] - if not _is_complex_default(value.default): - lines.append(f" :default: {_render_default(value.default)}") - lines.append("") if value.description: lines.extend([f" {value.description}", ""]) - lines.extend( - [ - f" Registered by ``{value.module_name}.setup()``.", - "", - f" Rebuild: ``{value.rebuild or 'none'}``.", - ], - ) return "\n".join(lines) @@ -407,10 +389,14 @@ def render_config_index_markup( " - Rebuild", ] for value in values: + type_text = normalize_type_collection_text( + value.types, + default=value.default, + ) lines.extend( [ f" * - ``{value.name}``", - f" - {_render_types(value.types, value.default)}", + f" - ``{type_text}``", f" - {_render_default(value.default)}", f" - ``{value.rebuild or 'none'}``", ], @@ -418,45 +404,6 @@ def render_config_index_markup( return "\n".join(lines) -# NOTE: This function is byte-for-byte identical to -# sphinx_autodoc_docutils._directives._render_blocks. Both packages depend -# only on sphinx (not on each other), so a shared location would require a new -# dependency. If a third caller emerges, extract to gp_sphinx._render. -def _render_blocks(directive: SphinxDirective, markup: str) -> list[nodes.Node]: - """Parse generated markup back through Sphinx. - - Examples - -------- - >>> class DummyState: - ... def nested_parse( - ... self, - ... view_list: StringList, - ... offset: int, - ... node: nodes.Element, - ... ) -> None: - ... for line in view_list: - ... node += nodes.paragraph("", line) - >>> class DummyDirective: - ... state = DummyState() - ... content_offset = 0 - ... def get_source_info(self) -> tuple[str, int]: - ... return ("demo.md", 1) - >>> rendered = _render_blocks(DummyDirective(), "demo") # type: ignore[arg-type] - >>> rendered[0].astext() - 'demo' - """ - if hasattr(directive, "parse_text_to_nodes"): - return directive.parse_text_to_nodes(markup) - - source, _line = directive.get_source_info() - view_list: StringList = StringList() - for line in markup.splitlines(): - view_list.append(line, source) - container = nodes.container() - directive.state.nested_parse(view_list, directive.content_offset, container) - return [container] if container.children else [] - - def _iter_desc_content( node_list: list[nodes.Node], ) -> t.Iterator[addnodes.desc_content]: @@ -471,7 +418,66 @@ def _iter_desc_content( [] """ for node in node_list: - yield from node.traverse(addnodes.desc_content) + yield from node.findall(addnodes.desc_content) + + +def _inject_config_badges( + node_list: list[nodes.Node], + value: SphinxConfigValue, +) -> None: + """Attach shared badge-slot metadata to parsed ``confval`` entries.""" + badge_group = build_config_badge_group(value) + for desc_node in iter_desc_nodes(node_list): + if desc_node.get("domain") != "std" or desc_node.get("objtype") != "confval": + continue + for sig_node in desc_node.children: + if not isinstance(sig_node, addnodes.desc_signature): + continue + inject_signature_slots( + sig_node, + marker_attr="sas_badges_injected", + badge_node=badge_group.deepcopy(), + extract_source_link=False, + ) + + +def _config_fact_rows(value: SphinxConfigValue) -> list[ApiFactRow]: + """Return shared fact rows for one config value.""" + default_body: nodes.Node + if _is_complex_default(value.default): + default_body = _make_default_block(value.default) + else: + default_body = _literal_paragraph(repr(value.default)) + return [ + ApiFactRow( + "Type", + _literal_paragraph( + normalize_type_collection_text( + value.types, + default=value.default, + ) + ), + ), + ApiFactRow("Default", default_body), + ApiFactRow("Registered by", _literal_paragraph(f"{value.module_name}.setup()")), + ] + + +def _render_config_value_nodes( + directive: SphinxDirective, + value: SphinxConfigValue, + *, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one config value into parsed nodes with layout metadata.""" + value_nodes = parse_generated_markup( + directive, + render_config_value_markup(value, no_index=no_index), + ) + _inject_config_badges(value_nodes, value) + for desc_content in _iter_desc_content(value_nodes): + desc_content += build_api_facts_section(_config_fact_rows(value)) + return value_nodes class AutoconfigvalueDirective(SphinxDirective): @@ -483,9 +489,10 @@ class AutoconfigvalueDirective(SphinxDirective): def run(self) -> list[nodes.Node]: value = discover_config_value(self.arguments[0]) - return _render_blocks( + return _render_config_value_nodes( self, - render_config_value_markup(value, no_index="no-index" in self.options), + value, + no_index="no-index" in self.options, ) @@ -501,16 +508,9 @@ def run(self) -> list[nodes.Node]: no_index = "no-index" in self.options result: list[nodes.Node] = [] for value in discover_config_values(module_name): - markup = render_config_value_markup(value, no_index=no_index) - value_nodes = _render_blocks(self, markup) - if _is_complex_default(value.default): - block = _make_default_block(value.default) - for desc_content in _iter_desc_content(value_nodes): - # Insert before the trailing metadata paragraphs - # ("Registered by …" and "Rebuild: …") - idx = max(0, len(desc_content) - 2) - desc_content.insert(idx, block) - result.extend(value_nodes) + result.extend( + _render_config_value_nodes(self, value, no_index=no_index), + ) return result @@ -522,10 +522,16 @@ class AutoconfigvalueIndexDirective(SphinxDirective): def run(self) -> list[nodes.Node]: markup = render_config_index_markup(self.arguments[0]) - return _render_blocks(self, markup) if markup else [] + if not markup: + return [] + rendered = parse_generated_markup(self, markup) + return [ + build_api_summary_section(node) if isinstance(node, nodes.table) else node + for node in rendered + ] -class AutosphinxconfigIndexDirective(SphinxDirective): +class AutoconfigvaluePageDirective(SphinxDirective): """Render a drop-in index plus detailed ``confval`` blocks. This keeps the legacy directive useful on package pages without forcing @@ -537,9 +543,16 @@ class AutosphinxconfigIndexDirective(SphinxDirective): def run(self) -> list[nodes.Node]: module_name = self.arguments[0] - parts = [ - render_config_index_markup(module_name, heading="Sphinx Config Index"), - render_config_values_markup(module_name), - ] - markup = "\n\n".join(part for part in parts if part) - return _render_blocks(self, markup) if markup else [] + result: list[nodes.Node] = [] + markup = render_config_index_markup(module_name, heading="Sphinx Config Index") + if markup: + rendered = parse_generated_markup(self, markup) + result.extend( + build_api_summary_section(node) + if isinstance(node, nodes.table) + else node + for node in rendered + ) + for value in discover_config_values(module_name): + result.extend(_render_config_value_nodes(self, value)) + return result diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_static/css/sphinx_autodoc_sphinx.css b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_static/css/sphinx_autodoc_sphinx.css new file mode 100644 index 00000000..f63775f7 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_static/css/sphinx_autodoc_sphinx.css @@ -0,0 +1,3 @@ +/* sphinx_autodoc_sphinx — badge colours moved to sab_palettes.css. + * This file is intentionally empty; retained as a placeholder. + */ diff --git a/packages/sphinx-autodoc-typehints-gp/README.md b/packages/sphinx-autodoc-typehints-gp/README.md new file mode 100644 index 00000000..40cbb01f --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/README.md @@ -0,0 +1,37 @@ +# sphinx-autodoc-typehints-gp + +Single-package replacement for `sphinx-autodoc-typehints` and +`sphinx.ext.napoleon` — resolves annotations statically at build time, +no monkey-patching required. + +Part of the [gp-sphinx](https://github.com/git-pull/gp-sphinx) shared +autodoc stack: annotation normalization, cross-referenced type links, and +`TYPE_CHECKING`-safe resolution all live here. + +## Install + +```console +$ pip install sphinx-autodoc-typehints-gp +``` + +## Usage + +```python +extensions = ["sphinx.ext.autodoc", "sphinx_autodoc_typehints_gp"] + +# Required: makes autodoc insert type annotations into parameter descriptions. +# Without this, the type cross-referencing pipeline fires but has nothing to attach to. +autodoc_typehints = "description" +``` + +## Features + +- Resolves type hints statically via AST — no `exec()`, no `typing.get_type_hints()`. +- Handles `TYPE_CHECKING` blocks correctly (import-time guards are not evaluated). +- No text-processing races with `sphinx.ext.napoleon`. +- Shared annotation normalization and rendering helpers for the `sphinx-autodoc-*` family. + +## Documentation + +See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-autodoc-typehints-gp/) +for the API reference, helper functions, and the static resolution comparison table. diff --git a/packages/sphinx-autodoc-typehints-gp/pyproject.toml b/packages/sphinx-autodoc-typehints-gp/pyproject.toml new file mode 100644 index 00000000..0227049e --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sphinx-autodoc-typehints-gp" +version = "0.0.1a7" +description = "Cross-referenced type annotations for Sphinx autodoc" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +dependencies = [ + "sphinx>=8.1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_autodoc_typehints_gp"] diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/__init__.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/__init__.py new file mode 100644 index 00000000..cab4ca5e --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/__init__.py @@ -0,0 +1,29 @@ +"""Unconventional typehints extension for gp-sphinx.""" + +from __future__ import annotations + +from sphinx_autodoc_typehints_gp.extension import setup +from sphinx_autodoc_typehints_gp.rendering import ( + AnnotationDisplay, + build_annotation_display_paragraph, + build_annotation_paragraph, + build_resolved_annotation_display_paragraph, + build_resolved_annotation_paragraph, + classify_annotation_display, + normalize_annotation_text, + normalize_type_collection_text, + render_annotation_nodes, +) + +__all__ = [ + "AnnotationDisplay", + "build_annotation_display_paragraph", + "build_annotation_paragraph", + "build_resolved_annotation_display_paragraph", + "build_resolved_annotation_paragraph", + "classify_annotation_display", + "normalize_annotation_text", + "normalize_type_collection_text", + "render_annotation_nodes", + "setup", +] diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py new file mode 100644 index 00000000..06767d4e --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/_numpy_docstring.py @@ -0,0 +1,797 @@ +"""NumPy docstring preprocessor replacing ``sphinx.ext.napoleon``. + +Converts NumPy-style section-based docstrings into RST field lists during +``autodoc-process-docstring``, eliminating the need for +``sphinx.ext.napoleon``. + +The parser handles: Parameters, Returns, Raises, Yields, Receives, +Examples, Notes, See Also, Attributes, References, and admonition sections +(Warning, Note, Caution, etc.). + +Type cross-referencing is NOT done here — that is handled by the companion +``merge_typehints`` doctree transform in ``extension.py`` which converts +plain-text type fields into ``pending_xref`` nodes. + +Examples +-------- +>>> lines = [ +... "Short summary.", +... "", +... "Parameters", +... "----------", +... "x : int", +... " The value.", +... ] +>>> result = process_numpy_docstring(lines) +>>> ":param x: The value." in result +True +>>> ":type x: int" in result +True +""" + +from __future__ import annotations + +import collections +import re +import typing as t + +_NUMPY_UNDERLINE_RE = re.compile(r"^[=\-`:'\"~^_*+#<>]{2,}\s*$") + +_XREF_OR_CODE_RE = re.compile( + r"((?::(?:[a-zA-Z0-9]+[\-_+:.])*[a-zA-Z0-9]+:`.+?`)|" + r"(?:``.+?``)|" + r"(?::meta .+:.*)|" + r"(?:`.+?\s*(?`))" +) +_SINGLE_COLON_RE = re.compile(r"(?\()?" + r"(\d+|#|[ivxlcdm]+|[IVXLCDM]+|[a-zA-Z])" + r"(?(paren)\)|\.)(\s+\S|\s*$)" +) + +_PARAM_NAMES = frozenset( + {"parameters", "params", "args", "arguments", "other parameters"} +) +_KEYWORD_NAMES = frozenset({"keyword arguments", "keyword args"}) +_RETURN_NAMES = frozenset({"returns", "return"}) +_RAISE_NAMES = frozenset({"raises", "raise"}) +_YIELD_NAMES = frozenset({"yields", "yield"}) +_RECEIVE_NAMES = frozenset({"receives", "receive"}) +_ATTRIBUTE_NAMES = frozenset({"attributes"}) +_EXAMPLE_NAMES = frozenset({"examples", "example"}) +_NOTE_NAMES = frozenset({"notes"}) +_REFERENCE_NAMES = frozenset({"references"}) +_SEE_ALSO_NAMES = frozenset({"see also"}) +_METHOD_NAMES = frozenset({"methods"}) +_ADMONITION_MAP: dict[str, str] = { + "warning": "warning", + "warnings": "warning", + "caution": "caution", + "danger": "danger", + "error": "error", + "hint": "hint", + "important": "important", + "note": "note", + "tip": "tip", + "todo": "todo", + "attention": "attention", +} +_ALL_SECTIONS = ( + _PARAM_NAMES + | _KEYWORD_NAMES + | _RETURN_NAMES + | _RAISE_NAMES + | _YIELD_NAMES + | _RECEIVE_NAMES + | _ATTRIBUTE_NAMES + | _EXAMPLE_NAMES + | _NOTE_NAMES + | _REFERENCE_NAMES + | _SEE_ALSO_NAMES + | _METHOD_NAMES + | set(_ADMONITION_MAP) +) + + +def process_numpy_docstring(lines: list[str]) -> list[str]: + """Convert NumPy-style docstring lines to RST field lists. + + Parameters + ---------- + lines : list[str] + Raw docstring lines from autodoc. + + Returns + ------- + list[str] + Processed lines with NumPy sections converted to RST. + + Examples + -------- + >>> result = process_numpy_docstring([ + ... "Summary line.", + ... "", + ... "Parameters", + ... "----------", + ... "x : int", + ... " The x value.", + ... "y : str", + ... " The y value.", + ... ]) + >>> ":param x: The x value." in result + True + >>> ":type x: int" in result + True + >>> ":param y: The y value." in result + True + + >>> result = process_numpy_docstring(["No sections here."]) + >>> result + ['No sections here.'] + """ + return _NumpyParser(lines).parse() + + +def _partition_on_colon(line: str) -> tuple[str, str, str]: + """Split *line* on the first bare colon outside cross-refs and code. + + Parameters + ---------- + line : str + A single docstring line. + + Returns + ------- + tuple[str, str, str] + ``(before, colon, after)`` — *colon* is ``':'`` when found, + otherwise all three are ``('', '', '')``. + + Examples + -------- + >>> _partition_on_colon("name : int") + ('name', ':', 'int') + + >>> _partition_on_colon("name") + ('name', '', '') + + >>> _partition_on_colon(":class:`Foo` : bar") + (':class:`Foo`', ':', 'bar') + """ + before: list[str] = [] + after: list[str] = [] + colon = "" + found = False + for i, source in enumerate(_XREF_OR_CODE_RE.split(line)): + if found: + after.append(source) + else: + m = _SINGLE_COLON_RE.search(source) + if (i % 2) == 0 and m: + found = True + colon = source[m.start() : m.end()] + before.append(source[: m.start()]) + after.append(source[m.end() :]) + else: + before.append(source) + return "".join(before).strip(), colon, "".join(after).strip() + + +def _escape_args_and_kwargs(name: str) -> str: + r"""Escape ``*`` and ``**`` prefixes for RST compatibility. + + Parameters + ---------- + name : str + Parameter name, possibly prefixed with ``*`` or ``**``. + + Returns + ------- + str + Escaped name. + + Examples + -------- + >>> _escape_args_and_kwargs("args") + 'args' + >>> _escape_args_and_kwargs("*args") + '\\*args' + >>> _escape_args_and_kwargs("**kwargs") + '\\*\\*kwargs' + """ + if name.startswith("**"): + return rf"\*\*{name[2:]}" + if name.startswith("*"): + return rf"\*{name[1:]}" + return name + + +def _get_indent(line: str) -> int: + """Return the number of leading whitespace characters. + + Examples + -------- + >>> _get_indent(" hello") + 4 + >>> _get_indent("hello") + 0 + >>> _get_indent("") + 0 + """ + for i, ch in enumerate(line): + if not ch.isspace(): + return i + return len(line) + + +def _is_indented(line: str, indent: int = 1) -> bool: + """Check whether *line* is indented at least *indent* characters. + + Examples + -------- + >>> _is_indented(" x", 2) + True + >>> _is_indented(" x", 2) + False + """ + for i, ch in enumerate(line): + if i >= indent: + return True + if not ch.isspace(): + return False + return False + + +def _dedent(lines: list[str]) -> list[str]: + """Remove common leading whitespace from *lines*. + + Examples + -------- + >>> _dedent([" a", " b"]) + ['a', 'b'] + >>> _dedent([" a", " b"]) + ['a', ' b'] + """ + min_indent: int | None = None + for line in lines: + if line: + ind = _get_indent(line) + if min_indent is None or ind < min_indent: + min_indent = ind + n = min_indent or 0 + return [line[n:] for line in lines] + + +def _strip_empty(lines: list[str]) -> list[str]: + """Strip leading and trailing empty lines. + + Examples + -------- + >>> _strip_empty(["", "a", "b", ""]) + ['a', 'b'] + """ + start = 0 + for i, line in enumerate(lines): + if line: + start = i + break + else: + return [] + end = len(lines) + for i in range(len(lines) - 1, -1, -1): + if lines[i]: + end = i + 1 + break + return lines[start:end] + + +def _indent(lines: list[str], n: int = 4) -> list[str]: + """Indent every line by *n* spaces. + + Examples + -------- + >>> _indent(["a", "b"], 3) + [' a', ' b'] + """ + pad = " " * n + return [pad + line for line in lines] + + +def _is_list(lines: list[str]) -> bool: + """Return whether *lines* starts with a bullet or enumerated list.""" + if not lines: + return False + if _BULLET_LIST_RE.match(lines[0]): + return True + if _ENUM_LIST_RE.match(lines[0]): + return True + if len(lines) < 2 or lines[0].endswith("::"): + return False + indent = _get_indent(lines[0]) + next_indent = indent + for line in lines[1:]: + if line: + next_indent = _get_indent(line) + break + return next_indent > indent + + +def _fix_field_desc(desc: list[str]) -> list[str]: + """Prepend a blank line when *desc* starts a list or literal block.""" + if _is_list(desc): + return ["", *desc] + if desc[0].endswith("::"): + block = desc[1:] + indent = _get_indent(desc[0]) + block_indent = _get_indent(block[0]) if block else indent + if block_indent > indent: + return ["", *desc] + return ["", desc[0], *_indent(block, 4)] + return desc + + +def _format_block(prefix: str, lines: list[str]) -> list[str]: + """Format *lines* with *prefix* on the first line, padding thereafter. + + Examples + -------- + >>> _format_block(":param x: ", ["Desc", "continued."]) + [':param x: Desc', ' continued.'] + """ + if not lines: + return [prefix] + padding = " " * len(prefix) + result: list[str] = [] + for i, line in enumerate(lines): + if i == 0: + result.append((prefix + line).rstrip()) + elif line: + result.append(padding + line) + else: + result.append("") + return result + + +def _format_field(name: str, type_: str, desc: list[str]) -> list[str]: + """Format a single field entry as ``**name** (*type*) -- desc``. + + Used for multi-value returns, yields, and warns sections where + individual ``:rtype:`` fields are not appropriate. + + Examples + -------- + >>> _format_field("x", "int", ["The value."]) + ['**x** (*int*) -- The value.'] + + >>> _format_field("", "bool", ["True if ok."]) + ['*bool* -- True if ok.'] + """ + desc = _strip_empty(desc) + has_desc = any(desc) + separator = " -- " if has_desc else "" + if name: + if type_: + if "`" in type_: + header = f"**{name}** ({type_}){separator}" + else: + header = f"**{name}** (*{type_}*){separator}" + else: + header = f"**{name}**{separator}" + elif type_: + header = f"{type_}{separator}" if "`" in type_ else f"*{type_}*{separator}" + else: + header = "" + + if has_desc: + desc = _fix_field_desc(desc) + if desc[0]: + return [header + desc[0], *desc[1:]] + return [header, *desc] + return [header] + + +def _parse_sa_item(text: str) -> tuple[str, str | None]: + """Extract a name and optional Sphinx role from a See Also entry. + + Matches ``:role:`name``` (returning the role) or a plain ``name`` + (returning ``None`` for the role). + + Parameters + ---------- + text : str + The text to parse, e.g. ``":func:`my_func`"`` or ``"my_func"``. + + Returns + ------- + tuple[str, str | None] + ``(name, role)`` where *role* is ``None`` for plain names. + + Examples + -------- + >>> _parse_sa_item("my_func") + ('my_func', None) + >>> _parse_sa_item(":func:`my_func`") + ('my_func', 'func') + >>> _parse_sa_item(":py:meth:`Widget.run`") + ('Widget.run', 'py:meth') + >>> _parse_sa_item("") + ('', None) + """ + m = _SA_NAME_RE.match(text.strip()) + if not m: + return text.strip(), None + role, role_name, plain_name = m.group(1), m.group(2), m.group(3) + if role is not None: + return role_name, role + return plain_name, None + + +class _NumpyParser: + """Line-based NumPy docstring parser. + + Consumes docstring lines from a deque, detecting NumPy section + headers and dispatching to section-specific formatters that produce + RST output. + """ + + __slots__ = ("_in_section", "_lines", "_section_indent") + + def __init__(self, lines: t.Sequence[str]) -> None: + self._lines: collections.deque[str] = collections.deque( + line.rstrip() for line in lines + ) + self._in_section = False + self._section_indent = 0 + + # -- public API -- + + def parse(self) -> list[str]: + """Parse the docstring and return RST lines.""" + result = self._consume_empty() + first_block = True + while self._lines: + if self._is_section_header(): + section = self._consume_section_header() + self._in_section = True + self._section_indent = self._current_indent() + try: + result.extend(self._dispatch(section.lower())) + finally: + self._in_section = False + self._section_indent = 0 + first_block = False + elif first_block: + result.extend(self._consume_contiguous()) + result.extend(self._consume_empty()) + first_block = False + else: + result.extend(self._consume_to_next_section()) + return result + + # -- line access -- + + def _peek(self, n: int = 0) -> str: + if n < len(self._lines): + return self._lines[n] + return "" + + def _next(self) -> str: + return self._lines.popleft() + + # -- detection -- + + def _is_section_header(self) -> bool: + section = self._peek(0).strip().lower() + underline = self._peek(1) + return bool( + section in _ALL_SECTIONS + and underline + and _NUMPY_UNDERLINE_RE.match(underline) + ) + + def _is_section_break(self) -> bool: + line1 = self._peek(0) + line2 = self._peek(1) + return ( + not self._lines + or self._is_section_header() + or (not line1 and not line2) + or ( + self._in_section + and bool(line1) + and not _is_indented(line1, self._section_indent) + ) + ) + + def _current_indent(self, peek_ahead: int = 0) -> int: + idx = peek_ahead + while idx < len(self._lines): + line = self._lines[idx] + if line: + return _get_indent(line) + idx += 1 + return 0 + + # -- consumers -- + + def _consume_section_header(self) -> str: + section = self._next() + self._next() # underline + return section.strip() + + def _consume_empty(self) -> list[str]: + result: list[str] = [] + while self._lines and not self._peek(0): + result.append(self._next()) + return result + + def _consume_contiguous(self) -> list[str]: + result: list[str] = [] + while self._lines and self._peek(0) and not self._is_section_header(): + result.append(self._next()) + return result + + def _consume_to_next_section(self) -> list[str]: + self._consume_empty() + result: list[str] = [] + while not self._is_section_break(): + result.append(self._next()) + result.extend(self._consume_empty()) + return result + + def _consume_indented_block(self, indent: int = 1) -> list[str]: + result: list[str] = [] + while not self._is_section_break() and ( + not self._peek(0) or _is_indented(self._peek(0), indent) + ): + result.append(self._next()) + return result + + def _consume_field( + self, + parse_type: bool = True, + prefer_type: bool = False, + ) -> tuple[str, str, list[str]]: + line = self._next() + if parse_type: + name, _, type_ = _partition_on_colon(line) + else: + name, type_ = line, "" + name = name.strip() + type_ = type_.strip() + + if ", " in name: + name = ", ".join( + _escape_args_and_kwargs(n.strip()) for n in name.split(", ") + ) + else: + name = _escape_args_and_kwargs(name) + + if prefer_type and not type_: + type_, name = name, type_ + + indent = _get_indent(line) + 1 + desc = _dedent(self._consume_indented_block(indent)) + return name, type_, desc + + def _consume_fields( + self, + parse_type: bool = True, + prefer_type: bool = False, + multiple: bool = False, + ) -> list[tuple[str, str, list[str]]]: + self._consume_empty() + fields: list[tuple[str, str, list[str]]] = [] + while not self._is_section_break(): + name, type_, desc = self._consume_field(parse_type, prefer_type) + if multiple and name: + fields.extend((n.strip(), type_, desc) for n in name.split(",")) + elif name or type_ or desc: + fields.append((name, type_, desc)) + return fields + + # -- dispatch -- + + def _dispatch(self, section: str) -> list[str]: + if section in _PARAM_NAMES: + return self._fmt_params() + if section in _KEYWORD_NAMES: + return self._fmt_params(field_role="keyword", type_role="kwtype") + if section in _RETURN_NAMES: + return self._fmt_returns() + if section in _RAISE_NAMES: + return self._fmt_raises() + if section in _YIELD_NAMES: + return self._fmt_fields("Yields") + if section in _RECEIVE_NAMES: + return self._fmt_params() + if section in _ATTRIBUTE_NAMES: + return self._fmt_attributes() + if section in _EXAMPLE_NAMES: + label = "Example" if section == "example" else "Examples" + return self._fmt_generic(label) + if section in _NOTE_NAMES: + return self._fmt_generic("Notes") + if section in _REFERENCE_NAMES: + return self._fmt_generic("References") + if section in _SEE_ALSO_NAMES: + return self._fmt_see_also() + if section in _METHOD_NAMES: + return self._fmt_methods() + if section in _ADMONITION_MAP: + return self._fmt_admonition(_ADMONITION_MAP[section]) + return self._fmt_generic(section.title()) + + # -- section formatters -- + + def _fmt_params( + self, field_role: str = "param", type_role: str = "type" + ) -> list[str]: + fields = self._consume_fields(multiple=True) + lines: list[str] = [] + for name, type_, desc in fields: + desc = _strip_empty(desc) + if any(desc): + desc = _fix_field_desc(desc) + lines.extend(_format_block(f":{field_role} {name}: ", desc)) + else: + lines.append(f":{field_role} {name}:") + if type_: + lines.append(f":{type_role} {name}: {type_}") + if lines: + lines.append("") + return lines + + def _fmt_returns(self) -> list[str]: + fields = self._consume_fields(prefer_type=True) + multi = len(fields) > 1 + lines: list[str] = [] + for name, type_, desc in fields: + if multi: + field = _format_field(name, type_, desc) + if lines: + lines.extend(_format_block(" * ", field)) + else: + lines.extend(_format_block(":returns: * ", field)) + else: + field = _format_field(name, "", desc) + if any(field): + lines.extend(_format_block(":returns: ", field)) + if type_: + lines.extend([f":rtype: {type_}", ""]) + if lines and lines[-1]: + lines.append("") + return lines + + def _fmt_raises(self) -> list[str]: + fields = self._consume_fields(parse_type=False, prefer_type=True) + lines: list[str] = [] + for _name, type_, desc in fields: + type_str = f" {type_}" if type_ else "" + desc = _strip_empty(desc) + desc_str = " " + "\n ".join(desc) if any(desc) else "" + lines.append(f":raises{type_str}:{desc_str}") + if lines: + lines.append("") + return lines + + def _fmt_fields(self, label: str) -> list[str]: + fields = self._consume_fields(prefer_type=True) + if not fields: + return [] + prefix = f":{label}:" + padding = " " * len(prefix) + multi = len(fields) > 1 + lines: list[str] = [] + for name, type_, desc in fields: + field = _format_field(name, type_, desc) + if multi: + if lines: + lines.extend(_format_block(f"{padding} * ", field)) + else: + lines.extend(_format_block(f"{prefix} * ", field)) + else: + lines.extend(_format_block(f"{prefix} ", field)) + if lines and lines[-1]: + lines.append("") + return lines + + def _fmt_attributes(self) -> list[str]: + lines: list[str] = [] + for name, type_, desc in self._consume_fields(): + lines.append(f".. attribute:: {name}") + lines.append("") + fields = _format_field("", "", desc) + lines.extend(_indent(fields, 3)) + if type_: + lines.append("") + lines.extend(_indent([f":type: {type_}"], 3)) + lines.append("") + return lines + + def _fmt_methods(self) -> list[str]: + lines: list[str] = [] + for name, _type, desc in self._consume_fields(parse_type=False): + lines.append(f".. method:: {name}") + if desc: + lines.extend(["", *_indent(desc, 3)]) + lines.append("") + return lines + + def _fmt_generic(self, label: str) -> list[str]: + raw = _strip_empty(self._consume_to_next_section()) + raw = _dedent(raw) + header = f".. rubric:: {label}" + if raw: + return [header, "", *raw, ""] + return [header, ""] + + def _fmt_admonition(self, directive: str) -> list[str]: + raw = _strip_empty(self._consume_to_next_section()) + if len(raw) == 1: + return [f".. {directive}:: {raw[0].strip()}", ""] + if raw: + raw = _indent(_dedent(raw), 3) + return [f".. {directive}::", "", *raw, ""] + return [f".. {directive}::", ""] + + def _fmt_see_also(self) -> list[str]: + lines = self._consume_to_next_section() + items: list[tuple[str, list[str], str | None]] = [] + current_name: str | None = None + current_desc: list[str] = [] + + def _push_item(name: str | None, desc: list[str]) -> None: + if not name: + return + parsed_name, role = _parse_sa_item(name) + items.append((parsed_name, desc.copy(), role)) + + for line in lines: + if not line.strip(): + continue + if not line.startswith(" "): + _push_item(current_name, current_desc) + current_desc = [] + current_name = None + if "," in line: + for part in line.split(","): + if part.strip(): + _push_item(part, []) + elif " : " in line: + _sa_name, _, _sa_desc = line.partition(" : ") + current_name = _sa_name.strip() + current_desc = [_sa_desc.strip()] if _sa_desc.strip() else [] + else: + current_name = line.strip() + elif current_name is not None: + current_desc.append(line.strip()) + + _push_item(current_name, current_desc) + + if not items: + return [] + + body: list[str] = [] + last_had_desc = True + for item_name, item_desc, item_role in items: + if item_role: + link = f":{item_role}:`{item_name}`" + else: + link = f":py:obj:`{item_name}`" + if item_desc or last_had_desc: + body.append("") + body.append(link) + else: + body[-1] += f", {link}" + if item_desc: + body.extend(_indent([" ".join(item_desc)], 3)) + last_had_desc = True + else: + last_had_desc = False + body.append("") + + raw = _indent(_dedent(body), 3) + return [".. seealso::", "", *raw, ""] 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 new file mode 100644 index 00000000..f1bff0e3 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/extension.py @@ -0,0 +1,599 @@ +"""Type annotation enhancement and NumPy docstring parsing for Sphinx autodoc. + +Replaces both ``sphinx-autodoc-typehints`` and ``sphinx.ext.napoleon`` with a +single self-contained extension. Two independent pipelines run in sequence: + +1. **NumPy docstring parsing** — ``process_docstring`` hooks + ``autodoc-process-docstring`` to convert NumPy section-based docstrings + (Parameters, Returns, Raises, Yields, …) into RST field lists. Implemented + in :mod:`sphinx_autodoc_typehints_gp._numpy_docstring`. + +2. **Type cross-referencing** — ``merge_typehints`` hooks + ``object-description-transform`` at priority 499 (before Sphinx's built-in + ``_merge_typehints`` at 500) to insert or upgrade ``:type:`` and ``:rtype:`` + field nodes with cross-referenced ``pending_xref`` content. + +Does **not** use ``exec()``, ``typing.get_type_hints()``, or any +monkeypatches. Type annotations are resolved statically via AST analysis. +""" + +from __future__ import annotations + +import ast +import builtins +import inspect +import logging +import sys +import typing as t + +from docutils import nodes +from sphinx import addnodes +from sphinx.errors import ExtensionError + +if t.TYPE_CHECKING: + from docutils.nodes import Element, Node + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + from sphinx.ext.autodoc._legacy_class_based._directive_options import ( # type: ignore[import-not-found] + Options, + ) + +logger = logging.getLogger(__name__) + +_MODULE_IMPORTS: dict[str, dict[str, str]] = {} + + +def get_module_imports(module_name: str) -> dict[str, str]: + """Extract all import aliases from a module's source code. + + Parses the AST of the module to find all ``import`` and + ``from ... import`` statements, including those inside + ``if TYPE_CHECKING:`` blocks. + + Parameters + ---------- + module_name : str + The fully qualified name of the module to inspect. + + Returns + ------- + dict[str, str] + A mapping of local names to fully qualified names. + + Examples + -------- + >>> import sphinx.util.typing + >>> aliases = get_module_imports('sphinx.util.typing') + >>> aliases['Any'] + 'typing.Any' + """ + if module_name in _MODULE_IMPORTS: + return _MODULE_IMPORTS[module_name] + + module = sys.modules.get(module_name) + if not module: + return {} + try: + source = inspect.getsource(module) + except (TypeError, OSError): + return {} + + try: + tree = ast.parse(source) + except SyntaxError: + return {} + + aliases: dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + name = alias.asname or alias.name + aliases[name] = alias.name + elif isinstance(node, ast.ImportFrom): + mod = node.module or "" + if node.level > 0: + parts = module_name.split(".") + if len(parts) >= node.level: + base = ".".join(parts[: -node.level]) + if base: + mod = f"{base}.{mod}" if mod else base + for alias in node.names: + name = alias.asname or alias.name + aliases[name] = f"{mod}.{alias.name}" if mod else alias.name + + _MODULE_IMPORTS[module_name] = aliases + return aliases + + +class _TypeTransformer(ast.NodeTransformer): + """AST transformer that rewrites type annotation names to Sphinx xrefs. + + Each bare name in a type annotation string is replaced with its + fully-qualified alias using Sphinx's ``~`` cross-reference prefix. + For example, a bare ``List`` becomes ``~typing.List``; a local + ``MyClass`` (imported as ``from other import MyClass``) becomes + ``~other.MyClass``. Attribute chains are collapsed: if the alias + for ``alias`` is ``~typing``, then ``alias.List`` collapses to + ``~typing.List``. + + The import alias map is built by :func:`get_module_imports` from the + module's AST, including names guarded by ``if TYPE_CHECKING:`` blocks + — so forward references that are only importable at type-check time + are resolved correctly without any runtime evaluation. + """ + + def __init__( + self, + module_name: str, + aliases: dict[str, str], + *, + qualify_unresolved: bool, + ) -> None: + self.module_name = module_name + self.aliases = aliases + self.qualify_unresolved = qualify_unresolved + + def visit_Name(self, node: ast.Name) -> ast.AST: + if node.id in self.aliases: + return ast.Name(id=f"~{self.aliases[node.id]}", ctx=node.ctx) + if hasattr(builtins, node.id): + return node + if not self.qualify_unresolved: + return node + return ast.Name(id=f"~{self.module_name}.{node.id}", ctx=node.ctx) + + def visit_Attribute(self, node: ast.Attribute) -> ast.AST: + self.generic_visit(node) + if isinstance(node.value, ast.Name) and node.value.id.startswith("~"): + return ast.Name(id=f"{node.value.id}.{node.attr}", ctx=node.ctx) + return node + + +def resolve_annotation_string( + ann_str: str, + module_name: str, + aliases: dict[str, str], + *, + qualify_unresolved: bool = True, +) -> str: + """Resolve a string annotation to use fully qualified names. + + Parameters + ---------- + ann_str : str + The string representation of the type annotation. + module_name : str + The name of the module where the annotation is defined. + aliases : dict[str, str] + A mapping of local names to fully qualified names. + + Returns + ------- + str + The resolved annotation string with ``~`` prefixes for Sphinx. + + Examples + -------- + >>> aliases = {'List': 'typing.List', 'MyClass': 'other.MyClass'} + >>> resolve_annotation_string('List[MyClass]', 'my_module', aliases) + '~typing.List[~other.MyClass]' + >>> resolve_annotation_string( + ... 'PathLike[str]', + ... 'my_module', + ... {'PathLike': 'os.PathLike'}, + ... qualify_unresolved=False, + ... ) + '~os.PathLike[str]' + """ + try: + tree = ast.parse(ann_str, mode="eval") + except SyntaxError: + return ann_str + + transformed = _TypeTransformer( + module_name, + aliases, + qualify_unresolved=qualify_unresolved, + ).visit(tree) + return ast.unparse(transformed) + + +def _annotation_to_nodes(annotation: str, env: BuildEnvironment) -> list[Node]: + """Convert a stringified annotation to cross-referenced docutils nodes. + + Delegates to ``sphinx.domains.python._annotations._parse_annotation`` + which produces ``pending_xref`` nodes for type names and + ``desc_sig_*`` nodes for punctuation. Falls back to the re-exported + name at the Python-domain package level for Sphinx < 7.2. + + ``_parse_annotation`` catches internal ``SyntaxError`` and returns a + single ``type_to_xref`` node when parsing fails — no extra error + handling is needed here. + + Parameters + ---------- + annotation : str + Stringified type annotation, e.g. ``'str | None'`` or + ``'dict[str, list[int]]'``. + env : BuildEnvironment + Sphinx build environment used for module/class context. + + Returns + ------- + list[Node] + Docutils nodes with ``pending_xref`` entries for resolvable names. + + Examples + -------- + >>> _annotation_to_nodes # doctest: +ELLIPSIS + + """ + try: + from sphinx.domains.python._annotations import ( + _parse_annotation, + ) + except ImportError: # Sphinx < 7.2 + from sphinx.domains.python import ( # type: ignore[attr-defined] + _parse_annotation, + ) + return _parse_annotation(annotation, env) + + +def _enhance_existing_type_field( + field: nodes.field, + env: BuildEnvironment, +) -> None: + """Upgrade a plain-text ``:type:`` or ``:rtype:`` field body to xrefs. + + Called on fields that already exist in the field list — typically + produced by ``sphinx.ext.napoleon`` from NumPy / Google docstring type + annotations. Replaces a single-paragraph plain-text body with + ``pending_xref`` nodes so types become clickable links. + + The upgrade is skipped when: + + - the field body is not exactly one paragraph (multi-node bodies are + assumed to be intentionally complex) + - the paragraph already contains a ``pending_xref`` node (already + cross-referenced) + - the paragraph text is empty + + Parameters + ---------- + field : nodes.field + A ``:type X:`` or ``:rtype:`` field whose body may contain + plain text to be enhanced. + env : BuildEnvironment + Sphinx build environment. + """ + if len(field.children) < 2: + return + body = field.children[1] + if not isinstance(body, nodes.field_body): + return + if len(body.children) != 1 or not isinstance(body.children[0], nodes.paragraph): + return + para = body.children[0] + if any(isinstance(c, addnodes.pending_xref) for c in para.children): + return # already cross-referenced + text = para.astext().strip() + if not text: + return + para.clear() + para.extend(_annotation_to_nodes(text, env)) + + +def process_docstring( + app: Sphinx, + what: str, + name: str, + obj: t.Any, + options: Options, + lines: list[str], +) -> None: + """Convert NumPy-style docstring sections to RST field lists. + + Hooks ``autodoc-process-docstring`` to replace ``sphinx.ext.napoleon`` + for NumPy-style docstrings. Delegates to + :func:`sphinx_autodoc_typehints_gp._numpy_docstring.process_numpy_docstring`. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + what : str + The type of the object being documented. + name : str + The fully qualified name of the object. + obj : t.Any + The object being documented. + options : Options + The options given to the autodoc directive. + lines : list[str] + The docstring lines, modified in place. + + Examples + -------- + >>> process_docstring # doctest: +ELLIPSIS + + """ + from sphinx_autodoc_typehints_gp._numpy_docstring import process_numpy_docstring + + lines[:] = process_numpy_docstring(lines) + + +def record_typehints( + app: Sphinx, + objtype: str, + name: str, + obj: t.Any, + options: Options, + args: str, + retann: str, +) -> None: + """Record type hints to the Sphinx environment. + + Hooks ``autodoc-process-signature`` and extracts annotations directly + from ``__annotations__``, resolving them statically via AST. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + objtype : str + The type of the object being documented. + name : str + The fully qualified name of the object. + obj : t.Any + The object being documented. + options : Options + The options given to the autodoc directive. + args : str + The arguments of the object. + retann : str + The return annotation of the object. + + Examples + -------- + >>> record_typehints # doctest: +ELLIPSIS + + """ + try: + annotations = getattr(obj, "__annotations__", None) + if not annotations: + return + + module_name = getattr(obj, "__module__", None) + if not module_name: + return + + aliases = get_module_imports(module_name) + + doc_annotations = app.env.current_document.autodoc_annotations.setdefault( + name, {} + ) + + from sphinx.util.typing import stringify_annotation + + for arg_name, annotation in annotations.items(): + if isinstance(annotation, str): + resolved = resolve_annotation_string(annotation, module_name, aliases) + else: + resolved = stringify_annotation(annotation, "smart") + doc_annotations[arg_name] = resolved + + except (TypeError, ValueError, AttributeError): + logger.debug("failed to record typehints for %s", name, exc_info=True) + + +def _modify_field_list( + node: nodes.field_list, + annotations: dict[str, str], + obj_type: str, + env: BuildEnvironment, +) -> None: + """Modify a docutils field list to include cross-referenced type annotations. + + Inserts missing ``:type name:`` and ``:rtype:`` fields with + ``pending_xref`` content. Fields that already exist (e.g. from Napoleon) + are left untouched here; ``_enhance_existing_type_field`` handles those. + + Parameters + ---------- + node : nodes.field_list + The field list node to modify. + annotations : dict[str, str] + The resolved type annotations. + obj_type : str + The type of the object (used to suppress ``None`` rtype for classes). + env : BuildEnvironment + Sphinx build environment for cross-reference resolution. + """ + arguments: dict[str, dict[str, bool]] = {} + fields = t.cast("t.Iterable[nodes.field]", node) + for field in fields: + field_name = field[0].astext() + parts = field_name.split() + if parts[0] == "param": + if len(parts) == 2: + arg = arguments.setdefault(parts[1], {}) + arg["param"] = True + elif len(parts) > 2: + name = " ".join(parts[2:]) + arg = arguments.setdefault(name, {}) + arg["param"] = True + arg["type"] = True + elif parts[0] == "type": + name = " ".join(parts[1:]) + arg = arguments.setdefault(name, {}) + arg["type"] = True + elif parts[0] == "rtype": + arguments["return"] = {"type": True} + + for name, annotation in annotations.items(): + if name == "return": + continue + + if "*" + name in arguments: + name = "*" + name + elif "**" + name in arguments: + name = "**" + name + + arg = arguments.get(name, {}) + + if not arg.get("type"): + field = nodes.field() + field += nodes.field_name("", "type " + name) + field += nodes.field_body( + "", + nodes.paragraph("", "", *_annotation_to_nodes(annotation, env)), + ) + node += field + if not arg.get("param"): + field = nodes.field() + field += nodes.field_name("", "param " + name) + field += nodes.field_body("", nodes.paragraph("", "")) + node += field + + if "return" in annotations and "return" not in arguments: + annotation = annotations["return"] + if annotation == "None" and obj_type == "class": + pass + else: + field = nodes.field() + field += nodes.field_name("", "rtype") + field += nodes.field_body( + "", + nodes.paragraph("", "", *_annotation_to_nodes(annotation, env)), + ) + node += field + + +def merge_typehints( + app: Sphinx, domain: str, obj_type: str, contentnode: Element +) -> None: + """Merge recorded type hints into the doctree field lists. + + Hooks ``object-description-transform`` at priority 499 — before + Sphinx's built-in ``_merge_typehints`` at 500. By the time Sphinx's + handler runs our cross-referenced fields already exist, so it skips + its plain-text duplicates. + + For each ``:type:`` / ``:rtype:`` field that Napoleon has already + inserted as plain text, ``_enhance_existing_type_field`` replaces the + text content with ``pending_xref`` nodes in-place. + + Only runs when ``autodoc_typehints`` is ``"description"`` or ``"both"``. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + domain : str + The Sphinx domain (only ``'py'`` is handled). + obj_type : str + The type of the object. + contentnode : Element + The ``desc_content`` node of the object description. + + Examples + -------- + >>> merge_typehints # doctest: +ELLIPSIS + + """ + autodoc_typehints = getattr(app.config, "autodoc_typehints", None) + if autodoc_typehints not in {"both", "description"}: + return + if domain != "py": + return + + try: + signature = t.cast("addnodes.desc_signature", contentnode.parent[0]) + if signature["module"]: + fullname = f"{signature['module']}.{signature['fullname']}" + else: + fullname = signature["fullname"] + except KeyError: + return + + annotations = app.env.current_document.autodoc_annotations + if not annotations.get(fullname): + return + + env = app.env + + field_lists = [n for n in contentnode if isinstance(n, nodes.field_list)] + if not field_lists: + field_list = nodes.field_list() + desc = [n for n in contentnode if isinstance(n, addnodes.desc)] + if desc: + index = contentnode.index(desc[0]) + contentnode.insert(index, [field_list]) + else: + contentnode += field_list + field_lists.append(field_list) + + for field_list in field_lists: + # Enhance existing plain-text type fields (e.g. from Napoleon) + for field in list(field_list.children): + if not isinstance(field, nodes.field): + continue + field_name_text = field[0].astext() + parts = field_name_text.split() + if parts[0] in {"type", "rtype"}: + _enhance_existing_type_field(field, env) + + # Insert missing type/rtype fields with cross-referenced content + _modify_field_list(field_list, annotations[fullname], obj_type, env) + + +def _clear_caches(app: Sphinx) -> None: + """Clear module-level caches at the start of each Sphinx build. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + + Examples + -------- + >>> callable(_clear_caches) + True + """ + _MODULE_IMPORTS.clear() + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Set up the Sphinx extension. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + + Returns + ------- + dict[str, t.Any] + The extension metadata. + + Examples + -------- + >>> setup # doctest: +ELLIPSIS + + """ + app.connect("builder-inited", _clear_caches) + try: + app.connect("autodoc-process-docstring", process_docstring) + app.connect("autodoc-process-signature", record_typehints) + except ExtensionError as exc: + if "Unknown event name" not in str(exc): + raise + # Priority 499: run before Sphinx's _merge_typehints at 500 so our + # cross-referenced fields are seen first and the plain-text duplicates + # are skipped by the built-in handler. + app.connect("object-description-transform", merge_typehints, priority=499) + return { + "version": "0.0.1a7", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/py.typed b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/py.typed similarity index 100% rename from packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/py.typed rename to packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/py.typed diff --git a/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/rendering.py b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/rendering.py new file mode 100644 index 00000000..3b7f73d8 --- /dev/null +++ b/packages/sphinx-autodoc-typehints-gp/src/sphinx_autodoc_typehints_gp/rendering.py @@ -0,0 +1,668 @@ +"""Shared annotation rendering helpers for gp-sphinx extensions. + +Examples +-------- +>>> normalize_annotation_text("list[str] | None", strip_none=True) +'list[str]' +>>> normalize_annotation_text("Literal['open', 'closed']", collapse_literal=True) +"'open', 'closed'" +>>> normalize_type_collection_text((bool, str)) +'bool | str' +""" + +from __future__ import annotations + +import inspect +import re +import typing as t +from dataclasses import dataclass + +from docutils import nodes +from sphinx import addnodes +from sphinx.util.typing import stringify_annotation as sphinx_stringify_annotation + +from sphinx_autodoc_typehints_gp.extension import ( + _annotation_to_nodes, + get_module_imports, + resolve_annotation_string, +) + +if t.TYPE_CHECKING: + from docutils.nodes import Node + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + +_LITERAL_DISPLAY_PATTERN = re.compile( + r"""^(?:'[^']*'|"[^"]*"|-?\d+(?:\.\d+)?|True|False|None)$""" +) + + +@dataclass(frozen=True, slots=True) +class AnnotationDisplay: + """Normalized annotation display metadata for UI and table renderers. + + Examples + -------- + >>> display = AnnotationDisplay( + ... text="'open', 'closed'", + ... is_literal_enum=True, + ... literal_members=("'open'", "'closed'"), + ... ) + >>> display.is_literal_enum + True + """ + + text: str + is_literal_enum: bool + literal_members: tuple[str, ...] = () + + +def _downgrade_unresolvable_xrefs(children: list[Node]) -> list[Node]: + """Replace known-nonresolvable type xrefs with safe inline literals. + + Parameters + ---------- + children : list[Node] + Annotation nodes returned by Sphinx's private annotation parser. + + Returns + ------- + list[Node] + Nodes safe to insert into arbitrary paragraphs and table cells. + + Examples + -------- + >>> xref = addnodes.pending_xref("", nodes.Text("None"), reftarget="None") + >>> _downgrade_unresolvable_xrefs([xref])[0].astext() + 'None' + """ + result: list[Node] = [] + for child in children: + if isinstance(child, addnodes.pending_xref) and child.get("reftarget") in { + "None", + "NoneType", + }: + result.append(nodes.literal("", child.astext())) + continue + result.append(child) + return result + + +def _strip_optional_none(text: str) -> str: + """Return *text* without a trailing ``| None`` union member. + + Parameters + ---------- + text : str + Normalized annotation text. + + Returns + ------- + str + The annotation text with ``None`` removed from a pipe union. + + Examples + -------- + >>> _strip_optional_none("str | None") + 'str' + >>> _strip_optional_none("None") + 'None' + """ + parts = [part.strip() for part in text.split("|")] + stripped = [part for part in parts if part != "None"] + if not stripped: + return text + return " | ".join(stripped) + + +def _split_annotation_display(text: str) -> tuple[str, ...]: + """Split normalized display text into candidate literal members. + + Parameters + ---------- + text : str + Normalized display text. + + Returns + ------- + tuple[str, ...] + Candidate members split on ``|`` and ``,``. + + Examples + -------- + >>> _split_annotation_display("'open', 'closed'") + ("'open'", "'closed'") + >>> _split_annotation_display("str | None") + ('str', 'None') + """ + members: list[str] = [] + for pipe_part in text.split("|"): + for member in pipe_part.split(","): + stripped = member.strip() + if stripped: + members.append(stripped) + return tuple(members) + + +def normalize_annotation_text( + annotation: t.Any, + *, + strip_none: bool = False, + collapse_literal: bool = False, + module_name: str | None = None, + aliases: dict[str, str] | None = None, + qualify_unresolved: bool = False, +) -> str: + """Return deterministic plain-text annotation content. + + Parameters + ---------- + annotation : Any + Annotation object or raw string. + strip_none : bool + When ``True``, drop ``None`` from ``X | None`` unions. + collapse_literal : bool + When ``True``, collapse ``Literal[...]`` to its member text. + module_name : str | None + Module context used when resolving forward-reference strings. + aliases : dict[str, str] | None + Explicit import or name aliases used to qualify string annotations. + qualify_unresolved : bool + When ``True``, unqualified non-builtin names are resolved relative to + ``module_name``. + + Returns + ------- + str + Stable plain-text annotation. + + Examples + -------- + >>> normalize_annotation_text(int) + 'int' + >>> normalize_annotation_text( + ... "Session", + ... module_name="libtmux.session", + ... qualify_unresolved=True, + ... ) + 'libtmux.session.Session' + >>> normalize_annotation_text( + ... "Literal['open', 'closed']", + ... collapse_literal=True, + ... ) + "'open', 'closed'" + """ + if annotation is inspect.Parameter.empty: + return "" + + if isinstance(annotation, str): + text = annotation + else: + try: + text = sphinx_stringify_annotation(annotation, mode="smart") + except Exception: + name = getattr(annotation, "__name__", None) + text = str(name) if isinstance(name, str) else str(annotation) + + text = text.replace("typing.", "").replace("collections.abc.", "") + + if module_name is not None and text: + alias_map = aliases if aliases is not None else get_module_imports(module_name) + if alias_map or qualify_unresolved: + text = resolve_annotation_string( + text, + module_name, + alias_map, + qualify_unresolved=qualify_unresolved, + ).replace("~", "") + + if strip_none and text: + text = _strip_optional_none(text) + if collapse_literal and text: + text = re.sub( + r"(?:t\.)?Literal\[([^\]]+)\]", + lambda match: match.group(1), + text, + ) + return text + + +def normalize_type_collection_text( + types: object, + *, + default: object = inspect.Parameter.empty, +) -> str: + """Return a readable type expression for Sphinx config-like metadata. + + Parameters + ---------- + types : object + Config-style ``types`` value from ``app.add_config_value()``. + default : object + Config default used when no explicit ``types`` are present. + + Returns + ------- + str + Human-readable type expression. + + Examples + -------- + >>> normalize_type_collection_text((bool, str)) + 'bool | str' + >>> normalize_type_collection_text((), default=None) + 'None' + """ + if isinstance(types, (list, tuple, set, frozenset)) and types: + names = sorted( + "None" + if getattr(item, "__name__", "") == "NoneType" + else normalize_annotation_text(item) + for item in t.cast("t.Iterable[t.Any]", types) + ) + return " | ".join(names) + if types: + return normalize_annotation_text(types) + if default is None: + return "None" + if default is inspect.Parameter.empty: + return "" + return normalize_annotation_text(type(default)) + + +def classify_annotation_display( + annotation: t.Any, + *, + strip_none: bool = False, + collapse_literal: bool = True, + module_name: str | None = None, + aliases: dict[str, str] | None = None, + qualify_unresolved: bool = False, +) -> AnnotationDisplay: + """Return normalized annotation text plus enum-display metadata. + + Parameters + ---------- + annotation : Any + Annotation object or raw string. + strip_none : bool + When ``True``, drop ``None`` from ``X | None`` unions. + collapse_literal : bool + When ``True``, collapse ``Literal[...]`` to its member text before + determining whether the display is enum-like. + module_name : str | None + Module context used when resolving forward-reference strings. + aliases : dict[str, str] | None + Explicit alias mapping used to qualify annotation names. + qualify_unresolved : bool + When ``True``, unqualified non-builtin names are resolved relative to + ``module_name``. + + Returns + ------- + AnnotationDisplay + Normalized text plus literal-enum classification metadata. + + Examples + -------- + >>> display = classify_annotation_display("Literal['open', 'closed']") + >>> display.text + "'open', 'closed'" + >>> display.literal_members + ("'open'", "'closed'") + >>> classify_annotation_display("str | None", strip_none=True).is_literal_enum + False + """ + text = normalize_annotation_text( + annotation, + strip_none=strip_none, + collapse_literal=collapse_literal, + module_name=module_name, + aliases=aliases, + qualify_unresolved=qualify_unresolved, + ) + if not text: + return AnnotationDisplay(text="", is_literal_enum=False) + + members = _split_annotation_display(text) + is_literal_enum = bool(members) and all( + _LITERAL_DISPLAY_PATTERN.fullmatch(member) for member in members + ) + literal_members = members if is_literal_enum else () + return AnnotationDisplay( + text=text, + is_literal_enum=is_literal_enum, + literal_members=literal_members, + ) + + +def render_annotation_nodes( + annotation: t.Any, + env: BuildEnvironment, + *, + strip_none: bool = False, + collapse_literal: bool = False, + module_name: str | None = None, + aliases: dict[str, str] | None = None, + qualify_unresolved: bool = False, +) -> list[Node]: + """Render annotation content into Sphinx/docutils nodes. + + Parameters + ---------- + annotation : Any + Annotation object or raw string. + env : BuildEnvironment + Sphinx build environment used to create ``pending_xref`` nodes. + strip_none : bool + When ``True``, drop ``None`` from ``X | None`` unions. + collapse_literal : bool + When ``True``, collapse ``Literal[...]`` to its member text. + module_name : str | None + Module context used when resolving forward-reference strings. + aliases : dict[str, str] | None + Explicit alias mapping used to qualify annotation names. + qualify_unresolved : bool + When ``True``, unqualified non-builtin names are resolved relative to + ``module_name``. + + Returns + ------- + list[Node] + Annotation nodes ready for insertion into a paragraph or table cell. + + Examples + -------- + >>> render_annotation_nodes # doctest: +ELLIPSIS + + """ + text = normalize_annotation_text( + annotation, + strip_none=strip_none, + collapse_literal=collapse_literal, + module_name=module_name, + aliases=aliases, + qualify_unresolved=qualify_unresolved, + ) + if not text: + return [] + return _downgrade_unresolvable_xrefs(_annotation_to_nodes(text, env)) + + +def build_annotation_paragraph( + annotation: t.Any, + env: BuildEnvironment, + *, + strip_none: bool = False, + collapse_literal: bool = False, + module_name: str | None = None, + aliases: dict[str, str] | None = None, + qualify_unresolved: bool = False, +) -> nodes.paragraph: + """Return a paragraph containing rendered annotation nodes. + + Parameters + ---------- + annotation : Any + Annotation object or raw string. + env : BuildEnvironment + Sphinx build environment used to create ``pending_xref`` nodes. + strip_none : bool + When ``True``, drop ``None`` from ``X | None`` unions. + collapse_literal : bool + When ``True``, collapse ``Literal[...]`` to its member text. + module_name : str | None + Module context used when resolving forward-reference strings. + aliases : dict[str, str] | None + Explicit alias mapping used to qualify annotation names. + qualify_unresolved : bool + When ``True``, unqualified non-builtin names are resolved relative to + ``module_name``. + + Returns + ------- + nodes.paragraph + Paragraph containing the rendered annotation nodes. + + Examples + -------- + >>> build_annotation_paragraph # doctest: +ELLIPSIS + + """ + paragraph = nodes.paragraph() + for child in render_annotation_nodes( + annotation, + env, + strip_none=strip_none, + collapse_literal=collapse_literal, + module_name=module_name, + aliases=aliases, + qualify_unresolved=qualify_unresolved, + ): + paragraph += child + return paragraph + + +def build_annotation_display_paragraph( + annotation: t.Any, + env: BuildEnvironment | None, + *, + strip_none: bool = False, + collapse_literal: bool = True, + module_name: str | None = None, + aliases: dict[str, str] | None = None, + qualify_unresolved: bool = False, +) -> nodes.paragraph: + """Return a paragraph using the shared display policy for annotations. + + Literal-only union displays collapse to a literal ``enum`` marker so + table cells and fact rows stay compact. All other annotations render + through the standard shared annotation-node pipeline. + + Parameters + ---------- + annotation : Any + Annotation object or raw string. + env : BuildEnvironment | None + Sphinx build environment used to create ``pending_xref`` nodes. When + omitted, the helper falls back to a plain literal rendering for + non-enum displays. + strip_none : bool + When ``True``, drop ``None`` from ``X | None`` unions. + collapse_literal : bool + When ``True``, collapse ``Literal[...]`` to its member text before + applying the display policy. + module_name : str | None + Module context used when resolving forward-reference strings. + aliases : dict[str, str] | None + Explicit alias mapping used to qualify annotation names. + qualify_unresolved : bool + When ``True``, unqualified non-builtin names are resolved relative to + ``module_name``. + + Returns + ------- + nodes.paragraph + Paragraph containing either the compact enum marker or the rendered + annotation nodes. + + Examples + -------- + >>> paragraph = build_annotation_display_paragraph( + ... "Literal['open', 'closed']", + ... None, + ... ) + >>> paragraph.astext() + 'enum' + >>> build_annotation_display_paragraph("str", None).astext() + 'str' + """ + display = classify_annotation_display( + annotation, + strip_none=strip_none, + collapse_literal=collapse_literal, + module_name=module_name, + aliases=aliases, + qualify_unresolved=qualify_unresolved, + ) + if display.is_literal_enum: + paragraph = nodes.paragraph() + paragraph += nodes.literal("", "enum") + return paragraph + if env is None: + paragraph = nodes.paragraph() + if display.text: + paragraph += nodes.literal("", display.text) + return paragraph + + return build_annotation_paragraph( + display.text, + env, + strip_none=False, + collapse_literal=False, + module_name=module_name, + aliases=aliases, + qualify_unresolved=qualify_unresolved, + ) + + +def build_resolved_annotation_paragraph( + annotation: t.Any, + app: Sphinx, + docname: str, + *, + strip_none: bool = False, + collapse_literal: bool = False, + module_name: str | None = None, + aliases: dict[str, str] | None = None, + qualify_unresolved: bool = False, +) -> nodes.paragraph: + """Return a paragraph with any late-added annotation xrefs resolved. + + Parameters + ---------- + annotation : Any + Annotation object or raw string. + app : Sphinx + Sphinx application used for environment and builder access. + docname : str + Current document name used for reference resolution. + strip_none : bool + When ``True``, drop ``None`` from ``X | None`` unions. + collapse_literal : bool + When ``True``, collapse ``Literal[...]`` to its member text. + module_name : str | None + Module context used when resolving forward-reference strings. + aliases : dict[str, str] | None + Explicit alias mapping used to qualify annotation names. + qualify_unresolved : bool + When ``True``, unqualified non-builtin names are resolved relative to + ``module_name``. + + Returns + ------- + nodes.paragraph + Paragraph containing resolved annotation nodes. + + Examples + -------- + >>> build_resolved_annotation_paragraph # doctest: +ELLIPSIS + + """ + from sphinx.util.docutils import new_document + + paragraph = build_annotation_paragraph( + annotation, + app.env, + strip_none=strip_none, + collapse_literal=collapse_literal, + module_name=module_name, + aliases=aliases, + qualify_unresolved=qualify_unresolved, + ) + if not any( + isinstance(child, addnodes.pending_xref) for child in paragraph.children + ): + return paragraph + + temp_doc = new_document("") + temp_para = paragraph.deepcopy() + temp_doc += temp_para + app.env.resolve_references(temp_doc, docname, app.builder) + return t.cast(nodes.paragraph, temp_doc.children[0]) + + +def build_resolved_annotation_display_paragraph( + annotation: t.Any, + app: Sphinx, + docname: str, + *, + strip_none: bool = False, + collapse_literal: bool = True, + module_name: str | None = None, + aliases: dict[str, str] | None = None, + qualify_unresolved: bool = False, +) -> nodes.paragraph: + """Return a resolved paragraph using the shared annotation display policy. + + Parameters + ---------- + annotation : Any + Annotation object or raw string. + app : Sphinx + Sphinx application used for environment and builder access. + docname : str + Current document name used for reference resolution. + strip_none : bool + When ``True``, drop ``None`` from ``X | None`` unions. + collapse_literal : bool + When ``True``, collapse ``Literal[...]`` to its member text before + applying the display policy. + module_name : str | None + Module context used when resolving forward-reference strings. + aliases : dict[str, str] | None + Explicit alias mapping used to qualify annotation names. + qualify_unresolved : bool + When ``True``, unqualified non-builtin names are resolved relative to + ``module_name``. + + Returns + ------- + nodes.paragraph + Paragraph containing either the compact enum marker or resolved + annotation nodes. + + Examples + -------- + >>> app = t.cast("Sphinx", object()) + >>> paragraph = build_resolved_annotation_display_paragraph( + ... "Literal['open', 'closed']", + ... app, + ... "index", + ... ) + >>> paragraph.astext() + 'enum' + """ + display = classify_annotation_display( + annotation, + strip_none=strip_none, + collapse_literal=collapse_literal, + module_name=module_name, + aliases=aliases, + qualify_unresolved=qualify_unresolved, + ) + if display.is_literal_enum: + paragraph = nodes.paragraph() + paragraph += nodes.literal("", "enum") + return paragraph + + return build_resolved_annotation_paragraph( + display.text, + app, + docname, + strip_none=False, + collapse_literal=False, + module_name=module_name, + aliases=aliases, + qualify_unresolved=qualify_unresolved, + ) diff --git a/packages/sphinx-fonts/pyproject.toml b/packages/sphinx-fonts/pyproject.toml index 8db78e6c..3e5c8d08 100644 --- a/packages/sphinx-fonts/pyproject.toml +++ b/packages/sphinx-fonts/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] license = { text = "MIT" } classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Framework :: Sphinx", "Framework :: Sphinx :: Extension", @@ -26,7 +26,7 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "fonts", "fontsource", "self-hosted"] dependencies = [ - "sphinx", + "sphinx>=8.1", ] [project.urls] diff --git a/packages/sphinx-gptheme/README.md b/packages/sphinx-gp-theme/README.md similarity index 85% rename from packages/sphinx-gptheme/README.md rename to packages/sphinx-gp-theme/README.md index 51239397..44b477be 100644 --- a/packages/sphinx-gptheme/README.md +++ b/packages/sphinx-gp-theme/README.md @@ -1,4 +1,4 @@ -# sphinx-gptheme +# sphinx-gp-theme Furo child theme for [git-pull](https://github.com/git-pull) project documentation. @@ -9,7 +9,7 @@ JS, and the git-pull project sidebar. ## Install ```console -$ pip install sphinx-gptheme +$ pip install sphinx-gp-theme ``` ## Usage @@ -17,7 +17,7 @@ $ pip install sphinx-gptheme In your `docs/conf.py`: ```python -html_theme = "sphinx-gptheme" +html_theme = "sphinx-gp-theme" ``` Or use with [gp-sphinx](https://gp-sphinx.git-pull.com) which sets the theme automatically. diff --git a/packages/sphinx-gptheme/pyproject.toml b/packages/sphinx-gp-theme/pyproject.toml similarity index 87% rename from packages/sphinx-gptheme/pyproject.toml rename to packages/sphinx-gp-theme/pyproject.toml index 3471cae3..18a19c1b 100644 --- a/packages/sphinx-gptheme/pyproject.toml +++ b/packages/sphinx-gp-theme/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "sphinx-gptheme" +name = "sphinx-gp-theme" version = "0.0.1a7" description = "Furo child theme for git-pull project documentation" requires-python = ">=3.10,<4.0" @@ -8,7 +8,7 @@ authors = [ ] license = { text = "MIT" } classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Framework :: Sphinx", "Framework :: Sphinx :: Theme", @@ -26,11 +26,12 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "theme", "furo", "documentation"] dependencies = [ + "sphinx>=8.1", "furo", ] [project.entry-points."sphinx.html_themes"] -"sphinx-gptheme" = "sphinx_gptheme" +"sphinx-gp-theme" = "sphinx_gp_theme" [project.urls] Repository = "https://github.com/git-pull/gp-sphinx" @@ -40,4 +41,4 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/sphinx_gptheme"] +packages = ["src/sphinx_gp_theme"] diff --git a/packages/sphinx-gptheme/src/sphinx_gptheme/__init__.py b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py similarity index 89% rename from packages/sphinx-gptheme/src/sphinx_gptheme/__init__.py rename to packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py index a1014d5d..8a59aaeb 100644 --- a/packages/sphinx-gptheme/src/sphinx_gptheme/__init__.py +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/__init__.py @@ -1,4 +1,4 @@ -"""sphinx-gptheme — Furo child theme for git-pull projects. +"""sphinx-gp-theme — Furo child theme for git-pull projects. Provides a shared visual identity for git-pull project documentation by inheriting from Furo and bundling common templates, CSS, and JS. @@ -27,7 +27,7 @@ def get_theme_path() -> pathlib.Path: - """Return the path to the sphinx-gptheme theme directory. + """Return the path to the sphinx-gp-theme theme directory. Returns ------- @@ -70,11 +70,11 @@ def setup(app: Sphinx) -> dict[str, bool | str]: >>> fake = FakeApp() >>> metadata = setup(fake) # type: ignore[arg-type] >>> fake.calls[0][0] - 'sphinx-gptheme' + 'sphinx-gp-theme' >>> metadata["parallel_read_safe"] True """ - app.add_html_theme("sphinx-gptheme", get_theme_path()) + app.add_html_theme("sphinx-gp-theme", get_theme_path()) return { "parallel_read_safe": True, "parallel_write_safe": True, diff --git a/packages/sphinx-gptheme/src/sphinx_gptheme/py.typed b/packages/sphinx-gp-theme/src/sphinx_gp_theme/py.typed similarity index 100% rename from packages/sphinx-gptheme/src/sphinx_gptheme/py.typed rename to packages/sphinx-gp-theme/src/sphinx_gp_theme/py.typed diff --git a/packages/sphinx-gptheme/src/sphinx_gptheme/theme/page.html b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/page.html similarity index 100% rename from packages/sphinx-gptheme/src/sphinx_gptheme/theme/page.html rename to packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/page.html diff --git a/packages/sphinx-gptheme/src/sphinx_gptheme/theme/sidebar/brand.html b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/sidebar/brand.html similarity index 100% rename from packages/sphinx-gptheme/src/sphinx_gptheme/theme/sidebar/brand.html rename to packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/sidebar/brand.html diff --git a/packages/sphinx-gptheme/src/sphinx_gptheme/theme/sidebar/projects.html b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/sidebar/projects.html similarity index 100% rename from packages/sphinx-gptheme/src/sphinx_gptheme/theme/sidebar/projects.html rename to packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/sidebar/projects.html diff --git a/packages/sphinx-gptheme/src/sphinx_gptheme/theme/static/css/argparse-highlight.css b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/argparse-highlight.css similarity index 99% rename from packages/sphinx-gptheme/src/sphinx_gptheme/theme/static/css/argparse-highlight.css rename to packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/argparse-highlight.css index f232c71c..8ec4e668 100644 --- a/packages/sphinx-gptheme/src/sphinx_gptheme/theme/static/css/argparse-highlight.css +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/argparse-highlight.css @@ -176,7 +176,7 @@ /* * These styles apply to the argparse directive output which uses custom - * nodes rendered by sphinx_argparse_neo. The directive adds highlight spans + * nodes rendered by sphinx_autodoc_argparse. The directive adds highlight spans * directly to the HTML output. */ diff --git a/packages/sphinx-gptheme/src/sphinx_gptheme/theme/static/css/custom.css b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/custom.css similarity index 91% rename from packages/sphinx-gptheme/src/sphinx_gptheme/theme/static/css/custom.css rename to packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/custom.css index d1410e45..30f6749b 100644 --- a/packages/sphinx-gptheme/src/sphinx_gptheme/theme/static/css/custom.css +++ b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/css/custom.css @@ -250,15 +250,15 @@ img[src*="codecov.io"] { * ergonomics that downstream docs used before gp-sphinx. * ────────────────────────────────────────────────────────── */ :root { - --badge-safety-readonly-bg: #1f7a3f; - --badge-safety-readonly-border: #2a8d4d; - --badge-safety-readonly-text: #f3fff7; - --badge-safety-mutating-bg: #b96a1a; - --badge-safety-mutating-border: #cf7a23; - --badge-safety-mutating-text: #fff8ef; - --badge-safety-destructive-bg: #b4232c; - --badge-safety-destructive-border: #cb3640; - --badge-safety-destructive-text: #fff5f5; + --gp-sphinx-fastmcp-safety-readonly-bg: #1f7a3f; + --gp-sphinx-fastmcp-safety-readonly-border: #2a8d4d; + --gp-sphinx-fastmcp-safety-readonly-text: #f3fff7; + --gp-sphinx-fastmcp-safety-mutating-bg: #b96a1a; + --gp-sphinx-fastmcp-safety-mutating-border: #cf7a23; + --gp-sphinx-fastmcp-safety-mutating-text: #fff8ef; + --gp-sphinx-fastmcp-safety-destructive-bg: #b4232c; + --gp-sphinx-fastmcp-safety-destructive-border: #cb3640; + --gp-sphinx-fastmcp-safety-destructive-text: #fff5f5; } h2:has(> .sd-badge[role="note"][aria-label^="Safety tier:"]), @@ -285,21 +285,21 @@ h4:has(> .sd-badge[role="note"][aria-label^="Safety tier:"]) { } .sd-badge.sd-bg-success[role="note"][aria-label^="Safety tier:"] { - background-color: var(--badge-safety-readonly-bg) !important; - color: var(--badge-safety-readonly-text) !important; - border-color: var(--badge-safety-readonly-border); + background-color: var(--gp-sphinx-fastmcp-safety-readonly-bg) !important; + color: var(--gp-sphinx-fastmcp-safety-readonly-text) !important; + border-color: var(--gp-sphinx-fastmcp-safety-readonly-border); } .sd-badge.sd-bg-warning[role="note"][aria-label^="Safety tier:"] { - background-color: var(--badge-safety-mutating-bg) !important; - color: var(--badge-safety-mutating-text) !important; - border-color: var(--badge-safety-mutating-border); + background-color: var(--gp-sphinx-fastmcp-safety-mutating-bg) !important; + color: var(--gp-sphinx-fastmcp-safety-mutating-text) !important; + border-color: var(--gp-sphinx-fastmcp-safety-mutating-border); } .sd-badge.sd-bg-danger[role="note"][aria-label^="Safety tier:"] { - background-color: var(--badge-safety-destructive-bg) !important; - color: var(--badge-safety-destructive-text) !important; - border-color: var(--badge-safety-destructive-border); + background-color: var(--gp-sphinx-fastmcp-safety-destructive-bg) !important; + color: var(--gp-sphinx-fastmcp-safety-destructive-text) !important; + border-color: var(--gp-sphinx-fastmcp-safety-destructive-border); } .sd-badge.sd-bg-success[role="note"][aria-label^="Safety tier:"]::before { diff --git a/packages/sphinx-gptheme/src/sphinx_gptheme/theme/static/js/spa-nav.js b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js similarity index 100% rename from packages/sphinx-gptheme/src/sphinx_gptheme/theme/static/js/spa-nav.js rename to packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/static/js/spa-nav.js diff --git a/packages/sphinx-gptheme/src/sphinx_gptheme/theme/theme.conf b/packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/theme.conf similarity index 100% rename from packages/sphinx-gptheme/src/sphinx_gptheme/theme/theme.conf rename to packages/sphinx-gp-theme/src/sphinx_gp_theme/theme/theme.conf diff --git a/packages/sphinx-ux-autodoc-layout/README.md b/packages/sphinx-ux-autodoc-layout/README.md new file mode 100644 index 00000000..f1e3470f --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/README.md @@ -0,0 +1,51 @@ +# sphinx-ux-autodoc-layout + +Componentized layout for Sphinx autodoc output. It preserves Sphinx's +outer `dl / dt / dd` structure while rebuilding managed object-description +entries into stable `api-*` components. The current shared layout covers +Python API objects, pytest fixtures, Sphinx `confval` entries, docutils +`rst:*` entries, and internal FastMCP `mcp:tool` prototypes used to validate +future consolidation work. + +The extension keeps header composition in a late `doctree-resolved` pass so +badges, source links, and permalinks can be positioned independently without +raw HTML mutation. Large signatures can fold into native multiline signature +markup, and expanded folded signatures show annotations by default via +`api_signature_show_annotations`. + +For shared consumers, the public helper surface now includes: + +- `build_api_card_entry()` for section-card consumers that need the same inner + `api-*` shell without becoming `desc` entries +- `build_api_summary_section()` for summary/index wrappers such as config and + fixture tables + +## Install + +```console +$ pip install sphinx-ux-autodoc-layout +``` + +## Usage + +Standalone Sphinx project: + +```python +extensions = ["sphinx.ext.autodoc", "sphinx_ux_autodoc_layout"] +api_layout_enabled = True +``` + +With `gp-sphinx`: + +```python +conf = merge_sphinx_config( + ..., + extra_extensions=["sphinx_ux_autodoc_layout"], + api_layout_enabled=True, +) +``` + +## Documentation + +See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-ux-autodoc-layout/) +for configuration reference, CSS classes, and live demos. diff --git a/packages/sphinx-ux-autodoc-layout/pyproject.toml b/packages/sphinx-ux-autodoc-layout/pyproject.toml new file mode 100644 index 00000000..6d895928 --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sphinx-ux-autodoc-layout" +version = "0.0.1a7" +description = "Componentized layout for Sphinx autodoc output" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +dependencies = ["sphinx>=8.1"] + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_ux_autodoc_layout"] diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py new file mode 100644 index 00000000..24168cf1 --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py @@ -0,0 +1,208 @@ +"""Componentized layout for Sphinx object-description output. + +Preserves Sphinx's outer ``dl / dt / dd`` structure while rebuilding +managed Sphinx object entries into stable ``gp-sphinx-api-*`` components. + +Examples +-------- +>>> from sphinx_ux_autodoc_layout import setup +>>> callable(setup) +True +""" + +from __future__ import annotations + +import pathlib +import typing as t + +from sphinx import addnodes + +from sphinx_ux_autodoc_layout._cards import build_api_card_entry +from sphinx_ux_autodoc_layout._css import API +from sphinx_ux_autodoc_layout._nodes import ( + api_component, + api_fold, + api_inline_component, + api_permalink, + api_region, + api_sig_fold, + api_slot, + build_api_component, + build_api_inline_component, + build_api_slot, +) +from sphinx_ux_autodoc_layout._render import iter_desc_nodes, parse_generated_markup +from sphinx_ux_autodoc_layout._sections import ( + ApiFactRow, + build_api_facts_section, + build_api_section, + build_api_summary_section, + build_api_table_section, +) +from sphinx_ux_autodoc_layout._slots import inject_signature_slots, is_viewcode_ref +from sphinx_ux_autodoc_layout._transforms import on_doctree_resolved +from sphinx_ux_autodoc_layout._visitors import ( + depart_api_component, + depart_api_fold, + depart_api_permalink, + depart_api_region, + depart_api_sig_fold, + depart_desc_signature_html, + passthrough_depart, + passthrough_visit, + visit_api_component, + visit_api_fold, + visit_api_permalink, + visit_api_region, + visit_api_sig_fold, + visit_desc_signature_html, +) + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +__all__ = [ + "API", + "ApiFactRow", + "api_component", + "api_fold", + "api_inline_component", + "api_permalink", + "api_region", + "api_sig_fold", + "api_slot", + "build_api_card_entry", + "build_api_component", + "build_api_facts_section", + "build_api_inline_component", + "build_api_section", + "build_api_slot", + "build_api_summary_section", + "build_api_table_section", + "inject_signature_slots", + "is_viewcode_ref", + "iter_desc_nodes", + "on_doctree_resolved", + "parse_generated_markup", + "setup", +] + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the extension with Sphinx. + + Parameters + ---------- + app : Sphinx + The Sphinx application object. + + Returns + ------- + dict[str, Any] + Extension metadata. + + Examples + -------- + >>> setup # doctest: +ELLIPSIS + + """ + # Config values + app.add_config_value( + "api_layout_enabled", default=False, rebuild="env", types=(bool,) + ) + app.add_config_value( + "api_fold_parameters", default=True, rebuild="env", types=(bool,) + ) + app.add_config_value( + "api_collapsed_threshold", default=10, rebuild="env", types=(int,) + ) + app.add_config_value( + "api_signature_show_annotations", default=True, rebuild="env", types=(bool,) + ) + + # Custom nodes with HTML visitors + passthrough for other builders + _pt = (passthrough_visit, passthrough_depart) + app.add_node( + api_region, + html=(visit_api_region, depart_api_region), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + app.add_node( + api_fold, + html=(visit_api_fold, depart_api_fold), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + app.add_node( + api_sig_fold, + html=(visit_api_sig_fold, depart_api_sig_fold), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + app.add_node( + api_component, + html=(visit_api_component, depart_api_component), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + app.add_node( + api_inline_component, + html=(visit_api_component, depart_api_component), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + app.add_node( + api_slot, + html=_pt, + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + app.add_node( + api_permalink, + html=(visit_api_permalink, depart_api_permalink), + latex=_pt, + text=_pt, + man=_pt, + texinfo=_pt, + ) + + # Managed desc signatures keep Sphinx's outer ``dt`` handling but skip the + # stock permalink injection so layout can place ``gp-sphinx-api-link`` explicitly. + app.add_node( + addnodes.desc_signature, + override=True, + html=(visit_desc_signature_html, depart_desc_signature_html), + ) + + # Transform: doctree-resolved at priority 600 (after api-style at 500) + app.connect("doctree-resolved", on_doctree_resolved, priority=600) + + # Static assets + _static_dir = str(pathlib.Path(__file__).parent / "_static") + + def _add_static_path(app: Sphinx) -> None: + if _static_dir not in app.config.html_static_path: + app.config.html_static_path.append(_static_dir) + + app.connect("builder-inited", _add_static_path) + app.add_css_file("css/layout.css") + app.add_js_file("js/layout.js", loading_method="defer") + + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_cards.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_cards.py new file mode 100644 index 00000000..1b758d4d --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_cards.py @@ -0,0 +1,98 @@ +"""Shared card-shell builders for non-``desc`` API entries. + +Examples +-------- +>>> from docutils import nodes +>>> entry = build_api_card_entry( +... profile_class="gp-sphinx-api-profile--demo", +... signature_children=(nodes.literal("", "demo"),), +... ) +>>> entry["classes"][:2] +['gp-sphinx-api-entry', 'gp-sphinx-api-card-entry'] +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + +from sphinx_ux_autodoc_layout._css import API +from sphinx_ux_autodoc_layout._nodes import ( + api_permalink, + build_api_component, + build_api_inline_component, +) +from sphinx_ux_badges import SAB + + +def build_api_card_entry( + *, + profile_class: str, + signature_children: t.Sequence[nodes.Node], + content_children: t.Sequence[nodes.Node] = (), + badge_group: nodes.Node | None = None, + permalink: api_permalink | None = None, + entry_classes: tuple[str, ...] = (), + signature_classes: tuple[str, ...] = (), + content_classes: tuple[str, ...] = (), +) -> nodes.Element: + """Build a shared ``gp-sphinx-api-*`` card entry for non-``desc`` consumers. + + Parameters + ---------- + profile_class : str + Stable profile class such as ``"gp-sphinx-api-profile--fastmcp-tool"``. + signature_children : Sequence[nodes.Node] + Children placed inside the ``gp-sphinx-api-signature`` wrapper. + content_children : Sequence[nodes.Node] + Children appended to ``gp-sphinx-api-content``. + badge_group : nodes.Node | None + Shared badge group rendered inside ``gp-sphinx-api-badge-container``. + permalink : api_permalink | None + Explicit header permalink placed in ``gp-sphinx-api-layout-left``. + entry_classes : tuple[str, ...] + Extra CSS classes for the outer ``gp-sphinx-api-entry`` wrapper. + signature_classes : tuple[str, ...] + Extra classes for the ``gp-sphinx-api-signature`` wrapper. + content_classes : tuple[str, ...] + Extra classes for the ``gp-sphinx-api-content`` wrapper. + + Returns + ------- + nodes.Element + Shared card entry tree using the stable ``gp-sphinx-api-*`` contract. + """ + entry = build_api_component( + 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, + ) + 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 + + layout += left + layout += right + header += layout + entry += header + + content = build_api_component(API.CONTENT, classes=content_classes) + for child in content_children: + content += child + entry += content + return entry 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 new file mode 100644 index 00000000..80025b17 --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_css.py @@ -0,0 +1,131 @@ +"""CSS class name constants for sphinx_ux_autodoc_layout. + +All constants use the ``gp-sphinx-api-`` namespace for shared layout +primitives (cards, regions, sections, folds, signatures). Every domain +package consuming the layout transforms emits these classes, so the +theme can style them once. + +Examples +-------- +>>> API.CONTAINER +'gp-sphinx-api-container' + +>>> API.HEADER +'gp-sphinx-api-header' + +>>> API.SIGNATURE_TOGGLE +'gp-sphinx-api-signature-toggle' + +>>> API.PROFILE_PREFIX +'gp-sphinx-api-profile' +""" + +from __future__ import annotations + + +class API: + """CSS class name constants (``gp-sphinx-api-`` namespace).""" + + PREFIX = "gp-sphinx-api" + + # ── Card / container primitives ────────────────────── + CONTAINER = "gp-sphinx-api-container" + CARD_SHELL = "gp-sphinx-api-card-shell" + CARD_ENTRY = "gp-sphinx-api-card-entry" + ENTRY = "gp-sphinx-api-entry" + + # ── Structural elements inside a card ──────────────── + HEADER = "gp-sphinx-api-header" + CONTENT = "gp-sphinx-api-content" + LAYOUT = "gp-sphinx-api-layout" + LAYOUT_LEFT = "gp-sphinx-api-layout-left" + LAYOUT_RIGHT = "gp-sphinx-api-layout-right" + SIGNATURE = "gp-sphinx-api-signature" + LINK = "gp-sphinx-api-link" + BADGE_CONTAINER = "gp-sphinx-api-badge-container" + SOURCE_LINK = "gp-sphinx-api-source-link" + + # ── Signature expand/collapse (long signatures) ────── + SIGNATURE_TOGGLE = "gp-sphinx-api-signature-toggle" + SIGNATURE_PREVIEW = "gp-sphinx-api-signature-preview" + SIGNATURE_EXPANDED = "gp-sphinx-api-signature-expanded" + SIG_TOGGLE = "gp-sphinx-api-sig-toggle" + SIG_PREVIEW = "gp-sphinx-api-sig-preview" + SIG_EXPANDED = "gp-sphinx-api-sig-expanded" + SIG_COLLAPSE = "gp-sphinx-api-sig-collapse" + + # ── Content sections (closed enum) ─────────────────── + DESCRIPTION = "gp-sphinx-api-description" + FACTS = "gp-sphinx-api-facts" + FACTS_LIST = "gp-sphinx-api-facts-list" + SUMMARY = "gp-sphinx-api-summary" + PARAMETERS = "gp-sphinx-api-parameters" + OPTIONS = "gp-sphinx-api-options" + FOOTER = "gp-sphinx-api-footer" + + # ── Region primitive (BEM block with modifier axis) ── + REGION = "gp-sphinx-api-region" + + # ── Disclosure (parameter folding) ─────────────────── + FOLD = "gp-sphinx-api-fold" + FOLD_SUMMARY = "gp-sphinx-api-fold-summary" + + # ── Slot primitive (generic slot nodes in docutils tree) ── + SLOT = "gp-sphinx-api-slot" + + # ── Dynamic modifier prefixes ──────────────────────── + # Used at call sites as f"{API.PROFILE_PREFIX}--{slug}" etc. + PROFILE_PREFIX = "gp-sphinx-api-profile" + REGION_MODIFIER_PREFIX = "gp-sphinx-api-region" # for f"{PREFIX}--{kind}" + FOLD_MODIFIER_PREFIX = "gp-sphinx-api-fold" # for f"{PREFIX}--{kind}" + SLOT_MODIFIER_PREFIX = "gp-sphinx-api-slot" # for f"{PREFIX}--{name}" + + @staticmethod + def profile(slug: str) -> str: + """Return the profile modifier class for ``slug``. + + Examples + -------- + >>> API.profile("py-function") + 'gp-sphinx-api-profile--py-function' + + >>> API.profile("fastmcp-tool") + 'gp-sphinx-api-profile--fastmcp-tool' + """ + return f"gp-sphinx-api-profile--{slug}" + + @staticmethod + def region_modifier(kind: str) -> str: + """Return the region modifier class for ``kind``. + + Examples + -------- + >>> API.region_modifier("fields") + 'gp-sphinx-api-region--fields' + + >>> API.region_modifier("members") + 'gp-sphinx-api-region--members' + """ + return f"gp-sphinx-api-region--{kind}" + + @staticmethod + def fold_modifier(kind: str) -> str: + """Return the fold modifier class for ``kind``. + + Examples + -------- + >>> API.fold_modifier("parameters") + 'gp-sphinx-api-fold--parameters' + """ + return f"gp-sphinx-api-fold--{kind}" + + @staticmethod + def slot_modifier(name: str) -> str: + """Return the slot modifier class for ``name``. + + Examples + -------- + >>> API.slot_modifier("badges") + 'gp-sphinx-api-slot--badges' + """ + return f"gp-sphinx-api-slot--{name}" diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_nodes.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_nodes.py new file mode 100644 index 00000000..13bb6b9f --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_nodes.py @@ -0,0 +1,286 @@ +"""Custom docutils nodes and builders for autodoc layout components. + +The extension keeps Sphinx's outer ``dl / dt / dd`` structure but +builds an explicit API component tree within those nodes. + +All nodes use the ``gp-sphinx-api-*`` prefix for the stable DOM +contract: structural wrappers (``api_component``, ``api_slot``, +``api_permalink``), disclosure widgets (``api_fold``, ``api_sig_fold``), +and the legacy region wrapper (``api_region``). + +Examples +-------- +>>> from sphinx_ux_autodoc_layout._nodes import ( +... api_component, +... build_api_component, +... api_fold, +... ) +>>> comp = api_component(name="gp-sphinx-api-layout", tag="div") +>>> comp.get("name") +'gp-sphinx-api-layout' + +>>> built = build_api_component("gp-sphinx-api-content", classes=("demo",)) +>>> built.get("classes") +['gp-sphinx-api-content', 'demo'] + +>>> fold = api_fold(kind="parameters", summary="Parameters (5)") +>>> fold.get("summary") +'Parameters (5)' +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + +from sphinx_ux_autodoc_layout._css import API + +APISlotName = t.Literal["badges", "source-link"] +"""Stable slot names used to hand structured header content to layout.""" + + +class api_region(nodes.General, nodes.Element): + """Legacy wrapper for a contiguous ``desc_content`` run. + + Parameters + ---------- + kind : str + One of ``"narrative"``, ``"fields"``, or ``"members"``. + + Examples + -------- + >>> r = api_region(kind="narrative") + >>> isinstance(r, api_region) + True + >>> r.get("kind") + 'narrative' + """ + + +class api_fold(nodes.General, nodes.Element): + """Block disclosure wrapper rendered as ``
/``. + + Parameters + ---------- + kind : str + Fold category, e.g. ``"parameters"``. + summary : str + Text shown in the ```` element. + open : bool + Whether the fold starts expanded. + + Examples + -------- + >>> f = api_fold(kind="parameters", summary="Parameters (3)") + >>> f.get("kind") + 'parameters' + >>> f.get("summary") + 'Parameters (3)' + """ + + +class api_component(nodes.General, nodes.Element): + """Generic API wrapper node with a stable component name. + + Parameters + ---------- + name : str + Stable DOM contract name such as ``"gp-sphinx-api-layout"``. + tag : str + HTML tag to emit. Defaults to ``"div"``. + + Examples + -------- + >>> node = api_component(name="gp-sphinx-api-content", tag="div") + >>> node.get("name") + 'gp-sphinx-api-content' + >>> node.get("tag") + 'div' + """ + + +class api_inline_component(nodes.General, nodes.Inline, nodes.TextElement): + """Inline API wrapper node for text-compatible header components. + + Parameters + ---------- + name : str + Stable DOM contract name such as ``"gp-sphinx-api-source-link"``. + tag : str + HTML tag to emit. Defaults to ``"span"``. + + Examples + -------- + >>> node = api_inline_component(name="gp-sphinx-api-source-link", tag="span") + >>> node.get("name") + 'gp-sphinx-api-source-link' + """ + + +class api_slot(nodes.General, nodes.Element): + """Structural slot marker consumed by the layout transform. + + Parameters + ---------- + slot : APISlotName + Slot name such as ``"badges"`` or ``"source-link"``. + + Examples + -------- + >>> slot = api_slot(slot="badges") + >>> slot.get("slot") + 'badges' + """ + + +class api_permalink(nodes.General, nodes.Element): + """Permalink anchor rendered inside ``gp-sphinx-api-layout-left``. + + Parameters + ---------- + href : str + Fragment link target such as ``"#mod.func"``. + title : str + Link title shown on hover. + + Examples + -------- + >>> link = api_permalink(href="#mod.func", title="Link to this definition") + >>> link.get("href") + '#mod.func' + """ + + +class api_sig_fold(nodes.General, nodes.Element): + """Inline signature disclosure toggle for large parameter lists. + + The preview button lives in the signature row, while the expanded + multiline signature content is rendered in a controlled wrapper + inside ``gp-sphinx-api-signature``. + + Parameters + ---------- + first_param : str + Text of the first parameter shown in collapsed preview. + param_count : int + Total number of parameters. + panel_id : str + DOM id of the controlled expanded signature wrapper. + + Examples + -------- + >>> sf = api_sig_fold(first_param="host", param_count=13, panel_id="sig-panel") + >>> sf.get("first_param") + 'host' + >>> sf.get("param_count") + 13 + >>> sf.get("panel_id") + 'sig-panel' + """ + + +def build_api_component( + name: str, + *, + tag: str = "div", + classes: tuple[str, ...] = (), + html_attrs: dict[str, str] | None = None, +) -> api_component: + """Create an ``api_component`` with stable DOM classes. + + Parameters + ---------- + name : str + Stable DOM contract name such as ``"gp-sphinx-api-layout"``. + tag : str + HTML tag emitted by the visitor. + classes : tuple[str, ...] + Additional compatibility classes. + html_attrs : dict[str, str] | None + Extra HTML attributes for the rendered tag. + + Returns + ------- + api_component + A configured component wrapper. + + Examples + -------- + >>> wrapper = build_api_component("gp-sphinx-api-content", classes=("legacy",)) + >>> wrapper.get("classes") + ['gp-sphinx-api-content', 'legacy'] + """ + component = api_component(name=name, tag=tag) + component["classes"] = [name, *classes] + if html_attrs: + component["html_attrs"] = html_attrs + return component + + +def build_api_inline_component( + name: str, + *, + tag: str = "span", + classes: tuple[str, ...] = (), + html_attrs: dict[str, str] | None = None, +) -> api_inline_component: + """Create an inline API wrapper for text-compatible header content. + + Parameters + ---------- + name : str + Stable DOM contract name such as ``"gp-sphinx-api-source-link"``. + tag : str + HTML tag emitted by the visitor. + classes : tuple[str, ...] + Additional compatibility classes. + html_attrs : dict[str, str] | None + Extra HTML attributes for the rendered tag. + + Returns + ------- + api_inline_component + A configured inline component wrapper. + """ + component = api_inline_component(name=name, tag=tag) + component["classes"] = [name, *classes] + if html_attrs: + component["html_attrs"] = html_attrs + return component + + +def build_api_slot( + slot_name: APISlotName, + *children: nodes.Node, + classes: tuple[str, ...] = (), +) -> api_slot: + """Create an ``api_slot`` with a stable slot-specific class name. + + Parameters + ---------- + slot_name : APISlotName + Slot name for the contained content. + *children : nodes.Node + Child nodes to place in the slot. + classes : tuple[str, ...] + Additional compatibility classes. + + Returns + ------- + api_slot + A configured slot marker node. + + Examples + -------- + >>> slot = build_api_slot("badges", nodes.inline("", "demo")) + >>> slot.get("classes") + ['gp-sphinx-api-slot', 'gp-sphinx-api-slot--badges'] + >>> slot.astext() + 'demo' + """ + slot = api_slot(slot=slot_name) + slot["classes"] = [API.SLOT, API.slot_modifier(slot_name), *classes] + for child in children: + slot += child + return slot diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_render.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_render.py new file mode 100644 index 00000000..ca974d15 --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_render.py @@ -0,0 +1,85 @@ +"""Shared helpers for reparsing generated Sphinx object markup. + +Examples +-------- +>>> from docutils import nodes +>>> from docutils.statemachine import StringList +>>> class DummyState: +... def nested_parse( +... self, +... view_list: StringList, +... offset: int, +... node: nodes.Element, +... ) -> None: +... for line in view_list: +... node += nodes.paragraph("", line) +>>> class DummyDirective: +... state = DummyState() +... content_offset = 0 +... def get_source_info(self) -> tuple[str, int]: +... return ("demo.md", 1) +>>> rendered = parse_generated_markup( +... DummyDirective(), +... "demo", +... ) # type: ignore[arg-type] +>>> rendered[0].astext() +'demo' +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from docutils.statemachine import StringList +from sphinx import addnodes + +if t.TYPE_CHECKING: + from sphinx.util.docutils import SphinxDirective + + +def parse_generated_markup( + directive: SphinxDirective, + markup: str, +) -> list[nodes.Node]: + """Parse generated markup through Sphinx when available. + + Parameters + ---------- + directive : SphinxDirective + Directive requesting nested parsing. + markup : str + Generated reStructuredText or MyST markup to parse. + + Returns + ------- + list[nodes.Node] + Parsed nodes ready for further normalization. + """ + if hasattr(directive, "parse_text_to_nodes"): + return directive.parse_text_to_nodes(markup) + + source, _line = directive.get_source_info() + view_list: StringList = StringList() + for line in markup.splitlines(): + view_list.append(line, source) + container = nodes.container() + directive.state.nested_parse(view_list, directive.content_offset, container) + return [container] if container.children else [] + + +def iter_desc_nodes(node_list: list[nodes.Node]) -> t.Iterator[addnodes.desc]: + """Yield ``addnodes.desc`` nodes from parsed markup. + + Parameters + ---------- + node_list : list[nodes.Node] + Parsed nodes returned by :func:`parse_generated_markup`. + + Yields + ------ + addnodes.desc + Description nodes found anywhere inside ``node_list``. + """ + for node in node_list: + yield from node.findall(addnodes.desc) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_sections.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_sections.py new file mode 100644 index 00000000..241f187a --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_sections.py @@ -0,0 +1,122 @@ +"""Shared body-section builders for componentized autodoc entries. + +Examples +-------- +>>> from docutils import nodes +>>> from sphinx_ux_autodoc_layout._sections import ( +... ApiFactRow, +... build_api_facts_section, +... build_api_summary_section, +... ) +>>> section = build_api_facts_section( +... [ApiFactRow("Type", nodes.paragraph("", "", nodes.literal("", "bool")))] +... ) +>>> section.get("name") +'gp-sphinx-api-facts' +>>> build_api_summary_section(nodes.paragraph("", "Summary")).get("name") +'gp-sphinx-api-summary' +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from docutils import nodes + +from sphinx_ux_autodoc_layout._css import API +from sphinx_ux_autodoc_layout._nodes import api_component, build_api_component + +_SECTION_KIND_CLASS: dict[str, str] = { + API.DESCRIPTION: "narrative", + API.FACTS: "facts", + API.SUMMARY: "summary", + API.PARAMETERS: "fields", + API.OPTIONS: "options", + API.FOOTER: "members", +} + + +@dataclass(frozen=True, slots=True) +class ApiFactRow: + """Typed fact row rendered inside a shared ``gp-sphinx-api-facts`` section. + + Parameters + ---------- + label : str + Field label displayed in the facts grid. + body : nodes.Node + Node rendered as the field body. + """ + + label: str + body: nodes.Node + + +def build_api_section( + name: str, + *children: nodes.Node, + classes: tuple[str, ...] = (), +) -> api_component: + """Return a shared API body section with stable region classes.""" + kind = _SECTION_KIND_CLASS.get(name) + region_classes = (API.REGION, API.region_modifier(kind)) if kind is not None else () + section = build_api_component(name, classes=(*region_classes, *classes)) + for child in children: + section += child + return section + + +def build_api_facts_section( + rows: t.Sequence[ApiFactRow], + *, + classes: tuple[str, ...] = (), +) -> api_component: + """Render a shared ``gp-sphinx-api-facts`` section from typed fact rows.""" + field_list = nodes.field_list(classes=[API.FACTS_LIST]) + for row in rows: + field_body = nodes.field_body() + field_body += row.body + field_list += nodes.field( + "", + nodes.field_name("", row.label), + field_body, + ) + return build_api_section(API.FACTS, field_list, classes=classes) + + +def build_api_table_section( + name: str, + *children: nodes.Node, + classes: tuple[str, ...] = (), +) -> api_component: + """Wrap one or more table-like body nodes in a shared API section.""" + return build_api_section(name, *children, classes=classes) + + +def build_api_summary_section( + *children: nodes.Node, + classes: tuple[str, ...] = (), +) -> api_component: + """Wrap summary or index content in the shared ``gp-sphinx-api-summary`` region. + + Parameters + ---------- + *children : nodes.Node + Summary or index nodes to render in the shared summary region. + classes : tuple[str, ...] + Extra CSS classes appended to the summary wrapper. + + Returns + ------- + api_component + Shared summary wrapper. + + Examples + -------- + >>> from docutils import nodes + >>> section = build_api_summary_section(nodes.paragraph("", "Summary")) + >>> section.get("name") + 'gp-sphinx-api-summary' + """ + return build_api_table_section(API.SUMMARY, *children, classes=classes) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_slots.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_slots.py new file mode 100644 index 00000000..3d4409fc --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_slots.py @@ -0,0 +1,101 @@ +"""Helpers for attaching structured layout slots to signatures. + +Examples +-------- +>>> from docutils import nodes +>>> from sphinx import addnodes +>>> sig = addnodes.desc_signature() +>>> source = nodes.inline(classes=["viewcode-link"]) +>>> source += nodes.Text("[source]") +>>> sig += nodes.reference("", "", source, internal=False) +>>> badge = nodes.inline("", "function") +>>> inject_signature_slots( +... sig, +... marker_attr="demo_slots", +... badge_node=badge, +... ) +True +>>> [child.get("slot") for child in sig.children if child.get("slot")] +['badges', 'source-link'] +""" + +from __future__ import annotations + +from docutils import nodes +from sphinx import addnodes + +from sphinx_ux_autodoc_layout._nodes import build_api_slot + + +def is_viewcode_ref(node: nodes.Node) -> bool: + """Return ``True`` when *node* is a viewcode/source reference. + + Parameters + ---------- + node : nodes.Node + Candidate node from a signature row. + + Returns + ------- + bool + ``True`` when the node is a source-link reference generated by + Sphinx's viewcode integration. + """ + return isinstance(node, nodes.reference) and any( + "viewcode-link" in getattr(child, "get", lambda *_: [])("classes", []) + for child in node.children + if isinstance(child, nodes.inline) + ) + + +def inject_signature_slots( + sig_node: addnodes.desc_signature, + *, + marker_attr: str, + badge_node: nodes.Node | None = None, + source_node: nodes.reference | None = None, + extract_source_link: bool = True, +) -> bool: + """Attach shared badge/source slots to ``sig_node`` once. + + Parameters + ---------- + sig_node : addnodes.desc_signature + Signature node to update. + marker_attr : str + Idempotence flag stored on the signature node. + badge_node : nodes.Node | None + Badge-group node to attach to ``api_slot("badges")``. + source_node : nodes.reference | None + Explicit source link to attach to ``api_slot("source-link")``. + When omitted, the helper can promote an existing viewcode reference. + extract_source_link : bool + When ``True``, remove the first existing viewcode/source reference from + ``sig_node`` and move it into the ``source-link`` slot. + + Returns + ------- + bool + ``True`` when the signature was updated and ``False`` when + ``marker_attr`` was already set. + """ + if sig_node.get(marker_attr): + return False + sig_node[marker_attr] = True + + extracted_source = source_node + if extract_source_link: + for child in list(sig_node.children): + if not is_viewcode_ref(child): + continue + sig_node.remove(child) + if extracted_source is None: + assert isinstance(child, nodes.reference) + extracted_source = child + break + + if badge_node is not None: + sig_node += build_api_slot("badges", badge_node) + if extracted_source is not None: + sig_node += build_api_slot("source-link", extracted_source) + return True 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 new file mode 100644 index 00000000..bca4cfd3 --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/css/layout.css @@ -0,0 +1,342 @@ +/* sphinx_ux_autodoc_layout — layout.css + * Stable api-* component wrappers and disclosure styling. + */ + +/* ── Content sections ───────────────────────────────── */ +.gp-sphinx-api-region + .gp-sphinx-api-region { + margin-top: 1rem; +} + +.gp-sphinx-api-footer, +.gp-sphinx-api-region--members { + margin-top: 1.5rem; +} + +.gp-sphinx-api-summary table { + margin-top: 0; +} + +/* ── API shell ──────────────────────────────────────── */ +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header { + display: flex; + align-items: center; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout { + display: flex; + align-items: center; + gap: 1rem; + width: 100%; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-left { + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 0.25rem; + min-width: 0; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-right { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + white-space: nowrap; + flex: 0 0 auto; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header[data-signature-expanded="true"] { + display: flex; + align-items: flex-start; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header[data-signature-expanded="true"] > .gp-sphinx-api-layout { + align-items: flex-start; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header[data-signature-expanded="true"] .gp-sphinx-api-layout-left { + align-items: flex-start; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header[data-signature-expanded="true"] .gp-sphinx-api-layout-right { + align-items: flex-start; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-right:empty { + display: none; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-source-link { + display: inline-flex; + align-items: center; +} + +/* ── Signature row ──────────────────────────────────── */ +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-signature { + flex: 1 1 auto; + font-family: var(--font-stack--monospace); + min-width: 0; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-link { + margin-left: 0.1rem; + flex: 0 0 auto; + visibility: hidden; +} + +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header:hover .gp-sphinx-api-link, +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-link:focus-visible { + visibility: visible; +} + +.gp-sphinx-api-signature-toggle { + appearance: none; + border: 0; + background: none; + color: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0; + font-family: var(--font-stack--monospace); + padding: 0; +} + +.gp-sphinx-api-signature-toggle .gp-sphinx-api-signature-preview { + color: var(--color-foreground-muted); +} + +.gp-sphinx-api-signature-toggle:hover .gp-sphinx-api-signature-preview, +.gp-sphinx-api-signature-toggle:focus-visible .gp-sphinx-api-signature-preview { + color: var(--color-link); +} + +.gp-sphinx-api-signature-toggle[aria-expanded="true"] { + display: none; +} + +/* ── Expanded signature wrapper ─────────────────────── */ +.gp-sphinx-api-signature-expanded { + display: contents; +} + +.gp-sphinx-api-signature-expanded[hidden] { + display: none; +} + +.gp-sphinx-api-signature-expanded > dl { + margin: 0; + padding-inline-start: var(--gp-sphinx-api-signature-indent, 1rem); +} + +.gp-sphinx-api-signature-expanded > dl > dd { + margin: 0; + margin-inline-start: 0 !important; + margin-left: 0 !important; +} + +.gp-sphinx-api-signature-expanded > .sig-paren:last-of-type { + margin-right: 0.35rem; +} + +.gp-sphinx-api-sig-collapse { + appearance: none; + background: none; + border: 0; + color: var(--color-foreground-muted); + cursor: pointer; + display: inline-flex; + font: inherit; + padding: 0; +} + +.gp-sphinx-api-sig-collapse:hover, +.gp-sphinx-api-sig-collapse:focus-visible { + 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; +} + +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; +} + +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dt { + font-weight: 600; +} + +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-facts dl.field-list > dd { + margin-left: 0; +} + +dl.gp-sphinx-api-container > dd.gp-sphinx-api-content .gp-sphinx-api-options > .rst.directive-option + .rst.directive-option { + margin-top: 1rem; +} + +/* ── Fold (block disclosure) ────────────────────────── */ +details.gp-sphinx-api-fold { + margin: 0; +} + +details.gp-sphinx-api-fold > summary.gp-sphinx-api-fold-summary { + cursor: pointer; + color: var(--color-foreground-muted); + font-size: 0.85em; + font-weight: 600; + padding: 0.25rem 0; + list-style: none; +} + +details.gp-sphinx-api-fold > summary.gp-sphinx-api-fold-summary::-webkit-details-marker { + display: none; +} + +details.gp-sphinx-api-fold > summary.gp-sphinx-api-fold-summary::before { + content: "\25B8 "; +} + +details.gp-sphinx-api-fold[open] > summary.gp-sphinx-api-fold-summary::before { + content: "\25BE "; +} + +details.gp-sphinx-api-fold > summary.gp-sphinx-api-fold-summary:hover { + color: var(--color-link); +} + +/* ── Card box for non-Python managed entries ────────── */ +/* rst:directive, rst:role, std:confval — Furo only cards dl.py.* */ +dl.gp-sphinx-api-container:not(.py) { + border: 1px solid var(--color-background-border); + border-radius: 0.5rem; + padding: 0; + margin-bottom: 1.5rem; + overflow: visible; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); +} + +dl.gp-sphinx-api-container:not(.py) > dt.gp-sphinx-api-header { + background: var(--color-background-secondary); + border-bottom: 1px solid var(--color-background-border); + padding: 0.5rem 0.75rem 0.5rem 1rem; + text-indent: 0; + margin: 0; + min-height: 2rem; + transition: background 100ms ease-out; +} + +dl.gp-sphinx-api-container:not(.py) > dt.gp-sphinx-api-header:hover { + background: var(--color-api-background-hover); +} + +dl.gp-sphinx-api-container:not(.py) > dd.gp-sphinx-api-content { + padding: 0.75rem 1rem; + margin-left: 0 !important; +} + +.gp-sphinx-api-card-shell { + border: 1px solid var(--color-background-border); + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + margin-bottom: 1.5rem; + overflow: clip; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header { + background: var(--color-background-secondary); + border-bottom: 1px solid var(--color-background-border); + padding: 0.5rem 0.75rem 0.5rem 1rem; + transition: background 100ms ease-out; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header:hover { + background: var(--color-api-background-hover); +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout { + display: flex; + align-items: center; + gap: 0.45rem; + width: 100%; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-left { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + flex: 1 1 auto; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-signature { + min-width: 0; + font-family: var(--font-stack--monospace); +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-right { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + white-space: nowrap; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-content { + padding: 0.75rem 1rem; +} + +.gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-content > * + * { + margin-top: 0.75rem; +} + +/* Nested entries — lighter (e.g. rst:directive:option inside rst:directive) */ +dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-container:not(.py) { + border-color: var(--color-background-border); + box-shadow: none; + margin-bottom: 1rem; +} + +dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-container:not(.py) > dt.gp-sphinx-api-header { + background: transparent; + border-bottom-color: var(--color-background-border); + padding-left: 0.75rem; +} + +dl.gp-sphinx-api-container:not(.py) .gp-sphinx-api-footer dl.gp-sphinx-api-container:not(.py) > dt.gp-sphinx-api-header:hover { + background: var(--color-api-background-hover); +} + +/* ── Mobile adjustments ─────────────────────────────── */ +@media (max-width: 52rem) { + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout { + flex-direction: column; + gap: 0.5rem; + } + + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-right { + margin-left: 0; + white-space: normal; + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } + + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header > .gp-sphinx-api-layout { + flex-direction: column; + align-items: flex-start; + } + + .gp-sphinx-api-card-shell > .gp-sphinx-api-card-entry > .gp-sphinx-api-header .gp-sphinx-api-layout-right { + margin-left: 0; + white-space: normal; + width: 100%; + flex-wrap: wrap; + } +} diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/js/layout.js b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/js/layout.js new file mode 100644 index 00000000..7077353b --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_static/js/layout.js @@ -0,0 +1,112 @@ +/** + * sphinx_ux_autodoc_layout — layout.js + * + * Hash-based auto-expansion for both block
folds and the + * custom gp-sphinx-api-signature disclosure wrapper. + */ + +(function () { + 'use strict'; + + function syncSignatureControls(expandedId, expanded) { + document + .querySelectorAll( + '.gp-sphinx-api-signature-toggle, .gp-sphinx-api-sig-collapse' + ) + .forEach(function (control) { + if (control.getAttribute('aria-controls') !== expandedId) return; + control.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + }); + } + + function setSignatureExpandedById(expandedId, expanded) { + if (!expandedId) return; + + var expandedPanel = document.getElementById(expandedId); + if (!expandedPanel) return; + + var signature = expandedPanel.closest('.gp-sphinx-api-signature'); + if (signature) { + signature.setAttribute('data-expanded', expanded ? 'true' : 'false'); + } + + var header = expandedPanel.closest('.gp-sphinx-api-header'); + if (header) { + header.setAttribute('data-signature-expanded', expanded ? 'true' : 'false'); + } + + syncSignatureControls(expandedId, expanded); + + if (expanded) { + expandedPanel.hidden = false; + expandedPanel.setAttribute('data-expanded', 'true'); + expandedPanel.setAttribute('aria-hidden', 'false'); + } else { + expandedPanel.hidden = true; + expandedPanel.setAttribute('data-expanded', 'false'); + expandedPanel.setAttribute('aria-hidden', 'true'); + } + } + + function setSignatureExpanded(button, expanded) { + if (!button) return; + setSignatureExpandedById(button.getAttribute('aria-controls'), expanded); + } + + function expandAncestors(target) { + var node = target; + while (node) { + if (node.tagName === 'DETAILS' && !node.open) { + node.open = true; + } + node = node.parentElement; + } + } + + function expandSignatureForTarget(target) { + var header = null; + + if (target.classList && target.classList.contains('gp-sphinx-api-header')) { + header = target; + } else if (target.closest) { + header = target.closest('.gp-sphinx-api-header'); + } + + if (!header) return; + + var expandedPanel = header.querySelector('.gp-sphinx-api-signature-expanded'); + if (!expandedPanel || !expandedPanel.id) return; + + setSignatureExpandedById(expandedPanel.id, true); + } + + function expandForHash() { + var hash = window.location.hash; + if (!hash) return; + + var id = hash.slice(1); + var target = document.getElementById(id); + if (!target) return; + + expandAncestors(target); + expandSignatureForTarget(target); + + setTimeout(function () { + target.scrollIntoView({ block: 'center' }); + }, 50); + } + + document.addEventListener('click', function (event) { + var button = event.target.closest( + '.gp-sphinx-api-signature-toggle, .gp-sphinx-api-sig-collapse' + ); + if (!button) return; + + event.preventDefault(); + var expanded = button.getAttribute('aria-expanded') === 'true'; + setSignatureExpanded(button, !expanded); + }); + + document.addEventListener('DOMContentLoaded', expandForHash); + window.addEventListener('hashchange', expandForHash); +})(); diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py new file mode 100644 index 00000000..b926d63d --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -0,0 +1,972 @@ +"""Doctree transforms for componentized autodoc layout. + +Runs as a ``doctree-resolved`` event handler after +``sphinx-autodoc-api-style``. It rebuilds managed Sphinx object entries into +stable ``gp-sphinx-api-*`` wrappers while preserving Sphinx's outer +``dl / dt / dd`` structure. + +Examples +-------- +>>> from sphinx_ux_autodoc_layout._transforms import _classify_child +>>> from docutils import nodes +>>> _classify_child(nodes.paragraph()) +'narrative' +>>> _classify_child(nodes.field_list()) +'fields' +""" + +from __future__ import annotations + +import dataclasses +import typing as t + +from docutils import nodes +from sphinx import addnodes + +from sphinx_ux_autodoc_layout._css import API +from sphinx_ux_autodoc_layout._nodes import ( + api_component, + api_fold, + api_permalink, + api_region, + api_sig_fold, + api_slot, + build_api_component, + build_api_inline_component, +) +from sphinx_ux_autodoc_layout._slots import is_viewcode_ref +from sphinx_ux_badges import SAB + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +_SECTION_COMPONENTS: dict[str, str] = { + "narrative": API.DESCRIPTION, + "facts": API.FACTS, + "fields": API.PARAMETERS, + "options": API.OPTIONS, + "members": API.FOOTER, +} + +_STRUCTURED_SECTION_NAMES: frozenset[str] = frozenset(_SECTION_COMPONENTS.values()) + +_SKIP_FOLD_OBJTYPES: frozenset[str] = frozenset( + { + "attribute", + "data", + "fixture", + "module", + "property", + } +) + +_MEMBER_CONTAINER_OBJTYPES: frozenset[str] = frozenset({"class", "exception"}) + +_MANAGED_PYTHON_OBJTYPES: tuple[str, ...] = ( + "attribute", + "class", + "classmethod", + "data", + "exception", + "fixture", + "function", + "method", + "module", + "property", + "staticmethod", + "type", +) + + +@dataclasses.dataclass(frozen=True, slots=True) +class DescLayoutProfile: + """Typed layout policy for a managed ``addnodes.desc`` entry.""" + + domain: str + objtype: str + slug: str + allow_signature_fold: bool = False + + @property + def class_name(self) -> str: + """Return the stable CSS class for the profile.""" + return API.profile(self.slug) + + +_PROFILE_REGISTRY: dict[tuple[str, str], DescLayoutProfile] = { + **{ + ("py", objtype): DescLayoutProfile( + domain="py", + objtype=objtype, + slug=f"py-{objtype.replace(':', '-')}", + allow_signature_fold=objtype not in _SKIP_FOLD_OBJTYPES, + ) + for objtype in _MANAGED_PYTHON_OBJTYPES + }, + ("std", "confval"): DescLayoutProfile( + domain="std", + objtype="confval", + slug="confval", + ), + ("rst", "directive"): DescLayoutProfile( + domain="rst", + objtype="directive", + slug="rst-directive", + ), + ("rst", "role"): DescLayoutProfile( + domain="rst", + objtype="role", + slug="rst-role", + ), + ("rst", "directive:option"): DescLayoutProfile( + domain="rst", + objtype="directive:option", + slug="rst-directive-option", + ), + ("mcp", "tool"): DescLayoutProfile( + domain="mcp", + objtype="tool", + slug="mcp-tool", + allow_signature_fold=True, + ), +} + + +def _append_class(node: nodes.Element, class_name: str) -> None: + """Append *class_name* to ``node['classes']`` if needed.""" + classes = list(node.get("classes", [])) + if class_name not in classes: + classes.append(class_name) + node["classes"] = classes + + +def _desc_layout_profile(desc_node: addnodes.desc) -> DescLayoutProfile | None: + """Return the layout profile for a managed description node.""" + domain = str(desc_node.get("domain", "")) + objtype = str(desc_node.get("objtype", "")) + return _PROFILE_REGISTRY.get((domain, objtype)) + + +def _make_api_permalink(desc_sig: addnodes.desc_signature) -> api_permalink | None: + """Create the managed permalink node for a signature.""" + ids: list[str] = [ + str(node_id) for node_id in t.cast(list[t.Any], desc_sig.get("ids", [])) + ] + if not ids: + return None + link = api_permalink( + href=f"#{ids[0]}", + title="Link to this definition", + ) + link["classes"] = ["headerlink", API.LINK] + return link + + +def _primary_signature_id(desc_node: addnodes.desc) -> str | None: + """Return the first signature id for a ``desc`` node.""" + for child in desc_node.children: + if isinstance(child, addnodes.desc_signature): + ids: list[str] = [ + str(node_id) for node_id in t.cast(list[t.Any], child.get("ids", [])) + ] + if ids: + return ids[0] + return None + + +def _classify_child(child: nodes.Node) -> str: + """Classify a ``desc_content`` child by its node type. + + Parameters + ---------- + child : nodes.Node + A direct child of ``desc_content``. + + Returns + ------- + str + One of ``"fields"``, ``"members"``, or ``"narrative"``. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> _classify_child(nodes.field_list()) + 'fields' + >>> _classify_child(addnodes.desc()) + 'members' + >>> _classify_child(nodes.note()) + 'narrative' + """ + if isinstance(child, nodes.field_list): + return "fields" + if isinstance(child, addnodes.desc): + return "members" + return "narrative" + + +def _component_name_for_kind(kind: str) -> str: + """Return the public API section name for a legacy kind string.""" + return _SECTION_COMPONENTS[kind] + + +def _wrap_content_runs(desc_node: addnodes.desc) -> None: + """Wrap contiguous ``desc_content`` runs in explicit API sections. + + Parameters + ---------- + desc_node : addnodes.desc + The description node to restructure. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> desc = addnodes.desc(domain="py", objtype="function") + >>> desc += addnodes.desc_signature() + >>> content = addnodes.desc_content() + >>> content += nodes.paragraph("", "hello") + >>> content += nodes.field_list() + >>> desc += content + >>> _wrap_content_runs(desc) + >>> [child.get("name") for child in content.children] + ['gp-sphinx-api-description', 'gp-sphinx-api-parameters'] + """ + content = next( + (c for c in desc_node.children if isinstance(c, addnodes.desc_content)), + None, + ) + if content is None: + return + + _append_class(content, API.CONTENT) + if not content.children: + return + + original = list(content.children) + content.children = [] + + current_kind: str | None = None + current_section: api_component | None = None + + for child in original: + if ( + isinstance(child, api_component) + and str(child.get("name", "")) in _STRUCTURED_SECTION_NAMES + ): + if current_section is not None: + content += current_section + current_section = None + current_kind = None + content += child + continue + kind = _classify_child(child) + if kind != current_kind: + if current_section is not None: + content += current_section + current_section = build_api_component( + _component_name_for_kind(kind), + classes=(API.REGION, API.region_modifier(kind)), + ) + current_kind = kind + assert current_section is not None + current_section += child + + if current_section is not None: + content += current_section + + +def _ensure_desc_content(desc_node: addnodes.desc) -> addnodes.desc_content: + """Return an existing ``desc_content`` child or create one.""" + for child in desc_node.children: + if isinstance(child, addnodes.desc_content): + return child + content = addnodes.desc_content() + desc_node += content + return content + + +def _is_member_desc_for_container( + member_desc: addnodes.desc, + *, + container_id: str, +) -> bool: + """Return ``True`` when *member_desc* belongs to *container_id*.""" + member_id = _primary_signature_id(member_desc) + if member_id is None: + return False + return member_id.startswith(f"{container_id}.") + + +def _nest_python_members(container: nodes.Element) -> None: + """Move sibling Python member descriptions into their parent class. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> parent = nodes.section() + >>> klass = addnodes.desc(domain="py", objtype="class") + >>> klass += addnodes.desc_signature(ids=["demo.Widget"]) + >>> klass += addnodes.desc_content() + >>> method = addnodes.desc(domain="py", objtype="method") + >>> method += addnodes.desc_signature(ids=["demo.Widget.run"]) + >>> method += addnodes.desc_content() + >>> parent += klass + >>> parent += method + >>> _nest_python_members(parent) + >>> len(parent.children) + 1 + >>> len(klass[-1].children) + 1 + """ + index = 0 + while index < len(container.children): + child = container.children[index] + + if isinstance(child, nodes.Element): + _nest_python_members(child) + + if not isinstance(child, addnodes.desc): + index += 1 + continue + if child.get("domain") != "py": + index += 1 + continue + if child.get("objtype") not in _MEMBER_CONTAINER_OBJTYPES: + index += 1 + continue + + container_id = _primary_signature_id(child) + if container_id is None: + index += 1 + continue + + content = _ensure_desc_content(child) + + while index + 1 < len(container.children): + sibling = container.children[index + 1] + if not isinstance(sibling, addnodes.desc): + break + if sibling.get("domain") != "py": + break + if not _is_member_desc_for_container(sibling, container_id=container_id): + break + + container.remove(sibling) + content += sibling + + index += 1 + + +def _deduplicate_return_type_fields(content: addnodes.desc_content) -> None: + """Remove duplicate "Return type" fields, keeping the richest one. + + Remove duplicate "Return type" fields that can appear when multiple + docstring processors each emit a ``:rtype:`` field. ``sphinx_autodoc_typehints_gp`` + inserts its cross-referenced entry at priority 499 before the Sphinx + built-in runs at 500, but the NumPy docstring parser may also produce a + plain-text ``:rtype:`` that ``_enhance_existing_type_field`` upgrades in + place. This helper is a defensive safety net: it removes all but the + first "Return type" field so no duplicate ever reaches the browser. + + Examples + -------- + >>> from docutils import nodes + >>> from sphinx import addnodes + >>> content = addnodes.desc_content() + >>> fl = nodes.field_list() + >>> for label in ("Return type", "Returns", "Return type"): + ... f = nodes.field() + ... f += nodes.field_name("", label) + ... f += nodes.field_body("", nodes.paragraph("", label + " body")) + ... fl += f + >>> content += fl + >>> _deduplicate_return_type_fields(content) + >>> rtype_fields = [ + ... f for f in fl.children + ... if isinstance(f, nodes.field) + ... and f.children + ... and f.children[0].astext().lower() == "return type" + ... ] + >>> len(rtype_fields) + 1 + """ + for field_list in content.findall(nodes.field_list): + seen_rtype = False + for field in list(field_list.children): + if not isinstance(field, nodes.field): + continue + name_node = field.children[0] if field.children else None + if ( + isinstance(name_node, nodes.field_name) + and name_node.astext().lower() == "return type" + ): + if seen_rtype: + field_list.remove(field) + else: + seen_rtype = True + + +def _count_field_entries(field_list: nodes.field_list) -> int: + """Count individual entries in a Sphinx field list. + + Parameters + ---------- + field_list : nodes.field_list + The field list to count. + + Returns + ------- + int + Total entry count. + """ + count = 0 + for field in field_list.children: + if not isinstance(field, nodes.field): + continue + for body_child in field.children: + if isinstance(body_child, nodes.field_body): + for item in body_child.children: + if isinstance(item, nodes.bullet_list): + count += sum( + 1 for li in item.children if isinstance(li, nodes.list_item) + ) + break + else: + count += 1 + break + else: + count += 1 + return count + + +def _is_parameters_section(node: nodes.Node) -> bool: + """Return ``True`` when *node* is an API parameters section.""" + if isinstance(node, api_component): + return str(node.get("name", "")) == API.PARAMETERS + if isinstance(node, api_region): + return str(node.get("kind", "")) == "fields" + return False + + +def _fold_large_field_regions( + content: addnodes.desc_content, + threshold: int, +) -> None: + """Wrap large field-list regions in ``api_fold`` disclosure blocks.""" + for section in content.children: + if not _is_parameters_section(section): + continue + assert isinstance(section, nodes.Element) + for field_list in list(section.children): + if not isinstance(field_list, nodes.field_list): + continue + entry_count = _count_field_entries(field_list) + if entry_count < threshold: + continue + fold = api_fold( + kind="parameters", + summary=f"Parameters ({entry_count})", + ) + idx = section.children.index(field_list) + section.remove(field_list) + fold += field_list + section.insert(idx, fold) + + +def _parameter_key(text: str) -> str: + """Return a normalized parameter key for preview and type lookup. + + Examples + -------- + >>> _parameter_key("host: str") + 'host' + >>> _parameter_key("port: int = 5432") + 'port' + """ + key = text.strip().rstrip(",") + for separator in (":", "="): + if separator in key: + key = key.split(separator, 1)[0].rstrip() + return key.strip() + + +def _count_signature_parameters( + parameter_list: addnodes.desc_parameterlist, +) -> tuple[str, int]: + """Return the first parameter text and total parameter count.""" + params = list(parameter_list.findall(addnodes.desc_parameter)) + first = _parameter_key(params[0].astext()) if params else "" + return first, len(params) + + +def _signature_expanded_id(desc_sig: addnodes.desc_signature) -> str: + """Return the stable DOM id for an expanded signature wrapper.""" + ids: list[str] = [ + str(node_id) for node_id in t.cast(list[t.Any], desc_sig.get("ids", [])) + ] + base = ids[0] if ids else API.SIGNATURE + return f"{base}--signature-expanded" + + +def _parameter_has_annotation(parameter: addnodes.desc_parameter) -> bool: + """Return ``True`` when a parameter already contains a type annotation.""" + return any( + isinstance(child, addnodes.desc_sig_punctuation) and ":" in child.astext() + for child in parameter.children + ) + + +def _parameter_default_index(parameter: addnodes.desc_parameter) -> int | None: + """Return the index of the default operator, if present.""" + for index, child in enumerate(parameter.children): + if isinstance(child, addnodes.desc_sig_operator) and child.astext() == "=": + return index + return None + + +def _is_space_node(child: nodes.Node) -> bool: + """Return ``True`` when a node renders as whitespace only.""" + return child.astext().isspace() + + +def _replace_parameter_children( + parameter: addnodes.desc_parameter, + children: list[nodes.Node], +) -> None: + """Replace a parameter's direct children with *children*.""" + parameter.children = [] + for child in children: + parameter += child + + +def _strip_parameter_annotation(parameter: addnodes.desc_parameter) -> None: + """Remove only the parameter annotation while preserving defaults.""" + children = list(parameter.children) + annotation_start = next( + ( + index + for index, child in enumerate(children) + if isinstance(child, addnodes.desc_sig_punctuation) + and ":" in child.astext() + ), + None, + ) + if annotation_start is None: + return + + default_index = _parameter_default_index(parameter) + prefix = children[:annotation_start] + while prefix and _is_space_node(prefix[-1]): + prefix.pop() + + if default_index is None: + _replace_parameter_children(parameter, prefix) + return + + suffix = children[default_index:] + while len(suffix) > 1 and _is_space_node(suffix[1]): + suffix.pop(1) + _replace_parameter_children(parameter, [*prefix, *suffix]) + + +def _make_annotation_nodes(type_nodes: list[nodes.Node]) -> list[nodes.Node]: + """Create signature annotation nodes from cloned *type_nodes*.""" + type_name = addnodes.desc_sig_name("", "") + for child in type_nodes: + type_name += child.deepcopy() + return [ + addnodes.desc_sig_punctuation("", ":"), + addnodes.desc_sig_space("", " "), + type_name, + ] + + +def _inject_parameter_annotation( + parameter: addnodes.desc_parameter, + type_nodes: list[nodes.Node], +) -> None: + """Insert a type annotation before a parameter default, if needed.""" + if not type_nodes or _parameter_has_annotation(parameter): + return + + key = _parameter_key(parameter.astext()) + if key in {"*", "/"}: + return + + children = list(parameter.children) + default_index = _parameter_default_index(parameter) + insert_at = len(children) if default_index is None else default_index + + annotation_children = _make_annotation_nodes(type_nodes) + if default_index is not None: + annotation_children.append(addnodes.desc_sig_space("", " ")) + + children[insert_at:insert_at] = annotation_children + + if default_index is not None: + operator_index = insert_at + len(annotation_children) + next_index = operator_index + 1 + if next_index < len(children) and not _is_space_node(children[next_index]): + children.insert(next_index, addnodes.desc_sig_space("", " ")) + + _replace_parameter_children(parameter, children) + + +def _iter_parameter_paragraphs( + field_body: nodes.field_body, +) -> t.Iterator[nodes.paragraph]: + """Yield parameter paragraphs from a Sphinx ``Parameters`` field body.""" + for child in field_body.children: + if isinstance(child, nodes.bullet_list): + for item in child.children: + if not isinstance(item, nodes.list_item): + continue + for grandchild in item.children: + if isinstance(grandchild, nodes.paragraph): + yield grandchild + elif isinstance(child, nodes.paragraph): + yield child + + +def _extract_type_nodes_from_paragraph( + paragraph: nodes.paragraph, +) -> tuple[str, list[nodes.Node]] | None: + """Extract a parameter name and its type nodes from a field-list paragraph.""" + if not paragraph.children: + return None + + first = paragraph.children[0] + if not isinstance(first, (nodes.strong, addnodes.literal_strong)): + return None + + key = _parameter_key(first.astext()) + if not key: + return None + + collected: list[nodes.Node] = [] + in_type = False + + for child in paragraph.children[1:]: + text = child.astext() + if not in_type: + if "(" not in text: + continue + in_type = True + after_open = text.split("(", 1)[1] + if ")" in after_open: + before_close = after_open.split(")", 1)[0] + if before_close: + collected.append(nodes.Text(before_close)) + break + if after_open: + collected.append(nodes.Text(after_open)) + continue + + if ")" in text: + before_close = text.split(")", 1)[0] + if before_close: + collected.append(nodes.Text(before_close)) + break + collected.append(child.deepcopy()) + + if not collected: + return None + return key, collected + + +def _extract_parameter_types(desc_node: addnodes.desc) -> dict[str, list[nodes.Node]]: + """Collect parameter annotations from the entry's ``Parameters`` field list.""" + content = next( + ( + child + for child in desc_node.children + if isinstance(child, addnodes.desc_content) + ), + None, + ) + if content is None: + return {} + + mapping: dict[str, list[nodes.Node]] = {} + for section in content.children: + if not _is_parameters_section(section): + continue + assert isinstance(section, nodes.Element) + for child in section.children: + if not isinstance(child, nodes.field_list): + continue + for field in child.children: + if not isinstance(field, nodes.field) or len(field.children) < 2: + continue + name = field.children[0] + body = field.children[1] + if not isinstance(name, nodes.field_name) or not isinstance( + body, nodes.field_body + ): + continue + if name.astext().strip().casefold() != "parameters": + continue + for paragraph in _iter_parameter_paragraphs(body): + extracted = _extract_type_nodes_from_paragraph(paragraph) + if extracted is None: + continue + key, type_nodes = extracted + mapping[key] = type_nodes + return mapping + + +def _prepare_folded_parameter_list( + parameter_list: addnodes.desc_parameterlist, + *, + parameter_types: dict[str, list[nodes.Node]], + show_annotations: bool, +) -> None: + """Convert a parameter list to Sphinx's multiline signature rendering.""" + parameter_list["multi_line_parameter_list"] = True + parameter_list["multi_line_trailing_comma"] = False + + for parameter in parameter_list.findall(addnodes.desc_parameter): + key = _parameter_key(parameter.astext()) + if show_annotations: + if _parameter_has_annotation(parameter): + continue + type_nodes = parameter_types.get(key) + if type_nodes is not None: + _inject_parameter_annotation(parameter, type_nodes) + continue + _strip_parameter_annotation(parameter) + + +def _extract_toolbar_content( + toolbar: nodes.inline | None, +) -> tuple[list[nodes.Node], nodes.reference | None]: + """Split toolbar content into badge-side children and source link.""" + badge_children: list[nodes.Node] = [] + source_ref: nodes.reference | None = None + + if toolbar is None: + return badge_children, source_ref + + for child in list(toolbar.children): + if source_ref is None and is_viewcode_ref(child): + assert isinstance(child, nodes.reference) + source_ref = child + continue + badge_children.append(child) + + return badge_children, source_ref + + +def _pop_slot_children(slot_node: api_slot) -> list[nodes.Node]: + """Detach and return all children from *slot_node* in source order.""" + slot_children: list[nodes.Node] = [] + for child in list(slot_node.children): + slot_node.remove(child) + slot_children.append(child) + return slot_children + + +def _extract_slot_content( + desc_sig: addnodes.desc_signature, +) -> dict[str, list[nodes.Node]]: + """Return detached slot payloads keyed by slot name.""" + slot_children: dict[str, list[nodes.Node]] = {} + for child in list(desc_sig.children): + if not isinstance(child, api_slot): + continue + desc_sig.remove(child) + slot_name = str(child.get("slot", "")) + if not slot_name: + continue + slot_children.setdefault(slot_name, []).extend(_pop_slot_children(child)) + return slot_children + + +def _rebuild_signature_layout( + desc_node: addnodes.desc, + desc_sig: addnodes.desc_signature, + *, + threshold: int, + include_permalink: bool, + show_annotations: bool, +) -> None: + """Rebuild a signature into explicit API header subcomponents.""" + if desc_sig.get("is_multiline"): + return + + slot_children = _extract_slot_content(desc_sig) + original = list(desc_sig.children) + desc_sig.children = [] + + toolbar: nodes.inline | None = None + row_children: list[nodes.Node] = [] + fallback_source_ref: nodes.reference | None = None + + for child in original: + if isinstance(child, nodes.inline) and SAB.TOOLBAR in child.get("classes", []): + toolbar = child + continue + if isinstance(child, nodes.reference) and "headerlink" in child.get( + "classes", [] + ): + continue + if fallback_source_ref is None and is_viewcode_ref(child): + assert isinstance(child, nodes.reference) + fallback_source_ref = child + continue + row_children.append(child) + + fallback_badge_children, source_ref = _extract_toolbar_content(toolbar) + badge_children = slot_children.get("badges", fallback_badge_children) + source_children = slot_children.get("source-link", []) + if not source_children and source_ref is not None: + source_children = [source_ref] + if not source_children and fallback_source_ref is not None: + source_children = [fallback_source_ref] + + layout = build_api_component(API.LAYOUT) + left = build_api_component(API.LAYOUT_LEFT) + signature = build_api_component(API.SIGNATURE) + right = build_api_component(API.LAYOUT_RIGHT, classes=(SAB.TOOLBAR,)) + parameter_types = _extract_parameter_types(desc_node) + folded = False + + for child in row_children: + if not folded and isinstance(child, addnodes.desc_parameterlist): + first_param, param_count = _count_signature_parameters(child) + if param_count >= threshold: + panel_id = _signature_expanded_id(desc_sig) + signature += api_sig_fold( + first_param=first_param, + param_count=param_count, + panel_id=panel_id, + ) + _prepare_folded_parameter_list( + child, + parameter_types=parameter_types, + show_annotations=show_annotations, + ) + expanded = build_api_component( + API.SIGNATURE_EXPANDED, + classes=(API.SIG_EXPANDED,), + html_attrs={ + "aria-hidden": "true", + "data-expanded": "false", + "hidden": "hidden", + "id": panel_id, + }, + ) + expanded += child + collapse = build_api_inline_component( + API.SIG_COLLAPSE, + tag="button", + html_attrs={ + "aria-controls": panel_id, + "aria-expanded": "true", + "type": "button", + }, + ) + collapse += nodes.Text("[collapse]") + expanded += collapse + signature += expanded + folded = True + continue + signature += child + + left += signature + if include_permalink: + permalink = _make_api_permalink(desc_sig) + if permalink is not None: + left += permalink + + if badge_children: + badge_container = build_api_inline_component(API.BADGE_CONTAINER) + for child in badge_children: + badge_container += child + right += badge_container + + if source_children: + source_container = build_api_inline_component(API.SOURCE_LINK) + for child in source_children: + source_container += child + right += source_container + + layout += left + layout += right + desc_sig += layout + + +def on_doctree_resolved( + app: Sphinx, + doctree: nodes.document, + docname: str, +) -> None: + """Restructure managed Sphinx object entries into stable API components. + + Parameters + ---------- + app : Sphinx + The Sphinx application. + doctree : nodes.document + The resolved doctree. + docname : str + The document name. + """ + if app.builder.format != "html": + return + api_layout_enabled = bool(app.config.api_layout_enabled) + if not api_layout_enabled and next(doctree.findall(api_slot), None) is None: + return + + threshold: int = app.config.api_collapsed_threshold + fold_params: bool = app.config.api_fold_parameters + show_annotations: bool = app.config.api_signature_show_annotations + include_permalink = bool( + app.config.html_permalinks and getattr(app.builder, "add_permalinks", False) + ) + + _nest_python_members(doctree) + + for desc_node in doctree.findall(addnodes.desc): + profile = _desc_layout_profile(desc_node) + if profile is None: + continue + + _append_class(desc_node, API.CONTAINER) + _append_class(desc_node, profile.class_name) + _wrap_content_runs(desc_node) + + for child in desc_node.children: + if isinstance(child, addnodes.desc_content): + _deduplicate_return_type_fields(child) + + allow_signature_fold = ( + api_layout_enabled and fold_params and profile.allow_signature_fold + ) + + for child in desc_node.children: + if not isinstance(child, addnodes.desc_signature): + continue + _append_class(child, API.HEADER) + child["api_managed"] = not child.get("is_multiline", False) + if child["api_managed"]: + child["html_attrs"] = {"data-signature-expanded": "false"} + _rebuild_signature_layout( + desc_node, + child, + threshold=threshold if allow_signature_fold else 10**9, + include_permalink=include_permalink, + show_annotations=show_annotations, + ) + + if not allow_signature_fold: + continue + + content = next( + (c for c in desc_node.children if isinstance(c, addnodes.desc_content)), + None, + ) + if content is not None: + _fold_large_field_regions(content, threshold) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_visitors.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_visitors.py new file mode 100644 index 00000000..27513943 --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_visitors.py @@ -0,0 +1,173 @@ +"""HTML visitors for autodoc layout nodes. + +The extension keeps Sphinx's outer ``dl / dt / dd`` shell, then renders +explicit API subcomponents inside those nodes. + +Examples +-------- +>>> callable(visit_api_component) +True +>>> callable(visit_api_permalink) +True +>>> callable(visit_desc_signature_html) +True +""" + +from __future__ import annotations + +import html +import typing as t + +from docutils import nodes +from sphinx.locale import _ +from sphinx.writers.html5 import HTML5Translator as SphinxHTML5Translator + +from sphinx_ux_autodoc_layout._css import API + +if t.TYPE_CHECKING: + from sphinx.writers.html5 import HTML5Translator + +_LEGACY_SECTION_COMPONENTS: dict[str, str] = { + "narrative": API.DESCRIPTION, + "fields": API.PARAMETERS, + "members": API.FOOTER, +} + + +def _html_attrs(node: nodes.Element) -> dict[str, str]: + """Return sanitized HTML attributes stored on a custom node.""" + attrs: t.Any = node.get("html_attrs", {}) + return {str(key): str(value) for key, value in attrs.items()} + + +def visit_api_region(self: HTML5Translator, node: nodes.Element) -> None: + """Open a legacy region wrapper ``
``.""" + kind = node.get("kind", "narrative") + component = _LEGACY_SECTION_COMPONENTS.get(kind) + classes = [API.REGION, API.region_modifier(kind)] + if component is not None: + classes.insert(0, component) + self.body.append(self.starttag(node, "div", "", classes=classes)) + + +def depart_api_region(self: HTML5Translator, node: nodes.Element) -> None: + """Close the legacy region wrapper.""" + self.body.append("
") + + +def visit_api_component(self: HTML5Translator, node: nodes.Element) -> None: + """Open an API component wrapper element.""" + tag = node.get("tag", "div") + attrs = _html_attrs(node) + ids = [attrs.pop("id")] if "id" in attrs else [] + starttag = t.cast(t.Any, self).starttag + self.body.append(starttag(node, tag, "", ids=ids, **attrs)) + + +def depart_api_component(self: HTML5Translator, node: nodes.Element) -> None: + """Close an API component wrapper element.""" + self.body.append(f"") + + +def visit_api_permalink(self: HTML5Translator, node: nodes.Element) -> None: + """Open a managed permalink anchor.""" + self.body.append( + self.starttag( + node, + "a", + "", + href=node.get("href", "#"), + title=node.get("title", _("Link to this definition")), + ) + ) + self.body.append(node.get("text", self.config.html_permalinks_icon)) + + +def depart_api_permalink(self: HTML5Translator, node: nodes.Element) -> None: + """Close the managed permalink anchor.""" + self.body.append("") + + +def visit_api_fold(self: HTML5Translator, node: nodes.Element) -> None: + """Open a ``
`` disclosure element.""" + summary = node.get("summary", "") + kind = node.get("kind", "") + open_attr = " open" if node.get("open", False) else "" + self.body.append( + f'
' + f'{html.escape(summary)}' + ) + + +def depart_api_fold(self: HTML5Translator, node: nodes.Element) -> None: + """Close the ``
`` element.""" + self.body.append("
") + + +def visit_api_sig_fold(self: HTML5Translator, node: nodes.Element) -> None: + """Open the custom signature disclosure toggle button.""" + first = html.escape(node.get("first_param", "")) + panel_id = node.get("panel_id", "") + preview = first if first else "..." + starttag = t.cast(t.Any, self).starttag + self.body.append( + starttag( + node, + "button", + "", + type="button", + classes=[API.SIGNATURE_TOGGLE, API.SIG_TOGGLE], + **{ + "aria-controls": panel_id, + "aria-expanded": "false", + }, + ) + ) + self.body.append('(') + preview_classes = f"{API.SIGNATURE_PREVIEW} {API.SIG_PREVIEW}" + self.body.append(f'{preview}, [...]') + self.body.append(')') + + +def depart_api_sig_fold(self: HTML5Translator, node: nodes.Element) -> None: + """Close the custom signature disclosure toggle button.""" + self.body.append("") + + +def visit_desc_signature_html(self: HTML5Translator, node: nodes.Element) -> None: + """Render managed desc signatures without Sphinx's default permalink.""" + if not node.get("api_managed", False): + SphinxHTML5Translator.visit_desc_signature(self, node) + return + + attrs = _html_attrs(node) + starttag = t.cast(t.Any, self).starttag + self.body.append(starttag(node, "dt", **attrs)) + self.protect_literal_text += 1 + + +def depart_desc_signature_html(self: HTML5Translator, node: nodes.Element) -> None: + """Close managed desc signatures while skipping Sphinx's auto permalink.""" + if not node.get("api_managed", False): + SphinxHTML5Translator.depart_desc_signature(self, node) + return + self.protect_literal_text -= 1 + self.body.append("\n") + + +def passthrough_visit(self: t.Any, node: nodes.Element) -> None: + """No-op visit for non-HTML builders. + + Examples + -------- + >>> passthrough_visit(None, None) + """ + + +def passthrough_depart(self: t.Any, node: nodes.Element) -> None: + """No-op depart for non-HTML builders. + + Examples + -------- + >>> passthrough_depart(None, None) + """ diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/py.typed b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/sphinx-ux-badges/README.md b/packages/sphinx-ux-badges/README.md new file mode 100644 index 00000000..e2596825 --- /dev/null +++ b/packages/sphinx-ux-badges/README.md @@ -0,0 +1,42 @@ +# sphinx-ux-badges + +Shared badge node and CSS for Sphinx autodoc extensions in the gp-sphinx ecosystem. + +Provides `BadgeNode`, HTML visitors, and builder helpers shared by +`sphinx-autodoc-api-style`, `sphinx-autodoc-pytest-fixtures`, +`sphinx-autodoc-sphinx`, `sphinx-autodoc-docutils`, and +`sphinx-autodoc-fastmcp`. + +## Install + +```console +$ pip install sphinx-ux-badges +``` + +## Usage + +Load the badge layer from your own extension's `setup()`: + +```python +def setup(app): + app.setup_extension("sphinx_ux_badges") +``` + +Then build badges in your directives or transforms: + +```python +from sphinx_ux_badges import build_badge, build_badge_group, build_toolbar + +group = build_badge_group([ + build_badge( + "readonly", + tooltip="Read-only", + classes=["gp-sphinx-fastmcp__safety-readonly"], + ), +]) +``` + +## Documentation + +See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-ux-badges/) +for the colour palette reference, `BadgeSpec` API, and live demos. diff --git a/packages/sphinx-autodoc-badges/pyproject.toml b/packages/sphinx-ux-badges/pyproject.toml similarity index 92% rename from packages/sphinx-autodoc-badges/pyproject.toml rename to packages/sphinx-ux-badges/pyproject.toml index 9452790a..c824c22e 100644 --- a/packages/sphinx-autodoc-badges/pyproject.toml +++ b/packages/sphinx-ux-badges/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "sphinx-autodoc-badges" +name = "sphinx-ux-badges" version = "0.0.1a7" description = "Shared badge node and CSS for Sphinx autodoc extensions" requires-python = ">=3.10,<4.0" @@ -26,7 +26,7 @@ classifiers = [ readme = "README.md" keywords = ["sphinx", "badges", "documentation"] dependencies = [ - "sphinx", + "sphinx>=8.1", ] [project.urls] @@ -37,4 +37,4 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/sphinx_autodoc_badges"] +packages = ["src/sphinx_ux_badges"] diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/__init__.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py similarity index 71% rename from packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/__init__.py rename to packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py index ebb8356e..9bbec586 100644 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/__init__.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py @@ -5,11 +5,11 @@ Examples -------- ->>> from sphinx_autodoc_badges import BadgeNode, build_badge +>>> from sphinx_ux_badges import BadgeNode, build_badge >>> callable(build_badge) True ->>> from sphinx_autodoc_badges import setup +>>> from sphinx_ux_badges import setup >>> callable(setup) True """ @@ -22,18 +22,26 @@ from sphinx.application import Sphinx -from sphinx_autodoc_badges._builders import ( +from sphinx_ux_badges._builders import ( + BadgeSpec, build_badge, + build_badge_from_spec, build_badge_group, + build_badge_group_from_specs, build_toolbar, ) -from sphinx_autodoc_badges._nodes import BadgeNode -from sphinx_autodoc_badges._visitors import depart_badge_html, visit_badge_html +from sphinx_ux_badges._css import SAB +from sphinx_ux_badges._nodes import BadgeNode +from sphinx_ux_badges._visitors import depart_badge_html, visit_badge_html __all__ = [ + "SAB", "BadgeNode", + "BadgeSpec", "build_badge", + "build_badge_from_spec", "build_badge_group", + "build_badge_group_from_specs", "build_toolbar", "setup", ] @@ -58,7 +66,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: Examples -------- - >>> from sphinx_autodoc_badges import setup + >>> from sphinx_ux_badges import setup >>> callable(setup) True """ @@ -71,7 +79,8 @@ def _add_static_path(app: Sphinx) -> None: app.config.html_static_path.append(_static_dir) app.connect("builder-inited", _add_static_path) - app.add_css_file("css/sphinx_autodoc_badges.css") + app.add_css_file("css/sphinx_ux_badges.css") + app.add_css_file("css/sab_palettes.css") return { "version": _EXTENSION_VERSION, diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_builders.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_builders.py new file mode 100644 index 00000000..352374ff --- /dev/null +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_builders.py @@ -0,0 +1,250 @@ +"""Badge builder helpers -- typed API for creating badge nodes. + +Examples +-------- +>>> b = build_badge( +... "readonly", +... tooltip="Read-only", +... classes=["gp-sphinx-fastmcp__safety-readonly"], +... ) +>>> b.astext() +'readonly' + +>>> "gp-sphinx-badge" in b["classes"] +True +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from docutils import nodes + +from sphinx_ux_badges._css import SAB +from sphinx_ux_badges._nodes import BadgeNode + + +@dataclass(frozen=True, slots=True) +class BadgeSpec: + """Typed badge descriptor used by producer extensions. + + Parameters + ---------- + text : str + Visible badge label. + tooltip : str + Tooltip and aria label for the badge. + icon : str + Optional icon rendered via ``::before``. + classes : tuple[str, ...] + Additional CSS classes for package-specific color and role styling. + style : {"full", "icon-only", "inline-icon"} + Structural variant. + fill : {"filled", "outline"} + Visual fill variant. + size : str + Optional size token: ``"xxs"``, ``"xs"``, ``"sm"``, ``"md"``, + ``"lg"``, or ``"xl"``. + tabindex : str + Focus behavior token forwarded to :class:`BadgeNode`. + + Examples + -------- + >>> spec = BadgeSpec("config", tooltip="Sphinx config value") + >>> spec.text + 'config' + """ + + text: str + tooltip: str = "" + icon: str = "" + classes: tuple[str, ...] = field(default_factory=tuple) + style: t.Literal["full", "icon-only", "inline-icon"] = "full" + fill: t.Literal["filled", "outline"] = "filled" + size: str = "" + tabindex: str = "0" + + +def build_badge( + text: str, + *, + tooltip: str = "", + icon: str = "", + classes: t.Sequence[str] = (), + style: t.Literal["full", "icon-only", "inline-icon"] = "full", + fill: t.Literal["filled", "outline"] = "filled", + size: str = "", + tabindex: str = "0", +) -> BadgeNode: + """Build a single badge node. + + Parameters + ---------- + text : str + Visible label. Empty string for icon-only badges. + tooltip : str + Hover text and ``aria-label``. + icon : str + Emoji character for CSS ``::before``. + classes : Sequence[str] + Additional CSS classes (plugin prefix + color class). + style : {"full", "icon-only", "inline-icon"} + Structural variant. + fill : {"filled", "outline"} + Visual fill variant. + size : str + Optional size tier: ``"xxs"``, ``"xs"``, ``"sm"``, ``"md"``, + ``"lg"``, or ``"xl"``. Empty string uses the default (no extra + class). + tabindex : str + ``"0"`` for focusable, ``""`` to skip. + + Returns + ------- + BadgeNode + + Examples + -------- + >>> b = build_badge("async", tooltip="Asynchronous", classes=[SAB.MOD_ASYNC]) + >>> b.astext() + 'async' + + >>> b = build_badge( + ... "", + ... style="icon-only", + ... classes=["gp-sphinx-fastmcp__safety-readonly"], + ... ) + >>> SAB.ICON_ONLY in b["classes"] + True + + >>> b = build_badge("big", size="lg") + >>> SAB.LG in b["classes"] + True + """ + extra_classes = list(classes) + if fill == "outline": + extra_classes.append(SAB.OUTLINE) + return BadgeNode( + text, + badge_tooltip=tooltip, + badge_icon=icon, + badge_style=style, + badge_size=size, + tabindex=tabindex, + classes=extra_classes, + ) + + +def build_badge_from_spec(spec: BadgeSpec) -> BadgeNode: + """Build a :class:`BadgeNode` from a typed :class:`BadgeSpec`. + + Parameters + ---------- + spec : BadgeSpec + Structured badge description from a producer extension. + + Returns + ------- + BadgeNode + Renderable badge node. + """ + return build_badge( + spec.text, + tooltip=spec.tooltip, + icon=spec.icon, + classes=spec.classes, + style=spec.style, + fill=spec.fill, + size=spec.size, + tabindex=spec.tabindex, + ) + + +def build_badge_group( + badges: t.Sequence[BadgeNode], + *, + classes: t.Sequence[str] = (), +) -> nodes.inline: + """Wrap badges in a group container with inter-badge spacing. + + Parameters + ---------- + badges : Sequence[BadgeNode] + Badge nodes to group. + classes : Sequence[str] + Additional CSS classes on the group container. + + Returns + ------- + nodes.inline + + Examples + -------- + >>> from sphinx_ux_badges._nodes import BadgeNode + >>> g = build_badge_group([BadgeNode("a"), BadgeNode("b")]) + >>> SAB.BADGE_GROUP in g["classes"] + True + """ + group = nodes.inline(classes=[SAB.BADGE_GROUP, *classes]) + for i, badge in enumerate(badges): + if i > 0: + group += nodes.Text(" ") + group += badge + return group + + +def build_badge_group_from_specs( + badges: t.Sequence[BadgeSpec], + *, + classes: t.Sequence[str] = (), +) -> nodes.inline: + """Wrap typed badge specs in a shared badge-group container. + + Parameters + ---------- + badges : Sequence[BadgeSpec] + Typed badge descriptors to render. + classes : Sequence[str] + Extra CSS classes for the group container. + + Returns + ------- + nodes.inline + Inline group containing rendered badge nodes. + """ + return build_badge_group( + [build_badge_from_spec(spec) for spec in badges], + classes=classes, + ) + + +def build_toolbar( + badge_group: nodes.inline, + *, + classes: t.Sequence[str] = (), +) -> nodes.inline: + """Wrap a badge group in a toolbar (``margin-left: auto`` for flex titles). + + Parameters + ---------- + badge_group : nodes.inline + Badge group from :func:`build_badge_group`. + classes : Sequence[str] + Additional CSS classes on the toolbar. + + Returns + ------- + nodes.inline + + Examples + -------- + >>> from sphinx_ux_badges._nodes import BadgeNode + >>> g = build_badge_group([BadgeNode("x")]) + >>> t = build_toolbar(g, classes=["gp-sphinx-fastmcp__toolbar"]) + >>> SAB.TOOLBAR in t["classes"] + True + """ + toolbar = nodes.inline(classes=[SAB.TOOLBAR, *classes]) + toolbar += badge_group + return toolbar diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py new file mode 100644 index 00000000..948e79fe --- /dev/null +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -0,0 +1,216 @@ +"""Shared CSS class name constants for sphinx_ux_badges. + +Examples +-------- +>>> SAB.BADGE +'gp-sphinx-badge' + +>>> SAB.BADGE_GROUP +'gp-sphinx-badge-group' + +>>> SAB.TOOLBAR +'gp-sphinx-toolbar' + +>>> SAB.ICON_ONLY +'gp-sphinx-badge--icon-only' + +>>> SAB.XXS +'gp-sphinx-badge--size-xxs' + +>>> SAB.MD +'gp-sphinx-badge--size-md' + +>>> SAB.SM +'gp-sphinx-badge--size-sm' + +>>> SAB.TYPE_FUNCTION +'gp-sphinx-badge--type-function' + +>>> SAB.TYPE_FIXTURE +'gp-sphinx-badge--type-fixture' + +>>> SAB.SCOPE_SESSION +'gp-sphinx-badge--scope-session' + +>>> SAB.TYPE_CONFIG +'gp-sphinx-badge--type-config' +""" + +from __future__ import annotations + + +class SAB: + """CSS class constants under the ``gp-sphinx-`` namespace. + + The ``gp-sphinx-`` prefix identifies these classes as part of the + gp-sphinx workspace. The badge block (``gp-sphinx-badge``) and its + sibling blocks (``gp-sphinx-badge-group``, ``gp-sphinx-toolbar``) are + tier-A shared concepts — any extension may consume them directly, + and the theme may restyle them once for every consumer. + + Covers both structural variants (size, outline, icon-only) and the + unified semantic colour palette from ``sab_palettes.css`` (filename + is historical). + + All consuming ``sphinx-autodoc-*`` packages use these constants for + shared badge primitives (group, badge, toolbar, type, modifier, + state classes). Extension-specific layout and semantic classes + live under each package's own namespace (e.g. + ``gp-sphinx-fastmcp__*`` for FastMCP tool sections, + ``gp-sphinx-pytest-fixtures__*`` for fixture-index layout). + + Examples + -------- + >>> SAB.PREFIX + 'gp-sphinx-badge' + + >>> SAB.OUTLINE + 'gp-sphinx-badge--outline' + + >>> SAB.TYPE_METHOD + 'gp-sphinx-badge--type-method' + + >>> SAB.STATE_AUTOUSE + 'gp-sphinx-badge--state-autouse' + """ + + PREFIX = "gp-sphinx-badge" + + # ── Structural / layout blocks ─────────────────────── + BADGE = "gp-sphinx-badge" + BADGE_GROUP = "gp-sphinx-badge-group" + TOOLBAR = "gp-sphinx-toolbar" + + # Inner label span (BEM element on badge) + BADGE_LABEL = "gp-sphinx-badge__label" + + # ── Badge variants ─────────────────────────────────── + ICON_ONLY = "gp-sphinx-badge--icon-only" + INLINE_ICON = "gp-sphinx-badge--inline-icon" + OUTLINE = "gp-sphinx-badge--outline" + FILLED = "gp-sphinx-badge--filled" + ICON_RIGHT = "gp-sphinx-badge--icon-right" + + # Underline control (compose with dense or any badge) + NO_UNDERLINE = "gp-sphinx-badge--underline-none" + UNDERLINE_DOTTED = "gp-sphinx-badge--underline-dotted" + UNDERLINE_SOLID = "gp-sphinx-badge--underline-solid" + + # Size axis + XXS = "gp-sphinx-badge--size-xxs" + XS = "gp-sphinx-badge--size-xs" + SM = "gp-sphinx-badge--size-sm" + MD = "gp-sphinx-badge--size-md" + LG = "gp-sphinx-badge--size-lg" + XL = "gp-sphinx-badge--size-xl" + + # Dense variant (compact, always-bordered, dotted-underline) + DENSE = "gp-sphinx-badge--dense" + + # ── Python API type badges (filled) ────────────────── + TYPE_FUNCTION = "gp-sphinx-badge--type-function" + TYPE_CLASS = "gp-sphinx-badge--type-class" + TYPE_METHOD = "gp-sphinx-badge--type-method" + TYPE_PROPERTY = "gp-sphinx-badge--type-property" + TYPE_ATTRIBUTE = "gp-sphinx-badge--type-attribute" + TYPE_DATA = "gp-sphinx-badge--type-data" + TYPE_EXCEPTION = "gp-sphinx-badge--type-exception" + TYPE_TYPEALIAS = "gp-sphinx-badge--type-typealias" + TYPE_MODULE = "gp-sphinx-badge--type-module" + + # Slot markers (filled / outlined) for Python API badges + BADGE_TYPE = "gp-sphinx-badge--slot-type" + BADGE_MOD = "gp-sphinx-badge--slot-mod" + + # ── Python API modifier badges (outlined) ──────────── + MOD_ASYNC = "gp-sphinx-badge--mod-async" + MOD_CLASSMETHOD = "gp-sphinx-badge--mod-classmethod" + MOD_STATICMETHOD = "gp-sphinx-badge--mod-staticmethod" + MOD_ABSTRACT = "gp-sphinx-badge--mod-abstract" + MOD_FINAL = "gp-sphinx-badge--mod-final" + + # Sphinx config rebuild-mode badge (outlined) + MOD_REBUILD = "gp-sphinx-badge--mod-rebuild" + + # ── Shared deprecated state ─────────────────────────── + STATE_DEPRECATED = "gp-sphinx-badge--state-deprecated" + + # ── pytest fixture type (filled green) ─────────────── + TYPE_FIXTURE = "gp-sphinx-badge--type-fixture" + + # Slot markers for fixture badges + BADGE_FIXTURE = "gp-sphinx-badge--slot-fixture" + BADGE_SCOPE = "gp-sphinx-badge--slot-scope" + BADGE_KIND = "gp-sphinx-badge--slot-kind" + BADGE_STATE = "gp-sphinx-badge--slot-state" + + # ── pytest fixture scopes (filled) ─────────────────── + SCOPE_SESSION = "gp-sphinx-badge--scope-session" + SCOPE_MODULE = "gp-sphinx-badge--scope-module" + SCOPE_CLASS = "gp-sphinx-badge--scope-class" + + # ── pytest fixture kinds / states (outlined) ───────── + STATE_FACTORY = "gp-sphinx-badge--state-factory" + STATE_OVERRIDE = "gp-sphinx-badge--state-override" + STATE_AUTOUSE = "gp-sphinx-badge--state-autouse" + + # ── Sphinx config (filled amber) ───────────────────── + TYPE_CONFIG = "gp-sphinx-badge--type-config" + + # ── docutils (filled violet) ───────────────────────── + TYPE_DIRECTIVE = "gp-sphinx-badge--type-directive" + TYPE_ROLE = "gp-sphinx-badge--type-role" + TYPE_OPTION = "gp-sphinx-badge--type-option" + + # ── Package metadata (maturity + links) ─────────────── + META_ALPHA = "gp-sphinx-badge--meta-alpha" + META_BETA = "gp-sphinx-badge--meta-beta" + META_LINK = "gp-sphinx-badge--meta-link" + + @staticmethod + def obj_type(name: str) -> str: + """Return the type-specific CSS class for a Python API object. + + Parameters + ---------- + name : str + Python domain object type name. + + Returns + ------- + str + CSS class string, e.g. ``"gp-sphinx-badge--type-function"``. + + Examples + -------- + >>> SAB.obj_type("method") + 'gp-sphinx-badge--type-method' + + >>> SAB.obj_type("exception") + 'gp-sphinx-badge--type-exception' + """ + return f"gp-sphinx-badge--type-{name}" + + @staticmethod + def scope(name: str) -> str: + """Return the scope-specific CSS class for a pytest fixture. + + Parameters + ---------- + name : str + Fixture scope string (``"session"``, ``"module"``, ``"class"``). + + Returns + ------- + str + CSS class string, e.g. ``"gp-sphinx-badge--scope-session"``. + + Examples + -------- + >>> SAB.scope("session") + 'gp-sphinx-badge--scope-session' + + >>> SAB.scope("module") + 'gp-sphinx-badge--scope-module' + """ + return f"gp-sphinx-badge--scope-{name}" diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_nodes.py similarity index 66% rename from packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py rename to packages/sphinx-ux-badges/src/sphinx_ux_badges/_nodes.py index 6214b3c7..240d3bf2 100644 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_nodes.py @@ -9,11 +9,11 @@ >>> node.astext() 'readonly' ->>> "sab-badge" in node["classes"] +>>> "gp-sphinx-badge" in node["classes"] True ->>> n2 = BadgeNode("sm", badge_size="sm") ->>> "sab-sm" in n2["classes"] +>>> n2 = BadgeNode("xxs", badge_size="xxs") +>>> "gp-sphinx-badge--size-xxs" in n2["classes"] True """ @@ -23,7 +23,17 @@ from docutils import nodes -_BADGE_SIZES = frozenset({"xs", "sm", "lg", "xl"}) +from sphinx_ux_badges._css import SAB + +_BADGE_SIZES = frozenset({"xxs", "xs", "sm", "md", "lg", "xl"}) + +# Maps badge_style ctor values to SAB class constants. +_STYLE_CLASSES: dict[str, str] = { + "icon-only": SAB.ICON_ONLY, + "inline-icon": SAB.INLINE_ICON, + "filled": SAB.FILLED, + "outline": SAB.OUTLINE, +} class BadgeNode(nodes.inline): @@ -57,7 +67,7 @@ def __init__( ) -> None: children = [nodes.Text(text)] if text else [] super().__init__("", *children, **attributes) - self["classes"].append("sab-badge") + self["classes"].append(SAB.BADGE) if classes: self["classes"].extend(classes) if badge_tooltip: @@ -66,13 +76,19 @@ def __init__( self["badge_icon"] = badge_icon if badge_style != "full": self["badge_style"] = badge_style - self["classes"].append(f"sab-{badge_style}") + style_class = _STYLE_CLASSES.get(badge_style) + if style_class is not None: + self["classes"].append(style_class) + else: + # Unknown style: preserve back-compat "gp-sphinx-badge--