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
This commit is contained in:
parent
353379966d
commit
0f3bbd9ad9
2
build.py
2
build.py
|
@ -14,7 +14,7 @@ def create_parser():
|
||||||
# flags / options
|
# flags / options
|
||||||
parser.add_argument("-f", "--fail-on-warning", action="store_true")
|
parser.add_argument("-f", "--fail-on-warning", action="store_true")
|
||||||
parser.add_argument("-n", "--nitpicky", 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
|
# extra build steps
|
||||||
parser.add_argument("-i", "--index-file", action="store_true") # for PEP 0
|
parser.add_argument("-i", "--index-file", action="store_true") # for PEP 0
|
||||||
|
|
13
conf.py
13
conf.py
|
@ -1,5 +1,10 @@
|
||||||
"""Configuration for building PEPs using Sphinx."""
|
"""Configuration for building PEPs using Sphinx."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.append(str(Path("pep_sphinx_extensions").absolute()))
|
||||||
|
|
||||||
# -- Project information -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
|
|
||||||
project = "PEPs"
|
project = "PEPs"
|
||||||
|
@ -7,10 +12,13 @@ master_doc = "contents"
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- 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.
|
# The file extensions of source files. Sphinx uses these suffixes as sources.
|
||||||
source_suffix = {
|
source_suffix = {
|
||||||
".rst": "restructuredtext",
|
".rst": "pep",
|
||||||
".txt": "restructuredtext",
|
".txt": "pep",
|
||||||
}
|
}
|
||||||
|
|
||||||
# List of patterns (relative to source dir) to ignore when looking for source files.
|
# List of patterns (relative to source dir) to ignore when looking for source files.
|
||||||
|
@ -32,6 +40,7 @@ exclude_patterns = [
|
||||||
# -- Options for HTML output -------------------------------------------------
|
# -- Options for HTML output -------------------------------------------------
|
||||||
|
|
||||||
# HTML output settings
|
# HTML output settings
|
||||||
|
html_math_renderer = "maths_to_html" # Maths rendering
|
||||||
html_show_copyright = False # Turn off miscellany
|
html_show_copyright = False # Turn off miscellany
|
||||||
html_show_sphinx = False
|
html_show_sphinx = False
|
||||||
html_title = "peps.python.org" # Set <title/>
|
html_title = "peps.python.org" # Set <title/>
|
||||||
|
|
|
@ -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}
|
|
@ -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/"
|
|
@ -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
|
|
@ -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,
|
||||||
|
]
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
Loading…
Reference in New Issue