diff --git a/AUTHOR_OVERRIDES.csv b/AUTHOR_OVERRIDES.csv new file mode 100644 index 00000000000..c97178f0c7d --- /dev/null +++ b/AUTHOR_OVERRIDES.csv @@ -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 diff --git a/build.py b/build.py index f5e26a7dcac..0423c3c0760 100644 --- a/build.py +++ b/build.py @@ -2,6 +2,7 @@ import argparse from pathlib import Path +import shutil from sphinx.application import Sphinx @@ -22,6 +23,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() @@ -52,3 +60,6 @@ def create_parser(): ) app.builder.copysource = False # Prevent unneeded source copying - we link direct to GitHub app.build() + + if args.index_file: + create_index_file(build_directory) diff --git a/pep_sphinx_extensions/__init__.py b/pep_sphinx_extensions/__init__.py index cac99c62665..326521eabea 100644 --- a/pep_sphinx_extensions/__init__.py +++ b/pep_sphinx_extensions/__init__.py @@ -4,12 +4,13 @@ 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 diff --git a/pep_sphinx_extensions/pep_processor/parsing/pep_parser.py b/pep_sphinx_extensions/pep_processor/parsing/pep_parser.py index 550d2ce719c..2ccbd6cb857 100644 --- a/pep_sphinx_extensions/pep_processor/parsing/pep_parser.py +++ b/pep_sphinx_extensions/pep_processor/parsing/pep_parser.py @@ -4,10 +4,10 @@ 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 diff --git a/pep_sphinx_extensions/pep_processor/transforms/pep_footer.py b/pep_sphinx_extensions/pep_processor/transforms/pep_footer.py index d959a6bcf44..9f25df2ae5a 100644 --- a/pep_sphinx_extensions/pep_processor/transforms/pep_footer.py +++ b/pep_sphinx_extensions/pep_processor/transforms/pep_footer.py @@ -1,6 +1,6 @@ import datetime -import subprocess from pathlib import Path +import subprocess from docutils import nodes from docutils import transforms diff --git a/pep_sphinx_extensions/pep_processor/transforms/pep_headers.py b/pep_sphinx_extensions/pep_processor/transforms/pep_headers.py index 259966c4afa..5e5a4bc18b2 100644 --- a/pep_sphinx_extensions/pep_processor/transforms/pep_headers.py +++ b/pep_sphinx_extensions/pep_processor/transforms/pep_headers.py @@ -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): diff --git a/pep_sphinx_extensions/pep_zero_generator/author.py b/pep_sphinx_extensions/pep_zero_generator/author.py new file mode 100644 index 00000000000..22299b056af --- /dev/null +++ b/pep_sphinx_extensions/pep_zero_generator/author.py @@ -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) diff --git a/pep_sphinx_extensions/pep_zero_generator/constants.py b/pep_sphinx_extensions/pep_zero_generator/constants.py new file mode 100644 index 00000000000..5b3ea5f6f91 --- /dev/null +++ b/pep_sphinx_extensions/pep_zero_generator/constants.py @@ -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} diff --git a/pep_sphinx_extensions/pep_zero_generator/errors.py b/pep_sphinx_extensions/pep_zero_generator/errors.py new file mode 100644 index 00000000000..deb12021ac9 --- /dev/null +++ b/pep_sphinx_extensions/pep_zero_generator/errors.py @@ -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 diff --git a/pep_sphinx_extensions/pep_zero_generator/parser.py b/pep_sphinx_extensions/pep_zero_generator/parser.py new file mode 100644 index 00000000000..e5cec9ebba8 --- /dev/null +++ b/pep_sphinx_extensions/pep_zero_generator/parser.py @@ -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"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.+?) <(?P.+?)>(,\s*)?") +author_paren = re.compile(r"(?P.+?) \((?P.+?)\)(,\s*)?") +author_simple = re.compile(r"(?P[^,]+)(,\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} ..." diff --git a/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py b/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py new file mode 100644 index 00000000000..898ab2b0b0f --- /dev/null +++ b/pep_sphinx_extensions/pep_zero_generator/pep_index_generator.py @@ -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) diff --git a/pep_sphinx_extensions/pep_zero_generator/writer.py b/pep_sphinx_extensions/pep_zero_generator/writer.py new file mode 100644 index 00000000000..61f98f091ca --- /dev/null +++ b/pep_sphinx_extensions/pep_zero_generator/writer.py @@ -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 +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())