blob: f1e5e098a9b20ce0aec5c6b71bfe65b90aa773c7 [file] [edit]
# Copyright (c) 2009, Giampaolo Rodola'. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Sanity checks for the Sphinx docs and blog posts."""
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from datetime import datetime
from datetime import timezone
import pytest
HERE = pathlib.Path(__file__).resolve().parent
ROOT = HERE.parent
DOCS = ROOT / "docs"
BLOG = DOCS / "blog"
VALID_BLOG_TAGS = frozenset({
# platforms
"bsd",
"linux",
"macos",
"sunos",
"windows",
# API / compatibility
"api-design",
"compatibility",
"new-api",
"new-platform",
# editorial
"featured",
# topics
"c",
"community",
"memory",
"performance",
"personal",
"python-core",
"release",
"tests",
"wheels",
})
sys.path.insert(0, str(HERE)) # so that "import conf" wins
import conf # noqa: E402
HTML_DIR = None
@pytest.fixture(scope="module")
def build_html():
"""Build the site once. `make html` already turns warnings into
errors by default.
"""
global HTML_DIR
tmp = tempfile.mkdtemp()
subprocess.check_call([
"make",
"-C",
str(DOCS),
"html",
f"BUILDDIR={tmp}",
f"PYTHON={sys.executable}",
])
HTML_DIR = pathlib.Path(tmp) / "html"
yield
shutil.rmtree(tmp)
HTML_DIR = None
def blog_posts():
return sorted(BLOG.rglob("*.rst"))
def read_html(*parts):
return HTML_DIR.joinpath(*parts).read_text()
def post_title(rst):
"""First H1 in a .rst file (the line above a `===` underline)."""
lines = rst.read_text().splitlines()
for i in range(1, len(lines)):
if re.fullmatch(r"=+", lines[i].strip()):
return lines[i - 1].strip()
raise ValueError(f"no RST H1 found in {rst}")
def post_tags(rst):
"""Tags from a blog post's `:tags:` line, or [] if absent."""
m = re.search(r":tags:\s*(.+)", rst.read_text())
if not m:
return []
return [t.strip() for t in m.group(1).split(",")]
def blog_html(rst):
"""Read the built HTML for a blog post given its source .rst."""
rel = rst.relative_to(BLOG).with_suffix(".html")
return (HTML_DIR / "blog" / rel).read_text()
def source_rst_for(html_path):
"""Return the .rst source for an HTML page, or None for pages
generated without a source (ablog archives, sphinx auto-pages
like genindex/search/py-modindex).
"""
rel = html_path.relative_to(HTML_DIR).with_suffix(".rst")
src = DOCS / rel
return src if src.is_file() else None
def og_value(html, prop):
"""Pull the `content` of a `<meta property="og:..." ...>` tag.
Sphinx and ablog don't agree on attribute order, so accept both.
"""
m = re.search(
rf'<meta (?:property="{prop}" content="([^"]*)"'
rf'|content="([^"]*)" property="{prop}")',
html,
)
return (m.group(1) or m.group(2)) if m else None
def all_html_pages():
"""Every "real content" HTML page in the build."""
for p in sorted(HTML_DIR.rglob("*.html")):
rel_parts = p.relative_to(HTML_DIR).parts
if any(part.startswith("_") for part in rel_parts):
continue
if source_rst_for(p) is None:
continue
yield p
class TestBlogPostFiles:
def test_every_file_has_directives(self):
missing = []
for p in blog_posts():
text = p.read_text()
missed = [
k for k in (".. post::", ":author:", ":tags:") if k not in text
]
if missed:
rel = p.relative_to(ROOT)
missing.append(f"{rel}: missing {missed}")
assert missing == []
def test_date_year_matches_path_year(self):
mismatches = []
for p in blog_posts():
m = re.search(r"\.\. post:: (\d{4})-", p.read_text())
if m and m.group(1) != p.parent.name:
rel = p.relative_to(ROOT)
mismatches.append(
f"{rel}: date-year={m.group(1)} dir={p.parent.name}"
)
assert mismatches == []
def test_valid_tags(self):
invalid = {}
for p in blog_posts():
bad = [t for t in post_tags(p) if t not in VALID_BLOG_TAGS]
if bad:
invalid[str(p.relative_to(ROOT))] = bad
assert not invalid, f"invalid tags: {invalid}"
@pytest.mark.usefixtures("build_html")
class TestHtmlBuild:
"""Build sanity: pages exist, extensions wire up, footer dates."""
def test_files_exist(self):
assert (HTML_DIR / "index.html").exists()
assert (HTML_DIR / "blog.html").exists()
def test_changelog_anchors(self):
# Indirectly test _ext/changelog_anchors.py. Every X.Y.Z
# version heading in changelog.rst must get an `id="XYZ"`
# anchor in the rendered HTML.
source = (DOCS / "changelog.rst").read_text()
html = read_html("changelog.html")
missing = []
for m in re.finditer(r"^(\d+)\.(\d+)\.(\d+)", source, re.MULTILINE):
anchor = "".join(m.groups())
if f'id="{anchor}"' not in html:
missing.append(m.group(0))
assert missing == []
def test_gh_role_resolves(self):
# Checks extlinks = {"gh": ...} in conf.py. Without it, every
# :gh:`NNN` in the docs silently renders as plain text instead
# of links.
html = read_html("changelog.html")
assert 'href="https://github.com/giampaolo/psutil/issues/' in html
def test_intersphinx_resolves(self):
# Check intersphinx_mapping["python"] in conf.py. If it does
# not work, :mod:`func` refs to the stdlib silently render as
# plain text instead of links.
html = read_html("api.html")
assert 'href="https://docs.python.org/3/' in html
def test_footer_last_updated_matches_git(self, subtests):
# Skip pages that pull in external files via `.. include::`
# or `.. raw:: <fmt> :file:`. sphinx-last-updated-by-git
# walks those deps and picks the latest commit timestamp,
# which we don't replicate here.
dep_pat = re.compile(r"^\.\. (?:include|raw)::", re.MULTILINE)
date_pat = re.compile(r"<span>Updated: (\d{4}-\d{2}-\d{2})</span>")
for html in all_html_pages():
src = source_rst_for(html)
if dep_pat.search(src.read_text()):
continue
m = date_pat.search(html.read_text())
if m is None:
continue # page doesn't render the footer date
cmd = ["git", "log", "-1", "--format=%ct", "--", str(src)]
ts = subprocess.check_output(cmd).strip()
if not ts:
continue # untracked source file
expected = datetime.fromtimestamp(
int(ts), tz=timezone.utc
).strftime("%Y-%m-%d")
with subtests.test(page=html.relative_to(HTML_DIR)):
assert m.group(1) == expected
@pytest.mark.usefixtures("build_html")
class TestSitemap:
def test_known_pages_listed(self, subtests):
# sphinx-sitemap should emit one <url> per built HTML page
# (source docs + blog posts + ablog-generated pages),
# rooted at html_baseurl.
sitemap = HTML_DIR / "sitemap.xml"
assert sitemap.exists()
ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
tree = ET.parse(sitemap)
urls = {u.text for u in tree.getroot().findall("s:url/s:loc", ns)}
for page in (
"index.html",
"api.html",
"changelog.html",
"blog/2026/event-driven-process-waiting.html",
):
with subtests.test(page=page):
assert conf.html_baseurl + page in urls
def test_has_every_blog_post(self, subtests):
# Catches drift between ablog's post registry and
# sphinx-sitemap. If sphinx-sitemap stops listing some posts
# (e.g. due to an ablog version bump), search engines stop
# discovering them.
sitemap = HTML_DIR / "sitemap.xml"
ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
urls = {
u.text
for u in ET.parse(sitemap).getroot().findall("s:url/s:loc", ns)
}
for rst in blog_posts():
rel = rst.relative_to(BLOG).with_suffix(".html")
expected = conf.html_baseurl + "blog/" + rel.as_posix()
with subtests.test(rst=rst):
assert expected in urls
@pytest.mark.usefixtures("build_html")
class TestCanonicalUrl:
def test_link_on_pages(self, subtests):
# Sphinx emits <link rel="canonical"> using html_baseurl. If
# html_baseurl is misconfigured (e.g. lacks the /latest/
# prefix while the deploy is version-pathed), every shared
# URL points at a 404.
pattern = re.compile(
r'<link (?:rel="canonical" href="([^"]*)"'
r'|href="([^"]*)" rel="canonical")'
)
for page in (
"index.html",
"api.html",
"blog/2026/event-driven-process-waiting.html",
):
m = pattern.search(read_html(page))
with subtests.test(page=page):
assert m is not None
url = m.group(1) or m.group(2)
assert url.startswith(conf.html_baseurl)
@pytest.mark.usefixtures("build_html")
class TestRightToc:
def test_visibility(self, subtests):
has_toc = ("api.html", "faq.html", "glossary.html", "blog.html")
no_toc = ("index.html", "genindex.html", "search.html")
pat = re.compile(r'<aside[^>]*\bclass="[^"]*\bright-sidebar\b')
for file in has_toc:
with subtests.test(file=file):
assert pat.search(read_html(file))
for file in no_toc:
with subtests.test(file=file):
assert not pat.search(read_html(file))
def test_hash_targets_resolve(self, subtests):
# Every <a href="#..."> inside the right-sidebar must point to
# an id that exists on the same page. Otherwise the JS
# hash-match path in right-toc.js silently misses on direct
# URL load.
html = read_html("api.html")
m = re.search(
r'<aside[^>]*\bclass="[^"]*\bright-sidebar\b[^"]*"[^>]*>(.*?)</aside>',
html,
re.DOTALL,
)
assert m
hrefs = re.findall(r'href="#([^"]+)"', m.group(1))
assert hrefs
ids_on_page = set(re.findall(r'\bid="([^"]+)"', html))
for href in hrefs:
with subtests.test(href=href):
assert href in ids_on_page
def test_glossary_mode(self):
# Tests docs/_ext/glossary_toc.py, which injects
# glossary_terms into the Jinja context.
html = read_html("glossary.html")
assert 'data-toc-mode="glossary"' in html
m = re.search(
r'<aside class="right-sidebar'
r' right-sidebar--glossary"[^>]*>(.*?)</aside>',
html,
re.DOTALL,
)
assert m, "right-sidebar--glossary aside not found"
terms = re.findall(r'<a href="#term-[^"]*">([^<]+)</a>', m.group(1))
assert terms
assert terms == sorted(terms, key=str.lower)
@pytest.mark.usefixtures("build_html")
class TestCodeAutoLink:
"""Checks sphinx-codeautolink integration."""
def test_enabled(self):
html = read_html("api-overview.html")
assert "sphinx-codeautolink-a" in html
def test_resolves_process_instance_methods(self):
# Check that things like `p.name()`are resolved.
html = read_html("api-overview.html")
assert (
'<a class="sphinx-codeautolink-a" '
'href="api.html#psutil.Process.name"'
in html
)
@pytest.mark.usefixtures("build_html")
class TestAtomFeed:
def test_entry_count(self):
feed = HTML_DIR / "blog" / "atom.xml"
ns = {"a": "http://www.w3.org/2005/Atom"}
entries = ET.parse(feed).getroot().findall("a:entry", ns)
assert len(entries) == len(blog_posts())
def test_uses_utc(self):
feed = HTML_DIR / "blog" / "atom.xml"
ns = {"a": "http://www.w3.org/2005/Atom"}
entries = ET.parse(feed).getroot().findall("a:entry", ns)
assert entries
non_utc = []
for e in entries:
for tag in ("a:updated", "a:published"):
el = e.find(tag, ns)
assert el is not None
if not (el.text.endswith("+00:00") or el.text.endswith("Z")):
non_utc.append(el.text)
assert non_utc == []
def test_uses_canonical_url(self):
# Regression: feed-level + per-entry URLs must include the
# RTD version path (/latest/) so they match the deployed
# location. Without it, feed readers 404. This check needs
# updating when the Single Version toggle is flipped at
# 8.0.0 — see the TODO on html_baseurl in conf.py.
feed = HTML_DIR / "blog" / "atom.xml"
ns = {"a": "http://www.w3.org/2005/Atom"}
root = ET.parse(feed).getroot()
urls = [link.get("href") for link in root.findall("a:link", ns)]
for e in root.findall("a:entry", ns):
urls.append(e.find("a:id", ns).text)
urls.extend(link.get("href") for link in e.findall("a:link", ns))
bad = [u for u in urls if u and "/latest/" not in u]
assert bad == []
def test_link_on_every_page_exactly_once(self, subtests):
# Every page must auto-discover the atom feed (so feed readers
# find it from any post URL), and the <link> must appear only
# once. layout.html used to inject it unconditionally,
# duplicating ablog's auto-injection.
needle = 'type="application/atom+xml"'
for html in all_html_pages():
with subtests.test(page=html.relative_to(HTML_DIR)):
head = html.read_text()[:8192]
assert head.count(needle) == 1
@pytest.mark.usefixtures("build_html")
class TestOpenGraph:
"""og:* and twitter:* social-preview meta tags."""
def test_tags_on_source_pages(self, subtests):
for rst in sorted(DOCS.rglob("*.rst")):
if rst.name == "blog.rst":
continue
rel = rst.relative_to(DOCS).with_suffix(".html")
html_path = HTML_DIR / rel
html = html_path.read_text()
with subtests.test(rst=rst):
for needle in (
"og:title",
"og:site_name",
"og:description",
"og:image",
):
assert needle in html
def test_tags_on_blog_posts(self, subtests):
# sphinxext-opengraph should emit og:* meta tags on every
# post page so that shared URLs render as rich previews.
properties = (
"og:title",
"og:url",
"og:site_name",
"og:description",
"og:image",
)
for rst in blog_posts():
html = blog_html(rst)
for prop in properties:
with subtests.test(rst=rst, prop=prop):
assert f'property="{prop}"' in html
def test_type(self, subtests):
# For blog posts it must be og:type="article" else "website".
for rst in blog_posts():
with subtests.test(rst=rst, expected="article"):
assert og_value(blog_html(rst), "og:type") == "article"
for page in ("index.html", "api.html", "install.html"):
with subtests.test(page=page, expected="website"):
assert og_value(read_html(page), "og:type") == "website"
def test_blog_index_metadata(self, subtests):
# Regression: ablog renders blog.html without a doctree, so
# sphinxext-opengraph skips emitting og:* tags. Our
# opengraph_override extension synthesizes them.
html = read_html("blog.html")
for tag in (
'name="description"',
'property="og:title"',
'property="og:type"',
'property="og:url"',
'property="og:site_name"',
'property="og:description"',
'property="og:image"',
'name="twitter:card"',
):
with subtests.test(tag=tag):
assert tag in html
def test_description_uses_post_excerpt(self):
# Blog post og:description (and the social card PNG sourced
# from the same value) should be the curated excerpt from the
# `.. post::` directive body, not the page body's first
# paragraph. Spot-check on a known post.
html = read_html("blog", "2026", "event-driven-process-waiting.html")
desc = og_value(html, "og:description")
# Excerpt opens with "Replacing"; the page body opens with
# "One of the less fun aspects of process management ...".
assert desc is not None
assert desc.startswith("Replacing")
def test_description_excludes_post_banner(self, subtests):
# Regression: the post banner (author/date/readtime/tags)
# used to leak into og:description, making social previews
# start with "Giampaolo Rodola 2026-01-28 5 min read ...".
for rst in blog_posts():
desc = og_value(blog_html(rst), "og:description")
with subtests.test(rst=rst):
assert desc is not None
assert not desc.startswith("Giampaolo")
assert "min read" not in desc[:80]
@pytest.mark.usefixtures("build_html")
class TestBlogPosts:
"""Rendered blog post pages and the blog index listing."""
def test_banner_on_every_post(self, subtests):
css_names = (
"post-meta-banner",
"post-meta-author",
"post-meta-date",
"post-meta-readtime",
"post-meta-tags",
)
for rst in blog_posts():
html = blog_html(rst)
for css in css_names:
with subtests.test(rst=rst, css=css):
assert css in html
def test_listing_has_all_posts(self, subtests):
# blog.html should list all posts, each containing the post's
# title.
posts = blog_posts()
html = read_html("blog.html")
assert len(re.findall(r'<li class="blog-card[^"]*"', html)) == len(
posts
)
for rst in posts:
with subtests.test(rst=rst):
assert post_title(rst) in html
def test_listing_shows_summary(self, subtests):
# Each post should have a summary below the title.
html = read_html("blog.html")
summaries = re.findall(
r'<div class="blog-card-summary">(.*?)</div>', html, re.DOTALL
)
posts = blog_posts()
assert len(summaries) == len(posts)
for i, summary in enumerate(summaries):
plain = re.sub(r"<[^>]+>", "", summary).strip()
with subtests.test(card=i):
assert plain
def test_featured_pill(self, subtests):
# Posts with the "featured" tag show a "Featured" pill in
# their post-meta banner via _ext/post_banner.py. Other posts
# must not.
for rst in blog_posts():
is_featured = "featured" in post_tags(rst)
html = blog_html(rst)
with subtests.test(rst=rst, is_featured=is_featured):
if is_featured:
assert "post-meta-featured" in html
else:
assert "post-meta-featured" not in html
@pytest.mark.usefixtures("build_html")
class TestNoExternalAssets:
"""Stylesheets and fonts are self-hosted, not pulled from a CDN."""
ASSET_LINK_RE = re.compile(
r'<link\b[^>]*\brel="(?:stylesheet|preload)"[^>]*>', re.IGNORECASE
)
HREF_RE = re.compile(r'\bhref="([^"]+)"')
@staticmethod
def is_external(url):
return url.startswith(("http://", "https://", "//"))
def test_no_external_stylesheets(self, subtests):
# Analytics <script>s are deliberately external; only links count.
for page in all_html_pages():
urls = []
for tag in self.ASSET_LINK_RE.findall(page.read_text()):
urls += self.HREF_RE.findall(tag)
external = [u for u in urls if self.is_external(u)]
with subtests.test(page=page.relative_to(HTML_DIR)):
assert external == []
def test_no_external_css_urls(self, subtests):
css_dir = HTML_DIR / "_static" / "css"
for css in sorted(css_dir.glob("*.css")):
urls = re.findall(r'url\(\s*["\']?([^"\')]+)', css.read_text())
external = [u for u in urls if self.is_external(u)]
with subtests.test(css=css.name):
assert external == []
@pytest.mark.usefixtures("build_html")
class TestFonts:
"""Every @font-face points at a font file that ships in the build."""
def test_font_face_files_exist(self, subtests):
css_dir = HTML_DIR / "_static" / "css"
face_re = re.compile(r"@font-face\s*\{[^}]*\}", re.DOTALL)
url_re = re.compile(r'url\(\s*["\']?([^"\')]+)')
found = 0
for css in sorted(css_dir.glob("*.css")):
for block in face_re.findall(css.read_text()):
for url in url_re.findall(block):
found += 1
resolved = (css.parent / url).resolve()
with subtests.test(css=css.name, url=url):
assert resolved.is_file()
assert found > 0
@pytest.mark.usefixtures("build_html")
class TestNoIndex:
"""Utility pages are noindex'd (layout.html); content pages aren't."""
NOINDEX_RE = re.compile(
r'<meta[^>]*name="robots"[^>]*content="noindex"', re.IGNORECASE
)
def test_utility_pages_noindex(self, subtests):
for name in ("genindex.html", "py-modindex.html", "404.html"):
path = HTML_DIR / name
if not path.is_file():
continue
with subtests.test(page=name):
assert self.NOINDEX_RE.search(path.read_text())
def test_content_pages_indexable(self, subtests):
for name in ("index.html", "api.html", "install.html"):
with subtests.test(page=name):
assert not self.NOINDEX_RE.search(read_html(name))