diff --git a/docs/conf.py b/docs/conf.py index c2cecc510..8d2bae130 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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 diff --git a/docs/extensions/conf.py b/docs/extensions/conf.py new file mode 100644 index 000000000..308d28be2 --- /dev/null +++ b/docs/extensions/conf.py @@ -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, + } diff --git a/poetry.lock b/poetry.lock index 6f0523a42..615598d67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 0058c7f9b..b546b4dc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]