Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sphinx - docutils #1931

Merged
merged 15 commits into from
Jun 9, 2021
2 changes: 1 addition & 1 deletion build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions conf.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
"""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"
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.
Expand All @@ -32,6 +40,7 @@
# -- 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 <title/>
47 changes: 47 additions & 0 deletions pep_sphinx_extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a bit brittle, is not a show stopper but I would prefer if we don't do this as this is a potential future breackage

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is overriding docutils.conf, which I didn't want to touch following the discussion about running old + new at the same time, as it is implicitly used in pep2html.py. I can add a explanatory comment if you'd like, as the end state would be to move this config into docutils.conf.

This is all just disabling default docutils stuff, apart from the PEP/RFC references, which I believe are on by default but wanted to declare explicitly.

# 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}
6 changes: 6 additions & 0 deletions pep_sphinx_extensions/config.py
Original file line number Diff line number Diff line change
@@ -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/"
86 changes: 86 additions & 0 deletions pep_sphinx_extensions/pep_processor/html/pep_html_translator.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions pep_sphinx_extensions/pep_processor/parsing/pep_parser.py
Original file line number Diff line number Diff line change
@@ -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,
]
16 changes: 16 additions & 0 deletions pep_sphinx_extensions/pep_processor/parsing/pep_role.py
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions pep_sphinx_extensions/pep_processor/transforms/pep_contents.py
Original file line number Diff line number Diff line change
@@ -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)
Loading