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 <p/> 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 <p> tags if possible.""" + if self.should_be_compact_paragraph(node): + self.context.append("") + else: + self.body.append(self.starttag(node, "p", "")) + self.context.append("</p>\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("</span>") + self.body.append("</dt>\n<dd>") + return + + # If only one reference to this footnote + back_references = node.parent["backrefs"] + if len(back_references) == 1: + self.body.append("</a>") + + # Close the tag + self.body.append("</span>") + + # If more than one reference + if len(back_references) > 1: + back_links = [f"<a href='#{ref}'>{i}</a>" for i, ref in enumerate(back_references, start=1)] + back_links_str = ", ".join(back_links) + self.body.append(f"<span class='fn-backref''><em> ({back_links_str}) </em></span>") + + # Close the def tags + self.body.append("</dt>\n<dd>") + + 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: # <title> and <pending> + 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