Add Sphinx extension for configuration value documentation

Create a custom Sphinx extension to document configuration values with
a simplified syntax. It is based on the `confval` but takes less space
when rendered. The extension provides:

- A `conf` directive for documenting individual configuration values
  with optional type and default parameters
- A `conf` role for cross-referencing configuration values
- Automatic formatting of default values in the signature
- A custom domain that handles indexing and cross-references

For example, if we have

.. conf:: search_limit
    :default: 5

We refer to this configuration option with :conf:`plugins.discogs:search_limit`.

The extension is loaded by adding the docs/extensions directory to the
Python path and registering it in the Sphinx extensions list.
This commit is contained in:
Šarūnas Nejus 2025-10-12 00:17:19 +01:00
parent becb073aac
commit a938449b29
No known key found for this signature in database
4 changed files with 173 additions and 5 deletions

View file

@ -6,6 +6,11 @@
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import sys
from pathlib import Path
# Add custom extensions directory to path
sys.path.insert(0, str(Path(__file__).parent / "extensions"))
project = "beets"
AUTHOR = "Adrian Sampson"
@ -26,6 +31,7 @@ extensions = [
"sphinx.ext.viewcode",
"sphinx_design",
"sphinx_copybutton",
"conf",
]
autosummary_generate = True

142
docs/extensions/conf.py Normal file
View file

