Skip to content

Commit 0f3bbd9

Browse files
authored
Sphinx support: add docutils support files (#1931)
See #2, #1385 for context. Superseeds #1566. This is the docutils parsing, transforms and writing part, building on PR #1930. It contains a pseudo-package, `sphinx_pep_extensions`, which itself contains: ### Docutils parsing: - `PEPParser` - collates transforms and interfaces with Sphinx core - `PEPRole` - deals with :PEP:`blah` in RST source ### Docutils transforms: - `PEPContents` (Creates table of contents without page title) - `PEPFooter` (Dels with footnotes, link to source, last modified commit) - `PEPHeaders` (Parses RFC2822 headers) - `PEPTitle` - Creates document title from PEP headers - `PEPZero` - Masks email addresses and creates links to PEP numbers from tables in `pep-0000.rst` ### Docutils HTML output: - `PEPTranslator` - Overrides to the default HTML translator to enable better matching of the current PEP styles
1 parent 3533799 commit 0f3bbd9

File tree

12 files changed

+615
-3
lines changed

12 files changed

+615
-3
lines changed

build.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def create_parser():
1414
# flags / options
1515
parser.add_argument("-f", "--fail-on-warning", action="store_true")
1616
parser.add_argument("-n", "--nitpicky", action="store_true")
17-
parser.add_argument("-j", "--jobs", type=int)
17+
parser.add_argument("-j", "--jobs", type=int, default=1)
1818

1919
# extra build steps
2020
parser.add_argument("-i", "--index-file", action="store_true") # for PEP 0

conf.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
"""Configuration for building PEPs using Sphinx."""
22

3+
import sys
4+
from pathlib import Path
5+
6+
sys.path.append(str(Path("pep_sphinx_extensions").absolute()))
7+
38
# -- Project information -----------------------------------------------------
49

510
project = "PEPs"
611
master_doc = "contents"
712

813
# -- General configuration ---------------------------------------------------
914

15+
# Add any Sphinx extension module names here, as strings.
16+
extensions = ["pep_sphinx_extensions", "sphinx.ext.githubpages"]
17+
1018
# The file extensions of source files. Sphinx uses these suffixes as sources.
1119
source_suffix = {
12-
".rst": "restructuredtext",
13-
".txt": "restructuredtext",
20+
".rst": "pep",
21+
".txt": "pep",
1422
}
1523

1624
# List of patterns (relative to source dir) to ignore when looking for source files.
@@ -32,6 +40,7 @@
3240
# -- Options for HTML output -------------------------------------------------
3341

3442
# HTML output settings
43+
html_math_renderer = "maths_to_html" # Maths rendering
3544
html_show_copyright = False # Turn off miscellany
3645
html_show_sphinx = False
3746
html_title = "peps.python.org" # Set <title/>

pep_sphinx_extensions/__init__.py

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Sphinx extensions for performant PEP processing"""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from sphinx.environment import default_settings
8+
from docutils.writers.html5_polyglot import HTMLTranslator
9+
10+
from pep_sphinx_extensions.pep_processor.html import pep_html_translator
11+
from pep_sphinx_extensions.pep_processor.parsing import pep_parser
12+
from pep_sphinx_extensions.pep_processor.parsing import pep_role
13+
14+
if TYPE_CHECKING:
15+
from sphinx.application import Sphinx
16+
17+
# Monkeypatch sphinx.environment.default_settings as Sphinx doesn't allow custom settings or Readers
18+
# These settings should go in docutils.conf, but are overridden here for now so as not to affect
19+
# pep2html.py
20+
default_settings |= {
21+
"pep_references": True,
22+
"rfc_references": True,
23+
"pep_base_url": "",
24+
"pep_file_url_template": "pep-%04d.html",
25+
"_disable_config": True, # disable using docutils.conf whilst running both PEP generators
26+
}
27+
28+
29+
def _depart_maths():
30+
pass # No-op callable for the type checker
31+
32+
33+
def setup(app: Sphinx) -> dict[str, bool]:
34+
"""Initialize Sphinx extension."""
35+
36+
# Register plugin logic
37+
app.add_source_parser(pep_parser.PEPParser) # Add PEP transforms
38+
app.add_role("pep", pep_role.PEPRole(), override=True) # Transform PEP references to links
39+
app.set_translator("html", pep_html_translator.PEPTranslator) # Docutils Node Visitor overrides
40+
41+
# Mathematics rendering
42+
inline_maths = HTMLTranslator.visit_math, _depart_maths
43+
block_maths = HTMLTranslator.visit_math_block, _depart_maths
44+
app.add_html_math_renderer("maths_to_html", inline_maths, block_maths) # Render maths to HTML
45+
46+
# Parallel safety: https://www.sphinx-doc.org/en/master/extdev/index.html#extension-metadata
47+
return {"parallel_read_safe": True, "parallel_write_safe": True}

pep_sphinx_extensions/config.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Miscellaneous configuration variables for the PEP Sphinx extensions."""
2+
3+
pep_stem = "pep-{:0>4}"
4+
pep_url = f"{pep_stem}.html"
5+
pep_vcs_url = "https://github.com/python/peps/blob/master/"
6+
pep_commits_url = "https://github.com/python/peps/commits/master/"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from docutils import nodes
6+
import sphinx.writers.html5 as html5
7+
8+
if TYPE_CHECKING:
9+
from sphinx.builders import html
10+
11+
12+
class PEPTranslator(html5.HTML5Translator):
13+
"""Custom RST -> HTML translation rules for PEPs."""
14+
15+
def __init__(self, document: nodes.document, builder: html.StandaloneHTMLBuilder):
16+
super().__init__(document, builder)
17+
self.compact_simple: bool = False
18+
19+
@staticmethod
20+
def should_be_compact_paragraph(node: nodes.paragraph) -> bool:
21+
"""Check if paragraph should be compact.
22+
23+
Omitting <p/> tags around paragraph nodes gives visually compact lists.
24+
25+
"""
26+
# Never compact paragraphs that are children of document or compound.
27+
if isinstance(node.parent, (nodes.document, nodes.compound)):
28+
return False
29+
30+
# Check for custom attributes in paragraph.
31+
for key, value in node.non_default_attributes().items():
32+
# if key equals "classes", carry on
33+
# if value is empty, or contains only "first", only "last", or both
34+
# "first" and "last", carry on
35+
# else return False
36+
if any((key != "classes", not set(value) <= {"first", "last"})):
37+
return False
38+
39+
# Only first paragraph can be compact (ignoring initial label & invisible nodes)
40+
first = isinstance(node.parent[0], nodes.label)
41+
visible_siblings = [child for child in node.parent.children[first:] if not isinstance(child, nodes.Invisible)]
42+
if visible_siblings[0] is not node:
43+
return False
44+
45+
# otherwise, the paragraph should be compact
46+
return True
47+
48+
def visit_paragraph(self, node: nodes.paragraph) -> None:
49+
"""Remove <p> tags if possible."""
50+
if self.should_be_compact_paragraph(node):
51+
self.context.append("")
52+
else:
53+
self.body.append(self.starttag(node, "p", ""))
54+
self.context.append("</p>\n")
55+
56+
def depart_paragraph(self, _: nodes.paragraph) -> None:
57+
"""Add corresponding end tag from `visit_paragraph`."""
58+
self.body.append(self.context.pop())
59+
60+
def depart_label(self, node) -> None:
61+
"""PEP link/citation block cleanup with italicised backlinks."""
62+
if not self.settings.footnote_backlinks:
63+
self.body.append("</span>")
64+
self.body.append("</dt>\n<dd>")
65+
return
66+
67+
# If only one reference to this footnote
68+
back_references = node.parent["backrefs"]
69+
if len(back_references) == 1:
70+
self.body.append("</a>")
71+
72+
# Close the tag
73+
self.body.append("</span>")
74+
75+
# If more than one reference
76+
if len(back_references) > 1:
77+
back_links = [f"<a href='#{ref}'>{i}</a>" for i, ref in enumerate(back_references, start=1)]
78+
back_links_str = ", ".join(back_links)
79+
self.body.append(f"<span class='fn-backref''><em> ({back_links_str}) </em></span>")
80+
81+
# Close the def tags
82+
self.body.append("</dt>\n<dd>")
83+
84+
def unknown_visit(self, node: nodes.Node) -> None:
85+
"""No processing for unknown node types."""
86+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from sphinx import parsers
6+
7+
from pep_sphinx_extensions.pep_processor.transforms import pep_headers
8+
from pep_sphinx_extensions.pep_processor.transforms import pep_title
9+
from pep_sphinx_extensions.pep_processor.transforms import pep_contents
10+
from pep_sphinx_extensions.pep_processor.transforms import pep_footer
11+
12+
if TYPE_CHECKING:
13+
from docutils import transforms
14+
15+
16+
class PEPParser(parsers.RSTParser):
17+
"""RST parser with custom PEP transforms."""
18+
19+
supported = ("pep", "python-enhancement-proposal") # for source_suffix in conf.py
20+
21+
def __init__(self):
22+
"""Mark the document as containing RFC 2822 headers."""
23+
super().__init__(rfc2822=True)
24+
25+
def get_transforms(self) -> list[type[transforms.Transform]]:
26+
"""Use our custom PEP transform rules."""
27+
return [
28+
pep_headers.PEPHeaders,
29+
pep_title.PEPTitle,
30+
pep_contents.PEPContents,
31+
pep_footer.PEPFooter,
32+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from sphinx import roles
2+
3+
from pep_sphinx_extensions.config import pep_url
4+
5+
6+
class PEPRole(roles.PEP):
7+
"""Override the :pep: role"""
8+
9+
def build_uri(self) -> str:
10+
"""Get PEP URI from role text."""
11+
base_url = self.inliner.document.settings.pep_base_url
12+
pep_num, _, fragment = self.target.partition("#")
13+
pep_base = base_url + pep_url.format(int(pep_num))
14+
if fragment:
15+
return f"{pep_base}#{fragment}"
16+
return pep_base
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from pathlib import Path
2+
3+
from docutils import nodes
4+
from docutils import transforms
5+
from docutils.transforms import parts
6+
7+
8+
class PEPContents(transforms.Transform):
9+
"""Add TOC placeholder and horizontal rule after PEP title and headers."""
10+
11+
# Use same priority as docutils.transforms.Contents
12+
default_priority = 380
13+
14+
def apply(self) -> None:
15+
if not Path(self.document["source"]).match("pep-*"):
16+
return # not a PEP file, exit early
17+
18+
# Create the contents placeholder section
19+
title = nodes.title("", "Contents")
20+
contents_topic = nodes.topic("", title, classes=["contents"])
21+
if not self.document.has_name("contents"):
22+
contents_topic["names"].append("contents")
23+
self.document.note_implicit_target(contents_topic)
24+
25+
# Add a table of contents builder
26+
pending = nodes.pending(Contents)
27+
contents_topic += pending
28+
self.document.note_pending(pending)
29+
30+
# Insert the toc after title and PEP headers
31+
self.document.children[0].insert(2, contents_topic)
32+
33+
# Add a horizontal rule before contents
34+
transition = nodes.transition()
35+
self.document[0].insert(2, transition)
36+
37+
38+
class Contents(parts.Contents):
39+
"""Build Table of Contents from document."""
40+
def __init__(self, document, startnode=None):
41+
super().__init__(document, startnode)
42+
43+
# used in parts.Contents.build_contents
44+
self.toc_id = None
45+
self.backlinks = None
46+
47+
def apply(self) -> None:
48+
# used in parts.Contents.build_contents
49+
self.toc_id = self.startnode.parent["ids"][0]
50+
self.backlinks = self.document.settings.toc_backlinks
51+
52+
# let the writer (or output software) build the contents list?
53+
if getattr(self.document.settings, "use_latex_toc", False):
54+
# move customisation settings to the parent node
55+
self.startnode.parent.attributes.update(self.startnode.details)
56+
self.startnode.parent.remove(self.startnode)
57+
else:
58+
contents = self.build_contents(self.document[0])
59+
if contents:
60+
self.startnode.replace_self(contents)
61+
else:
62+
# if no contents, remove the empty placeholder
63+
self.startnode.parent.parent.remove(self.startnode.parent)

0 commit comments

Comments
 (0)