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:
Adam Turner 2021-06-12 18:51:14 +01:00 committed by GitHub
parent 0f278396f4
commit 749c2d9a37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 717 additions and 6 deletions

11
AUTHOR_OVERRIDES.csv Normal file
View File

@ -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
1 Overridden Name Surname First Name Reference
2 The Python core team and community The Python core team and community python-dev
3 Ernest W. Durbin III Durbin, Ernest W., III Durbin
4 Greg Ewing Ewing, Gregory Ewing
5 Guido van Rossum van Rossum, Guido (GvR) GvR
6 Inada Naoki Inada, Naoki Inada
7 Jim Jewett Jewett, Jim J. Jewett
8 Just van Rossum van Rossum, Just (JvR) JvR
9 Martin v. Löwis von Löwis, Martin von Löwis
10 Nathaniel Smith Smith, Nathaniel J. Smith
11 P.J. Eby Eby, Phillip J. Eby

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,6 @@
import datetime
import subprocess
from pathlib import Path
import subprocess
from docutils import nodes
from docutils import transforms

View File

@ -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):

View File

@ -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)

View File

@ -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}

View File

@ -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

View File

@ -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} ..."

View File

@ -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)

View File

@ -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())