@ -0,0 +1,142 @@
"""Sphinx extension for simple configuration value documentation."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx import addnodes
from sphinx.directives import ObjectDescription
from sphinx.domains import Domain, ObjType
from sphinx.roles import XRefRole
from sphinx.util.nodes import make_refnode
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from docutils.nodes import Element
from docutils.parsers.rst.states import Inliner
from sphinx.addnodes import desc_signature, pending_xref
from sphinx.application import Sphinx
from sphinx.builders import Builder
from sphinx.environment import BuildEnvironment
from sphinx.util.typing import ExtensionMetadata, OptionSpec
class Conf(ObjectDescription[str]):
"""Directive for documenting a single configuration value."""
option_spec: ClassVar[OptionSpec] = {
"default": directives.unchanged,
}
def handle_signature(self, sig: str, signode: desc_signature) -> str:
"""Process the directive signature (the config name)."""
signode += addnodes.desc_name(sig, sig)
# Add default value if provided
if "default" in self.options:
signode += nodes.Text(" ")
default_container = nodes.inline("", "")
default_container += nodes.Text("(default: ")
default_container += nodes.literal("", self.options["default"])
default_container += nodes.Text(")")
signode += default_container
return sig
def add_target_and_index(
self, name: str, sig: str, signode: desc_signature
) -> None:
"""Add cross-reference target and index entry."""
target = f"conf-{name}"
if target not in self.state.document.ids:
signode["ids"].append(target)
self.state.document.note_explicit_target(signode)
# A unique full name which includes the document name
index_name = f"{self.env.docname.replace('/', '.')}:{name}"
# Register with the conf domain
domain = self.env.get_domain("conf")
domain.data["objects"][index_name] = (self.env.docname, target)
# Add to index
self.indexnode["entries"].append(
("single", f"{name} (configuration value)", target, "", None)
)
class ConfDomain(Domain):
"""Domain for simple configuration values."""
name = "conf"
label = "Simple Configuration"
object_types = {"conf": ObjType("conf", "conf")}
directives = {"conf": Conf}
roles = {"conf": XRefRole()}
initial_data: dict[str, Any] = {"objects": {}}
def get_objects(self) -> Iterable[tuple[str, str, str, str, str, int]]:
"""Return an iterable of object tuples for the inventory."""
for name, (docname, targetname) in self.data["objects"].items():
# Remove the document name prefix for display
display_name = name.split(":")[-1]
yield (name, display_name, "conf", docname, targetname, 1)
def resolve_xref(
self,
env: BuildEnvironment,
fromdocname: str,
builder: Builder,
typ: str,
target: str,
node: pending_xref,
contnode: Element,
) -> Element | None:
if entry := self.data["objects"].get(target):
docname, targetid = entry
return make_refnode(
builder, fromdocname, docname, targetid, contnode
)
return None
# sphinx.util.typing.RoleFunction
def conf_role(
name: str,
rawtext: str,
text: str,
lineno: int,
inliner: Inliner,
/,
options: dict[str, Any] | None = None,
content: Sequence[str] = (),
) -> tuple[list[nodes.Node], list[nodes.system_message]]:
"""Role for referencing configuration values."""
node = addnodes.pending_xref(
"",
refdomain="conf",
reftype="conf",
reftarget=text,
refwarn=True,
**(options or {}),
)
node += nodes.literal(text, text.split(":")[-1])
return [node], []
def setup(app: Sphinx) -> ExtensionMetadata:
app.add_domain(ConfDomain)
# register a top-level directive so users can use ".. conf:: ..."
app.add_directive("conf", Conf)
# Register role with short name
app.add_role("conf", conf_role)
return {
"version": "0.1",
"parallel_read_safe": True,
"parallel_write_safe": True,
}

15
poetry.lock generated
View file

@ -3473,6 +3473,17 @@ files = [
[package.dependencies]
types-html5lib = "*"
[[package]]
name = "types-docutils"
version = "0.22.2.20251006"
description = "Typing stubs for docutils"
optional = false
python-versions = ">=3.9"
files = [
{file = "types_docutils-0.22.2.20251006-py3-none-any.whl", hash = "sha256:1e61afdeb4fab4ae802034deea3e853ced5c9b5e1d156179000cb68c85daf384"},
{file = "types_docutils-0.22.2.20251006.tar.gz", hash = "sha256:c36c0459106eda39e908e9147bcff9dbd88535975cde399433c428a517b9e3b2"},
]
[[package]]
name = "types-flask-cors"
version = "6.0.0.20250520"
@ -3650,7 +3661,7 @@ beatport = ["requests-oauthlib"]
bpd = ["PyGObject"]
chroma = ["pyacoustid"]
discogs = ["python3-discogs-client"]
docs = ["pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"]
docs = ["docutils", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinx-design"]
embedart = ["Pillow"]
embyupdate = ["requests"]
fetchart = ["Pillow", "beautifulsoup4", "langdetect", "requests"]
@ -3672,4 +3683,4 @@ web = ["flask", "flask-cors"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.9,<4"
content-hash = "1db39186aca430ef6f1fd9e51b9dcc3ed91880a458bc21b22d950ed8589fdf5a"
content-hash = "aedfeb1ac78ae0120855c6a7d6f35963c63cc50a8750142c95dd07ffd213683f"

View file

@ -77,10 +77,11 @@ resampy = { version = ">=0.4.3", optional = true }
requests-oauthlib = { version = ">=0.6.1", optional = true }
soco = { version = "*", optional = true }
docutils = { version = ">=0.20.1", optional = true }
pydata-sphinx-theme = { version = "*", optional = true }
sphinx = { version = "*", optional = true }
sphinx-design = { version = "^0.6.1", optional = true }
sphinx-copybutton = { version = "^0.5.2", optional = true }
sphinx-design = { version = ">=0.6.1", optional = true }
sphinx-copybutton = { version = ">=0.5.2", optional = true }
[tool.poetry.group.test.dependencies]
beautifulsoup4 = "*"
@ -109,6 +110,7 @@ sphinx-lint = ">=1.0.0"
[tool.poetry.group.typing.dependencies]
mypy = "*"
types-beautifulsoup4 = "*"
types-docutils = ">=0.22.2.20251006"
types-mock = "*"
types-Flask-Cors = "*"
types-Pillow = "*"
@ -131,7 +133,14 @@ beatport = ["requests-oauthlib"]
bpd = ["PyGObject"] # gobject-introspection, gstreamer1.0-plugins-base, python3-gst-1.0
chroma = ["pyacoustid"] # chromaprint or fpcalc
# convert # ffmpeg
docs = ["pydata-sphinx-theme", "sphinx", "sphinx-lint", "sphinx-design", "sphinx-copybutton"]
docs = [
"docutils",
"pydata-sphinx-theme",
"sphinx",
"sphinx-lint",
"sphinx-design",
"sphinx-copybutton",
]
discogs = ["python3-discogs-client"]
embedart = ["Pillow"] # ImageMagick
embyupdate = ["requests"]