# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Regenerate Sphinx API docs and dynamic documentation content.
Backs the `update-docs` command: orchestrates `sphinx-apidoc`, the RST-to-MyST
conversion, the project's `docs/docs_update.py` script, and the self-updating
directive-block refresh. Configuration is read from `[tool.repomatic.docs]`.
"""
from __future__ import annotations
import logging
import subprocess
from pathlib import Path
from click_extra import ClickException, convert_rst_files_in_directory, echo
from .metadata import Metadata
from .uv import uv_cmd
TYPE_CHECKING = False
if TYPE_CHECKING:
from .config import Config
[docs]
def validate_docs_script_path(script: str, repo_root: Path) -> Path | None:
"""Validate and resolve a docs update script path.
:param script: Configured `docs.update-script` path, relative to the repo.
:param repo_root: Repository root the script path resolves against.
:return: The resolved path, or `None` when the configured value is empty.
:raises ClickException: If the path escapes the repository root or is not
a `.py` file under `docs/`.
"""
if not script:
return None
script_path = (repo_root / script).resolve()
# Must be under the repository root.
try:
script_path.relative_to(repo_root)
except ValueError:
raise ClickException(f"docs.update-script escapes repository root: {script}")
# Must be under docs/ and be a Python file.
docs_dir = (repo_root / "docs").resolve()
try:
script_path.relative_to(docs_dir)
except ValueError:
raise ClickException(f"docs.update-script must be under docs/: {script}")
if script_path.suffix != ".py":
raise ClickException(f"docs.update-script must be a .py file: {script}")
return script_path
DIRECTIVE_BLOCK_MARKERS: tuple[str, ...] = (
"{matrix}",
"<!-- matrix",
":mirror:",
"<!-- mirror",
)
"""Markers of a self-updating block `click-extra refresh-directives` rewrites.
Every form the refresh recognizes: the ``{matrix}`` MyST fence (live-rendered by
Sphinx), the `<!-- matrix -->` comment region (whose embedded table renders on
GitHub too), and the `python:render` `:mirror:` region (`<!-- mirror -->`, whose
generator Python the refresh executes).
"""
[docs]
def has_directive_block(path: Path) -> bool:
"""Whether *path* carries a self-updating block worth refreshing.
:param path: Markdown file to scan.
:return: `True` when any {data}`DIRECTIVE_BLOCK_MARKERS` entry appears.
"""
text = path.read_text(encoding="UTF-8")
return any(marker in text for marker in DIRECTIVE_BLOCK_MARKERS)
def _run_docs_tool(label: str, *args: str, check: bool = False) -> int:
"""Run a tool from the `docs` dependency group through uv.
Shared invocation shape for every `update-docs` phase: the command runs
with the frozen lockfile and the `docs` group installed, failures raise a
`ClickException` naming the phase, and success is echoed.
:param label: Human-readable phase name for logs and errors.
:param args: The command and its arguments, passed after `uv run --`.
:param check: In drift-detection mode a non-zero exit means "out of date"
rather than "failed", so the code is returned instead of raised and the
caller aggregates drift across phases.
:return: The command's exit code.
"""
cmd = [*uv_cmd("run", frozen=True), "--group", "docs", "--", *args]
logging.info(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, check=False)
if check:
return result.returncode
if result.returncode:
raise ClickException(f"{label} failed with exit code {result.returncode}")
echo(f"{label} completed.")
return result.returncode
[docs]
def update_docs(config: Config, *, check: bool = False) -> None:
"""Regenerate Sphinx autodoc stubs and run the project's update script.
Orchestrates four phases:
1. Run `sphinx-apidoc` to generate RST stubs for all modules.
2. If MyST-Parser is detected, convert the RST stubs to MyST markdown
with ``{eval-rst}`` blocks.
3. Run the project-specific `docs/docs_update.py` script (if present)
to generate dynamic content.
4. Refresh self-updating blocks (``{matrix}`` compatibility tables and
`python:render` `:mirror:` regions) found in `docs/` pages and
`readme.md`, via `click-extra refresh-directives`.
:param config: The resolved `[tool.repomatic]` configuration.
:param check: Report out-of-date content without writing, for CI drift
detection. Phases 1β2 regenerate files and have no dry-run mode, so
they are skipped; the self-updating phases run in their own check modes
(`docs_update.py --check` and `refresh-directives --check`) and any
drift raises a `ClickException`. The update script must accept a
``--check`` flag to participate: a script that ignores it will still
write.
"""
repo_root = Path.cwd()
docs_dir = repo_root / "docs"
# Detect Sphinx capabilities from conf.py.
meta = Metadata()
if not meta.is_sphinx:
logging.info("No Sphinx configuration found. Nothing to do.")
return
# Names of the self-updating phases found out of date, collected in check
# mode and reported at the end.
drift: list[str] = []
# Phases 1-2 write files and have no dry-run mode, so they are skipped when
# only checking for drift.
if not check:
# Phase 1: sphinx-apidoc.
if meta.active_autodoc:
_run_docs_tool(
"sphinx-apidoc",
"sphinx-apidoc",
"--no-toc",
"--module-first",
"--output-dir",
str(docs_dir),
*config.docs.apidoc_extra_args,
".",
*config.docs.apidoc_exclude,
)
else:
logging.info("No active autodoc extensions. Skipping sphinx-apidoc.")
# Phase 2: RST β MyST conversion.
if meta.uses_myst and docs_dir.is_dir():
converted = convert_rst_files_in_directory(docs_dir)
if converted:
echo(f"Converted {len(converted)} RST file(s) to MyST markdown.")
else:
logging.info("No RST files to convert.")
elif not meta.uses_myst:
logging.info("MyST-Parser not detected. Skipping RST conversion.")
# Phase 3: docs update script.
script_path = validate_docs_script_path(config.docs.update_script, repo_root)
if script_path and script_path.is_file():
label = f"Docs update script ({script_path.name})"
script_args = [str(script_path), *(["--check"] if check else [])]
code = _run_docs_tool(label, "python", *script_args, check=check)
if check and code:
drift.append(label)
elif script_path:
logging.info(f"Docs update script not found: {script_path}")
else:
logging.info("Docs update script disabled (empty path).")
# Phase 4: self-updating blocks. Only files already carrying a block are
# passed, so repositories without any stay clear of the sphinx extra that
# `refresh-directives` requires.
candidates = sorted(docs_dir.rglob("*.md")) if docs_dir.is_dir() else []
readme_path = repo_root / "readme.md"
if readme_path.is_file():
candidates.append(readme_path)
directive_files = [path for path in candidates if has_directive_block(path)]
if directive_files:
refresh_args = [
"click-extra",
"refresh-directives",
*(["--check"] if check else []),
*(str(path) for path in directive_files),
]
code = _run_docs_tool("Directive-block refresh", *refresh_args, check=check)
if check and code:
drift.append("self-updating directive blocks")
else:
logging.info("No self-updating directive blocks found. Skipping refresh.")
if check:
if drift:
raise ClickException(
"Documentation is out of date ("
+ ", ".join(drift)
+ "). Run `repomatic update-docs`."
)
echo("Documentation is up to date.")