Files
2026-07-13 13:17:40 +08:00

277 lines
9.6 KiB
Python

#!/usr/bin/env python
"""Preflight check that the local environment matches the Read the Docs build.
Ray's docs build on Read the Docs with ``fail_on_warning: true``, so a local
build is only trustworthy when it reproduces RtD's environment. This doctor
compares the active interpreter and installed packages against what RtD uses
and reports any drift before ``make rtd`` spends minutes on a build that
wouldn't be faithful anyway.
The expected values are read from the repo, never hardcoded, so this keeps
working as versions are tweaked:
* Python version <- ``.readthedocs.yaml`` ``build.tools.python``
* pinned deps <- ``doc/requirements-doc.lock.txt`` (the docs lock)
This script is intentionally stdlib-only: it diagnoses a possibly-broken or
mismatched environment, so it must not depend on the very packages it checks
(it uses PyYAML only if it happens to be importable, with a regex fallback).
Exit status: 0 when faithful (or under ``--warn-only``), 1 on critical drift,
2 when the expected values can't be determined (a repo/config problem).
"""
# Annotations are lazy strings so this runs under any interpreter a contributor
# might have active, even one older than the docs build's Python.
from __future__ import annotations
import argparse
import platform
import re
import sys
from importlib import metadata
from pathlib import Path
# Optional: use PyYAML for a structural parse when present, but never depend on
# it — this script must run in an environment that's missing the docs deps.
try:
import yaml
except ImportError:
yaml = None
# doc/rtd_doctor.py -> doc/ -> repo root
DOC_DIR = Path(__file__).resolve().parent
RAY_ROOT = DOC_DIR.parent
RTD_YAML = RAY_ROOT / ".readthedocs.yaml"
DOC_LOCK = DOC_DIR / "requirements-doc.lock.txt"
# RtD runs on this OS (informational only; see .readthedocs.yaml build.os).
RTD_OS = "ubuntu-24.04"
# Packages whose pinned version most directly determines build fidelity. The
# names are stable; their expected versions come from the lock, not from here.
KEY_PACKAGES = ["sphinx", "myst-parser", "myst-nb", "pydata-sphinx-theme"]
# Status tiers. CRITICAL fails the check (and blocks `make rtd`); WARN and INFO
# never do.
CRITICAL, WARN, INFO, OK = "critical", "warn", "info", "ok"
class DoctorError(Exception):
"""The doctor couldn't determine what to check (a repo/config problem)."""
def _normalize(name: str) -> str:
"""Normalize a distribution name per PEP 503 for comparison."""
return re.sub(r"[-_.]+", "-", name).lower()
def read_expected_python() -> str:
"""Return the Python version RtD builds with, from .readthedocs.yaml."""
if not RTD_YAML.is_file():
raise DoctorError(f"{RTD_YAML} not found")
text = RTD_YAML.read_text()
# Prefer a structural parse when PyYAML is available; fall back to a
# targeted scan so the doctor still runs in a stripped-down environment.
if yaml is not None:
try:
data = yaml.safe_load(text)
value = data["build"]["tools"]["python"]
return str(value).strip()
except (KeyError, TypeError, AttributeError):
pass # fall through to the regex scan
# The first `python:` after `tools:` is build.tools.python; the top-level
# `python:` install block appears later in the file.
match = re.search(r"tools:.*?\bpython:\s*[\"']?(\d+(?:\.\d+)?)", text, re.DOTALL)
if not match:
raise DoctorError(f"could not find build.tools.python in {RTD_YAML.name}")
return match.group(1)
def read_lock_versions() -> dict[str, str]:
"""Return {normalized name: version} parsed from the docs lock."""
if not DOC_LOCK.is_file():
raise DoctorError(f"{DOC_LOCK} not found")
versions = {}
# Lock lines look like `sphinx==8.2.3 \`; --hash/# via/--index-url lines
# don't start with a package-name character, so anchoring excludes them.
pin = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s\\]+)")
for line in DOC_LOCK.read_text().splitlines():
match = pin.match(line)
if match:
versions[_normalize(match.group(1))] = match.group(2)
if not versions:
raise DoctorError(f"no pinned versions parsed from {DOC_LOCK.name}")
return versions
def installed_version(name: str) -> str | None:
"""Return the installed version of ``name``, or None if absent."""
try:
return metadata.version(name)
except metadata.PackageNotFoundError:
return None
def lock_target_python() -> str | None:
"""Best-effort Python tag from the resolved lock filename (e.g. '3.11')."""
try:
name = DOC_LOCK.resolve().name
except OSError:
return None
match = re.search(r"py(\d+)\.?(\d+)", name)
return f"{match.group(1)}.{match.group(2)}" if match else None
def build_findings() -> tuple[list[tuple[str, str, str, str]], str]:
"""Run every check, returning (rows, expected_python).
Each row is (axis, expected, actual, status). Raises DoctorError if the
expected values themselves can't be read.
"""
expected_python = read_expected_python()
lock = read_lock_versions()
rows = []
actual_python = f"{sys.version_info.major}.{sys.version_info.minor}"
rows.append(
(
"Python",
expected_python,
actual_python,
OK if actual_python == expected_python else CRITICAL,
)
)
for pkg in KEY_PACKAGES:
expected = lock.get(_normalize(pkg))
if expected is None:
rows.append((pkg, "(not in lock)", "-", WARN))
continue
actual = installed_version(pkg)
if actual is None:
rows.append((pkg, expected, "(not installed)", CRITICAL))
else:
rows.append((pkg, expected, actual, OK if actual == expected else CRITICAL))
# Informational / repo-consistency axes — never block.
rows.append(("operating system", RTD_OS, platform.platform(), INFO))
lock_py = lock_target_python()
if lock_py is not None and lock_py != expected_python:
rows.append(("docs lock target", f"py{expected_python}", f"py{lock_py}", WARN))
return rows, expected_python
def render(rows: list[tuple[str, str, str, str]], warn_only: bool, quiet: bool) -> int:
"""Print the report and return the process exit code."""
criticals = [r for r in rows if r[3] == CRITICAL]
warnings = [r for r in rows if r[3] == WARN]
status_label = {
OK: "ok",
CRITICAL: "MISMATCH" if warn_only else "MISMATCH (critical)",
WARN: "check",
INFO: "differs (low risk)",
}
if not quiet:
print("Read the Docs build fidelity check")
print(f" expected Python from {RTD_YAML.name} (build.tools.python)")
print(f" expected deps from {DOC_LOCK.name} (the docs lock)")
print()
axis_w = max(len(r[0]) for r in rows)
exp_w = max(len(str(r[1])) for r in rows)
act_w = max(len(str(r[2])) for r in rows)
for axis, expected, actual, status in rows:
print(
f" {axis:<{axis_w}} {str(expected):<{exp_w}} "
f"{str(actual):<{act_w}} {status_label[status]}"
)
print()
if criticals and not warn_only:
print(
f"✗ Environment does not match the Read the Docs build "
f"({len(criticals)} critical issue(s))."
)
_print_fixes(criticals)
return 1
if criticals: # warn_only: surface them but don't block
print(
f"! {len(criticals)} environment mismatch(es) (continuing anyway "
f"because --warn-only). The local build may not match RtD."
)
_print_fixes(criticals)
return 0
if warnings:
print(
"✓ Environment matches the Read the Docs build "
f"({len(warnings)} non-blocking warning(s) above)."
)
return 0
print("✓ Environment matches the Read the Docs build.")
return 0
def _print_fixes(criticals: list[tuple[str, str, str, str]]) -> None:
"""Print actionable remediation for the critical findings."""
axes = {r[0] for r in criticals}
print(" Fix:")
if "Python" in axes:
expected = next(r[1] for r in criticals if r[0] == "Python")
print(
f" - Use Python {expected} (RtD's version). Recreate your env, "
f"e.g. `conda create -n docs python={expected}`."
)
if axes - {"Python"}: # any dependency drift
print(
" - Reinstall the pinned docs deps without -U, from the doc/ "
"directory:"
)
print(" pip install -r requirements-doc.lock.txt")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=(
"Check that this environment matches the Read the Docs build "
"(Python version and pinned docs dependencies)."
)
)
parser.add_argument(
"--warn-only",
action="store_true",
help="Report drift as warnings and exit 0 instead of failing.",
)
parser.add_argument(
"--quiet",
action="store_true",
help="Print only the verdict and any fixes, not the full table.",
)
args = parser.parse_args(argv)
try:
rows, _ = build_findings()
except DoctorError as exc:
if args.warn_only:
print(
f"! Could not run the RtD fidelity check: {exc} "
f"(continuing because --warn-only)."
)
return 0
print(f"✗ Could not run the RtD fidelity check: {exc}")
return 2
return render(rows, warn_only=args.warn_only, quiet=args.quiet)
if __name__ == "__main__":
sys.exit(main())