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
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from sphinx.application import Sphinx
|
||||
|
||||
|
@ -24,6 +25,13 @@ def create_parser():
|
|||
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__":
|
||||
args = create_parser()
|
||||
|
||||
|
@ -60,3 +68,6 @@ if __name__ == "__main__":
|
|||
)
|
||||
app.builder.copysource = False # Prevent unneeded source copying - we link direct to GitHub
|
||||
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 sphinx.environment import default_settings
|
||||
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.parsing import pep_parser
|
||||
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:
|
||||
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_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.connect("env-before-read-docs", create_pep_zero) # PEP 0 hook
|
||||
|
||||
# Mathematics rendering
|
||||
inline_maths = HTMLTranslator.visit_math, _depart_maths
|
||||
|
|
|
@ -4,10 +4,10 @@ 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
|
||||
from pep_sphinx_extensions.pep_processor.transforms import pep_headers
|
||||
from pep_sphinx_extensions.pep_processor.transforms import pep_title
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docutils import transforms
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import datetime
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from docutils import nodes
|
||||
from docutils import transforms
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
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
|
||||
from pep_sphinx_extensions.pep_processor.transforms import pep_zero
|
||||
|
||||
|
||||
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