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/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/package_reference.py b/docs/_ext/package_reference.py new file mode 100644 index 00000000..77e7d607 --- /dev/null +++ b/docs/_ext/package_reference.py @@ -0,0 +1,894 @@ +"""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 +``RecorderApp`` class inside ``collect_extension_surface()``. Follow the pattern +of the existing ``add_directive`` / ``add_role`` handlers. + +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 logging +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] + +logger = logging.getLogger(__name__) + + +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() + 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) + + 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}."): + 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 + + +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() + 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() + registered_roles: list[tuple[str, object]] = [] + original_local = roles.register_local_role + original_canonical = roles.register_canonical_role + + def _record_local(name: str, role: object) -> None: + registered_roles.append((name, role)) + + # Temporarily replace the two docutils global role-registration functions so + # that any role registered by setup(app) is captured in registered_roles. + # The try/finally guarantees restoration even if setup() raises. + # Limitation: this mutates process-global state and is not safe for + # parallel Sphinx builds (sphinx -j N); single-threaded builds only. + try: + roles.register_local_role = t.cast(t.Any, _record_local) + roles.register_canonical_role = t.cast(t.Any, _record_local) + setup = t.cast(t.Callable[[object], object], getattr(module, "setup")) + setup(app) + finally: + roles.register_local_role = original_local + roles.register_canonical_role = original_canonical + + 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) < 1: + continue + option = str(args[0]) + 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( + { + "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": "{py:meth}`~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": "{py:meth}`~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 ``{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) + '{py:obj}`~package_reference.RecorderApp`' + """ + module_name = getattr(value, "__module__", type(value).__module__) + object_name = getattr(value, "__name__", type(value).__name__) + return f"{{py:obj}}`~{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. + + 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), + 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 = [ + 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/main/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 + >>> "+++" 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']}", + f":link: {package['name']}", + ":link-type: doc", + "", + str(package["description"]), + "", + "+++", + maturity_badge(package["maturity"]), + ":::", + "", + ] + ) + lines.append("::::") + 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(): + pkg_docname = f"packages/{package['name']}" + + 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 + + recorder = RecorderApp() + docutils_roles: list[tuple[str, object]] = [] + original_local = roles.register_local_role + original_canonical = roles.register_canonical_role + + def _capture( + role_name: str, + role_fn: object, + _roles: list[tuple[str, object]] = docutils_roles, + ) -> None: + _roles.append((role_name, role_fn)) + + try: + roles.register_local_role = t.cast(t.Any, _capture) + roles.register_canonical_role = t.cast(t.Any, _capture) + setup_fn(recorder) + except Exception: + continue + finally: + roles.register_local_role = original_local + roles.register_canonical_role = original_canonical + + raw_objs: list[tuple[object, str]] = [] # (obj, objtype) + for call_name, args, _kwargs in recorder.calls: + 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): + """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) + app.connect("env-check-consistency", _register_extension_objects) + return { + "parallel_read_safe": True, + "parallel_write_safe": True, + "version": "0.0.1", + } 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, + } diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 90327735..817e0a03 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -232,3 +232,166 @@ 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; +} + +/* ── 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 ──────────────────────────────────────── + * 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: #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: #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: #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) !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) !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. + * 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; + vertical-align: middle; +} diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..14f09ada --- /dev/null +++ b/docs/api.md @@ -0,0 +1,52 @@ +# 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 +``` + +### Wiring into conf.py + +Pass the resolver to {py:func}`~gp_sphinx.config.merge_sphinx_config` via `**overrides`. +{py:mod}`sphinx: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} +.. autofunction:: gp_sphinx.config.deep_merge +``` + +## setup + +```{eval-rst} +.. autofunction:: gp_sphinx.config.setup +``` diff --git a/docs/conf.py b/docs/conf.py index 1dabba35..7a4e8d13 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,27 +11,38 @@ 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 = { + "py": ("https://docs.python.org/3/", None), + "sphinx": ("https://www.sphinx-doc.org/en/master/", None), +} + conf = merge_sphinx_config( project=gp_sphinx.__title__, version=gp_sphinx.__version__, copyright=gp_sphinx.__copyright__, source_repository=f"{gp_sphinx.__github__}/", docs_url=gp_sphinx.__docs__, - source_branch="master", - extra_extensions=["sphinx_autodoc_pytest_fixtures"], + source_branch="main", + 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 new file mode 100644 index 00000000..f3bcb791 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,161 @@ +(configuration)= + +# Configuration + +Reference for {py:func}`gp_sphinx.config.merge_sphinx_config` and the shared defaults +it applies. + +## Integration pattern + +```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) +``` + +{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. + +## `merge_sphinx_config()` parameters + +All parameters are keyword-only. + +| 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` | `"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 | +| `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 | + +## Auto-computed values + +### From `source_repository` + +| Key | Value | +| --- | --- | +| `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 | + +### From `docs_url` + +| Key | Value | +| --- | --- | +| `ogp_site_url` | `docs_url` | +| `ogp_site_name` | `project` | +| `ogp_image` | `"_static/img/icons/icon-192x192.png"` | + +### From `**overrides` + +If `linkcode_resolve` is present in `**overrides`, `merge_sphinx_config()` +automatically appends {py:mod}`sphinx:sphinx.ext.linkcode` to `extensions` if it is not +already present. + +## Injected `setup(app)` + +The returned config includes a `setup(app)` function from +{py:func}`gp_sphinx.config.setup`. It does two things: + +| 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 | + +## Always-set coordinator values + +These are injected even though they are not exposed as `DEFAULT_*` constants: + +| Key | Value | +| --- | --- | +| `master_doc` | `"index"` | +| `release` | `version` | +| `html_theme` | `DEFAULT_THEME` | +| `html_theme_path` | `[]` | +| `rediraffe_redirects` | `{}` | +| `rediraffe_branch` | `"master~1"` | +| `exclude_patterns` | `["_build"]` | +| `setup` | {py:func}`gp_sphinx.config.setup` | + +## Shared `DEFAULT_*` constants + +### Extensions and source parsing + +| 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"]` | + +### Theme defaults + +| Constant | Value | +| --- | --- | +| `DEFAULT_THEME` | `"sphinx-gptheme"` | +| `DEFAULT_THEME_OPTIONS` | footer GitHub icon, `source_repository=""`, `source_branch="main"`, `source_directory="docs/"` | + +### Font defaults + +| Constant | Value | +| --- | --- | +| `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` | + +### 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` | `"\\"` | + +### 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"]` | + +## 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/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/extensions/sphinx-autodoc-pytest-fixtures.md b/docs/extensions/sphinx-autodoc-pytest-fixtures.md deleted file mode 100644 index eb006289..00000000 --- a/docs/extensions/sphinx-autodoc-pytest-fixtures.md +++ /dev/null @@ -1,109 +0,0 @@ -# sphinx-autodoc-pytest-fixtures - -Sphinx extension that documents pytest fixtures as first-class domain objects -with scope badges, dependency tracking, and auto-generated usage snippets. - -## 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 -``` - ---- - -### Plain (FIXTURE badge only) - -Function scope, resource kind, not autouse. Shows only the green FIXTURE badge. - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_plain -``` - ---- - -### Scope badges - -#### Session scope - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_session -``` - -#### Module scope - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_module -``` - -#### Class scope - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_class -``` - ---- - -### Kind badges - -#### Factory kind - -Return type `type[str]` is auto-detected as factory — no explicit `:kind:` needed. - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_factory -``` - -#### Override hook - -Requires explicit `:kind: override_hook` since it cannot be inferred from type. - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_override_hook - :kind: override_hook -``` - ---- - -### State badges - -#### Autouse - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_autouse -``` - -#### Deprecated - -The `deprecated` badge is set via the `:deprecated:` RST option on `py:fixture`. - -```{eval-rst} -.. py:fixture:: demo_deprecated - :deprecated: 1.0 - :replacement: demo_plain - :return-type: str - - Return a deprecated value. Use :fixture:`demo_plain` instead. -``` - ---- - -### Combinations - -#### Session + Factory - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_session_factory -``` - -#### Session + Autouse - -```{eval-rst} -.. autofixture:: spf_demo_fixtures.demo_session_autouse -``` diff --git a/docs/index.md b/docs/index.md index 2a8e0171..5cbde6c4 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. +Seven workspace packages — coordinator, extensions, and theme. +::: + +:::{grid-item-card} Configuration +:link: configuration +:link-type: doc +Parameter reference for {py:func}`~gp_sphinx.config.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..fe81bc61 --- /dev/null +++ b/docs/packages/gp-sphinx.md @@ -0,0 +1,58 @@ +# gp-sphinx + +{bdg-warning-line}`Alpha` + +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. + +```console +$ pip install gp-sphinx +``` + +```console +$ uv add gp-sphinx +``` + +## 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=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) +``` + +## 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 {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. + +:::{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/main/docs/conf.py) +for the exact coordinator call. +::: + +```{package-reference} gp-sphinx +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/gp-sphinx) diff --git a/docs/packages/index.md b/docs/packages/index.md new file mode 100644 index 00000000..2f39dcb8 --- /dev/null +++ b/docs/packages/index.md @@ -0,0 +1,18 @@ +# Packages + +Seven workspace packages, each independently installable. + +```{workspace-package-grid} +``` + +```{toctree} +:hidden: + +gp-sphinx +sphinx-autodoc-docutils +sphinx-autodoc-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..5ed93f8e --- /dev/null +++ b/docs/packages/sphinx-argparse-neo.md @@ -0,0 +1,115 @@ +# sphinx-argparse-neo + +{bdg-success-line}`Beta` + +Modern Sphinx extension for documenting `argparse` CLIs. The base package +registers the `argparse` directive plus renderer config values; the +`sphinx_argparse_neo.exemplar` layer adds example extraction, lexers, and CLI +inline roles. + +```console +$ pip install sphinx-argparse-neo +``` + +## Downstream `conf.py` + +```python +extensions = [ + "sphinx_argparse_neo", + "sphinx_argparse_neo.exemplar", +] + +argparse_examples_section_title = "Examples" +argparse_reorder_usage_before_examples = True +``` + +## Live directive demos + +### Base parser rendering + +```{argparse} +:module: demo_cli +:func: create_parser +:prog: myapp +``` + +### Subcommand rendering + +Drill into a single subcommand with `:path:`: + +```{argparse} +:module: demo_cli +:func: create_parser +:path: mysubcommand +:prog: myapp +``` + +### Inline roles + +The exemplar layer also registers live inline roles for CLI prose: +{cli-command}`myapp`, {cli-option}`--verbose`, {cli-choice}`json`, +{cli-metavar}`DIR`, and {cli-default}`text`. + +## Configuration values + +### Base extension + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_argparse_neo +.. autoconfigvalues:: sphinx_argparse_neo +``` + +### Exemplar layer + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_argparse_neo.exemplar +.. autoconfigvalues:: sphinx_argparse_neo.exemplar +``` + +## Registered directives and roles + +### Base `argparse` directive + +```{eval-rst} +.. autodirective:: sphinx_argparse_neo.directive.ArgparseDirective + :no-index: +``` + +### Exemplar override + +```{eval-rst} +.. autodirective:: sphinx_argparse_neo.exemplar.CleanArgParseDirective +``` + +### CLI role callables + +```{eval-rst} +.. autorole-index:: sphinx_argparse_neo.roles +.. autoroles:: sphinx_argparse_neo.roles +``` + +## Downstream usage snippets + +Use native MyST directives in Markdown: + +````myst +```{argparse} +:module: myproject.cli +:func: create_parser +:prog: myproject +``` +```` + +Or reStructuredText: + +```rst +.. argparse:: + :module: myproject.cli + :func: create_parser + :prog: myproject +``` + +```{package-reference} sphinx-argparse-neo +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-argparse-neo) diff --git a/docs/packages/sphinx-autodoc-docutils.md b/docs/packages/sphinx-autodoc-docutils.md new file mode 100644 index 00000000..a8e83d98 --- /dev/null +++ b/docs/packages/sphinx-autodoc-docutils.md @@ -0,0 +1,119 @@ +# sphinx-autodoc-docutils + +{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; +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: + +````myst +```{eval-rst} +.. autodirective:: my_project.docs_ext.MyDirective +``` +```` + +````myst +```{eval-rst} +.. autorole:: my_project.docs_roles.cli_option_role +``` +```` + +Use the bulk directives to render a full module reference plus an index: + +````myst +```{eval-rst} +.. autodirective-index:: my_project.docs_ext +``` +```` + +````myst +```{eval-rst} +.. autodirectives:: my_project.docs_ext +``` +```` + +````myst +```{eval-rst} +.. autorole-index:: my_project.docs_roles +``` +```` + +````myst +```{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: +``` + +### 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. + +```{package-reference} 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 new file mode 100644 index 00000000..a2765ee5 --- /dev/null +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -0,0 +1,94 @@ +# sphinx-autodoc-pytest-fixtures + +{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 +fixture discovery, and the badge/index UI used throughout the page below. + +```console +$ pip install sphinx-autodoc-pytest-fixtures +``` + +## Downstream `conf.py` + +```python +extensions = ["sphinx_autodoc_pytest_fixtures"] + +pytest_fixture_lint_level = "warning" +pytest_external_fixture_links = { + "db": "https://docs.example.com/testing#db", +} +``` + +## Registered configuration values + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_autodoc_pytest_fixtures +.. autoconfigvalues:: sphinx_autodoc_pytest_fixtures +``` + +## Registered directives and roles + +```{eval-rst} +.. autodirective-index:: sphinx_autodoc_pytest_fixtures +.. autorole-index:: sphinx_autodoc_pytest_fixtures +``` + +## Live demos + +```{py:module} spf_demo_fixtures +``` + +### Fixture index + +```{autofixture-index} spf_demo_fixtures +``` + +### Bulk autodoc + +```{eval-rst} +.. 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} +.. autofixture:: spf_demo_fixtures.demo_plain + :no-index: +``` + +```{eval-rst} +.. autofixture:: spf_demo_fixtures.demo_session_factory + :no-index: +``` + +### Manual domain directive + +```{eval-rst} +.. py:fixture:: demo_deprecated + :no-index: + :deprecated: 1.0 + :replacement: demo_plain + :return-type: str + + Return a deprecated value. Use :fixture:`demo_plain` instead. +``` + +```{package-reference} 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 new file mode 100644 index 00000000..a77402bd --- /dev/null +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -0,0 +1,79 @@ +# sphinx-autodoc-sphinx + +{bdg-warning-line}`Alpha` + +Experimental Sphinx extension for documenting config values registered by +extension `setup()` hooks. It takes the repetitive part of `conf.py` +reference-writing, records {py:meth}`sphinx:~sphinx.application.Sphinx.add_config_value` calls, and renders them as +live `confval` entries and summary indexes. + +```console +$ pip install sphinx-autodoc-sphinx +``` + +## Downstream `conf.py` + +```python +extensions = ["sphinx_autodoc_sphinx"] +``` + +## Working usage examples + +Render one config value: + +````myst +```{eval-rst} +.. autoconfigvalue:: sphinx_fonts.sphinx_font_preload +``` +```` + +Render every config value from an extension module: + +````myst +```{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: +``` + +### 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} +.. 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/main/packages/sphinx-autodoc-sphinx) diff --git a/docs/packages/sphinx-fonts.md b/docs/packages/sphinx-fonts.md new file mode 100644 index 00000000..7f350db3 --- /dev/null +++ b/docs/packages/sphinx-fonts.md @@ -0,0 +1,84 @@ +# sphinx-fonts + +{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 +`_static/fonts/`, and exposes template context values that themes can render as +inline `@font-face` and preload tags. + +```console +$ pip install sphinx-fonts +``` + +## Downstream `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"], + "subset": "latin", + }, +] + +sphinx_font_preload = [ + ("IBM Plex Sans", 400, "normal"), +] + +sphinx_font_css_variables = { + "--font-stack": '"IBM Plex Sans", system-ui, sans-serif', +} +``` + +## 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")

