From 0f3bbd9ad928d303dac645bdff30c24c50153231 Mon Sep 17 00:00:00 2001
From: Adam Turner <9087854+AA-Turner@users.noreply.github.com>
Date: Wed, 9 Jun 2021 01:37:55 +0100
Subject: [PATCH] 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
---
build.py | 2 +-
conf.py | 13 +-
pep_sphinx_extensions/__init__.py | 47 +++++++
pep_sphinx_extensions/config.py | 6 +
.../pep_processor/html/pep_html_translator.py | 86 +++++++++++++
.../pep_processor/parsing/pep_parser.py | 32 +++++
.../pep_processor/parsing/pep_role.py | 16 +++
.../pep_processor/transforms/pep_contents.py | 63 ++++++++++
.../pep_processor/transforms/pep_footer.py | 111 ++++++++++++++++
.../pep_processor/transforms/pep_headers.py | 119 ++++++++++++++++++
.../pep_processor/transforms/pep_title.py | 49 ++++++++
.../pep_processor/transforms/pep_zero.py | 74 +++++++++++
12 files changed, 615 insertions(+), 3 deletions(-)
create mode 100644 pep_sphinx_extensions/__init__.py
create mode 100644 pep_sphinx_extensions/config.py
create mode 100644 pep_sphinx_extensions/pep_processor/html/pep_html_translator.py
create mode 100644 pep_sphinx_extensions/pep_processor/parsing/pep_parser.py
create mode 100644 pep_sphinx_extensions/pep_processor/parsing/pep_role.py
create mode 100644 pep_sphinx_extensions/pep_processor/transforms/pep_contents.py
create mode 100644 pep_sphinx_extensions/pep_processor/transforms/pep_footer.py
create mode 100644 pep_sphinx_extensions/pep_processor/transforms/pep_headers.py
create mode 100644 pep_sphinx_extensions/pep_processor/transforms/pep_title.py
create mode 100644 pep_sphinx_extensions/pep_processor/transforms/pep_zero.py
diff --git a/build.py b/build.py
index bd615eb14..f5e26a7dc 100644
--- a/build.py
+++ b/build.py
@@ -14,7 +14,7 @@ def create_parser():
# flags / options
parser.add_argument("-f", "--fail-on-warning", action="store_true")
parser.add_argument("-n", "--nitpicky", action="store_true")
- parser.add_argument("-j", "--jobs", type=int)
+ parser.add_argument("-j", "--jobs", type=int, default=1)
# extra build steps
parser.add_argument("-i", "--index-file", action="store_true") # for PEP 0
diff --git a/conf.py b/conf.py
index 34835c38a..91e9dcdfb 100644
--- a/conf.py
+++ b/conf.py
@@ -1,5 +1,10 @@
"""Configuration for building PEPs using Sphinx."""
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path("pep_sphinx_extensions").absolute()))
+
# -- Project information -----------------------------------------------------
project = "PEPs"
@@ -7,10 +12,13 @@ master_doc = "contents"
# -- General configuration ---------------------------------------------------
+# Add any Sphinx extension module names here, as strings.
+extensions = ["pep_sphinx_extensions", "sphinx.ext.githubpages"]
+
# The file extensions of source files. Sphinx uses these suffixes as sources.
source_suffix = {
- ".rst": "restructuredtext",
- ".txt": "restructuredtext",
+ ".rst": "pep",
+ ".txt": "pep",
}
# List of patterns (relative to source dir) to ignore when looking for source files.
@@ -32,6 +40,7 @@ exclude_patterns = [
# -- Options for HTML output -------------------------------------------------
# HTML output settings
+html_math_renderer = "maths_to_html" # Maths rendering
html_show_copyright = False # Turn off miscellany
html_show_sphinx = False
html_title = "peps.python.org" # Set
diff --git a/pep_sphinx_extensions/__init__.py b/pep_sphinx_extensions/__init__.py
new file mode 100644
index 000000000..cac99c626
--- /dev/null
+++ b/pep_sphinx_extensions/__init__.py
@@ -0,0 +1,47 @@
+"""Sphinx extensions for performant PEP processing"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from sphinx.environment import default_settings
+from docutils.writers.html5_polyglot import HTMLTranslator
+
+from pep_sphinx_extensions.pep_processor.html import pep_html_translator
+from pep_sphinx_extensions.pep_processor.parsing import pep_parser
+from pep_sphinx_extensions.pep_processor.parsing import pep_role
+
+if TYPE_CHECKING:
+ from sphinx.application import Sphinx
+
+# Monkeypatch sphinx.environment.default_settings as Sphinx doesn't allow custom settings or Readers
+# These settings should go in docutils.conf, but are overridden here for now so as not to affect
+# pep2html.py
+default_settings |= {
+ "pep_references": True,
+ "rfc_references": True,
+ "pep_base_url": "",
+ "pep_file_url_template": "pep-%04d.html",
+ "_disable_config": True, # disable using docutils.conf whilst running both PEP generators
+}
+
+
+def _depart_maths():
+ pass # No-op callable for the type checker
+
+
+def setup(app: Sphinx) -> dict[str, bool]:
+ """Initialize Sphinx extension."""
+
+ # Register plugin logic
+ app.add_source_parser(pep_parser.PEPParser) # Add PEP transforms
+ app.add_role("pep", pep_role.PEPRole(), override=True) # Transform PEP references to links
+ app.set_translator("html", pep_html_translator.PEPTranslator) # Docutils Node Visitor overrides
+
+ # Mathematics rendering
+ inline_maths = HTMLTranslator.visit_math, _depart_maths
+ block_maths = HTMLTranslator.visit_math_block, _depart_maths
+ app.add_html_math_renderer("maths_to_html", inline_maths, block_maths) # Render maths to HTML
+
+ # Parallel safety: https://www.sphinx-doc.org/en/master/extdev/index.html#extension-metadata
+ return {"parallel_read_safe": True, "parallel_write_safe": True}
diff --git a/pep_sphinx_extensions/config.py b/pep_sphinx_extensions/config.py
new file mode 100644
index 000000000..3ea56aad4
--- /dev/null
+++ b/pep_sphinx_extensions/config.py
@@ -0,0 +1,6 @@
+"""Miscellaneous configuration variables for the PEP Sphinx extensions."""
+
+pep_stem = "pep-{:0>4}"
+pep_url = f"{pep_stem}.html"
+pep_vcs_url = "https://github.com/python/peps/blob/master/"
+pep_commits_url = "https://github.com/python/peps/commits/master/"
diff --git a/pep_sphinx_extensions/pep_processor/html/pep_html_translator.py b/pep_sphinx_extensions/pep_processor/html/pep_html_translator.py
new file mode 100644
index 000000000..9e87daf89
--- /dev/null
+++ b/pep_sphinx_extensions/pep_processor/html/pep_html_translator.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from docutils import nodes
+import sphinx.writers.html5 as html5
+
+if TYPE_CHECKING:
+ from sphinx.builders import html
+
+
+class PEPTranslator(html5.HTML5Translator):
+ """Custom RST -> HTML translation rules for PEPs."""
+
+ def __init__(self, document: nodes.document, builder: html.StandaloneHTMLBuilder):
+ super().__init__(document, builder)
+ self.compact_simple: bool = False
+
+ @staticmethod
+ def should_be_compact_paragraph(node: nodes.paragraph) -> bool:
+ """Check if paragraph should be compact.
+
+ Omitting tags around paragraph nodes gives visually compact lists.
+
+ """
+ # Never compact paragraphs that are children of document or compound.
+ if isinstance(node.parent, (nodes.document, nodes.compound)):
+ return False
+
+ # Check for custom attributes in paragraph.
+ for key, value in node.non_default_attributes().items():
+ # if key equals "classes", carry on
+ # if value is empty, or contains only "first", only "last", or both
+ # "first" and "last", carry on
+ # else return False
+ if any((key != "classes", not set(value) <= {"first", "last"})):
+ return False
+
+ # Only first paragraph can be compact (ignoring initial label & invisible nodes)
+ first = isinstance(node.parent[0], nodes.label)
+ visible_siblings = [child for child in node.parent.children[first:] if not isinstance(child, nodes.Invisible)]
+ if visible_siblings[0] is not node:
+ return False
+
+ # otherwise, the paragraph should be compact
+ return True
+
+ def visit_paragraph(self, node: nodes.paragraph) -> None:
+ """Remove tags if possible."""
+ if self.should_be_compact_paragraph(node):
+ self.context.append("")
+ else:
+ self.body.append(self.starttag(node, "p", ""))
+ self.context.append("
\n")
+
+ def depart_paragraph(self, _: nodes.paragraph) -> None:
+ """Add corresponding end tag from `visit_paragraph`."""
+ self.body.append(self.context.pop())
+
+ def depart_label(self, node) -> None:
+ """PEP link/citation block cleanup with italicised backlinks."""
+ if not self.settings.footnote_backlinks:
+ self.body.append("")
+ self.body.append("\n")
+ return
+
+ # If only one reference to this footnote
+ back_references = node.parent["backrefs"]
+ if len(back_references) == 1:
+ self.body.append("")
+
+ # Close the tag
+ self.body.append("")
+
+ # If more than one reference
+ if len(back_references) > 1:
+ back_links = [f"{i}" for i, ref in enumerate(back_references, start=1)]
+ back_links_str = ", ".join(back_links)
+ self.body.append(f" ({back_links_str}) ")
+
+ # Close the def tags
+ self.body.append("\n")
+
+ def unknown_visit(self, node: nodes.Node) -> None:
+ """No processing for unknown node types."""
+ pass
diff --git a/pep_sphinx_extensions/pep_processor/parsing/pep_parser.py b/pep_sphinx_extensions/pep_processor/parsing/pep_parser.py
new file mode 100644
index 000000000..550d2ce71
--- /dev/null
+++ b/pep_sphinx_extensions/pep_processor/parsing/pep_parser.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from sphinx import parsers
+
+from pep_sphinx_extensions.pep_processor.transforms import pep_headers
+from pep_sphinx_extensions.pep_processor.transforms import pep_title
+from pep_sphinx_extensions.pep_processor.transforms import pep_contents
+from pep_sphinx_extensions.pep_processor.transforms import pep_footer
+
+if TYPE_CHECKING:
+ from docutils import transforms
+
+
+class PEPParser(parsers.RSTParser):
+ """RST parser with custom PEP transforms."""
+
+ supported = ("pep", "python-enhancement-proposal") # for source_suffix in conf.py
+
+ def __init__(self):
+ """Mark the document as containing RFC 2822 headers."""
+ super().__init__(rfc2822=True)
+
+ def get_transforms(self) -> list[type[transforms.Transform]]:
+ """Use our custom PEP transform rules."""
+ return [
+ pep_headers.PEPHeaders,
+ pep_title.PEPTitle,
+ pep_contents.PEPContents,
+ pep_footer.PEPFooter,
+ ]
diff --git a/pep_sphinx_extensions/pep_processor/parsing/pep_role.py b/pep_sphinx_extensions/pep_processor/parsing/pep_role.py
new file mode 100644
index 000000000..5df21a731
--- /dev/null
+++ b/pep_sphinx_extensions/pep_processor/parsing/pep_role.py
@@ -0,0 +1,16 @@
+from sphinx import roles
+
+from pep_sphinx_extensions.config import pep_url
+
+
+class PEPRole(roles.PEP):
+ """Override the :pep: role"""
+
+ def build_uri(self) -> str:
+ """Get PEP URI from role text."""
+ base_url = self.inliner.document.settings.pep_base_url
+ pep_num, _, fragment = self.target.partition("#")
+ pep_base = base_url + pep_url.format(int(pep_num))
+ if fragment:
+ return f"{pep_base}#{fragment}"
+ return pep_base
diff --git a/pep_sphinx_extensions/pep_processor/transforms/pep_contents.py b/pep_sphinx_extensions/pep_processor/transforms/pep_contents.py
new file mode 100644
index 000000000..94caf3ba8
--- /dev/null
+++ b/pep_sphinx_extensions/pep_processor/transforms/pep_contents.py
@@ -0,0 +1,63 @@
+from pathlib import Path
+
+from docutils import nodes
+from docutils import transforms
+from docutils.transforms import parts
+
+
+class PEPContents(transforms.Transform):
+ """Add TOC placeholder and horizontal rule after PEP title and headers."""
+
+ # Use same priority as docutils.transforms.Contents
+ default_priority = 380
+
+ def apply(self) -> None:
+ if not Path(self.document["source"]).match("pep-*"):
+ return # not a PEP file, exit early
+
+ # Create the contents placeholder section
+ title = nodes.title("", "Contents")
+ contents_topic = nodes.topic("", title, classes=["contents"])
+ if not self.document.has_name("contents"):
+ contents_topic["names"].append("contents")
+ self.document.note_implicit_target(contents_topic)
+
+ # Add a table of contents builder
+ pending = nodes.pending(Contents)
+ contents_topic += pending
+ self.document.note_pending(pending)
+
+ # Insert the toc after title and PEP headers
+ self.document.children[0].insert(2, contents_topic)
+
+ # Add a horizontal rule before contents
+ transition = nodes.transition()
+ self.document[0].insert(2, transition)
+
+
+class Contents(parts.Contents):
+ """Build Table of Contents from document."""
+ def __init__(self, document, startnode=None):
+ super().__init__(document, startnode)
+
+ # used in parts.Contents.build_contents
+ self.toc_id = None
+ self.backlinks = None
+
+ def apply(self) -> None:
+ # used in parts.Contents.build_contents
+ self.toc_id = self.startnode.parent["ids"][0]
+ self.backlinks = self.document.settings.toc_backlinks
+
+ # let the writer (or output software) build the contents list?
+ if getattr(self.document.settings, "use_latex_toc", False):
+ # move customisation settings to the parent node
+ self.startnode.parent.attributes.update(self.startnode.details)
+ self.startnode.parent.remove(self.startnode)
+ else:
+ contents = self.build_contents(self.document[0])
+ if contents:
+ self.startnode.replace_self(contents)
+ else:
+ # if no contents, remove the empty placeholder
+ self.startnode.parent.parent.remove(self.startnode.parent)
diff --git a/pep_sphinx_extensions/pep_processor/transforms/pep_footer.py b/pep_sphinx_extensions/pep_processor/transforms/pep_footer.py
new file mode 100644
index 000000000..d959a6bcf
--- /dev/null
+++ b/pep_sphinx_extensions/pep_processor/transforms/pep_footer.py
@@ -0,0 +1,111 @@
+import datetime
+import subprocess
+from pathlib import Path
+
+from docutils import nodes
+from docutils import transforms
+from docutils.transforms import misc
+from docutils.transforms import references
+
+from pep_sphinx_extensions import config
+
+
+class PEPFooter(transforms.Transform):
+ """Footer transforms for PEPs.
+
+ - Appends external links to footnotes.
+ - Creates a link to the (GitHub) source text.
+
+ TargetNotes:
+ Locate the `References` section, insert a placeholder at the end
+ for an external target footnote insertion transform, and schedule
+ the transform to run immediately.
+
+ Source Link:
+ Create the link to the source file from the document source path,
+ and append the text to the end of the document.
+
+ """
+
+ # Uses same priority as docutils.transforms.TargetNotes
+ default_priority = 520
+
+ def apply(self) -> None:
+ pep_source_path = Path(self.document["source"])
+ if not pep_source_path.match("pep-*"):
+ return # not a PEP file, exit early
+
+ doc = self.document[0]
+ reference_section = copyright_section = None
+
+ # Iterate through sections from the end of the document
+ num_sections = len(doc)
+ for i, section in enumerate(reversed(doc)):
+ if not isinstance(section, nodes.section):
+ continue
+ title_words = section[0].astext().lower().split()
+ if "references" in title_words:
+ reference_section = section
+ break
+ elif "copyright" in title_words:
+ copyright_section = num_sections - i - 1
+
+ # Add a references section if we didn't find one
+ if not reference_section:
+ reference_section = nodes.section()
+ reference_section += nodes.title("", "References")
+ self.document.set_id(reference_section)
+ if copyright_section:
+ # Put the new "References" section before "Copyright":
+ doc.insert(copyright_section, reference_section)
+ else:
+ # Put the new "References" section at end of doc:
+ doc.append(reference_section)
+
+ # Add and schedule execution of the TargetNotes transform
+ pending = nodes.pending(references.TargetNotes)
+ reference_section.append(pending)
+ self.document.note_pending(pending, priority=0)
+
+ # If there are no references after TargetNotes has finished, remove the
+ # references section
+ pending = nodes.pending(misc.CallBack, details={"callback": self.cleanup_callback})
+ reference_section.append(pending)
+ self.document.note_pending(pending, priority=1)
+
+ # Add link to source text and last modified date
+ self.add_source_link(pep_source_path)
+ self.add_commit_history_info(pep_source_path)
+
+ @staticmethod
+ def cleanup_callback(pending: nodes.pending) -> None:
+ """Remove an empty "References" section.
+
+ Called after the `references.TargetNotes` transform is complete.
+
+ """
+ if len(pending.parent) == 2: # and
+ pending.parent.parent.remove(pending.parent)
+
+ def add_source_link(self, pep_source_path: Path) -> None:
+ """Add link to source text on VCS (GitHub)"""
+ source_link = config.pep_vcs_url + pep_source_path.name
+ link_node = nodes.reference("", source_link, refuri=source_link)
+ span_node = nodes.inline("", "Source: ", link_node)
+ self.document.append(span_node)
+
+ def add_commit_history_info(self, pep_source_path: Path) -> None:
+ """Use local git history to find last modified date."""
+ args = ["git", "--no-pager", "log", "-1", "--format=%at", pep_source_path.name]
+ try:
+ file_modified = subprocess.check_output(args)
+ since_epoch = file_modified.decode("utf-8").strip()
+ dt = datetime.datetime.utcfromtimestamp(float(since_epoch))
+ except (subprocess.CalledProcessError, ValueError):
+ return None
+
+ commit_link = config.pep_commits_url + pep_source_path.name
+ link_node = nodes.reference("", f"{dt.isoformat()}Z", refuri=commit_link)
+ span_node = nodes.inline("", "Last modified: ", link_node)
+ self.document.append(nodes.line("", "", classes=["zero-height"]))
+ self.document.append(span_node)
diff --git a/pep_sphinx_extensions/pep_processor/transforms/pep_headers.py b/pep_sphinx_extensions/pep_processor/transforms/pep_headers.py
new file mode 100644
index 000000000..259966c4a
--- /dev/null
+++ b/pep_sphinx_extensions/pep_processor/transforms/pep_headers.py
@@ -0,0 +1,119 @@
+import re
+from pathlib import Path
+
+from docutils import nodes
+from docutils import transforms
+from docutils.transforms import peps
+from sphinx import errors
+
+from pep_sphinx_extensions.pep_processor.transforms import pep_zero
+from pep_sphinx_extensions.config import pep_url
+
+
+class PEPParsingError(errors.SphinxError):
+ pass
+
+
+# PEPHeaders is identical to docutils.transforms.peps.Headers excepting bdfl-delegate, sponsor & superseeded-by
+class PEPHeaders(transforms.Transform):
+ """Process fields in a PEP's initial RFC-2822 header."""
+
+ # Run before pep_processor.transforms.pep_title.PEPTitle
+ default_priority = 330
+
+ def apply(self) -> None:
+ if not Path(self.document["source"]).match("pep-*"):
+ return # not a PEP file, exit early
+
+ if not len(self.document):
+ raise PEPParsingError("Document tree is empty.")
+
+ header = self.document[0]
+ if not isinstance(header, nodes.field_list) or "rfc2822" not in header["classes"]:
+ raise PEPParsingError("Document does not begin with an RFC-2822 header; it is not a PEP.")
+
+ # PEP number should be the first field
+ pep_field = header[0]
+ if pep_field[0].astext().lower() != "pep":
+ raise PEPParsingError("Document does not contain an RFC-2822 'PEP' header!")
+
+ # Extract PEP number
+ value = pep_field[1].astext()
+ try:
+ pep = int(value)
+ except ValueError:
+ raise PEPParsingError(f"'PEP' header must contain an integer. '{value}' is invalid!")
+
+ # Special processing for PEP 0.
+ if pep == 0:
+ pending = nodes.pending(pep_zero.PEPZero)
+ self.document.insert(1, pending)
+ self.document.note_pending(pending)
+
+ # If there are less than two headers in the preamble, or if Title is absent
+ if len(header) < 2 or header[1][0].astext().lower() != "title":
+ raise PEPParsingError("No title!")
+
+ fields_to_remove = []
+ for field in header:
+ name = field[0].astext().lower()
+ body = field[1]
+ if len(body) == 0:
+ # body is empty
+ continue
+ elif len(body) > 1:
+ msg = f"PEP header field body contains multiple elements:\n{field.pformat(level=1)}"
+ raise PEPParsingError(msg)
+ elif not isinstance(body[0], nodes.paragraph): # len(body) == 1
+ msg = f"PEP header field body may only contain a single paragraph:\n{field.pformat(level=1)}"
+ raise PEPParsingError(msg)
+
+ para = body[0]
+ if name in {"author", "bdfl-delegate", "pep-delegate", "sponsor"}:
+ # mask emails
+ for node in para:
+ if isinstance(node, nodes.reference):
+ pep_num = pep if name == "discussions-to" else -1
+ node.replace_self(peps.mask_email(node, pep_num))
+ elif name in {"replaces", "superseded-by", "requires"}:
+ # replace PEP numbers with normalised list of links to PEPs
+ new_body = []
+ space = nodes.Text(" ")
+ for ref_pep in re.split(r",?\s+", body.astext()):
+ new_body.append(nodes.reference(
+ ref_pep, ref_pep,
+ refuri=(self.document.settings.pep_base_url + pep_url.format(int(ref_pep)))))
+ new_body.append(space)
+ para[:] = new_body[:-1] # drop trailing space
+ elif name in {"last-modified", "content-type", "version"}:
+ # Mark unneeded fields
+ fields_to_remove.append(field)
+
+ # Remove unneeded fields
+ for field in fields_to_remove:
+ field.parent.remove(field)
+
+
+def _mask_email(ref: nodes.reference, pep_num: int = -1) -> nodes.reference:
+ """Mask the email address in `ref` and return a replacement node.
+
+ `ref` is returned unchanged if it contains no email address.
+
+ If given an email not explicitly whitelisted, process it such that
+ `user@host` -> `user at host`.
+
+ If given a PEP number `pep_num`, add a default email subject.
+
+ """
+ if "refuri" in ref and ref["refuri"].startswith("mailto:"):
+ non_masked_addresses = {"peps@python.org", "python-list@python.org", "python-dev@python.org"}
+ if ref['refuri'].removeprefix("mailto:").strip() in non_masked_addresses:
+ replacement = ref[0]
+ else:
+ replacement_text = ref.astext().replace("@", " at ")
+ replacement = nodes.raw('', replacement_text, format="html")
+
+ if pep_num != -1:
+ replacement['refuri'] += f"?subject=PEP%20{pep_num}"
+ return replacement
+ return ref
diff --git a/pep_sphinx_extensions/pep_processor/transforms/pep_title.py b/pep_sphinx_extensions/pep_processor/transforms/pep_title.py
new file mode 100644
index 000000000..84657fbad
--- /dev/null
+++ b/pep_sphinx_extensions/pep_processor/transforms/pep_title.py
@@ -0,0 +1,49 @@
+from pathlib import Path
+
+from docutils import nodes
+import docutils.transforms as transforms
+
+
+class PEPTitle(transforms.Transform):
+ """Add PEP title and organise document hierarchy."""
+
+ # needs to run before docutils.transforms.frontmatter.DocInfo and after
+ # pep_processor.transforms.pep_title.PEPTitle
+ default_priority = 335
+
+ def apply(self) -> None:
+ if not Path(self.document["source"]).match("pep-*"):
+ return # not a PEP file, exit early
+
+ # Directory to hold the PEP's RFC2822 header details, to extract a title string
+ pep_header_details = {}
+
+ # Iterate through the header fields, which are the first section of the document
+ for field in self.document[0]:
+ # Hold details of the attribute's tag against its details
+ row_attributes = {sub.tagname: sub.rawsource for sub in field}
+ pep_header_details[row_attributes["field_name"]] = row_attributes["field_body"]
+
+ # We only need the PEP number and title
+ if pep_header_details.keys() >= {"PEP", "Title"}:
+ break
+
+ # Create the title string for the PEP
+ pep_number = int(pep_header_details["PEP"])
+ pep_title = pep_header_details["Title"]
+ pep_title_string = f"PEP {pep_number} -- {pep_title}" # double hyphen for en dash
+
+ # Generate the title section node and its properties
+ pep_title_node = nodes.section()
+ text_node = nodes.Text(pep_title_string, pep_title_string)
+ title_node = nodes.title(pep_title_string, "", text_node)
+ title_node["classes"].append("page-title")
+ name = " ".join(title_node.astext().lower().split()) # normalise name
+ pep_title_node["names"].append(name)
+ pep_title_node += title_node
+
+ # Insert the title node as the root element, move children down
+ document_children = self.document.children
+ self.document.children = [pep_title_node]
+ pep_title_node.extend(document_children)
+ self.document.note_implicit_target(pep_title_node, pep_title_node)
diff --git a/pep_sphinx_extensions/pep_processor/transforms/pep_zero.py b/pep_sphinx_extensions/pep_processor/transforms/pep_zero.py
new file mode 100644
index 000000000..bfaa82a41
--- /dev/null
+++ b/pep_sphinx_extensions/pep_processor/transforms/pep_zero.py
@@ -0,0 +1,74 @@
+from docutils import nodes
+from docutils import transforms
+from docutils.transforms import peps
+
+from pep_sphinx_extensions.config import pep_url
+
+
+class PEPZero(transforms.Transform):
+ """Schedule PEP 0 processing."""
+
+ # Run during sphinx post processing
+ default_priority = 760
+
+ def apply(self) -> None:
+ # Walk document and then remove this node
+ visitor = PEPZeroSpecial(self.document)
+ self.document.walk(visitor)
+ self.startnode.parent.remove(self.startnode)
+
+
+class PEPZeroSpecial(nodes.SparseNodeVisitor):
+ """Perform the special processing needed by PEP 0:
+
+ - Mask email addresses.
+ - Link PEP numbers in the second column of 4-column tables to the PEPs themselves.
+
+ """
+
+ def __init__(self, document: nodes.document):
+ super().__init__(document)
+ self.pep_table: int = 0
+ self.entry: int = 0
+
+ def unknown_visit(self, node: nodes.Node) -> None:
+ """No processing for undefined node types."""
+ pass
+
+ @staticmethod
+ def visit_reference(node: nodes.reference) -> None:
+ """Mask email addresses if present."""
+ node.replace_self(peps.mask_email(node))
+
+ @staticmethod
+ def visit_field_list(node: nodes.field_list) -> None:
+ """Skip PEP headers."""
+ if "rfc2822" in node["classes"]:
+ raise nodes.SkipNode
+
+ def visit_tgroup(self, node: nodes.tgroup) -> None:
+ """Set column counter and PEP table marker."""
+ self.pep_table = node["cols"] == 4
+ self.entry = 0 # reset column number
+
+ def visit_colspec(self, node: nodes.colspec) -> None:
+ self.entry += 1
+ if self.pep_table and self.entry == 2:
+ node["classes"].append("num")
+
+ def visit_row(self, _node: nodes.row) -> None:
+ self.entry = 0 # reset column number
+
+ def visit_entry(self, node: nodes.entry) -> None:
+ self.entry += 1
+ if self.pep_table and self.entry == 2 and len(node) == 1:
+ node["classes"].append("num")
+ # if this is the PEP number column, replace the number with a link to the PEP
+ para = node[0]
+ if isinstance(para, nodes.paragraph) and len(para) == 1:
+ pep_str = para.astext()
+ try:
+ ref = self.document.settings.pep_base_url + pep_url.format(int(pep_str))
+ para[0] = nodes.reference(pep_str, pep_str, refuri=ref)
+ except ValueError:
+ pass