From 62b655051bbc7b82da1057824252a1d1a4e83c10 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 19:05:47 -0500 Subject: [PATCH 01/57] docs(packages): Complete documentation site with package pages, config reference, and API why: The extensions/ hub had 3 dead-end cards (sphinx-fonts, sphinx-gptheme, sphinx-argparse-neo linked to nothing), no configuration reference for merge_sphinx_config(), and no API docs. what: - Rename extensions/ to packages/ (gp-sphinx and sphinx-gptheme aren't extensions; packages/ matches the UV workspace vocabulary) - Add package pages for gp-sphinx, sphinx-fonts, sphinx-gptheme, and sphinx-argparse-neo with badges, install commands, usage examples, and source links - Add configuration.md: full parameter table for merge_sphinx_config(), auto-computed values, hardcoded defaults, shared defaults by category (extensions, theme, fonts, MyST, autodoc, copybutton, napoleon) - Add api.md: autodoc for merge_sphinx_config, make_linkcode_resolve, deep_merge - Update landing page: add Packages and Configuration cards - Add redirect entries for old extensions/ URLs --- docs/api.md | 23 +++ docs/configuration.md | 138 ++++++++++++++++++ docs/extensions/index.md | 36 ----- docs/index.md | 18 ++- docs/packages/gp-sphinx.md | 46 ++++++ docs/packages/index.md | 53 +++++++ docs/packages/sphinx-argparse-neo.md | 52 +++++++ .../sphinx-autodoc-pytest-fixtures.md | 0 docs/packages/sphinx-fonts.md | 51 +++++++ docs/packages/sphinx-gptheme.md | 38 +++++ docs/redirects.txt | 2 + 11 files changed, 416 insertions(+), 41 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/configuration.md delete mode 100644 docs/extensions/index.md create mode 100644 docs/packages/gp-sphinx.md create mode 100644 docs/packages/index.md create mode 100644 docs/packages/sphinx-argparse-neo.md rename docs/{extensions => packages}/sphinx-autodoc-pytest-fixtures.md (100%) create mode 100644 docs/packages/sphinx-fonts.md create mode 100644 docs/packages/sphinx-gptheme.md diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..59315974 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,23 @@ +# API Reference + +Public API for building Sphinx configurations and source link resolvers. + +For shared defaults and configuration options, see {doc}`configuration`. + +## merge_sphinx_config + +```{eval-rst} +.. autofunction:: gp_sphinx.config.merge_sphinx_config +``` + +## make_linkcode_resolve + +```{eval-rst} +.. autofunction:: gp_sphinx.config.make_linkcode_resolve +``` + +## deep_merge + +```{eval-rst} +.. autofunction:: gp_sphinx.config.deep_merge +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..b940483c --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,138 @@ +(configuration)= + +# Configuration + +Reference for `merge_sphinx_config()` and the shared defaults it applies. + +## merge_sphinx_config() + +All parameters are keyword-only. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `project` | `str` | required | Sphinx project name | +| `version` | `str` | required | Project version (also sets `release`) | +| `copyright` | `str` | required | Copyright string | +| `extensions` | `list[str] \| None` | `None` | Replace entire extension list (overrides defaults) | +| `extra_extensions` | `list[str] \| None` | `None` | Append to default extension list | +| `remove_extensions` | `list[str] \| None` | `None` | Remove specific extensions from defaults | +| `theme_options` | `dict \| None` | `None` | Deep-merged with `DEFAULT_THEME_OPTIONS` | +| `source_repository` | `str \| None` | `None` | GitHub URL (auto-computes `issue_url_tpl` and footer icon) | +| `source_branch` | `str` | `"master"` | Default branch name | +| `light_logo` / `dark_logo` | `str \| None` | `None` | Theme logo paths | +| `docs_url` | `str \| None` | `None` | Docs URL (auto-computes OGP settings) | +| `intersphinx_mapping` | `Mapping \| None` | `None` | Intersphinx targets | +| `**overrides` | `Any` | — | Any Sphinx config key, passed through verbatim | + +`**overrides` is the escape hatch — any valid Sphinx configuration key can be +passed as a keyword argument. This includes extension-specific settings like +`rediraffe_redirects`, `pytest_fixture_lint_level`, or `html_favicon`. +Auto-computed values can also be overridden this way. + +## Auto-computed values + +When `source_repository` is provided: + +- `issue_url_tpl` for `linkify_issues` +- `html_theme_options["source_repository"]` +- Footer icon GitHub URL + +When `docs_url` is provided: + +- `ogp_site_url` for `sphinxext.opengraph` +- `ogp_site_name` (set to project name) +- `ogp_image` (`_static/img/icons/icon-192x192.png`) + +When `linkcode_resolve` is in `**overrides`: + +- `sphinx.ext.linkcode` is auto-appended to extensions + +## Hardcoded defaults + +Set unconditionally. Override via `**overrides` if needed: + +| Key | Value | +|-----|-------| +| `master_doc` | `"index"` | +| `release` | same as `version` | +| `source_suffix` | `{".rst": "restructuredtext", ".md": "markdown"}` | +| `html_static_path` | `["_static"]` | +| `templates_path` | `["_templates"]` | +| `pygments_style` | `"monokai"` | +| `pygments_dark_style` | `"monokai"` | +| `exclude_patterns` | `["_build"]` | + +## Injected setup() + +The returned config includes a `setup` function that: + +- Registers `js/spa-nav.js` with deferred loading (from sphinx-gptheme) +- Connects a `build-finished` hook to remove `tabs.js` (sphinx-inline-tabs workaround) + +## Integration pattern + +```python +conf = merge_sphinx_config(...) +globals().update(conf) +``` + +This injects all keys into the module namespace, which is how Sphinx +reads `conf.py`. + +## Default extensions + +| Extension | Purpose | +|-----------|---------| +| `sphinx.ext.autodoc` | Auto-document Python objects | +| `sphinx_fonts` | Self-hosted fonts via Fontsource CDN | +| `sphinx.ext.intersphinx` | Cross-project linking | +| `sphinx_autodoc_typehints` | Type hints in docstrings | +| `sphinx.ext.todo` | TODO directive | +| `sphinx.ext.napoleon` | NumPy/Google docstring support | +| `sphinx_inline_tabs` | Inline tab containers | +| `sphinx_copybutton` | Copy button on code blocks | +| `sphinxext.opengraph` | OpenGraph meta tags | +| `sphinxext.rediraffe` | URL redirects | +| `sphinx_design` | Cards, grids, badges | +| `myst_parser` | Markdown support | +| `linkify_issues` | Auto-link `#123` to issues (from gp-libs) | + +## Default theme + +`sphinx-gptheme` — Furo child theme. Source directory `docs/`, source +branch `master`, GitHub footer icon. Theme options are deep-merged when +`theme_options` is passed. + +## Font defaults + +- **IBM Plex Sans**: weights 400, 500, 600, 700 (normal + italic) +- **IBM Plex Mono**: weight 400 (normal + italic) +- **Preload**: Sans 400 normal, Sans 700 normal, Mono 400 normal +- **Fallbacks**: metric-matched Arial/Courier New for zero-CLS loading +- **CSS variables**: `--font-stack`, `--font-stack--monospace`, `--font-stack--headings` + +## MyST defaults + +Extensions: `colon_fence`, `substitution`, `replacements`, `strikethrough`, `linkify`. +Heading anchors: 4 levels. + +## Autodoc defaults + +| Setting | Value | +|---------|-------| +| `autoclass_content` | `"both"` | +| `autodoc_member_order` | `"bysource"` | +| `autodoc_class_signature` | `"separated"` | +| `autodoc_typehints` | `"description"` | +| `toc_object_entries_show_parents` | `"hide"` | + +## Copybutton defaults + +Regex prompt stripping for `>>>`, `...`, `$`, `#`, IPython prompts. +Line continuation character: `\`. + +## Other defaults + +- **Napoleon**: Google docstrings enabled, `napoleon_include_init_with_doc = False` +- **Suppress warnings**: `sphinx_autodoc_typehints.forward_reference` +- **Rediraffe**: `rediraffe_redirects = {}`, `rediraffe_branch = "master~1"` diff --git a/docs/extensions/index.md b/docs/extensions/index.md deleted file mode 100644 index d3e1a9a2..00000000 --- a/docs/extensions/index.md +++ /dev/null @@ -1,36 +0,0 @@ -# Extensions - -Workspace packages that ship as independent Sphinx extensions. - -::::{grid} 1 1 2 2 -:gutter: 2 2 3 3 - -:::{grid-item-card} sphinx-autodoc-pytest-fixtures -:link: sphinx-autodoc-pytest-fixtures -:link-type: doc -Autodocumenter for pytest fixtures with scope badges, dependency -tracking, and usage snippets. -::: - -:::{grid-item-card} sphinx-fonts -Self-hosted web fonts via Fontsource CDN with `@font-face` injection -and preload hints. -::: - -:::{grid-item-card} sphinx-gptheme -Furo child theme with custom sidebar, SPA navigation, and IBM Plex -typography. -::: - -:::{grid-item-card} sphinx-argparse-neo -Argparse CLI documentation with `.. argparse::` directive and epilog -transformation. -::: - -:::: - -```{toctree} -:hidden: - -sphinx-autodoc-pytest-fixtures -``` diff --git a/docs/index.md b/docs/index.md index 2a8e0171..b272f5ce 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ Shared Sphinx documentation platform for [git-pull](https://github.com/git-pull) projects. -::::{grid} 1 1 2 2 +::::{grid} 1 1 2 3 :gutter: 2 2 3 3 :::{grid-item-card} Quickstart @@ -13,10 +13,16 @@ Shared Sphinx documentation platform for [git-pull](https://github.com/git-pull) Install and get started in minutes. ::: -:::{grid-item-card} Contributing -:link: project/index +:::{grid-item-card} Packages +:link: packages/index :link-type: doc -Development setup, code style, release process. +Five workspace packages — coordinator, extensions, and theme. +::: + +:::{grid-item-card} Configuration +:link: configuration +:link-type: doc +Parameter reference for `merge_sphinx_config()` and shared defaults. ::: :::: @@ -51,7 +57,9 @@ globals().update(conf) :hidden: quickstart -extensions/index +configuration +packages/index +api project/index history ``` diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx.md new file mode 100644 index 00000000..8a9dafd2 --- /dev/null +++ b/docs/packages/gp-sphinx.md @@ -0,0 +1,46 @@ +# gp-sphinx + +{bdg-warning-line}`Alpha` {bdg-success}`coordinator` + +Configuration coordinator for shared Sphinx documentation infrastructure. +A single `merge_sphinx_config()` call builds a complete Sphinx namespace from +shared defaults — extensions, theme, fonts, autodoc settings, copybutton, +MyST, and more. + +```console +$ pip install gp-sphinx +``` + +```console +$ uv add gp-sphinx +``` + +## Usage + +```python +from gp_sphinx.config import merge_sphinx_config + +conf = merge_sphinx_config( + project="my-project", + version="1.0.0", + copyright="2026, Tony Narlock", + source_repository="https://github.com/git-pull/my-project/", +) +globals().update(conf) +``` + +That single call configures 13 extensions, the sphinx-gptheme Furo child +theme, IBM Plex fonts via sphinx-fonts, copybutton with regex prompt +stripping, MyST with colon fences and linkify, intersphinx, opengraph, +rediraffe, and napoleon. + +:::{admonition} Self-documenting +This docs site is built by gp-sphinx. See +[docs/conf.py](https://github.com/git-pull/gp-sphinx/blob/master/docs/conf.py) +— it uses the same `merge_sphinx_config()` pattern described here. +::: + +See {doc}`/configuration` for the full parameter reference and shared +defaults. + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/gp-sphinx) diff --git a/docs/packages/index.md b/docs/packages/index.md new file mode 100644 index 00000000..9a37127c --- /dev/null +++ b/docs/packages/index.md @@ -0,0 +1,53 @@ +# Packages + +Five workspace packages, each independently installable. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} gp-sphinx {bdg-warning-line}`Alpha` +:link: gp-sphinx +:link-type: doc +Configuration coordinator. One `merge_sphinx_config()` call replaces +duplicated `docs/conf.py` boilerplate. +::: + +:::{grid-item-card} sphinx-autodoc-pytest-fixtures {bdg-warning-line}`Alpha` +:link: sphinx-autodoc-pytest-fixtures +:link-type: doc +Autodocumenter for pytest fixtures with scope badges, dependency +tracking, and usage snippets. +::: + +:::{grid-item-card} sphinx-fonts {bdg-success-line}`Beta` +:link: sphinx-fonts +:link-type: doc +Self-hosted web fonts via Fontsource CDN with `@font-face` injection +and preload hints. +::: + +:::{grid-item-card} sphinx-gptheme {bdg-success-line}`Beta` +:link: sphinx-gptheme +:link-type: doc +Furo child theme with custom sidebar, SPA navigation, and IBM Plex +typography. +::: + +:::{grid-item-card} sphinx-argparse-neo {bdg-success-line}`Beta` +:link: sphinx-argparse-neo +:link-type: doc +Argparse CLI documentation with `.. argparse::` directive and epilog +transformation. +::: + +:::: + +```{toctree} +:hidden: + +gp-sphinx +sphinx-autodoc-pytest-fixtures +sphinx-fonts +sphinx-gptheme +sphinx-argparse-neo +``` diff --git a/docs/packages/sphinx-argparse-neo.md b/docs/packages/sphinx-argparse-neo.md new file mode 100644 index 00000000..fe3b846a --- /dev/null +++ b/docs/packages/sphinx-argparse-neo.md @@ -0,0 +1,52 @@ +# sphinx-argparse-neo + +{bdg-success-line}`Beta` {bdg-primary}`extension` + +Sphinx extension for documenting argparse-based CLI tools. Renders argument +parsers as structured documentation with usage sections, argument groups, +subcommands, and optional epilog-to-section transformation. + +```console +$ pip install sphinx-argparse-neo +``` + +Or as a gp-sphinx optional extra: + +```console +$ pip install gp-sphinx[argparse] +``` + +## Usage + +Add to your Sphinx extensions: + +```python +extensions = ["sphinx_argparse_neo"] +``` + +Then use the `argparse` directive: + +```rst +.. argparse:: + :module: myapp.cli + :func: create_parser + :prog: myapp +``` + +## Bundled Pygments lexers + +The extension registers custom lexers for syntax-highlighted CLI output: + +- `argparse` — general argparse output +- `argparse-usage` — usage line formatting +- `argparse-help` — help text formatting + +The `argparse_exemplar` sub-extension adds `cli-usage` and CLI inline roles +for richer documentation. + +## Renderer + +Output is customizable via the renderer system — sections vs rubrics, +subcommand flattening, and configurable heading levels. + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-argparse-neo) diff --git a/docs/extensions/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md similarity index 100% rename from docs/extensions/sphinx-autodoc-pytest-fixtures.md rename to docs/packages/sphinx-autodoc-pytest-fixtures.md diff --git a/docs/packages/sphinx-fonts.md b/docs/packages/sphinx-fonts.md new file mode 100644 index 00000000..bb885153 --- /dev/null +++ b/docs/packages/sphinx-fonts.md @@ -0,0 +1,51 @@ +# sphinx-fonts + +{bdg-success-line}`Beta` {bdg-primary}`extension` + +Self-hosted web fonts via [Fontsource](https://fontsource.org/) CDN. Downloads +font files at build time, caches them locally, and injects structured font data +into the template context for inline `@font-face` CSS generation. + +```console +$ pip install sphinx-fonts +``` + +## Usage + +In `conf.py`: + +```python +extensions = ["sphinx_fonts"] + +sphinx_fonts = [ + { + "family": "IBM Plex Sans", + "package": "@fontsource/ibm-plex-sans", + "version": "5.2.8", + "weights": [400, 500, 600, 700], + "styles": ["normal", "italic"], + }, +] +``` + +When used with gp-sphinx, font configuration is provided by default — IBM Plex +Sans and IBM Plex Mono are pre-configured with preload hints and fallback +font-metric overrides for zero-CLS loading. + +## Configuration + +| Config value | Type | Description | +|-------------|------|-------------| +| `sphinx_fonts` | `list[dict]` | Font family definitions (family, package, version, weights, styles) | +| `sphinx_font_preload` | `list[tuple]` | Critical variants to preload (family, weight, style) | +| `sphinx_font_fallbacks` | `list[dict]` | Metric-matched fallback font faces | +| `sphinx_font_css_variables` | `dict` | CSS custom properties for Furo font stacks | + +## How it works + +1. **`builder-inited`**: Downloads font files from Fontsource CDN, caches in `~/.cache/sphinx-fonts` +2. **`html-page-context`**: Injects font face data, preload `` hrefs, fallbacks, and CSS variables into the Jinja2 template context + +This site uses IBM Plex Sans and Mono via sphinx-fonts. + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-fonts) diff --git a/docs/packages/sphinx-gptheme.md b/docs/packages/sphinx-gptheme.md new file mode 100644 index 00000000..8dcafd60 --- /dev/null +++ b/docs/packages/sphinx-gptheme.md @@ -0,0 +1,38 @@ +# sphinx-gptheme + +{bdg-success-line}`Beta` {bdg-info}`theme` + +Furo child theme for [git-pull](https://github.com/git-pull) project +documentation. Inherits Furo's responsive layout and dark mode, adding a +custom sidebar with project links, footer icons, SPA-style page navigation, +and CSS variable-driven IBM Plex typography. + +```console +$ pip install sphinx-gptheme +``` + +## Usage + +```python +html_theme = "sphinx-gptheme" +``` + +When used with gp-sphinx, the theme is set automatically by +`merge_sphinx_config()`. + +## What it provides + +- **Templates**: `page.html`, `sidebar/brand.html`, `sidebar/projects.html` +- **Static assets**: `css/custom.css`, `js/spa-nav.js` +- **Parent theme**: Furo (declared in `theme.conf` with `inherit = furo`) +- **Entry point**: registered via `sphinx.html_themes` as `"sphinx-gptheme"` + +## SPA navigation + +`spa-nav.js` intercepts internal link clicks and swaps page content without +a full page reload, preserving scroll position and sidebar state. Loaded +with `defer` by the `setup()` function injected by `merge_sphinx_config()`. + +This site is built with sphinx-gptheme. + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-gptheme) diff --git a/docs/redirects.txt b/docs/redirects.txt index e69de29b..64f5ffa0 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -0,0 +1,2 @@ +extensions/index packages/index +extensions/sphinx-autodoc-pytest-fixtures packages/sphinx-autodoc-pytest-fixtures From 697398fa5668f5bda3311a0ecb9e3f4b0aa35631 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 20:20:48 -0500 Subject: [PATCH 02/57] fix(docs): Correct argparse_exemplar extension name to sphinx_argparse_neo.exemplar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: argparse_exemplar is not importable as a top-level module — downstream users copying the quickstart example get ModuleNotFoundError. The correct Sphinx extension name is sphinx_argparse_neo.exemplar. what: - Fix docs/quickstart.md extra_extensions example - Fix config.py docstring example --- docs/quickstart.md | 2 +- packages/gp-sphinx/src/gp_sphinx/config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index e78cd064..d625cb38 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -85,7 +85,7 @@ globals().update(conf) ```python conf = merge_sphinx_config( # ... - extra_extensions=["argparse_exemplar", "sphinx_click"], + extra_extensions=["sphinx_argparse_neo.exemplar", "sphinx_click"], ) ``` diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 329d9b5d..93d373b9 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -245,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., ``["argparse_exemplar"]``). + Add extensions to the defaults (e.g., ``["sphinx_argparse_neo.exemplar"]``). remove_extensions : list[str] | None Remove specific defaults (e.g., ``["sphinx_design"]``). theme_options : dict | None From e9f0e0790c595f761798a641f68dae038faf3757 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 20:21:59 -0500 Subject: [PATCH 03/57] fix(docs): Escape **overrides in docstring to prevent RST bold markup warning why: autodoc interprets **overrides as malformed bold RST markup when rendering api.md, producing parse warnings under -W. what: - Escape as \**overrides in the parameter list and prose - Use raw docstring (r""") to support the backslash escapes --- packages/gp-sphinx/src/gp_sphinx/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 93d373b9..34529d8d 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -219,7 +219,7 @@ def merge_sphinx_config( intersphinx_mapping: t.Mapping[str, tuple[str, str | None]] | None = None, **overrides: t.Any, ) -> dict[str, t.Any]: - """Build a complete Sphinx conf namespace from shared defaults. + r"""Build a complete Sphinx conf namespace from shared defaults. Returns a flat dictionary suitable for injection into a ``docs/conf.py`` module namespace via ``globals().update(conf)``. @@ -232,7 +232,7 @@ def merge_sphinx_config( for the ``linkify_issues`` extension. When ``docs_url`` is provided, ``ogp_site_url``, ``ogp_image``, and ``ogp_site_name`` are auto-computed for ``sphinxext.opengraph``. All auto-computed values can be overridden - via ``**overrides``. + via ``\**overrides``. Parameters ---------- @@ -263,7 +263,7 @@ def merge_sphinx_config( Used to auto-compute ``ogp_site_url`` and ``ogp_site_name``. intersphinx_mapping : dict | None Intersphinx targets. - **overrides + \**overrides Any additional Sphinx config values. Returns From 41f6f781d54d7365e0cadddb9221defb9dba7763 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 20:22:47 -0500 Subject: [PATCH 04/57] docs(sphinx-autodoc-pytest-fixtures): Add reference content above badge demo why: The page was a badge gallery with no install, config, directive, or role documentation. All 4 config values, 4 directives, 1 role, and 11 py:fixture options were undocumented. what: - Add maturity badge, install commands, usage example - Document all 4 registered config values with defaults - Document all directives (py:fixture, autofixture, autofixtures, autofixture-index) and the :fixture: role - Document all py:fixture directive options - Add source link - Badge demo section preserved below reference content --- .../sphinx-autodoc-pytest-fixtures.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index eb006289..0777dcda 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -1,8 +1,64 @@ # sphinx-autodoc-pytest-fixtures +{bdg-warning-line}`Alpha` {bdg-primary}`extension` + Sphinx extension that documents pytest fixtures as first-class domain objects with scope badges, dependency tracking, and auto-generated usage snippets. +```console +$ pip install sphinx-autodoc-pytest-fixtures +``` + +## Usage + +Add to your Sphinx extensions: + +```python +extensions = ["sphinx_autodoc_pytest_fixtures"] +``` + +## Directives + +| Directive | Description | +|-----------|-------------| +| `.. py:fixture:: name` | Document a single fixture with full metadata | +| `.. autofixture:: module.name` | Autodoc-style single fixture (use `eval-rst` in MyST) | +| `.. autofixtures:: module` | Discover and document all fixtures in a module | +| `.. autofixture-index:: module` | Summary table with badge columns | + +## Role + +`:fixture:\`name\`` — cross-reference to a documented fixture. + +## Configuration + +| Config | Default | Description | +|--------|---------|-------------| +| `pytest_fixture_hidden_dependencies` | `PYTEST_HIDDEN` (frozenset) | Fixture names to hide from "Depends on" | +| `pytest_fixture_builtin_links` | `PYTEST_BUILTIN_LINKS` (dict) | Fallback URLs for pytest builtins | +| `pytest_external_fixture_links` | `{}` | Custom external fixture links | +| `pytest_fixture_lint_level` | `"warning"` | Validation severity: `"none"`, `"warning"`, or `"error"` | + +## py:fixture options + +| Option | Type | Description | +|--------|------|-------------| +| `:scope:` | string | `function`, `module`, `class`, or `session` | +| `:autouse:` | flag | Mark as autouse | +| `:depends:` | string | Comma-separated dependency fixtures | +| `:kind:` | string | `resource`, `factory`, or `override_hook` | +| `:return-type:` | string | Return type annotation | +| `:usage:` | string | `auto` or `none` | +| `:params:` | string | Parametrized values | +| `:teardown:` | flag | Mark as yield fixture | +| `:async:` | flag | Mark as async | +| `:deprecated:` | string | Version string | +| `:replacement:` | string | Replacement fixture name | + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-autodoc-pytest-fixtures) + +--- + ## Badge demo Visual reference for all badge permutations. Use this page to verify badge From f929b1e9f878ac6c6c723780f1821d89a3624bc2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 20:23:53 -0500 Subject: [PATCH 05/57] docs(sphinx-argparse-neo): Document all config values, directive options, lexers, and roles why: The page documented 0/4 base config values, 0/9 exemplar config values, 0/7 directive options, 0/4 lexers, and 0/5 CLI roles. what: - Document 4 base config values with defaults - Document key argparse directive options - Add exemplar sub-extension section with correct extension name (sphinx_argparse_neo.exemplar, not argparse_exemplar) - Document 9 exemplar config values, 4 Pygments lexers, 5 CLI roles - Clarify lexers and roles are registered by the exemplar, not the base --- docs/packages/sphinx-argparse-neo.md | 73 ++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/docs/packages/sphinx-argparse-neo.md b/docs/packages/sphinx-argparse-neo.md index fe3b846a..57afebdf 100644 --- a/docs/packages/sphinx-argparse-neo.md +++ b/docs/packages/sphinx-argparse-neo.md @@ -33,20 +33,73 @@ Then use the `argparse` directive: :prog: myapp ``` -## Bundled Pygments lexers +## Base extension config values -The extension registers custom lexers for syntax-highlighted CLI output: +| Config | Default | Description | +|--------|---------|-------------| +| `argparse_group_title_prefix` | `""` | Prefix for argument group titles | +| `argparse_show_defaults` | `True` | Show default values in argument docs | +| `argparse_show_choices` | `True` | Show choice constraints | +| `argparse_show_types` | `True` | Show type information | -- `argparse` — general argparse output -- `argparse-usage` — usage line formatting -- `argparse-help` — help text formatting +## argparse directive options -The `argparse_exemplar` sub-extension adds `cli-usage` and CLI inline roles -for richer documentation. +Key options for the `.. argparse::` directive: -## Renderer +| Option | Description | +|--------|-------------| +| `:module:` | Python module containing the parser factory | +| `:func:` | Function that returns an `ArgumentParser` | +| `:prog:` | Program name for usage display | +| `:path:` | Subcommand path (e.g., `sub1 sub2`) | +| `:nodefault:` | Suppress default value display | +| `:nosubcommands:` | Suppress subcommand documentation | +| `:nosectionheading:` | Use rubrics instead of heading sections | -Output is customizable via the renderer system — sections vs rubrics, -subcommand flattening, and configurable heading levels. +## Exemplar sub-extension + +The exemplar layer adds enhanced features on top of the base directive. +Add it separately: + +```python +extensions = ["sphinx_argparse_neo", "sphinx_argparse_neo.exemplar"] +``` + +### Exemplar config values + +| Config | Default | Description | +|--------|---------|-------------| +| `argparse_examples_term_suffix` | `"examples"` | Term suffix for examples detection | +| `argparse_examples_base_term` | `"examples"` | Base term for examples matching | +| `argparse_examples_section_title` | `"Examples"` | Section title for extracted examples | +| `argparse_usage_pattern` | `"usage:"` | Pattern to detect usage blocks | +| `argparse_examples_command_prefix` | `"$ "` | Prefix for example commands | +| `argparse_examples_code_language` | `"console"` | Language for example code blocks | +| `argparse_examples_code_classes` | `("highlight-console",)` | CSS classes for example blocks | +| `argparse_usage_code_language` | `"cli-usage"` | Language for usage code blocks | +| `argparse_reorder_usage_before_examples` | `True` | Move usage before examples | + +### Pygments lexers + +Registered by the exemplar extension, not the base: + +| Lexer | Description | +|-------|-------------| +| `argparse` | General argparse output | +| `argparse-usage` | Usage line formatting | +| `argparse-help` | Help text formatting | +| `cli-usage` | CLI usage block formatting | + +### CLI inline roles + +Registered by the exemplar via `register_roles()`: + +| Role | Description | +|------|-------------| +| `:cli-option:` | CLI options (`--verbose`, `-h`) | +| `:cli-metavar:` | Metavar placeholders (`FILE`, `PATH`) | +| `:cli-command:` | Command names (`sync`, `add`) | +| `:cli-default:` | Default values (`None`, `"default"`) | +| `:cli-choice:` | Choice values (`json`, `yaml`) | [Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-argparse-neo) From 46d2bd19fcb5234c1e777c5dad1a8def4ea1aeec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 20:29:41 -0500 Subject: [PATCH 06/57] docs(sphinx-gptheme): Document theme options, templates, and assets why: The page listed bundled files but documented 0/7 theme.conf options, no copyable html_theme_options example, and no template/stylesheet detail. what: - Document all 7 theme.conf options with descriptions - Add copyable html_theme_options example - Document templates (brand, projects), stylesheets (custom, argparse), and JavaScript (spa-nav.js) - Document theme inheritance and entry point --- docs/packages/sphinx-gptheme.md | 65 ++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/docs/packages/sphinx-gptheme.md b/docs/packages/sphinx-gptheme.md index 8dcafd60..f381b123 100644 --- a/docs/packages/sphinx-gptheme.md +++ b/docs/packages/sphinx-gptheme.md @@ -20,18 +20,65 @@ html_theme = "sphinx-gptheme" When used with gp-sphinx, the theme is set automatically by `merge_sphinx_config()`. -## What it provides +## Theme options -- **Templates**: `page.html`, `sidebar/brand.html`, `sidebar/projects.html` -- **Static assets**: `css/custom.css`, `js/spa-nav.js` -- **Parent theme**: Furo (declared in `theme.conf` with `inherit = furo`) -- **Entry point**: registered via `sphinx.html_themes` as `"sphinx-gptheme"` +Options declared in `theme.conf` (passed via `html_theme_options`): + +| Option | Description | +|--------|-------------| +| `announcement` | Banner text displayed above the header | +| `light_logo` | Logo path for light mode | +| `dark_logo` | Logo path for dark mode | +| `sidebar_hide_name` | Hide project name in sidebar brand | +| `footer_icons` | List of footer icon dicts (`name`, `url`, `html`, `class`) | +| `light_css_variables` | CSS custom property overrides for light mode | +| `dark_css_variables` | CSS custom property overrides for dark mode | + +### Example + +```python +html_theme_options = { + "light_logo": "img/my-logo.svg", + "dark_logo": "img/my-logo-dark.svg", + "announcement": "Note: This project is in alpha.", + "footer_icons": [ + { + "name": "GitHub", + "url": "https://github.com/my-org/my-project", + "html": "...", + "class": "", + }, + ], +} +``` + +## Bundled assets -## SPA navigation +### Templates -`spa-nav.js` intercepts internal link clicks and swaps page content without -a full page reload, preserving scroll position and sidebar state. Loaded -with `defer` by the `setup()` function injected by `merge_sphinx_config()`. +| Template | Description | +|----------|-------------| +| `sidebar/brand.html` | Project logo and name | +| `sidebar/projects.html` | Cross-project navigation links | + +### Stylesheets + +| File | Description | +|------|-------------| +| `css/custom.css` | Base typography and layout overrides | +| `css/argparse-highlight.css` | Syntax colors for CLI output lexers | + +### JavaScript + +| File | Description | +|------|-------------| +| `js/spa-nav.js` | SPA-style page navigation (deferred loading) | + +## Inheritance + +- **Parent theme**: Furo (`inherit = furo` in `theme.conf`) +- **Entry point**: registered via `sphinx.html_themes` as `"sphinx-gptheme"` +- **Sidebars**: scroll-start, brand, search, navigation, projects, scroll-end This site is built with sphinx-gptheme. From d14c99dc438d115508880e6ec98cf5e03b4ed324 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 20:30:52 -0500 Subject: [PATCH 07/57] docs(sphinx-fonts): Add defaults, font dict shape, and template context variables why: Config table documented names but omitted default values from gp-sphinx and the template integration contract that downstream theme authors need. what: - Add gp-sphinx default values for all 4 config options - Document the FontConfig dict shape - Document 4 template context variables injected during html-page-context (font_faces, font_preload_hrefs, font_fallbacks, font_css_variables) --- docs/packages/sphinx-fonts.md | 42 ++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/packages/sphinx-fonts.md b/docs/packages/sphinx-fonts.md index bb885153..e46e7070 100644 --- a/docs/packages/sphinx-fonts.md +++ b/docs/packages/sphinx-fonts.md @@ -34,17 +34,43 @@ font-metric overrides for zero-CLS loading. ## Configuration -| Config value | Type | Description | -|-------------|------|-------------| -| `sphinx_fonts` | `list[dict]` | Font family definitions (family, package, version, weights, styles) | -| `sphinx_font_preload` | `list[tuple]` | Critical variants to preload (family, weight, style) | -| `sphinx_font_fallbacks` | `list[dict]` | Metric-matched fallback font faces | -| `sphinx_font_css_variables` | `dict` | CSS custom properties for Furo font stacks | +| Config value | Type | Default (via gp-sphinx) | Description | +|-------------|------|------------------------|-------------| +| `sphinx_fonts` | `list[dict]` | IBM Plex Sans (400/500/600/700, normal+italic), IBM Plex Mono (400, normal+italic) | Font family definitions | +| `sphinx_font_preload` | `list[tuple]` | Sans 400 normal, Sans 700 normal, Mono 400 normal | Critical variants to preload | +| `sphinx_font_fallbacks` | `list[dict]` | Metric-matched Arial/Courier New with size_adjust | Fallback font faces for CLS reduction | +| `sphinx_font_css_variables` | `dict` | `--font-stack`, `--font-stack--monospace`, `--font-stack--headings` | CSS custom properties for Furo font stacks | + +Each font dict in `sphinx_fonts` has the shape: + +```python +{ + "family": "IBM Plex Sans", + "package": "@fontsource/ibm-plex-sans", + "version": "5.2.8", + "weights": [400, 500, 600, 700], + "styles": ["normal", "italic"], +} +``` ## How it works -1. **`builder-inited`**: Downloads font files from Fontsource CDN, caches in `~/.cache/sphinx-fonts` -2. **`html-page-context`**: Injects font face data, preload `` hrefs, fallbacks, and CSS variables into the Jinja2 template context +1. **`builder-inited`**: Downloads font files from Fontsource CDN, caches in `~/.cache/sphinx-fonts`, copies to `_static/fonts/` +2. **`html-page-context`**: Injects structured data into the Jinja2 template context + +### Template context variables + +The extension makes these variables available to theme templates: + +| Variable | Type | Description | +|----------|------|-------------| +| `font_faces` | `list[dict]` | `@font-face` declaration data (family, src, weight, style, unicode-range) | +| `font_preload_hrefs` | `list[str]` | `` href values for critical fonts | +| `font_fallbacks` | `list[dict]` | Fallback `@font-face` declarations with metric overrides | +| `font_css_variables` | `dict[str, str]` | CSS custom properties for font stacks | + +Theme templates consume these to generate inline CSS. When using +sphinx-gptheme, this is handled automatically. This site uses IBM Plex Sans and Mono via sphinx-fonts. From 51f9e4788c1d180c66434186b124af5f1b2ce7a5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 20:33:06 -0500 Subject: [PATCH 08/57] docs(configuration): Add DEFAULT_* constant names and autodoc_default_options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The page summarized defaults by behavior but never named the actual constants or their values — downstream authors couldn't verify coverage against the source. what: - Add DEFAULT_AUTODOC_OPTIONS dict (5 keys) - Add DEFAULT_SOURCE_SUFFIX, DEFAULT_HTML_STATIC_PATH, DEFAULT_TEMPLATES_PATH - Add copybutton constant names and values - Add napoleon and suppress_warnings constant names --- docs/configuration.md | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b940483c..3ebbe484 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -118,6 +118,8 @@ Heading anchors: 4 levels. ## Autodoc defaults +`DEFAULT_AUTODOC_OPTIONS`: + | Setting | Value | |---------|-------| | `autoclass_content` | `"both"` | @@ -126,13 +128,40 @@ Heading anchors: 4 levels. | `autodoc_typehints` | `"description"` | | `toc_object_entries_show_parents` | `"hide"` | +`DEFAULT_AUTODOC_OPTIONS` dict (applied to `autodoc_default_options`): + +| Key | Value | +|-----|-------| +| `members` | `True` | +| `undoc-members` | `True` | +| `private-members` | `False` | +| `show-inheritance` | `True` | +| `member-order` | `"bysource"` | + +## Static paths and source suffix + +| Constant | Value | +|----------|-------| +| `DEFAULT_SOURCE_SUFFIX` | `{".rst": "restructuredtext", ".md": "markdown"}` | +| `DEFAULT_HTML_STATIC_PATH` | `["_static"]` | +| `DEFAULT_TEMPLATES_PATH` | `["_templates"]` | + ## Copybutton defaults -Regex prompt stripping for `>>>`, `...`, `$`, `#`, IPython prompts. -Line continuation character: `\`. +`DEFAULT_COPYBUTTON_PROMPT_TEXT` — regex matching Python (`>>>`), continuation (`...`), shell (`$`, `#`), and IPython prompts. See `defaults.py` for the full pattern. + +| Constant | Value | +|----------|-------| +| `DEFAULT_COPYBUTTON_PROMPT_IS_REGEXP` | `True` | +| `DEFAULT_COPYBUTTON_REMOVE_PROMPTS` | `True` | +| `DEFAULT_COPYBUTTON_LINE_CONTINUATION_CHARACTER` | `"\\"` | ## Other defaults -- **Napoleon**: Google docstrings enabled, `napoleon_include_init_with_doc = False` -- **Suppress warnings**: `sphinx_autodoc_typehints.forward_reference` -- **Rediraffe**: `rediraffe_redirects = {}`, `rediraffe_branch = "master~1"` +| Constant | Value | +|----------|-------| +| `DEFAULT_NAPOLEON_GOOGLE_DOCSTRING` | `True` | +| `DEFAULT_NAPOLEON_INCLUDE_INIT_WITH_DOC` | `False` | +| `DEFAULT_SUPPRESS_WARNINGS` | `["sphinx_autodoc_typehints.forward_reference"]` | + +Rediraffe: `rediraffe_redirects = {}`, `rediraffe_branch = "master~1"`. From f4c72b68960873b9fb9b3f7064cbe1bcb063f7a5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 3 Apr 2026 20:34:02 -0500 Subject: [PATCH 09/57] docs(sphinx-autodoc-pytest-fixtures): Write package README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: README.md was 0 bytes — PyPI and editor-side DX showed no package description. what: - Add description, install command, usage example, and docs link --- .../sphinx-autodoc-pytest-fixtures/README.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/sphinx-autodoc-pytest-fixtures/README.md b/packages/sphinx-autodoc-pytest-fixtures/README.md index e69de29b..d78a30ee 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/README.md +++ b/packages/sphinx-autodoc-pytest-fixtures/README.md @@ -0,0 +1,32 @@ +# sphinx-autodoc-pytest-fixtures + +Sphinx extension that documents pytest fixtures as first-class domain objects +with scope badges, dependency tracking, reverse-dep graphs, and auto-generated +usage snippets. + +## Install + +```console +$ pip install sphinx-autodoc-pytest-fixtures +``` + +## Usage + +```python +extensions = ["sphinx_autodoc_pytest_fixtures"] +``` + +Then document fixtures with: + +```rst +.. autofixture:: myproject.conftest.my_fixture + +.. autofixtures:: myproject.conftest + +.. autofixture-index:: myproject.conftest +``` + +## Documentation + +See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-autodoc-pytest-fixtures/) for +config values, directive options, and the badge demo. From 2ac805c4f025fde21cb0ff54e5b87c48976e60db Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 05:48:51 -0500 Subject: [PATCH 10/57] docs(demo-cli): Add generic demo parser and fix lint in _ext modules why: The argparse demo used "gp-demo" with "build"/"serve" subcommands, which didn't look like obvious examples. Epilog text also rendered as unformatted paragraphs due to a nested_parse limitation. what: - Rewrite demo_cli.py with generic names (myapp, mysubcommand, myothersubcommand) and remove epilog that triggered rendering bug - Fix RUF012 in docutils_demo.py: add ClassVar to option_spec - Fix E501 in sphinx_config_demo.py, sphinx_config_single_demo.py: shorten doctest stub construction --- docs/_ext/demo_cli.py | 86 ++++++++++++++++++++++++ docs/_ext/docutils_demo.py | 90 ++++++++++++++++++++++++++ docs/_ext/sphinx_config_demo.py | 34 ++++++++++ docs/_ext/sphinx_config_single_demo.py | 28 ++++++++ 4 files changed, 238 insertions(+) create mode 100644 docs/_ext/demo_cli.py create mode 100644 docs/_ext/docutils_demo.py create mode 100644 docs/_ext/sphinx_config_demo.py create mode 100644 docs/_ext/sphinx_config_single_demo.py diff --git a/docs/_ext/demo_cli.py b/docs/_ext/demo_cli.py new file mode 100644 index 00000000..fdc3de70 --- /dev/null +++ b/docs/_ext/demo_cli.py @@ -0,0 +1,86 @@ +"""Synthetic argparse parser factory used by the docs site. + +Examples +-------- +>>> parser = create_parser() +>>> parser.prog +'myapp' +>>> parser.parse_args(["mysubcommand", "--output", "dist"]).output +'dist' +""" + +from __future__ import annotations + +import argparse + + +def create_parser() -> argparse.ArgumentParser: + """Return a parser that exercises the extension's rendering features. + + Examples + -------- + >>> parser = create_parser() + >>> parser.prog + 'myapp' + """ + parser = argparse.ArgumentParser( + prog="myapp", + description="Example CLI showing how sphinx-argparse-neo renders parsers.", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose output.", + ) + parser.add_argument( + "--config", + default="pyproject.toml", + metavar="PATH", + help="Path to configuration file.", + ) + + subparsers = parser.add_subparsers(dest="command") + + sub1 = subparsers.add_parser( + "mysubcommand", + help="Run the primary task.", + description="Execute the primary task with configurable output.", + ) + sub1.add_argument( + "--output", + "-o", + default="build", + metavar="DIR", + help="Output directory.", + ) + sub1.add_argument( + "--format", + choices=["text", "json"], + default="text", + help="Output format.", + ) + sub1.add_argument( + "--clean", + action="store_true", + help="Remove previous output first.", + ) + + sub2 = subparsers.add_parser( + "myothersubcommand", + help="Run a secondary task.", + description="Execute a secondary task with network options.", + ) + sub2.add_argument( + "--port", + type=int, + default=8000, + help="Port number.", + ) + sub2.add_argument( + "--host", + default="localhost", + help="Host to bind to.", + ) + + return parser diff --git a/docs/_ext/docutils_demo.py b/docs/_ext/docutils_demo.py new file mode 100644 index 00000000..c657240c --- /dev/null +++ b/docs/_ext/docutils_demo.py @@ -0,0 +1,90 @@ +"""Synthetic directives and roles for live autodoc-docutils demos. + +Examples +-------- +>>> DemoBadgeDirective.required_arguments +1 +>>> demo_badge_role.content +True +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from docutils.parsers.rst import Directive, directives + +if t.TYPE_CHECKING: + from docutils.parsers.rst.states import Inliner + + +class DemoBadgeDirective(Directive): + """Render a short badge-like paragraph for directive demos.""" + + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = {"class": directives.class_option} + + def run(self) -> list[nodes.Node]: + """Return a paragraph node for the requested badge label.""" + paragraph = nodes.paragraph(text=f"demo badge: {self.arguments[0]}") + paragraph["classes"].extend(self.options.get("class", [])) + return [paragraph] + + +class DemoCalloutDirective(Directive): + """Render a simple titled container for directive demos.""" + + required_arguments = 0 + optional_arguments = 0 + has_content = True + option_spec: t.ClassVar[dict[str, t.Any]] = { + "title": directives.unchanged_required, + } + + def run(self) -> list[nodes.Node]: + """Return a container with an optional title and paragraph content.""" + container = nodes.container() + if "title" in self.options: + container += nodes.strong(text=self.options["title"]) + if self.content: + container += nodes.paragraph(text=" ".join(self.content)) + return [container] + + +def demo_badge_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: Inliner | None, + options: dict[str, t.Any] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Return a literal node with badge-style classes. + + Examples + -------- + >>> nodes_, messages = demo_badge_role( + ... "demo-badge", + ... ":demo-badge:`Alpha`", + ... "Alpha", + ... 1, + ... None, + ... ) + >>> nodes_[0].astext() + 'Alpha' + >>> messages + [] + """ + merged_options = options or {} + classes = ["demo-badge"] + classes.extend(merged_options.get("class", [])) + return [nodes.literal(rawtext, text, classes=classes)], [] + + +demo_badge_role.options = {"class": directives.class_option} +demo_badge_role.content = True diff --git a/docs/_ext/sphinx_config_demo.py b/docs/_ext/sphinx_config_demo.py new file mode 100644 index 00000000..50b10e10 --- /dev/null +++ b/docs/_ext/sphinx_config_demo.py @@ -0,0 +1,34 @@ +"""Synthetic config registrations for live autodoc-sphinx demos. + +Examples +-------- +>>> stub = type("App", (), {"add_config_value": lambda *a, **kw: None})() +>>> metadata = setup(stub) +>>> metadata["parallel_read_safe"] +True +""" + +from __future__ import annotations + +import typing as t + + +def setup(app: t.Any) -> dict[str, object]: + """Register a small config surface for documentation demos.""" + app.add_config_value( + "demo_theme_accent", + {"light": "mint", "dark": "teal"}, + "html", + types=[dict], + ) + app.add_config_value( + "demo_show_callouts", + True, + "html", + types=[bool], + ) + return { + "version": "0.0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_ext/sphinx_config_single_demo.py b/docs/_ext/sphinx_config_single_demo.py new file mode 100644 index 00000000..83e4f508 --- /dev/null +++ b/docs/_ext/sphinx_config_single_demo.py @@ -0,0 +1,28 @@ +"""Single-value config registration for the autoconfigvalue demo. + +Examples +-------- +>>> stub = type("App", (), {"add_config_value": lambda *a, **kw: None})() +>>> metadata = setup(stub) +>>> metadata["parallel_write_safe"] +True +""" + +from __future__ import annotations + +import typing as t + + +def setup(app: t.Any) -> dict[str, object]: + """Register one config value for single-entry rendering demos.""" + app.add_config_value( + "demo_debug", + False, + "env", + types=[bool], + ) + return { + "version": "0.0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } From b2b2f8dd69b1a1e1ac683cab6ca2ee30e7390066 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 06:00:34 -0500 Subject: [PATCH 11/57] docs(feat[autodoc]): Add sphinx-autodoc-docutils, sphinx-autodoc-sphinx, and package-reference directive why: Enable auto-generated reference tables for directives, roles, and config values across all package docs pages, replacing static Markdown tables with live introspection. what: - Add sphinx-autodoc-docutils package with autodirective, autorole, autodirective-index, autorole-index directives - Add sphinx-autodoc-sphinx package with autoconfigvalue, autoconfigvalues, autoconfigvalue-index directives - Add package_reference.py docs extension (auto-generates registered surface tables from setup() introspection) - Rewrite all package docs pages to use live autodoc directives - Add argparse_neo_demo.py with subcommand and epilog examples - Register new extensions in conf.py with env-gated intersphinx - Add workspace packages to pyproject.toml, ruff, and mypy config - Add tests for autodoc-docutils, autodoc-sphinx, and package-reference - Add CSS for package demo cards and font specimens - Add redirects for new package doc pages --- docs/_ext/argparse_neo_demo.py | 64 ++ docs/_ext/package_reference.py | 714 ++++++++++++++++++ docs/_static/css/custom.css | 31 + docs/conf.py | 26 +- docs/configuration.md | 238 +++--- docs/index.md | 2 +- docs/packages/gp-sphinx.md | 46 +- docs/packages/index.md | 45 +- docs/packages/sphinx-argparse-neo.md | 160 ++-- docs/packages/sphinx-autodoc-docutils.md | 93 +++ .../sphinx-autodoc-pytest-fixtures.md | 142 +--- docs/packages/sphinx-autodoc-sphinx.md | 71 ++ docs/packages/sphinx-fonts.md | 92 ++- docs/packages/sphinx-gptheme.md | 113 ++- docs/redirects.txt | 6 + packages/gp-sphinx/src/gp_sphinx/config.py | 108 ++- packages/sphinx-autodoc-docutils/README.md | 43 ++ .../sphinx-autodoc-docutils/pyproject.toml | 40 + .../src/sphinx_autodoc_docutils/__init__.py | 32 + .../src/sphinx_autodoc_docutils/_constants.py | 4 + .../sphinx_autodoc_docutils/_directives.py | 395 ++++++++++ .../sphinx_autodoc_docutils/_documenter.py | 51 ++ .../src/sphinx_autodoc_docutils/py.typed | 0 packages/sphinx-autodoc-sphinx/README.md | 38 + packages/sphinx-autodoc-sphinx/pyproject.toml | 40 + .../src/sphinx_autodoc_sphinx/__init__.py | 42 ++ .../src/sphinx_autodoc_sphinx/_constants.py | 4 + .../src/sphinx_autodoc_sphinx/_directives.py | 432 +++++++++++ .../src/sphinx_autodoc_sphinx/_documenter.py | 43 ++ .../src/sphinx_autodoc_sphinx/py.typed | 0 pyproject.toml | 21 +- tests/conftest.py | 8 + tests/ext/autodoc_docutils/__init__.py | 1 + tests/ext/autodoc_docutils/test_directives.py | 56 ++ .../autodoc_docutils/test_sphinx_config.py | 38 + tests/ext/autodoc_sphinx/test_directives.py | 50 ++ tests/test_package_reference.py | 85 +++ uv.lock | 30 + 38 files changed, 2873 insertions(+), 531 deletions(-) create mode 100644 docs/_ext/argparse_neo_demo.py create mode 100644 docs/_ext/package_reference.py create mode 100644 docs/packages/sphinx-autodoc-docutils.md create mode 100644 docs/packages/sphinx-autodoc-sphinx.md create mode 100644 packages/sphinx-autodoc-docutils/README.md create mode 100644 packages/sphinx-autodoc-docutils/pyproject.toml create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_constants.py create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_documenter.py create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/py.typed create mode 100644 packages/sphinx-autodoc-sphinx/README.md create mode 100644 packages/sphinx-autodoc-sphinx/pyproject.toml create mode 100644 packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py create mode 100644 packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_constants.py create mode 100644 packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py create mode 100644 packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py create mode 100644 packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/py.typed create mode 100644 tests/ext/autodoc_docutils/__init__.py create mode 100644 tests/ext/autodoc_docutils/test_directives.py create mode 100644 tests/ext/autodoc_docutils/test_sphinx_config.py create mode 100644 tests/ext/autodoc_sphinx/test_directives.py create mode 100644 tests/test_package_reference.py diff --git a/docs/_ext/argparse_neo_demo.py b/docs/_ext/argparse_neo_demo.py new file mode 100644 index 00000000..702516f8 --- /dev/null +++ b/docs/_ext/argparse_neo_demo.py @@ -0,0 +1,64 @@ +"""Demo parser factories for the sphinx-argparse-neo docs page.""" + +from __future__ import annotations + +import argparse +import textwrap + + +def build_parser() -> argparse.ArgumentParser: + """Return a parser with groups, subcommands, and example epilogs.""" + parser = argparse.ArgumentParser( + prog="gp-demo", + description="Inspect and synchronize documentation metadata.", + ) + parser.add_argument( + "--format", + choices=["table", "json"], + default="table", + help="output format", + ) + parser.add_argument( + "--jobs", + type=int, + default=4, + help="number of worker jobs", + ) + + subcommands = parser.add_subparsers(dest="command") + + sync = subcommands.add_parser( + "sync", + help="synchronize package docs", + description="Synchronize package metadata into the docs site.", + epilog=textwrap.dedent( + """ + examples: + gp-demo sync packages/sphinx-fonts + gp-demo sync packages/sphinx-gptheme + + Machine-readable output examples: + gp-demo sync --format json packages/sphinx-fonts + """ + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sync.add_argument("target", metavar="PACKAGE", help="package to synchronize") + sync.add_argument( + "--strict", + action="store_true", + help="fail on missing docs coverage", + ) + + doctor = subcommands.add_parser( + "doctor", + help="check docs build health", + description="Run validation checks for the documentation site.", + ) + doctor.add_argument( + "--warnings-as-errors", + action="store_true", + help="treat warnings as fatal", + ) + + return parser diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py new file mode 100644 index 00000000..e250f857 --- /dev/null +++ b/docs/_ext/package_reference.py @@ -0,0 +1,714 @@ +"""Generate package reference sections from live workspace metadata. + +Examples +-------- +>>> package = workspace_packages()[0] +>>> package["name"] in { +... "gp-sphinx", +... "sphinx-fonts", +... "sphinx-gptheme", +... "sphinx-argparse-neo", +... "sphinx-autodoc-docutils", +... "sphinx-autodoc-pytest-fixtures", +... "sphinx-autodoc-sphinx", +... } +True + +>>> surface = collect_extension_surface("sphinx_fonts") +>>> any(item["name"] == "sphinx_fonts" for item in surface["config_values"]) +True +""" + +from __future__ import annotations + +import configparser +import importlib +import inspect +import os +import pathlib +import pkgutil +import sys +import typing as t + +from docutils import nodes +from docutils.parsers.rst import roles +from sphinx.util.docutils import SphinxDirective + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib # type: ignore[import-not-found] + + +class SurfaceDict(t.TypedDict): + """Collected extension surface rows keyed by registration category.""" + + module: str + config_values: list[dict[str, str]] + directives: list[dict[str, str]] + roles: list[dict[str, str]] + lexers: list[dict[str, str]] + themes: list[dict[str, str]] + + +def ensure_workspace_imports() -> None: + """Ensure each workspace package ``src`` directory is importable. + + Examples + -------- + >>> ensure_workspace_imports() + """ + for package in workspace_packages(): + src_path = os.fspath(pathlib.Path(package["package_dir"]) / "src") + if src_path not in sys.path: + sys.path.insert(0, src_path) + + +def workspace_root() -> pathlib.Path: + """Return the repository root for the current docs build. + + Examples + -------- + >>> workspace_root().name + 'gp-sphinx' + """ + return pathlib.Path(__file__).resolve().parents[2] + + +def workspace_packages() -> list[dict[str, str]]: + """Return publishable workspace packages and their module names. + + Examples + -------- + >>> names = [package["name"] for package in workspace_packages()] + >>> "gp-sphinx" in names + True + """ + packages_dir = workspace_root() / "packages" + packages: list[dict[str, str]] = [] + for pyproject_path in sorted(packages_dir.glob("*/pyproject.toml")): + with pyproject_path.open("rb") as handle: + project = tomllib.load(handle)["project"] + src_dir = pyproject_path.parent / "src" + module_dir = next((path for path in src_dir.iterdir() if path.is_dir()), None) + if module_dir is None: + continue + packages.append( + { + "name": str(project["name"]), + "module_name": module_dir.name, + "package_dir": str(pyproject_path.parent), + "description": str(project.get("description", "")), + "version": str(project["version"]), + "repository": str(project.get("urls", {}).get("Repository", "")), + "maturity": maturity_from_classifiers( + t.cast(list[str], project.get("classifiers", [])) + ), + } + ) + return packages + + +def maturity_from_classifiers(classifiers: list[str]) -> str: + """Return the short maturity label derived from project classifiers. + + Examples + -------- + >>> maturity_from_classifiers(["Development Status :: 4 - Beta"]) + 'Beta' + >>> maturity_from_classifiers([]) + 'Unknown' + """ + for classifier in classifiers: + if classifier.startswith("Development Status :: 3"): + return "Alpha" + if classifier.startswith("Development Status :: 4"): + return "Beta" + if classifier.startswith("Development Status :: 5"): + return "Production/Stable" + return "Unknown" + + +def extension_modules(module_name: str) -> list[str]: + """Return importable submodules that expose a Sphinx ``setup()`` function. + + Examples + -------- + >>> "sphinx_argparse_neo" in extension_modules("sphinx_argparse_neo") + True + >>> "sphinx_argparse_neo.exemplar" in extension_modules("sphinx_argparse_neo") + True + """ + ensure_workspace_imports() + module = importlib.import_module(module_name) + modules = [] + if callable(getattr(module, "setup", None)): + modules.append(module_name) + + package_paths = getattr(module, "__path__", None) + if package_paths is None: + return modules + + for module_info in pkgutil.walk_packages(package_paths, prefix=f"{module_name}."): + submodule = importlib.import_module(module_info.name) + if callable(getattr(submodule, "setup", None)): + modules.append(module_info.name) + return modules + + +def summarize(text: str | None) -> str: + """Return the first non-empty sentence-like summary from a docstring. + + Examples + -------- + >>> summarize("One sentence.\\n Two sentence.") + 'One sentence.' + >>> summarize(None) + '' + """ + if not text: + return "" + stripped = inspect.cleandoc(text).strip() + if not stripped: + return "" + first_line = stripped.splitlines()[0].strip() + if first_line: + return first_line + return stripped + + +def render_value(value: object) -> str: + """Render a compact literal representation for docs tables. + + Examples + -------- + >>> render_value(True) + '`True`' + >>> render_value(["a", "b"]) + "`['a', 'b']`" + """ + return f"`{value!r}`" + + +def render_types(types: object, default: object) -> str: + """Render a readable type cell for a config-value table. + + Examples + -------- + >>> render_types([dict], {}) + '`dict`' + >>> render_types(None, "x") + '`str`' + """ + if isinstance(types, (list, tuple, set, frozenset)) and types: + names = sorted( + getattr(item, "__name__", str(item)) + for item in t.cast(t.Iterable[object], types) + ) + return f"`{' | '.join(names)}`" + if default is None: + return "`None`" + return f"`{type(default).__name__}`" + + +class RecorderApp: + """Lightweight recorder for Sphinx setup calls. + + Examples + -------- + >>> app = RecorderApp() + >>> app.add_config_value("demo", 1, "env") + >>> app.calls[0][0] + 'add_config_value' + """ + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] + + def __getattr__(self, name: str) -> t.Callable[..., None]: + """Record arbitrary Sphinx app API calls used by extension setup code. + + Examples + -------- + >>> app = RecorderApp() + >>> app.add_role("demo", object()) + >>> app.calls[0][0] + 'add_role' + """ + + def _record(*args: object, **kwargs: object) -> None: + self.calls.append((name, args, kwargs)) + + return _record + + +def collect_extension_surface(module_name: str) -> SurfaceDict: + """Collect config values, directives, roles, and lexers for an extension. + + Examples + -------- + >>> surface = collect_extension_surface("sphinx_autodoc_pytest_fixtures") + >>> any(item["name"] == "autofixtures" for item in surface["directives"]) + True + """ + ensure_workspace_imports() + app = RecorderApp() + module = importlib.import_module(module_name) + 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)) + + 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 + + config_values: list[dict[str, str]] = [] + directives: list[dict[str, str]] = [] + role_items: list[dict[str, str]] = [] + lexers: list[dict[str, str]] = [] + themes: list[dict[str, str]] = [] + + for name, args, kwargs in app.calls: + if name == "add_config_value": + if len(args) < 2: + continue + option = str(args[0]) + default = args[1] + rebuild = str(kwargs.get("rebuild", args[2] if len(args) > 2 else "")) + types = kwargs.get("types") + config_values.append( + { + "name": option, + "default": render_value(default), + "rebuild": f"`{rebuild}`" if rebuild else "", + "types": render_types(types, default), + } + ) + elif name == "add_directive": + directive_name = str(args[0]) + directive_cls = args[1] + directives.append( + { + "name": directive_name, + "kind": "directive", + "callable": object_path(directive_cls), + "summary": summarize(getattr(directive_cls, "__doc__", None)), + "options": directive_options_markdown(directive_cls), + } + ) + elif name == "add_directive_to_domain": + domain = str(args[0]) + directive_name = str(args[1]) + directive_cls = args[2] + directives.append( + { + "name": f"{domain}:{directive_name}", + "kind": "domain directive", + "callable": object_path(directive_cls), + "summary": summarize(getattr(directive_cls, "__doc__", None)), + "options": directive_options_markdown(directive_cls), + } + ) + elif name == "add_crossref_type": + directive_name = str(args[0]) + role_name = str(args[1] if len(args) > 1 else args[0]) + directives.append( + { + "name": f"std:{directive_name}", + "kind": "cross-reference directive", + "callable": "`sphinx.application.Sphinx.add_crossref_type`", + "summary": "Registers a standard-domain cross-reference target.", + "options": "", + } + ) + role_items.append( + { + "name": f"std:{role_name}", + "kind": "cross-reference role", + "callable": "`sphinx.application.Sphinx.add_crossref_type`", + "summary": "Registers a standard-domain cross-reference role.", + } + ) + elif name == "add_role": + role_name = str(args[0]) + role_fn = args[1] + role_items.append( + { + "name": role_name, + "kind": "role", + "callable": object_path(role_fn), + "summary": summarize(getattr(role_fn, "__doc__", None)), + } + ) + elif name == "add_role_to_domain": + domain = str(args[0]) + role_name = str(args[1]) + role_fn = args[2] + role_items.append( + { + "name": f"{domain}:{role_name}", + "kind": "domain role", + "callable": object_path(role_fn), + "summary": summarize(getattr(role_fn, "__doc__", None)), + } + ) + elif name == "add_lexer": + lexers.append( + { + "name": str(args[0]), + "callable": object_path(args[1]), + } + ) + elif name == "add_html_theme": + themes.append( + { + "name": str(args[0]), + "path": f"`{args[1]}`", + } + ) + + 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), + "directives": unique_by_name(directives), + "roles": unique_by_name(role_items), + "lexers": unique_by_name(lexers), + "themes": unique_by_name(themes), + } + + +def object_path(value: object) -> str: + """Return a best-effort dotted import path for an arbitrary object. + + Examples + -------- + >>> object_path(RecorderApp) + '`package_reference.RecorderApp`' + """ + module_name = getattr(value, "__module__", type(value).__module__) + object_name = getattr(value, "__name__", type(value).__name__) + return f"`{module_name}.{object_name}`" + + +def unique_by_name(items: list[dict[str, str]]) -> list[dict[str, str]]: + """Deduplicate rows while preserving their first-seen order. + + Examples + -------- + >>> unique_by_name([{"name": "x"}, {"name": "x"}, {"name": "y"}]) + [{'name': 'x'}, {'name': 'y'}] + """ + seen: set[str] = set() + result: list[dict[str, str]] = [] + for item in items: + name = item["name"] + if name in seen: + continue + seen.add(name) + result.append(item) + return result + + +def directive_options_markdown(directive_cls: object) -> str: + """Render a Markdown table of directive options, if any. + + Examples + -------- + >>> from sphinx_argparse_neo.directive import ArgparseDirective + >>> "module" in directive_options_markdown(ArgparseDirective) + True + """ + option_spec = getattr(directive_cls, "option_spec", None) + if not isinstance(option_spec, dict) or not option_spec: + return "" + lines = [ + "", + "| Option | |", + "| --- | --- |", + ] + for option_name in sorted(str(key) for key in option_spec): + lines.append(f"| `:{option_name}:` | Registered option |") + return "\n".join(lines) + + +def theme_options(package_dir: pathlib.Path) -> list[str]: + """Return theme option names declared in a package ``theme.conf`` file. + + Examples + -------- + >>> "light_logo" in theme_options(workspace_root() / "packages" / "sphinx-gptheme") + True + """ + theme_conf = package_dir / "src" / "sphinx_gptheme" / "theme" / "theme.conf" + if not theme_conf.exists(): + return [] + parser = configparser.ConfigParser() + parser.read(theme_conf) + if "options" not in parser: + return [] + return sorted(parser["options"].keys()) + + +def package_reference_markdown(package_name: str) -> str: + """Render the generated Markdown fragment for a workspace package page. + + Examples + -------- + >>> "Registered Surface" in package_reference_markdown("sphinx-fonts") + True + """ + package = next( + item for item in workspace_packages() if item["name"] == package_name + ) + package_dir = pathlib.Path(package["package_dir"]) + module_name = package["module_name"] + extension_blocks = [ + collect_extension_surface(name) for name in extension_modules(module_name) + ] + + lines = [ + "## Copyable config snippet", + "", + "```python", + "extensions = [", + ] + + if extension_blocks: + for block in extension_blocks: + lines.append(f' "{block["module"]}",') + elif package_name == "gp-sphinx": + lines.append(' "gp_sphinx",') + else: + lines.append(f' "{module_name}",') + + lines.extend(["]", "```", ""]) + + if package["repository"]: + lines.extend( + [ + "## Package metadata", + "", + f"- Source on GitHub: [{package_name}]({package['repository']}/tree/master/packages/{package_name})", + f"- Maturity: `{package['maturity']}`", + "", + ] + ) + + if package_name == "gp-sphinx": + lines.extend( + [ + "## Registered Surface", + "", + "This package is a coordinator rather than a Sphinx extension module.", + "Its public runtime surface is documented in {doc}`/configuration` and {doc}`/api`.", + "", + ] + ) + return "\n".join(lines) + + lines.extend(["## Registered Surface", ""]) + + for block in extension_blocks: + lines.extend([f"### {block['module']}", ""]) + config_rows = block["config_values"] + if config_rows: + lines.extend( + [ + "#### Config values", + "", + "| Name | Default | Rebuild | Types |", + "| --- | --- | --- | --- |", + ] + ) + for row in config_rows: + lines.append( + f"| `{row['name']}` | {row['default']} | {row['rebuild']} | {row['types']} |" + ) + lines.append("") + + directive_rows = block["directives"] + if directive_rows: + lines.extend( + [ + "#### Directives", + "", + "| Name | Kind | Callable | Summary |", + "| --- | --- | --- | --- |", + ] + ) + for row in directive_rows: + lines.append( + f"| `{row['name']}` | {row['kind']} | {row['callable']} | {row['summary']} |" + ) + lines.append("") + for row in directive_rows: + if row["options"]: + lines.extend( + [ + f"##### {row['name']} options", + row["options"], + "", + ] + ) + + role_rows = block["roles"] + if role_rows: + lines.extend( + [ + "#### Roles", + "", + "| Name | Kind | Callable | Summary |", + "| --- | --- | --- | --- |", + ] + ) + for row in role_rows: + lines.append( + f"| `{row['name']}` | {row['kind']} | {row['callable']} | {row['summary']} |" + ) + lines.append("") + + lexer_rows = block["lexers"] + if lexer_rows: + lines.extend( + [ + "#### Lexers", + "", + "| Name | Callable |", + "| --- | --- |", + ] + ) + for row in lexer_rows: + lines.append(f"| `{row['name']}` | {row['callable']} |") + lines.append("") + + theme_rows = block["themes"] + if theme_rows: + lines.extend( + [ + "#### Theme registration", + "", + "| Theme | Path |", + "| --- | --- |", + ] + ) + for row in theme_rows: + lines.append(f"| `{row['name']}` | {row['path']} |") + lines.append("") + + if module_name == "sphinx_gptheme": + options = theme_options(package_dir) + lines.extend( + [ + "### Theme options (theme.conf)", + "", + "| Option |", + "| --- |", + ] + ) + for option in options: + lines.append(f"| `{option}` |") + lines.append("") + + return "\n".join(lines) + + +def maturity_badge(maturity: str) -> str: + """Return a sphinx-design badge role matching a package maturity label. + + Examples + -------- + >>> maturity_badge("Alpha") + '{bdg-warning-line}`Alpha`' + """ + if maturity == "Alpha": + return "{bdg-warning-line}`Alpha`" + if maturity == "Beta": + return "{bdg-success-line}`Beta`" + return f"{{bdg-secondary-line}}`{maturity}`" + + +def workspace_package_grid_markdown() -> str: + """Render the package index grid from workspace metadata. + + Examples + -------- + >>> "grid-item-card" in workspace_package_grid_markdown() + True + """ + lines = [ + "::::{grid} 1 1 2 2", + ":gutter: 2 2 3 3", + "", + ] + for package in workspace_packages(): + lines.extend( + [ + f":::{{grid-item-card}} {package['name']} {maturity_badge(package['maturity'])}", + f":link: {package['name']}", + ":link-type: doc", + "", + str(package["description"]), + ":::", + "", + ] + ) + lines.append("::::") + return "\n".join(lines) + + +class PackageReferenceDirective(SphinxDirective): + """Render a generated package reference block inside a page.""" + + required_arguments = 1 + has_content = False + + def run(self) -> list[nodes.Node]: + package_name = self.arguments[0] + return self.parse_text_to_nodes(package_reference_markdown(package_name)) + + +class WorkspacePackageGridDirective(SphinxDirective): + """Render the packages index grid from workspace package metadata.""" + + has_content = False + + def run(self) -> list[nodes.Node]: + return self.parse_text_to_nodes(workspace_package_grid_markdown()) + + +def setup(app: t.Any) -> dict[str, object]: + """Register the package-reference directive for documentation pages. + + Examples + -------- + >>> fake = RecorderApp() + >>> metadata = setup(fake) + >>> metadata["parallel_read_safe"] + True + """ + ensure_workspace_imports() + app.add_directive("package-reference", PackageReferenceDirective) + app.add_directive("workspace-package-grid", WorkspacePackageGridDirective) + return { + "parallel_read_safe": True, + "parallel_write_safe": True, + "version": "0.0.1", + } diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 90327735..95373cf0 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -232,3 +232,34 @@ img[src*="codecov.io"] { ::view-transition-new(root) { animation-duration: 150ms; } + +.package-demo-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + margin: 1.25rem 0; +} + +.package-demo-card { + border: 1px solid var(--color-background-border); + border-radius: 0.75rem; + background: var(--color-background-secondary); + padding: 1rem; +} + +.package-demo-card h3 { + margin-top: 0; + margin-bottom: 0.5rem; +} + +.font-specimen-sans { + font-family: var(--font-stack); + font-size: 1.25rem; + line-height: 1.35; +} + +.font-specimen-mono { + font-family: var(--font-stack--monospace); + font-size: 1rem; + line-height: 1.5; +} diff --git a/docs/conf.py b/docs/conf.py index 1dabba35..618be1be 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import pathlib import sys @@ -11,14 +12,24 @@ 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-autodoc-pytest-fixtures" / "src") ) -sys.path.insert(0, str(cwd / "_ext")) # spf_demo_fixtures for badge demo +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(cwd / "_ext")) # docs demo modules import gp_sphinx # noqa: E402 from gp_sphinx.config import merge_sphinx_config # noqa: E402 +intersphinx_mapping = {} +if os.environ.get("GP_SPHINX_ENABLE_INTERSPHINX") == "1": + intersphinx_mapping = { + "py": ("https://docs.python.org/", None), + "sphinx": ("https://www.sphinx-doc.org/en/master/", None), + } + conf = merge_sphinx_config( project=gp_sphinx.__title__, version=gp_sphinx.__version__, @@ -26,12 +37,15 @@ source_repository=f"{gp_sphinx.__github__}/", docs_url=gp_sphinx.__docs__, source_branch="master", - extra_extensions=["sphinx_autodoc_pytest_fixtures"], + extra_extensions=[ + "package_reference", + "sphinx_autodoc_pytest_fixtures", + "sphinx_autodoc_docutils", + "sphinx_autodoc_sphinx", + "sphinx_argparse_neo.exemplar", + ], pytest_fixture_lint_level="none", rediraffe_redirects="redirects.txt", - intersphinx_mapping={ - "py": ("https://docs.python.org/", None), - "sphinx": ("https://www.sphinx-doc.org/en/master/", None), - }, + intersphinx_mapping=intersphinx_mapping, ) globals().update(conf) diff --git a/docs/configuration.md b/docs/configuration.md index 3ebbe484..19c58d6d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,166 +2,160 @@ # Configuration -Reference for `merge_sphinx_config()` and the shared defaults it applies. +Reference for `gp_sphinx.config.merge_sphinx_config()` and the shared defaults +it applies. -## merge_sphinx_config() - -All parameters are keyword-only. - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `project` | `str` | required | Sphinx project name | -| `version` | `str` | required | Project version (also sets `release`) | -| `copyright` | `str` | required | Copyright string | -| `extensions` | `list[str] \| None` | `None` | Replace entire extension list (overrides defaults) | -| `extra_extensions` | `list[str] \| None` | `None` | Append to default extension list | -| `remove_extensions` | `list[str] \| None` | `None` | Remove specific extensions from defaults | -| `theme_options` | `dict \| None` | `None` | Deep-merged with `DEFAULT_THEME_OPTIONS` | -| `source_repository` | `str \| None` | `None` | GitHub URL (auto-computes `issue_url_tpl` and footer icon) | -| `source_branch` | `str` | `"master"` | Default branch name | -| `light_logo` / `dark_logo` | `str \| None` | `None` | Theme logo paths | -| `docs_url` | `str \| None` | `None` | Docs URL (auto-computes OGP settings) | -| `intersphinx_mapping` | `Mapping \| None` | `None` | Intersphinx targets | -| `**overrides` | `Any` | — | Any Sphinx config key, passed through verbatim | - -`**overrides` is the escape hatch — any valid Sphinx configuration key can be -passed as a keyword argument. This includes extension-specific settings like -`rediraffe_redirects`, `pytest_fixture_lint_level`, or `html_favicon`. -Auto-computed values can also be overridden this way. - -## Auto-computed values - -When `source_repository` is provided: +## Integration pattern -- `issue_url_tpl` for `linkify_issues` -- `html_theme_options["source_repository"]` -- Footer icon GitHub URL +```python +from gp_sphinx.config import merge_sphinx_config + +conf = merge_sphinx_config( + project="my-project", + version="1.2.3", + copyright="2026, Your Name", + source_repository="https://github.com/your-org/my-project/", +) +globals().update(conf) +``` -When `docs_url` is provided: +`merge_sphinx_config()` returns a flat dictionary meant to be injected into the +module namespace with `globals().update(conf)`. That is the conventional Sphinx +integration point: Sphinx reads `conf.py` globals directly, and the returned +mapping already includes the coordinator’s generated `setup(app)` hook. -- `ogp_site_url` for `sphinxext.opengraph` -- `ogp_site_name` (set to project name) -- `ogp_image` (`_static/img/icons/icon-192x192.png`) +## `merge_sphinx_config()` parameters -When `linkcode_resolve` is in `**overrides`: +All parameters are keyword-only. -- `sphinx.ext.linkcode` is auto-appended to extensions +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `project` | `str` | required | Project name assigned to `project` and used in derived metadata | +| `version` | `str` | required | Version string assigned to both `version` and `release` | +| `copyright` | `str` | required | Copyright string for Sphinx metadata | +| `extensions` | `list[str] \| None` | `None` | Seed extension list; when omitted, uses `DEFAULT_EXTENSIONS` | +| `extra_extensions` | `list[str] \| None` | `None` | Additional extensions appended after the base list is chosen | +| `remove_extensions` | `list[str] \| None` | `None` | Extensions removed from the selected base list | +| `theme_options` | `dict[str, Any] \| None` | `None` | Deep-merged into `DEFAULT_THEME_OPTIONS` after auto-populated source/logo values | +| `source_repository` | `str \| None` | `None` | GitHub repository URL used for issue links, footer icon URLs, and theme source metadata | +| `source_branch` | `str` | `"master"` | Source branch stored in `html_theme_options["source_branch"]` | +| `light_logo` | `str \| None` | `None` | Light-mode logo path merged into theme options | +| `dark_logo` | `str \| None` | `None` | Dark-mode logo path merged into theme options | +| `docs_url` | `str \| None` | `None` | Canonical docs URL used to derive Open Graph settings | +| `intersphinx_mapping` | `Mapping[str, tuple[str, str \| None]] \| None` | `None` | Mapping assigned to `intersphinx_mapping` when provided | +| `**overrides` | `Any` | none | Final escape hatch for any Sphinx config key; applied after all defaults and auto-computed values | -## Hardcoded defaults +## Auto-computed values -Set unconditionally. Override via `**overrides` if needed: +### From `source_repository` | Key | Value | -|-----|-------| -| `master_doc` | `"index"` | -| `release` | same as `version` | -| `source_suffix` | `{".rst": "restructuredtext", ".md": "markdown"}` | -| `html_static_path` | `["_static"]` | -| `templates_path` | `["_templates"]` | -| `pygments_style` | `"monokai"` | -| `pygments_dark_style` | `"monokai"` | -| `exclude_patterns` | `["_build"]` | - -## Injected setup() - -The returned config includes a `setup` function that: - -- Registers `js/spa-nav.js` with deferred loading (from sphinx-gptheme) -- Connects a `build-finished` hook to remove `tabs.js` (sphinx-inline-tabs workaround) - -## Integration pattern +| --- | --- | +| `issue_url_tpl` | `"{repo}/issues/{issue_id}"` | +| `html_theme_options["source_repository"]` | repository URL | +| `html_theme_options["footer_icons"][0]["url"]` | repository URL for the GitHub footer icon | -```python -conf = merge_sphinx_config(...) -globals().update(conf) -``` +### From `docs_url` -This injects all keys into the module namespace, which is how Sphinx -reads `conf.py`. +| Key | Value | +| --- | --- | +| `ogp_site_url` | `docs_url` | +| `ogp_site_name` | `project` | +| `ogp_image` | `"_static/img/icons/icon-192x192.png"` | -## Default extensions +### From `**overrides` -| Extension | Purpose | -|-----------|---------| -| `sphinx.ext.autodoc` | Auto-document Python objects | -| `sphinx_fonts` | Self-hosted fonts via Fontsource CDN | -| `sphinx.ext.intersphinx` | Cross-project linking | -| `sphinx_autodoc_typehints` | Type hints in docstrings | -| `sphinx.ext.todo` | TODO directive | -| `sphinx.ext.napoleon` | NumPy/Google docstring support | -| `sphinx_inline_tabs` | Inline tab containers | -| `sphinx_copybutton` | Copy button on code blocks | -| `sphinxext.opengraph` | OpenGraph meta tags | -| `sphinxext.rediraffe` | URL redirects | -| `sphinx_design` | Cards, grids, badges | -| `myst_parser` | Markdown support | -| `linkify_issues` | Auto-link `#123` to issues (from gp-libs) | +If `linkcode_resolve` is present in `**overrides`, `merge_sphinx_config()` +automatically appends `sphinx.ext.linkcode` to `extensions` if it is not +already present. -## Default theme +## Injected `setup(app)` -`sphinx-gptheme` — Furo child theme. Source directory `docs/`, source -branch `master`, GitHub footer icon. Theme options are deep-merged when -`theme_options` is passed. +The returned config includes a `setup(app)` function from +`gp_sphinx.config.setup`. It does two things: -## Font defaults +| Action | Effect | +| --- | --- | +| `app.add_js_file("js/spa-nav.js", loading_method="defer")` | Registers the bundled SPA navigation script from `sphinx-gptheme` | +| `app.connect("build-finished", remove_tabs_js)` | Removes `_static/tabs.js` after HTML builds as a `sphinx-inline-tabs` workaround | -- **IBM Plex Sans**: weights 400, 500, 600, 700 (normal + italic) -- **IBM Plex Mono**: weight 400 (normal + italic) -- **Preload**: Sans 400 normal, Sans 700 normal, Mono 400 normal -- **Fallbacks**: metric-matched Arial/Courier New for zero-CLS loading -- **CSS variables**: `--font-stack`, `--font-stack--monospace`, `--font-stack--headings` +## Always-set coordinator values -## MyST defaults +These are injected even though they are not exposed as `DEFAULT_*` constants: -Extensions: `colon_fence`, `substitution`, `replacements`, `strikethrough`, `linkify`. -Heading anchors: 4 levels. +| Key | Value | +| --- | --- | +| `master_doc` | `"index"` | +| `release` | `version` | +| `html_theme` | `DEFAULT_THEME` | +| `html_theme_path` | `[]` | +| `rediraffe_redirects` | `{}` | +| `rediraffe_branch` | `"master~1"` | +| `exclude_patterns` | `["_build"]` | +| `setup` | `gp_sphinx.config.setup` | -## Autodoc defaults +## Shared `DEFAULT_*` constants -`DEFAULT_AUTODOC_OPTIONS`: +### Extensions and source parsing -| Setting | Value | -|---------|-------| -| `autoclass_content` | `"both"` | -| `autodoc_member_order` | `"bysource"` | -| `autodoc_class_signature` | `"separated"` | -| `autodoc_typehints` | `"description"` | -| `toc_object_entries_show_parents` | `"hide"` | +| 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_SOURCE_SUFFIX` | `{".rst": "restructuredtext", ".md": "markdown"}` | +| `DEFAULT_MYST_EXTENSIONS` | `["colon_fence", "substitution", "replacements", "strikethrough", "linkify"]` | +| `DEFAULT_MYST_HEADING_ANCHORS` | `4` | +| `DEFAULT_TEMPLATES_PATH` | `["_templates"]` | +| `DEFAULT_HTML_STATIC_PATH` | `["_static"]` | -`DEFAULT_AUTODOC_OPTIONS` dict (applied to `autodoc_default_options`): +### Theme defaults -| Key | Value | -|-----|-------| -| `members` | `True` | -| `undoc-members` | `True` | -| `private-members` | `False` | -| `show-inheritance` | `True` | -| `member-order` | `"bysource"` | +| Constant | Value | +| --- | --- | +| `DEFAULT_THEME` | `"sphinx-gptheme"` | +| `DEFAULT_THEME_OPTIONS` | footer GitHub icon, `source_repository=""`, `source_branch="master"`, `source_directory="docs/"` | -## Static paths and source suffix +### Font defaults | Constant | Value | -|----------|-------| -| `DEFAULT_SOURCE_SUFFIX` | `{".rst": "restructuredtext", ".md": "markdown"}` | -| `DEFAULT_HTML_STATIC_PATH` | `["_static"]` | -| `DEFAULT_TEMPLATES_PATH` | `["_templates"]` | +| --- | --- | +| `DEFAULT_SPHINX_FONTS` | IBM Plex Sans (400/500/600/700, normal+italic) and IBM Plex Mono (400, normal+italic) Fontsource definitions | +| `DEFAULT_SPHINX_FONT_PRELOAD` | `("IBM Plex Sans", 400, "normal")`, `("IBM Plex Sans", 700, "normal")`, `("IBM Plex Mono", 400, "normal")` | +| `DEFAULT_SPHINX_FONT_FALLBACKS` | Metric-adjusted Arial and Courier fallback declarations | +| `DEFAULT_SPHINX_FONT_CSS_VARIABLES` | `--font-stack`, `--font-stack--monospace`, `--font-stack--headings` | -## Copybutton defaults - -`DEFAULT_COPYBUTTON_PROMPT_TEXT` — regex matching Python (`>>>`), continuation (`...`), shell (`$`, `#`), and IPython prompts. See `defaults.py` for the full pattern. +### Syntax highlighting and copybutton | Constant | Value | -|----------|-------| +| --- | --- | +| `DEFAULT_PYGMENTS_STYLE` | `"monokai"` | +| `DEFAULT_PYGMENTS_DARK_STYLE` | `"monokai"` | +| `DEFAULT_COPYBUTTON_PROMPT_TEXT` | regex matching Python, shell, and IPython prompts | | `DEFAULT_COPYBUTTON_PROMPT_IS_REGEXP` | `True` | | `DEFAULT_COPYBUTTON_REMOVE_PROMPTS` | `True` | | `DEFAULT_COPYBUTTON_LINE_CONTINUATION_CHARACTER` | `"\\"` | -## Other defaults +### Autodoc defaults | Constant | Value | -|----------|-------| +| --- | --- | +| `DEFAULT_AUTOCLASS_CONTENT` | `"both"` | +| `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 + +| Constant | Value | +| --- | --- | | `DEFAULT_NAPOLEON_GOOGLE_DOCSTRING` | `True` | | `DEFAULT_NAPOLEON_INCLUDE_INIT_WITH_DOC` | `False` | | `DEFAULT_SUPPRESS_WARNINGS` | `["sphinx_autodoc_typehints.forward_reference"]` | -Rediraffe: `rediraffe_redirects = {}`, `rediraffe_branch = "master~1"`. +## Parameter interactions + +- `extensions`, `extra_extensions`, and `remove_extensions` are applied in that order. +- `theme_options` is deep-merged, so nested theme dictionaries can be overridden without replacing the whole structure. +- `**overrides` runs last, so it can replace any default or auto-computed value. +- The returned `setup(app)` hook survives `globals().update(conf)` intact because Sphinx reads it as a normal top-level `conf.py` function. diff --git a/docs/index.md b/docs/index.md index b272f5ce..27d3f3f2 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 -Five workspace packages — coordinator, extensions, and theme. +Seven workspace packages — coordinator, extensions, and theme. ::: :::{grid-item-card} Configuration diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx.md index 8a9dafd2..038c51d2 100644 --- a/docs/packages/gp-sphinx.md +++ b/docs/packages/gp-sphinx.md @@ -2,10 +2,9 @@ {bdg-warning-line}`Alpha` {bdg-success}`coordinator` -Configuration coordinator for shared Sphinx documentation infrastructure. -A single `merge_sphinx_config()` call builds a complete Sphinx namespace from -shared defaults — extensions, theme, fonts, autodoc settings, copybutton, -MyST, and more. +Shared configuration coordinator for Sphinx projects. `merge_sphinx_config()` +builds a complete `conf.py` namespace from the workspace defaults and leaves +per-project overrides in one place. ```console $ pip install gp-sphinx @@ -15,32 +14,45 @@ $ pip install gp-sphinx $ uv add gp-sphinx ``` -## Usage +## Downstream `conf.py` ```python +from __future__ import annotations + from gp_sphinx.config import merge_sphinx_config +import my_project + conf = merge_sphinx_config( project="my-project", - version="1.0.0", - copyright="2026, Tony Narlock", - source_repository="https://github.com/git-pull/my-project/", + version=my_project.__version__, + copyright="2026, Your Name", + source_repository="https://github.com/your-org/my-project/", + docs_url="https://my-project.example.com/", + intersphinx_mapping={ + "py": ("https://docs.python.org/3", None), + }, ) globals().update(conf) ``` -That single call configures 13 extensions, the sphinx-gptheme Furo child -theme, IBM Plex fonts via sphinx-fonts, copybutton with regex prompt -stripping, MyST with colon fences and linkify, intersphinx, opengraph, -rediraffe, and napoleon. +## What it injects + +- Shared extension defaults, theme defaults, fonts, MyST, napoleon, copybutton, and rediraffe settings. +- Auto-computed values like `issue_url_tpl`, `ogp_site_url`, `ogp_site_name`, and `ogp_image` when repository and docs URLs are provided. +- A `setup(app)` hook that registers `js/spa-nav.js` and removes `tabs.js` after HTML builds. +- Support for appending `sphinx.ext.linkcode` automatically when `linkcode_resolve` is supplied in `**overrides`. -:::{admonition} Self-documenting -This docs site is built by gp-sphinx. See +See {doc}`/configuration` for the complete parameter reference and every shared `DEFAULT_*` constant. + +:::{admonition} Live example +This site is built with `gp-sphinx`, using the same integration pattern shown +above. See [docs/conf.py](https://github.com/git-pull/gp-sphinx/blob/master/docs/conf.py) -— it uses the same `merge_sphinx_config()` pattern described here. +for the exact coordinator call. ::: -See {doc}`/configuration` for the full parameter reference and shared -defaults. +```{package-reference} gp-sphinx +``` [Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/gp-sphinx) diff --git a/docs/packages/index.md b/docs/packages/index.md index 9a37127c..2f39dcb8 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,51 +1,16 @@ # Packages -Five workspace packages, each independently installable. +Seven workspace packages, each independently installable. -::::{grid} 1 1 2 2 -:gutter: 2 2 3 3 - -:::{grid-item-card} gp-sphinx {bdg-warning-line}`Alpha` -:link: gp-sphinx -:link-type: doc -Configuration coordinator. One `merge_sphinx_config()` call replaces -duplicated `docs/conf.py` boilerplate. -::: - -:::{grid-item-card} sphinx-autodoc-pytest-fixtures {bdg-warning-line}`Alpha` -:link: sphinx-autodoc-pytest-fixtures -:link-type: doc -Autodocumenter for pytest fixtures with scope badges, dependency -tracking, and usage snippets. -::: - -:::{grid-item-card} sphinx-fonts {bdg-success-line}`Beta` -:link: sphinx-fonts -:link-type: doc -Self-hosted web fonts via Fontsource CDN with `@font-face` injection -and preload hints. -::: - -:::{grid-item-card} sphinx-gptheme {bdg-success-line}`Beta` -:link: sphinx-gptheme -:link-type: doc -Furo child theme with custom sidebar, SPA navigation, and IBM Plex -typography. -::: - -:::{grid-item-card} sphinx-argparse-neo {bdg-success-line}`Beta` -:link: sphinx-argparse-neo -:link-type: doc -Argparse CLI documentation with `.. argparse::` directive and epilog -transformation. -::: - -:::: +```{workspace-package-grid} +``` ```{toctree} :hidden: gp-sphinx +sphinx-autodoc-docutils +sphinx-autodoc-sphinx sphinx-autodoc-pytest-fixtures sphinx-fonts sphinx-gptheme diff --git a/docs/packages/sphinx-argparse-neo.md b/docs/packages/sphinx-argparse-neo.md index 57afebdf..f85cc57d 100644 --- a/docs/packages/sphinx-argparse-neo.md +++ b/docs/packages/sphinx-argparse-neo.md @@ -2,104 +2,114 @@ {bdg-success-line}`Beta` {bdg-primary}`extension` -Sphinx extension for documenting argparse-based CLI tools. Renders argument -parsers as structured documentation with usage sections, argument groups, -subcommands, and optional epilog-to-section transformation. +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 ``` -Or as a gp-sphinx optional extra: +## Downstream `conf.py` -```console -$ pip install gp-sphinx[argparse] +```python +extensions = [ + "sphinx_argparse_neo", + "sphinx_argparse_neo.exemplar", +] + +argparse_examples_section_title = "Examples" +argparse_reorder_usage_before_examples = True ``` -## Usage +## Live directive demos -Add to your Sphinx extensions: +### Base parser rendering -```python -extensions = ["sphinx_argparse_neo"] +```{argparse} +:module: demo_cli +:func: create_parser +:prog: myapp ``` -Then use the `argparse` directive: +### Subcommand rendering -```rst -.. argparse:: - :module: myapp.cli - :func: create_parser - :prog: myapp +Drill into a single subcommand with `:path:`: + +```{argparse} +:module: demo_cli +:func: create_parser +:path: mysubcommand +:prog: myapp ``` -## Base extension config values +### Inline roles -| Config | Default | Description | -|--------|---------|-------------| -| `argparse_group_title_prefix` | `""` | Prefix for argument group titles | -| `argparse_show_defaults` | `True` | Show default values in argument docs | -| `argparse_show_choices` | `True` | Show choice constraints | -| `argparse_show_types` | `True` | Show type information | +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`. -## argparse directive options +## Configuration values -Key options for the `.. argparse::` directive: +### Base extension -| Option | Description | -|--------|-------------| -| `:module:` | Python module containing the parser factory | -| `:func:` | Function that returns an `ArgumentParser` | -| `:prog:` | Program name for usage display | -| `:path:` | Subcommand path (e.g., `sub1 sub2`) | -| `:nodefault:` | Suppress default value display | -| `:nosubcommands:` | Suppress subcommand documentation | -| `:nosectionheading:` | Use rubrics instead of heading sections | +```{eval-rst} +.. autoconfigvalue-index:: sphinx_argparse_neo +.. autoconfigvalues:: sphinx_argparse_neo +``` + +### Exemplar layer -## Exemplar sub-extension +```{eval-rst} +.. autoconfigvalue-index:: sphinx_argparse_neo.exemplar +.. autoconfigvalues:: sphinx_argparse_neo.exemplar +``` -The exemplar layer adds enhanced features on top of the base directive. -Add it separately: +## Registered directives and roles -```python -extensions = ["sphinx_argparse_neo", "sphinx_argparse_neo.exemplar"] +### 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: + +````md +```{argparse} +:module: myproject.cli +:func: create_parser +:prog: myproject ``` +```` -### Exemplar config values - -| Config | Default | Description | -|--------|---------|-------------| -| `argparse_examples_term_suffix` | `"examples"` | Term suffix for examples detection | -| `argparse_examples_base_term` | `"examples"` | Base term for examples matching | -| `argparse_examples_section_title` | `"Examples"` | Section title for extracted examples | -| `argparse_usage_pattern` | `"usage:"` | Pattern to detect usage blocks | -| `argparse_examples_command_prefix` | `"$ "` | Prefix for example commands | -| `argparse_examples_code_language` | `"console"` | Language for example code blocks | -| `argparse_examples_code_classes` | `("highlight-console",)` | CSS classes for example blocks | -| `argparse_usage_code_language` | `"cli-usage"` | Language for usage code blocks | -| `argparse_reorder_usage_before_examples` | `True` | Move usage before examples | - -### Pygments lexers - -Registered by the exemplar extension, not the base: - -| Lexer | Description | -|-------|-------------| -| `argparse` | General argparse output | -| `argparse-usage` | Usage line formatting | -| `argparse-help` | Help text formatting | -| `cli-usage` | CLI usage block formatting | - -### CLI inline roles - -Registered by the exemplar via `register_roles()`: - -| Role | Description | -|------|-------------| -| `:cli-option:` | CLI options (`--verbose`, `-h`) | -| `:cli-metavar:` | Metavar placeholders (`FILE`, `PATH`) | -| `:cli-command:` | Command names (`sync`, `add`) | -| `:cli-default:` | Default values (`None`, `"default"`) | -| `:cli-choice:` | Choice values (`json`, `yaml`) | +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/master/packages/sphinx-argparse-neo) diff --git a/docs/packages/sphinx-autodoc-docutils.md b/docs/packages/sphinx-autodoc-docutils.md new file mode 100644 index 00000000..59fef730 --- /dev/null +++ b/docs/packages/sphinx-autodoc-docutils.md @@ -0,0 +1,93 @@ +# sphinx-autodoc-docutils + +{bdg-warning-line}`Alpha` {bdg-primary}`extension` + +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. + +```console +$ pip install sphinx-autodoc-docutils +``` + +## Downstream `conf.py` + +```python +extensions = ["sphinx_autodoc_docutils"] +``` + +## Working usage examples + +Use a single-object directive when you want one rendered reference entry: + +````md +```{eval-rst} +.. autodirective:: my_project.docs_ext.MyDirective +``` + +```{eval-rst} +.. autorole:: my_project.docs_roles.cli_option_role +``` +```` + +Use the bulk directives to render a full module reference plus an index: + +````md +```{eval-rst} +.. autodirective-index:: my_project.docs_ext +``` + +```{eval-rst} +.. autodirectives:: my_project.docs_ext +``` + +```{eval-rst} +.. autorole-index:: my_project.docs_roles +``` + +```{eval-rst} +.. autoroles:: my_project.docs_roles +``` +```` + +## Live demos + +This page intentionally uses directive and role autodoc to document the +documentation helpers themselves. If that feels a little recursive, that is the +point: roles and directives should be documentable the same way fixtures are. + +### Index demo directives + +```{eval-rst} +.. autodirective-index:: docutils_demo +``` + +### Document one demo directive + +```{eval-rst} +.. autodirective:: docutils_demo.DemoBadgeDirective + :no-index: +``` + +### Index demo roles + +```{eval-rst} +.. autorole-index:: docutils_demo +``` + +### Document one demo role + +```{eval-rst} +.. autorole:: docutils_demo.demo_badge_role + :no-index: +``` + +The extension itself registers directives, not docutils roles or Sphinx config +values. The generated package reference below lists its registered surface from +the live `setup()` calls. + +```{package-reference} sphinx-autodoc-docutils +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-autodoc-docutils) diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index 0777dcda..3b62a675 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -2,145 +2,72 @@ {bdg-warning-line}`Alpha` {bdg-primary}`extension` -Sphinx extension that documents pytest fixtures as first-class domain objects -with scope badges, dependency tracking, and auto-generated usage snippets. +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, and the badge/index UI used throughout the page below. ```console $ pip install sphinx-autodoc-pytest-fixtures ``` -## Usage - -Add to your Sphinx extensions: +## Downstream `conf.py` ```python extensions = ["sphinx_autodoc_pytest_fixtures"] -``` - -## Directives - -| Directive | Description | -|-----------|-------------| -| `.. py:fixture:: name` | Document a single fixture with full metadata | -| `.. autofixture:: module.name` | Autodoc-style single fixture (use `eval-rst` in MyST) | -| `.. autofixtures:: module` | Discover and document all fixtures in a module | -| `.. autofixture-index:: module` | Summary table with badge columns | - -## Role - -`:fixture:\`name\`` — cross-reference to a documented fixture. - -## Configuration - -| Config | Default | Description | -|--------|---------|-------------| -| `pytest_fixture_hidden_dependencies` | `PYTEST_HIDDEN` (frozenset) | Fixture names to hide from "Depends on" | -| `pytest_fixture_builtin_links` | `PYTEST_BUILTIN_LINKS` (dict) | Fallback URLs for pytest builtins | -| `pytest_external_fixture_links` | `{}` | Custom external fixture links | -| `pytest_fixture_lint_level` | `"warning"` | Validation severity: `"none"`, `"warning"`, or `"error"` | - -## py:fixture options - -| Option | Type | Description | -|--------|------|-------------| -| `:scope:` | string | `function`, `module`, `class`, or `session` | -| `:autouse:` | flag | Mark as autouse | -| `:depends:` | string | Comma-separated dependency fixtures | -| `:kind:` | string | `resource`, `factory`, or `override_hook` | -| `:return-type:` | string | Return type annotation | -| `:usage:` | string | `auto` or `none` | -| `:params:` | string | Parametrized values | -| `:teardown:` | flag | Mark as yield fixture | -| `:async:` | flag | Mark as async | -| `:deprecated:` | string | Version string | -| `:replacement:` | string | Replacement fixture name | - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-autodoc-pytest-fixtures) - ---- -## Badge demo - -Visual reference for all badge permutations. Use this page to verify badge -rendering across themes, zoom levels, and light/dark modes. - -```{py:module} spf_demo_fixtures -``` - -### Fixture index - -```{autofixture-index} spf_demo_fixtures +pytest_fixture_lint_level = "warning" +pytest_external_fixture_links = { + "db": "https://docs.example.com/testing#db", +} ``` ---- - -### Plain (FIXTURE badge only) - -Function scope, resource kind, not autouse. Shows only the green FIXTURE badge. +## Registered configuration values ```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_plain +.. autoconfigvalue-index:: sphinx_autodoc_pytest_fixtures +.. autoconfigvalues:: sphinx_autodoc_pytest_fixtures ``` ---- - -### Scope badges - -#### Session scope +## Registered directives and roles ```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_session +.. autodirective-index:: sphinx_autodoc_pytest_fixtures +.. autorole-index:: sphinx_autodoc_pytest_fixtures ``` -#### Module scope +## Live demos -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_module +```{py:module} spf_demo_fixtures ``` -#### Class scope +### Fixture index -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_class +```{autofixture-index} spf_demo_fixtures ``` ---- - -### Kind badges - -#### Factory kind - -Return type `type[str]` is auto-detected as factory — no explicit `:kind:` needed. +### Bulk autodoc ```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_factory +.. autofixtures:: spf_demo_fixtures ``` -#### Override hook - -Requires explicit `:kind: override_hook` since it cannot be inferred from type. +### Single autodoc entries ```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_override_hook - :kind: override_hook +.. autofixture:: spf_demo_fixtures.demo_plain + :no-index: ``` ---- - -### State badges - -#### Autouse - ```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_autouse +.. autofixture:: spf_demo_fixtures.demo_session_factory + :no-index: ``` -#### Deprecated - -The `deprecated` badge is set via the `:deprecated:` RST option on `py:fixture`. +### Manual domain directive ```{eval-rst} .. py:fixture:: demo_deprecated + :no-index: :deprecated: 1.0 :replacement: demo_plain :return-type: str @@ -148,18 +75,7 @@ The `deprecated` badge is set via the `:deprecated:` RST option on `py:fixture`. Return a deprecated value. Use :fixture:`demo_plain` instead. ``` ---- - -### Combinations - -#### Session + Factory - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_session_factory +```{package-reference} sphinx-autodoc-pytest-fixtures ``` -#### Session + Autouse - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_session_autouse -``` +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-autodoc-pytest-fixtures) diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md new file mode 100644 index 00000000..e2e1a791 --- /dev/null +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -0,0 +1,71 @@ +# sphinx-autodoc-sphinx + +{bdg-warning-line}`Alpha` {bdg-primary}`extension` + +Experimental Sphinx extension for documenting config values registered by +extension `setup()` hooks. It takes the repetitive part of `conf.py` +reference-writing, records `app.add_config_value()` calls, and renders them as +live `confval` entries and summary indexes. + +```console +$ pip install sphinx-autodoc-sphinx +``` + +## Downstream `conf.py` + +```python +extensions = ["sphinx_autodoc_sphinx"] +``` + +## Working usage examples + +Render one config value: + +````md +```{eval-rst} +.. autoconfigvalue:: sphinx_fonts.sphinx_font_preload +``` +```` + +Render every config value from an extension module: + +````md +```{eval-rst} +.. autoconfigvalue-index:: sphinx_config_demo +``` +```` + +## Live demos + +This page also uses `sphinx-autodoc-docutils` to document the config-doc +directives themselves, so the page demonstrates both config-value output and +directive documentation. + +### Index a demo extension's config surface + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_config_demo +``` + +### Render a single demo config value + +```{eval-rst} +.. autoconfigvalue:: sphinx_config_single_demo.demo_debug + :no-index: +``` + +### Document the extension's own directive helper + +```{eval-rst} +.. autodirective:: sphinx_autodoc_sphinx._directives.AutoconfigvalueDirective + :no-index: +``` + +The extension itself registers documentation directives rather than new roles +or config values. The generated package reference below lists its registered +surface from the live `setup()` calls. + +```{package-reference} sphinx-autodoc-sphinx +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-autodoc-sphinx) diff --git a/docs/packages/sphinx-fonts.md b/docs/packages/sphinx-fonts.md index e46e7070..cdcf2f35 100644 --- a/docs/packages/sphinx-fonts.md +++ b/docs/packages/sphinx-fonts.md @@ -2,17 +2,16 @@ {bdg-success-line}`Beta` {bdg-primary}`extension` -Self-hosted web fonts via [Fontsource](https://fontsource.org/) CDN. Downloads -font files at build time, caches them locally, and injects structured font data -into the template context for inline `@font-face` CSS generation. +Sphinx extension for self-hosted web fonts via Fontsource. It downloads font +assets during the HTML build, caches them locally, copies them into +`_static/fonts/`, and exposes template context values that themes can render as +inline `@font-face` and preload tags. ```console $ pip install sphinx-fonts ``` -## Usage - -In `conf.py`: +## Downstream `conf.py` ```python extensions = ["sphinx_fonts"] @@ -24,54 +23,69 @@ sphinx_fonts = [ "version": "5.2.8", "weights": [400, 500, 600, 700], "styles": ["normal", "italic"], + "subset": "latin", }, ] -``` -When used with gp-sphinx, font configuration is provided by default — IBM Plex -Sans and IBM Plex Mono are pre-configured with preload hints and fallback -font-metric overrides for zero-CLS loading. +sphinx_font_preload = [ + ("IBM Plex Sans", 400, "normal"), +] -## Configuration +sphinx_font_css_variables = { + "--font-stack": '"IBM Plex Sans", system-ui, sans-serif', +} +``` -| Config value | Type | Default (via gp-sphinx) | Description | -|-------------|------|------------------------|-------------| -| `sphinx_fonts` | `list[dict]` | IBM Plex Sans (400/500/600/700, normal+italic), IBM Plex Mono (400, normal+italic) | Font family definitions | -| `sphinx_font_preload` | `list[tuple]` | Sans 400 normal, Sans 700 normal, Mono 400 normal | Critical variants to preload | -| `sphinx_font_fallbacks` | `list[dict]` | Metric-matched Arial/Courier New with size_adjust | Fallback font faces for CLS reduction | -| `sphinx_font_css_variables` | `dict` | `--font-stack`, `--font-stack--monospace`, `--font-stack--headings` | CSS custom properties for Furo font stacks | +## Live specimen + +This site uses `sphinx-fonts`, so the samples below are rendered with the same +template context that downstream themes receive. + +```{raw} html +
+
+

Sans stack

+

Sphinx DX should feel intentional, readable, and fast.

+
+
+

Monospace stack

+

merge_sphinx_config(project="demo", version="1.0.0")

+
+
+``` -Each font dict in `sphinx_fonts` has the shape: +## Configuration values -```python -{ - "family": "IBM Plex Sans", - "package": "@fontsource/ibm-plex-sans", - "version": "5.2.8", - "weights": [400, 500, 600, 700], - "styles": ["normal", "italic"], -} +```{eval-rst} +.. autoconfigvalue-index:: sphinx_fonts +.. autoconfigvalues:: sphinx_fonts ``` -## How it works +## Directives and Roles -1. **`builder-inited`**: Downloads font files from Fontsource CDN, caches in `~/.cache/sphinx-fonts`, copies to `_static/fonts/` -2. **`html-page-context`**: Injects structured data into the Jinja2 template context +```{eval-rst} +.. autodirective-index:: sphinx_fonts +.. autorole-index:: sphinx_fonts +``` -### Template context variables +## Template context -The extension makes these variables available to theme templates: +The extension injects these values during `html-page-context`: | Variable | Type | Description | -|----------|------|-------------| -| `font_faces` | `list[dict]` | `@font-face` declaration data (family, src, weight, style, unicode-range) | -| `font_preload_hrefs` | `list[str]` | `` href values for critical fonts | -| `font_fallbacks` | `list[dict]` | Fallback `@font-face` declarations with metric overrides | -| `font_css_variables` | `dict[str, str]` | CSS custom properties for font stacks | +| --- | --- | --- | +| `font_faces` | `list[dict[str, str]]` | File metadata for generated `@font-face` declarations | +| `font_preload_hrefs` | `list[str]` | Font filenames to preload | +| `font_fallbacks` | `list[dict[str, str]]` | Metric-adjusted fallback declarations | +| `font_css_variables` | `dict[str, str]` | CSS custom properties for theme font stacks | -Theme templates consume these to generate inline CSS. When using -sphinx-gptheme, this is handled automatically. +## Notes -This site uses IBM Plex Sans and Mono via sphinx-fonts. +- 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. + +```{package-reference} sphinx-fonts +``` [Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-fonts) diff --git a/docs/packages/sphinx-gptheme.md b/docs/packages/sphinx-gptheme.md index f381b123..702ee936 100644 --- a/docs/packages/sphinx-gptheme.md +++ b/docs/packages/sphinx-gptheme.md @@ -2,84 +2,83 @@ {bdg-success-line}`Beta` {bdg-info}`theme` -Furo child theme for [git-pull](https://github.com/git-pull) project -documentation. Inherits Furo's responsive layout and dark mode, adding a -custom sidebar with project links, footer icons, SPA-style page navigation, -and CSS variable-driven IBM Plex typography. +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 ``` -## Usage +## Downstream `conf.py` ```python +extensions = ["sphinx_gptheme"] html_theme = "sphinx-gptheme" -``` - -When used with gp-sphinx, the theme is set automatically by -`merge_sphinx_config()`. - -## Theme options - -Options declared in `theme.conf` (passed via `html_theme_options`): - -| Option | Description | -|--------|-------------| -| `announcement` | Banner text displayed above the header | -| `light_logo` | Logo path for light mode | -| `dark_logo` | Logo path for dark mode | -| `sidebar_hide_name` | Hide project name in sidebar brand | -| `footer_icons` | List of footer icon dicts (`name`, `url`, `html`, `class`) | -| `light_css_variables` | CSS custom property overrides for light mode | -| `dark_css_variables` | CSS custom property overrides for dark mode | -### Example - -```python html_theme_options = { - "light_logo": "img/my-logo.svg", - "dark_logo": "img/my-logo-dark.svg", - "announcement": "Note: This project is in alpha.", - "footer_icons": [ - { - "name": "GitHub", - "url": "https://github.com/my-org/my-project", - "html": "...", - "class": "", - }, - ], + "project_name": "my-project", + "project_description": "Shared docs for my project.", + "light_logo": "img/logo-light.svg", + "dark_logo": "img/logo-dark.svg", + "source_repository": "https://github.com/your-org/my-project/", + "source_branch": "main", + "source_directory": "docs/", } ``` -## Bundled assets +## Live theme notes -### Templates +- This site is rendered with `sphinx-gptheme`. +- 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. -| Template | Description | -|----------|-------------| -| `sidebar/brand.html` | Project logo and name | -| `sidebar/projects.html` | Cross-project navigation links | +## Theme options -### Stylesheets +Options declared in `theme.conf` and accepted through `html_theme_options`: -| File | Description | -|------|-------------| -| `css/custom.css` | Base typography and layout overrides | -| `css/argparse-highlight.css` | Syntax colors for CLI output lexers | +| Option | Description | +| --- | --- | +| `announcement` | Banner content rendered above the header | +| `dark_css_variables` | Dark-mode CSS variable overrides | +| `dark_logo` | Logo path for dark mode | +| `footer_icons` | Footer icon list with `name`, `url`, `html`, and `class` keys | +| `light_css_variables` | Light-mode CSS variable overrides | +| `light_logo` | Logo path for light mode | +| `mask_icon` | Safari pinned-tab icon | +| `project_description` | Project summary used by sidebar/meta templates | +| `project_name` | Short project name | +| `project_title` | Alternate long-form title | +| `project_url` | Canonical project home URL | +| `show_meta_app_icon_tags` | Emit app icon meta tags | +| `show_meta_manifest_tag` | Emit web manifest link tag | +| `show_meta_og_tags` | Emit Open Graph tags | +| `sidebar_hide_name` | Hide the sidebar brand name when a logo is present | +| `source_branch` | Source branch used for edit/view links | +| `source_directory` | Repository path containing docs sources | +| `source_edit_link` | Override the generated edit link | +| `source_repository` | Repository URL used for source links and footer GitHub icon | +| `source_view_link` | Override the generated view-source link | +| `top_of_page_button` | Single top-of-page action, defaults to `edit` | +| `top_of_page_buttons` | Multiple top-of-page actions | -### JavaScript +## Bundled assets -| File | Description | -|------|-------------| -| `js/spa-nav.js` | SPA-style page navigation (deferred loading) | +| File | Purpose | +| --- | --- | +| `theme/sidebar/brand.html` | Sidebar brand block | +| `theme/sidebar/projects.html` | Cross-project navigation | +| `theme/static/css/custom.css` | Base layout and typography overrides | +| `theme/static/css/argparse-highlight.css` | CLI lexer highlighting rules | +| `theme/static/js/spa-nav.js` | Deferred navigation enhancer | -## Inheritance +## Relationship to gp-sphinx -- **Parent theme**: Furo (`inherit = furo` in `theme.conf`) -- **Entry point**: registered via `sphinx.html_themes` as `"sphinx-gptheme"` -- **Sidebars**: scroll-start, brand, search, navigation, projects, scroll-end +`gp-sphinx` sets this theme automatically via `merge_sphinx_config()` and +pre-populates `source_repository`, `source_branch`, `source_directory`, footer +icons, and the IBM Plex font stacks consumed by the theme templates. -This site is built with sphinx-gptheme. +```{package-reference} sphinx-gptheme +``` [Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-gptheme) diff --git a/docs/redirects.txt b/docs/redirects.txt index 64f5ffa0..b9497f7f 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -1,2 +1,8 @@ +extensions/gp-sphinx packages/gp-sphinx extensions/index packages/index +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-fonts packages/sphinx-fonts +extensions/sphinx-gptheme packages/sphinx-gptheme diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 34529d8d..6d1f6b9e 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -78,17 +78,13 @@ def deep_merge(base: dict[str, t.Any], override: dict[str, t.Any]) -> dict[str, When both values for a key are dicts, they are merged recursively. Otherwise the value from *override* wins. - Parameters - ---------- - base : dict - The base dictionary. - override : dict - The dictionary whose values take precedence. + :param base: The base dictionary. + :type base: dict + :param override: The dictionary whose values take precedence. + :type override: dict - Returns - ------- - dict - A new merged dictionary. + :returns: A new merged dictionary. + :rtype: dict Examples -------- @@ -118,21 +114,18 @@ def make_linkcode_resolve( on GitHub. The returned function follows the interface expected by ``sphinx.ext.linkcode``. - Parameters - ---------- - package_module : types.ModuleType - The top-level package module (e.g., ``import libtmux; libtmux``). + :param package_module: The top-level package module + (e.g., ``import libtmux; libtmux``). Used to compute relative file paths. - github_url : str - Base GitHub repository URL (e.g., + :type package_module: types.ModuleType + :param github_url: Base GitHub repository URL (e.g., ``"https://github.com/tmux-python/libtmux"``). - src_dir : str - Directory containing the source package (default ``"src"``). + :type github_url: str + :param src_dir: Directory containing the source package (default ``"src"``). + :type src_dir: str - Returns - ------- - Callable[[str, dict[str, str]], str | None] - A function suitable for ``linkcode_resolve`` in Sphinx config. + :returns: A function suitable for ``linkcode_resolve`` in Sphinx config. + :rtype: Callable[[str, dict[str, str]], str | None] Examples -------- @@ -232,45 +225,42 @@ def merge_sphinx_config( for the ``linkify_issues`` extension. When ``docs_url`` is provided, ``ogp_site_url``, ``ogp_image``, and ``ogp_site_name`` are auto-computed for ``sphinxext.opengraph``. All auto-computed values can be overridden - via ``\**overrides``. - - Parameters - ---------- - project : str - Sphinx project name. - version : str - Project version string. - copyright : str - Copyright string. - 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"]``). - remove_extensions : list[str] | None - Remove specific defaults (e.g., ``["sphinx_design"]``). - theme_options : dict | None - Deep-merged with default theme options. - source_repository : str | None - GitHub repository URL. - source_branch : str - Default branch name. - light_logo : str | None - Path to light-mode logo. - dark_logo : str | None - Path to dark-mode logo. - docs_url : str | None - Documentation site URL (e.g., ``"https://libtmux.git-pull.com"``). + via ``overrides``. + + :param project: Sphinx project name. + :type project: str + :param version: Project version string. + :type version: str + :param copyright: Copyright string. + :type copyright: str + :param extensions: Replace the default extension list entirely. Usually not needed. + :type extensions: list[str] | None + :param extra_extensions: Add extensions to the defaults + (e.g., ``["sphinx_argparse_neo.exemplar"]``). + :type extra_extensions: list[str] | None + :param remove_extensions: Remove specific defaults (e.g., ``["sphinx_design"]``). + :type remove_extensions: list[str] | None + :param theme_options: Deep-merged with default theme options. + :type theme_options: dict | None + :param source_repository: GitHub repository URL. + :type source_repository: str | None + :param source_branch: Default branch name. + :type source_branch: str + :param light_logo: Path to light-mode logo. + :type light_logo: str | None + :param dark_logo: Path to dark-mode logo. + :type dark_logo: str | None + :param docs_url: Documentation site URL (e.g., ``"https://libtmux.git-pull.com"``). Used to auto-compute ``ogp_site_url`` and ``ogp_site_name``. - intersphinx_mapping : dict | None - Intersphinx targets. - \**overrides - Any additional Sphinx config values. - - Returns - ------- - dict[str, Any] - Complete Sphinx configuration namespace including a ``setup`` + :type docs_url: str | None + :param intersphinx_mapping: Intersphinx targets. + :type intersphinx_mapping: dict | None + :param overrides: Any additional Sphinx config values. + :type overrides: dict + + :returns: Complete Sphinx configuration namespace including a ``setup`` function for workaround hooks. + :rtype: dict[str, Any] Examples -------- diff --git a/packages/sphinx-autodoc-docutils/README.md b/packages/sphinx-autodoc-docutils/README.md new file mode 100644 index 00000000..751a61f9 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/README.md @@ -0,0 +1,43 @@ +# sphinx-autodoc-docutils + +Sphinx extension for turning docutils directives and roles into copyable +reference entries inside your docs site. + +## Install + +```console +$ pip install sphinx-autodoc-docutils +``` + +## Usage + +```python +extensions = ["sphinx_autodoc_docutils"] +``` + +Then document directive classes and role callables with `eval-rst`: + +````md +```{eval-rst} +.. autodirective:: my_project.docs_ext.MyDirective +``` + +```{eval-rst} +.. autorole:: my_project.docs_roles.cli_option_role +``` +```` + +For module-wide reference pages: + +```rst +.. autodirective-index:: my_project.docs_ext +.. autodirectives:: my_project.docs_ext + +.. autorole-index:: my_project.docs_roles +.. autoroles:: my_project.docs_roles +``` + +## Documentation + +See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-autodoc-docutils/) +for live demos, directive option rendering, and downstream usage patterns. diff --git a/packages/sphinx-autodoc-docutils/pyproject.toml b/packages/sphinx-autodoc-docutils/pyproject.toml new file mode 100644 index 00000000..04925e0c --- /dev/null +++ b/packages/sphinx-autodoc-docutils/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-autodoc-docutils" +version = "0.0.1a0" +description = "Sphinx extension for documenting docutils directives and roles as first-class reference entries" +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", "docutils", "directives", "roles", "documentation", "autodoc"] +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_docutils"] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py new file mode 100644 index 00000000..6faa5282 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -0,0 +1,32 @@ +"""Sphinx extension for documenting docutils directives and roles.""" + +from __future__ import annotations + +import typing as t + +from sphinx.application import Sphinx + +from sphinx_autodoc_docutils._directives import ( + AutoDirective, + AutoDirectiveIndex, + AutoDirectives, + AutoRole, + AutoRoleIndex, + AutoRoles, +) + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the extension.""" + app.add_directive("autodirective", AutoDirective) + app.add_directive("autodirectives", AutoDirectives) + app.add_directive("autodirective-index", AutoDirectiveIndex) + app.add_directive("autorole", AutoRole) + app.add_directive("autoroles", AutoRoles) + app.add_directive("autorole-index", AutoRoleIndex) + + return { + "version": "0.0.1a0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_constants.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_constants.py new file mode 100644 index 00000000..8032de92 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_constants.py @@ -0,0 +1,4 @@ +# Constants for sphinx-autodoc-docutils +from __future__ import annotations + +EXTENSION_NAME = "sphinx_autodoc_docutils" diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py new file mode 100644 index 00000000..a44a9786 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -0,0 +1,395 @@ +"""Rendering directives for docutils directive and role documentation.""" + +from __future__ import annotations + +import importlib +import inspect + +from docutils import nodes +from docutils.parsers.rst import Directive, directives +from docutils.statemachine import StringList +from sphinx.util.docutils import SphinxDirective + + +def _summary(value: object) -> str: + """Return the first summary line for a Python object. + + Examples + -------- + >>> _summary(Directive) + 'Base class for reStructuredText directives.' + """ + doc = inspect.getdoc(value) or "" + for line in doc.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +def _module_members(module_name: str) -> list[tuple[str, object]]: + """Return public members defined directly in a module. + + Examples + -------- + >>> members = _module_members("sphinx_autodoc_docutils._directives") + >>> any(name == "AutoDirectiveIndex" for name, _value in members) + True + """ + module = importlib.import_module(module_name) + return [ + (name, value) + for name, value in inspect.getmembers(module) + if getattr(value, "__module__", None) == module.__name__ + and not name.startswith("_") + ] + + +def _directive_classes(module_name: str) -> list[tuple[str, type[Directive]]]: + """Return public docutils directive classes in a module. + + Examples + -------- + >>> directives = _directive_classes("sphinx_autodoc_docutils._directives") + >>> any(name == "AutoDirectiveIndex" for name, _value in directives) + True + """ + results: list[tuple[str, type[Directive]]] = [] + for name, value in _module_members(module_name): + if inspect.isclass(value) and issubclass(value, Directive): + results.append((name, value)) + return results + + +def _role_callables(module_name: str) -> list[tuple[str, object]]: + """Return public docutils role callables in a module. + + Examples + -------- + >>> roles = _role_callables("sphinx_argparse_neo.roles") + >>> any(name == "cli_option_role" for name, _value in roles) + True + """ + results: list[tuple[str, object]] = [] + for name, value in _module_members(module_name): + if name.endswith("_role") and callable(value): + results.append((name, value)) + return results + + +def _registered_name(name: str) -> str: + """Return the documented name for a directive class or role function. + + Examples + -------- + >>> _registered_name("AutoDirectiveIndex") + 'autodirective-index' + >>> _registered_name("cli_option_role") + 'cli-option' + """ + explicit = { + "AutoDirective": "autodirective", + "AutoDirectives": "autodirectives", + "AutoDirectiveIndex": "autodirective-index", + "AutoRole": "autorole", + "AutoRoles": "autoroles", + "AutoRoleIndex": "autorole-index", + } + if name in explicit: + return explicit[name] + if name.endswith("_role"): + return name.removesuffix("_role").replace("_", "-") + return name.removesuffix("Directive").lower() + + +def _option_rows(option_spec: object) -> list[str]: + """Return table rows describing a directive or role option spec. + + Examples + -------- + >>> rows = _option_rows({"class": str}) + >>> rows[0] + '| `class` | `str` |' + """ + if not isinstance(option_spec, dict) or not option_spec: + return [] + rows = [] + for name, converter in sorted(option_spec.items()): + converter_name = getattr(converter, "__name__", type(converter).__name__) + rows.append(f"| `{name}` | `{converter_name}` |") + return rows + + +def _render_blocks(directive: SphinxDirective, markup: str) -> list[nodes.Node]: + """Parse generated markup through Sphinx when available. + + 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 _directive_markup( + path: str, + directive_cls: type[Directive], + *, + directive_name: str, + no_index: bool = False, +) -> str: + """Return reStructuredText markup documenting one directive class. + + Examples + -------- + >>> markup = _directive_markup("x.y.MyDirective", Directive, directive_name="my-directive") + >>> ".. rst:directive:: my-directive" in markup + True + """ + lines = [ + f".. rst:directive:: {directive_name}", + " :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: + 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.extend( + [ + f" .. rst:directive:option:: {clean_option_name}", + "", + f" Validator: ``{clean_converter_name}``.", + "", + ] + ) + return "\n".join(lines) + + +def _role_markup( + path: str, role_name: str, role_fn: object, *, no_index: bool = False +) -> str: + """Return reStructuredText markup documenting one role callable. + + Examples + -------- + >>> def demo_role(*args: object, **kwargs: object) -> tuple[list[object], list[object]]: + ... return [], [] + >>> demo_role.options = {"class": str} + >>> markup = _role_markup("demo.demo_role", "demo", demo_role) + >>> ".. rst:role:: demo" in markup + True + """ + lines = [ + f".. rst:role:: {role_name}", + " :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) + + +def _index_markup(heading: str, rows: list[tuple[str, str, str]]) -> str: + """Return a reStructuredText summary table for autodocumented objects. + + Examples + -------- + >>> markup = _index_markup("Demo", [("x", "p.x", "summary")]) + >>> ".. list-table::" in markup + True + """ + if not rows: + return "" + lines = [ + f".. rubric:: {heading}", + "", + ".. list-table::", + " :header-rows: 1", + "", + " * - Name", + " - Python path", + " - Summary", + ] + for name, path, summary in rows: + lines.extend( + [ + f" * - ``{name}``", + f" - ``{path}``", + f" - {summary}", + ] + ) + return "\n".join(lines) + + +class AutoDirective(SphinxDirective): + """Render documentation for a single directive class.""" + + required_arguments = 1 + has_content = False + option_spec = {"no-index": directives.flag} + + 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( + self, + _directive_markup( + path, + directive_cls, + directive_name=_registered_name(attr_name), + no_index="no-index" in self.options, + ), + ) + + +class AutoDirectives(SphinxDirective): + """Render documentation for every directive class in a module.""" + + required_arguments = 1 + has_content = False + option_spec = {"no-index": directives.flag} + + 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, + ) + for name, directive_cls in _directive_classes(module_name) + ) + return _render_blocks(self, markup) + + +class AutoDirectiveIndex(SphinxDirective): + """Generate a summary index for all directives in a module.""" + + required_arguments = 1 + has_content = False + + def run(self) -> list[nodes.Node]: + module_name = self.arguments[0] + rows = [ + (_registered_name(name), f"{module_name}.{name}", _summary(directive_cls)) + for name, directive_cls in _directive_classes(module_name) + ] + markup = _index_markup("Directive Index", rows) + return _render_blocks(self, markup) if markup else [] + + +class AutoRole(SphinxDirective): + """Render documentation for a single role callable.""" + + required_arguments = 1 + has_content = False + option_spec = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + path = self.arguments[0] + 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( + self, + _role_markup( + path, + role_name, + role_fn, + no_index="no-index" in self.options, + ), + ) + + +class AutoRoles(SphinxDirective): + """Render documentation for every role callable in a module.""" + + required_arguments = 1 + has_content = False + option_spec = {"no-index": directives.flag} + + 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, + ) + for name, role_fn in _role_callables(module_name) + ) + return _render_blocks(self, markup) + + +class AutoRoleIndex(SphinxDirective): + """Generate a summary index for all roles in a module.""" + + required_arguments = 1 + has_content = False + + def run(self) -> list[nodes.Node]: + module_name = self.arguments[0] + rows = [ + ( + _registered_name(name), + f"{module_name}.{name}", + _summary(role_fn), + ) + for name, role_fn in _role_callables(module_name) + ] + markup = _index_markup("Role Index", rows) + return _render_blocks(self, markup) if markup else [] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_documenter.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_documenter.py new file mode 100644 index 00000000..d938e9fa --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_documenter.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import inspect +import typing as t + +from docutils.parsers.rst import directives, roles +from sphinx.ext.autodoc import Documenter + + +class DocutilsDocumenter(Documenter): + objtype = "docutils" + directivetype = "class" + priority = 10 + Documenter.priority + + @classmethod + def can_document_member( + cls, + member: object, + membername: str, + isattr: bool, + parent: object, + ) -> bool: + return False + + def import_object(self, raiseerror: bool = False) -> bool: + directive_registry = t.cast( + dict[str, object], getattr(directives, "_directives", {}) + ) + role_registry = t.cast(dict[str, object], getattr(roles, "_roles", {})) + if self.name in directive_registry: + self.object = directive_registry[self.name] + self.docutils_type = "directive" + return True + if self.name in role_registry: + self.object = role_registry[self.name] + self.docutils_type = "role" + return True + if raiseerror: + raise ImportError(f"No docutils directive or role found for {self.name}") + return False + + def get_real_modname(self) -> str: + if hasattr(self.object, "__module__"): + return t.cast(str, self.object.__module__) + return type(self.object).__module__ + + def get_doc(self) -> list[list[str]] | None: + docstring = inspect.getdoc(self.object) + if docstring: + return [docstring.splitlines()] + return [] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/py.typed b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/sphinx-autodoc-sphinx/README.md b/packages/sphinx-autodoc-sphinx/README.md new file mode 100644 index 00000000..11c276d8 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/README.md @@ -0,0 +1,38 @@ +# sphinx-autodoc-sphinx + +Sphinx extension for documenting config values registered by +`app.add_config_value()` as copyable `conf.py` reference entries. + +## Install + +```console +$ pip install sphinx-autodoc-sphinx +``` + +## Usage + +```python +extensions = ["sphinx_autodoc_sphinx"] +``` + +Then document one config value: + +````md +```{eval-rst} +.. autoconfigvalue:: sphinx_fonts.sphinx_font_preload +``` +```` + +Or generate a full reference section for an extension module: + +```rst +.. autoconfigvalue-index:: sphinx_fonts +.. autoconfigvalues:: sphinx_fonts + +.. autosphinxconfig-index:: sphinx_argparse_neo.exemplar +``` + +## Documentation + +See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-autodoc-sphinx/) +for live demos, generated `confval` entries, and downstream usage patterns. diff --git a/packages/sphinx-autodoc-sphinx/pyproject.toml b/packages/sphinx-autodoc-sphinx/pyproject.toml new file mode 100644 index 00000000..23723247 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-autodoc-sphinx" +version = "0.0.1a0" +description = "Sphinx extension for documenting extension config values as first-class conf.py reference entries" +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", "configuration", "conf.py", "documentation", "autodoc"] +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_sphinx"] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py new file mode 100644 index 00000000..0dfcad78 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -0,0 +1,42 @@ +"""Sphinx extension for documenting config values registered by extensions.""" + +from __future__ import annotations + +import typing as t + +from sphinx.application import Sphinx + +from sphinx_autodoc_sphinx._directives import ( + AutoconfigvalueDirective, + AutoconfigvalueIndexDirective, + AutoconfigvaluesDirective, + AutosphinxconfigIndexDirective, +) + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register config-value documentation directives. + + Examples + -------- + >>> class FakeApp: + ... def __init__(self) -> None: + ... self.calls: list[tuple[str, str]] = [] + ... def add_directive(self, name: str, directive: object) -> None: + ... self.calls.append(("add_directive", name)) + >>> fake = FakeApp() + >>> metadata = setup(fake) # type: ignore[arg-type] + >>> ("add_directive", "autoconfigvalue") in fake.calls + True + >>> metadata["parallel_read_safe"] + True + """ + app.add_directive("autoconfigvalue", AutoconfigvalueDirective) + app.add_directive("autoconfigvalues", AutoconfigvaluesDirective) + app.add_directive("autoconfigvalue-index", AutoconfigvalueIndexDirective) + app.add_directive("autosphinxconfig-index", AutosphinxconfigIndexDirective) + return { + "version": "0.0.1a0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_constants.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_constants.py new file mode 100644 index 00000000..c3de82d5 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_constants.py @@ -0,0 +1,4 @@ +# Constants for sphinx-autodoc-sphinx +from __future__ import annotations + +EXTENSION_NAME = "sphinx_autodoc_sphinx" diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py new file mode 100644 index 00000000..bcdca772 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -0,0 +1,432 @@ +"""Rendering directives for Sphinx configuration value documentation. + +Examples +-------- +>>> values = discover_config_values("sphinx_fonts") +>>> {value.name for value in values} == { +... "sphinx_fonts", +... "sphinx_font_fallbacks", +... "sphinx_font_css_variables", +... "sphinx_font_preload", +... } +True + +>>> markup = render_config_index_markup("sphinx_fonts") +>>> ".. list-table::" in markup +True +""" + +from __future__ import annotations + +import importlib +import typing as t +from dataclasses import dataclass + +from docutils import nodes +from docutils.parsers.rst import directives +from docutils.statemachine import StringList +from sphinx.util.docutils import SphinxDirective + + +class InvalidConfigValuePathError(ValueError): + """Raised when a config-value path is missing the ``module.option`` form. + + Examples + -------- + >>> str(InvalidConfigValuePathError("demo")) + "Expected 'module_name.config_value', got 'demo'" + """ + + def __init__(self, path: str) -> None: + super().__init__(f"Expected 'module_name.config_value', got {path!r}") + + +class UnknownConfigValueError(LookupError): + """Raised when a module does not register a requested config value. + + Examples + -------- + >>> str(UnknownConfigValueError("demo_ext", "missing")) + "No config value named 'missing' registered by 'demo_ext'" + """ + + def __init__(self, module_name: str, value_name: str) -> None: + super().__init__( + f"No config value named {value_name!r} registered by {module_name!r}" + ) + + +@dataclass(frozen=True) +class SphinxConfigValue: + """Recorded metadata for a config value registered via ``setup()``. + + Examples + -------- + >>> value = SphinxConfigValue( + ... module_name="demo_ext", + ... name="demo_option", + ... default=True, + ... rebuild="html", + ... types=(bool,), + ... ) + >>> value.qualified_name + 'demo_ext.demo_option' + """ + + module_name: str + name: str + default: object + rebuild: str + types: object = () + description: str = "" + + @property + def qualified_name(self) -> str: + """Return the fully-qualified value path used by the single directive. + + Examples + -------- + >>> value = SphinxConfigValue("demo_ext", "demo_option", None, "") + >>> value.qualified_name + 'demo_ext.demo_option' + """ + return f"{self.module_name}.{self.name}" + + +class RecorderApp: + """Minimal Sphinx-app recorder used to observe ``setup()`` calls. + + Examples + -------- + >>> app = RecorderApp() + >>> app.add_config_value("demo_option", True, "html") + >>> app.calls[0][0] + 'add_config_value' + """ + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] + + def __getattr__(self, name: str) -> t.Callable[..., None]: + """Record arbitrary method calls without implementing Sphinx itself. + + Examples + -------- + >>> app = RecorderApp() + >>> app.setup_extension("demo_ext") + >>> app.calls[0][0] + 'setup_extension' + """ + + def _record(*args: object, **kwargs: object) -> None: + self.calls.append((name, args, kwargs)) + + return _record + + +def _call_setup(module_name: str) -> RecorderApp: + """Run a module's ``setup()`` function against a recorder app. + + Examples + -------- + >>> app = _call_setup("sphinx_fonts") + >>> any(name == "add_config_value" for name, _args, _kwargs in app.calls) + True + """ + module = importlib.import_module(module_name) + app = RecorderApp() + setup = module.setup + setup(app) + return app + + +def _render_default(value: object) -> str: + """Render a compact literal for a ``:default:`` option. + + Examples + -------- + >>> _render_default(True) + '``True``' + >>> _render_default("demo") + "``'demo'``" + """ + return f"``{value!r}``" + + +def _render_types(types: object, default: object) -> str: + """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]]], +) -> list[SphinxConfigValue]: + """Extract config-value metadata from recorded setup calls. + + Examples + -------- + >>> values = _config_values_from_calls( + ... "demo_ext", + ... [("add_config_value", ("demo_option", 1, "env"), {"types": (int,)})], + ... ) + >>> values[0].name + 'demo_option' + """ + values: list[SphinxConfigValue] = [] + seen: set[str] = set() + for call_name, args, kwargs in calls: + if call_name != "add_config_value" or len(args) < 3: + continue + name = str(args[0]) + if name in seen: + continue + seen.add(name) + values.append( + SphinxConfigValue( + module_name=module_name, + name=name, + default=args[1], + rebuild=str(kwargs.get("rebuild", args[2])), + types=kwargs.get("types", args[3] if len(args) > 3 else ()), + description=str( + kwargs.get("description", args[4] if len(args) > 4 else "") + ), + ) + ) + return values + + +def discover_config_values(module_name: str) -> list[SphinxConfigValue]: + """Return config values registered by a Sphinx extension module. + + Examples + -------- + >>> names = {value.name for value in discover_config_values("sphinx_argparse_neo")} + >>> names == { + ... "argparse_group_title_prefix", + ... "argparse_show_defaults", + ... "argparse_show_choices", + ... "argparse_show_types", + ... } + True + """ + app = _call_setup(module_name) + return _config_values_from_calls(module_name, app.calls) + + +def discover_config_value(path: str) -> SphinxConfigValue: + """Return one config value from a fully-qualified path. + + Examples + -------- + >>> value = discover_config_value("sphinx_fonts.sphinx_font_preload") + >>> value.name + 'sphinx_font_preload' + """ + module_name, _, value_name = path.rpartition(".") + if not module_name or not value_name: + raise InvalidConfigValuePathError(path) + for value in discover_config_values(module_name): + if value.name == value_name: + return value + raise UnknownConfigValueError(module_name, value_name) + + +def render_config_value_markup( + value: SphinxConfigValue, *, no_index: bool = False +) -> str: + """Return reStructuredText for one real ``confval`` entry. + + Examples + -------- + >>> value = SphinxConfigValue("demo_ext", "demo_option", True, "html", (bool,)) + >>> markup = render_config_value_markup(value) + >>> ".. confval:: demo_option" in markup + True + >>> ":default: ``True``" in markup + True + """ + lines = [ + f".. confval:: {value.name}", + " :no-index:" if no_index else "", + f" :type: {_render_types(value.types, value.default)}", + f" :default: {_render_default(value.default)}", + "", + ] + 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) + + +def render_config_values_markup(module_name: str, *, no_index: bool = False) -> str: + """Return reStructuredText for every config value from a module. + + Examples + -------- + >>> markup = render_config_values_markup("sphinx_fonts") + >>> ".. confval:: sphinx_fonts" in markup + True + """ + return "\n\n".join( + render_config_value_markup(value, no_index=no_index) + for value in discover_config_values(module_name) + ) + + +def render_config_index_markup( + module_name: str, *, heading: str = "Config Value Index" +) -> str: + """Return a list-table index summarizing a module's config values. + + Examples + -------- + >>> markup = render_config_index_markup("sphinx_fonts") + >>> "sphinx_font_preload" in markup + True + """ + values = discover_config_values(module_name) + if not values: + return "" + + lines = [ + f".. rubric:: {heading}", + "", + ".. list-table::", + " :header-rows: 1", + "", + " * - Name", + " - Type", + " - Default", + " - Rebuild", + ] + for value in values: + lines.extend( + [ + f" * - ``{value.name}``", + f" - {_render_types(value.types, value.default)}", + f" - {_render_default(value.default)}", + f" - ``{value.rebuild or 'none'}``", + ] + ) + return "\n".join(lines) + + +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 [] + + +class AutoconfigvalueDirective(SphinxDirective): + """Render one config value from a fully-qualified ``module.option`` path.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[dict[str, object]] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + value = discover_config_value(self.arguments[0]) + return _render_blocks( + self, + render_config_value_markup(value, no_index="no-index" in self.options), + ) + + +class AutoconfigvaluesDirective(SphinxDirective): + """Render all config values registered by one extension module.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[dict[str, object]] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + markup = render_config_values_markup( + self.arguments[0], no_index="no-index" in self.options + ) + return _render_blocks(self, markup) if markup else [] + + +class AutoconfigvalueIndexDirective(SphinxDirective): + """Render a summary table for a module's config values.""" + + required_arguments = 1 + has_content = False + + def run(self) -> list[nodes.Node]: + markup = render_config_index_markup(self.arguments[0]) + return _render_blocks(self, markup) if markup else [] + + +class AutosphinxconfigIndexDirective(SphinxDirective): + """Render a drop-in index plus detailed ``confval`` blocks. + + This keeps the legacy directive useful on package pages without forcing + authors to remember a second directive just to get the detailed entries. + """ + + required_arguments = 1 + has_content = False + + 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 [] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py new file mode 100644 index 00000000..d2983c48 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import typing as t + +from sphinx.ext.autodoc import Documenter + + +class SphinxConfigDocumenter(Documenter): + objtype = "sphinxconfig" + directivetype = "data" + priority = 10 + Documenter.priority + + @classmethod + def can_document_member( + cls, + member: object, + membername: str, + isattr: bool, + parent: object, + ) -> bool: + return False + + def import_object(self, raiseerror: bool = False) -> bool: + # We need access to the app to get config values. + # In Sphinx Documenter, self.env.app is available. + app = self.env.app + if self.name in app.config.values: + self.object = app.config.values[self.name] + return True + if raiseerror: + msg = f"No sphinx config value found for {self.name}" + raise ImportError(msg) + return False + + def get_real_modname(self) -> str: + return "sphinx.config" + + def get_doc(self) -> list[list[str]] | None: + # Config values usually don't have docstrings attached to the values dict, + # but we can format the default value and type. + default, rebuild, types = t.cast(tuple[object, object, object], self.object) + doc = [f"Default: {default}", f"Rebuild: {rebuild}", f"Types: {types}"] + return [doc] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/py.typed b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index 12a2daf1..ed37dfa4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ sphinx-fonts = { workspace = true } sphinx-gptheme = { workspace = true } sphinx-argparse-neo = { workspace = true } sphinx-autodoc-pytest-fixtures = { workspace = true } +sphinx-autodoc-docutils = { workspace = true } +sphinx-autodoc-sphinx = { workspace = true } gp-sphinx = { workspace = true } [dependency-groups] @@ -27,6 +29,8 @@ dev = [ "gp-sphinx", "sphinx-argparse-neo", "sphinx-autodoc-pytest-fixtures", + "sphinx-autodoc-docutils", + "sphinx-autodoc-sphinx", # Docs "sphinx-autobuild", # Testing @@ -57,7 +61,7 @@ packages = ["src/gp_sphinx_workspace"] [tool.mypy] strict = true python_version = "3.10" -mypy_path = "scripts/ci" +mypy_path = "scripts/ci:docs/_ext" files = [ "packages/", "tests/", @@ -79,6 +83,12 @@ ignore_errors = true [tool.ruff] target-version = "py310" +extend-exclude = [ + "build_extensions.py", + "copy_packages.py", + "rewrite_docutils.py", + "rewrite_sphinx.py", +] [tool.ruff.lint] select = [ @@ -116,6 +126,8 @@ known-first-party = [ "sphinx_gptheme", "sphinx_argparse_neo", "sphinx_autodoc_pytest_fixtures", + "sphinx_autodoc_docutils", + "sphinx_autodoc_sphinx", ] combine-as-imports = true required-imports = [ @@ -127,14 +139,19 @@ convention = "numpy" [tool.ruff.lint.per-file-ignores] "*/__init__.py" = ["F401"] +"docs/_ext/package_reference.py" = ["B009", "D102", "D301", "E501", "PERF401"] "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"] "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/docutils/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] [tool.pytest.ini_options] -addopts = "--tb=short --no-header --showlocals --doctest-modules --ignore=packages/sphinx-argparse-neo --ignore=packages/sphinx-autodoc-pytest-fixtures" +addopts = "--tb=short --no-header --showlocals --doctest-modules --ignore=packages/sphinx-argparse-neo --ignore=packages/sphinx-autodoc-pytest-fixtures --ignore=packages/sphinx-autodoc-docutils" doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE" markers = [ "integration: sphinx integration tests (require full sphinx build)", diff --git a/tests/conftest.py b/tests/conftest.py index 38209f13..08211617 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,9 +7,17 @@ from __future__ import annotations import pathlib +import sys import pytest +for src_path in sorted( + (pathlib.Path(__file__).resolve().parents[1] / "packages").glob("*/src") +): + src_str = str(src_path) + if src_str not in sys.path: + sys.path.insert(0, src_str) + @pytest.fixture(autouse=True) def _doctest_namespace( diff --git a/tests/ext/autodoc_docutils/__init__.py b/tests/ext/autodoc_docutils/__init__.py new file mode 100644 index 00000000..f36df558 --- /dev/null +++ b/tests/ext/autodoc_docutils/__init__.py @@ -0,0 +1 @@ +"""Tests for sphinx_autodoc_docutils.""" diff --git a/tests/ext/autodoc_docutils/test_directives.py b/tests/ext/autodoc_docutils/test_directives.py new file mode 100644 index 00000000..87746500 --- /dev/null +++ b/tests/ext/autodoc_docutils/test_directives.py @@ -0,0 +1,56 @@ +"""Tests for autodoc docutils directives.""" + +from __future__ import annotations + +from sphinx_autodoc_docutils import setup +from sphinx_autodoc_docutils._directives import ( + _directive_classes, + _directive_markup, + _role_callables, + _role_markup, +) + + +def test_extension_setup() -> None: + """The extension setup function is importable.""" + assert callable(setup) + + +def test_directive_classes_discovers_public_directives() -> None: + """The helper discovers directive classes defined in a module.""" + directives = _directive_classes("sphinx_autodoc_docutils._directives") + names = {name for name, _directive in directives} + assert "AutoDirective" in names + assert "AutoDirectiveIndex" in names + + +def test_role_callables_discovers_public_roles() -> None: + """The helper discovers role callables defined in a module.""" + roles = _role_callables("sphinx_argparse_neo.roles") + names = {name for name, _role in roles} + assert "cli_option_role" in names + assert "cli_choice_role" in names + + +def test_directive_markup_contains_path_and_summary() -> None: + """Rendered directive markup includes the import path and summary.""" + directive_cls = dict(_directive_classes("sphinx_autodoc_docutils._directives"))[ + "AutoDirectiveIndex" + ] + markup = _directive_markup( + "sphinx_autodoc_docutils._directives.AutoDirectiveIndex", + directive_cls, + directive_name="autodirective-index", + ) + assert "sphinx_autodoc_docutils._directives.AutoDirectiveIndex" in markup + assert "Generate a summary index for all directives in a module." in markup + + +def test_role_markup_contains_role_name_and_path() -> None: + """Rendered role markup includes the displayed role name and path.""" + role_fn = dict(_role_callables("sphinx_argparse_neo.roles"))["cli_option_role"] + markup = _role_markup( + "sphinx_argparse_neo.roles.cli_option_role", "cli-option", role_fn + ) + assert "cli-option" in markup + assert "sphinx_argparse_neo.roles.cli_option_role" in markup diff --git a/tests/ext/autodoc_docutils/test_sphinx_config.py b/tests/ext/autodoc_docutils/test_sphinx_config.py new file mode 100644 index 00000000..9dea96a3 --- /dev/null +++ b/tests/ext/autodoc_docutils/test_sphinx_config.py @@ -0,0 +1,38 @@ +"""Tests for sphinx_autodoc_sphinx helpers.""" + +from __future__ import annotations + +from sphinx_autodoc_sphinx import setup +from sphinx_autodoc_sphinx._directives import ( + discover_config_values, + render_config_value_markup, +) + + +def test_sphinx_autodoc_sphinx_setup_is_importable() -> None: + """The extension setup function is importable.""" + assert callable(setup) + + +def test_config_values_discovers_registered_options() -> None: + """The helper captures config values from an extension setup hook.""" + values = discover_config_values("sphinx_fonts") + names = {value.name for value in values} + assert names == { + "sphinx_fonts", + "sphinx_font_fallbacks", + "sphinx_font_css_variables", + "sphinx_font_preload", + } + + +def test_config_markup_contains_default_and_rebuild() -> None: + """Rendered config markup shows the default and rebuild target.""" + value = next( + item + for item in discover_config_values("sphinx_argparse_neo") + if item.name == "argparse_show_defaults" + ) + markup = render_config_value_markup(value) + assert ":default: ``True``" in markup + assert "Rebuild: ``html``" in markup diff --git a/tests/ext/autodoc_sphinx/test_directives.py b/tests/ext/autodoc_sphinx/test_directives.py new file mode 100644 index 00000000..2bed0c7d --- /dev/null +++ b/tests/ext/autodoc_sphinx/test_directives.py @@ -0,0 +1,50 @@ +"""Tests for autodoc sphinx config directives.""" + +from __future__ import annotations + +from sphinx_autodoc_sphinx import setup +from sphinx_autodoc_sphinx._directives import ( + discover_config_value, + discover_config_values, + render_config_index_markup, + render_config_value_markup, +) + + +def test_extension_setup() -> None: + """The extension setup function is importable.""" + assert callable(setup) + + +def test_config_index_discovers_registered_values() -> None: + """The helper includes config values registered via setup().""" + values = discover_config_values("sphinx_fonts") + names = {item.name for item in values} + assert "sphinx_fonts" in names + assert "sphinx_font_preload" in names + + +def test_config_blocks_render_confval_entries() -> None: + """Detailed rendering produces confval blocks for downstream docs.""" + value = next( + item + for item in discover_config_values("sphinx_argparse_neo") + if item.name == "argparse_show_defaults" + ) + markup = render_config_value_markup(value) + assert ".. confval:: argparse_show_defaults" in markup + assert ":default: ``True``" in markup + + +def test_discover_config_value_resolves_qualified_paths() -> None: + """Single-value lookup accepts ``module_name.option`` paths.""" + value = discover_config_value("sphinx_fonts.sphinx_font_preload") + assert value.name == "sphinx_font_preload" + assert value.module_name == "sphinx_fonts" + + +def test_config_index_renders_summary_table() -> None: + """The summary index renders a real list-table instead of placeholder text.""" + markup = render_config_index_markup("sphinx_fonts") + assert ".. list-table::" in markup + assert "sphinx_font_css_variables" in markup diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py new file mode 100644 index 00000000..931863fa --- /dev/null +++ b/tests/test_package_reference.py @@ -0,0 +1,85 @@ +"""Tests for the docs package reference helpers.""" + +from __future__ import annotations + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "docs" / "_ext")) + +import package_reference + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def test_workspace_packages_lists_publishable_packages() -> None: + """Workspace package discovery includes every published package.""" + names = {package["name"] for package in package_reference.workspace_packages()} + assert names == { + "gp-sphinx", + "sphinx-argparse-neo", + "sphinx-autodoc-docutils", + "sphinx-autodoc-pytest-fixtures", + "sphinx-autodoc-sphinx", + "sphinx-fonts", + "sphinx-gptheme", + } + + +def test_collect_extension_surface_for_sphinx_fonts() -> None: + """The surface collector captures live config registration.""" + surface = package_reference.collect_extension_surface("sphinx_fonts") + config_names = {item["name"] for item in surface["config_values"]} + assert config_names == { + "sphinx_fonts", + "sphinx_font_fallbacks", + "sphinx_font_css_variables", + "sphinx_font_preload", + } + + +def test_package_reference_markdown_for_argparse_includes_roles() -> None: + """Generated markdown includes the exemplar role registrations.""" + markdown = package_reference.package_reference_markdown("sphinx-argparse-neo") + assert "cli-option" in markdown + assert "argparse_examples_section_title" in markdown + + +def test_package_reference_markdown_for_docutils_includes_directives() -> None: + """Generated markdown includes registered docutils autodoc directives.""" + markdown = package_reference.package_reference_markdown("sphinx-autodoc-docutils") + assert "autodirective" in markdown + assert "autorole-index" in markdown + + +def test_package_reference_markdown_uses_plain_config_heading() -> None: + """Generated markdown avoids headings that become accidental autolinks.""" + markdown = package_reference.package_reference_markdown("sphinx-fonts") + assert "## Copyable config snippet" in markdown + + +def test_docs_package_pages_exist_for_every_workspace_package() -> None: + """Each publishable package has a matching docs page.""" + page_names = { + path.stem + for path in (REPO_ROOT / "docs" / "packages").glob("*.md") + if path.stem != "index" + } + package_names = { + package["name"] for package in package_reference.workspace_packages() + } + assert page_names == package_names + + +def test_redirects_cover_legacy_extensions_paths() -> None: + """Legacy extensions/* redirects exist for the packages index and pages.""" + redirects = (REPO_ROOT / "docs" / "redirects.txt").read_text().splitlines() + redirect_map = dict(line.split(maxsplit=1) for line in redirects if line.strip()) + expected = { + "extensions/index": "packages/index", + **{ + f"extensions/{package['name']}": f"packages/{package['name']}" + for package in package_reference.workspace_packages() + }, + } + assert redirect_map == expected diff --git a/uv.lock b/uv.lock index 17411efe..7365a1e8 100644 --- a/uv.lock +++ b/uv.lock @@ -12,7 +12,9 @@ members = [ "gp-sphinx", "gp-sphinx-workspace", "sphinx-argparse-neo", + "sphinx-autodoc-docutils", "sphinx-autodoc-pytest-fixtures", + "sphinx-autodoc-sphinx", "sphinx-fonts", "sphinx-gptheme", ] @@ -463,7 +465,9 @@ 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-docutils" }, { name = "sphinx-autodoc-pytest-fixtures" }, + { name = "sphinx-autodoc-sphinx" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "types-docutils" }, { name = "types-pygments" }, @@ -487,7 +491,9 @@ dev = [ { name = "ruff" }, { name = "sphinx-argparse-neo", editable = "packages/sphinx-argparse-neo" }, { name = "sphinx-autobuild" }, + { 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" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "types-docutils" }, { name = "types-pygments" }, @@ -1244,6 +1250,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-docutils" +version = "0.0.1a0" +source = { editable = "packages/sphinx-autodoc-docutils" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { 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-pytest-fixtures" version = "0.0.1a0" @@ -1260,6 +1278,18 @@ requires-dist = [ { name = "sphinx" }, ] +[[package]] +name = "sphinx-autodoc-sphinx" +version = "0.0.1a0" +source = { editable = "packages/sphinx-autodoc-sphinx" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { 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-typehints" version = "3.0.1" From 2baeadef7d46ca9da51c182ccb74392f972ba111 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 06:02:41 -0500 Subject: [PATCH 12/57] fix(sphinx-autodoc-sphinx): Correct option_spec type annotation for mypy why: Base class Directive expects dict[str, Callable] | None, not dict[str, object]. what: - Change option_spec annotation from dict[str, object] to dict[str, t.Any] in AutoconfigvalueDirective and AutoconfigvaluesDirective --- .../src/sphinx_autodoc_sphinx/_directives.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 bcdca772..1e0a3d51 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -377,7 +377,7 @@ class AutoconfigvalueDirective(SphinxDirective): required_arguments = 1 has_content = False - option_spec: t.ClassVar[dict[str, object]] = {"no-index": directives.flag} + option_spec: t.ClassVar[dict[str, t.Any]] = {"no-index": directives.flag} def run(self) -> list[nodes.Node]: value = discover_config_value(self.arguments[0]) @@ -392,7 +392,7 @@ class AutoconfigvaluesDirective(SphinxDirective): required_arguments = 1 has_content = False - option_spec: t.ClassVar[dict[str, object]] = {"no-index": directives.flag} + option_spec: t.ClassVar[dict[str, t.Any]] = {"no-index": directives.flag} def run(self) -> list[nodes.Node]: markup = render_config_values_markup( From 42a310587e2b3c4e182b44b6b0307320c51f2da9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 06:54:59 -0500 Subject: [PATCH 13/57] fix(sphinx-autodoc-sphinx[documenter]): Replace _Opt tuple unpacking with attribute access why: Sphinx 8.x changed config values from 3-tuples to _Opt instances. The t.cast + tuple unpacking emits 4 RemovedInSphinx90Warning per call and will crash in Sphinx 9. what: - Use isinstance(opt, tuple) guard for backward compat with old Sphinx - Access .default, .rebuild, .valid_types attributes on _Opt instances - Remove unused import typing (cleaned by ruff) --- .../src/sphinx_autodoc_sphinx/_documenter.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py index d2983c48..ff32425b 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py @@ -1,7 +1,5 @@ from __future__ import annotations -import typing as t - from sphinx.ext.autodoc import Documenter @@ -38,6 +36,14 @@ def get_real_modname(self) -> str: def get_doc(self) -> list[list[str]] | None: # Config values usually don't have docstrings attached to the values dict, # but we can format the default value and type. - default, rebuild, types = t.cast(tuple[object, object, object], self.object) + # Sphinx 8.x uses _Opt (class with .default/.rebuild/.valid_types attrs); + # older Sphinx used a plain tuple. Support both. + opt = self.object + if isinstance(opt, tuple): + default, rebuild, types = opt + else: + default = getattr(opt, "default", None) + rebuild = getattr(opt, "rebuild", "") + types = getattr(opt, "valid_types", ()) doc = [f"Default: {default}", f"Rebuild: {rebuild}", f"Types: {types}"] return [doc] From 67e0805d60757996948f7a22d18099220516cc3e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 06:55:46 -0500 Subject: [PATCH 14/57] refactor(typing[setup]): Use ExtensionMetadata return type for setup() functions why: ExtensionMetadata is a public TypedDict from sphinx.util.typing (Sphinx 7.3+) that catches typos in metadata keys at type-check time. what: - Add TYPE_CHECKING import of ExtensionMetadata in both packages - Change setup() return annotation from dict[str, t.Any] to ExtensionMetadata --- .../src/sphinx_autodoc_docutils/__init__.py | 5 ++++- .../src/sphinx_autodoc_sphinx/__init__.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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 6faa5282..6c84c509 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -15,8 +15,11 @@ AutoRoles, ) +if t.TYPE_CHECKING: + from sphinx.util.typing import ExtensionMetadata -def setup(app: Sphinx) -> dict[str, t.Any]: + +def setup(app: Sphinx) -> ExtensionMetadata: """Register the extension.""" app.add_directive("autodirective", AutoDirective) app.add_directive("autodirectives", AutoDirectives) 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 0dfcad78..e9c722dc 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -13,8 +13,11 @@ AutosphinxconfigIndexDirective, ) +if t.TYPE_CHECKING: + from sphinx.util.typing import ExtensionMetadata -def setup(app: Sphinx) -> dict[str, t.Any]: + +def setup(app: Sphinx) -> ExtensionMetadata: """Register config-value documentation directives. Examples From 0775c7408367d55591133c5343b1ece09a94db1c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 06:56:58 -0500 Subject: [PATCH 15/57] refactor(typing[option_spec]): Annotate option_spec with OptionSpec from sphinx.util.typing why: OptionSpec (dict[str, Callable[[str], Any]]) is Sphinx's public type alias for directive option specs, available since Sphinx 4.0. what: - Add TYPE_CHECKING import of OptionSpec in both packages - Annotate all option_spec class attributes with ClassVar[OptionSpec] - Add import typing as t to docutils _directives.py (was missing) --- .../src/sphinx_autodoc_docutils/_directives.py | 12 ++++++++---- .../src/sphinx_autodoc_sphinx/_directives.py | 7 +++++-- 2 files changed, 13 insertions(+), 6 deletions(-) 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 a44a9786..93430d56 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -4,12 +4,16 @@ import importlib import inspect +import typing as t from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.statemachine import StringList from sphinx.util.docutils import SphinxDirective +if t.TYPE_CHECKING: + from sphinx.util.typing import OptionSpec + def _summary(value: object) -> str: """Return the first summary line for a Python object. @@ -277,7 +281,7 @@ class AutoDirective(SphinxDirective): required_arguments = 1 has_content = False - option_spec = {"no-index": directives.flag} + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} def run(self) -> list[nodes.Node]: path = self.arguments[0] @@ -299,7 +303,7 @@ class AutoDirectives(SphinxDirective): required_arguments = 1 has_content = False - option_spec = {"no-index": directives.flag} + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} def run(self) -> list[nodes.Node]: module_name = self.arguments[0] @@ -336,7 +340,7 @@ class AutoRole(SphinxDirective): required_arguments = 1 has_content = False - option_spec = {"no-index": directives.flag} + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} def run(self) -> list[nodes.Node]: path = self.arguments[0] @@ -359,7 +363,7 @@ class AutoRoles(SphinxDirective): required_arguments = 1 has_content = False - option_spec = {"no-index": directives.flag} + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} def run(self) -> list[nodes.Node]: module_name = self.arguments[0] 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 1e0a3d51..4977de16 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -27,6 +27,9 @@ from docutils.statemachine import StringList from sphinx.util.docutils import SphinxDirective +if t.TYPE_CHECKING: + from sphinx.util.typing import OptionSpec + class InvalidConfigValuePathError(ValueError): """Raised when a config-value path is missing the ``module.option`` form. @@ -377,7 +380,7 @@ class AutoconfigvalueDirective(SphinxDirective): required_arguments = 1 has_content = False - option_spec: t.ClassVar[dict[str, t.Any]] = {"no-index": directives.flag} + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} def run(self) -> list[nodes.Node]: value = discover_config_value(self.arguments[0]) @@ -392,7 +395,7 @@ class AutoconfigvaluesDirective(SphinxDirective): required_arguments = 1 has_content = False - option_spec: t.ClassVar[dict[str, t.Any]] = {"no-index": directives.flag} + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} def run(self) -> list[nodes.Node]: markup = render_config_values_markup( From de1bda50e7f4608143160494a233513ca798baaa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 06:57:36 -0500 Subject: [PATCH 16/57] refactor(typing[_option_rows]): Narrow option_spec parameter to OptionSpec | None why: The parameter receives Directive.option_spec values which are dict[str, Callable[[str], Any]] | None per types-docutils stubs. what: - Change _option_rows(option_spec: object) to OptionSpec | None - isinstance guard on line 116 still narrows correctly for mypy --- .../src/sphinx_autodoc_docutils/_directives.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 93430d56..18bca754 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -106,7 +106,7 @@ def _registered_name(name: str) -> str: return name.removesuffix("Directive").lower() -def _option_rows(option_spec: object) -> list[str]: +def _option_rows(option_spec: OptionSpec | None) -> list[str]: """Return table rows describing a directive or role option spec. Examples From f7520c9843d42e3ca303b3f50f51e68707302f51 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 07:00:10 -0500 Subject: [PATCH 17/57] docs(typing): Add inline comments justifying intentional object annotations why: Both packages ship py.typed with strict mypy. Reviewers and contributors need to know why object is used instead of Any or a narrower type in 14+ locations. what: - Add brief inline comments on every intentional object annotation explaining why it is the correct type (e.g., wraps inspect.getdoc, config defaults are heterogeneous, roles have monkey-patched attrs) - Comments follow pattern: # object: --- .../sphinx_autodoc_docutils/_directives.py | 20 +++++++++++++++---- .../sphinx_autodoc_docutils/_documenter.py | 4 ++-- .../src/sphinx_autodoc_sphinx/_directives.py | 14 ++++++++----- .../src/sphinx_autodoc_sphinx/_documenter.py | 4 ++-- 4 files changed, 29 insertions(+), 13 deletions(-) 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 18bca754..c148ea7f 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -15,7 +15,7 @@ from sphinx.util.typing import OptionSpec -def _summary(value: object) -> str: +def _summary(value: object) -> str: # object: wraps inspect.getdoc() """Return the first summary line for a Python object. Examples @@ -31,7 +31,11 @@ def _summary(value: object) -> str: return "" -def _module_members(module_name: str) -> list[tuple[str, object]]: +def _module_members( + module_name: str, +) -> list[ + tuple[str, object] +]: # object: inspect.getmembers() returns heterogeneous values """Return public members defined directly in a module. Examples @@ -65,7 +69,11 @@ def _directive_classes(module_name: str) -> list[tuple[str, type[Directive]]]: return results -def _role_callables(module_name: str) -> list[tuple[str, object]]: +def _role_callables( + module_name: str, +) -> list[ + tuple[str, object] +]: # object: roles have monkey-patched attrs; no Protocol fits """Return public docutils role callables in a module. Examples @@ -209,7 +217,11 @@ def _directive_markup( def _role_markup( - path: str, role_name: str, role_fn: object, *, no_index: bool = False + path: str, + role_name: str, + role_fn: object, # object: accesses .options/.content via getattr; Protocol impractical + *, + no_index: bool = False, ) -> str: """Return reStructuredText markup documenting one role callable. diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_documenter.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_documenter.py index d938e9fa..43ebfb41 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_documenter.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_documenter.py @@ -15,10 +15,10 @@ class DocutilsDocumenter(Documenter): @classmethod def can_document_member( cls, - member: object, + member: object, # object: stricter than Sphinx's Any; unused param membername: str, isattr: bool, - parent: object, + parent: object, # object: stricter than Sphinx's Any; unused param ) -> bool: return False 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 4977de16..85d93000 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -78,9 +78,9 @@ class SphinxConfigValue: module_name: str name: str - default: object + default: object # object: config defaults are genuinely heterogeneous rebuild: str - types: object = () + types: object = () # object: Sphinx allows ENUM, not just type description: str = "" @property @@ -121,7 +121,9 @@ def __getattr__(self, name: str) -> t.Callable[..., None]: 'setup_extension' """ - def _record(*args: object, **kwargs: object) -> None: + def _record( + *args: object, **kwargs: object + ) -> None: # object: universal __getattr__ stub self.calls.append((name, args, kwargs)) return _record @@ -143,7 +145,7 @@ def _call_setup(module_name: str) -> RecorderApp: return app -def _render_default(value: object) -> str: +def _render_default(value: object) -> str: # object: only calls repr() """Render a compact literal for a ``:default:`` option. Examples @@ -156,7 +158,9 @@ def _render_default(value: object) -> str: return f"``{value!r}``" -def _render_types(types: object, default: object) -> str: +def _render_types( + types: object, default: object +) -> str: # object: uses isinstance guards """Render a readable type expression for ``:type:``. Examples diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py index ff32425b..51fb0a63 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py @@ -11,10 +11,10 @@ class SphinxConfigDocumenter(Documenter): @classmethod def can_document_member( cls, - member: object, + member: object, # object: stricter than Sphinx's Any; unused param membername: str, isattr: bool, - parent: object, + parent: object, # object: stricter than Sphinx's Any; unused param ) -> bool: return False From 2c760223b4aac3feee236bbd8a1ed62c6f34a2be Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 07:46:49 -0500 Subject: [PATCH 18/57] fix(sphinx-autodoc-sphinx): Handle kwargs-style add_config_value() in config extractor why: _config_values_from_calls() required 3+ positional args, silently dropping extensions that use keyword args (e.g., sphinx-autodoc-pytest- fixtures passes default=, rebuild=, types= as kwargs). what: - Lower guard from len(args) < 3 to len(args) < 1 - Read default/rebuild/types/description from kwargs with positional fallback - Add kwargs-style doctest to verify extraction --- .../src/sphinx_autodoc_sphinx/_directives.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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 85d93000..acb94e24 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -189,19 +189,35 @@ def _config_values_from_calls( ) -> list[SphinxConfigValue]: """Extract config-value metadata from recorded setup calls. + Handles both positional and keyword-style ``add_config_value()`` calls. + Examples -------- + Positional args: + >>> values = _config_values_from_calls( ... "demo_ext", ... [("add_config_value", ("demo_option", 1, "env"), {"types": (int,)})], ... ) >>> values[0].name 'demo_option' + + Keyword args (name positional, rest as kwargs): + + >>> kw_args = {"default": True, "rebuild": "html"} + >>> kw_call = ("add_config_value", ("kw_opt",), kw_args) + >>> values = _config_values_from_calls("demo_ext", [kw_call]) + >>> values[0].name + 'kw_opt' + >>> values[0].default + True + >>> values[0].rebuild + 'html' """ values: list[SphinxConfigValue] = [] seen: set[str] = set() for call_name, args, kwargs in calls: - if call_name != "add_config_value" or len(args) < 3: + if call_name != "add_config_value" or len(args) < 1: continue name = str(args[0]) if name in seen: @@ -211,8 +227,8 @@ def _config_values_from_calls( SphinxConfigValue( module_name=module_name, name=name, - default=args[1], - rebuild=str(kwargs.get("rebuild", args[2])), + default=kwargs.get("default", args[1] if len(args) > 1 else None), + rebuild=str(kwargs.get("rebuild", args[2] if len(args) > 2 else "")), types=kwargs.get("types", args[3] if len(args) > 3 else ()), description=str( kwargs.get("description", args[4] if len(args) > 4 else "") From 266479f8db3eccacd66f26691708717b5abc2de8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 07:47:31 -0500 Subject: [PATCH 19/57] fix(package-reference): Handle kwargs-style add_config_value() in surface extractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Same bug as sphinx-autodoc-sphinx — len(args) < 2 guard dropped kwargs-style calls where default/rebuild are keyword arguments. what: - Lower guard from len(args) < 2 to len(args) < 1 - Read default from kwargs with positional fallback --- docs/_ext/package_reference.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index e250f857..a4fbe45e 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -278,10 +278,10 @@ def _record_local(name: str, role: object) -> None: for name, args, kwargs in app.calls: if name == "add_config_value": - if len(args) < 2: + if len(args) < 1: continue option = str(args[0]) - default = args[1] + default = kwargs.get("default", args[1] if len(args) > 1 else None) rebuild = str(kwargs.get("rebuild", args[2] if len(args) > 2 else "")) types = kwargs.get("types") config_values.append( From c2071b178c7c7d8603e50f6f884fec2b2dd67b24 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 07:48:25 -0500 Subject: [PATCH 20/57] docs(quickstart): Add "Your first build" tutorial section why: The quickstart stopped at the conf.py snippet with no build command, directory creation, or verification step. A new user could not go from zero to rendered docs. what: - Add mkdir, index.md creation, sphinx-build command, and browser step - Reference the Usage section for conf.py pattern --- docs/quickstart.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/quickstart.md b/docs/quickstart.md index d625cb38..8c302049 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -109,6 +109,32 @@ conf = merge_sphinx_config( ) ``` +## Your first build + +Create a docs directory with a static assets folder: + +```console +$ mkdir -p docs/_static +``` + +Create a minimal `docs/index.md`: + +```markdown +# My Project + +Welcome to my project documentation. +``` + +Create `docs/conf.py` using the pattern from {ref}`Usage ` above. + +Build the HTML output: + +```console +$ uv run sphinx-build -b html docs docs/_build/html +``` + +Open `docs/_build/html/index.html` in your browser to see the result. + [pip]: https://pip.pypa.io/en/stable/ [pipx]: https://pypa.github.io/pipx/docs/ [uv]: https://docs.astral.sh/uv/ From b5960872f025f6a1cd148bf9bdb08abe40eebb1a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 07:49:33 -0500 Subject: [PATCH 21/57] docs(contributing): Update uv sync command to workspace form why: The install command used the old single-package form that doesn't resolve workspace members. what: - Change uv sync --all-extras --dev to uv sync --all-packages --all-extras --group dev --- docs/project/contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/project/contributing.md b/docs/project/contributing.md index a3473881..6b5e3e60 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -15,7 +15,7 @@ $ cd gp-sphinx Install packages: ```console -$ uv sync --all-extras --dev +$ uv sync --all-packages --all-extras --group dev ``` ## Tests From 2598652da3d0457e24f3f690781802893e4b7791 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 07:50:16 -0500 Subject: [PATCH 22/57] docs(sphinx-autodoc-pytest-fixtures): Document autofixtures :order: and :exclude: options why: The only filtering and sorting mechanisms for bulk fixture documentation were invisible to users. what: - Add autofixtures option table (:order:, :exclude:) - Add autofixture-index option table (:exclude:) --- docs/packages/sphinx-autodoc-pytest-fixtures.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index 3b62a675..e8ad4bdc 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -51,6 +51,19 @@ pytest_external_fixture_links = { .. autofixtures:: spf_demo_fixtures ``` +#### autofixtures options + +| Option | Default | Description | +|--------|---------|-------------| +| `:order:` | `"source"` | `"source"` preserves module order; `"alpha"` sorts alphabetically | +| `:exclude:` | (empty) | Comma-separated fixture names to skip | + +#### autofixture-index options + +| Option | Default | Description | +|--------|---------|-------------| +| `:exclude:` | (empty) | Comma-separated fixture names to exclude from index | + ### Single autodoc entries ```{eval-rst} From 74b06bc13ce37ad49564b449fcef17b9e4d2ee3c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 07:50:58 -0500 Subject: [PATCH 23/57] docs(api): Add make_linkcode_resolve wiring example why: The autofunction block showed the signature but not how to wire the resolver into merge_sphinx_config() via **overrides. what: - Add conf.py example showing linkcode_resolve= usage - Note that sphinx.ext.linkcode is auto-appended to extensions --- docs/api.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/api.md b/docs/api.md index 59315974..b70d8e43 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,6 +16,29 @@ For shared defaults and configuration options, see {doc}`configuration`. .. autofunction:: gp_sphinx.config.make_linkcode_resolve ``` +### Wiring into conf.py + +Pass the resolver to `merge_sphinx_config()` via `**overrides`. +`sphinx.ext.linkcode` is auto-appended to extensions when `linkcode_resolve` +is provided: + +```python +import my_project +from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config + +conf = merge_sphinx_config( + project="my-project", + version=my_project.__version__, + copyright="2026, My Name", + source_repository="https://github.com/my-org/my-project/", + linkcode_resolve=make_linkcode_resolve( + my_project, + "https://github.com/my-org/my-project", + ), +) +globals().update(conf) +``` + ## deep_merge ```{eval-rst} From a764ceebad033efb72e2e9499de083ff084c3356 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 07:51:43 -0500 Subject: [PATCH 24/57] docs(sphinx-fonts): Remove empty Directives and Roles section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: sphinx-fonts registers no directives or roles — only config values and template context hooks. The empty heading was confusing. what: - Remove the "Directives and Roles" heading and its empty autodirective-index/autorole-index blocks --- docs/packages/sphinx-fonts.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/packages/sphinx-fonts.md b/docs/packages/sphinx-fonts.md index cdcf2f35..d03127bc 100644 --- a/docs/packages/sphinx-fonts.md +++ b/docs/packages/sphinx-fonts.md @@ -61,13 +61,6 @@ template context that downstream themes receive. .. autoconfigvalues:: sphinx_fonts ``` -## Directives and Roles - -```{eval-rst} -.. autodirective-index:: sphinx_fonts -.. autorole-index:: sphinx_fonts -``` - ## Template context The extension injects these values during `html-page-context`: From 04aba719391cef0faa44cee7493bf3c8bd6abf08 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 07:54:23 -0500 Subject: [PATCH 25/57] docs(autodoc-packages): Add live demos for bulk directive variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Pages demoed index + single-object directives but skipped bulk variants (autodirectives, autoroles, autoconfigvalues) — the forms most users reach for first. what: - Add autodirectives and autoroles bulk demos to sphinx-autodoc-docutils - Add autoconfigvalues bulk demo to sphinx-autodoc-sphinx - Pass :no-index: through to rst:directive:option:: to avoid duplicate description warnings in bulk rendering --- docs/packages/sphinx-autodoc-docutils.md | 18 ++++++++++++++++++ docs/packages/sphinx-autodoc-sphinx.md | 8 ++++++++ .../src/sphinx_autodoc_docutils/_directives.py | 1 + 3 files changed, 27 insertions(+) diff --git a/docs/packages/sphinx-autodoc-docutils.md b/docs/packages/sphinx-autodoc-docutils.md index 59fef730..ee937778 100644 --- a/docs/packages/sphinx-autodoc-docutils.md +++ b/docs/packages/sphinx-autodoc-docutils.md @@ -83,6 +83,24 @@ point: roles and directives should be documentable the same way fixtures are. :no-index: ``` +### Bulk directives demo + +Renders all directive classes in a module at once: + +```{eval-rst} +.. autodirectives:: docutils_demo + :no-index: +``` + +### Bulk roles demo + +Renders all role callables in a module at once: + +```{eval-rst} +.. autoroles:: docutils_demo + :no-index: +``` + The extension itself registers directives, not docutils roles or Sphinx config values. The generated package reference below lists its registered surface from the live `setup()` calls. diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md index e2e1a791..342cd28f 100644 --- a/docs/packages/sphinx-autodoc-sphinx.md +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -54,6 +54,14 @@ directive documentation. :no-index: ``` +### Bulk config values demo + +Renders all config values from a module at once: + +```{eval-rst} +.. autoconfigvalues:: sphinx_config_demo +``` + ### Document the extension's own directive helper ```{eval-rst} 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 c148ea7f..6a85f7d8 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -208,6 +208,7 @@ def _directive_markup( lines.extend( [ f" .. rst:directive:option:: {clean_option_name}", + " :no-index:" if no_index else "", "", f" Validator: ``{clean_converter_name}``.", "", From b209492457f7ee4dd192278ecb6295d1d4c9cf93 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 08:28:36 -0500 Subject: [PATCH 26/57] refactor(config): Add description= to all add_config_value() calls why: Sphinx 8.2.3+ accepts description= as structured metadata for config values. Enriches self-documentation and future autodoc rendering. what: - Add description= to all 21 add_config_value() calls across sphinx-fonts (4), argparse-neo base (4), argparse-neo exemplar (9), and sphinx-autodoc-pytest-fixtures (4) - Fix sphinx-fonts test mocks to accept **kwargs --- .../src/sphinx_argparse_neo/__init__.py | 28 +++++++-- .../src/sphinx_argparse_neo/exemplar.py | 61 ++++++++++++++++--- .../__init__.py | 4 ++ .../sphinx-fonts/src/sphinx_fonts/__init__.py | 28 +++++++-- tests/ext/test_sphinx_fonts.py | 12 ++-- 5 files changed, 110 insertions(+), 23 deletions(-) diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/__init__.py b/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/__init__.py index 75ad1a39..89deb79a 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/__init__.py +++ b/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/__init__.py @@ -68,10 +68,30 @@ def setup(app: Sphinx) -> SetupDict: Extension metadata. """ # Configuration options - app.add_config_value("argparse_group_title_prefix", "", "html") - app.add_config_value("argparse_show_defaults", True, "html") - app.add_config_value("argparse_show_choices", True, "html") - app.add_config_value("argparse_show_types", True, "html") + app.add_config_value( + "argparse_group_title_prefix", + "", + "html", + description="Prefix for argument group titles", + ) + app.add_config_value( + "argparse_show_defaults", + True, + "html", + description="Show default values in argument docs", + ) + app.add_config_value( + "argparse_show_choices", + True, + "html", + description="Show choice constraints", + ) + app.add_config_value( + "argparse_show_types", + True, + "html", + description="Show type information", + ) # Register custom nodes app.add_node( diff --git a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/exemplar.py b/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/exemplar.py index bcb945b7..40f5bbf5 100644 --- a/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/exemplar.py +++ b/packages/sphinx-argparse-neo/src/sphinx_argparse_neo/exemplar.py @@ -1281,17 +1281,60 @@ def setup(app: Sphinx) -> SetupDict: app.setup_extension("sphinx_argparse_neo") # Register configuration options - app.add_config_value("argparse_examples_term_suffix", "examples", "html") - app.add_config_value("argparse_examples_base_term", "examples", "html") - app.add_config_value("argparse_examples_section_title", "Examples", "html") - app.add_config_value("argparse_usage_pattern", "usage:", "html") - app.add_config_value("argparse_examples_command_prefix", "$ ", "html") - app.add_config_value("argparse_examples_code_language", "console", "html") app.add_config_value( - "argparse_examples_code_classes", ["highlight-console"], "html" + "argparse_examples_term_suffix", + "examples", + "html", + description="Term suffix for detecting example definition lists", + ) + app.add_config_value( + "argparse_examples_base_term", + "examples", + "html", + description="Base term for matching example sections", + ) + app.add_config_value( + "argparse_examples_section_title", + "Examples", + "html", + description="Section title for extracted examples", + ) + app.add_config_value( + "argparse_usage_pattern", + "usage:", + "html", + description="Pattern to detect usage blocks in epilog text", + ) + app.add_config_value( + "argparse_examples_command_prefix", + "$ ", + "html", + description="Prefix for example commands in code blocks", + ) + app.add_config_value( + "argparse_examples_code_language", + "console", + "html", + description="Language for example code blocks", + ) + app.add_config_value( + "argparse_examples_code_classes", + ["highlight-console"], + "html", + description="CSS classes for example code blocks", + ) + app.add_config_value( + "argparse_usage_code_language", + "cli-usage", + "html", + description="Language for usage code blocks", + ) + app.add_config_value( + "argparse_reorder_usage_before_examples", + True, + "html", + description="Move usage sections before examples sections", ) - app.add_config_value("argparse_usage_code_language", "cli-usage", "html") - app.add_config_value("argparse_reorder_usage_before_examples", True, "html") # Override the argparse directive with our enhanced version app.add_directive("argparse", CleanArgParseDirective, override=True) 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 925b3593..146a4984 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 @@ -130,24 +130,28 @@ def _add_static_path(app: Sphinx) -> None: default=PYTEST_HIDDEN, rebuild="env", types=[frozenset], + description="Fixture names to hide from dependency tracking", ) app.add_config_value( _CONFIG_BUILTIN_LINKS, default=PYTEST_BUILTIN_LINKS, rebuild="env", types=[dict], + description="Fallback URLs for pytest built-in fixtures", ) app.add_config_value( _CONFIG_EXTERNAL_LINKS, default={}, rebuild="env", types=[dict], + description="Custom external fixture link URLs", ) app.add_config_value( _CONFIG_LINT_LEVEL, default="warning", rebuild="env", types=[str], + description="Validation severity: 'none', 'warning', or 'error'", ) # Register std:fixture so :external+pytest:std:fixture: intersphinx diff --git a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py index 16bfaa12..01e5d96a 100644 --- a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py +++ b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py @@ -237,10 +237,30 @@ def setup(app: Sphinx) -> SetupDict: SetupDict Extension metadata. """ - app.add_config_value("sphinx_fonts", [], "html") - app.add_config_value("sphinx_font_fallbacks", [], "html") - app.add_config_value("sphinx_font_css_variables", {}, "html") - app.add_config_value("sphinx_font_preload", [], "html") + app.add_config_value( + "sphinx_fonts", + [], + "html", + description="Font family definitions (list of dicts with family, package, version, weights, styles)", + ) + app.add_config_value( + "sphinx_font_fallbacks", + [], + "html", + description="Fallback @font-face declarations with metric overrides for CLS reduction", + ) + app.add_config_value( + "sphinx_font_css_variables", + {}, + "html", + description="CSS custom properties for Furo font stacks (e.g. --font-stack)", + ) + app.add_config_value( + "sphinx_font_preload", + [], + "html", + description="Critical font variants to preload as (family, weight, style) tuples", + ) app.connect("builder-inited", _on_builder_inited) app.connect("html-page-context", _on_html_page_context) return { diff --git a/tests/ext/test_sphinx_fonts.py b/tests/ext/test_sphinx_fonts.py index 35a47068..fdcb51df 100644 --- a/tests/ext/test_sphinx_fonts.py +++ b/tests/ext/test_sphinx_fonts.py @@ -495,8 +495,8 @@ def test_setup_return_value() -> None: app = t.cast( Sphinx, types.SimpleNamespace( - add_config_value=lambda name, default, rebuild: config_values.append( - (name, default, rebuild) + add_config_value=lambda name, default, rebuild, **kwargs: ( + config_values.append((name, default, rebuild)) ), connect=lambda event, handler: connections.append((event, handler)), ), @@ -519,8 +519,8 @@ def test_setup_config_values() -> None: app = t.cast( Sphinx, types.SimpleNamespace( - add_config_value=lambda name, default, rebuild: config_values.append( - (name, default, rebuild) + add_config_value=lambda name, default, rebuild, **kwargs: ( + config_values.append((name, default, rebuild)) ), connect=lambda event, handler: connections.append((event, handler)), ), @@ -544,8 +544,8 @@ def test_setup_event_connections() -> None: app = t.cast( Sphinx, types.SimpleNamespace( - add_config_value=lambda name, default, rebuild: config_values.append( - (name, default, rebuild) + add_config_value=lambda name, default, rebuild, **kwargs: ( + config_values.append((name, default, rebuild)) ), connect=lambda event, handler: connections.append((event, handler)), ), From 64914ad59db495cc45b0ca7b55b617cfa131f014 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 08:32:12 -0500 Subject: [PATCH 27/57] fix(docs): Change GitHub source links from tree/master to tree/main why: Repository default branch is main, not master; links 404 otherwise. what: - Update 7 docs/packages/*.md source links - Update hardcoded fallback URL in docs/_ext/package_reference.py --- docs/_ext/package_reference.py | 2 +- docs/packages/gp-sphinx.md | 2 +- docs/packages/sphinx-argparse-neo.md | 2 +- docs/packages/sphinx-autodoc-docutils.md | 2 +- docs/packages/sphinx-autodoc-pytest-fixtures.md | 2 +- docs/packages/sphinx-autodoc-sphinx.md | 2 +- docs/packages/sphinx-fonts.md | 2 +- docs/packages/sphinx-gptheme.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index a4fbe45e..f427f35e 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -506,7 +506,7 @@ def package_reference_markdown(package_name: str) -> str: [ "## Package metadata", "", - f"- Source on GitHub: [{package_name}]({package['repository']}/tree/master/packages/{package_name})", + f"- Source on GitHub: [{package_name}]({package['repository']}/tree/main/packages/{package_name})", f"- Maturity: `{package['maturity']}`", "", ] diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx.md index 038c51d2..c34a1bc4 100644 --- a/docs/packages/gp-sphinx.md +++ b/docs/packages/gp-sphinx.md @@ -55,4 +55,4 @@ for the exact coordinator call. ```{package-reference} gp-sphinx ``` -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/gp-sphinx) +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/gp-sphinx) diff --git a/docs/packages/sphinx-argparse-neo.md b/docs/packages/sphinx-argparse-neo.md index f85cc57d..0a27101b 100644 --- a/docs/packages/sphinx-argparse-neo.md +++ b/docs/packages/sphinx-argparse-neo.md @@ -112,4 +112,4 @@ Or reStructuredText: ```{package-reference} sphinx-argparse-neo ``` -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-argparse-neo) +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-argparse-neo) diff --git a/docs/packages/sphinx-autodoc-docutils.md b/docs/packages/sphinx-autodoc-docutils.md index ee937778..eab54f0f 100644 --- a/docs/packages/sphinx-autodoc-docutils.md +++ b/docs/packages/sphinx-autodoc-docutils.md @@ -108,4 +108,4 @@ the live `setup()` calls. ```{package-reference} sphinx-autodoc-docutils ``` -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-autodoc-docutils) +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-docutils) diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index e8ad4bdc..c6ae874d 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -91,4 +91,4 @@ pytest_external_fixture_links = { ```{package-reference} sphinx-autodoc-pytest-fixtures ``` -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-autodoc-pytest-fixtures) +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-pytest-fixtures) diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md index 342cd28f..fa0f245d 100644 --- a/docs/packages/sphinx-autodoc-sphinx.md +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -76,4 +76,4 @@ surface from the live `setup()` calls. ```{package-reference} sphinx-autodoc-sphinx ``` -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-autodoc-sphinx) +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-sphinx) diff --git a/docs/packages/sphinx-fonts.md b/docs/packages/sphinx-fonts.md index d03127bc..05443438 100644 --- a/docs/packages/sphinx-fonts.md +++ b/docs/packages/sphinx-fonts.md @@ -81,4 +81,4 @@ The extension injects these values during `html-page-context`: ```{package-reference} sphinx-fonts ``` -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-fonts) +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-fonts) diff --git a/docs/packages/sphinx-gptheme.md b/docs/packages/sphinx-gptheme.md index 702ee936..eee15b7e 100644 --- a/docs/packages/sphinx-gptheme.md +++ b/docs/packages/sphinx-gptheme.md @@ -81,4 +81,4 @@ icons, and the IBM Plex font stacks consumed by the theme templates. ```{package-reference} sphinx-gptheme ``` -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/master/packages/sphinx-gptheme) +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gptheme) From 3765dceddc3376db83ae24cd1f65eaf092ca7150 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 08:32:55 -0500 Subject: [PATCH 28/57] docs(_ext/package_reference): Add contributor architecture docs to module docstring why: New contributors had no entry point for understanding how the auto-generation pipeline works or how to extend it. what: - Describe the three-layer pipeline (discovery, surface extraction, rendering) - Explain how to add a new package (no code changes needed) - Explain how to extend the surface extractor for new app.add_* calls --- docs/_ext/package_reference.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index f427f35e..a141e6c4 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -1,5 +1,37 @@ """Generate package reference sections from live workspace metadata. +Architecture +------------ +This Sphinx extension auto-generates the "Registered Surface" and "Copyable +config snippet" sections that appear at the bottom of every +``docs/packages/.md`` page. It works in three layers: + +1. **Workspace discovery** (``workspace_packages()``) — walks + ``packages/*/pyproject.toml`` to find every publishable package and reads + 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``. + +3. **Rendering** (``package_reference_markdown()``) — converts the collected + surface into a Markdown fragment (config snippet + tables), which the + ``PackageReferenceDirective`` injects into the page via a raw docutils node. + +Adding a new package +-------------------- +No code changes are required. Once a ``packages//pyproject.toml`` +exists with a ``[project]`` table the package is picked up automatically on +the next docs build. + +Extending the surface extractor +-------------------------------- +To capture a new ``app.add_*`` call, add a handler to the mock +``_MockApp`` class inside ``collect_extension_surface()``. Follow the pattern +of the existing ``add_directive`` / ``add_role`` handlers. + Examples -------- >>> package = workspace_packages()[0] From abe27e5a94fdad33e7b8dd17e511f77536575698 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 08:34:05 -0500 Subject: [PATCH 29/57] fix(docs/gp-sphinx): Fix remaining tree/master link in live example admonition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Missed in the batch master→main fix; the blob URL pointed to the wrong branch and would 404 for users following the link. what: - docs/packages/gp-sphinx.md: blob/master → blob/main for conf.py link --- docs/packages/gp-sphinx.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx.md index c34a1bc4..6c23db12 100644 --- a/docs/packages/gp-sphinx.md +++ b/docs/packages/gp-sphinx.md @@ -48,7 +48,7 @@ See {doc}`/configuration` for the complete parameter reference and every shared :::{admonition} Live example This site is built with `gp-sphinx`, using the same integration pattern shown above. See -[docs/conf.py](https://github.com/git-pull/gp-sphinx/blob/master/docs/conf.py) +[docs/conf.py](https://github.com/git-pull/gp-sphinx/blob/main/docs/conf.py) for the exact coordinator call. ::: From 0e96e0ba5c1159a6c73a78dcd687e9e89911cbb5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 08:39:49 -0500 Subject: [PATCH 30/57] docs(sphinx-autodoc-docutils): Split usage examples into one block per fence why: Multiple eval-rst blocks inside a single quadruple-backtick fence render as a single copyable unit, making individual commands hard to copy independently. what: - Separate each code block into its own ````md ... ```` fence - Affects both single-object examples (autodirective/autorole) and bulk-directive examples (autodirective-index/autodirectives/ autorole-index/autoroles) --- docs/packages/sphinx-autodoc-docutils.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/packages/sphinx-autodoc-docutils.md b/docs/packages/sphinx-autodoc-docutils.md index eab54f0f..22712e7a 100644 --- a/docs/packages/sphinx-autodoc-docutils.md +++ b/docs/packages/sphinx-autodoc-docutils.md @@ -25,7 +25,9 @@ Use a single-object directive when you want one rendered reference entry: ```{eval-rst} .. autodirective:: my_project.docs_ext.MyDirective ``` +```` +````md ```{eval-rst} .. autorole:: my_project.docs_roles.cli_option_role ``` @@ -37,15 +39,21 @@ Use the bulk directives to render a full module reference plus an index: ```{eval-rst} .. autodirective-index:: my_project.docs_ext ``` +```` +````md ```{eval-rst} .. autodirectives:: my_project.docs_ext ``` +```` +````md ```{eval-rst} .. autorole-index:: my_project.docs_roles ``` +```` +````md ```{eval-rst} .. autoroles:: my_project.docs_roles ``` From 968e4c3a4d3d085957dd10efc53353dc043fa8b4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 10:35:12 -0500 Subject: [PATCH 31/57] chore(stubs[pygments]): Add markup.pyi stub for MarkdownLexer and RstLexer why: types-Pygments (types-Pygments) does not ship stubs for pygments.lexers.markup, causing mypy no-untyped-call errors on RstLexer() and MystLexer() and a ClassVar override conflict from LexerMeta.aliases. what: - Add stubs/pygments/__init__.pyi and stubs/pygments/lexers/__init__.pyi (empty) - Add stubs/pygments/lexers/markup.pyi with RstLexer and MarkdownLexer, declaring aliases/filenames as ClassVar[list[str]] to avoid metaclass conflict - Add :stubs to mypy_path in pyproject.toml so mypy finds the local stub --- pyproject.toml | 2 +- stubs/pygments/__init__.pyi | 0 stubs/pygments/lexers/__init__.pyi | 0 stubs/pygments/lexers/markup.pyi | 44 ++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 stubs/pygments/__init__.pyi create mode 100644 stubs/pygments/lexers/__init__.pyi create mode 100644 stubs/pygments/lexers/markup.pyi diff --git a/pyproject.toml b/pyproject.toml index ed37dfa4..bbb8218d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ packages = ["src/gp_sphinx_workspace"] [tool.mypy] strict = true python_version = "3.10" -mypy_path = "scripts/ci:docs/_ext" +mypy_path = "scripts/ci:docs/_ext:stubs" files = [ "packages/", "tests/", diff --git a/stubs/pygments/__init__.pyi b/stubs/pygments/__init__.pyi new file mode 100644 index 00000000..e69de29b diff --git a/stubs/pygments/lexers/__init__.pyi b/stubs/pygments/lexers/__init__.pyi new file mode 100644 index 00000000..e69de29b diff --git a/stubs/pygments/lexers/markup.pyi b/stubs/pygments/lexers/markup.pyi new file mode 100644 index 00000000..8d41a719 --- /dev/null +++ b/stubs/pygments/lexers/markup.pyi @@ -0,0 +1,44 @@ +"""Type stubs for pygments.lexers.markup (RstLexer and MarkdownLexer). + +Only the symbols used by gp_sphinx.myst_lexer are covered here. +""" + +from collections.abc import Iterable, Iterator +from typing import Any, ClassVar + +from pygments.lexer import RegexLexer +from pygments.token import _TokenType + +class RstLexer(RegexLexer): + name: ClassVar[str] + aliases: ClassVar[list[str]] + filenames: ClassVar[list[str]] + mimetypes: ClassVar[list[str]] + handlecodeblocks: bool + + def __init__( + self, + *, + handlecodeblocks: bool = ..., + stripnl: bool = ..., + **options: object, + ) -> None: ... + def get_tokens_unprocessed( + self, + text: str, + stack: Iterable[str] = ..., + ) -> Iterator[tuple[int, _TokenType, str]]: ... + +class MarkdownLexer(RegexLexer): + name: ClassVar[str] + aliases: ClassVar[list[str]] + filenames: ClassVar[list[str]] + mimetypes: ClassVar[list[str]] + tokens: ClassVar[dict[str, list[Any]]] + + def __init__(self, **options: object) -> None: ... + def get_tokens_unprocessed( + self, + text: str, + stack: Iterable[str] = ..., + ) -> Iterator[tuple[int, _TokenType, str]]: ... From 98a053e07c5c45c30ee7aa968690963efbabbf88 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 10:35:21 -0500 Subject: [PATCH 32/57] feat(gp-sphinx[myst_lexer]): Add MystLexer for {eval-rst} fenced block highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: MarkdownLexer's fenced-block language pattern [\w\-]+ rejects {eval-rst} (the {} are not word chars), so blocks in .myst.md source files fall through to plain Token.Text with no syntax spans when shown via literalinclude. what: - Add MystLexer(MarkdownLexer) with a priority rule for ```{eval-rst} blocks - _handle_eval_rst() emits opening/closing fences as String.Backtick and delegates the body to RstLexer(handlecodeblocks=True) for 3-level nesting: MyST fence → RST directive → inner language (e.g. Python) - Add tokenize_myst() convenience wrapper for tests and doctests - Full NumPy docstrings with working doctests throughout --- .../gp-sphinx/src/gp_sphinx/myst_lexer.py | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 packages/gp-sphinx/src/gp_sphinx/myst_lexer.py diff --git a/packages/gp-sphinx/src/gp_sphinx/myst_lexer.py b/packages/gp-sphinx/src/gp_sphinx/myst_lexer.py new file mode 100644 index 00000000..0b4f8f19 --- /dev/null +++ b/packages/gp-sphinx/src/gp_sphinx/myst_lexer.py @@ -0,0 +1,189 @@ +"""Pygments lexer for MyST Markdown source files. + +Provides :class:`MystLexer`, a custom Pygments lexer that adds +``{eval-rst}`` fenced block support on top of :class:`MarkdownLexer`. + +This module is for highlighting MyST **source files** shown as source +text (e.g. via ``literalinclude`` or docs-of-docs pages). It is NOT +needed for normal Sphinx builds, where MyST is parsed to docutils nodes +before Pygments runs. + +Three distinct contexts exist for highlighting MyST content: + +- **Sphinx HTML build output**: Already works. MyST parses ``{eval-rst}`` + to docutils nodes before Pygments sees anything. No lexer needed. +- **MyST source shown as source text**: The real gap. This module + provides the :class:`MystLexer` that fills it. +- **Editors / GitHub UI**: A Pygments lexer cannot help here. That + requires a tree-sitter or TextMate grammar. + +Examples +-------- +>>> from gp_sphinx.myst_lexer import tokenize_myst +>>> tokens = tokenize_myst("Hello world") +>>> any("Hello" in v for _, v in tokens) +True +""" + +from __future__ import annotations + +import typing as t + +from pygments.lexers.markup import MarkdownLexer, RstLexer +from pygments.token import String, Whitespace + +if t.TYPE_CHECKING: + import re + + from pygments.token import _TokenType + + +class MystLexer(MarkdownLexer): + """Markdown lexer with MyST ``{eval-rst}`` fenced block support. + + For highlighting MyST **source files** shown as source text + (``literalinclude``, docs-of-docs). NOT needed for normal Sphinx + builds, where MyST is parsed to docutils nodes before Pygments runs. + + Only ``{eval-rst}`` fenced blocks are handled specially; all other + Markdown syntax is delegated unchanged to :class:`MarkdownLexer`. + + Token *types* compose correctly through all three levels + (MyST -> RST -> Python). Token *offsets* are correct at levels 1-2; + innermost (Python) offsets inherit the upstream ``RstLexer`` + limitation shared with ``MarkdownLexer``'s acknowledged offset + ``FIXME`` inside ``_handle_codeblock``. + + Examples + -------- + >>> lexer = MystLexer() + >>> tokens = [(str(tok), v) for tok, v in lexer.get_tokens("Hello")] + >>> any(v == "Hello" for _, v in tokens) + True + """ + + name = "MyST Markdown" + aliases: t.ClassVar[list[str]] = ["myst", "myst-md"] + filenames: t.ClassVar[list[str]] = ["*.myst.md"] + + def _handle_eval_rst( + self, + match: re.Match[str], + ) -> t.Iterator[tuple[int, _TokenType, str]]: + """Lex a ``{eval-rst}`` fenced block by delegating body to RstLexer. + + Emits the opening fence as ``String.Backtick``, delegates the + body to ``RstLexer(handlecodeblocks=True)`` (enabling 3-level + nesting: MyST fence → RST directive → inner language), then + emits the closing fence as ``String.Backtick``. + + Parameters + ---------- + match : re.Match[str] + Regex match with named groups ``opening``, ``newline``, + ``body``, and ``closing``. + + Yields + ------ + tuple[int, _TokenType, str] + ``(offset, token_type, value)`` triples whose offsets are + relative to the start of the full document, suitable for + ``get_tokens_unprocessed``. + + Notes + ----- + ``RstLexer._handle_sourcecode`` requires at least one line of + code content followed by a trailing blank line to recognise a + ``.. code-block::`` directive. Single-line bodies without a + trailing blank line will not trigger inner language highlighting. + + Token *types* are correct at all three levels. Token *offsets* + for the innermost (e.g. Python) tokens are relative to the + stripped code content rather than the full document — an + upstream limitation in ``do_insertions`` shared with + ``MarkdownLexer._handle_codeblock``. + + Examples + -------- + >>> tokens = tokenize_myst("```{eval-rst}\\nHello RST\\n```\\n") + >>> any("Backtick" in tok for tok, _ in tokens) + True + """ # noqa: D301 - backslashes are in doctest code, not escape sequences + yield match.start("opening"), String.Backtick, match.group("opening") + yield match.start("newline"), Whitespace, match.group("newline") + + rst_body = match.group("body") + body_offset = match.start("body") + rst_lexer = RstLexer(handlecodeblocks=True, stripnl=False) + for index, token, value in rst_lexer.get_tokens_unprocessed(rst_body): + # index is relative to rst_body; add body_offset to produce + # the document-relative position. Innermost (Python) offsets + # inherit an upstream RstLexer limitation — do_insertions() + # yields offsets relative to the stripped code content. + yield body_offset + index, token, value + + yield match.start("closing"), String.Backtick, match.group("closing") + + # tokens must be declared AFTER _handle_eval_rst because the class + # body is executed sequentially and the dict literal references + # _handle_eval_rst by name. + tokens: t.ClassVar[dict[str, list[t.Any]]] = { + "root": [ + # This rule MUST precede the inherited generic fenced-block + # rule from MarkdownLexer: its language pattern [\w\-]+ + # cannot match {eval-rst}, so without this rule the entire + # block falls through to plain Token.Text tokens. + # + # re.MULTILINE is inherited from MarkdownLexer.flags, so ^ + # matches at the start of each line (not just position 0). + ( + # group opening: backtick fence + directive + optional + # info string (e.g. ```{eval-rst} some-arg) + r"(?P^```\{eval-rst\}[^\n]*)" + r"(?P\n)" + # group body: RST content, non-greedy to stop at first + # closing fence + r"(?P(?:.|\n)*?)" + # group closing: bare ``` at start of line + r"(?P^```[ \t]*$\n?)", + _handle_eval_rst, + ), + # All MarkdownLexer root rules follow unchanged, providing + # highlighting for normal fenced code blocks, inline code, + # headings, etc. + *MarkdownLexer.tokens["root"], + ], + "inline": MarkdownLexer.tokens["inline"], + } + + +def tokenize_myst(text: str) -> list[tuple[str, str]]: + """Tokenize MyST source text, returning ``(token_type_str, value)`` pairs. + + Convenience wrapper around :class:`MystLexer` for tests and + doctests. Token type strings are the standard Pygments string form, + e.g. ``"Token.Literal.String.Backtick"``. + + Parameters + ---------- + text : str + MyST Markdown source text to tokenize. + + Returns + ------- + list[tuple[str, str]] + List of ``(str(token_type), value)`` pairs covering all tokens + in the input, in document order. + + Examples + -------- + >>> tokens = tokenize_myst("Hello world") + >>> any("Hello" in v for _, v in tokens) + True + + >>> tokens = tokenize_myst("```{eval-rst}\\nHello RST\\n```\\n") + >>> ("Token.Literal.String.Backtick", "```{eval-rst}") in tokens + True + """ # noqa: D301 - backslashes are in doctest code, not escape sequences + lexer = MystLexer() + return [(str(tok), val) for tok, val in lexer.get_tokens(text)] From ea29e47cb263c582545f8ea4133de48a7c533b7a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 10:35:30 -0500 Subject: [PATCH 33/57] feat(gp-sphinx[config,tests]): Register MystLexer aliases and add test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: MystLexer must be registered with Sphinx so ````myst fences in docs select it via lexer_classes["myst"] in PygmentsBridge.get_lexer(); tests document and lock the token output for all key cases. what: - Import MystLexer in config.py and register "myst"/"myst-md" aliases via app.add_lexer() in the setup() hook - Add tests/ext/test_myst_lexer.py with NamedTuple+parametrize pattern: FenceFixture (opening/info-string/closing as String.Backtick), RegressionFixture (plain text and standard Python fences still work), NestedHighlightFixture (3-level RST→Python nesting, documents trailing- blank-line requirement from RstLexer._handle_sourcecode) - Standalone tests: empty block, multiple blocks, EOF without newline, tokenize_myst helper --- packages/gp-sphinx/src/gp_sphinx/config.py | 4 + tests/ext/test_myst_lexer.py | 231 +++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 tests/ext/test_myst_lexer.py diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 6d1f6b9e..1694256d 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -69,6 +69,8 @@ from sphinx.application import Sphinx +from gp_sphinx.myst_lexer import MystLexer + logger = logging.getLogger(__name__) @@ -466,3 +468,5 @@ def setup(app: Sphinx) -> None: """ app.add_js_file("js/spa-nav.js", loading_method="defer") app.connect("build-finished", remove_tabs_js) + app.add_lexer("myst", MystLexer) + app.add_lexer("myst-md", MystLexer) diff --git a/tests/ext/test_myst_lexer.py b/tests/ext/test_myst_lexer.py new file mode 100644 index 00000000..c932b261 --- /dev/null +++ b/tests/ext/test_myst_lexer.py @@ -0,0 +1,231 @@ +"""Tests for gp_sphinx.myst_lexer.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from gp_sphinx.myst_lexer import MystLexer, tokenize_myst + +# --- Helper --- + + +def get_tokens(text: str) -> list[tuple[str, str]]: + """Return (token_type_str, value) tuples for *text* via MystLexer.""" + lexer = MystLexer() + return [(str(tok), val) for tok, val in lexer.get_tokens(text)] + + +# --------------------------------------------------------------------------- +# Fence markers: {eval-rst} opening/closing emitted as String.Backtick +# --------------------------------------------------------------------------- + +_BACKTICK = "Token.Literal.String.Backtick" + + +class FenceFixture(t.NamedTuple): + test_id: str + input_text: str + expected_contains: list[tuple[str, str]] + + +FENCE_FIXTURES: list[FenceFixture] = [ + FenceFixture( + test_id="fence_opening_is_backtick", + input_text="```{eval-rst}\nHello\n```\n", + expected_contains=[(_BACKTICK, "```{eval-rst}")], + ), + FenceFixture( + test_id="fence_with_info_string", + input_text="```{eval-rst} some-arg\nHello\n```\n", + expected_contains=[(_BACKTICK, "```{eval-rst} some-arg")], + ), + FenceFixture( + test_id="fence_closing_is_backtick", + input_text="```{eval-rst}\nHello\n```\n", + expected_contains=[(_BACKTICK, "```\n")], + ), +] + + +@pytest.mark.parametrize( + list(FenceFixture._fields), + FENCE_FIXTURES, + ids=[f.test_id for f in FENCE_FIXTURES], +) +def test_fence_markers( + test_id: str, + input_text: str, + expected_contains: list[tuple[str, str]], +) -> None: + tokens = get_tokens(input_text) + for tok, val in expected_contains: + assert (tok, val) in tokens, ( + f"Expected ({tok!r}, {val!r}) in tokens for test_id={test_id!r}\n" + f"Got: {tokens}" + ) + + +# --------------------------------------------------------------------------- +# Empty block: two String.Backtick tokens (opening + closing) +# --------------------------------------------------------------------------- + + +def test_empty_eval_rst_block() -> None: + tokens = get_tokens("```{eval-rst}\n```\n") + backtick_tokens = [val for tok, val in tokens if tok == _BACKTICK] + assert len(backtick_tokens) >= 2, ( + f"Expected at least 2 String.Backtick tokens, got: {backtick_tokens}" + ) + + +# --------------------------------------------------------------------------- +# Regression: plain Markdown and standard fenced blocks still work +# --------------------------------------------------------------------------- + + +class RegressionFixture(t.NamedTuple): + test_id: str + input_text: str + expected_value_present: str + + +REGRESSION_FIXTURES: list[RegressionFixture] = [ + RegressionFixture( + test_id="plain_text_no_regression", + input_text="Hello world\n", + expected_value_present="Hello", + ), + RegressionFixture( + test_id="standard_python_fence_regression", + input_text="```python\nimport this\n```\n", + expected_value_present="import", + ), +] + + +@pytest.mark.parametrize( + list(RegressionFixture._fields), + REGRESSION_FIXTURES, + ids=[f.test_id for f in REGRESSION_FIXTURES], +) +def test_no_regression( + test_id: str, + input_text: str, + expected_value_present: str, +) -> None: + tokens = get_tokens(input_text) + values = [val for _, val in tokens] + assert any(expected_value_present in v for v in values), ( + f"Expected {expected_value_present!r} in some token value for " + f"test_id={test_id!r}\nValues: {values}" + ) + + +def test_standard_python_fence_has_keyword() -> None: + """Standard ```python fence produces Python keyword tokens.""" + tokens = get_tokens("```python\nimport this\n```\n") + token_types = [tok for tok, _ in tokens] + assert "Token.Keyword.Namespace" in token_types, ( + f"Expected Python keyword token, got types: {set(token_types)}" + ) + + +# --------------------------------------------------------------------------- +# 3-level nesting: {eval-rst} -> .. code-block:: python -> Python tokens +# --------------------------------------------------------------------------- + +_PY_IMPORT_KEYWORD = "Token.Keyword.Namespace" + + +class NestedHighlightFixture(t.NamedTuple): + test_id: str + input_text: str + expect_python_tokens: bool + + +NESTED_HIGHLIGHT_FIXTURES: list[NestedHighlightFixture] = [ + NestedHighlightFixture( + test_id="python_with_trailing_blank", + # Trailing blank line before ``` is required by RstLexer._handle_sourcecode + input_text=("```{eval-rst}\n.. code-block:: python\n\n import this\n\n```\n"), + expect_python_tokens=True, + ), + NestedHighlightFixture( + test_id="python_without_trailing_blank_no_highlighting", + # No trailing blank — RstLexer's regex doesn't match; documents limitation + input_text=("```{eval-rst}\n.. code-block:: python\n\n import this\n```\n"), + expect_python_tokens=False, + ), +] + + +@pytest.mark.parametrize( + list(NestedHighlightFixture._fields), + NESTED_HIGHLIGHT_FIXTURES, + ids=[f.test_id for f in NESTED_HIGHLIGHT_FIXTURES], +) +def test_nested_highlight( + test_id: str, + input_text: str, + expect_python_tokens: bool, +) -> None: + tokens = get_tokens(input_text) + token_types = [tok for tok, _ in tokens] + has_python = _PY_IMPORT_KEYWORD in token_types + assert has_python == expect_python_tokens, ( + f"test_id={test_id!r}: expected Python tokens={expect_python_tokens}, " + f"got={has_python}\nToken types: {sorted(set(token_types))}" + ) + + +# --------------------------------------------------------------------------- +# Multiple {eval-rst} blocks in one file +# --------------------------------------------------------------------------- + + +def test_multiple_eval_rst_blocks() -> None: + src = ( + "```{eval-rst}\nFirst block\n```\n" + "\n" + "Some text in between.\n" + "\n" + "```{eval-rst}\nSecond block\n```\n" + ) + tokens = get_tokens(src) + backtick_tokens = [val for tok, val in tokens if tok == _BACKTICK] + # Two opening fences + two closing fences = at least 4 + assert len(backtick_tokens) >= 4, ( + f"Expected at least 4 String.Backtick tokens (2 open + 2 close), " + f"got {len(backtick_tokens)}: {backtick_tokens}" + ) + + +# --------------------------------------------------------------------------- +# {eval-rst} block at end of file without trailing newline +# --------------------------------------------------------------------------- + + +def test_eval_rst_block_at_eof() -> None: + src = "```{eval-rst}\nHello RST\n```" # no trailing newline + tokens = get_tokens(src) + backtick_tokens = [val for tok, val in tokens if tok == _BACKTICK] + assert "```{eval-rst}" in backtick_tokens, ( + f"Expected opening fence token at EOF, got: {backtick_tokens}" + ) + + +# --------------------------------------------------------------------------- +# tokenize_myst helper function +# --------------------------------------------------------------------------- + + +def test_tokenize_myst_helper() -> None: + tokens = tokenize_myst("Hello world") + assert any("Hello" in v for _, v in tokens) + + +def test_tokenize_myst_returns_backtick_for_eval_rst() -> None: + tokens = tokenize_myst("```{eval-rst}\nHello RST\n```\n") + assert (_BACKTICK, "```{eval-rst}") in tokens From 37a3be0a3fff8029a6ad3bbbf3ecdf59a9c0509f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 10:35:38 -0500 Subject: [PATCH 34/57] fix(docs[packages]): Change ````md to ````myst in usage example fences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: ````md triggers MarkdownLexer (via Pygments fallback get_lexer_by_name) whose fenced-block rule uses [\w\-]+; {eval-rst} contains {} so it doesn't match, and the block emits + raw text (class="highlight-md"). ````myst hits lexer_classes["myst"] in PygmentsBridge.get_lexer() at sphinx/highlighting.py:160 before the Pygments fallback, selecting MystLexer. what: - sphinx-autodoc-docutils.md: 6 occurrences ````md → ````myst - sphinx-autodoc-sphinx.md: 2 occurrences ````md → ````myst - sphinx-argparse-neo.md: 1 occurrence ````md → ````myst --- docs/packages/sphinx-argparse-neo.md | 2 +- docs/packages/sphinx-autodoc-docutils.md | 12 ++++++------ docs/packages/sphinx-autodoc-sphinx.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/packages/sphinx-argparse-neo.md b/docs/packages/sphinx-argparse-neo.md index 0a27101b..09e814d5 100644 --- a/docs/packages/sphinx-argparse-neo.md +++ b/docs/packages/sphinx-argparse-neo.md @@ -92,7 +92,7 @@ The exemplar layer also registers live inline roles for CLI prose: Use native MyST directives in Markdown: -````md +````myst ```{argparse} :module: myproject.cli :func: create_parser diff --git a/docs/packages/sphinx-autodoc-docutils.md b/docs/packages/sphinx-autodoc-docutils.md index 22712e7a..a403b3fa 100644 --- a/docs/packages/sphinx-autodoc-docutils.md +++ b/docs/packages/sphinx-autodoc-docutils.md @@ -21,13 +21,13 @@ extensions = ["sphinx_autodoc_docutils"] Use a single-object directive when you want one rendered reference entry: -````md +````myst ```{eval-rst} .. autodirective:: my_project.docs_ext.MyDirective ``` ```` -````md +````myst ```{eval-rst} .. autorole:: my_project.docs_roles.cli_option_role ``` @@ -35,25 +35,25 @@ Use a single-object directive when you want one rendered reference entry: Use the bulk directives to render a full module reference plus an index: -````md +````myst ```{eval-rst} .. autodirective-index:: my_project.docs_ext ``` ```` -````md +````myst ```{eval-rst} .. autodirectives:: my_project.docs_ext ``` ```` -````md +````myst ```{eval-rst} .. autorole-index:: my_project.docs_roles ``` ```` -````md +````myst ```{eval-rst} .. autoroles:: my_project.docs_roles ``` diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md index fa0f245d..db534693 100644 --- a/docs/packages/sphinx-autodoc-sphinx.md +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -21,7 +21,7 @@ extensions = ["sphinx_autodoc_sphinx"] Render one config value: -````md +````myst ```{eval-rst} .. autoconfigvalue:: sphinx_fonts.sphinx_font_preload ``` @@ -29,7 +29,7 @@ Render one config value: Render every config value from an extension module: -````md +````myst ```{eval-rst} .. autoconfigvalue-index:: sphinx_config_demo ``` From 1be2f41da998bb4a8793a0e4b58526a418daf064 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 10:35:50 -0500 Subject: [PATCH 35/57] fix(sphinx-fonts[setup]): Shorten add_config_value description strings for E501 why: Three description= strings added in b209492 exceeded the 88-char line limit enforced by ruff E501, blocking a clean ruff check run. what: - sphinx_fonts: "Font family dicts (family, package, version, weights, styles)." - sphinx_font_fallbacks: "Fallback @font-face declarations with metric overrides for CLS." - sphinx_font_preload: "Critical font variants to preload (family, weight, style)." --- packages/sphinx-fonts/src/sphinx_fonts/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py index 01e5d96a..cd8a690c 100644 --- a/packages/sphinx-fonts/src/sphinx_fonts/__init__.py +++ b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py @@ -241,13 +241,13 @@ def setup(app: Sphinx) -> SetupDict: "sphinx_fonts", [], "html", - description="Font family definitions (list of dicts with family, package, version, weights, styles)", + description="Font family dicts (family, package, version, weights, styles).", ) app.add_config_value( "sphinx_font_fallbacks", [], "html", - description="Fallback @font-face declarations with metric overrides for CLS reduction", + description="Fallback @font-face declarations with metric overrides for CLS.", ) app.add_config_value( "sphinx_font_css_variables", @@ -259,7 +259,7 @@ def setup(app: Sphinx) -> SetupDict: "sphinx_font_preload", [], "html", - description="Critical font variants to preload as (family, weight, style) tuples", + description="Critical font variants to preload (family, weight, style).", ) app.connect("builder-inited", _on_builder_inited) app.connect("html-page-context", _on_html_page_context) From f1473e064aa7c4e10ee16119663741e9ac8effab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 11:42:04 -0500 Subject: [PATCH 36/57] fix(package_reference): Return empty string for unknown package name why: Bare next(generator) raised StopIteration and crashed the Sphinx build for any package-reference directive with an unrecognised name. what: - Add default=None to next() and return "" with a logged warning - Add import logging and module-level logger - Add doctest and test_package_reference_markdown_unknown_package_returns_empty --- docs/_ext/package_reference.py | 14 +++++++++++++- tests/test_package_reference.py | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index a141e6c4..66320507 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -56,6 +56,7 @@ import configparser import importlib import inspect +import logging import os import pathlib import pkgutil @@ -71,6 +72,8 @@ else: import tomli as tomllib # type: ignore[import-not-found] +logger = logging.getLogger(__name__) + class SurfaceDict(t.TypedDict): """Collected extension surface rows keyed by registration category.""" @@ -502,14 +505,23 @@ def theme_options(package_dir: pathlib.Path) -> list[str]: def package_reference_markdown(package_name: str) -> str: """Render the generated Markdown fragment for a workspace package page. + Returns an empty string and logs a warning when ``package_name`` is not + found among the workspace packages. + Examples -------- >>> "Registered Surface" in package_reference_markdown("sphinx-fonts") True + >>> package_reference_markdown("nonexistent-package") + '' """ package = next( - item for item in workspace_packages() if item["name"] == package_name + (item for item in workspace_packages() if item["name"] == package_name), + None, ) + if package is None: + logger.warning("package-reference: unknown package %r", package_name) + return "" package_dir = pathlib.Path(package["package_dir"]) module_name = package["module_name"] extension_blocks = [ diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 931863fa..135d16a5 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -71,6 +71,12 @@ def test_docs_package_pages_exist_for_every_workspace_package() -> None: assert page_names == package_names +def test_package_reference_markdown_unknown_package_returns_empty() -> None: + """Unknown package names return an empty string rather than crashing.""" + result = package_reference.package_reference_markdown("nonexistent-package") + assert result == "" + + def test_redirects_cover_legacy_extensions_paths() -> None: """Legacy extensions/* redirects exist for the packages index and pages.""" redirects = (REPO_ROOT / "docs" / "redirects.txt").read_text().splitlines() From 82fc8492906c8ad3e8e76abe0970d497560dab00 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 11:44:01 -0500 Subject: [PATCH 37/57] fix(package_reference): Guard importlib.import_module() calls against ImportError why: All three import_module() call sites were unguarded; any missing dependency or syntax error in a workspace package crashed the full build. what: - extension_modules(): wrap top-level import and submodule loop in try/except - collect_extension_surface(): wrap import and return empty SurfaceDict on failure - Log a warning at each site so the skipped module is visible in the build output - Add test_extension_modules_skips_unimportable_module - Add test_collect_extension_surface_skips_unimportable_module --- docs/_ext/package_reference.py | 27 ++++++++++++++++++++++++--- tests/test_package_reference.py | 16 ++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 66320507..5795eb05 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -175,7 +175,11 @@ def extension_modules(module_name: str) -> list[str]: True """ ensure_workspace_imports() - module = importlib.import_module(module_name) + try: + module = importlib.import_module(module_name) + except ImportError: + logger.warning("package-reference: could not import %r", module_name) + return [] modules = [] if callable(getattr(module, "setup", None)): modules.append(module_name) @@ -185,7 +189,13 @@ def extension_modules(module_name: str) -> list[str]: return modules for module_info in pkgutil.walk_packages(package_paths, prefix=f"{module_name}."): - submodule = importlib.import_module(module_info.name) + try: + submodule = importlib.import_module(module_info.name) + except ImportError: + logger.warning( + "package-reference: could not import submodule %r", module_info.name + ) + continue if callable(getattr(submodule, "setup", None)): modules.append(module_info.name) return modules @@ -287,8 +297,19 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: True """ ensure_workspace_imports() + try: + module = importlib.import_module(module_name) + except ImportError: + logger.warning("package-reference: could not import %r", module_name) + return SurfaceDict( + module=module_name, + config_values=[], + directives=[], + roles=[], + lexers=[], + themes=[], + ) app = RecorderApp() - module = importlib.import_module(module_name) registered_roles: list[tuple[str, object]] = [] original_local = roles.register_local_role original_canonical = roles.register_canonical_role diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 135d16a5..8a3e3597 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -71,6 +71,22 @@ def test_docs_package_pages_exist_for_every_workspace_package() -> None: assert page_names == package_names +def test_extension_modules_skips_unimportable_module() -> None: + """An ImportError during module import returns [] instead of crashing.""" + result = package_reference.extension_modules("_this_module_does_not_exist_") + assert result == [] + + +def test_collect_extension_surface_skips_unimportable_module() -> None: + """An ImportError in collect_extension_surface returns an empty SurfaceDict.""" + surface = package_reference.collect_extension_surface( + "_this_module_does_not_exist_" + ) + assert surface["module"] == "_this_module_does_not_exist_" + assert surface["config_values"] == [] + assert surface["directives"] == [] + + def test_package_reference_markdown_unknown_package_returns_empty() -> None: """Unknown package names return an empty string rather than crashing.""" result = package_reference.package_reference_markdown("nonexistent-package") From 99742f08b121e12a51debe9a6469ce303a479f6e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 11:44:51 -0500 Subject: [PATCH 38/57] fix(sphinx-autodoc-docutils): Guard AutoDirectives/AutoRoles run() against empty markup why: AutoDirectives.run() and AutoRoles.run() called _render_blocks(self, markup) without a guard, unlike AutoDirectiveIndex which already has if markup else []. Modules with no directive classes or role callables produced a markup="" call that is inconsistent with the Index variant behaviour. what: - Add `if markup else []` guard to AutoDirectives.run() (line 332) - Add `if markup else []` guard to AutoRoles.run() (line 392) - Add test_directive_classes_empty_for_module_with_no_directives - Add test_role_callables_empty_for_module_with_no_roles --- .../src/sphinx_autodoc_docutils/_directives.py | 4 ++-- tests/ext/autodoc_docutils/test_directives.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) 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 6a85f7d8..d6a6680d 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -329,7 +329,7 @@ def run(self) -> list[nodes.Node]: ) for name, directive_cls in _directive_classes(module_name) ) - return _render_blocks(self, markup) + return _render_blocks(self, markup) if markup else [] class AutoDirectiveIndex(SphinxDirective): @@ -389,7 +389,7 @@ def run(self) -> list[nodes.Node]: ) for name, role_fn in _role_callables(module_name) ) - return _render_blocks(self, markup) + return _render_blocks(self, markup) if markup else [] class AutoRoleIndex(SphinxDirective): diff --git a/tests/ext/autodoc_docutils/test_directives.py b/tests/ext/autodoc_docutils/test_directives.py index 87746500..32b4d7e3 100644 --- a/tests/ext/autodoc_docutils/test_directives.py +++ b/tests/ext/autodoc_docutils/test_directives.py @@ -46,6 +46,20 @@ def test_directive_markup_contains_path_and_summary() -> None: assert "Generate a summary index for all directives in a module." in markup +def test_directive_classes_empty_for_module_with_no_directives() -> None: + """A module without directive classes yields an empty list, not an error.""" + # sphinx_fonts has no directive classes; the join produces "" and the + # if markup else [] guard in AutoDirectives.run() returns [] not an error. + result = _directive_classes("sphinx_fonts") + assert result == [] + + +def test_role_callables_empty_for_module_with_no_roles() -> None: + """A module without role callables yields an empty list, not an error.""" + result = _role_callables("sphinx_fonts") + assert result == [] + + def test_role_markup_contains_role_name_and_path() -> None: """Rendered role markup includes the displayed role name and path.""" role_fn = dict(_role_callables("sphinx_argparse_neo.roles"))["cli_option_role"] From 3a58584306ded91ad15a10a0f105834d994cd839 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 11:45:15 -0500 Subject: [PATCH 39/57] fix(sphinx-autodoc-docutils): Add FakeApp doctest to setup() why: CLAUDE.md requires all functions/methods to have working doctests; setup() only had a one-line docstring with no doctest. what: - Add FakeApp doctest matching the pattern in sphinx_autodoc_sphinx/__init__.py - Verify autodirective is registered and parallel_read_safe is True --- .../src/sphinx_autodoc_docutils/__init__.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 6c84c509..c15c1bd4 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -20,7 +20,22 @@ def setup(app: Sphinx) -> ExtensionMetadata: - """Register the extension.""" + """Register docutils directive and role autodoc directives. + + Examples + -------- + >>> class FakeApp: + ... def __init__(self) -> None: + ... self.calls: list[tuple[str, str]] = [] + ... def add_directive(self, name: str, directive: object) -> None: + ... self.calls.append(("add_directive", name)) + >>> fake = FakeApp() + >>> metadata = setup(fake) # type: ignore[arg-type] + >>> ("add_directive", "autodirective") in fake.calls + True + >>> metadata["parallel_read_safe"] + True + """ app.add_directive("autodirective", AutoDirective) app.add_directive("autodirectives", AutoDirectives) app.add_directive("autodirective-index", AutoDirectiveIndex) From 2b673872965e105bed80a0daac937411b87b94da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 11:46:06 -0500 Subject: [PATCH 40/57] fix(sphinx-autodoc-docutils,sphinx-autodoc-sphinx): Add NullHandler to library __init__ files why: CLAUDE.md requires NullHandler in every library __init__.py; neither new package had it, causing logging.lastResort to print WARNING+ to stderr in user projects that don't configure logging. what: - Add import logging and NullHandler to sphinx_autodoc_docutils/__init__.py - Add import logging and NullHandler to sphinx_autodoc_sphinx/__init__.py --- .../src/sphinx_autodoc_docutils/__init__.py | 3 +++ .../src/sphinx_autodoc_sphinx/__init__.py | 3 +++ 2 files changed, 6 insertions(+) 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 c15c1bd4..1c863857 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t from sphinx.application import Sphinx @@ -18,6 +19,8 @@ if t.TYPE_CHECKING: from sphinx.util.typing import ExtensionMetadata +logging.getLogger(__name__).addHandler(logging.NullHandler()) + def setup(app: Sphinx) -> ExtensionMetadata: """Register docutils directive and role autodoc directives. 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 e9c722dc..d1415b8e 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t from sphinx.application import Sphinx @@ -16,6 +17,8 @@ if t.TYPE_CHECKING: from sphinx.util.typing import ExtensionMetadata +logging.getLogger(__name__).addHandler(logging.NullHandler()) + def setup(app: Sphinx) -> ExtensionMetadata: """Register config-value documentation directives. From eede2a29318fd5dc2693330a63f09be93f427fc3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 11:47:21 -0500 Subject: [PATCH 41/57] docs(sphinx-autodoc,package_reference): Comment duplication and parallel-build constraint why: Two code review findings flagged undocumented constraints: _render_blocks() is silently duplicated across both autodoc packages, and the docutils global monkey-patch in collect_extension_surface() is not safe for parallel builds. what: - Add NOTE comment above both _render_blocks() copies explaining the duplication and why extraction requires a new dep (extract to gp_sphinx._render if a third caller emerges) - Add comment in collect_extension_surface() explaining the try/finally pattern and noting the parallel-build limitation (sphinx -j N) --- docs/_ext/package_reference.py | 5 +++++ .../src/sphinx_autodoc_docutils/_directives.py | 4 ++++ .../src/sphinx_autodoc_sphinx/_directives.py | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 5795eb05..3870e188 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -317,6 +317,11 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: 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) 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 d6a6680d..6615009c 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -132,6 +132,10 @@ 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. 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 acb94e24..b147baf4 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -360,6 +360,10 @@ 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. From ab3cfb8521a46bde77b80887ded5ba32751e4d35 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 12:50:48 -0500 Subject: [PATCH 42/57] style(docs): Add badge metadata strip, matte palette, and dark mode CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Badges after H1 had inverted visual gravity — more whitespace above than below — making them read as floating unattached metadata rather than a subtitle. sphinx-design defaults use Bootstrap-era bright solid fills that clash with the Furo muted aesthetic. what: - Tighten h1 bottom margin (0.75rem → 0.2rem) so the badge strip reads as attached subtitle via `article > section > h1` - Convert badge-only first-

into a flex metadata strip using CSS :has() - Reset .sd-badge to inline-flex, compact sizing, lower border-radius - Add --badge-alpha-* / --badge-beta-* CSS vars for Furo dark mode: body[data-theme="dark"] for explicit mode, @media (prefers-color-scheme: dark) { body:not([data-theme="light"]) } for auto mode (two separate blocks — cannot combine as selector list) - Alpha: sd-outline-warning → warm amber outline with color-mix tint - Beta: sd-outline-success → cool green outline with color-mix tint - Type badges (sd-bg-primary/success/info): muted gray, scoped to metadata strip paragraph only - .sd-card-footer .sd-badge: compact sizing for grid index cards --- docs/_static/css/custom.css | 115 ++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 95373cf0..e2580dca 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -263,3 +263,118 @@ img[src*="codecov.io"] { font-size: 1rem; line-height: 1.5; } + +/* ── Package page metadata strip ──────────────────────────── + * Selects the first

inside the top-level section that + * contains ONLY sphinx-design badges (no non-badge children). + * + * MyST renders {bdg-*} roles as inside + * a bare

that is a direct child of

, which is a + * direct child of
. + * + * :has() requires CSS Level 4 (all evergreen browsers ≥ 2024). + * Falls back gracefully: badges render inline without the flex strip. + * + * Overrides `article h1 { margin-bottom: 0.75rem }` (line 38) + * via equal specificity (0,0,2) and later source order. + * ────────────────────────────────────────────────────────── */ + +/* Tighten h1 → badge strip gap so they read as a unit */ +article > section > h1 { + margin-bottom: 0.2rem; +} + +/* Convert badge-only paragraph into a flex metadata strip */ +article > section > p:first-of-type:has(> .sd-badge:first-child):not(:has(*:not(.sd-badge))) { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0; + margin-bottom: 1.5rem; + padding: 0; + line-height: 1; +} + +/* ── Base badge reset ────────────────────────────────────── */ +.sd-badge { + 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; +} + +/* ── Maturity palette (CSS custom properties) ────────────── */ +:root { + --badge-alpha-color: #a85e14; + --badge-alpha-border: #d4821c; + --badge-beta-color: #1a6b38; + --badge-beta-border: #27a055; +} + +/* Furo explicit dark theme */ +body[data-theme="dark"] { + --badge-alpha-color: #f0a84e; + --badge-alpha-border: #b87427; + --badge-beta-color: #5bcb85; + --badge-beta-border: #2a8d4d; +} + +/* Furo auto mode: system dark when not explicitly set to light */ +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) { + --badge-alpha-color: #f0a84e; + --badge-alpha-border: #b87427; + --badge-beta-color: #5bcb85; + --badge-beta-border: #2a8d4d; + } +} + +/* {bdg-warning-line} → .sd-badge.sd-outline-warning.sd-text-warning */ +.sd-badge.sd-outline-warning { + color: var(--badge-alpha-color); + border-color: var(--badge-alpha-border); + background: color-mix(in srgb, var(--badge-alpha-border) 10%, transparent); +} + +/* {bdg-success-line} → .sd-badge.sd-outline-success.sd-text-success */ +.sd-badge.sd-outline-success { + color: var(--badge-beta-color); + border-color: var(--badge-beta-border); + background: color-mix(in srgb, var(--badge-beta-border) 10%, transparent); +} + +/* Type badges (solid fills) — muted gray, scoped to metadata strip only. + * extension: {bdg-primary} → .sd-badge.sd-bg-primary + * coordinator: {bdg-success} → .sd-badge.sd-bg-success (solid fill) + * theme: {bdg-info} → .sd-badge.sd-bg-info + * .sd-bg-success and .sd-outline-success are mutually exclusive in sphinx-design. */ +article > section > p:first-of-type > .sd-badge.sd-bg-primary, +article > section > p:first-of-type > .sd-badge.sd-bg-success, +article > section > p:first-of-type > .sd-badge.sd-bg-info { + font-size: 0.6rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + opacity: 0.65; + background-color: var(--color-background-border) !important; + color: var(--color-foreground-secondary) !important; + border: 1px solid var(--color-foreground-border); +} + +/* Card footer badges — compact and scannable in the grid index */ +.sd-card-footer .sd-badge { + font-size: 0.6rem; + font-weight: 500; + padding: 0.13rem 0.35rem; + letter-spacing: 0.03em; + text-transform: uppercase; + opacity: 0.8; + vertical-align: middle; +} From 8755c93c269986d75a93cbebbfae6e0a341f927f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 12:52:20 -0500 Subject: [PATCH 43/57] refactor(package_reference): Move maturity badge from card title to card footer why: maturity_badge() was injected directly into the grid-item-card title f-string, making it impossible to reposition via CSS. Moving it to the +++ footer section lets the .sd-card-footer CSS rule style it independently. what: - workspace_package_grid_markdown(): remove maturity_badge() from card title, add blank line + +++ + maturity_badge() before ::: close - Add "+++" doctest assertion to verify footer section presence - tests/test_package_reference.py: add MaturityBadgeFixture NamedTuple parametrize for maturity_badge() inputs; GridMarkdownFixture parametrize for structural checks; separate test asserting no badge appears in card title lines --- docs/_ext/package_reference.py | 7 ++- tests/test_package_reference.py | 93 +++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 3870e188..287bdbbf 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -722,6 +722,8 @@ def workspace_package_grid_markdown() -> str: -------- >>> "grid-item-card" in workspace_package_grid_markdown() True + >>> "+++" in workspace_package_grid_markdown() + True """ lines = [ "::::{grid} 1 1 2 2", @@ -731,11 +733,14 @@ def workspace_package_grid_markdown() -> str: for package in workspace_packages(): lines.extend( [ - f":::{{grid-item-card}} {package['name']} {maturity_badge(package['maturity'])}", + f":::{{grid-item-card}} {package['name']}", f":link: {package['name']}", ":link-type: doc", "", str(package["description"]), + "", + "+++", + maturity_badge(package["maturity"]), ":::", "", ] diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 8a3e3597..b6969881 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -4,6 +4,9 @@ import pathlib import sys +import typing as t + +import pytest sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "docs" / "_ext")) @@ -105,3 +108,93 @@ def test_redirects_cover_legacy_extensions_paths() -> None: }, } assert redirect_map == expected + + +class MaturityBadgeFixture(t.NamedTuple): + """Fixture for maturity_badge() input/output pairs.""" + + test_id: str + maturity: str + expected: str + + +MATURITY_BADGE_FIXTURES: list[MaturityBadgeFixture] = [ + MaturityBadgeFixture( + test_id="alpha", + maturity="Alpha", + expected="{bdg-warning-line}`Alpha`", + ), + MaturityBadgeFixture( + test_id="beta", + maturity="Beta", + expected="{bdg-success-line}`Beta`", + ), + MaturityBadgeFixture( + test_id="unknown_falls_back_to_secondary", + maturity="Stable", + expected="{bdg-secondary-line}`Stable`", + ), +] + + +@pytest.mark.parametrize( + list(MaturityBadgeFixture._fields), + MATURITY_BADGE_FIXTURES, + ids=[f.test_id for f in MATURITY_BADGE_FIXTURES], +) +def test_maturity_badge(test_id: str, maturity: str, expected: str) -> None: + """maturity_badge() returns the correct sphinx-design badge role.""" + assert package_reference.maturity_badge(maturity) == expected + + +class GridMarkdownFixture(t.NamedTuple): + """Fixture for workspace_package_grid_markdown() structural checks.""" + + test_id: str + substring: str + present: bool + + +GRID_MARKDOWN_FIXTURES: list[GridMarkdownFixture] = [ + GridMarkdownFixture( + test_id="has_grid_directive", + substring="::::{grid} 1 1 2 2", + present=True, + ), + GridMarkdownFixture( + test_id="has_card_footer_separator", + substring="+++", + present=True, + ), + GridMarkdownFixture( + test_id="maturity_badge_present_somewhere_in_output", + substring="{bdg-", + present=True, + ), +] + + +@pytest.mark.parametrize( + list(GridMarkdownFixture._fields), + GRID_MARKDOWN_FIXTURES, + ids=[f.test_id for f in GRID_MARKDOWN_FIXTURES], +) +def test_workspace_package_grid_markdown_structure( + test_id: str, + substring: str, + present: bool, +) -> None: + """Grid markdown output has the expected structural properties.""" + output = package_reference.workspace_package_grid_markdown() + assert (substring in output) == present + + +def test_workspace_package_grid_markdown_badge_not_in_card_titles() -> None: + """Maturity badges appear in the card footer, not in card title lines.""" + output = package_reference.workspace_package_grid_markdown() + title_lines = [ + line for line in output.splitlines() if line.startswith(":::{grid-item-card}") + ] + assert title_lines, "expected at least one card title line" + for line in title_lines: + assert "{bdg-" not in line, f"badge found in card title: {line!r}" From 21d0b29cfb8efa48c43450174c18ab0c7a00f103 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 12:53:08 -0500 Subject: [PATCH 44/57] docs(packages): Remove redundant type badge from individual package pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Type (extension/theme/coordinator) is redundant on individual package pages — the URL, breadcrumb, and section heading already communicate where you are. The maturity signal (Alpha/Beta) is the one piece of metadata that earns page-level real estate. Type badge survives in the grid index cards. what: - Remove {bdg-primary}`extension`, {bdg-success}`coordinator`, {bdg-info}`theme` from line 3 of all 7 docs/packages/*.md files - Keep maturity badge ({bdg-warning-line}`Alpha` / {bdg-success-line}`Beta`) only --- docs/packages/gp-sphinx.md | 2 +- docs/packages/sphinx-argparse-neo.md | 2 +- docs/packages/sphinx-autodoc-docutils.md | 2 +- docs/packages/sphinx-autodoc-pytest-fixtures.md | 2 +- docs/packages/sphinx-autodoc-sphinx.md | 2 +- docs/packages/sphinx-fonts.md | 2 +- docs/packages/sphinx-gptheme.md | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx.md index 6c23db12..05132940 100644 --- a/docs/packages/gp-sphinx.md +++ b/docs/packages/gp-sphinx.md @@ -1,6 +1,6 @@ # gp-sphinx -{bdg-warning-line}`Alpha` {bdg-success}`coordinator` +{bdg-warning-line}`Alpha` Shared configuration coordinator for Sphinx projects. `merge_sphinx_config()` builds a complete `conf.py` namespace from the workspace defaults and leaves diff --git a/docs/packages/sphinx-argparse-neo.md b/docs/packages/sphinx-argparse-neo.md index 09e814d5..5ed93f8e 100644 --- a/docs/packages/sphinx-argparse-neo.md +++ b/docs/packages/sphinx-argparse-neo.md @@ -1,6 +1,6 @@ # sphinx-argparse-neo -{bdg-success-line}`Beta` {bdg-primary}`extension` +{bdg-success-line}`Beta` Modern Sphinx extension for documenting `argparse` CLIs. The base package registers the `argparse` directive plus renderer config values; the diff --git a/docs/packages/sphinx-autodoc-docutils.md b/docs/packages/sphinx-autodoc-docutils.md index a403b3fa..a8e83d98 100644 --- a/docs/packages/sphinx-autodoc-docutils.md +++ b/docs/packages/sphinx-autodoc-docutils.md @@ -1,6 +1,6 @@ # sphinx-autodoc-docutils -{bdg-warning-line}`Alpha` {bdg-primary}`extension` +{bdg-warning-line}`Alpha` Experimental Sphinx extension for documenting docutils directives and role callables as reference material. The extension does not invent a new domain; diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index c6ae874d..a2765ee5 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -1,6 +1,6 @@ # sphinx-autodoc-pytest-fixtures -{bdg-warning-line}`Alpha` {bdg-primary}`extension` +{bdg-warning-line}`Alpha` Sphinx extension for documenting pytest fixtures as first-class objects. It registers a Python-domain fixture directive and role, autodoc helpers for bulk diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md index db534693..6d6185c6 100644 --- a/docs/packages/sphinx-autodoc-sphinx.md +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -1,6 +1,6 @@ # sphinx-autodoc-sphinx -{bdg-warning-line}`Alpha` {bdg-primary}`extension` +{bdg-warning-line}`Alpha` Experimental Sphinx extension for documenting config values registered by extension `setup()` hooks. It takes the repetitive part of `conf.py` diff --git a/docs/packages/sphinx-fonts.md b/docs/packages/sphinx-fonts.md index 05443438..7f350db3 100644 --- a/docs/packages/sphinx-fonts.md +++ b/docs/packages/sphinx-fonts.md @@ -1,6 +1,6 @@ # sphinx-fonts -{bdg-success-line}`Beta` {bdg-primary}`extension` +{bdg-success-line}`Beta` Sphinx extension for self-hosted web fonts via Fontsource. It downloads font assets during the HTML build, caches them locally, copies them into diff --git a/docs/packages/sphinx-gptheme.md b/docs/packages/sphinx-gptheme.md index eee15b7e..b1089c69 100644 --- a/docs/packages/sphinx-gptheme.md +++ b/docs/packages/sphinx-gptheme.md @@ -1,6 +1,6 @@ # sphinx-gptheme -{bdg-success-line}`Beta` {bdg-info}`theme` +{bdg-success-line}`Beta` 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 From 6153f77fc9cbe5c4b053a723d721bac628dc6f9b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 14:23:05 -0500 Subject: [PATCH 45/57] style(docs/badges): Switch to Radix subtle fill palette with !important overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Previous color-mix approach was doubly broken — sphinx-design sets color and border-color with !important on .sd-text-warning/.sd-outline-warning, silently discarding our overrides; and color-mix(20%, transparent) resolved to a near-invisible tint on Furo's off-white card footer background. what: - Replace color-mix backgrounds with opaque Radix amber-3/green-3 hex values - Add !important to color and border-color to match sphinx-design's own escalation - Switch to background-color (narrower override; no !important needed) - Use Radix step-12 text (11.62:1/10.55:1 AAA), step-11 border (WCAG 1.4.11 ✓) - Add --badge-alpha-bg / --badge-beta-bg variables for clean dark-mode override - Dark mode: Radix amber-3/green-3 dark tints with step-11 text (9.13:1/6.66:1) --- docs/_static/css/custom.css | 57 ++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index e2580dca..817e0a03 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -310,44 +310,62 @@ article > section > p:first-of-type:has(> .sd-badge:first-child):not(:has(*:not( -webkit-user-select: none; } -/* ── Maturity palette (CSS custom properties) ────────────── */ +/* ── Maturity palette ──────────────────────────────────────── + * Subtle fill: Radix steps 12 (text), 11 (border), 3 (bg). + * Step-12 text passes WCAG AAA on step-3 tinted backgrounds. + * Step-11 border passes WCAG 1.4.11 (non-text contrast ≥ 3:1). + * + * !important matches sphinx-design's own !important on + * .sd-text-warning / .sd-text-success and .sd-outline-*. + * Without it, color and border-color are silently overridden. + * background-color does not need !important — sphinx-design + * does not set background on these badge variants. + * ─────────────────────────────────────────────────────────── */ :root { - --badge-alpha-color: #a85e14; - --badge-alpha-border: #d4821c; - --badge-beta-color: #1a6b38; - --badge-beta-border: #27a055; + --badge-alpha-color: #4e2009; /* Radix amber-12 — 11.62:1 on amber-3 (AAA) */ + --badge-alpha-border: #ab6400; /* Radix amber-11 — 4.11:1 on card footer (WCAG 1.4.11 ✓) */ + --badge-alpha-bg: #ffedc6; /* Radix amber-3 — opaque tint, no color-mix */ + + --badge-beta-color: #193b2d; /* Radix green-12 — 10.55:1 on green-3 (AAA) */ + --badge-beta-border: #218358; /* Radix green-11 — 4.22:1 on card footer (WCAG 1.4.11 ✓) */ + --badge-beta-bg: #ddf3e4; /* Radix green-3 */ } /* Furo explicit dark theme */ body[data-theme="dark"] { - --badge-alpha-color: #f0a84e; - --badge-alpha-border: #b87427; - --badge-beta-color: #5bcb85; - --badge-beta-border: #2a8d4d; + --badge-alpha-color: #ffca16; /* Radix amber-11 dark — 9.13:1 on #3f2700 (AAA) */ + --badge-alpha-border: #8f6424; /* Radix amber-8 dark */ + --badge-alpha-bg: #3f2700; /* Radix amber-3 dark */ + + --badge-beta-color: #3dd68c; /* Radix green-11 dark — 6.66:1 on #113b29 (AA) */ + --badge-beta-border: #2f7c57; /* Radix green-8 dark */ + --badge-beta-bg: #113b29; /* Radix green-3 dark */ } /* Furo auto mode: system dark when not explicitly set to light */ @media (prefers-color-scheme: dark) { body:not([data-theme="light"]) { - --badge-alpha-color: #f0a84e; - --badge-alpha-border: #b87427; - --badge-beta-color: #5bcb85; - --badge-beta-border: #2a8d4d; + --badge-alpha-color: #ffca16; + --badge-alpha-border: #8f6424; + --badge-alpha-bg: #3f2700; + --badge-beta-color: #3dd68c; + --badge-beta-border: #2f7c57; + --badge-beta-bg: #113b29; } } /* {bdg-warning-line} → .sd-badge.sd-outline-warning.sd-text-warning */ .sd-badge.sd-outline-warning { - color: var(--badge-alpha-color); - border-color: var(--badge-alpha-border); - background: color-mix(in srgb, var(--badge-alpha-border) 10%, transparent); + color: var(--badge-alpha-color) !important; + border-color: var(--badge-alpha-border) !important; + background-color: var(--badge-alpha-bg); } /* {bdg-success-line} → .sd-badge.sd-outline-success.sd-text-success */ .sd-badge.sd-outline-success { - color: var(--badge-beta-color); - border-color: var(--badge-beta-border); - background: color-mix(in srgb, var(--badge-beta-border) 10%, transparent); + color: var(--badge-beta-color) !important; + border-color: var(--badge-beta-border) !important; + background-color: var(--badge-beta-bg); } /* Type badges (solid fills) — muted gray, scoped to metadata strip only. @@ -375,6 +393,5 @@ article > section > p:first-of-type > .sd-badge.sd-bg-info { padding: 0.13rem 0.35rem; letter-spacing: 0.03em; text-transform: uppercase; - opacity: 0.8; vertical-align: middle; } From b6d366ab76f814c54deda2e2424be9d0b0a08330 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 15:34:02 -0500 Subject: [PATCH 46/57] feat(sphinx-autodoc-sphinx[directives]): Render complex defaults as Pygments code blocks why: Dict/frozenset defaults (e.g. pytest_fixture_builtin_links with 6 full URLs) rendered as huge single-line elements inside confval entries, breaking layout. Direct node construction bypasses RST re-parsing and build cache issues. what: - Add _COMPLEX_REPR_THRESHOLD (60 chars) and _is_complex_default() helper - Add _make_default_block() returning nodes.literal_block with language=python - Add _iter_desc_content() to traverse desc_content nodes from parsed output - Omit :default: field in render_config_value_markup() for complex values - Inject literal_block directly into desc_content in AutoconfigvaluesDirective - Add parametrized test_is_complex_default, test_make_default_block, and test_render_config_value_markup_omits_default_for_complex --- .../src/sphinx_autodoc_sphinx/_directives.py | 92 +++++++++++++++++-- tests/ext/autodoc_sphinx/test_directives.py | 53 +++++++++++ 2 files changed, 139 insertions(+), 6 deletions(-) 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 b147baf4..09a73382 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -19,17 +19,21 @@ from __future__ import annotations import importlib +import pprint import typing as t from dataclasses import dataclass 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 if t.TYPE_CHECKING: from sphinx.util.typing import OptionSpec +_COMPLEX_REPR_THRESHOLD = 60 + class InvalidConfigValuePathError(ValueError): """Raised when a config-value path is missing the ``module.option`` form. @@ -158,6 +162,48 @@ def _render_default(value: object) -> str: # object: only calls repr() return f"``{value!r}``" +def _is_complex_default(value: object) -> bool: # object: only calls repr() + """Return True when repr of value exceeds the inline display threshold. + + Values whose repr is longer than :data:`_COMPLEX_REPR_THRESHOLD` chars + are rendered as a Pygments-highlighted ``literal_block`` node rather than + as an inline ``:default:`` field literal. + + Examples + -------- + >>> _is_complex_default(True) + False + >>> _is_complex_default("warning") + False + >>> _is_complex_default(frozenset(range(15))) + True + """ + return len(repr(value)) > _COMPLEX_REPR_THRESHOLD + + +def _make_default_block(value: object) -> nodes.literal_block: # object: calls repr + """Return a Pygments-highlighted ``literal_block`` for a complex default. + + The ``language='python'`` attribute causes Sphinx's HTML writer to call + ``highlighter.highlight_block()``, producing ``
``. + + Examples + -------- + >>> block = _make_default_block({"k": "v"}) + >>> block["language"] + 'python' + >>> "'k'" in block.astext() + True + """ + formatted = pprint.pformat(value, width=72) + block = nodes.literal_block(formatted, formatted) + block["language"] = "python" + block["linenos"] = False + block["highlight_args"] = {} + block["force"] = False + return block + + def _render_types( types: object, default: object ) -> str: # object: uses isinstance guards @@ -279,6 +325,11 @@ def render_config_value_markup( ) -> str: """Return reStructuredText for one real ``confval`` entry. + Simple defaults (repr ≤ :data:`_COMPLEX_REPR_THRESHOLD` chars) use the + inline ``:default:`` field. Complex defaults omit the field; callers that + need Pygments output should inject a :func:`_make_default_block` node into + the parsed ``desc_content`` directly. + Examples -------- >>> value = SphinxConfigValue("demo_ext", "demo_option", True, "html", (bool,)) @@ -292,9 +343,10 @@ def render_config_value_markup( f".. confval:: {value.name}", " :no-index:" if no_index else "", f" :type: {_render_types(value.types, value.default)}", - f" :default: {_render_default(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( @@ -399,6 +451,23 @@ def _render_blocks(directive: SphinxDirective, markup: str) -> list[nodes.Node]: return [container] if container.children else [] +def _iter_desc_content( + node_list: list[nodes.Node], +) -> t.Iterator[addnodes.desc_content]: + """Yield ``desc_content`` nodes from a list of parsed nodes. + + ``addnodes.desc_content`` is the ``
`` body of a Sphinx object + description (confval, function, etc.). + + Examples + -------- + >>> list(_iter_desc_content([])) + [] + """ + for node in node_list: + yield from node.traverse(addnodes.desc_content) + + class AutoconfigvalueDirective(SphinxDirective): """Render one config value from a fully-qualified ``module.option`` path.""" @@ -422,10 +491,21 @@ class AutoconfigvaluesDirective(SphinxDirective): option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} def run(self) -> list[nodes.Node]: - markup = render_config_values_markup( - self.arguments[0], no_index="no-index" in self.options - ) - return _render_blocks(self, markup) if markup else [] + module_name = self.arguments[0] + 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) + return result class AutoconfigvalueIndexDirective(SphinxDirective): diff --git a/tests/ext/autodoc_sphinx/test_directives.py b/tests/ext/autodoc_sphinx/test_directives.py index 2bed0c7d..6957d7fc 100644 --- a/tests/ext/autodoc_sphinx/test_directives.py +++ b/tests/ext/autodoc_sphinx/test_directives.py @@ -2,8 +2,15 @@ from __future__ import annotations +import typing as t + +import pytest + from sphinx_autodoc_sphinx import setup from sphinx_autodoc_sphinx._directives import ( + SphinxConfigValue, + _is_complex_default, + _make_default_block, discover_config_value, discover_config_values, render_config_index_markup, @@ -48,3 +55,49 @@ def test_config_index_renders_summary_table() -> None: markup = render_config_index_markup("sphinx_fonts") assert ".. list-table::" in markup assert "sphinx_font_css_variables" in markup + + +class IsComplexCase(t.NamedTuple): + """Test case for _is_complex_default.""" + + value: object + expected: bool + test_id: str + + +@pytest.mark.parametrize( + "case", + [ + IsComplexCase(True, False, "bool_simple"), + IsComplexCase("warning", False, "short_string"), + IsComplexCase({}, False, "empty_dict"), + IsComplexCase({"k" * 5: "v" * 60}, True, "long_dict"), + IsComplexCase(frozenset(range(15)), True, "large_frozenset"), + ], + ids=lambda c: c.test_id, +) +def test_is_complex_default(case: IsComplexCase) -> None: + """Values with repr > 60 chars are flagged as complex.""" + assert _is_complex_default(case.value) == case.expected + + +def test_make_default_block_produces_literal_block() -> None: + """_make_default_block returns a literal_block with language='python'.""" + block = _make_default_block({"key": "value"}) + assert block["language"] == "python" + assert "key" in block.astext() + + +def test_render_config_value_markup_omits_default_for_complex() -> None: + """Complex defaults omit the :default: field; simple defaults keep it.""" + complex_value = SphinxConfigValue( + "demo_ext", + "demo_map", + {"key": "https://example.com/very/long/url/path/that/exceeds/threshold"}, + "env", + (dict,), + ) + assert ":default:" not in render_config_value_markup(complex_value) + + simple_value = SphinxConfigValue("demo_ext", "demo_flag", True, "html", (bool,)) + assert ":default: ``True``" in render_config_value_markup(simple_value) From 546dfde97e1c4898562c092e7f2e5770d452f3a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 15:37:00 -0500 Subject: [PATCH 47/57] docs(package_reference): Link Callable column via {py:obj} with ~ short name why: Full dotted paths like sphinx_autodoc_docutils._directives.AutoDirectives made the Callable column very wide; linking to the autodoc entry and showing only the short name is more scannable and navigable. what: - Change object_path() to return {py:obj}`~module.Name` cross-reference markup - Update hard-coded add_crossref_type callable to {py:meth}`~...` form - Update object_path() doctest to reflect new return format --- docs/_ext/package_reference.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 287bdbbf..cb715203 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -385,7 +385,7 @@ def _record_local(name: str, role: object) -> None: { "name": f"std:{directive_name}", "kind": "cross-reference directive", - "callable": "`sphinx.application.Sphinx.add_crossref_type`", + "callable": "{py:meth}`~sphinx.application.Sphinx.add_crossref_type`", "summary": "Registers a standard-domain cross-reference target.", "options": "", } @@ -394,7 +394,7 @@ def _record_local(name: str, role: object) -> None: { "name": f"std:{role_name}", "kind": "cross-reference role", - "callable": "`sphinx.application.Sphinx.add_crossref_type`", + "callable": "{py:meth}`~sphinx.application.Sphinx.add_crossref_type`", "summary": "Registers a standard-domain cross-reference role.", } ) @@ -457,16 +457,18 @@ def _record_local(name: str, role: object) -> None: def object_path(value: object) -> str: - """Return a best-effort dotted import path for an arbitrary object. + """Return a ``{py:obj}`` cross-reference for an arbitrary object. + + Uses the ``~`` prefix so Sphinx renders just the short name as link text. Examples -------- >>> object_path(RecorderApp) - '`package_reference.RecorderApp`' + '{py:obj}`~package_reference.RecorderApp`' """ module_name = getattr(value, "__module__", type(value).__module__) object_name = getattr(value, "__name__", type(value).__name__) - return f"`{module_name}.{object_name}`" + return f"{{py:obj}}`~{module_name}.{object_name}`" def unique_by_name(items: list[dict[str, str]]) -> list[dict[str, str]]: From c71680d9c8f392aa4e3bf2d2bf243be6cf95aac7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 16:01:31 -0500 Subject: [PATCH 48/57] feat(package_reference): Link callable column via py domain cross-references why: The Callable column showed full dotted paths as plain code. With py:obj roles resolved to the py domain, they now render as clickable short names. what: - Add _register_extension_objects() to populate py domain from workspace extension setup() calls on env-check-consistency (after all docs are read but before the write phase resolves xrefs) - Use env-check-consistency not env-before-read-docs: clear_doc() wipes domain entries whose docname matches the page being re-read, making pre-registration in env-before-read-docs invisible to the resolver - Add parametrized test_register_extension_objects_populates_py_domain covering directives and role classes for multiple packages - Update object_path() docstring and doctest to reflect {py:obj} markup --- docs/_ext/package_reference.py | 82 +++++++++++++++++++++++++++++++++ tests/test_package_reference.py | 58 +++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index cb715203..7858aec2 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -751,6 +751,87 @@ def workspace_package_grid_markdown() -> str: return "\n".join(lines) +def _register_extension_objects( + app: t.Any, + env: t.Any, +) -> None: + """Populate the Sphinx py domain so {py:obj} callables resolve as links. + + Runs on ``env-check-consistency`` — after all source files are read and + ``clear_doc()`` calls are complete, but before the write phase resolves + cross-references. Registering earlier (e.g. ``env-before-read-docs``) + fails because ``clear_doc()`` wipes domain entries whose docname matches + the page being re-read. + + Examples + -------- + >>> class _MockPyDomain: + ... objects: dict[str, object] = {} + >>> class _MockEnv: + ... domains: dict[str, object] = {"py": _MockPyDomain()} + >>> _register_extension_objects(None, _MockEnv()) + >>> "sphinx_autodoc_docutils._directives.AutoDirective" in _MockPyDomain.objects + True + """ + try: + from sphinx.domains.python import ObjectEntry + + py_domain = env.domains["py"] + except (KeyError, AttributeError, ImportError): + return + + for package in workspace_packages(): + module_name = package["module_name"] + pkg_docname = f"packages/{package['name']}" + + try: + module = importlib.import_module(module_name) + except ImportError: + continue + + setup_fn = getattr(module, "setup", None) + if not callable(setup_fn): + continue + + recorder = RecorderApp() + try: + setup_fn(recorder) + except Exception: + continue + + for call_name, args, _kwargs in recorder.calls: + obj: object = None + objtype = "class" + if call_name == "add_directive" and len(args) >= 2: + obj, objtype = args[1], "class" + elif call_name == "add_directive_to_domain" and len(args) >= 3: + obj, objtype = args[2], "class" + elif call_name == "add_role" and len(args) >= 2: + obj = args[1] + objtype = "function" if not inspect.isclass(obj) else "class" + elif call_name == "add_role_to_domain" and len(args) >= 3: + obj = args[2] + objtype = "function" if not inspect.isclass(obj) else "class" + elif call_name == "add_lexer" and len(args) >= 2: + obj, objtype = args[1], "class" + + if obj is None: + continue + + mod = getattr(obj, "__module__", None) or type(obj).__module__ + name = getattr(obj, "__name__", None) or type(obj).__name__ + full_name = f"{mod}.{name}" + if full_name in py_domain.objects: + continue + node_id = full_name.replace(".", "-") + py_domain.objects[full_name] = ObjectEntry( + docname=pkg_docname, + node_id=node_id, + objtype=objtype, + aliased=False, + ) + + class PackageReferenceDirective(SphinxDirective): """Render a generated package reference block inside a page.""" @@ -784,6 +865,7 @@ def setup(app: t.Any) -> dict[str, object]: ensure_workspace_imports() app.add_directive("package-reference", PackageReferenceDirective) app.add_directive("workspace-package-grid", WorkspacePackageGridDirective) + app.connect("env-check-consistency", _register_extension_objects) return { "parallel_read_safe": True, "parallel_write_safe": True, diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index b6969881..cdc59859 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -189,6 +189,64 @@ def test_workspace_package_grid_markdown_structure( assert (substring in output) == present +class DomainRegistrationFixture(t.NamedTuple): + """Expected py-domain registration from _register_extension_objects.""" + + test_id: str + full_name: str + expected_objtype: str + expected_docname: str + + +DOMAIN_REGISTRATION_FIXTURES: list[DomainRegistrationFixture] = [ + DomainRegistrationFixture( + test_id="autodirective_class", + full_name="sphinx_autodoc_docutils._directives.AutoDirective", + expected_objtype="class", + expected_docname="packages/sphinx-autodoc-docutils", + ), + DomainRegistrationFixture( + test_id="autorole_class", + full_name="sphinx_autodoc_docutils._directives.AutoRole", + expected_objtype="class", + expected_docname="packages/sphinx-autodoc-docutils", + ), + DomainRegistrationFixture( + test_id="sphinx_autoconfigvalue_class", + full_name="sphinx_autodoc_sphinx._directives.AutoconfigvalueDirective", + expected_objtype="class", + expected_docname="packages/sphinx-autodoc-sphinx", + ), +] + + +@pytest.mark.parametrize( + list(DomainRegistrationFixture._fields), + DOMAIN_REGISTRATION_FIXTURES, + ids=[f.test_id for f in DOMAIN_REGISTRATION_FIXTURES], +) +def test_register_extension_objects_populates_py_domain( + test_id: str, + full_name: str, + expected_objtype: str, + expected_docname: str, +) -> None: + """_register_extension_objects writes extension classes into the py domain dict.""" + + class _MockPyDomain: + objects: dict[str, t.Any] = {} + + class _MockEnv: + domains: dict[str, object] = {"py": _MockPyDomain()} + + package_reference._register_extension_objects(None, _MockEnv()) + + assert full_name in _MockPyDomain.objects, f"{full_name!r} not registered" + entry = _MockPyDomain.objects[full_name] + assert entry.objtype == expected_objtype + assert entry.docname == expected_docname + + def test_workspace_package_grid_markdown_badge_not_in_card_titles() -> None: """Maturity badges appear in the card footer, not in card title lines.""" output = package_reference.workspace_package_grid_markdown() From 3648ac499feb5b2d07db685644b8b579d3d75ca2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 17:07:39 -0500 Subject: [PATCH 49/57] fix(package_reference): Register objects from all extension submodules why: _register_extension_objects() only ran setup() on the base module, missing roles/lexers/directives from submodules like sphinx_argparse_neo.exemplar. Also missed docutils roles registered via register_local_role() rather than app.add_role() (e.g. cli_option_role in sphinx_argparse_neo.exemplar). what: - Loop over extension_modules() (all submodules with setup()) not just base module - Patch docutils register_local_role/register_canonical_role during setup() to capture docutils-registered roles alongside app.add_role() calls - Add exemplar_role_from_submodule fixture to domain registration tests --- docs/_ext/package_reference.py | 95 ++++++++++++++++++--------------- tests/test_package_reference.py | 6 +++ 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 7858aec2..3c887293 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -781,55 +781,66 @@ def _register_extension_objects( return for package in workspace_packages(): - module_name = package["module_name"] pkg_docname = f"packages/{package['name']}" - try: - module = importlib.import_module(module_name) - except ImportError: - continue + for ext_module_name in extension_modules(package["module_name"]): + try: + module = importlib.import_module(ext_module_name) + except ImportError: + continue - setup_fn = getattr(module, "setup", None) - if not callable(setup_fn): - continue + setup_fn = getattr(module, "setup", None) + if not callable(setup_fn): + continue - recorder = RecorderApp() - try: - setup_fn(recorder) - except Exception: - continue + recorder = RecorderApp() + docutils_roles: list[tuple[str, object]] = [] + original_local = roles.register_local_role + original_canonical = roles.register_canonical_role - for call_name, args, _kwargs in recorder.calls: - obj: object = None - objtype = "class" - if call_name == "add_directive" and len(args) >= 2: - obj, objtype = args[1], "class" - elif call_name == "add_directive_to_domain" and len(args) >= 3: - obj, objtype = args[2], "class" - elif call_name == "add_role" and len(args) >= 2: - obj = args[1] - objtype = "function" if not inspect.isclass(obj) else "class" - elif call_name == "add_role_to_domain" and len(args) >= 3: - obj = args[2] - objtype = "function" if not inspect.isclass(obj) else "class" - elif call_name == "add_lexer" and len(args) >= 2: - obj, objtype = args[1], "class" - - if obj is None: - continue + def _capture(role_name: str, role_fn: object) -> None: + docutils_roles.append((role_name, role_fn)) - mod = getattr(obj, "__module__", None) or type(obj).__module__ - name = getattr(obj, "__name__", None) or type(obj).__name__ - full_name = f"{mod}.{name}" - if full_name in py_domain.objects: + 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 - node_id = full_name.replace(".", "-") - py_domain.objects[full_name] = ObjectEntry( - docname=pkg_docname, - node_id=node_id, - objtype=objtype, - aliased=False, - ) + 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: + if call_name == "add_directive" and len(args) >= 2: + raw_objs.append((args[1], "class")) + elif call_name == "add_directive_to_domain" and len(args) >= 3: + raw_objs.append((args[2], "class")) + elif call_name == "add_role" and len(args) >= 2: + obj = args[1] + raw_objs.append((obj, "function" if not inspect.isclass(obj) else "class")) + elif call_name == "add_role_to_domain" and len(args) >= 3: + obj = args[2] + raw_objs.append((obj, "function" if not inspect.isclass(obj) else "class")) + 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__ + name = getattr(obj, "__name__", None) or type(obj).__name__ + full_name = f"{mod}.{name}" + if full_name in py_domain.objects: + continue + node_id = full_name.replace(".", "-") + py_domain.objects[full_name] = ObjectEntry( + docname=pkg_docname, + node_id=node_id, + objtype=objtype, + aliased=False, + ) class PackageReferenceDirective(SphinxDirective): diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index cdc59859..56b0b544 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -217,6 +217,12 @@ class DomainRegistrationFixture(t.NamedTuple): expected_objtype="class", expected_docname="packages/sphinx-autodoc-sphinx", ), + DomainRegistrationFixture( + test_id="exemplar_role_from_submodule", + full_name="sphinx_argparse_neo.roles.cli_option_role", + expected_objtype="function", + expected_docname="packages/sphinx-argparse-neo", + ), ] From 29e0dc84dea51804378ffa79ad95bd5fe7c76413 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 17:41:13 -0500 Subject: [PATCH 50/57] fix(package_reference): Bind docutils_roles in _capture default arg to fix B023 why: _capture was defined inside a for-loop body and closed over docutils_roles without binding it, creating a latent late-binding closure bug (ruff B023). Also fix RUF012 on _MockPyDomain/_MockEnv mock classes in the test. what: - Add _roles: list[tuple[str, object]] = docutils_roles default arg to _capture - Annotate mock class attributes with t.ClassVar in test_package_reference.py --- docs/_ext/package_reference.py | 20 +++++++++++++++----- tests/test_package_reference.py | 4 ++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 3c887293..c37937df 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -798,8 +798,12 @@ def _register_extension_objects( original_local = roles.register_local_role original_canonical = roles.register_canonical_role - def _capture(role_name: str, role_fn: object) -> None: - docutils_roles.append((role_name, role_fn)) + 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) @@ -819,14 +823,20 @@ def _capture(role_name: str, role_fn: object) -> None: raw_objs.append((args[2], "class")) elif call_name == "add_role" and len(args) >= 2: obj = args[1] - raw_objs.append((obj, "function" if not inspect.isclass(obj) else "class")) + raw_objs.append( + (obj, "function" if not inspect.isclass(obj) else "class") + ) elif call_name == "add_role_to_domain" and len(args) >= 3: obj = args[2] - raw_objs.append((obj, "function" if not inspect.isclass(obj) else "class")) + raw_objs.append( + (obj, "function" if not inspect.isclass(obj) else "class") + ) 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")) + 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/tests/test_package_reference.py b/tests/test_package_reference.py index 56b0b544..be28fa8b 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -240,10 +240,10 @@ def test_register_extension_objects_populates_py_domain( """_register_extension_objects writes extension classes into the py domain dict.""" class _MockPyDomain: - objects: dict[str, t.Any] = {} + objects: t.ClassVar[dict[str, t.Any]] = {} class _MockEnv: - domains: dict[str, object] = {"py": _MockPyDomain()} + domains: t.ClassVar[dict[str, object]] = {"py": _MockPyDomain()} package_reference._register_extension_objects(None, _MockEnv()) From babfe29f8cfd5415f12719f565c9670dc1a00374 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 17:41:36 -0500 Subject: [PATCH 51/57] docs(package_reference): Fix stale _MockApp reference in module docstring --- docs/_ext/package_reference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index c37937df..77e7d607 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -29,7 +29,7 @@ Extending the surface extractor -------------------------------- To capture a new ``app.add_*`` call, add a handler to the mock -``_MockApp`` class inside ``collect_extension_surface()``. Follow the pattern +``RecorderApp`` class inside ``collect_extension_surface()``. Follow the pattern of the existing ``add_directive`` / ``add_role`` handlers. Examples From c50dff7cac830a1299f7ee890282221eb00c9afe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 17:54:18 -0500 Subject: [PATCH 52/57] docs: Replace plain backtick function refs with {py:func} cross-references why: Inline mentions of merge_sphinx_config(), setup(), etc. were unlinked plain code; MyST {py:func} roles make them navigable cross-references. what: - Add autofunction entry for gp_sphinx.config.setup to api.md - Link gp_sphinx.config.merge_sphinx_config in configuration.md, index.md, packages/gp-sphinx.md, packages/sphinx-gptheme.md, and api.md prose - Link gp_sphinx.config.setup in configuration.md (intro paragraph + table) --- docs/api.md | 8 +++++++- docs/configuration.md | 8 ++++---- docs/index.md | 2 +- docs/packages/gp-sphinx.md | 2 +- docs/packages/sphinx-gptheme.md | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/api.md b/docs/api.md index b70d8e43..b61898a2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,7 +18,7 @@ For shared defaults and configuration options, see {doc}`configuration`. ### Wiring into conf.py -Pass the resolver to `merge_sphinx_config()` via `**overrides`. +Pass the resolver to {py:func}`~gp_sphinx.config.merge_sphinx_config` via `**overrides`. `sphinx.ext.linkcode` is auto-appended to extensions when `linkcode_resolve` is provided: @@ -44,3 +44,9 @@ globals().update(conf) ```{eval-rst} .. autofunction:: gp_sphinx.config.deep_merge ``` + +## setup + +```{eval-rst} +.. autofunction:: gp_sphinx.config.setup +``` diff --git a/docs/configuration.md b/docs/configuration.md index 19c58d6d..f7495f86 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,7 +2,7 @@ # Configuration -Reference for `gp_sphinx.config.merge_sphinx_config()` and the shared defaults +Reference for {py:func}`gp_sphinx.config.merge_sphinx_config` and the shared defaults it applies. ## Integration pattern @@ -19,7 +19,7 @@ conf = merge_sphinx_config( globals().update(conf) ``` -`merge_sphinx_config()` returns a flat dictionary meant to be injected into the +{py:func}`~gp_sphinx.config.merge_sphinx_config` returns a flat dictionary meant to be injected into the module namespace with `globals().update(conf)`. That is the conventional Sphinx integration point: Sphinx reads `conf.py` globals directly, and the returned mapping already includes the coordinator’s generated `setup(app)` hook. @@ -72,7 +72,7 @@ already present. ## Injected `setup(app)` The returned config includes a `setup(app)` function from -`gp_sphinx.config.setup`. It does two things: +{py:func}`gp_sphinx.config.setup`. It does two things: | Action | Effect | | --- | --- | @@ -92,7 +92,7 @@ These are injected even though they are not exposed as `DEFAULT_*` constants: | `rediraffe_redirects` | `{}` | | `rediraffe_branch` | `"master~1"` | | `exclude_patterns` | `["_build"]` | -| `setup` | `gp_sphinx.config.setup` | +| `setup` | {py:func}`gp_sphinx.config.setup` | ## Shared `DEFAULT_*` constants diff --git a/docs/index.md b/docs/index.md index 27d3f3f2..5cbde6c4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ Seven workspace packages — coordinator, extensions, and theme. :::{grid-item-card} Configuration :link: configuration :link-type: doc -Parameter reference for `merge_sphinx_config()` and shared defaults. +Parameter reference for {py:func}`~gp_sphinx.config.merge_sphinx_config` and shared defaults. ::: :::: diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx.md index 05132940..e20286a7 100644 --- a/docs/packages/gp-sphinx.md +++ b/docs/packages/gp-sphinx.md @@ -2,7 +2,7 @@ {bdg-warning-line}`Alpha` -Shared configuration coordinator for Sphinx projects. `merge_sphinx_config()` +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 per-project overrides in one place. diff --git a/docs/packages/sphinx-gptheme.md b/docs/packages/sphinx-gptheme.md index b1089c69..5d962f59 100644 --- a/docs/packages/sphinx-gptheme.md +++ b/docs/packages/sphinx-gptheme.md @@ -74,7 +74,7 @@ Options declared in `theme.conf` and accepted through `html_theme_options`: ## Relationship to gp-sphinx -`gp-sphinx` sets this theme automatically via `merge_sphinx_config()` and +`gp-sphinx` sets this theme automatically via {py:func}`~gp_sphinx.config.merge_sphinx_config` and pre-populates `source_repository`, `source_branch`, `source_directory`, footer icons, and the IBM Plex font stacks consumed by the theme templates. From 86f3df202655f94980ead6f70f347ec717ab00d4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 18:03:18 -0500 Subject: [PATCH 53/57] chore(docs/conf): Enable intersphinx unconditionally for Python and Sphinx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: intersphinx_mapping was gated behind GP_SPHINX_ENABLE_INTERSPHINX=1 with no real benefit — Sphinx caches objects.inv locally after first fetch, and CI already has network access. Remove the gate and always map both targets. what: - Remove os import and env-var conditional - Always enable py (docs.python.org) and sphinx (sphinx-doc.org) mappings - Use /3/ suffix on Python URL for stable version --- docs/conf.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 618be1be..a87f0b34 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os import pathlib import sys @@ -23,12 +22,10 @@ import gp_sphinx # noqa: E402 from gp_sphinx.config import merge_sphinx_config # noqa: E402 -intersphinx_mapping = {} -if os.environ.get("GP_SPHINX_ENABLE_INTERSPHINX") == "1": - intersphinx_mapping = { - "py": ("https://docs.python.org/", None), - "sphinx": ("https://www.sphinx-doc.org/en/master/", None), - } +intersphinx_mapping = { + "py": ("https://docs.python.org/3/", None), + "sphinx": ("https://www.sphinx-doc.org/en/master/", None), +} conf = merge_sphinx_config( project=gp_sphinx.__title__, From 9eaec87c60b3492eb2d2ab37ea1ffc1a5df1794f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 18:03:24 -0500 Subject: [PATCH 54/57] docs: Link sphinx.ext.linkcode and add_config_value via intersphinx why: Now that intersphinx is always enabled we can cross-reference Sphinx's own API docs from prose that mentions those symbols. what: - sphinx-autodoc-sphinx.md: link add_config_value via {py:meth}sphinx:~... - configuration.md: link sphinx.ext.linkcode via {py:mod}sphinx:... - packages/gp-sphinx.md: link sphinx.ext.linkcode - api.md: link sphinx.ext.linkcode in make_linkcode_resolve prose --- docs/api.md | 2 +- docs/configuration.md | 2 +- docs/packages/gp-sphinx.md | 2 +- docs/packages/sphinx-autodoc-sphinx.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index b61898a2..14f09ada 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,7 +19,7 @@ For shared defaults and configuration options, see {doc}`configuration`. ### Wiring into conf.py Pass the resolver to {py:func}`~gp_sphinx.config.merge_sphinx_config` via `**overrides`. -`sphinx.ext.linkcode` is auto-appended to extensions when `linkcode_resolve` +{py:mod}`sphinx:sphinx.ext.linkcode` is auto-appended to extensions when `linkcode_resolve` is provided: ```python diff --git a/docs/configuration.md b/docs/configuration.md index f7495f86..b994ab09 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,7 +66,7 @@ All parameters are keyword-only. ### From `**overrides` If `linkcode_resolve` is present in `**overrides`, `merge_sphinx_config()` -automatically appends `sphinx.ext.linkcode` to `extensions` if it is not +automatically appends {py:mod}`sphinx:sphinx.ext.linkcode` to `extensions` if it is not already present. ## Injected `setup(app)` diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx.md index e20286a7..fe81bc61 100644 --- a/docs/packages/gp-sphinx.md +++ b/docs/packages/gp-sphinx.md @@ -41,7 +41,7 @@ globals().update(conf) - Shared extension defaults, theme defaults, fonts, MyST, napoleon, copybutton, and rediraffe settings. - Auto-computed values like `issue_url_tpl`, `ogp_site_url`, `ogp_site_name`, and `ogp_image` when repository and docs URLs are provided. - A `setup(app)` hook that registers `js/spa-nav.js` and removes `tabs.js` after HTML builds. -- Support for appending `sphinx.ext.linkcode` automatically when `linkcode_resolve` is supplied in `**overrides`. +- Support for appending {py:mod}`sphinx:sphinx.ext.linkcode` automatically when `linkcode_resolve` is supplied in `**overrides`. See {doc}`/configuration` for the complete parameter reference and every shared `DEFAULT_*` constant. diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md index 6d6185c6..a77402bd 100644 --- a/docs/packages/sphinx-autodoc-sphinx.md +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -4,7 +4,7 @@ Experimental Sphinx extension for documenting config values registered by extension `setup()` hooks. It takes the repetitive part of `conf.py` -reference-writing, records `app.add_config_value()` calls, and renders them as +reference-writing, records {py:meth}`sphinx:~sphinx.application.Sphinx.add_config_value` calls, and renders them as live `confval` entries and summary indexes. ```console From 610a4f67c0969222eefe6064f6d279409846611a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 19:53:40 -0500 Subject: [PATCH 55/57] docs(fix[config]): Convert config.py docstrings to NumPy style why: CLAUDE.md mandates NumPy docstring style; deep_merge, make_linkcode_resolve, and merge_sphinx_config used Sphinx :param:/:type:/:rtype: format instead. what: - Convert deep_merge docstring to NumPy Parameters/Returns sections - Convert make_linkcode_resolve docstring to NumPy Parameters/Returns sections - Convert merge_sphinx_config docstring to NumPy Parameters/Returns sections --- packages/gp-sphinx/src/gp_sphinx/config.py | 104 +++++++++++---------- 1 file changed, 57 insertions(+), 47 deletions(-) diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 1694256d..a7269b2d 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -80,13 +80,17 @@ def deep_merge(base: dict[str, t.Any], override: dict[str, t.Any]) -> dict[str, When both values for a key are dicts, they are merged recursively. Otherwise the value from *override* wins. - :param base: The base dictionary. - :type base: dict - :param override: The dictionary whose values take precedence. - :type override: dict + Parameters + ---------- + base : dict + The base dictionary. + override : dict + The dictionary whose values take precedence. - :returns: A new merged dictionary. - :rtype: dict + Returns + ------- + dict + A new merged dictionary. Examples -------- @@ -116,18 +120,21 @@ def make_linkcode_resolve( on GitHub. The returned function follows the interface expected by ``sphinx.ext.linkcode``. - :param package_module: The top-level package module - (e.g., ``import libtmux; libtmux``). + Parameters + ---------- + package_module : types.ModuleType + The top-level package module (e.g., ``import libtmux; libtmux``). Used to compute relative file paths. - :type package_module: types.ModuleType - :param github_url: Base GitHub repository URL (e.g., + github_url : str + Base GitHub repository URL (e.g., ``"https://github.com/tmux-python/libtmux"``). - :type github_url: str - :param src_dir: Directory containing the source package (default ``"src"``). - :type src_dir: str + src_dir : str + Directory containing the source package (default ``"src"``). - :returns: A function suitable for ``linkcode_resolve`` in Sphinx config. - :rtype: Callable[[str, dict[str, str]], str | None] + Returns + ------- + Callable[[str, dict[str, str]], str | None] + A function suitable for ``linkcode_resolve`` in Sphinx config. Examples -------- @@ -229,40 +236,43 @@ def merge_sphinx_config( for ``sphinxext.opengraph``. All auto-computed values can be overridden via ``overrides``. - :param project: Sphinx project name. - :type project: str - :param version: Project version string. - :type version: str - :param copyright: Copyright string. - :type copyright: str - :param extensions: Replace the default extension list entirely. Usually not needed. - :type extensions: list[str] | None - :param extra_extensions: Add extensions to the defaults - (e.g., ``["sphinx_argparse_neo.exemplar"]``). - :type extra_extensions: list[str] | None - :param remove_extensions: Remove specific defaults (e.g., ``["sphinx_design"]``). - :type remove_extensions: list[str] | None - :param theme_options: Deep-merged with default theme options. - :type theme_options: dict | None - :param source_repository: GitHub repository URL. - :type source_repository: str | None - :param source_branch: Default branch name. - :type source_branch: str - :param light_logo: Path to light-mode logo. - :type light_logo: str | None - :param dark_logo: Path to dark-mode logo. - :type dark_logo: str | None - :param docs_url: Documentation site URL (e.g., ``"https://libtmux.git-pull.com"``). + Parameters + ---------- + project : str + Sphinx project name. + version : str + Project version string. + copyright : str + Copyright string. + 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"]``). + remove_extensions : list[str] | None + Remove specific defaults (e.g., ``["sphinx_design"]``). + theme_options : dict | None + Deep-merged with default theme options. + source_repository : str | None + GitHub repository URL. + source_branch : str + Default branch name. + light_logo : str | None + Path to light-mode logo. + dark_logo : str | None + Path to dark-mode logo. + docs_url : str | None + Documentation site URL (e.g., ``"https://libtmux.git-pull.com"``). Used to auto-compute ``ogp_site_url`` and ``ogp_site_name``. - :type docs_url: str | None - :param intersphinx_mapping: Intersphinx targets. - :type intersphinx_mapping: dict | None - :param overrides: Any additional Sphinx config values. - :type overrides: dict - - :returns: Complete Sphinx configuration namespace including a ``setup`` + intersphinx_mapping : dict | None + Intersphinx targets. + **overrides : Any + Any additional Sphinx config values. + + Returns + ------- + dict[str, Any] + Complete Sphinx configuration namespace including a ``setup`` function for workaround hooks. - :rtype: dict[str, Any] Examples -------- From caa7d7232fe35b92f64009cab3f5d703647c0764 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 19:54:06 -0500 Subject: [PATCH 56/57] test(fix[testpaths]): Add sphinx-autodoc-sphinx to testpaths why: 21 doctests in sphinx_autodoc_sphinx/_directives.py were never collected because packages/sphinx-autodoc-sphinx/src was absent from testpaths. what: - Add packages/sphinx-autodoc-sphinx/src to pytest testpaths in pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index bbb8218d..42558aa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -162,6 +162,7 @@ testpaths = [ "packages/gp-sphinx/src", "packages/sphinx-fonts/src", "packages/sphinx-gptheme/src", + "packages/sphinx-autodoc-sphinx/src", ] filterwarnings = [ "ignore:distutils Version classes are deprecated. Use packaging.version instead.", From e655064f78ca432dde3213cd2060732a26835b6b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Apr 2026 19:55:01 -0500 Subject: [PATCH 57/57] fix(config[source_branch]): Change source_branch default from "master" to "main" why: Repo default branch is main; "master" default caused broken "Edit on GitHub" and "View source" links on every docs page. what: - Change source_branch parameter default in merge_sphinx_config() to "main" - Change DEFAULT_THEME_OPTIONS["source_branch"] seed value to "main" - Update docs/conf.py source_branch kwarg to "main" - Update configuration.md table rows to reflect new defaults --- docs/conf.py | 2 +- docs/configuration.md | 4 ++-- packages/gp-sphinx/src/gp_sphinx/config.py | 2 +- packages/gp-sphinx/src/gp_sphinx/defaults.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index a87f0b34..7a4e8d13 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,7 +33,7 @@ copyright=gp_sphinx.__copyright__, source_repository=f"{gp_sphinx.__github__}/", docs_url=gp_sphinx.__docs__, - source_branch="master", + source_branch="main", extra_extensions=[ "package_reference", "sphinx_autodoc_pytest_fixtures", diff --git a/docs/configuration.md b/docs/configuration.md index b994ab09..f3bcb791 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,7 +38,7 @@ All parameters are keyword-only. | `remove_extensions` | `list[str] \| None` | `None` | Extensions removed from the selected base list | | `theme_options` | `dict[str, Any] \| None` | `None` | Deep-merged into `DEFAULT_THEME_OPTIONS` after auto-populated source/logo values | | `source_repository` | `str \| None` | `None` | GitHub repository URL used for issue links, footer icon URLs, and theme source metadata | -| `source_branch` | `str` | `"master"` | Source branch stored in `html_theme_options["source_branch"]` | +| `source_branch` | `str` | `"main"` | Source branch stored in `html_theme_options["source_branch"]` | | `light_logo` | `str \| None` | `None` | Light-mode logo path merged into theme options | | `dark_logo` | `str \| None` | `None` | Dark-mode logo path merged into theme options | | `docs_url` | `str \| None` | `None` | Canonical docs URL used to derive Open Graph settings | @@ -112,7 +112,7 @@ These are injected even though they are not exposed as `DEFAULT_*` constants: | Constant | Value | | --- | --- | | `DEFAULT_THEME` | `"sphinx-gptheme"` | -| `DEFAULT_THEME_OPTIONS` | footer GitHub icon, `source_repository=""`, `source_branch="master"`, `source_directory="docs/"` | +| `DEFAULT_THEME_OPTIONS` | footer GitHub icon, `source_repository=""`, `source_branch="main"`, `source_directory="docs/"` | ### Font defaults diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index a7269b2d..59d1c573 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -214,7 +214,7 @@ def merge_sphinx_config( remove_extensions: list[str] | None = None, theme_options: dict[str, t.Any] | None = None, source_repository: str | None = None, - source_branch: str = "master", + source_branch: str = "main", light_logo: str | None = None, dark_logo: str | None = None, docs_url: str | None = None, diff --git a/packages/gp-sphinx/src/gp_sphinx/defaults.py b/packages/gp-sphinx/src/gp_sphinx/defaults.py index 1de8f836..6f16b6f0 100644 --- a/packages/gp-sphinx/src/gp_sphinx/defaults.py +++ b/packages/gp-sphinx/src/gp_sphinx/defaults.py @@ -115,7 +115,7 @@ class FontConfig(_FontConfigRequired, total=False): }, ], "source_repository": "", - "source_branch": "master", + "source_branch": "main", "source_directory": "docs/", } """Default Furo theme options.