+
+
+``` + +## Configuration values + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_fonts +.. autoconfigvalues:: sphinx_fonts +``` + +## Template context + +The extension injects these values during `html-page-context`: + +| Variable | Type | Description | +| --- | --- | --- | +| `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 | + +## Notes + +- 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/main/packages/sphinx-fonts) diff --git a/docs/packages/sphinx-gptheme.md b/docs/packages/sphinx-gptheme.md new file mode 100644 index 00000000..5d962f59 --- /dev/null +++ b/docs/packages/sphinx-gptheme.md @@ -0,0 +1,84 @@ +# sphinx-gptheme + +{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 +controls, metadata toggles, and SPA-style navigation. + +```console +$ pip install sphinx-gptheme +``` + +## Downstream `conf.py` + +```python +extensions = ["sphinx_gptheme"] +html_theme = "sphinx-gptheme" + +html_theme_options = { + "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/", +} +``` + +## Live theme notes + +- 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. + +## Theme options + +Options declared in `theme.conf` and accepted through `html_theme_options`: + +| 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 | + +## Bundled assets + +| 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 | + +## Relationship to gp-sphinx + +`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. + +```{package-reference} sphinx-gptheme +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gptheme) 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 diff --git a/docs/quickstart.md b/docs/quickstart.md index e78cd064..8c302049 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"], ) ``` @@ -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/ diff --git a/docs/redirects.txt b/docs/redirects.txt index e69de29b..b9497f7f 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -0,0 +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 329d9b5d..59d1c573 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__) @@ -212,14 +214,14 @@ 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, 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 +234,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 ---------- @@ -245,7 +247,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 @@ -263,7 +265,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 Any additional Sphinx config values. Returns @@ -476,3 +478,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/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. 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)] 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-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..1c863857 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -0,0 +1,53 @@ +"""Sphinx extension for documenting docutils directives and roles.""" + +from __future__ import annotations + +import logging +import typing as t + +from sphinx.application import Sphinx + +from sphinx_autodoc_docutils._directives import ( + AutoDirective, + AutoDirectiveIndex, + AutoDirectives, + AutoRole, + AutoRoleIndex, + AutoRoles, +) + +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. + + 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) + 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..6615009c --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -0,0 +1,416 @@ +"""Rendering directives for docutils directive and role documentation.""" + +from __future__ import annotations + +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: # object: wraps inspect.getdoc() + """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] +]: # object: inspect.getmembers() returns heterogeneous values + """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] +]: # object: roles have monkey-patched attrs; no Protocol fits + """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: OptionSpec | None) -> 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 + + +# 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. + + 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}", + " :no-index:" if no_index else "", + "", + f" Validator: ``{clean_converter_name}``.", + "", + ] + ) + return "\n".join(lines) + + +def _role_markup( + 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. + + 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: t.ClassVar[OptionSpec] = {"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: t.ClassVar[OptionSpec] = {"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) if markup else [] + + +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: t.ClassVar[OptionSpec] = {"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: t.ClassVar[OptionSpec] = {"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) if markup else [] + + +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..43ebfb41 --- /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, # object: stricter than Sphinx's Any; unused param + membername: str, + isattr: bool, + parent: object, # object: stricter than Sphinx's Any; unused param + ) -> 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-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. 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-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..d1415b8e --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -0,0 +1,48 @@ +"""Sphinx extension for documenting config values registered by extensions.""" + +from __future__ import annotations + +import logging +import typing as t + +from sphinx.application import Sphinx + +from sphinx_autodoc_sphinx._directives import ( + AutoconfigvalueDirective, + AutoconfigvalueIndexDirective, + AutoconfigvaluesDirective, + AutosphinxconfigIndexDirective, +) + +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. + + 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..09a73382 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -0,0 +1,539 @@ +"""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 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. + + 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 # object: config defaults are genuinely heterogeneous + rebuild: str + types: object = () # object: Sphinx allows ENUM, not just type + 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: # object: universal __getattr__ stub + 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: # object: only calls repr() + """Render a compact literal for a ``:default:`` option. + + Examples + -------- + >>> _render_default(True) + '``True``' + >>> _render_default("demo") + "``'demo'``" + """ + 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 + """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. + + 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) < 1: + continue + name = str(args[0]) + if name in seen: + continue + seen.add(name) + values.append( + SphinxConfigValue( + module_name=module_name, + name=name, + 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 "") + ), + ) + ) + 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. + + 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,)) + >>> 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)}", + ] + if not _is_complex_default(value.default): + lines.append(f" :default: {_render_default(value.default)}") + lines.append("") + if value.description: + lines.extend([f" {value.description}", ""]) + lines.extend( + [ + f" Registered by ``{value.module_name}.setup()``.", + "", + f" Rebuild: ``{value.rebuild or 'none'}``.", + ] + ) + return "\n".join(lines) + + +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) + + +# NOTE: This function is byte-for-byte identical to +# sphinx_autodoc_docutils._directives._render_blocks. Both packages depend +# only on sphinx (not on each other), so a shared location would require a new +# dependency. If a third caller emerges, extract to gp_sphinx._render. +def _render_blocks(directive: SphinxDirective, markup: str) -> list[nodes.Node]: + """Parse generated markup back through Sphinx. + + Examples + -------- + >>> class DummyState: + ... def nested_parse( + ... self, + ... view_list: StringList, + ... offset: int, + ... node: nodes.Element, + ... ) -> None: + ... for line in view_list: + ... node += nodes.paragraph("", line) + >>> class DummyDirective: + ... state = DummyState() + ... content_offset = 0 + ... def get_source_info(self) -> tuple[str, int]: + ... return ("demo.md", 1) + >>> rendered = _render_blocks(DummyDirective(), "demo") # type: ignore[arg-type] + >>> rendered[0].astext() + 'demo' + """ + if hasattr(directive, "parse_text_to_nodes"): + return directive.parse_text_to_nodes(markup) + + source, _line = directive.get_source_info() + view_list: StringList = StringList() + for line in markup.splitlines(): + view_list.append(line, source) + container = nodes.container() + directive.state.nested_parse(view_list, directive.content_offset, container) + return [container] if container.children else [] + + +def _iter_desc_content( + node_list: list[nodes.Node], +) -> t.Iterator[addnodes.desc_content]: + """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.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"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[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + 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): + """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..51fb0a63 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_documenter.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +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, # object: stricter than Sphinx's Any; unused param + membername: str, + isattr: bool, + parent: object, # object: stricter than Sphinx's Any; unused param + ) -> 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. + # 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] 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/packages/sphinx-fonts/src/sphinx_fonts/__init__.py b/packages/sphinx-fonts/src/sphinx_fonts/__init__.py index 16bfaa12..cd8a690c 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 dicts (family, package, version, weights, styles).", + ) + app.add_config_value( + "sphinx_font_fallbacks", + [], + "html", + description="Fallback @font-face declarations with metric overrides for CLS.", + ) + 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 (family, weight, style).", + ) app.connect("builder-inited", _on_builder_inited) app.connect("html-page-context", _on_html_page_context) return { diff --git a/pyproject.toml b/pyproject.toml index 12a2daf1..42558aa6 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:stubs" 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)", @@ -145,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.", 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]]: ... 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..32b4d7e3 --- /dev/null +++ b/tests/ext/autodoc_docutils/test_directives.py @@ -0,0 +1,70 @@ +"""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_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"] + 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..6957d7fc --- /dev/null +++ b/tests/ext/autodoc_sphinx/test_directives.py @@ -0,0 +1,103 @@ +"""Tests for autodoc sphinx config directives.""" + +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, + 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 + + +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) 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 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)), ), diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py new file mode 100644 index 00000000..be28fa8b --- /dev/null +++ b/tests/test_package_reference.py @@ -0,0 +1,264 @@ +"""Tests for the docs package reference helpers.""" + +from __future__ import annotations + +import pathlib +import sys +import typing as t + +import pytest + +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_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") + 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() + 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 + + +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 + + +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", + ), + 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", + ), +] + + +@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: t.ClassVar[dict[str, t.Any]] = {} + + class _MockEnv: + domains: t.ClassVar[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() + 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}" 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"