render_diff.py
render_diff.pyBrowse 2 files
2,023 tokens
8,200 bytes
Token encoding: o200k_base
Snapshot 39882c6
← Back to SKILL.md
1"""Diff a Read the Docs PR preview against /en/master, page by page.2 3An RST-to-MyST conversion is meant to be format-only, so the rendered HTML4should be equivalent. A green build proves every reference resolved; it does not5prove the page still renders the same thing. Several ways to change the render6leave the build green and emit no warning at all:7 8 - `.. title::` does not set the document title from inside an {eval-rst}9 block, so a page whose title came from that directive silently loses it.10 - An RST `.. image::` with no `:alt:` takes its alt text from the URI and11 emits a bare <img>; Markdown `![]()` emits alt="" wrapped in a <p>.12 - A directive that builds RST into a ViewList and hands it to `nested_parse`13 degrades to literal visible text under MyST, losing every domain object,14 anchor, and cross-reference it would have created.15 16This catches all of them, because it compares output rather than source.17 18Usage:19 python3 render_diff.py <preview_base_url> <page.html> [<page.html> ...]20 21 python3 render_diff.py https://anyscale-ray--12345.com.readthedocs.build/en/12345/ \\22 index.html ray-core/key-concepts.html23 24Exit status is the number of pages with an unexplained diff. Read the diffs25rather than trusting the count: byte-identical is not always the right bar, and26a legitimate markup change (a caption-less {figure}, say) shows up here too.27 28Requires beautifulsoup4, which the docs toolchain already installs.29"""30 31import argparse32import difflib33import re34import sys35import urllib.request36 37from bs4 import BeautifulSoup38 39MASTER = "https://docs.ray.io/en/master/"40 41# Differences every MyST page shows against an RST page, carrying no rendered42# consequence. Filtered out so a real regression is not buried in nine copies of43# the same benign line. Stripped from the parse tree rather than the serialized44# HTML, so a change in class order or an extra class can't silently stop the45# filter from matching and resurrect the noise it exists to remove.46#47# tex2jax_ignore / mathjax_ignore: myst-parser stamps these on the root48# <section> of every MyST document. Present on every already-Markdown page.49# code: `default_role = "code"` makes a single-backtick RST literal render as50# <code class="code docutils literal notranslate">; a Markdown single-backtick51# drops the `code` class. That class carries no styling in either Ray's CSS52# or pydata-sphinx-theme, so plain backticks are correct and this diff is53# expected. Only stripped alongside the full inline-literal class set, so a54# `code` class meaning something else elsewhere is left alone.55BENIGN_CLASSES = frozenset({"tex2jax_ignore", "mathjax_ignore"})56INLINE_LITERAL_CLASSES = frozenset({"docutils", "literal", "notranslate"})57 58# Release strings drift between a preview and master whenever a version bump59# lands in between. Applied to the <title> as well as the body: Sphinx builds60# titles as "… — Ray {release}" from html_title, so without this every page61# reports a title difference on a bump alone.62RELEASE_RE = re.compile(r"\bRay \d+\.\d+\.\d+[\w.]*")63 64# Pages whose body is randomized at build time and so differ between any two65# builds of the same commit. `custom_directives.py` picks the example-gallery66# icons with random.randint / random.choice.67NONDETERMINISTIC = {"ray-overview/examples.html"}68 69 70def fetch(url: str) -> str:71 req = urllib.request.Request(url, headers={"User-Agent": "render-diff"})72 with urllib.request.urlopen(req, timeout=60) as response:73 return response.read().decode("utf-8", "replace")74 75 76def strip_benign_classes(body) -> None:77 """Remove the classes a MyST page carries that its RST original didn't."""78 for tag in body.find_all(class_=True):79 classes = tag.get("class")80 if not isinstance(classes, list):81 continue82 kept = [c for c in classes if c not in BENIGN_CLASSES]83 if "code" in kept and INLINE_LITERAL_CLASSES <= set(kept):84 kept = [c for c in kept if c != "code"]85 if kept:86 tag["class"] = kept87 else:88 # An emptied class attribute must go entirely, or the MyST side89 # serializes class="" where the RST side has no attribute at all.90 del tag["class"]91 92 93def normalize(html: str, base: str, keep_benign: bool):94 """Reduce a page to its comparable body plus its title.95 96 Strips what legitimately differs between two builds: the host in absolute97 links, the release string in version-pinned URLs, and the search-highlight98 query parameters Sphinx appends. Collapses whitespace so a re-indented99 directive body does not read as a content change, then puts one tag per100 line so difflib reports a tight hunk.101 """102 soup = BeautifulSoup(html, "html.parser")103 title = soup.title.get_text(strip=True) if soup.title else "(no <title>)"104 title = RELEASE_RE.sub("Ray VERSION", title)105 106 body = soup.find("article") or soup.find("body")107 if body is None:108 return title, []109 110 if not keep_benign:111 strip_benign_classes(body)112 113 text = str(body).replace(base, "/").replace(MASTER, "/")114 text = re.sub(r"/en/(master|latest|[\w.-]+)/", "/en/VERSION/", text)115 text = RELEASE_RE.sub("Ray VERSION", text)116 text = re.sub(r"\?highlight=[^\"'&]*", "", text)117 text = re.sub(r"\s+", " ", text)118 return title, re.sub(r">\s*<", ">\n<", text).splitlines()119 120 121def compare(page: str, preview_base: str, keep_benign: bool, context: int) -> bool:122 """Print one page's diff. Returns True when the page differs."""123 page = page.lstrip("/")124 try:125 master_html = fetch(MASTER + page)126 preview_html = fetch(preview_base + page)127 except OSError as exc:128 # A typo'd page path or a preview that has not finished publishing129 # should read as "not verified", never as "verified clean".130 # OSError rather than urllib.error.URLError: URLError subclasses it, and131 # a read timeout surfaces from response.read() as a bare TimeoutError,132 # which is an OSError but not a URLError. Catching only URLError lets a133 # stalled preview traceback instead of printing this line.134 print(f"ERROR {page} (fetch failed: {exc})")135 return True136 137 master_title, master_lines = normalize(master_html, MASTER, keep_benign)138 preview_title, preview_lines = normalize(preview_html, preview_base, keep_benign)139 140 title_note = ""141 if master_title != preview_title:142 title_note = f" TITLE master={master_title!r} preview={preview_title!r}"143 144 diff = list(145 difflib.unified_diff(146 master_lines, preview_lines, "master", "preview", n=context, lineterm=""147 )148 )149 if not diff and not title_note:150 print(f"IDENTICAL {page} (title: {master_title!r})")151 return False152 153 note = " [known nondeterministic]" if page in NONDETERMINISTIC else ""154 added = sum(1 for d in diff if d.startswith("+") and not d.startswith("+++"))155 removed = sum(1 for d in diff if d.startswith("-") and not d.startswith("---"))156 print(f"\nDIFFERS {page} (+{added} -{removed}){title_note}{note}")157 for line in diff:158 print(" ", line)159 return True160 161 162def main() -> int:163 parser = argparse.ArgumentParser(description=__doc__)164 parser.add_argument("preview_base", help="RtD PR preview base URL")165 parser.add_argument("pages", nargs="+", help="page paths, e.g. ray-core/index.html")166 parser.add_argument(167 "--keep-benign",168 action="store_true",169 help="do not filter the known-benign MyST markup differences",170 )171 parser.add_argument("-n", "--context", type=int, default=1)172 args = parser.parse_args()173 174 preview_base = args.preview_base.rstrip("/") + "/"175 differing = sum(176 compare(page, preview_base, args.keep_benign, args.context)177 for page in args.pages178 )179 print(f"\n{len(args.pages) - differing}/{len(args.pages)} pages render identically")180 # Capped: an exit status is masked to 8 bits, so an uncapped count would181 # wrap to 0 (success) on a run of exactly 256 differing pages.182 return min(differing, 125)183 184 185if __name__ == "__main__":186 sys.exit(main())187 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 157.SKILL.mdView in source ↗157- **`default_role = "code"`** (`doc/source/conf.py`): an RST single-backtick already renders as inline code, so single-backtick → single-backtick is the right conversion. It is *not* byte-identical, though: the RST form emits `<code class="code docutils literal notranslate">` and the Markdown form drops the `code` class. That class carries no styling in Ray's CSS or in `pydata-sphinx-theme`, and every already-converted page in the tree renders without it, so plain backticks are the house choice and `render_diff.py` filters this difference by default. Use the `` {code}`x` `` role only if you need a byte-identical diff for some other reason.158- **Admonitions**: prefer colon fences `:::{note}` … `:::` (the `colon_fence` MyST extension is on). They nest a ` ``` ` code fence cleanly without backtick-counting. Backtick ` ```{note} ` also works for simple admonitions with no nested fence. A one-line RST admonition (`.. note:: text`) becomes `:::{note}` / `text` / `:::`.
Source excerpt starting at line 223.2234. **Regression — run this, don't eyeball it.** Compare the RtD preview against **`/en/master`** (not `/en/latest`) with [`render_diff.py`](render_diff.py), which fetches both, extracts `<article>`, normalizes the host, release string, and search-highlight params, and diffs: