#!/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())