diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c892243..af6a861 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -130,6 +130,7 @@ jobs: - sphinx-autodoc-docutils - sphinx-autodoc-sphinx - sphinx-autodoc-pytest-fixtures + - sphinx-autodoc-api-style steps: - uses: actions/checkout@v6 diff --git a/CHANGES b/CHANGES index 8816960..5a42369 100644 --- a/CHANGES +++ b/CHANGES @@ -35,9 +35,9 @@ $ uv add gp-sphinx --prerelease allow Furo code blocks use 300, and intermediate weights inherit from surrounding context — previously only Sans had the full set) - Replace `font-weight: 650` with `700` in badge CSS across - `sphinx-autodoc-pytest-fixtures`, `sphinx-gptheme`, and docs (650 is not - a standard Fontsource weight, so browsers were synthesizing bold instead - of using the real font file) + `sphinx-autodoc-api-style`, `sphinx-autodoc-pytest-fixtures`, + `sphinx-gptheme`, and docs (650 is not a standard Fontsource weight, + so browsers were synthesizing bold instead of using the real font file) ### Workspace packages diff --git a/docs/_ext/gas_demo_api.py b/docs/_ext/gas_demo_api.py new file mode 100644 index 0000000..5035c5a --- /dev/null +++ b/docs/_ext/gas_demo_api.py @@ -0,0 +1,187 @@ +"""Synthetic Python objects for the sphinx_autodoc_api_style badge demo page. + +Each object exercises one badge combination so the demo page can show +every type and modifier badge side-by-side: + + Types: function | class | method | property | attribute | data | exception + Modifiers: async | classmethod | staticmethod | abstract | final | deprecated + +These definitions are purely for documentation; they are never used in +production code. +""" + +from __future__ import annotations + +import abc +import typing as t + + +def demo_function(name: str, count: int = 1) -> list[str]: + """Plain function. Shows ``function`` type badge. + + Parameters + ---------- + name : str + The name to repeat. + count : int + Number of repetitions. + + Returns + ------- + list[str] + A list of repeated names. + """ + return [name] * count + + +async def demo_async_function(url: str) -> bytes: + """Asynchronous function. Shows ``async`` + ``function`` badges. + + Parameters + ---------- + url : str + The URL to fetch. + + Returns + ------- + bytes + The fetched content. + """ + return b"" + + +def demo_deprecated_function() -> None: + """Do nothing (deprecated placeholder). + + Shows ``deprecated`` + ``function`` badges. + + .. deprecated:: 2.0 + Use :func:`demo_function` instead. + """ + + +DEMO_CONSTANT: int = 42 +"""Module-level constant. Shows ``data`` type badge.""" + + +class DemoError(Exception): + """Custom exception class. Shows ``exception`` type badge. + + Raised when a demo operation fails unexpectedly. + """ + + +class DemoClass: + """Demonstration class with various method types. + + Shows ``class`` type badge on the class itself, and per-method + badges for each method kind. + + Parameters + ---------- + value : str + Initial value for the demo instance. + """ + + demo_attr: str = "hello" + """Class attribute. Shows ``attribute`` type badge.""" + + def __init__(self, value: str) -> None: + self.value = value + + def regular_method(self, x: int) -> str: + """Regular instance method. Shows ``method`` type badge. + + Parameters + ---------- + x : int + Input value. + + Returns + ------- + str + String representation. + """ + return f"{self.value}:{x}" + + @classmethod + def from_int(cls, n: int) -> DemoClass: + """Class method. Shows ``classmethod`` + ``method`` badges. + + Parameters + ---------- + n : int + Integer to convert. + + Returns + ------- + DemoClass + A new instance. + """ + return cls(str(n)) + + @staticmethod + def utility(a: int, b: int) -> int: + """Add two integers. Shows ``staticmethod`` + ``method`` badges. + + Parameters + ---------- + a : int + First operand. + b : int + Second operand. + + Returns + ------- + int + Sum of operands. + """ + return a + b + + @property + def computed(self) -> str: + """Computed property. Shows ``property`` type badge. + + Returns + ------- + str + The uppercased value. + """ + return self.value.upper() + + async def async_method(self) -> None: + """Asynchronous method. Shows ``async`` + ``method`` badges.""" + + def deprecated_method(self) -> None: + """Do nothing (deprecated placeholder). + + Shows ``deprecated`` + ``method`` badges. + + .. deprecated:: 1.5 + Use :meth:`regular_method` instead. + """ + + +class DemoAbstractBase(abc.ABC): + """Abstract base class. Shows ``class`` type badge. + + Subclass this to provide concrete implementations. + """ + + @abc.abstractmethod + def must_implement(self) -> str: + """Abstract method. Shows ``abstract`` + ``method`` badges. + + Returns + ------- + str + Implementation-specific value. + """ + + @abc.abstractmethod + async def async_abstract(self) -> None: + """Async abstract method. Shows ``async`` + ``abstract`` + ``method`` badges.""" + + +if t.TYPE_CHECKING: + DemoAlias = str | int diff --git a/docs/conf.py b/docs/conf.py index bb0aa2f..72da2b0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,6 +18,7 @@ ) sys.path.insert(0, str(project_root / "packages" / "sphinx-autodoc-docutils" / "src")) sys.path.insert(0, str(project_root / "packages" / "sphinx-autodoc-sphinx" / "src")) +sys.path.insert(0, str(project_root / "packages" / "sphinx-autodoc-api-style" / "src")) sys.path.insert(0, str(cwd / "_ext")) # docs demo modules import gp_sphinx # noqa: E402 @@ -37,6 +38,7 @@ source_branch="main", extra_extensions=[ "package_reference", + "sphinx_autodoc_api_style", "sphinx_autodoc_pytest_fixtures", "sphinx_autodoc_docutils", "sphinx_autodoc_sphinx", diff --git a/docs/index.md b/docs/index.md index 5cbde6c..e6c747b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ Install and get started in minutes. :::{grid-item-card} Packages :link: packages/index :link-type: doc -Seven workspace packages — coordinator, extensions, and theme. +Eight workspace packages — coordinator, extensions, and theme. ::: :::{grid-item-card} Configuration diff --git a/docs/packages/index.md b/docs/packages/index.md index 2f39dcb..a896454 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,6 +1,6 @@ # Packages -Seven workspace packages, each independently installable. +Eight workspace packages, each independently installable. ```{workspace-package-grid} ``` @@ -9,6 +9,7 @@ Seven workspace packages, each independently installable. :hidden: gp-sphinx +sphinx-autodoc-api-style sphinx-autodoc-docutils sphinx-autodoc-sphinx sphinx-autodoc-pytest-fixtures diff --git a/docs/packages/sphinx-autodoc-api-style.md b/docs/packages/sphinx-autodoc-api-style.md new file mode 100644 index 0000000..90b2860 --- /dev/null +++ b/docs/packages/sphinx-autodoc-api-style.md @@ -0,0 +1,131 @@ +(sphinx-autodoc-api-style)= + +# sphinx-autodoc-api-style + +{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` + +Sphinx extension that adds type and modifier badges to standard Python domain +entries (functions, classes, methods, properties, attributes, data, +exceptions). Mirrors the badge system from +{doc}`sphinx-autodoc-pytest-fixtures` so API pages and fixture pages share a +consistent visual language. + +```console +$ pip install sphinx-autodoc-api-style +``` + +## Features + +- **Type badges** (rightmost): `function`, `class`, `method`, `property`, + `attribute`, `data`, `exception` — each with a distinct color +- **Modifier badges** (left of type): `async`, `classmethod`, `staticmethod`, + `abstract`, `final`, `deprecated` +- **Card containers**: bordered cards with secondary-background headers +- **Dark mode**: full light/dark theming via CSS custom properties +- **Accessibility**: keyboard-focusable badges with tooltip popups +- **Non-invasive**: hooks into `doctree-resolved` without replacing directives + +## How it works + +Add `sphinx_autodoc_api_style` to your Sphinx extensions. With `gp-sphinx`, +use `extra_extensions`: + +```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_autodoc_api_style"], +) +``` + +Or without `merge_sphinx_config`: + +```python +extensions = ["sphinx_autodoc_api_style"] +``` + +No special directives are needed — existing `.. autofunction::`, +`.. autoclass::`, `.. automodule::` directives automatically receive badges. + +## Live demo + +```{py:module} gas_demo_api +``` + +### Functions + +```{eval-rst} +.. autofunction:: gas_demo_api.demo_function +``` + +```{eval-rst} +.. autofunction:: gas_demo_api.demo_async_function +``` + +```{eval-rst} +.. autofunction:: gas_demo_api.demo_deprecated_function +``` + +### Module data + +```{eval-rst} +.. autodata:: gas_demo_api.DEMO_CONSTANT +``` + +### Exceptions + +```{eval-rst} +.. autoexception:: gas_demo_api.DemoError +``` + +### Classes + +```{eval-rst} +.. autoclass:: gas_demo_api.DemoClass + :members: + :undoc-members: +``` + +### Abstract base classes + +```{eval-rst} +.. autoclass:: gas_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 | + +## 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. + +```{package-reference} sphinx-autodoc-api-style +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-api-style) · [PyPI](https://pypi.org/project/sphinx-autodoc-api-style/) diff --git a/docs/redirects.txt b/docs/redirects.txt index b9497f7..f5f7e0a 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -4,5 +4,6 @@ extensions/sphinx-argparse-neo packages/sphinx-argparse-neo 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-fonts packages/sphinx-fonts extensions/sphinx-gptheme packages/sphinx-gptheme diff --git a/packages/sphinx-autodoc-api-style/README.md b/packages/sphinx-autodoc-api-style/README.md new file mode 100644 index 0000000..f3beff2 --- /dev/null +++ b/packages/sphinx-autodoc-api-style/README.md @@ -0,0 +1,25 @@ +# sphinx-autodoc-api-style + +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). + +## Install + +```console +$ pip install sphinx-autodoc-api-style +``` + +## Usage + +```python +extensions = ["sphinx_autodoc_api_style"] +``` + +No special directives are required — existing `.. autofunction::`, +`.. autoclass::`, and related directives receive badges automatically. + +## Documentation + +See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-autodoc-api-style/) for +demos and the badge reference. diff --git a/packages/sphinx-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml new file mode 100644 index 0000000..32fd866 --- /dev/null +++ b/packages/sphinx-autodoc-api-style/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-autodoc-api-style" +version = "0.0.1a4" +description = "Sphinx extension for enhanced autodoc API entry styling (badges and cards)" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +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", +] +readme = "README.md" +keywords = ["sphinx", "autodoc", "documentation", "api", "badges"] +dependencies = [ + "sphinx", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_autodoc_api_style"] 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 new file mode 100644 index 0000000..1ae8ccf --- /dev/null +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/__init__.py @@ -0,0 +1,109 @@ +"""Sphinx extension for enhanced Python API entry styling. + +Injects badge groups (type + modifier badges) into standard Python domain +``desc`` nodes (functions, classes, methods, properties, attributes, etc.) +and registers CSS for card-style containers that match the fixture styling +from ``sphinx_autodoc_pytest_fixtures``. + +Badge types: + - **Type badges** (rightmost): function, class, method, property, + attribute, data, exception + - **Modifier badges** (left of type): async, classmethod, staticmethod, + abstract, final, deprecated + +.. note:: + + This extension self-registers its CSS via ``add_css_file()``. The rules + live in ``_static/css/api_style.css`` inside this package. + +Examples +-------- +>>> 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 + +import logging +import pathlib +import typing as t + +from docutils import nodes + +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 ( + depart_abbreviation_html, + on_doctree_resolved, + visit_abbreviation_html, +) + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +__all__ = [ + "_CSS", + "build_badge_group", + "setup", +] + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +_EXTENSION_VERSION = "1.0" + + +class _SetupDict(t.TypedDict): + """Return type for Sphinx extension ``setup()``.""" + + version: str + parallel_read_safe: bool + parallel_write_safe: bool + + +def setup(app: Sphinx) -> _SetupDict: + """Register the ``sphinx_autodoc_api_style`` extension. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + + Returns + ------- + _SetupDict + Extension metadata dict. + + Examples + -------- + >>> callable(setup) + True + """ + app.setup_extension("sphinx.ext.autodoc") + + _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/api_style.css") + + app.add_node( + nodes.abbreviation, + override=True, + html=(visit_abbreviation_html, depart_abbreviation_html), + ) + + app.connect("doctree-resolved", on_doctree_resolved) + + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } 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 new file mode 100644 index 0000000..613b58f --- /dev/null +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py @@ -0,0 +1,166 @@ +"""Badge group rendering helpers for sphinx_autodoc_api_style. + +Builds portable ``nodes.abbreviation`` badge groups that render as +```` in HTML with hover tooltips and keyboard-accessible +focus states. + +Examples +-------- +>>> group = build_badge_group("function", modifiers=frozenset()) +>>> "gas-badge-group" in group["classes"] +True +""" + +from __future__ import annotations + +from docutils import nodes + +from sphinx_autodoc_api_style._css import _CSS + +_TYPE_TOOLTIPS: dict[str, str] = { + "function": "Python function", + "class": "Python class", + "method": "Instance method", + "classmethod": "Class method", + "staticmethod": "Static method", + "property": "Python property", + "attribute": "Class or instance attribute", + "data": "Module-level data", + "exception": "Exception class", + "type": "Type alias", + "module": "Python module", +} + +_TYPE_LABELS: dict[str, str] = { + "function": "function", + "class": "class", + "method": "method", + "classmethod": "method", + "staticmethod": "method", + "property": "property", + "attribute": "attribute", + "data": "data", + "exception": "exception", + "type": "type", + "module": "module", +} + +_MOD_TOOLTIPS: dict[str, str] = { + "async": "Asynchronous \u2014 returns a coroutine", + "classmethod": "Class method \u2014 receives cls as first argument", + "staticmethod": "Static method \u2014 no implicit self or cls", + "abstract": "Abstract \u2014 must be overridden in subclasses", + "final": "Final \u2014 cannot be overridden in subclasses", + "deprecated": "Deprecated \u2014 see docs for replacement", +} + +_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, +} + +_MOD_LABELS: dict[str, str] = { + "async": "async", + "classmethod": "classmethod", + "staticmethod": "staticmethod", + "abstract": "abstract", + "final": "final", + "deprecated": "deprecated", +} + +_MOD_ORDER: tuple[str, ...] = ( + "deprecated", + "abstract", + "final", + "async", + "classmethod", + "staticmethod", +) + + +def build_badge_group( + objtype: str, + *, + modifiers: frozenset[str], + show_type_badge: bool = True, +) -> nodes.inline: + """Return a badge group for a Python API entry. + + Badge slots (left-to-right in visual order): + + * Slots 0\u2013N (modifiers): ``deprecated``, ``abstract``, ``final``, + ``async``, ``classmethod``, ``staticmethod`` \u2014 in fixed order. + * Final slot (type): ``function``, ``class``, ``method``, etc. + + Parameters + ---------- + objtype : str + Python domain object type (``"function"``, ``"class"``, etc.). + modifiers : frozenset[str] + Active modifier names (e.g. ``{"async", "abstract"}``). + show_type_badge : bool + When ``False``, suppress the type badge at the rightmost slot. + + Returns + ------- + nodes.inline + Badge group container with abbreviation badge children. + + Examples + -------- + >>> group = build_badge_group("function", modifiers=frozenset()) + >>> "gas-badge-group" in group["classes"] + True + + >>> group = build_badge_group("method", modifiers=frozenset({"async"})) + >>> len(list(group.findall(nodes.abbreviation))) == 2 + True + + >>> group = build_badge_group( + ... "class", + ... modifiers=frozenset({"abstract", "deprecated"}), + ... ) + >>> labels = [n.astext() for n in group.findall(nodes.abbreviation)] + >>> "deprecated" in labels and "abstract" in labels and "class" in labels + True + """ + group = nodes.inline(classes=[_CSS.BADGE_GROUP]) + badges: list[nodes.abbreviation] = [] + + for mod in _MOD_ORDER: + if mod not in modifiers: + continue + badges.append( + nodes.abbreviation( + _MOD_LABELS[mod], + _MOD_LABELS[mod], + explanation=_MOD_TOOLTIPS[mod], + classes=[_CSS.BADGE, _CSS.BADGE_MOD, _MOD_CSS[mod]], + ), + ) + + if show_type_badge: + label = _TYPE_LABELS.get(objtype, objtype) + tooltip = _TYPE_TOOLTIPS.get(objtype, f"Python {objtype}") + badges.append( + nodes.abbreviation( + label, + label, + explanation=tooltip, + classes=[_CSS.BADGE, _CSS.BADGE_TYPE, _CSS.obj_type(objtype)], + ), + ) + + for badge in badges: + badge["tabindex"] = "0" + + for i, badge in enumerate(badges): + group += badge + if i < len(badges) - 1: + group += nodes.Text(" ") + + return 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 new file mode 100644 index 0000000..0b64d07 --- /dev/null +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_css.py @@ -0,0 +1,80 @@ +"""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 new file mode 100644 index 0000000..86f8ce4 --- /dev/null +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css @@ -0,0 +1,460 @@ +/* ── sphinx_autodoc_api_style ───────────────────────────── + * Badge system for Python API entries: functions, classes, + * methods, properties, attributes, data, exceptions. + * + * 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; +} + +/* 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); +} + +/* ── abbr[title] specificity fix ─────────────────────────── + * Same fix as fixture extension: Normalize.css sets + * abbr[title] { border-bottom: none; text-decoration: underline dotted } + * which conflicts with badge border styling. + * ────────────────────────────────────────────────────────── */ +abbr.gas-badge { + border-bottom-style: solid; + border-bottom-width: var(--gas-badge-border-w, 1px); + text-decoration: underline dotted; +} + +abbr.gas-type-function { border-color: var(--gas-type-function-border); } +abbr.gas-type-class { border-color: var(--gas-type-class-border); } +abbr.gas-type-method { border-color: var(--gas-type-method-border); } +abbr.gas-type-classmethod { border-color: var(--gas-type-method-border); } +abbr.gas-type-staticmethod { border-color: var(--gas-type-method-border); } +abbr.gas-type-property { border-color: var(--gas-type-property-border); } +abbr.gas-type-attribute { border-color: var(--gas-type-attribute-border); } +abbr.gas-type-data { border-color: var(--gas-type-data-border); } +abbr.gas-type-exception { border-color: var(--gas-type-exception-border); } +abbr.gas-type-type { border-color: var(--gas-type-type-border); } +abbr.gas-mod-async { border-color: var(--gas-mod-async-border); } +abbr.gas-mod-classmethod { border-color: var(--gas-mod-classmethod-border); } +abbr.gas-mod-staticmethod { border-color: var(--gas-mod-staticmethod-border); } +abbr.gas-mod-abstract { border-color: var(--gas-mod-abstract-border); } +abbr.gas-mod-final { border-color: var(--gas-mod-final-border); } +abbr.gas-deprecated { border-color: var(--gas-deprecated-border); } + +/* ── Deprecated entry muting ───────────────────────────── */ +dl.py.gas-deprecated > dt { + opacity: 0.7; +} + +/* ── Card treatment for top-level API entries ──────────── */ +dl.py:not(.fixture) { + 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.py:not(.fixture) > dt { + background: var(--color-background-secondary); + border-bottom: 1px solid var(--color-background-border); + padding: 0.5rem 0.75rem; + text-indent: 0; + margin: 0; + padding-left: 1rem; + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + min-height: 2rem; + transition: background 100ms ease-out; +} + +dl.py:not(.fixture) > dt:hover { + background: var(--color-api-background-hover); +} + +dl.py:not(.fixture) > dd { + padding: 0.75rem 1rem; + margin-left: 0 !important; +} + +/* Nested API entries (methods inside classes) get lighter treatment */ +dl.py:not(.fixture) dd dl.py:not(.fixture) { + border-color: var(--color-background-border); + box-shadow: none; + margin-bottom: 1rem; +} + +dl.py:not(.fixture) dd dl.py:not(.fixture) > dt { + background: transparent; + border-bottom-color: var(--color-background-border); + padding-left: 0.75rem; + transition: background 100ms ease-out; +} + +dl.py:not(.fixture) dd dl.py:not(.fixture) > dt:hover { + background: var(--color-api-background-hover); +} + +/* ── Metadata fields (compact grid) ────────────────────── */ +dl.py:not(.fixture) > dd > dl.field-list { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0.25rem 1rem; + border-top: 1px solid var(--color-background-border); + padding-top: 0.5rem; + margin-top: 0.5rem; +} + +dl.py:not(.fixture) > dd > dl.field-list > dt { + grid-column: 1; + font-weight: normal; + text-transform: uppercase; + font-size: 0.85em; + letter-spacing: 0.025em; +} + +dl.py:not(.fixture) > dd > dl.field-list > dd { + grid-column: 2; + margin-left: 0; +} + +@media (max-width: 52rem) { + dl.py:not(.fixture) > dd > dl.field-list { + grid-template-columns: 1fr; + } + dl.py:not(.fixture) > dd > dl.field-list > dt, + dl.py:not(.fixture) > dd > dl.field-list > dd { + grid-column: 1; + } +} 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 new file mode 100644 index 0000000..23d0638 --- /dev/null +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py @@ -0,0 +1,274 @@ +"""Doctree-resolved transforms and HTML visitors for sphinx_autodoc_api_style. + +Injects badge groups into Python domain ``desc`` nodes and provides a +custom ``abbreviation`` HTML visitor that emits ``tabindex`` for +keyboard-accessible tooltips. +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from sphinx import addnodes +from sphinx.util import logging as sphinx_logging +from sphinx.writers.html5 import HTML5Translator + +from sphinx_autodoc_api_style._badges import build_badge_group +from sphinx_autodoc_api_style._css import _CSS + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = sphinx_logging.getLogger(__name__) + +_HANDLED_OBJTYPES: frozenset[str] = frozenset( + { + "function", + "class", + "method", + "classmethod", + "staticmethod", + "property", + "attribute", + "data", + "exception", + "type", + }, +) + +_SKIP_OBJTYPES: frozenset[str] = frozenset( + { + "fixture", + "module", + }, +) + +_KEYWORD_TO_MOD: dict[str, str] = { + "async": "async", + "classmethod": "classmethod", + "static": "staticmethod", + "abstract": "abstract", + "abstractmethod": "abstract", + "final": "final", +} + + +def _detect_modifiers(sig_node: addnodes.desc_signature) -> frozenset[str]: + """Detect modifier keywords from a signature node's annotations. + + Walks ``desc_annotation`` children looking for ``desc_sig_keyword`` + nodes whose text maps to a known modifier. + + Parameters + ---------- + sig_node : addnodes.desc_signature + The signature node to inspect. + + Returns + ------- + frozenset[str] + Set of detected modifier names. + + Examples + -------- + >>> from sphinx import addnodes + >>> sig = addnodes.desc_signature() + >>> ann = addnodes.desc_annotation() + >>> ann += addnodes.desc_sig_keyword("", "async") + >>> sig += ann + >>> sorted(_detect_modifiers(sig)) + ['async'] + """ + mods: set[str] = set() + for ann in sig_node.findall(addnodes.desc_annotation): + for kw in ann.findall(addnodes.desc_sig_keyword): + text = kw.astext().strip() + if text in _KEYWORD_TO_MOD: + mods.add(_KEYWORD_TO_MOD[text]) + return frozenset(mods) + + +def _detect_deprecated(desc_node: addnodes.desc) -> bool: + """Check whether a desc node's own content has a deprecation notice. + + Looks for ``versionmodified`` nodes with ``type='deprecated'`` in + direct ``desc_content`` children, excluding content nested inside + child ``desc`` nodes (so a class is not marked deprecated just + because one of its methods is). + + Parameters + ---------- + desc_node : addnodes.desc + The description node to inspect. + + Returns + ------- + bool + ``True`` if a deprecation notice is found. + + Examples + -------- + >>> from sphinx import addnodes + >>> desc = addnodes.desc() + >>> _detect_deprecated(desc) + False + """ + for child in desc_node.children: + if not isinstance(child, addnodes.desc_content): + continue + for node in child.findall(addnodes.versionmodified): + if node.get("type") != "deprecated": + continue + parent = node.parent + inside_nested = False + while parent is not None and parent is not child: + if isinstance(parent, addnodes.desc): + inside_nested = True + break + parent = parent.parent + if not inside_nested: + return True + return False + + +def _inject_badges(sig_node: addnodes.desc_signature, objtype: str) -> None: + """Inject a toolbar containing badges, viewcode, and headerlink. + + 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. + + Parameters + ---------- + sig_node : addnodes.desc_signature + The signature node to modify. + objtype : str + Python domain object type. + + Examples + -------- + >>> from sphinx import addnodes + >>> sig = addnodes.desc_signature() + >>> sig += addnodes.desc_name("", "my_func") + >>> _inject_badges(sig, "function") + >>> sig.get("gas_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 + + +def on_doctree_resolved( + app: Sphinx, + doctree: nodes.document, + docname: str, +) -> None: + """Inject badges into Python domain description nodes. + + Connected to the ``doctree-resolved`` event. Walks all ``desc`` + nodes, filters to Python domain entries, and injects badge groups + for functions, classes, methods, properties, attributes, etc. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + doctree : nodes.document + The resolved document tree. + docname : str + The name of the document being resolved. + + Examples + -------- + >>> from unittest.mock import MagicMock + >>> app = MagicMock() + >>> from docutils import nodes + >>> doc = nodes.document(None, None) + >>> on_doctree_resolved(app, doc, "index") + """ + for desc_node in doctree.findall(addnodes.desc): + domain = desc_node.get("domain") + objtype = desc_node.get("objtype", "") + + if domain != "py": + continue + if objtype in _SKIP_OBJTYPES: + continue + if objtype not in _HANDLED_OBJTYPES: + continue + + for child in desc_node.children: + if isinstance(child, addnodes.desc_signature): + _inject_badges(child, objtype) + + +def visit_abbreviation_html( + self: HTML5Translator, + node: nodes.abbreviation, +) -> None: + """Emit ```` with ``tabindex`` when present. + + Strict superset of Sphinx's built-in ``visit_abbreviation``: + non-badge abbreviation nodes produce identical output because the + ``tabindex`` guard only fires when the attribute is explicitly set. + + Parameters + ---------- + self : HTML5Translator + The HTML translator instance. + node : nodes.abbreviation + The abbreviation node being visited. + """ + attrs: dict[str, t.Any] = {} + if node.get("explanation"): + attrs["title"] = node["explanation"] + if node.get("tabindex"): + attrs["tabindex"] = node["tabindex"] + self.body.append(self.starttag(node, "abbr", "", **attrs)) + + +def depart_abbreviation_html( + self: HTML5Translator, + node: nodes.abbreviation, +) -> None: + """Close the ```` tag. + + Parameters + ---------- + self : HTML5Translator + The HTML translator instance. + node : nodes.abbreviation + The abbreviation node being departed. + """ + self.body.append("") diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/py.typed b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index f6b0c12..385a5fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ sphinx-argparse-neo = { workspace = true } sphinx-autodoc-pytest-fixtures = { workspace = true } sphinx-autodoc-docutils = { workspace = true } sphinx-autodoc-sphinx = { workspace = true } +sphinx-autodoc-api-style = { workspace = true } gp-sphinx = { workspace = true } [dependency-groups] @@ -31,6 +32,7 @@ dev = [ "sphinx-autodoc-pytest-fixtures", "sphinx-autodoc-docutils", "sphinx-autodoc-sphinx", + "sphinx-autodoc-api-style", # Docs "sphinx-autobuild", # Testing @@ -129,6 +131,7 @@ known-first-party = [ "sphinx_autodoc_pytest_fixtures", "sphinx_autodoc_docutils", "sphinx_autodoc_sphinx", + "sphinx_autodoc_api_style", ] combine-as-imports = true required-imports = [ @@ -144,11 +147,13 @@ convention = "numpy" "packages/sphinx-argparse-neo/**/*.py" = ["E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "packages/sphinx-autodoc-pytest-fixtures/**/*.py" = ["D417", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "packages/sphinx-autodoc-docutils/**/*.py" = ["D417", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] +"packages/sphinx-autodoc-api-style/**/*.py" = ["D417", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/argparse_neo/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/autodoc_docutils/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/autodoc_sphinx/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/pytest_fixtures/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] +"tests/ext/api_style/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/docutils/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] [tool.pytest.ini_options] @@ -164,6 +169,7 @@ testpaths = [ "packages/sphinx-fonts/src", "packages/sphinx-gptheme/src", "packages/sphinx-autodoc-sphinx/src", + "packages/sphinx-autodoc-api-style/src", ] filterwarnings = [ "ignore:distutils Version classes are deprecated. Use packaging.version instead.", diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index 016086f..82519ba 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -537,6 +537,24 @@ def smoke_sphinx_autodoc_pytest_fixtures(dist_dir: pathlib.Path, version: str) - ) +def smoke_sphinx_autodoc_api_style(dist_dir: pathlib.Path, version: str) -> None: + """Verify the autodoc-api-style extension installs and imports cleanly.""" + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv( + python_path, + *_workspace_wheel_requirements(dist_dir), + ) + _run_python( + python_path, + ( + "import sphinx_autodoc_api_style; " + "from sphinx_autodoc_api_style import setup; " + "assert callable(setup)" + ), + ) + + def smoke( target: str, *, @@ -561,6 +579,7 @@ def smoke( "sphinx-autodoc-docutils": smoke_sphinx_autodoc_docutils, "sphinx-autodoc-sphinx": smoke_sphinx_autodoc_sphinx, "sphinx-autodoc-pytest-fixtures": smoke_sphinx_autodoc_pytest_fixtures, + "sphinx-autodoc-api-style": smoke_sphinx_autodoc_api_style, } if target not in runners: message = f"unknown smoke target: {target}" @@ -593,6 +612,7 @@ def main() -> int: "sphinx-autodoc-docutils", "sphinx-autodoc-sphinx", "sphinx-autodoc-pytest-fixtures", + "sphinx-autodoc-api-style", ], ) smoke_parser.add_argument("--dist-dir", type=pathlib.Path) diff --git a/tests/ext/api_style/__init__.py b/tests/ext/api_style/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ext/api_style/test_api_style.py b/tests/ext/api_style/test_api_style.py new file mode 100644 index 0000000..efa9f31 --- /dev/null +++ b/tests/ext/api_style/test_api_style.py @@ -0,0 +1,523 @@ +"""Tests for sphinx_autodoc_api_style Sphinx extension.""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from sphinx import addnodes + +from sphinx_autodoc_api_style._badges import ( + _MOD_ORDER, + _MOD_TOOLTIPS, + _TYPE_LABELS, + _TYPE_TOOLTIPS, + build_badge_group, +) +from sphinx_autodoc_api_style._css import _CSS +from sphinx_autodoc_api_style._transforms import ( + _HANDLED_OBJTYPES, + _KEYWORD_TO_MOD, + _SKIP_OBJTYPES, + _detect_deprecated, + _detect_modifiers, + _inject_badges, + on_doctree_resolved, +) + +# --------------------------------------------------------------------------- +# _CSS constants +# --------------------------------------------------------------------------- + + +def test_css_prefix() -> None: + """CSS prefix is 'gas'.""" + assert _CSS.PREFIX == "gas" + + +def test_css_badge_group_class() -> None: + """Badge group class includes prefix.""" + assert _CSS.BADGE_GROUP == "gas-badge-group" + + +def test_css_obj_type_class() -> None: + """obj_type() returns prefixed type-specific class.""" + assert _CSS.obj_type("function") == "gas-type-function" + assert _CSS.obj_type("class") == "gas-type-class" + assert _CSS.obj_type("method") == "gas-type-method" + + +# --------------------------------------------------------------------------- +# build_badge_group +# --------------------------------------------------------------------------- + + +def test_badge_group_returns_inline() -> None: + """build_badge_group returns a nodes.inline with badge-group class.""" + group = build_badge_group("function", modifiers=frozenset()) + assert isinstance(group, nodes.inline) + assert _CSS.BADGE_GROUP in group["classes"] + + +def test_badge_group_type_badge_present() -> None: + """Type badge is present when show_type_badge is True (default).""" + group = build_badge_group("class", modifiers=frozenset()) + badges = list(group.findall(nodes.abbreviation)) + assert len(badges) == 1 + assert badges[0].astext() == "class" + assert _CSS.BADGE_TYPE in badges[0]["classes"] + assert _CSS.obj_type("class") in badges[0]["classes"] + + +def test_badge_group_type_badge_suppressed() -> None: + """Type badge is absent when show_type_badge is False.""" + group = build_badge_group("function", modifiers=frozenset(), show_type_badge=False) + badges = list(group.findall(nodes.abbreviation)) + assert len(badges) == 0 + + +def test_badge_group_with_modifiers() -> None: + """Modifier badges appear before the type badge.""" + group = build_badge_group( + "method", + modifiers=frozenset({"async", "abstract"}), + ) + badges = list(group.findall(nodes.abbreviation)) + labels = [b.astext() for b in badges] + assert "abstract" in labels + assert "async" in labels + assert "method" in labels + # Type badge is last + assert labels[-1] == "method" + + +def test_badge_group_modifier_order() -> None: + """Modifiers appear in the canonical order defined by _MOD_ORDER.""" + all_mods = frozenset(_MOD_ORDER) + group = build_badge_group("function", modifiers=all_mods) + badges = list(group.findall(nodes.abbreviation)) + mod_labels = [b.astext() for b in badges if _CSS.BADGE_MOD in b["classes"]] + expected = list(_MOD_ORDER) + assert mod_labels == expected + + +def test_badge_group_tabindex() -> None: + """All badges have tabindex='0' for keyboard accessibility.""" + group = build_badge_group( + "function", + modifiers=frozenset({"async"}), + ) + for badge in group.findall(nodes.abbreviation): + assert badge.get("tabindex") == "0" + + +def test_badge_group_tooltips() -> None: + """Badges have explanation attributes for hover tooltips.""" + group = build_badge_group( + "function", + modifiers=frozenset({"async"}), + ) + badges = list(group.findall(nodes.abbreviation)) + async_badge = [b for b in badges if b.astext() == "async"][0] + assert async_badge["explanation"] == _MOD_TOOLTIPS["async"] + + func_badge = [b for b in badges if b.astext() == "function"][0] + assert func_badge["explanation"] == _TYPE_TOOLTIPS["function"] + + +def test_badge_group_text_separators() -> None: + """Text separators between badges for non-HTML builders.""" + group = build_badge_group( + "method", + modifiers=frozenset({"async"}), + ) + text_nodes = [c for c in group.children if isinstance(c, nodes.Text)] + assert len(text_nodes) == 1 + assert text_nodes[0].astext() == " " + + +def test_badge_group_single_badge_no_separator() -> None: + """No text separator when only one badge.""" + group = build_badge_group("function", modifiers=frozenset()) + text_nodes = [c for c in group.children if isinstance(c, nodes.Text)] + assert len(text_nodes) == 0 + + +def test_badge_group_deprecated() -> None: + """Deprecated modifier badge uses DEPRECATED CSS class.""" + group = build_badge_group("class", modifiers=frozenset({"deprecated"})) + badges = list(group.findall(nodes.abbreviation)) + dep_badge = [b for b in badges if b.astext() == "deprecated"][0] + assert _CSS.DEPRECATED in dep_badge["classes"] + assert _CSS.BADGE_MOD in dep_badge["classes"] + + +def test_badge_group_all_type_labels() -> None: + """All handled objtypes produce a valid type badge label.""" + for objtype in _HANDLED_OBJTYPES: + group = build_badge_group(objtype, modifiers=frozenset()) + badges = list(group.findall(nodes.abbreviation)) + assert len(badges) >= 1 + label = badges[-1].astext() + assert label == _TYPE_LABELS.get(objtype, objtype) + + +# --------------------------------------------------------------------------- +# _detect_modifiers +# --------------------------------------------------------------------------- + + +def test_detect_modifiers_empty() -> None: + """Empty signature has no modifiers.""" + sig = addnodes.desc_signature() + assert _detect_modifiers(sig) == frozenset() + + +def test_detect_modifiers_async() -> None: + """Detects async keyword in desc_annotation.""" + sig = addnodes.desc_signature() + ann = addnodes.desc_annotation() + ann += addnodes.desc_sig_keyword("", "async") + sig += ann + assert "async" in _detect_modifiers(sig) + + +def test_detect_modifiers_classmethod() -> None: + """Detects classmethod keyword.""" + sig = addnodes.desc_signature() + ann = addnodes.desc_annotation() + ann += addnodes.desc_sig_keyword("", "classmethod") + sig += ann + assert "classmethod" in _detect_modifiers(sig) + + +def test_detect_modifiers_static() -> None: + """Detects 'static' keyword and maps to 'staticmethod'.""" + sig = addnodes.desc_signature() + ann = addnodes.desc_annotation() + ann += addnodes.desc_sig_keyword("", "static") + sig += ann + assert "staticmethod" in _detect_modifiers(sig) + + +def test_detect_modifiers_abstract() -> None: + """Detects abstract keyword.""" + sig = addnodes.desc_signature() + ann = addnodes.desc_annotation() + ann += addnodes.desc_sig_keyword("", "abstract") + sig += ann + assert "abstract" in _detect_modifiers(sig) + + +def test_detect_modifiers_abstractmethod() -> None: + """Detects abstractmethod keyword (used by py:property).""" + sig = addnodes.desc_signature() + ann = addnodes.desc_annotation() + ann += addnodes.desc_sig_keyword("", "abstractmethod") + sig += ann + assert "abstract" in _detect_modifiers(sig) + + +def test_detect_modifiers_multiple() -> None: + """Detects multiple keywords in one annotation.""" + sig = addnodes.desc_signature() + ann = addnodes.desc_annotation() + ann += addnodes.desc_sig_keyword("", "abstract") + ann += addnodes.desc_sig_space() + ann += addnodes.desc_sig_keyword("", "async") + sig += ann + mods = _detect_modifiers(sig) + assert "abstract" in mods + assert "async" in mods + + +def test_detect_modifiers_ignores_type_keywords() -> None: + """Keywords like 'class' and 'property' are not modifiers.""" + sig = addnodes.desc_signature() + ann = addnodes.desc_annotation() + ann += addnodes.desc_sig_keyword("", "class") + sig += ann + assert _detect_modifiers(sig) == frozenset() + + +def test_keyword_to_mod_mapping() -> None: + """_KEYWORD_TO_MOD maps Sphinx keywords to modifier names.""" + assert _KEYWORD_TO_MOD["async"] == "async" + assert _KEYWORD_TO_MOD["static"] == "staticmethod" + assert _KEYWORD_TO_MOD["abstractmethod"] == "abstract" + + +# --------------------------------------------------------------------------- +# _detect_deprecated +# --------------------------------------------------------------------------- + + +def test_detect_deprecated_false() -> None: + """Returns False when no versionmodified node is present.""" + desc = addnodes.desc() + assert not _detect_deprecated(desc) + + +def test_detect_deprecated_true() -> None: + """Returns True when a deprecated versionmodified node is present.""" + desc = addnodes.desc() + content = addnodes.desc_content() + vm = addnodes.versionmodified() + vm["type"] = "deprecated" + content += vm + desc += content + assert _detect_deprecated(desc) + + +def test_detect_deprecated_ignores_versionadded() -> None: + """Returns False for versionadded — only deprecated triggers.""" + desc = addnodes.desc() + content = addnodes.desc_content() + vm = addnodes.versionmodified() + vm["type"] = "versionadded" + content += vm + desc += content + assert not _detect_deprecated(desc) + + +# --------------------------------------------------------------------------- +# _inject_badges +# --------------------------------------------------------------------------- + + +def test_inject_badges_sets_flag() -> None: + """_inject_badges sets gas_badges_injected flag on signature.""" + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "my_func") + _inject_badges(sig, "function") + assert sig.get("gas_badges_injected") is True + + +def test_inject_badges_idempotent() -> None: + """Calling _inject_badges twice doesn't duplicate badges.""" + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "my_func") + _inject_badges(sig, "function") + badge_count_1 = len(list(sig.findall(nodes.abbreviation))) + _inject_badges(sig, "function") + badge_count_2 = len(list(sig.findall(nodes.abbreviation))) + assert badge_count_1 == badge_count_2 + + +def test_inject_badges_adds_toolbar_with_badge_group() -> None: + """Toolbar containing badge group is added to the signature.""" + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "my_func") + _inject_badges(sig, "function") + toolbars = [ + c + for c in sig.children + if isinstance(c, nodes.inline) and _CSS.TOOLBAR in c.get("classes", []) + ] + assert len(toolbars) == 1 + groups = list(toolbars[0].findall(nodes.inline)) + badge_groups = [g for g in groups if _CSS.BADGE_GROUP in g.get("classes", [])] + assert len(badge_groups) == 1 + + +def test_inject_badges_detects_deprecated_parent() -> None: + """Deprecated modifier is detected from parent desc node.""" + desc = addnodes.desc() + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "old_func") + desc += sig + content = addnodes.desc_content() + vm = addnodes.versionmodified() + vm["type"] = "deprecated" + content += vm + desc += content + + _inject_badges(sig, "function") + + badges = list(sig.findall(nodes.abbreviation)) + labels = [b.astext() for b in badges] + assert "deprecated" in labels + + +def test_inject_badges_toolbar_contains_viewcode() -> None: + """Viewcode [source] link is inside the toolbar, after badge group.""" + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "my_func") + viewcode_span = nodes.inline(classes=["viewcode-link"]) + viewcode_span += nodes.Text("[source]") + viewcode_ref = nodes.reference("", "", viewcode_span, internal=False) + sig += viewcode_ref + + _inject_badges(sig, "function") + + toolbars = [ + c + for c in sig.children + if isinstance(c, nodes.inline) and _CSS.TOOLBAR in c.get("classes", []) + ] + assert len(toolbars) == 1 + toolbar = toolbars[0] + + toolbar_items: list[str] = [] + for c in toolbar.children: + if isinstance(c, nodes.inline) and _CSS.BADGE_GROUP in c.get("classes", []): + toolbar_items.append("badge_group") + elif isinstance(c, nodes.reference): + toolbar_items.append("viewcode") + assert toolbar_items == ["badge_group", "viewcode"] + + +def test_inject_badges_headerlink_not_in_toolbar() -> None: + """Headerlink stays as a direct child of sig, never inside the toolbar. + + Sphinx's HTML writer adds the headerlink as raw HTML during + ``depart_desc_signature``, so it's not a doctree node during our + transform. But if a theme or extension adds one as a node, we must + leave it alone — it belongs next to the signature name, not grouped + with badges and [source] in the toolbar. + """ + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "Server") + + headerlink = nodes.reference( + "", "\u00b6", refuri="#libtmux.Server", classes=["headerlink"] + ) + sig += headerlink + + viewcode_span = nodes.inline(classes=["viewcode-link"]) + viewcode_span += nodes.Text("[source]") + viewcode_ref = nodes.reference("", "", viewcode_span, internal=False) + sig += viewcode_ref + + _inject_badges(sig, "class") + + toolbar = None + for c in sig.children: + if isinstance(c, nodes.inline) and _CSS.TOOLBAR in c.get("classes", []): + toolbar = c + break + assert toolbar is not None + + toolbar_refs = list(toolbar.findall(nodes.reference)) + for ref in toolbar_refs: + assert "headerlink" not in ref.get("classes", []), ( + "headerlink must not be inside the toolbar" + ) + + sig_direct_refs = [ + c + for c in sig.children + if isinstance(c, nodes.reference) and "headerlink" in c.get("classes", []) + ] + assert len(sig_direct_refs) == 1, "headerlink should remain a direct child of sig" + + +# --------------------------------------------------------------------------- +# on_doctree_resolved +# --------------------------------------------------------------------------- + + +def test_on_doctree_resolved_processes_py_desc(monkeypatch: t.Any) -> None: + """on_doctree_resolved injects badges into py function desc nodes.""" + from unittest.mock import MagicMock + + app = MagicMock() + doc = nodes.document(None, None) # type: ignore[arg-type] + desc = addnodes.desc() + desc["domain"] = "py" + desc["objtype"] = "function" + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "my_func") + desc += sig + desc += addnodes.desc_content() + doc += desc + + on_doctree_resolved(app, doc, "index") + + assert sig.get("gas_badges_injected") is True + + +def test_on_doctree_resolved_skips_fixture() -> None: + """on_doctree_resolved skips fixture objtypes.""" + from unittest.mock import MagicMock + + app = MagicMock() + doc = nodes.document(None, None) # type: ignore[arg-type] + desc = addnodes.desc() + desc["domain"] = "py" + desc["objtype"] = "fixture" + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "my_fixture") + desc += sig + doc += desc + + on_doctree_resolved(app, doc, "index") + + assert sig.get("gas_badges_injected") is None + + +def test_on_doctree_resolved_skips_non_py() -> None: + """on_doctree_resolved skips non-Python domain entries.""" + from unittest.mock import MagicMock + + app = MagicMock() + doc = nodes.document(None, None) # type: ignore[arg-type] + desc = addnodes.desc() + desc["domain"] = "c" + desc["objtype"] = "function" + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "c_func") + desc += sig + doc += desc + + on_doctree_resolved(app, doc, "index") + + assert sig.get("gas_badges_injected") is None + + +def test_on_doctree_resolved_handles_multiple() -> None: + """on_doctree_resolved processes all qualifying desc nodes.""" + from unittest.mock import MagicMock + + app = MagicMock() + doc = nodes.document(None, None) # type: ignore[arg-type] + + for objtype in ("function", "class", "method"): + desc = addnodes.desc() + desc["domain"] = "py" + desc["objtype"] = objtype + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", f"obj_{objtype}") + desc += sig + desc += addnodes.desc_content() + doc += desc + + on_doctree_resolved(app, doc, "index") + + for desc in doc.findall(addnodes.desc): + for sig in desc.findall(addnodes.desc_signature): + assert sig.get("gas_badges_injected") is True + + +# --------------------------------------------------------------------------- +# Handled vs skipped objtypes +# --------------------------------------------------------------------------- + + +def test_handled_objtypes_comprehensive() -> None: + """_HANDLED_OBJTYPES covers all standard Python domain types.""" + expected_minimum = { + "function", + "class", + "method", + "property", + "attribute", + "data", + "exception", + } + assert expected_minimum.issubset(_HANDLED_OBJTYPES) + + +def test_skip_objtypes_includes_fixture() -> None: + """_SKIP_OBJTYPES includes fixture to avoid conflict.""" + assert "fixture" in _SKIP_OBJTYPES diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index e146706..f3752d0 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -21,6 +21,7 @@ def test_workspace_packages_lists_publishable_packages() -> None: assert names == { "gp-sphinx", "sphinx-argparse-neo", + "sphinx-autodoc-api-style", "sphinx-autodoc-docutils", "sphinx-autodoc-pytest-fixtures", "sphinx-autodoc-sphinx", @@ -71,7 +72,9 @@ def test_docs_package_pages_exist_for_every_workspace_package() -> None: package_names = { package["name"] for package in package_reference.workspace_packages() } - assert page_names == package_names + assert package_names <= page_names, ( + f"Missing docs pages for packages: {package_names - page_names}" + ) def test_extension_modules_skips_unimportable_module() -> None: diff --git a/uv.lock b/uv.lock index f3cc982..2df4997 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ members = [ "gp-sphinx", "gp-sphinx-workspace", "sphinx-argparse-neo", + "sphinx-autodoc-api-style", "sphinx-autodoc-docutils", "sphinx-autodoc-pytest-fixtures", "sphinx-autodoc-sphinx", @@ -465,6 +466,7 @@ dev = [ { name = "sphinx-argparse-neo" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-docutils" }, { name = "sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx" }, @@ -491,6 +493,7 @@ dev = [ { name = "ruff" }, { name = "sphinx-argparse-neo", editable = "packages/sphinx-argparse-neo" }, { name = "sphinx-autobuild" }, + { name = "sphinx-autodoc-api-style", editable = "packages/sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-docutils", editable = "packages/sphinx-autodoc-docutils" }, { name = "sphinx-autodoc-pytest-fixtures", editable = "packages/sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx", editable = "packages/sphinx-autodoc-sphinx" }, @@ -1262,6 +1265,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, ] +[[package]] +name = "sphinx-autodoc-api-style" +version = "0.0.1a4" +source = { editable = "packages/sphinx-autodoc-api-style" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "sphinx" }] + [[package]] name = "sphinx-autodoc-docutils" version = "0.0.1a4"