Sphinx support: add PEP 0 generation extension (1932)
* Add PEP 0 parser * Add PEP 0 writer * Add PEP 0 generator and authors override * Add/update build and run * Simplify `create_index_file` * Special status handling * Add constants for PEP related magic strings * Prefer checking on class * Add PEP.hide_status, use constants * Remove comment from 2008 (current method works fine) * Clarify intent of for-else loop * Hook in to Sphinx (oops, missed when splitting out this PR) * Rename AUTHORS.csv for clarity * Sort and strip spaces * Prefer `authors_overrides` name * Add pep_0_errors.py * Move author_sort_by to writer * PEP init misc * Split out Author * Drop pep_0 prefix * Pass title length as an argument * Add constants.py to hold global type / status values * Capitalise constants * Capitalise constants * Update PEP classification algorithm * Extract static methods to module level * Add emit_text, emit_pep_row * Use constants in writer.py * Sort imports * Sort constants * Fix sorting in historical and dead PEPs * Extract static methods to module level * Extract static methods to module level (parser.py * Make Author a NamedTuple * Fix author duplication bug with NamedTuples * Revert to old PEP classification algorithm * Define PEP equality
This commit is contained in:
parent
0f278396f4
commit
749c2d9a37
|
@ -0,0 +1,11 @@
|
||||||
|
Overridden Name,Surname First,Name Reference
|
||||||
|
The Python core team and community,The Python core team and community,python-dev
|
||||||
|
Ernest W. Durbin III,"Durbin, Ernest W., III",Durbin
|
||||||
|
Greg Ewing,"Ewing, Gregory",Ewing
|
||||||
|
Guido van Rossum,"van Rossum, Guido (GvR)",GvR
|
||||||
|
Inada Naoki,"Inada, Naoki",Inada
|
||||||
|
Jim Jewett,"Jewett, Jim J.",Jewett
|
||||||
|
Just van Rossum,"van Rossum, Just (JvR)",JvR
|
||||||
|
Martin v. Löwis,"von Löwis, Martin",von Löwis
|
||||||
|
Nathaniel Smith,"Smith, Nathaniel J.",Smith
|
||||||
|
P.J. Eby,"Eby, Phillip J.",Eby
|
|
11
build.py
11
build.py
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
|
||||||
from sphinx.application import Sphinx
|
from sphinx.application import Sphinx
|
||||||
|
|
||||||
|
@ -24,6 +25,13 @@ def create_parser():
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def create_index_file(html_root: Path):
|
||||||
|
"""Copies PEP 0 to the root index.html so that /peps/ works."""
|
||||||
|
pep_zero_path = html_root / "pep-0000" / "index.html"
|
||||||
|
if pep_zero_path.is_file():
|
||||||
|
shutil.copy(pep_zero_path, html_root / "index.html")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
args = create_parser()
|
args = create_parser()
|
||||||
|
|
||||||
|
@ -60,3 +68,6 @@ if __name__ == "__main__":
|
||||||
)
|
)
|
||||||
app.builder.copysource = False # Prevent unneeded source copying - we link direct to GitHub
|
app.builder.copysource = False # Prevent unneeded source copying - we link direct to GitHub
|
||||||
app.build()
|
app.build()
|
||||||
|
|
||||||
|
if args.index_file:
|
||||||
|
create_index_file(build_directory)
|
||||||
|
|
|
@ -4,12 +4,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sphinx.environment import default_settings
|
|
||||||
from docutils.writers.html5_polyglot import HTMLTranslator
|
from docutils.writers.html5_polyglot import HTMLTranslator
|
||||||
|
from sphinx.environment import default_settings
|
||||||
|
|
||||||
from pep_sphinx_extensions.pep_processor.html import pep_html_translator
|
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_parser
|
||||||
from pep_sphinx_extensions.pep_processor.parsing import pep_role
|
from pep_sphinx_extensions.pep_processor.parsing import pep_role
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.pep_index_generator import create_pep_zero
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sphinx.application import Sphinx
|
from sphinx.application import Sphinx
|
||||||
|
@ -37,6 +38,7 @@ def setup(app: Sphinx) -> dict[str, bool]:
|
||||||
app.add_source_parser(pep_parser.PEPParser) # Add PEP transforms
|
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.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
|
app.set_translator("html", pep_html_translator.PEPTranslator) # Docutils Node Visitor overrides
|
||||||
|
app.connect("env-before-read-docs", create_pep_zero) # PEP 0 hook
|
||||||
|
|
||||||
# Mathematics rendering
|
# Mathematics rendering
|
||||||
inline_maths = HTMLTranslator.visit_math, _depart_maths
|
inline_maths = HTMLTranslator.visit_math, _depart_maths
|
||||||
|
|
|
@ -4,10 +4,10 @@ from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sphinx import parsers
|
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_contents
|
||||||
from pep_sphinx_extensions.pep_processor.transforms import pep_footer
|
from pep_sphinx_extensions.pep_processor.transforms import pep_footer
|
||||||
|
from pep_sphinx_extensions.pep_processor.transforms import pep_headers
|
||||||
|
from pep_sphinx_extensions.pep_processor.transforms import pep_title
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from docutils import transforms
|
from docutils import transforms
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import datetime
|
import datetime
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from docutils import nodes
|
from docutils import nodes
|
||||||
from docutils import transforms
|
from docutils import transforms
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
from docutils import nodes
|
from docutils import nodes
|
||||||
from docutils import transforms
|
from docutils import transforms
|
||||||
from docutils.transforms import peps
|
from docutils.transforms import peps
|
||||||
from sphinx import errors
|
from sphinx import errors
|
||||||
|
|
||||||
from pep_sphinx_extensions.pep_processor.transforms import pep_zero
|
|
||||||
from pep_sphinx_extensions.config import pep_url
|
from pep_sphinx_extensions.config import pep_url
|
||||||
|
from pep_sphinx_extensions.pep_processor.transforms import pep_zero
|
||||||
|
|
||||||
|
|
||||||
class PEPParsingError(errors.SphinxError):
|
class PEPParsingError(errors.SphinxError):
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
|
||||||
|
class _Name(NamedTuple):
|
||||||
|
mononym: str = None
|
||||||
|
forename: str = None
|
||||||
|
surname: str = None
|
||||||
|
suffix: str = None
|
||||||
|
|
||||||
|
|
||||||
|
class Author(NamedTuple):
|
||||||
|
"""Represent PEP authors."""
|
||||||
|
last_first: str # The author's name in Surname, Forename, Suffix order.
|
||||||
|
nick: str # Author's nickname for PEP tables. Defaults to surname.
|
||||||
|
email: str # The author's email address.
|
||||||
|
|
||||||
|
|
||||||
|
def parse_author_email(author_email_tuple: tuple[str, str], authors_overrides: dict[str, dict[str, str]]) -> Author:
|
||||||
|
"""Parse the name and email address of an author."""
|
||||||
|
name, email = author_email_tuple
|
||||||
|
_first_last = name.strip()
|
||||||
|
email = email.lower()
|
||||||
|
|
||||||
|
if _first_last in authors_overrides:
|
||||||
|
name_dict = authors_overrides[_first_last]
|
||||||
|
last_first = name_dict["Surname First"]
|
||||||
|
nick = name_dict["Name Reference"]
|
||||||
|
return Author(last_first, nick, email)
|
||||||
|
|
||||||
|
name_parts = _parse_name(_first_last)
|
||||||
|
if name_parts.mononym is not None:
|
||||||
|
return Author(name_parts.mononym, name_parts.mononym, email)
|
||||||
|
|
||||||
|
if name_parts.surname[1] == ".":
|
||||||
|
# Add an escape to avoid docutils turning `v.` into `22.`.
|
||||||
|
name_parts.surname = f"\\{name_parts.surname}"
|
||||||
|
|
||||||
|
if name_parts.suffix:
|
||||||
|
last_first = f"{name_parts.surname}, {name_parts.forename}, {name_parts.suffix}"
|
||||||
|
return Author(last_first, name_parts.surname, email)
|
||||||
|
|
||||||
|
last_first = f"{name_parts.surname}, {name_parts.forename}"
|
||||||
|
return Author(last_first, name_parts.surname, email)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_name(full_name: str) -> _Name:
|
||||||
|
"""Decompose a full name into parts.
|
||||||
|
|
||||||
|
If a mononym (e.g, 'Aahz') then return the full name. If there are
|
||||||
|
suffixes in the name (e.g. ', Jr.' or 'II'), then find and extract
|
||||||
|
them. If there is a middle initial followed by a full stop, then
|
||||||
|
combine the following words into a surname (e.g. N. Vander Weele). If
|
||||||
|
there is a leading, lowercase portion to the last name (e.g. 'van' or
|
||||||
|
'von') then include it in the surname.
|
||||||
|
|
||||||
|
"""
|
||||||
|
possible_suffixes = {"Jr", "Jr.", "II", "III"}
|
||||||
|
|
||||||
|
pre_suffix, _, raw_suffix = full_name.partition(",")
|
||||||
|
name_parts = pre_suffix.strip().split(" ")
|
||||||
|
num_parts = len(name_parts)
|
||||||
|
suffix = raw_suffix.strip()
|
||||||
|
|
||||||
|
if num_parts == 0:
|
||||||
|
raise ValueError("Name is empty!")
|
||||||
|
elif num_parts == 1:
|
||||||
|
return _Name(mononym=name_parts[0], suffix=suffix)
|
||||||
|
elif num_parts == 2:
|
||||||
|
return _Name(forename=name_parts[0].strip(), surname=name_parts[1], suffix=suffix)
|
||||||
|
|
||||||
|
# handles rogue uncaught suffixes
|
||||||
|
if name_parts[-1] in possible_suffixes:
|
||||||
|
suffix = f"{name_parts.pop(-1)} {suffix}".strip()
|
||||||
|
|
||||||
|
# handles von, van, v. etc.
|
||||||
|
if name_parts[-2].islower():
|
||||||
|
forename = " ".join(name_parts[:-2]).strip()
|
||||||
|
surname = " ".join(name_parts[-2:])
|
||||||
|
return _Name(forename=forename, surname=surname, suffix=suffix)
|
||||||
|
|
||||||
|
# handles double surnames after a middle initial (e.g. N. Vander Weele)
|
||||||
|
elif any(s.endswith(".") for s in name_parts):
|
||||||
|
split_position = [i for i, x in enumerate(name_parts) if x.endswith(".")][-1] + 1
|
||||||
|
forename = " ".join(name_parts[:split_position]).strip()
|
||||||
|
surname = " ".join(name_parts[split_position:])
|
||||||
|
return _Name(forename=forename, surname=surname, suffix=suffix)
|
||||||
|
|
||||||
|
# default to using the last item as the surname
|
||||||
|
else:
|
||||||
|
forename = " ".join(name_parts[:-1]).strip()
|
||||||
|
return _Name(forename=forename, surname=name_parts[-1], suffix=suffix)
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""Holds type and status constants for PEP 0 generation."""
|
||||||
|
|
||||||
|
STATUS_ACCEPTED = "Accepted"
|
||||||
|
STATUS_ACTIVE = "Active"
|
||||||
|
STATUS_DEFERRED = "Deferred"
|
||||||
|
STATUS_DRAFT = "Draft"
|
||||||
|
STATUS_FINAL = "Final"
|
||||||
|
STATUS_PROVISIONAL = "Provisional"
|
||||||
|
STATUS_REJECTED = "Rejected"
|
||||||
|
STATUS_SUPERSEDED = "Superseded"
|
||||||
|
STATUS_WITHDRAWN = "Withdrawn"
|
||||||
|
|
||||||
|
# Valid values for the Status header.
|
||||||
|
STATUS_VALUES = {
|
||||||
|
STATUS_ACCEPTED, STATUS_PROVISIONAL, STATUS_REJECTED, STATUS_WITHDRAWN,
|
||||||
|
STATUS_DEFERRED, STATUS_FINAL, STATUS_ACTIVE, STATUS_DRAFT, STATUS_SUPERSEDED,
|
||||||
|
}
|
||||||
|
# Map of invalid/special statuses to their valid counterparts
|
||||||
|
SPECIAL_STATUSES = {
|
||||||
|
"April Fool!": STATUS_REJECTED, # See PEP 401 :)
|
||||||
|
}
|
||||||
|
# Draft PEPs have no status displayed, Active shares a key with Accepted
|
||||||
|
HIDE_STATUS = {STATUS_DRAFT, STATUS_ACTIVE}
|
||||||
|
# Dead PEP statuses
|
||||||
|
DEAD_STATUSES = {STATUS_REJECTED, STATUS_WITHDRAWN, STATUS_SUPERSEDED}
|
||||||
|
|
||||||
|
TYPE_INFO = "Informational"
|
||||||
|
TYPE_PROCESS = "Process"
|
||||||
|
TYPE_STANDARDS = "Standards Track"
|
||||||
|
|
||||||
|
# Valid values for the Type header.
|
||||||
|
TYPE_VALUES = {TYPE_STANDARDS, TYPE_INFO, TYPE_PROCESS}
|
||||||
|
# Active PEPs can only be for Informational or Process PEPs.
|
||||||
|
ACTIVE_ALLOWED = {TYPE_PROCESS, TYPE_INFO}
|
|
@ -0,0 +1,16 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class PEPError(Exception):
|
||||||
|
def __init__(self, error: str, pep_file: Path, pep_number: int | None = None):
|
||||||
|
super().__init__(error)
|
||||||
|
self.filename = pep_file
|
||||||
|
self.number = pep_number
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
error_msg = super(PEPError, self).__str__()
|
||||||
|
error_msg = f"({self.filename}): {error_msg}"
|
||||||
|
pep_str = f"PEP {self.number}"
|
||||||
|
return f"{pep_str} {error_msg}" if self.number is not None else error_msg
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""Code for handling object representation of a PEP."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from email.parser import HeaderParser
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import textwrap
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.author import parse_author_email
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import ACTIVE_ALLOWED
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import HIDE_STATUS
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import SPECIAL_STATUSES
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_ACTIVE
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_PROVISIONAL
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_VALUES
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import TYPE_STANDARDS
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import TYPE_VALUES
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.errors import PEPError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.author import Author
|
||||||
|
|
||||||
|
|
||||||
|
class PEP:
|
||||||
|
"""Representation of PEPs.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
number : PEP number.
|
||||||
|
title : PEP title.
|
||||||
|
pep_type : The type of PEP. Can only be one of the values from TYPE_VALUES.
|
||||||
|
status : The PEP's status. Value must be found in STATUS_VALUES.
|
||||||
|
authors : A list of the authors.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The required RFC 822 headers for all PEPs.
|
||||||
|
required_headers = {"PEP", "Title", "Author", "Status", "Type", "Created"}
|
||||||
|
|
||||||
|
def __init__(self, filename: Path, authors_overrides: dict):
|
||||||
|
"""Init object from an open PEP file object.
|
||||||
|
|
||||||
|
pep_file is full text of the PEP file, filename is path of the PEP file, author_lookup is author exceptions file
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.filename: Path = filename
|
||||||
|
|
||||||
|
# Parse the headers.
|
||||||
|
pep_text = filename.read_text(encoding="utf-8")
|
||||||
|
metadata = HeaderParser().parsestr(pep_text)
|
||||||
|
required_header_misses = PEP.required_headers - set(metadata.keys())
|
||||||
|
if required_header_misses:
|
||||||
|
_raise_pep_error(self, f"PEP is missing required headers {required_header_misses}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.number = int(metadata["PEP"])
|
||||||
|
except ValueError:
|
||||||
|
_raise_pep_error(self, "PEP number isn't an integer")
|
||||||
|
|
||||||
|
# Check PEP number matches filename
|
||||||
|
if self.number != int(filename.stem[4:]):
|
||||||
|
_raise_pep_error(self, f"PEP number does not match file name ({filename})", pep_num=True)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
self.title: str = metadata["Title"]
|
||||||
|
|
||||||
|
# Type
|
||||||
|
self.pep_type: str = metadata["Type"]
|
||||||
|
if self.pep_type not in TYPE_VALUES:
|
||||||
|
_raise_pep_error(self, f"{self.pep_type} is not a valid Type value", pep_num=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status = metadata["Status"]
|
||||||
|
if status in SPECIAL_STATUSES:
|
||||||
|
status = SPECIAL_STATUSES[status]
|
||||||
|
if status not in STATUS_VALUES:
|
||||||
|
_raise_pep_error(self, f"{status} is not a valid Status value", pep_num=True)
|
||||||
|
|
||||||
|
# Special case for Active PEPs.
|
||||||
|
if status == STATUS_ACTIVE and self.pep_type not in ACTIVE_ALLOWED:
|
||||||
|
msg = "Only Process and Informational PEPs may have an Active status"
|
||||||
|
_raise_pep_error(self, msg, pep_num=True)
|
||||||
|
|
||||||
|
# Special case for Provisional PEPs.
|
||||||
|
if status == STATUS_PROVISIONAL and self.pep_type != TYPE_STANDARDS:
|
||||||
|
msg = "Only Standards Track PEPs may have a Provisional status"
|
||||||
|
_raise_pep_error(self, msg, pep_num=True)
|
||||||
|
self.status: str = status
|
||||||
|
|
||||||
|
# Parse PEP authors
|
||||||
|
self.authors: list[Author] = _parse_authors(self, metadata["Author"], authors_overrides)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<PEP {self.number:0>4} - {self.title}>"
|
||||||
|
|
||||||
|
def __lt__(self, other: PEP) -> bool:
|
||||||
|
return self.number < other.number
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.number == other.number
|
||||||
|
|
||||||
|
def details(self, *, title_length) -> dict[str, str | int]:
|
||||||
|
"""Return the line entry for the PEP."""
|
||||||
|
return {
|
||||||
|
# how the type is to be represented in the index
|
||||||
|
"type": self.pep_type[0].upper(),
|
||||||
|
"number": self.number,
|
||||||
|
"title": _title_abbr(self.title, title_length),
|
||||||
|
# how the status should be represented in the index
|
||||||
|
"status": " " if self.status in HIDE_STATUS else self.status[0].upper(),
|
||||||
|
# the author list as a comma-separated with only last names
|
||||||
|
"authors": ", ".join(author.nick for author in self.authors),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_pep_error(pep: PEP, msg: str, pep_num: bool = False) -> None:
|
||||||
|
if pep_num:
|
||||||
|
raise PEPError(msg, pep.filename, pep_number=pep.number)
|
||||||
|
raise PEPError(msg, pep.filename)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_authors(pep: PEP, author_header: str, authors_overrides: dict) -> list[Author]:
|
||||||
|
"""Parse Author header line"""
|
||||||
|
authors_and_emails = _parse_author(author_header)
|
||||||
|
if not authors_and_emails:
|
||||||
|
raise _raise_pep_error(pep, "no authors found", pep_num=True)
|
||||||
|
return [parse_author_email(author_tuple, authors_overrides) for author_tuple in authors_and_emails]
|
||||||
|
|
||||||
|
|
||||||
|
author_angled = re.compile(r"(?P<author>.+?) <(?P<email>.+?)>(,\s*)?")
|
||||||
|
author_paren = re.compile(r"(?P<email>.+?) \((?P<author>.+?)\)(,\s*)?")
|
||||||
|
author_simple = re.compile(r"(?P<author>[^,]+)(,\s*)?")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_author(data: str) -> list[tuple[str, str]]:
|
||||||
|
"""Return a list of author names and emails."""
|
||||||
|
|
||||||
|
author_list = []
|
||||||
|
for regex in (author_angled, author_paren, author_simple):
|
||||||
|
for match in regex.finditer(data):
|
||||||
|
# Watch out for suffixes like 'Jr.' when they are comma-separated
|
||||||
|
# from the name and thus cause issues when *all* names are only
|
||||||
|
# separated by commas.
|
||||||
|
match_dict = match.groupdict()
|
||||||
|
author = match_dict["author"]
|
||||||
|
if not author.partition(" ")[1] and author.endswith("."):
|
||||||
|
prev_author = author_list.pop()
|
||||||
|
author = ", ".join([prev_author, author])
|
||||||
|
if "email" not in match_dict:
|
||||||
|
email = ""
|
||||||
|
else:
|
||||||
|
email = match_dict["email"]
|
||||||
|
author_list.append((author, email))
|
||||||
|
|
||||||
|
# If authors were found then stop searching as only expect one
|
||||||
|
# style of author citation.
|
||||||
|
if author_list:
|
||||||
|
break
|
||||||
|
return author_list
|
||||||
|
|
||||||
|
|
||||||
|
def _title_abbr(title, title_length) -> str:
|
||||||
|
"""Shorten the title to be no longer than the max title length."""
|
||||||
|
if len(title) <= title_length:
|
||||||
|
return title
|
||||||
|
wrapped_title, *_excess = textwrap.wrap(title, title_length - 4)
|
||||||
|
return f"{wrapped_title} ..."
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Automatically create PEP 0 (the PEP index),
|
||||||
|
|
||||||
|
This file generates and writes the PEP index to disk, ready for later
|
||||||
|
processing by Sphinx. Firstly, we parse the individual PEP files, getting the
|
||||||
|
RFC2822 header, and parsing and then validating that metadata.
|
||||||
|
|
||||||
|
After collecting and validating all the PEP data, the creation of the index
|
||||||
|
itself is in three steps:
|
||||||
|
|
||||||
|
1. Output static text.
|
||||||
|
2. Format an entry for the PEP.
|
||||||
|
3. Output the PEP (both by the category and numerical index).
|
||||||
|
|
||||||
|
We then add the newly created PEP 0 file to two Sphinx environment variables
|
||||||
|
to allow it to be processed as normal.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator import parser
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator import writer
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sphinx.application import Sphinx
|
||||||
|
from sphinx.environment import BuildEnvironment
|
||||||
|
|
||||||
|
|
||||||
|
def create_pep_zero(_: Sphinx, env: BuildEnvironment, docnames: list[str]) -> None:
|
||||||
|
# Sphinx app object is unneeded by this function
|
||||||
|
|
||||||
|
# Read from root directory
|
||||||
|
path = Path(".")
|
||||||
|
|
||||||
|
pep_zero_filename = "pep-0000"
|
||||||
|
peps: list[parser.PEP] = []
|
||||||
|
pep_pat = re.compile(r"pep-\d{4}") # Path.match() doesn't support regular expressions
|
||||||
|
|
||||||
|
# AUTHOR_OVERRIDES.csv is an exception file for PEP0 name parsing
|
||||||
|
with open("AUTHOR_OVERRIDES.csv", "r", encoding="utf-8") as f:
|
||||||
|
authors_overrides = {}
|
||||||
|
for line in csv.DictReader(f):
|
||||||
|
full_name = line.pop("Overridden Name")
|
||||||
|
authors_overrides[full_name] = line
|
||||||
|
|
||||||
|
for file_path in path.iterdir():
|
||||||
|
if not file_path.is_file():
|
||||||
|
continue # Skip directories etc.
|
||||||
|
if file_path.match("pep-0000*"):
|
||||||
|
continue # Skip pre-existing PEP 0 files
|
||||||
|
if pep_pat.match(str(file_path)) and file_path.suffix in {".txt", ".rst"}:
|
||||||
|
pep = parser.PEP(path.joinpath(file_path).absolute(), authors_overrides)
|
||||||
|
peps.append(pep)
|
||||||
|
|
||||||
|
pep0_text = writer.PEPZeroWriter().write_pep0(sorted(peps))
|
||||||
|
Path(f"{pep_zero_filename}.rst").write_text(pep0_text, encoding="utf-8")
|
||||||
|
|
||||||
|
# Add to files for builder
|
||||||
|
docnames.insert(1, pep_zero_filename)
|
||||||
|
# Add to files for writer
|
||||||
|
env.found_docs.add(pep_zero_filename)
|
|
@ -0,0 +1,311 @@
|
||||||
|
"""Code to handle the output of PEP 0."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import functools
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import DEAD_STATUSES
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import HIDE_STATUS
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_ACCEPTED
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_ACTIVE
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_DEFERRED
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_DRAFT
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_FINAL
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_PROVISIONAL
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_REJECTED
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_VALUES
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_WITHDRAWN
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import TYPE_INFO
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import TYPE_PROCESS
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.constants import TYPE_VALUES
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.errors import PEPError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pep_sphinx_extensions.pep_zero_generator.parser import PEP
|
||||||
|
|
||||||
|
title_length = 55
|
||||||
|
author_length = 40
|
||||||
|
table_separator = "== ==== " + "="*title_length + " " + "="*author_length
|
||||||
|
|
||||||
|
# column format is called as a function with a mapping containing field values
|
||||||
|
column_format = functools.partial(
|
||||||
|
"{type}{status}{number: >5} {title: <{title_length}} {authors}".format,
|
||||||
|
title_length=title_length
|
||||||
|
)
|
||||||
|
|
||||||
|
header = f"""\
|
||||||
|
PEP: 0
|
||||||
|
Title: Index of Python Enhancement Proposals (PEPs)
|
||||||
|
Last-Modified: {datetime.date.today()}
|
||||||
|
Author: python-dev <python-dev@python.org>
|
||||||
|
Status: Active
|
||||||
|
Type: Informational
|
||||||
|
Content-Type: text/x-rst
|
||||||
|
Created: 13-Jul-2000
|
||||||
|
"""
|
||||||
|
|
||||||
|
intro = """\
|
||||||
|
This PEP contains the index of all Python Enhancement Proposals,
|
||||||
|
known as PEPs. PEP numbers are assigned by the PEP editors, and
|
||||||
|
once assigned are never changed [1_]. The version control history [2_] of
|
||||||
|
the PEP texts represent their historical record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
references = """\
|
||||||
|
.. [1] PEP 1: PEP Purpose and Guidelines
|
||||||
|
.. [2] View PEP history online: https://github.com/python/peps
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PEPZeroWriter:
|
||||||
|
# This is a list of reserved PEP numbers. Reservations are not to be used for
|
||||||
|
# the normal PEP number allocation process - just give out the next available
|
||||||
|
# PEP number. These are for "special" numbers that may be used for semantic,
|
||||||
|
# humorous, or other such reasons, e.g. 401, 666, 754.
|
||||||
|
#
|
||||||
|
# PEP numbers may only be reserved with the approval of a PEP editor. Fields
|
||||||
|
# here are the PEP number being reserved and the claimants for the PEP.
|
||||||
|
# Although the output is sorted when PEP 0 is generated, please keep this list
|
||||||
|
# sorted as well.
|
||||||
|
RESERVED = {
|
||||||
|
801: "Warsaw",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.output: list[str] = []
|
||||||
|
|
||||||
|
def emit_text(self, content: str) -> None:
|
||||||
|
# Appends content argument to the output list
|
||||||
|
self.output.append(content)
|
||||||
|
|
||||||
|
def emit_newline(self) -> None:
|
||||||
|
self.output.append("")
|
||||||
|
|
||||||
|
def emit_table_separator(self) -> None:
|
||||||
|
self.output.append(table_separator)
|
||||||
|
|
||||||
|
def emit_author_table_separator(self, max_name_len: int) -> None:
|
||||||
|
author_table_separator = "=" * max_name_len + " " + "=" * len("email address")
|
||||||
|
self.output.append(author_table_separator)
|
||||||
|
|
||||||
|
def emit_pep_row(self, pep_details: dict[str, int | str]) -> None:
|
||||||
|
self.emit_text(column_format(**pep_details))
|
||||||
|
|
||||||
|
def emit_column_headers(self) -> None:
|
||||||
|
"""Output the column headers for the PEP indices."""
|
||||||
|
self.emit_table_separator()
|
||||||
|
self.emit_pep_row({"status": ".", "type": ".", "number": "PEP", "title": "PEP Title", "authors": "PEP Author(s)"})
|
||||||
|
self.emit_table_separator()
|
||||||
|
|
||||||
|
def emit_title(self, text: str, anchor: str, *, symbol: str = "=") -> None:
|
||||||
|
self.output.append(f".. _{anchor}:\n")
|
||||||
|
self.output.append(text)
|
||||||
|
self.output.append(symbol * len(text))
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
def emit_subtitle(self, text: str, anchor: str) -> None:
|
||||||
|
self.emit_title(text, anchor, symbol="-")
|
||||||
|
|
||||||
|
def emit_pep_category(self, category: str, anchor: str, peps: list[PEP]) -> None:
|
||||||
|
self.emit_subtitle(category, anchor)
|
||||||
|
self.emit_column_headers()
|
||||||
|
for pep in peps:
|
||||||
|
self.output.append(column_format(**pep.details(title_length=title_length)))
|
||||||
|
self.emit_table_separator()
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
def write_pep0(self, peps: list[PEP]):
|
||||||
|
|
||||||
|
# PEP metadata
|
||||||
|
self.emit_text(header)
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
self.emit_title("Introduction", "intro")
|
||||||
|
self.emit_text(intro)
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
# PEPs by category
|
||||||
|
self.emit_title("Index by Category", "by-category")
|
||||||
|
meta, info, provisional, accepted, open_, finished, historical, deferred, dead = _classify_peps(peps)
|
||||||
|
pep_categories = [
|
||||||
|
("Meta-PEPs (PEPs about PEPs or Processes)", "by-category-meta", meta),
|
||||||
|
("Other Informational PEPs", "by-category-other-info", info),
|
||||||
|
("Provisional PEPs (provisionally accepted; interface may still change)", "by-category-provisional", provisional),
|
||||||
|
("Accepted PEPs (accepted; may not be implemented yet)", "by-category-accepted", accepted),
|
||||||
|
("Open PEPs (under consideration)", "by-category-open", open_),
|
||||||
|
("Finished PEPs (done, with a stable interface)", "by-category-finished", finished),
|
||||||
|
("Historical Meta-PEPs and Informational PEPs", "by-category-historical", historical),
|
||||||
|
("Deferred PEPs (postponed pending further research or updates)", "by-category-deferred", deferred),
|
||||||
|
("Abandoned, Withdrawn, and Rejected PEPs", "by-category-abandoned", dead),
|
||||||
|
]
|
||||||
|
for (category, anchor, peps_in_category) in pep_categories:
|
||||||
|
self.emit_pep_category(category, anchor, peps_in_category)
|
||||||
|
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
# PEPs by number
|
||||||
|
self.emit_title("Numerical Index", "by-pep-number")
|
||||||
|
self.emit_column_headers()
|
||||||
|
prev_pep = 0
|
||||||
|
for pep in peps:
|
||||||
|
if pep.number - prev_pep > 1:
|
||||||
|
self.emit_newline()
|
||||||
|
self.emit_pep_row(pep.details(title_length=title_length))
|
||||||
|
prev_pep = pep.number
|
||||||
|
|
||||||
|
self.emit_table_separator()
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
# Reserved PEP numbers
|
||||||
|
self.emit_title("Reserved PEP Numbers", "reserved")
|
||||||
|
self.emit_column_headers()
|
||||||
|
for number, claimants in sorted(self.RESERVED.items()):
|
||||||
|
self.emit_pep_row({"type": ".", "status": ".", "number": number, "title": "RESERVED", "authors": claimants})
|
||||||
|
|
||||||
|
self.emit_table_separator()
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
# PEP types key
|
||||||
|
self.emit_title("PEP Types Key", "type-key")
|
||||||
|
for type_ in sorted(TYPE_VALUES):
|
||||||
|
self.emit_text(f" {type_[0]} - {type_} PEP")
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
# PEP status key
|
||||||
|
self.emit_title("PEP Status Key", "status-key")
|
||||||
|
for status in sorted(STATUS_VALUES):
|
||||||
|
# Draft PEPs have no status displayed, Active shares a key with Accepted
|
||||||
|
if status in HIDE_STATUS:
|
||||||
|
continue
|
||||||
|
if status == STATUS_ACCEPTED:
|
||||||
|
msg = " A - Accepted (Standards Track only) or Active proposal"
|
||||||
|
else:
|
||||||
|
msg = f" {status[0]} - {status} proposal"
|
||||||
|
self.emit_text(msg)
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
# PEP owners
|
||||||
|
authors_dict = _verify_email_addresses(peps)
|
||||||
|
max_name_len = max(len(author_name) for author_name in authors_dict)
|
||||||
|
self.emit_title("Authors/Owners", "authors")
|
||||||
|
self.emit_author_table_separator(max_name_len)
|
||||||
|
self.emit_text(f"{'Name':{max_name_len}} Email Address")
|
||||||
|
self.emit_author_table_separator(max_name_len)
|
||||||
|
for author_name in _sort_authors(authors_dict):
|
||||||
|
# Use the email from authors_dict instead of the one from "author" as
|
||||||
|
# the author instance may have an empty email.
|
||||||
|
self.emit_text(f"{author_name:{max_name_len}} {authors_dict[author_name]}")
|
||||||
|
self.emit_author_table_separator(max_name_len)
|
||||||
|
self.emit_newline()
|
||||||
|
self.emit_newline()
|
||||||
|
|
||||||
|
# References for introduction footnotes
|
||||||
|
self.emit_title("References", "references")
|
||||||
|
self.emit_text(references)
|
||||||
|
|
||||||
|
pep0_string = "\n".join([str(s) for s in self.output])
|
||||||
|
return pep0_string
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_peps(peps: list[PEP]) -> tuple[list[PEP], ...]:
|
||||||
|
"""Sort PEPs into meta, informational, accepted, open, finished,
|
||||||
|
and essentially dead."""
|
||||||
|
meta = []
|
||||||
|
info = []
|
||||||
|
provisional = []
|
||||||
|
accepted = []
|
||||||
|
open_ = []
|
||||||
|
finished = []
|
||||||
|
historical = []
|
||||||
|
deferred = []
|
||||||
|
dead = []
|
||||||
|
for pep in peps:
|
||||||
|
# Order of 'if' statement important. Key Status values take precedence
|
||||||
|
# over Type value, and vice-versa.
|
||||||
|
if pep.status == STATUS_DRAFT:
|
||||||
|
open_.append(pep)
|
||||||
|
elif pep.status == STATUS_DEFERRED:
|
||||||
|
deferred.append(pep)
|
||||||
|
elif pep.pep_type == TYPE_PROCESS:
|
||||||
|
if pep.status == STATUS_ACTIVE:
|
||||||
|
meta.append(pep)
|
||||||
|
elif pep.status in {STATUS_WITHDRAWN, STATUS_REJECTED}:
|
||||||
|
dead.append(pep)
|
||||||
|
else:
|
||||||
|
historical.append(pep)
|
||||||
|
elif pep.status in DEAD_STATUSES:
|
||||||
|
dead.append(pep)
|
||||||
|
elif pep.pep_type == TYPE_INFO:
|
||||||
|
# Hack until the conflict between the use of "Final"
|
||||||
|
# for both API definition PEPs and other (actually
|
||||||
|
# obsolete) PEPs is addressed
|
||||||
|
if pep.status == STATUS_ACTIVE or "Release Schedule" not in pep.title:
|
||||||
|
info.append(pep)
|
||||||
|
else:
|
||||||
|
historical.append(pep)
|
||||||
|
elif pep.status == STATUS_PROVISIONAL:
|
||||||
|
provisional.append(pep)
|
||||||
|
elif pep.status in {STATUS_ACCEPTED, STATUS_ACTIVE}:
|
||||||
|
accepted.append(pep)
|
||||||
|
elif pep.status == STATUS_FINAL:
|
||||||
|
finished.append(pep)
|
||||||
|
else:
|
||||||
|
raise PEPError(f"Unsorted ({pep.pep_type}/{pep.status})", pep.filename, pep.number)
|
||||||
|
return meta, info, provisional, accepted, open_, finished, historical, deferred, dead
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_email_addresses(peps: list[PEP]) -> dict[str, str]:
|
||||||
|
authors_dict: dict[str, set[str]] = {}
|
||||||
|
for pep in peps:
|
||||||
|
for author in pep.authors:
|
||||||
|
# If this is the first time we have come across an author, add them.
|
||||||
|
if author.last_first not in authors_dict:
|
||||||
|
authors_dict[author.last_first] = set()
|
||||||
|
|
||||||
|
# If the new email is an empty string, move on.
|
||||||
|
if not author.email:
|
||||||
|
continue
|
||||||
|
# If the email has not been seen, add it to the list.
|
||||||
|
authors_dict[author.last_first].add(author.email)
|
||||||
|
|
||||||
|
valid_authors_dict: dict[str, str] = {}
|
||||||
|
too_many_emails: list[tuple[str, set[str]]] = []
|
||||||
|
for last_first, emails in authors_dict.items():
|
||||||
|
if len(emails) > 1:
|
||||||
|
too_many_emails.append((last_first, emails))
|
||||||
|
else:
|
||||||
|
valid_authors_dict[last_first] = next(iter(emails), "")
|
||||||
|
if too_many_emails:
|
||||||
|
err_output = []
|
||||||
|
for author, emails in too_many_emails:
|
||||||
|
err_output.append(" " * 4 + f"{author}: {emails}")
|
||||||
|
raise ValueError(
|
||||||
|
"some authors have more than one email address listed:\n"
|
||||||
|
+ "\n".join(err_output)
|
||||||
|
)
|
||||||
|
|
||||||
|
return valid_authors_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_authors(authors_dict: dict[str, str]) -> list[str]:
|
||||||
|
return sorted(authors_dict, key=_author_sort_by)
|
||||||
|
|
||||||
|
|
||||||
|
def _author_sort_by(author_name: str) -> str:
|
||||||
|
"""Skip lower-cased words in surname when sorting."""
|
||||||
|
surname, *_ = author_name.split(",")
|
||||||
|
surname_parts = surname.split()
|
||||||
|
for i, part in enumerate(surname_parts):
|
||||||
|
if part[0].isupper():
|
||||||
|
base = " ".join(surname_parts[i:]).lower()
|
||||||
|
return unicodedata.normalize("NFKD", base)
|
||||||
|
# If no capitals, use the whole string
|
||||||
|
return unicodedata.normalize("NFKD", surname.lower())
|
Loading…
Reference in New Issue