Merge remote-tracking branch 'upstream/main' into patch-1

This commit is contained in:
Hugo van Kemenade 2023-09-20 21:40:49 +03:00
commit 024252ccfc
719 changed files with 13531 additions and 4753 deletions

View File

@ -7,3 +7,11 @@
Error-de_DE=Wenn ist das Nunstück git und Slotermeyer?
Ja! Beiherhund das Oder die Virtualenvironment gersput!
<https://devguide.python.org/pullrequest/#licensing>`__
class ClassE[T: [str, int]]: ... # Type checker error: illegal expression form
class ClassE[T: t1]: ... # Type checker error: literal tuple expression required
explicitly declared using ``in``, ``out`` and ``inout`` keywords.
| | | | | | | inout |

View File

@ -1,4 +1,5 @@
adaptee
ancilliary
ans
arithmetics
asend
@ -6,12 +7,15 @@ ba
clos
complies
crate
dedented
extraversion
falsy
fo
iif
nd
ned
recuse
reenable
referencable
therefor
warmup

4
.gitattributes vendored
View File

@ -3,3 +3,7 @@
*.png binary
*.pptx binary
*.odp binary
# Instruct linguist not to ignore the PEPs
# https://github.com/github-linguist/linguist/blob/master/docs/overrides.md
peps/*.rst text linguist-detectable

1300
.github/CODEOWNERS vendored

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ If your PEP is not Standards Track, remove the corresponding section.
## Basic requirements (all PEP Types)
* [ ] Read and followed [PEP 1](https://peps.python.org/1) & [PEP 12](https://peps.python.org/12)
* [ ] File created from the [latest PEP template](https://github.com/python/peps/blob/main/pep-0012/pep-NNNN.rst?plain=1)
* [ ] File created from the [latest PEP template](https://github.com/python/peps/blob/main/peps/pep-0012/pep-NNNN.rst?plain=1)
* [ ] PEP has next available number, & set in filename (``pep-NNNN.rst``), PR title (``PEP 123: <Title of PEP>``) and ``PEP`` header
* [ ] Title clearly, accurately and concisely describes the content in 79 characters or less
* [ ] Core dev/PEP editor listed as ``Author`` or ``Sponsor``, and formally confirmed their approval

View File

@ -9,4 +9,4 @@ If you're unsure about something, just leave it blank and we'll take a look.
* [ ] Any substantial changes since the accepted version approved by the SC/PEP delegate
* [ ] Pull request title in appropriate format (``PEP 123: Mark Final``)
* [ ] ``Status`` changed to ``Final`` (and ``Python-Version`` is correct)
* [ ] Canonical docs/spec linked with a ``canonical-doc`` directive (or ``pypa-spec``, for packaging PEPs)
* [ ] Canonical docs/spec linked with a ``canonical-doc`` directive (or ``canonical-pypa-spec``, for packaging PEPs)

View File

@ -1,12 +1,18 @@
name: Read the Docs PR preview
on:
pull_request_target:
types:
- opened
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
documentation-links:
runs-on: ubuntu-latest

View File

@ -1,6 +1,20 @@
name: Lint
name: Lint PEPs
on: [push, pull_request, workflow_dispatch]
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
FORCE_COLOR: 1
RUFF_FORMAT: github
jobs:
pre-commit:
@ -8,13 +22,31 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python 3
uses: actions/setup-python@v4
with:
python-version: '3.x'
python-version: "3.x"
cache: pip
- name: Run pre-commit hooks
uses: pre-commit/action@v3.0.0
- name: Check spelling
uses: pre-commit/action@v3.0.0
with:
extra_args: --all-files --hook-stage manual codespell || true
check-peps:
name: Run check-peps
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3
uses: actions/setup-python@v4
with:
python-version: "3"
- name: Run check-peps
run: python check-peps.py --detailed

View File

@ -1,48 +1,65 @@
name: Render PEPs
on: [push, pull_request, workflow_dispatch]
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
FORCE_COLOR: 1
jobs:
render-peps:
name: Render PEPs
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
fail-fast: false
matrix:
python-version: ["3.x", "3.12-dev"]
python-version:
- "3.x"
- "3.12-dev"
steps:
- name: 🛎️ Checkout
uses: actions/checkout@v3
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # fetch all history so that last modified date-times are accurate
- name: 🐍 Set up Python ${{ matrix.python-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: 👷‍ Install dependencies
- name: Update pip
run: |
python -m pip install --upgrade pip
- name: 🔧 Render PEPs
- name: Render PEPs
run: make dirhtml JOBS=$(nproc)
# remove the .doctrees folder when building for deployment as it takes two thirds of disk space
- name: 🔥 Clean up files
- name: Clean up files
run: rm -r build/.doctrees/
- name: 🚀 Deploy to GitHub pages
- name: Deploy to GitHub pages
# This allows CI to build branches for testing
if: (github.ref == 'refs/heads/main') && (matrix.python-version == '3.x')
uses: JamesIves/github-pages-deploy-action@v4
with:
folder: build # Synchronise with build.py -> build_directory
folder: build # Synchronise with Makefile -> OUTPUT_DIR
single-commit: true # Delete existing files
- name: ♻️ Purge CDN cache
- name: Purge CDN cache
if: github.ref == 'refs/heads/main'
run: |
curl -H "Accept: application/json" -H "Fastly-Key: $FASTLY_TOKEN" -X POST "https://api.fastly.com/service/$FASTLY_SERVICE_ID/purge_all"

View File

@ -13,6 +13,13 @@ on:
- "tox.ini"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
FORCE_COLOR: 1
@ -22,12 +29,18 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12-dev"]
os: [windows-latest, macos-latest, ubuntu-latest]
python-version:
- "3.9"
- "3.10"
- "3.11"
- "3.12-dev"
os:
- "windows-latest"
- "macos-latest"
- "ubuntu-latest"
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
@ -40,7 +53,7 @@ jobs:
python -m pip install -U wheel
python -m pip install -U tox
- name: Run tests with tox
- name: Run tests
run: |
tox -e py -- -v --cov-report term

28
.gitignore vendored
View File

@ -1,18 +1,24 @@
coverage.xml
pep-0000.txt
# PEPs
pep-0000.rst
pep-????.html
peps.rss
topic
/build
# Bytecode
__pycache__
*.pyc
*.pyo
*.py[co]
# Editors
*~
*env
.coverage
.tox
.idea
.vscode
*.swp
/build
/package
/topic
# Tests
coverage.xml
.coverage
.tox
# Virtual environments
*env
/venv

View File

@ -43,7 +43,7 @@ repos:
name: "Check YAML"
- repo: https://github.com/psf/black
rev: 22.12.0
rev: 23.7.0
hooks:
- id: black
name: "Format with Black"
@ -52,22 +52,23 @@ repos:
- '--target-version=py310'
files: 'pep_sphinx_extensions/tests/.*'
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.287
hooks:
- id: isort
name: "Sort imports with isort"
args: ['--profile=black', '--atomic']
files: 'pep_sphinx_extensions/tests/.*'
- id: ruff
name: "Lint with Ruff"
args:
- '--exit-non-zero-on-fix'
files: '^pep_sphinx_extensions/tests/'
- repo: https://github.com/tox-dev/tox-ini-fmt
rev: 0.6.1
rev: 1.3.1
hooks:
- id: tox-ini-fmt
name: "Format tox.ini"
- repo: https://github.com/sphinx-contrib/sphinx-lint
rev: v0.6.7
rev: v0.6.8
hooks:
- id: sphinx-lint
name: "Sphinx lint"
@ -79,20 +80,16 @@ repos:
hooks:
- id: rst-backticks
name: "Check RST: No single backticks"
files: '^pep-\d\.txt|\.rst$'
types: [text]
- id: rst-inline-touching-normal
name: "Check RST: No backticks touching text"
files: '^pep-\d+\.txt|\.rst$'
types: [text]
- id: rst-directive-colons
name: "Check RST: 2 colons after directives"
files: '^pep-\d+\.txt|\.rst$'
types: [text]
# Manual codespell check
- repo: https://github.com/codespell-project/codespell
rev: v2.2.2
rev: v2.2.5
hooks:
- id: codespell
name: "Check for common misspellings in text files"
@ -101,152 +98,134 @@ repos:
# Local checks for PEP headers and more
- repo: local
hooks:
- id: check-no-tabs
name: "Check tabs not used in PEPs"
language: pygrep
entry: '\t'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
# # Hook to run "check-peps.py"
# - id: "check-peps"
# name: "Check PEPs for metadata and content enforcement"
# entry: "python check-peps.py"
# language: "system"
# files: "^pep-\d{4}\.(rst|txt)$"
# require_serial: true
- id: check-required-headers
name: "PEPs must have all required headers"
language: pygrep
entry: '(?-m:^PEP:(?=[\s\S]*\nTitle:)(?=[\s\S]*\nAuthor:)(?=[\s\S]*\nStatus:)(?=[\s\S]*\nType:)(?=[\s\S]*\nContent-Type:)(?=[\s\S]*\nCreated:))'
args: ['--negate', '--multiline']
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: check-header-order
name: "PEP header order must follow PEP 12"
language: pygrep
entry: '^PEP:[^\n]+\nTitle:[^\n]+\n(Version:[^\n]+\n)?(Last-Modified:[^\n]+\n)?Author:[^\n]+\n( +\S[^\n]+\n)*(Sponsor:[^\n]+\n)?((PEP|BDFL)-Delegate:[^\n]*\n)?(Discussions-To:[^\n]*\n)?Status:[^\n]+\nType:[^\n]+\n(Topic:[^\n]+\n)?Content-Type:[^\n]+\n(Requires:[^\n]+\n)?Created:[^\n]+\n(Python-Version:[^\n]*\n)?(Post-History:[^\n]*\n( +\S[^\n]*\n)*)?(Replaces:[^\n]+\n)?(Superseded-By:[^\n]+\n)?(Resolution:[^\n]*\n)?\n'
args: ['--negate', '--multiline']
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-pep-number
name: "'PEP' header must be a number 1-9999"
language: pygrep
entry: '(?-m:^PEP:(?:(?! +(0|[1-9][0-9]{0,3})\n)))'
args: ['--multiline']
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-title
name: "'Title' must be 1-79 characters"
language: pygrep
entry: '(?<=\n)Title:(?:(?! +\S.{1,78}\n(?=[A-Z])))'
args: ['--multiline']
files: '^pep-\d+\.(rst|txt)$'
exclude: '^pep-(0499)\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
exclude: '^peps/pep-(0499)\.rst$'
- id: validate-author
name: "'Author' must be list of 'Name <email@example.com>, ...'"
language: pygrep
entry: '(?<=\n)Author:(?:(?!((( +|\n {1,8})[^!#$%&()*+,/:;<=>?@\[\\\]\^_`{|}~]+( <[\w!#$%&''*+\-/=?^_{|}~.]+(@| at )[\w\-.]+\.[A-Za-z0-9]+>)?)(,|(?=\n[^ ])))+\n(?=[A-Z])))'
args: [--multiline]
files: '^pep-\d+\.(rst|txt)$'
types: [text]
args: ["--multiline"]
files: '^peps/pep-\d+\.rst$'
- id: validate-sponsor
name: "'Sponsor' must have format 'Name <email@example.com>'"
language: pygrep
entry: '^Sponsor:(?: (?! *[^!#$%&()*+,/:;<=>?@\[\\\]\^_`{|}~]+( <[\w!#$%&''*+\-/=?^_{|}~.]+(@| at )[\w\-.]+\.[A-Za-z0-9]+>)?$))'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-delegate
name: "'Delegate' must have format 'Name <email@example.com>'"
language: pygrep
entry: '^(PEP|BDFL)-Delegate: (?:(?! *[^!#$%&()*+,/:;<=>?@\[\\\]\^_`{|}~]+( <[\w!#$%&''*+\-/=?^_{|}~.]+(@| at )[\w\-.]+\.[A-Za-z0-9]+>)?$))'
files: '^pep-\d+\.(rst|txt)$'
exclude: '^pep-(0451)\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
exclude: '^peps/pep-(0451)\.rst$'
- id: validate-discussions-to
name: "'Discussions-To' must be a thread URL"
language: pygrep
entry: '^Discussions-To: (?:(?!([\w\-]+@(python\.org|googlegroups\.com))|https://((discuss\.python\.org/t/([\w\-]+/)?\d+/?)|(mail\.python\.org/pipermail/[\w\-]+/\d{4}-[A-Za-z]+/[A-Za-z0-9]+\.html)|(mail\.python\.org/archives/list/[\w\-]+@python\.org/thread/[A-Za-z0-9]+/?))$))'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-status
name: "'Status' must be a valid PEP status"
language: pygrep
entry: '^Status:(?:(?! +(Draft|Withdrawn|Rejected|Accepted|Final|Active|Provisional|Deferred|Superseded|April Fool!)$))'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-type
name: "'Type' must be a valid PEP type"
language: pygrep
entry: '^Type:(?:(?! +(Standards Track|Informational|Process)$))'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-topic
name: "'Topic' must be for a valid sub-index"
language: pygrep
entry: '^Topic:(?:(?! +(Governance|Packaging|Typing|Release)(, (Governance|Packaging|Typing|Release))*$))'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-content-type
name: "'Content-Type' must be 'text/x-rst'"
language: pygrep
entry: '^Content-Type:(?:(?! +text/x-rst$))'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-pep-references
name: "`Requires`/`Replaces`/`Superseded-By` must be 'NNN' PEP IDs"
language: pygrep
entry: '^(Requires|Replaces|Superseded-By):(?:(?! *( (0|[1-9][0-9]{0,3})(,|$))+$))'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-created
name: "'Created' must be a 'DD-mmm-YYYY' date"
language: pygrep
entry: '^Created:(?:(?! +([0-2][0-9]|(3[01]))-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(199[0-9]|20[0-9][0-9])$))'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-python-version
name: "'Python-Version' must be a 'X.Y[.Z]` version"
language: pygrep
entry: '^Python-Version:(?:(?! *( [1-9]\.([0-9][0-9]?|x)(\.[1-9][0-9]?)?(,|$))+$))'
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-post-history
name: "'Post-History' must be '`DD-mmm-YYYY <Thread URL>`__, ...'"
language: pygrep
entry: '(?<=\n)Post-History:(?:(?! ?\n|((( +|\n {1,14})(([0-2][0-9]|(3[01]))-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(199[0-9]|20[0-9][0-9])|`([0-2][0-9]|(3[01]))-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(199[0-9]|20[0-9][0-9]) <https://((discuss\.python\.org/t/([\w\-]+/)?\d+(?:/\d+/|/?))|(mail\.python\.org/pipermail/[\w\-]+/\d{4}-[A-Za-z]+/[A-Za-z0-9]+\.html)|(mail\.python\.org/archives/list/[\w\-]+@python\.org/thread/[A-Za-z0-9]+/?(#[A-Za-z0-9]+)?))>`__)(,|(?=\n[^ ])))+\n(?=[A-Z\n]))))'
args: [--multiline]
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: validate-resolution
name: "'Resolution' must be a direct thread/message URL"
language: pygrep
entry: '(?<!\n\n)(?<=\n)Resolution: (?:(?!https://((discuss\.python\.org/t/([\w\-]+/)?\d+(/\d+)?/?)|(mail\.python\.org/pipermail/[\w\-]+/\d{4}-[A-Za-z]+/[A-Za-z0-9]+\.html)|(mail\.python\.org/archives/list/[\w\-]+@python\.org/(message|thread)/[A-Za-z0-9]+/?(#[A-Za-z0-9]+)?))\n))'
args: ['--multiline']
files: '^pep-\d+\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
- id: check-direct-pep-links
name: "Check that PEPs aren't linked directly"
language: pygrep
entry: '(dev/peps|peps\.python\.org)/pep-\d+'
files: '^pep-\d+\.(rst|txt)$'
exclude: '^pep-(0009|0287|0676|0684|8001)\.(rst|txt)$'
types: [text]
files: '^peps/pep-\d+\.rst$'
exclude: '^peps/pep-(0009|0287|0676|0684|8001)\.rst$'
- id: check-direct-rfc-links
name: "Check that RFCs aren't linked directly"
language: pygrep
entry: '(rfc-editor\.org|ietf\.org)/[\.\-_\?\&\#\w/]*[Rr][Ff][Cc][\-_]?\d+'
files: '\.(rst|txt)$'
types: [text]
types: ['rst']

15
.ruff.toml Normal file
View File

@ -0,0 +1,15 @@
ignore = [
"E501", # Line too long
]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
"PT", # flake8-pytest-style
"W", # pycodestyle warnings
]
show-source = true
target-version = "py39"

View File

@ -1,12 +0,0 @@
Overridden Name,Surname First,Name Reference
The Python core team and community,"The Python core team and community",python-dev
Erik De Bonte,"De Bonte, Erik",De Bonte
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
Germán Méndez Bravo,"Méndez Bravo, Germán",Méndez Bravo
1 Overridden Name Surname First Name Reference
2 The Python core team and community The Python core team and community python-dev
3 Erik De Bonte De Bonte, Erik De Bonte
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
12 Germán Méndez Bravo Méndez Bravo, Germán Méndez Bravo

View File

@ -1,15 +1,23 @@
# Builds PEP files to HTML using sphinx
PYTHON=python3
VENVDIR=.venv
JOBS=8
OUTPUT_DIR=build
RENDER_COMMAND=$(VENVDIR)/bin/python3 build.py -j $(JOBS) -o $(OUTPUT_DIR)
# You can set these variables from the command line.
PYTHON = python3
VENVDIR = .venv
SPHINXBUILD = PATH=$(VENVDIR)/bin:$$PATH sphinx-build
BUILDER = html
JOBS = 8
SOURCES =
# synchronise with render.yml -> deploy step
OUTPUT_DIR = build
SPHINXERRORHANDLING = -W --keep-going -w sphinx-warnings.txt
ALLSPHINXOPTS = -b $(BUILDER) -j $(JOBS) \
$(SPHINXOPTS) $(SPHINXERRORHANDLING) peps $(OUTPUT_DIR) $(SOURCES)
## html to render PEPs to "pep-NNNN.html" files
.PHONY: html
html: venv
$(RENDER_COMMAND)
$(SPHINXBUILD) $(ALLSPHINXOPTS)
## htmlview to open the index page built by the html target in your browser
.PHONY: htmlview
@ -18,23 +26,15 @@ htmlview: html
## dirhtml to render PEPs to "index.html" files within "pep-NNNN" directories
.PHONY: dirhtml
dirhtml: venv rss
$(RENDER_COMMAND) --build-dirs
## fail-warning to render PEPs to "pep-NNNN.html" files and fail the Sphinx build on any warning
.PHONY: fail-warning
fail-warning: venv
$(RENDER_COMMAND) --fail-on-warning
dirhtml: BUILDER = dirhtml
dirhtml: venv
$(SPHINXBUILD) $(ALLSPHINXOPTS)
## check-links to check validity of links within PEP sources
.PHONY: check-links
check-links: BUILDER = linkcheck
check-links: venv
$(RENDER_COMMAND) --check-links
## rss to generate the peps.rss file
.PHONY: rss
rss: venv
$(VENVDIR)/bin/python3 generate_rss.py -o $(OUTPUT_DIR)
$(SPHINXBUILD) $(ALLSPHINXOPTS)
## clean to remove the venv and build files
.PHONY: clean
@ -76,16 +76,6 @@ spellcheck: venv
$(VENVDIR)/bin/python3 -m pre_commit --version > /dev/null || $(VENVDIR)/bin/python3 -m pip install pre-commit
$(VENVDIR)/bin/python3 -m pre_commit run --all-files --hook-stage manual codespell
## render (deprecated: use 'make html' alias instead)
.PHONY: render
render: html
@echo "\033[0;33mWarning:\033[0;31m 'make render' \033[0;33mis deprecated, use\033[0;32m 'make html' \033[0;33malias instead\033[0m"
## pages (deprecated: use 'make dirhtml' alias instead)
.PHONY: pages
pages: dirhtml
@echo "\033[0;33mWarning:\033[0;31m 'make pages' \033[0;33mis deprecated, use\033[0;32m 'make dirhtml' \033[0;33malias instead\033[0m"
.PHONY: help
help : Makefile
@echo "Please use \`make <target>' where <target> is one of"

View File

@ -58,8 +58,8 @@ In summary, run the following in a fresh, activated virtual environment:
# Install requirements
python -m pip install -U -r requirements.txt
# Render the PEPs
make render
# Build the PEPs
make html
# Or, if you don't have 'make':
python build.py

View File

@ -5,6 +5,7 @@
"""Build script for Sphinx documentation"""
import argparse
import os
from pathlib import Path
from sphinx.application import Sphinx
@ -27,19 +28,10 @@ def create_parser():
help='Render PEPs to "index.html" files within "pep-NNNN" directories. '
'Cannot be used with "-f" or "-l".')
# flags / options
parser.add_argument("-w", "--fail-on-warning", action="store_true",
help="Fail the Sphinx build on any warning.")
parser.add_argument("-n", "--nitpicky", action="store_true",
help="Run Sphinx in 'nitpicky' mode, "
"warning on every missing reference target.")
parser.add_argument("-j", "--jobs", type=int, default=1,
help="How many parallel jobs to run (if supported). "
"Integer, default 1.")
parser.add_argument(
"-o",
"--output-dir",
default="build", # synchronise with render.yaml -> deploy step
default="build",
help="Output directory, relative to root. Default 'build'.",
)
@ -61,33 +53,23 @@ def create_index_file(html_root: Path, builder: str) -> None:
if __name__ == "__main__":
args = create_parser()
root_directory = Path(".").absolute()
source_directory = root_directory
root_directory = Path(__file__).resolve().parent
source_directory = root_directory / "peps"
build_directory = root_directory / args.output_dir
doctree_directory = build_directory / ".doctrees"
# builder configuration
if args.builder is not None:
sphinx_builder = args.builder
else:
# default builder
sphinx_builder = "html"
# other configuration
config_overrides = {}
if args.nitpicky:
config_overrides["nitpicky"] = True
sphinx_builder = args.builder or "html"
app = Sphinx(
source_directory,
confdir=source_directory,
outdir=build_directory,
doctreedir=doctree_directory,
outdir=build_directory / sphinx_builder,
doctreedir=build_directory / "doctrees",
buildername=sphinx_builder,
confoverrides=config_overrides,
warningiserror=args.fail_on_warning,
parallel=args.jobs,
warningiserror=True,
parallel=os.cpu_count() or 1,
tags=["internal_builder"],
keep_going=True,
)
app.build()

605
check-peps.py Executable file
View File

@ -0,0 +1,605 @@
#!/usr/bin/env python3
# This file is placed in the public domain or under the
# CC0-1.0-Universal license, whichever is more permissive.
"""check-peps: Check PEPs for common mistakes.
Usage: check-peps [-d | --detailed] <PEP files...>
Only the PEPs specified are checked.
If none are specified, all PEPs are checked.
Use "--detailed" to show the contents of lines where errors were found.
"""
from __future__ import annotations
import datetime as dt
import re
import sys
from pathlib import Path
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, KeysView, Sequence
from typing import TypeAlias
# (line number, warning message)
Message: TypeAlias = tuple[int, str]
MessageIterator: TypeAlias = Iterator[Message]
# get the directory with the PEP sources
ROOT_DIR = Path(__file__).resolve().parent
PEP_ROOT = ROOT_DIR / "peps"
# See PEP 12 for the order
# Note we retain "BDFL-Delegate"
ALL_HEADERS = (
"PEP",
"Title",
"Version",
"Last-Modified",
"Author",
"Sponsor",
"BDFL-Delegate", "PEP-Delegate",
"Discussions-To",
"Status",
"Type",
"Topic",
"Content-Type",
"Requires",
"Created",
"Python-Version",
"Post-History",
"Replaces",
"Superseded-By",
"Resolution",
)
REQUIRED_HEADERS = frozenset({"PEP", "Title", "Author", "Status", "Type", "Created"})
# See PEP 1 for the full list
ALL_STATUSES = frozenset({
"Accepted",
"Active",
"April Fool!",
"Deferred",
"Draft",
"Final",
"Provisional",
"Rejected",
"Superseded",
"Withdrawn",
})
# PEPs that are allowed to link directly to PEPs
SKIP_DIRECT_PEP_LINK_CHECK = frozenset({"0009", "0287", "0676", "0684", "8001"})
DEFAULT_FLAGS = re.ASCII | re.IGNORECASE # Insensitive latin
# any sequence of letters or '-', followed by a single ':' and a space or end of line
HEADER_PATTERN = re.compile(r"^([a-z\-]+):(?: |$)", DEFAULT_FLAGS)
# any sequence of unicode letters or legal special characters
NAME_PATTERN = re.compile(r"(?:[^\W\d_]|[ ',\-.])+(?: |$)")
# any sequence of ASCII letters, digits, or legal special characters
EMAIL_LOCAL_PART_PATTERN = re.compile(r"[\w!#$%&'*+\-/=?^{|}~.]+", DEFAULT_FLAGS)
DISCOURSE_THREAD_PATTERN = re.compile(r"([\w\-]+/)?\d+", DEFAULT_FLAGS)
DISCOURSE_POST_PATTERN = re.compile(r"([\w\-]+/)?\d+(/\d+)?", DEFAULT_FLAGS)
MAILMAN_2_PATTERN = re.compile(r"[\w\-]+/\d{4}-[a-z]+/\d+\.html", DEFAULT_FLAGS)
MAILMAN_3_THREAD_PATTERN = re.compile(r"[\w\-]+@python\.org/thread/[a-z0-9]+/?", DEFAULT_FLAGS)
MAILMAN_3_MESSAGE_PATTERN = re.compile(r"[\w\-]+@python\.org/message/[a-z0-9]+/?(#[a-z0-9]+)?", DEFAULT_FLAGS)
# Controlled by the "--detailed" flag
DETAILED_ERRORS = False
def check(filenames: Sequence[str] = (), /) -> int:
"""The main entry-point."""
if filenames:
filenames = map(Path, filenames)
else:
filenames = PEP_ROOT.glob("pep-????.rst")
if (count := sum(map(check_file, filenames))) > 0:
s = "s" * (count != 1)
print(f"check-peps failed: {count} error{s}", file=sys.stderr)
return 1
return 0
def check_file(filename: Path, /) -> int:
filename = filename.resolve()
try:
content = filename.read_text(encoding="utf-8")
except FileNotFoundError:
return _output_error(filename, [""], [(0, "Could not read PEP!")])
else:
lines = content.splitlines()
return _output_error(filename, lines, check_peps(filename, lines))
def check_peps(filename: Path, lines: Sequence[str], /) -> MessageIterator:
yield from check_headers(lines)
for line_num, line in enumerate(lines, start=1):
if filename.stem.removeprefix("pep-") in SKIP_DIRECT_PEP_LINK_CHECK:
continue
yield from check_direct_links(line_num, line.lstrip())
def check_headers(lines: Sequence[str], /) -> MessageIterator:
yield from _validate_pep_number(next(iter(lines), ""))
found_headers = {}
line_num = 0
for line_num, line in enumerate(lines, start=1):
if line.strip() == "":
headers_end_line_num = line_num
break
if match := HEADER_PATTERN.match(line):
header = match[1]
if header in ALL_HEADERS:
if header not in found_headers:
found_headers[match[1]] = line_num
else:
yield line_num, f"Must not have duplicate header: {header} "
else:
yield line_num, f"Must not have invalid header: {header}"
else:
headers_end_line_num = line_num
yield from _validate_required_headers(found_headers.keys())
shifted_line_nums = list(found_headers.values())[1:]
for i, (header, line_num) in enumerate(found_headers.items()):
start = line_num - 1
end = headers_end_line_num - 1
if i < len(found_headers) - 1:
end = shifted_line_nums[i] - 1
remainder = "\n".join(lines[start:end]).removeprefix(f"{header}:")
if remainder != "":
if remainder[0] not in {" ", "\n"}:
yield line_num, f"Headers must have a space after the colon: {header}"
remainder = remainder.lstrip()
yield from _validate_header(header, line_num, remainder)
def _validate_header(header: str, line_num: int, content: str) -> MessageIterator:
if header == "Title":
yield from _validate_title(line_num, content)
elif header == "Author":
yield from _validate_author(line_num, content)
elif header == "Sponsor":
yield from _validate_sponsor(line_num, content)
elif header in {"BDFL-Delegate", "PEP-Delegate"}:
yield from _validate_delegate(line_num, content)
elif header == "Discussions-To":
yield from _validate_discussions_to(line_num, content)
elif header == "Status":
yield from _validate_status(line_num, content)
elif header == "Type":
yield from _validate_type(line_num, content)
elif header == "Topic":
yield from _validate_topic(line_num, content)
elif header == "Content-Type":
yield from _validate_content_type(line_num, content)
elif header in {"Requires", "Replaces", "Superseded-By"}:
yield from _validate_pep_references(line_num, content)
elif header == "Created":
yield from _validate_created(line_num, content)
elif header == "Python-Version":
yield from _validate_python_version(line_num, content)
elif header == "Post-History":
yield from _validate_post_history(line_num, content)
elif header == "Resolution":
yield from _validate_resolution(line_num, content)
def check_direct_links(line_num: int, line: str) -> MessageIterator:
"""Check that PEPs and RFCs aren't linked directly"""
line = line.lower()
if "dev/peps/pep-" in line or "peps.python.org/pep-" in line:
yield line_num, "Use the :pep:`NNN` role to refer to PEPs"
if "rfc-editor.org/rfc/" in line or "ietf.org/doc/html/rfc" in line:
yield line_num, "Use the :rfc:`NNN` role to refer to RFCs"
def _output_error(filename: Path, lines: Sequence[str], errors: Iterable[Message]) -> int:
relative_filename = filename.relative_to(ROOT_DIR)
err_count = 0
for line_num, msg in errors:
err_count += 1
print(f"{relative_filename}:{line_num}: {msg}")
if not DETAILED_ERRORS:
continue
line = lines[line_num - 1]
print(" |")
print(f"{line_num: >4} | '{line}'")
print(" |")
return err_count
###########################
# PEP Header Validators #
###########################
def _validate_required_headers(found_headers: KeysView[str]) -> MessageIterator:
"""PEPs must have all required headers, in the PEP 12 order"""
if missing := REQUIRED_HEADERS.difference(found_headers):
for missing_header in sorted(missing, key=ALL_HEADERS.index):
yield 1, f"Must have required header: {missing_header}"
ordered_headers = sorted(found_headers, key=ALL_HEADERS.index)
if list(found_headers) != ordered_headers:
order_str = ", ".join(ordered_headers)
yield 1, "Headers must be in PEP 12 order. Correct order: " + order_str
def _validate_pep_number(line: str) -> MessageIterator:
"""'PEP' header must be a number 1-9999"""
if not line.startswith("PEP: "):
yield 1, "PEP must begin with the 'PEP:' header"
return
pep_number = line.removeprefix("PEP: ").lstrip()
yield from _pep_num(1, pep_number, "'PEP:' header")
def _validate_title(line_num: int, line: str) -> MessageIterator:
"""'Title' must be 1-79 characters"""
if len(line) == 0:
yield line_num, "PEP must have a title"
elif len(line) > 79:
yield line_num, "PEP title must be less than 80 characters"
def _validate_author(line_num: int, body: str) -> MessageIterator:
"""'Author' must be list of 'Name <email@example.com>, …'"""
lines = body.split("\n")
for offset, line in enumerate(lines):
if offset >= 1 and line[:9].isspace():
# Checks for:
# Author: Alice
# Bob
# ^^^^
# Note that len("Author: ") == 8
yield line_num + offset, "Author line must not be over-indented"
if offset < len(lines) - 1:
if not line.endswith(","):
yield line_num + offset, "Author continuation lines must end with a comma"
for part in line.removesuffix(",").split(", "):
yield from _email(line_num + offset, part, "Author")
def _validate_sponsor(line_num: int, line: str) -> MessageIterator:
"""'Sponsor' must have format 'Name <email@example.com>'"""
yield from _email(line_num, line, "Sponsor")
def _validate_delegate(line_num: int, line: str) -> MessageIterator:
"""'Delegate' must have format 'Name <email@example.com>'"""
if line == "":
return
# PEP 451
if ", " in line:
for part in line.removesuffix(",").split(", "):
yield from _email(line_num, part, "Delegate")
return
yield from _email(line_num, line, "Delegate")
def _validate_discussions_to(line_num: int, line: str) -> MessageIterator:
"""'Discussions-To' must be a thread URL"""
yield from _thread(line_num, line, "Discussions-To", discussions_to=True)
if line.startswith("https://"):
return
for suffix in "@python.org", "@googlegroups.com":
if line.endswith(suffix):
remainder = line.removesuffix(suffix)
if re.fullmatch(r"[\w\-]+", remainder) is None:
yield line_num, "Discussions-To must be a valid mailing list"
return
yield line_num, "Discussions-To must be a valid thread URL or mailing list"
def _validate_status(line_num: int, line: str) -> MessageIterator:
"""'Status' must be a valid PEP status"""
if line not in ALL_STATUSES:
yield line_num, "Status must be a valid PEP status"
def _validate_type(line_num: int, line: str) -> MessageIterator:
"""'Type' must be a valid PEP type"""
if line not in {"Standards Track", "Informational", "Process"}:
yield line_num, "Type must be a valid PEP type"
def _validate_topic(line_num: int, line: str) -> MessageIterator:
"""'Topic' must be for a valid sub-index"""
topics = line.split(", ")
unique_topics = set(topics)
if len(topics) > len(unique_topics):
yield line_num, "Topic must not contain duplicates"
if unique_topics - {"Governance", "Packaging", "Typing", "Release"}:
if not all(map(str.istitle, unique_topics)):
yield line_num, "Topic must be properly capitalised (Title Case)"
if unique_topics - {"governance", "packaging", "typing", "release"}:
yield line_num, "Topic must be for a valid sub-index"
if sorted(topics) != topics:
yield line_num, "Topic must be sorted lexicographically"
def _validate_content_type(line_num: int, line: str) -> MessageIterator:
"""'Content-Type' must be 'text/x-rst'"""
if line != "text/x-rst":
yield line_num, "Content-Type must be 'text/x-rst'"
def _validate_pep_references(line_num: int, line: str) -> MessageIterator:
"""`Requires`/`Replaces`/`Superseded-By` must be 'NNN' PEP IDs"""
line = line.removesuffix(",").rstrip()
if line.count(", ") != line.count(","):
yield line_num, "PEP references must be separated by comma-spaces (', ')"
return
references = line.split(", ")
for reference in references:
yield from _pep_num(line_num, reference, "PEP reference")
def _validate_created(line_num: int, line: str) -> MessageIterator:
"""'Created' must be a 'DD-mmm-YYYY' date"""
yield from _date(line_num, line, "Created")
def _validate_python_version(line_num: int, line: str) -> MessageIterator:
"""'Python-Version' must be an ``X.Y[.Z]`` version"""
versions = line.split(", ")
for version in versions:
if version.count(".") not in {1, 2}:
yield line_num, f"Python-Version must have two or three segments: {version}"
continue
try:
major, minor, micro = version.split(".", 2)
except ValueError:
major, minor = version.split(".", 1)
micro = ""
if major not in "123":
yield line_num, f"Python-Version major part must be 1, 2, or 3: {version}"
if not _is_digits(minor) and minor != "x":
yield line_num, f"Python-Version minor part must be numeric: {version}"
elif minor != "0" and minor[0] == "0":
yield line_num, f"Python-Version minor part must not have leading zeros: {version}"
if micro == "":
return
if minor == "x":
yield line_num, f"Python-Version micro part must be empty if minor part is 'x': {version}"
elif micro[0] == "0":
yield line_num, f"Python-Version micro part must not have leading zeros: {version}"
elif not _is_digits(micro):
yield line_num, f"Python-Version micro part must be numeric: {version}"
def _validate_post_history(line_num: int, body: str) -> MessageIterator:
"""'Post-History' must be '`DD-mmm-YYYY <Thread URL>`__, …'"""
if body == "":
return
for offset, line in enumerate(body.removesuffix(",").split("\n"), start=line_num):
for post in line.removesuffix(",").strip().split(", "):
if not post.startswith("`") and not post.endswith(">`__"):
yield from _date(offset, post, "Post-History")
else:
post_date, post_url = post[1:-4].split(" <")
yield from _date(offset, post_date, "Post-History")
yield from _thread(offset, post_url, "Post-History")
def _validate_resolution(line_num: int, line: str) -> MessageIterator:
"""'Resolution' must be a direct thread/message URL"""
yield from _thread(line_num, line, "Resolution", allow_message=True)
########################
# Validation Helpers #
########################
def _pep_num(line_num: int, pep_number: str, prefix: str) -> MessageIterator:
if pep_number == "":
yield line_num, f"{prefix} must not be blank: {pep_number!r}"
return
if pep_number.startswith("0") and pep_number != "0":
yield line_num, f"{prefix} must not contain leading zeros: {pep_number!r}"
if not _is_digits(pep_number):
yield line_num, f"{prefix} must be numeric: {pep_number!r}"
elif not 0 <= int(pep_number) <= 9999:
yield line_num, f"{prefix} must be between 0 and 9999: {pep_number!r}"
def _is_digits(string: str) -> bool:
"""Match a string of ASCII digits ([0-9]+)."""
return string.isascii() and string.isdigit()
def _email(line_num: int, author_email: str, prefix: str) -> MessageIterator:
author_email = author_email.strip()
if author_email.count("<") > 1:
msg = f"{prefix} entries must not contain multiple '<': {author_email!r}"
yield line_num, msg
if author_email.count(">") > 1:
msg = f"{prefix} entries must not contain multiple '>': {author_email!r}"
yield line_num, msg
if author_email.count("@") > 1:
msg = f"{prefix} entries must not contain multiple '@': {author_email!r}"
yield line_num, msg
author = author_email.split("<", 1)[0].rstrip()
if NAME_PATTERN.fullmatch(author) is None:
msg = f"{prefix} entries must begin with a valid 'Name': {author_email!r}"
yield line_num, msg
return
email_text = author_email.removeprefix(author)
if not email_text:
# Does not have the optional email part
return
if not email_text.startswith(" <") or not email_text.endswith(">"):
msg = f"{prefix} entries must be formatted as 'Name <email@example.com>': {author_email!r}"
yield line_num, msg
email_text = email_text.removeprefix(" <").removesuffix(">")
if "@" in email_text:
local, domain = email_text.rsplit("@", 1)
elif " at " in email_text:
local, domain = email_text.rsplit(" at ", 1)
else:
yield line_num, f"{prefix} entries must contain a valid email address: {author_email!r}"
return
if EMAIL_LOCAL_PART_PATTERN.fullmatch(local) is None or _invalid_domain(domain):
yield line_num, f"{prefix} entries must contain a valid email address: {author_email!r}"
def _invalid_domain(domain_part: str) -> bool:
*labels, root = domain_part.split(".")
for label in labels:
if not label.replace("-", "").isalnum():
return True
return not root.isalnum() or not root.isascii()
def _thread(line_num: int, url: str, prefix: str, *, allow_message: bool = False, discussions_to: bool = False) -> MessageIterator:
if allow_message and discussions_to:
msg = "allow_message and discussions_to cannot both be True"
raise ValueError(msg)
msg = f"{prefix} must be a valid thread URL"
if not url.startswith("https://"):
if not discussions_to:
yield line_num, msg
return
if url.startswith("https://discuss.python.org/t/"):
remainder = url.removeprefix("https://discuss.python.org/t/").removesuffix("/")
# Discussions-To links must be the thread itself, not a post
if discussions_to:
# The equivalent pattern is similar to '([\w\-]+/)?\d+',
# but the topic name must contain a non-numeric character
# We use ``str.rpartition`` as the topic name is optional
topic_name, _, topic_id = remainder.rpartition("/")
if topic_name == '' and _is_digits(topic_id):
return
topic_name = topic_name.replace("-", "0").replace("_", "0")
# the topic name must not be entirely numeric
valid_topic_name = not _is_digits(topic_name) and topic_name.isalnum()
if valid_topic_name and _is_digits(topic_id):
return
else:
# The equivalent pattern is similar to '([\w\-]+/)?\d+(/\d+)?',
# but the topic name must contain a non-numeric character
if remainder.count("/") == 2:
# When there are three parts, the URL must be "topic-name/topic-id/post-id".
topic_name, topic_id, post_id = remainder.rsplit("/", 2)
topic_name = topic_name.replace("-", "0").replace("_", "0")
valid_topic_name = not _is_digits(topic_name) and topic_name.isalnum()
if valid_topic_name and _is_digits(topic_id) and _is_digits(post_id):
# the topic name must not be entirely numeric
return
elif remainder.count("/") == 1:
# When there are only two parts, there's an ambiguity between
# "topic-name/topic-id" and "topic-id/post-id".
# We disambiguate by checking if the LHS is a valid name and
# the RHS is a valid topic ID (for the former),
# and then if both the LHS and RHS are valid IDs (for the latter).
left, right = remainder.rsplit("/")
left = left.replace("-", "0").replace("_", "0")
# the topic name must not be entirely numeric
left_is_name = not _is_digits(left) and left.isalnum()
if left_is_name and _is_digits(right):
return
elif _is_digits(left) and _is_digits(right):
return
else:
# When there's only one part, it must be a valid topic ID.
if _is_digits(remainder):
return
if url.startswith("https://mail.python.org/pipermail/"):
remainder = url.removeprefix("https://mail.python.org/pipermail/")
if MAILMAN_2_PATTERN.fullmatch(remainder) is not None:
return
if url.startswith("https://mail.python.org/archives/list/"):
remainder = url.removeprefix("https://mail.python.org/archives/list/")
if allow_message and MAILMAN_3_MESSAGE_PATTERN.fullmatch(remainder) is not None:
return
if MAILMAN_3_THREAD_PATTERN.fullmatch(remainder) is not None:
return
yield line_num, msg
def _date(line_num: int, date_str: str, prefix: str) -> MessageIterator:
try:
parsed_date = dt.datetime.strptime(date_str, "%d-%b-%Y")
except ValueError:
yield line_num, f"{prefix} must be a 'DD-mmm-YYYY' date: {date_str!r}"
return
else:
if date_str[1] == "-": # Date must be zero-padded
yield line_num, f"{prefix} must be a 'DD-mmm-YYYY' date: {date_str!r}"
return
if parsed_date.year < 1990:
yield line_num, f"{prefix} must not be before Python was invented: {date_str!r}"
if parsed_date > (dt.datetime.now() + dt.timedelta(days=14)):
yield line_num, f"{prefix} must not be in the future: {date_str!r}"
if __name__ == "__main__":
if {"-h", "--help", "-?"}.intersection(sys.argv[1:]):
print(__doc__, file=sys.stderr)
raise SystemExit(0)
files = {}
for arg in sys.argv[1:]:
if not arg.startswith("-"):
files[arg] = None
elif arg in {"-d", "--detailed"}:
DETAILED_ERRORS = True
else:
print(f"Unknown option: {arg!r}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(check(files))

View File

@ -1,5 +1,4 @@
..
Author: Adam Turner
:author: Adam Turner
Building PEPs Locally
@ -10,8 +9,8 @@ This can also be used to check that the PEP is valid reStructuredText before
submission to the PEP editors.
The rest of this document assumes you are working from a local clone of the
`PEPs repository <https://github.com/python/peps>`__, with
**Python 3.9 or later** installed.
`PEPs repository <https://github.com/python/peps>`__,
with **Python 3.9 or later** installed.
Render PEPs locally
@ -43,7 +42,7 @@ Render PEPs locally
.. code-block:: shell
make render
make html
If you don't have access to ``make``, run:
@ -51,11 +50,6 @@ Render PEPs locally
(venv) PS> python build.py
.. note::
There may be a series of warnings about unreferenced citations or labels.
Whilst these are valid warnings, they do not impact the build process.
4. Navigate to the ``build`` directory of your PEPs repo to find the HTML pages.
PEP 0 provides a formatted index, and may be a useful reference.
@ -87,28 +81,8 @@ Check the validity of links within PEP sources (runs the `Sphinx linkchecker
.. code-block:: shell
python build.py --check-links
make check-links
Stricter rendering
''''''''''''''''''
Run in `nit-picky <https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-nitpicky>`__
mode.
This generates warnings for all missing references.
.. code-block:: shell
python build.py --nitpicky
Fail the build on any warning.
As of January 2022, there are around 250 warnings when building the PEPs.
.. code-block:: shell
python build.py --fail-on-warning
make fail-warning
python build.py --check-links
make check-links
``build.py`` usage
@ -118,4 +92,4 @@ For details on the command-line options to the ``build.py`` script, run:
.. code-block:: shell
python build.py --help
python build.py --help

View File

@ -1,6 +1,6 @@
..
Author: Adam Turner
:author: Adam Turner
..
We can't use :pep:`N` references in this document, as they use links relative
to the current file, which doesn't work in a subdirectory like this one.
@ -9,7 +9,7 @@ An Overview of the PEP Rendering System
=======================================
This document provides an overview of the PEP rendering system, as a companion
to :doc:`PEP 676 <../pep-0676>`.
to `PEP 676 <https://peps.python.org/pep-0676/>`__.
1. Configuration
@ -17,14 +17,14 @@ to :doc:`PEP 676 <../pep-0676>`.
Configuration is stored in three files:
- ``conf.py`` contains the majority of the Sphinx configuration
- ``contents.rst`` creates the Sphinx-mandated table of contents directive
- ``peps/conf.py`` contains the majority of the Sphinx configuration
- ``peps/contents.rst`` contains the compulsory table of contents directive
- ``pep_sphinx_extensions/pep_theme/theme.conf`` sets the Pygments themes
The configuration:
- registers the custom Sphinx extension
- sets both ``.txt`` and ``.rst`` suffixes to be parsed as PEPs
- sets the ``.rst`` suffix to be parsed as PEPs
- tells Sphinx which source files to use
- registers the PEP theme, maths renderer, and template
- disables some default settings that are covered in the extension
@ -35,7 +35,7 @@ The configuration:
----------------
``build.py`` manages the rendering process.
Usage is covered in :doc:`build`.
Usage is covered in `Building PEPs Locally <./build.rst>`_.
3. Extension
@ -110,7 +110,8 @@ This overrides the built-in ``:pep:`` role to return the correct URL.
3.4.2 ``PEPHeaders`` transform
******************************
PEPs start with a set of :rfc:`2822` headers, per :doc:`PEP 1 <../pep-0001>`.
PEPs start with a set of :rfc:`2822` headers,
per `PEP 1 <https://peps.python.org/pep-0001/>`__.
This transform validates that the required headers are present and of the
correct data type, and removes headers not for display.
It must run before the ``PEPTitle`` transform.
@ -122,7 +123,7 @@ It must run before the ``PEPTitle`` transform.
We generate the title node from the parsed title in the PEP headers, and make
all nodes in the document children of the new title node.
This transform must also handle parsing reStructuredText markup within PEP
titles, such as :doc:`PEP 604 <../pep-0604>`.
titles, such as `PEP 604 <https://peps.python.org/pep-0604/>`__.
3.4.4 ``PEPContents`` transform
@ -153,7 +154,7 @@ the footer (source link and last modified timestamp).
3.5 Prepare for writing
''''''''''''''''''''''''
``pep_html_builder.FileBuilder.prepare_writing`` initialises the bare miniumum
``pep_html_builder.FileBuilder.prepare_writing`` initialises the bare minimum
of the Docutils writer and the settings for writing documents.
This provides a significant speed-up over the base Sphinx implementation, as
most of the data automatically initialised was unused.
@ -216,12 +217,9 @@ parse and validate that metadata.
After collecting and validating all the PEP data, the index itself is created in
three steps:
1. Output the header text
2. Output the category and numerical indices
3. Output the author index
The ``AUTHOR_OVERRIDES.csv`` file can be used to override an author's name in
the PEP 0 output.
1. Output the header text
2. Output the category and numerical indices
3. Output the author index
We then add the newly created PEP 0 file to two Sphinx variables so that it will
be processed as a normal source document.

View File

@ -1,210 +0,0 @@
#!/usr/bin/env python3
# This file is placed in the public domain or under the
# CC0-1.0-Universal license, whichever is more permissive.
import argparse
import datetime
import email.utils
from html import escape
from pathlib import Path
import re
import docutils.frontend
from docutils import nodes
from docutils import utils
from docutils.parsers import rst
from docutils.parsers.rst import roles
# get the directory with the PEP sources
PEP_ROOT = Path(__file__).parent
def _format_rfc_2822(dt: datetime.datetime) -> str:
dt = dt.replace(tzinfo=datetime.timezone.utc)
return email.utils.format_datetime(dt, usegmt=True)
line_cache: dict[Path, dict[str, str]] = {}
# Monkeypatch PEP and RFC reference roles to match Sphinx behaviour
EXPLICIT_TITLE_RE = re.compile(r'^(.+?)\s*(?<!\x00)<(.*?)>$', re.DOTALL)
def _pep_reference_role(role, rawtext, text, lineno, inliner,
options={}, content=[]):
matched = EXPLICIT_TITLE_RE.match(text)
if matched:
title = utils.unescape(matched.group(1))
target = utils.unescape(matched.group(2))
else:
target = utils.unescape(text)
title = "PEP " + utils.unescape(text)
pep_str, _, fragment = target.partition("#")
try:
pepnum = int(pep_str)
if pepnum < 0 or pepnum > 9999:
raise ValueError
except ValueError:
msg = inliner.reporter.error(
f'PEP number must be a number from 0 to 9999; "{pep_str}" is invalid.',
line=lineno)
prb = inliner.problematic(rawtext, rawtext, msg)
return [prb], [msg]
# Base URL mainly used by inliner.pep_reference; so this is correct:
ref = (inliner.document.settings.pep_base_url
+ inliner.document.settings.pep_file_url_template % pepnum)
if fragment:
ref += "#" + fragment
roles.set_classes(options)
return [nodes.reference(rawtext, title, refuri=ref, **options)], []
def _rfc_reference_role(role, rawtext, text, lineno, inliner,
options={}, content=[]):
matched = EXPLICIT_TITLE_RE.match(text)
if matched:
title = utils.unescape(matched.group(1))
target = utils.unescape(matched.group(2))
else:
target = utils.unescape(text)
title = "RFC " + utils.unescape(text)
pep_str, _, fragment = target.partition("#")
try:
rfcnum = int(pep_str)
if rfcnum < 0 or rfcnum > 9999:
raise ValueError
except ValueError:
msg = inliner.reporter.error(
f'RFC number must be a number from 0 to 9999; "{pep_str}" is invalid.',
line=lineno)
prb = inliner.problematic(rawtext, rawtext, msg)
return [prb], [msg]
ref = (inliner.document.settings.rfc_base_url + inliner.rfc_url % rfcnum)
if fragment:
ref += "#" + fragment
roles.set_classes(options)
return [nodes.reference(rawtext, title, refuri=ref, **options)], []
roles.register_canonical_role("pep-reference", _pep_reference_role)
roles.register_canonical_role("rfc-reference", _rfc_reference_role)
def first_line_starting_with(full_path: Path, text: str) -> str:
# Try and retrieve from cache
if full_path in line_cache:
return line_cache[full_path].get(text, "")
# Else read source
line_cache[full_path] = path_cache = {}
for line in full_path.open(encoding="utf-8"):
if line.startswith("Created:"):
path_cache["Created:"] = line.removeprefix("Created:").strip()
elif line.startswith("Title:"):
path_cache["Title:"] = line.removeprefix("Title:").strip()
elif line.startswith("Author:"):
path_cache["Author:"] = line.removeprefix("Author:").strip()
# Once all have been found, exit loop
if path_cache.keys == {"Created:", "Title:", "Author:"}:
break
return path_cache.get(text, "")
def pep_creation(full_path: Path) -> datetime.datetime:
created_str = first_line_starting_with(full_path, "Created:")
if full_path.stem == "pep-0102":
# remove additional content on the Created line
created_str = created_str.split(" ", 1)[0]
return datetime.datetime.strptime(created_str, "%d-%b-%Y")
def parse_rst(full_path: Path) -> nodes.document:
text = full_path.read_text(encoding="utf-8")
settings = docutils.frontend.get_default_settings(rst.Parser)
document = utils.new_document(f'<{full_path}>', settings=settings)
rst.Parser(rfc2822=True).parse(text, document)
return document
def pep_abstract(full_path: Path) -> str:
"""Return the first paragraph of the PEP abstract"""
for node in parse_rst(full_path).findall(nodes.section):
if node.next_node(nodes.title).astext() == "Abstract":
return node.next_node(nodes.paragraph).astext().strip().replace("\n", " ")
return ""
def main():
parser = argparse.ArgumentParser(description="Generate RSS feed")
parser.add_argument(
"-o",
"--output-dir",
default="build", # synchronise with render.yaml -> deploy step
help="Output directory, relative to root. Default 'build'.",
)
args = parser.parse_args()
# get list of peps with creation time (from "Created:" string in pep source)
peps_with_dt = sorted((pep_creation(path), path) for path in PEP_ROOT.glob("pep-????.???"))
# generate rss items for 10 most recent peps
items = []
for dt, full_path in peps_with_dt[-10:]:
try:
pep_num = int(full_path.stem.split("-")[-1])
except ValueError:
continue
title = first_line_starting_with(full_path, "Title:")
author = first_line_starting_with(full_path, "Author:")
if "@" in author or " at " in author:
parsed_authors = email.utils.getaddresses([author])
joined_authors = ", ".join(f"{name} ({email_address})" for name, email_address in parsed_authors)
else:
joined_authors = author
url = f"https://peps.python.org/pep-{pep_num:0>4}/"
item = f"""\
<item>
<title>PEP {pep_num}: {escape(title, quote=False)}</title>
<link>{escape(url, quote=False)}</link>
<description>{escape(pep_abstract(full_path), quote=False)}</description>
<author>{escape(joined_authors, quote=False)}</author>
<guid isPermaLink="true">{url}</guid>
<pubDate>{_format_rfc_2822(dt)}</pubDate>
</item>"""
items.append(item)
# The rss envelope
desc = """
Newest Python Enhancement Proposals (PEPs) - Information on new
language features, and some meta-information like release
procedure and schedules.
"""
last_build_date = _format_rfc_2822(datetime.datetime.utcnow())
items = "\n".join(reversed(items))
output = f"""\
<?xml version='1.0' encoding='UTF-8'?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
<channel>
<title>Newest Python PEPs</title>
<link>https://peps.python.org/peps.rss</link>
<description>{" ".join(desc.split())}</description>
<atom:link href="https://peps.python.org/peps.rss" rel="self"/>
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
<language>en</language>
<lastBuildDate>{last_build_date}</lastBuildDate>
{items}
</channel>
</rss>
"""
# output directory for target HTML files
out_dir = PEP_ROOT / args.output_dir
out_dir.mkdir(exist_ok=True, parents=True)
out_dir.joinpath("peps.rss").write_text(output)
if __name__ == "__main__":
main()

View File

@ -1,997 +0,0 @@
PEP: 649
Title: Deferred Evaluation Of Annotations Using Descriptors
Author: Larry Hastings <larry@hastings.org>
Status: Draft
Type: Standards Track
Topic: Typing
Content-Type: text/x-rst
Created: 11-Jan-2021
Post-History: 11-Jan-2021, 11-Apr-2021
Abstract
========
As of Python 3.9, Python supports two different behaviors
for annotations:
* original or "stock" Python semantics, in which annotations
are evaluated at the time they are bound, and
* :pep:`563` semantics, currently enabled per-module by
``from __future__ import annotations``, in which annotations
are converted back into strings and must be reparsed and
executed by ``eval()`` to be used.
Original Python semantics created a circular references problem
for static typing analysis. :pep:`563` solved that problem--but
its novel semantics introduced new problems, including its
restriction that annotations can only reference names at
module-level scope.
This PEP proposes a third way that embodies the best of both
previous approaches. It solves the same circular reference
problems solved by :pep:`563`, while otherwise preserving Python's
original annotation semantics, including allowing annotations
to refer to local and class variables.
In this new approach, the code to generate the annotations
dict is written to its own function which computes and returns
the annotations dict. Then, ``__annotations__`` is a "data
descriptor" which calls this annotation function once and
retains the result. This delays the evaluation of annotations
expressions until the annotations are examined, at which point
all circular references have likely been resolved. And if
the annotations are never examined, the function is never
called and the annotations are never computed.
Annotations defined using this PEP's semantics have the same
visibility into the symbol table as annotations under "stock"
semantics--any name visible to an annotation in Python 3.9
is visible to an annotation under this PEP. In addition,
annotations under this PEP can refer to names defined *after*
the annotation is defined, as long as the name is defined in
a scope visible to the annotation. Specifically, when this PEP
is active:
* An annotation can refer to a local variable defined in the
current function scope.
* An annotation can refer to a local variable defined in an
enclosing function scope.
* An annotation can refer to a class variable defined in the
current class scope.
* An annotation can refer to a global variable.
And in all four of these cases, the variable referenced by
the annotation needn't be defined at the time the annotation
is defined--it can be defined afterwards. The only restriction
is that the name or variable be defined before the annotation
is *evaluated.*
If accepted, these new semantics for annotations would initially
be gated behind ``from __future__ import co_annotations``.
However, these semantics would eventually be promoted to be
Python's default behavior. Thus this PEP would *supersede*
:pep:`563`, and :pep:`563`'s behavior would be deprecated and
eventually removed.
Overview
========
.. note:: The code presented in this section is simplified
for clarity. The intention is to communicate the high-level
concepts involved without getting lost in with the details.
The actual details are often quite different. See the
Implementation_ section later in this PEP for a much more
accurate description of how this PEP works.
Consider this example code:
.. code-block::
def foo(x: int = 3, y: MyType = None) -> float:
...
class MyType:
...
foo_y_type = foo.__annotations__['y']
As we see here, annotations are available at runtime through an
``__annotations__`` attribute on functions, classes, and modules.
When annotations are specified on one of these objects,
``__annotations__`` is a dictionary mapping the names of the
fields to the value specified as that field's annotation.
The default behavior in Python 3.9 is to evaluate the expressions
for the annotations, and build the annotations dict, at the time
the function, class, or module is bound. At runtime the above
code actually works something like this:
.. code-block::
annotations = {'x': int, 'y': MyType, 'return': float}
def foo(x = 3, y = "abc"):
...
foo.__annotations__ = annotations
class MyType:
...
foo_y_type = foo.__annotations__['y']
The crucial detail here is that the values ``int``, ``MyType``,
and ``float`` are looked up at the time the function object is
bound, and these values are stored in the annotations dict.
But this code doesn't run—it throws a ``NameError`` on the first
line, because ``MyType`` hasn't been defined yet.
:pep:`563`'s solution is to decompile the expressions back
into strings, and store those *strings* in the annotations dict.
The equivalent runtime code would look something like this:
.. code-block::
annotations = {'x': 'int', 'y': 'MyType', 'return': 'float'}
def foo(x = 3, y = "abc"):
...
foo.__annotations__ = annotations
class MyType:
...
foo_y_type = foo.__annotations__['y']
This code now runs successfully. However, ``foo_y_type``
is no longer a reference to ``MyType``, it is the *string*
``'MyType'``. The code would have to be further modified to
call ``eval()`` or ``typing.get_type_hints()`` to convert
the string into a useful reference to the actual ``MyType``
object.
This PEP proposes a third approach, delaying the evaluation of
the annotations by computing them in their own function. If
this PEP was active, the generated code would work something
like this:
.. code-block::
class function:
# __annotations__ on a function object is already a
# "data descriptor" in Python, we're just changing what it does
@property
def __annotations__(self):
return self.__co_annotations__()
# ...
def foo_annotations_fn():
return {'x': int, 'y': MyType, 'return': float}
def foo(x = 3, y = "abc"):
...
foo.__co_annotations__ = foo_annotations_fn
class MyType:
...
foo_y_type = foo.__annotations__['y']
The important change is that the code constructing the
annotations dict now lives in a function—here, called
``foo_annotations_fn()``. But this function isn't called
until we ask for the value of ``foo.__annotations__``,
and we don't do that until *after* the definition of ``MyType``.
So this code also runs successfully, and ``foo_y_type`` now
has the correct value--the class ``MyType``--even though
``MyType`` wasn't defined until *after* the annotation was
defined.
Motivation
==========
Python's original semantics for annotations made its use for
static type analysis painful due to forward reference problems.
This was the main justification for :pep:`563`, and we need not
revisit those arguments here.
However, :pep:`563`'s solution was to decompile code for Python
annotations back into strings at compile time, requiring
users of annotations to ``eval()`` those strings to restore
them to their actual Python values. This has several drawbacks:
* It requires Python implementations to stringize their
annotations. This is surprising behavior—unprecedented
for a language-level feature. Also, adding this feature
to CPython was complicated, and this complicated code would
need to be reimplemented independently by every other Python
implementation.
* It requires that all annotations be evaluated at module-level
scope. Annotations under :pep:`563` can no longer refer to
* class variables,
* local variables in the current function, or
* local variables in enclosing functions.
* It requires a code change every time existing code uses an
annotation, to handle converting the stringized
annotation back into a useful value.
* ``eval()`` is slow.
* ``eval()`` isn't always available; it's sometimes removed
from Python for space reasons.
* In order to evaluate the annotations on a class,
it requires obtaining a reference to that class's globals,
which :pep:`563` suggests should be done by looking up that class
by name in ``sys.modules``—another surprising requirement for
a language-level feature.
* It adds an ongoing maintenance burden to Python implementations.
Every time the language adds a new feature available in expressions,
the implementation's stringizing code must be updated in
tandem in order to support decompiling it.
This PEP also solves the forward reference problem outlined in
:pep:`563` while avoiding the problems listed above:
* Python implementations would generate annotations as code
objects. This is simpler than stringizing, and is something
Python implementations are already quite good at. This means:
- alternate implementations would need to write less code to
implement this feature, and
- the implementation would be simpler overall, which should
reduce its ongoing maintenance cost.
* Existing annotations would not need to be changed to only
use global scope. Actually, annotations would become much
easier to use, as they would now also handle forward
references.
* Code examining annotations at runtime would no longer need
to use ``eval()`` or anything else—it would automatically
see the correct values. This is easier, faster, and
removes the dependency on ``eval()``.
Backwards Compatibility
=======================
:pep:`563` changed the semantics of annotations. When its semantics
are active, annotations must assume they will be evaluated in
*module-level* scope. They may no longer refer directly
to local variables or class attributes.
This PEP removes that restriction; annotations may refer to globals,
local variables inside functions, local variables defined in enclosing
functions, and class members in the current class. In addition,
annotations may refer to any of these that haven't been defined yet
at the time the annotation is defined, as long as the not-yet-defined
name is created normally (in such a way that it is known to the symbol
table for the relevant block, or is a global or class variable found
using normal name resolution). Thus, this PEP demonstrates *improved*
backwards compatibility over :pep:`563`.
:pep:`563` also requires using ``eval()`` or ``typing.get_type_hints()``
to examine annotations. Code updated to work with :pep:`563` that calls
``eval()`` directly would have to be updated simply to remove the
``eval()`` call. Code using ``typing.get_type_hints()`` would
continue to work unchanged, though future use of that function
would become optional in most cases.
Because this PEP makes semantic changes to how annotations are
evaluated, this PEP will be initially gated with a per-module
``from __future__ import co_annotations`` before it eventually
becomes the default behavior.
Apart from the delay in evaluating values stored in annotations
dicts, this PEP preserves nearly all existing behavior of
annotations dicts. Specifically:
* Annotations dicts are mutable, and any changes to them are
preserved.
* The ``__annotations__`` attribute can be explicitly set,
and any value set this way will be preserved.
* The ``__annotations__`` attribute can be deleted using
the ``del`` statement.
However, there are two uncommon interactions possible with class
and module annotations that work today—both with stock semantics,
and with :pep:`563` semantics—that would no longer work when this PEP
was active. These two interactions would have to be prohibited.
The good news is, neither is common, and neither is considered good
practice. In fact, they're rarely seen outside of Python's own
regression test suite. They are:
* *Code that sets annotations on module or class attributes
from inside any kind of flow control statement.* It's
currently possible to set module and class attributes with
annotations inside an ``if`` or ``try`` statement, and it works
as one would expect. It's untenable to support this behavior
when this PEP is active.
* *Code in module or class scope that references or modifies the
local* ``__annotations__`` *dict directly.* Currently, when
setting annotations on module or class attributes, the generated
code simply creates a local ``__annotations__`` dict, then sets
mappings in it as needed. It's also possible for user code
to directly modify this dict, though this doesn't seem like it's
an intentional feature. Although it would be possible to support
this after a fashion when this PEP was active, the semantics
would likely be surprising and wouldn't make anyone happy.
Note that these are both also pain points for static type checkers,
and are unsupported by those checkers. It seems reasonable to
declare that both are at the very least unsupported, and their
use results in undefined behavior. It might be worth making a
small effort to explicitly prohibit them with compile-time checks.
In addition, there are a few operators that would no longer be
valid for use in annotations, because their side effects would
affect the *annotation function* instead of the
class/function/module the annotation was nominally defined in:
* ``:=`` (aka the "walrus operator"),
* ``yield`` and ``yield from``, and
* ``await``.
Use of any of these operators in an annotation will result in a
compile-time error.
Since delaying the evaluation of annotations until they are
evaluated changes the semantics of the language, it's observable
from within the language. Therefore it's possible to write code
that behaves differently based on whether annotations are
evaluated at binding time or at access time, e.g.
.. code-block::
mytype = str
def foo(a:mytype): pass
mytype = int
print(foo.__annotations__['a'])
This will print ``<class 'str'>`` with stock semantics
and ``<class 'int'>`` when this PEP is active. Since
this is poor programming style to begin with, it seems
acceptable that this PEP changes its behavior.
Finally, there's a standard idiom that's actually somewhat common
when accessing class annotations, and which will become more
problematic when this PEP is active: code often accesses class
annotations via ``cls.__dict__.get("__annotations__", {})``
rather than simply ``cls.__annotations__``. It's due to a flaw
in the original design of annotations themselves. This topic
will be examined in a separate discussion; the outcome of
that discussion will likely guide the future evolution of this
PEP.
Mistaken Rejection Of This Approach In November 2017
====================================================
During the early days of discussion around :pep:`563`,
using code to delay the evaluation of annotations was
briefly discussed, in a November 2017 thread in
``comp.lang.python-dev``. At the time the
technique was termed an "implicit lambda expression".
Guido van Rossum—Python's BDFL at the time—replied,
asserting that these "implicit lambda expression" wouldn't
work, because they'd only be able to resolve symbols at
module-level scope:
IMO the inability of referencing class-level definitions
from annotations on methods pretty much kills this idea.
https://mail.python.org/pipermail/python-dev/2017-November/150109.html
This led to a short discussion about extending lambda-ized
annotations for methods to be able to refer to class-level
definitions, by maintaining a reference to the class-level
scope. This idea, too, was quickly rejected.
:pep:`PEP 563 summarizes the above discussion
<563#keeping-the-ability-to-use-function-local-state-when-defining-annotations>`
What's puzzling is :pep:`563`'s own changes to the scoping rules
of annotations—it *also* doesn't permit annotations to reference
class-level definitions. It's not immediately clear why an
inability to reference class-level definitions was enough to
reject using "implicit lambda expressions" for annotations,
but was acceptable for stringized annotations.
In retrospect there was probably a pivot during the development
of :pep:`563`. It seems that, early on, there was a prevailing
assumption that :pep:`563` would support references to class-level
definitions. But by the time :pep:`563` was finalized, this
assumption had apparently been abandoned. And it looks like
"implicit lambda expressions" were never reconsidered in this
new light.
In any case, annotations are still able to refer to class-level
definitions under this PEP, rendering the objection moot.
.. _Implementation:
Implementation
==============
There's a prototype implementation of this PEP, here:
https://github.com/larryhastings/co_annotations/
As of this writing, all features described in this PEP are
implemented, and there are some rudimentary tests in the
test suite. There are still some broken tests, and the
``co_annotations`` repo is many months behind the
CPython repo.
from __future__ import co_annotations
-------------------------------------
In the prototype, the semantics presented in this PEP are gated with:
.. code-block::
from __future__ import co_annotations
__co_annotations__
------------------
Python supports runtime metadata for annotations for three different
types: function, classes, and modules. The basic approach to
implement this PEP is much the same for all three with only minor
variations.
With this PEP, each of these types adds a new attribute,
``__co_annotations__``. ``__co_annotations__`` is a function:
it takes no arguments, and must return either ``None`` or a dict
(or subclass of dict). It adds the following semantics:
* ``__co_annotations__`` is always set, and may contain either
``None`` or a callable.
* ``__co_annotations__`` cannot be deleted.
* ``__annotations__`` and ``__co_annotations__`` can't both
be set to a useful value simultaneously:
- If you set ``__annotations__`` to a dict, this also sets
``__co_annotations__`` to None.
- If you set ``__co_annotations__`` to a callable, this also
deletes ``__annotations__``
Internally, ``__co_annotations__`` is a "data descriptor",
where functions are called whenever user code gets, sets,
or deletes the attribute. In all three cases, the object
has separate internal storage for the current value
of the ``__co_annotations__`` attribute.
``__annotations__`` is also as a data descriptor, with its own
separate internal storage for its internal value. The code
implementing the "get" for ``__annotations__`` works something
like this:
.. code-block::
if (the internal value is set)
return the internal annotations dict
if (__co_annotations__ is not None)
call the __co_annotations__ function
if the result is a dict:
store the result as the internal value
set __co_annotations__ to None
return the internal value
do whatever this object does when there are no annotations
Unbound code objects
--------------------
When Python code defines one of these three objects with
annotations, the Python compiler generates a separate code
object which builds and returns the appropriate annotations
dict. Wherever possible, the "annotation code object" is
then stored *unbound* as the internal value of
``__co_annotations__``; it is then bound on demand when
the user asks for ``__annotations__``.
This is a useful optimization for both speed and memory
consumption. Python processes rarely examine annotations
at runtime. Therefore, pre-binding these code objects to
function objects would usually be a waste of resources.
When is this optimization not possible?
* When an annotation function contains references to
free variables, in the current function or in an
outer function.
* When an annotation function is defined on a method
(a function defined inside a class) and the annotations
possibly refer directly to class variables.
Note that user code isn't permitted to directly access these
unbound code objects. If the user "gets" the value of
``__co_annotations__``, and the internal value of
``__co_annotations__`` is an unbound code object,
it immediately binds the code object, and the resulting
function object is stored as the new value of
``__co_annotations__`` and returned.
(However, these unbound code objects *are* stored in the
``.pyc`` file. So a determined user could examine them
should that be necessary for some reason.)
Function Annotations
--------------------
When compiling a function, the CPython bytecode compiler
visits the annotations for the function all in one place,
starting with ``compiler_visit_annotations()`` in ``compile.c``.
If there are any annotations, they create the scope for
the annotations function on demand, and
``compiler_visit_annotations()`` assembles it.
The code object is passed in place of the annotations dict
for the ``MAKE_FUNCTION`` bytecode instruction.
``MAKE_FUNCTION`` supports a new bit in its oparg
bitfield, ``0x10``, which tells it to expect a
``co_annotations`` code object on the stack.
The bitfields for ``annotations`` (``0x04``) and
``co_annotations`` (``0x10``) are mutually exclusive.
When binding an unbound annotation code object, a function will
use its own ``__globals__`` as the new function's globals.
One quirk of Python: you can't actually remove the annotations
from a function object. If you delete the ``__annotations__``
attribute of a function, then get its ``__annotations__`` member,
it will create an empty dict and use that as its
``__annotations__``. The implementation of this PEP maintains
this quirk for backwards compatibility.
Class Annotations
-----------------
When compiling a class body, the compiler maintains two scopes:
one for the normal class body code, and one for annotations.
(This is facilitated by four new functions: ``compiler.c``
adds ``compiler_push_scope()`` and ``compiler_pop_scope()``,
and ``symtable.c`` adds ``symtable_push_scope()`` and
``symtable_pop_scope()``.)
Once the code generator reaches the end of the class body,
but before it generates the bytecode for the class body,
it assembles the bytecode for ``__co_annotations__``, then
assigns that to ``__co_annotations__`` using ``STORE_NAME``.
It also sets a new ``__globals__`` attribute. Currently it
does this by calling ``globals()`` and storing the result.
(Surely there's a more elegant way to find the class's
globals--but this was good enough for the prototype.) When
binding an unbound annotation code object, a class will use
the value of this ``__globals__`` attribute. When the class
drops its reference to the unbound code object--either because
it has bound it to a function, or because ``__annotations__``
has been explicitly set--it also deletes its ``__globals__``
attribute.
As discussed above, examination or modification of
``__annotations__`` from within the class body is no
longer supported. Also, any flow control (``if`` or ``try`` blocks)
around declarations of members with annotations is unsupported.
If you delete the ``__annotations__`` attribute of a class,
then get its ``__annotations__`` member, it will return the
annotations dict of the first base class with annotations set.
If no base classes have annotations set, it will raise
``AttributeError``.
Although it's an implementation-specific detail, currently
classes store the internal value of ``__co_annotations__``
in their ``tp_dict`` under the same name.
Module Annotations
------------------
Module annotations work much the same as class annotations.
The main difference is, a module uses its own dict as the
``__globals__`` when binding the function.
If you delete the ``__annotations__`` attribute of a class,
then get its ``__annotations__`` member, the module will
raise ``AttributeError``.
Annotations With Closures
-------------------------
It's possible to write annotations that refer to
free variables, and even free variables that have yet
to be defined. For example:
.. code-block::
from __future__ import co_annotations
def outer():
def middle():
def inner(a:mytype, b:mytype2): pass
mytype = str
return inner
mytype2 = int
return middle()
fn = outer()
print(fn.__annotations__)
At the time ``fn`` is set, ``inner.__co_annotations__()``
hasn't been run. So it has to retain a reference to
the *future* definitions of ``mytype`` and ``mytype2`` if
it is to correctly evaluate its annotations.
If an annotation function refers to a local variable
from the current function scope, or a free variable
from an enclosing function scope--if, in CPython, the
annotation function code object contains one or more
``LOAD_DEREF`` opcodes--then the annotation code object
is bound at definition time with references to these
variables. ``LOAD_DEREF`` instructions require the annotation
function to be bound with special run-time information
(in CPython, a ``freevars`` array). Rather than store
that separately and use that to later lazy-bind the
function object, the current implementation simply
early-binds the function object.
Note that, since the annotation function ``inner.__co_annotations__()``
is defined while parsing ``outer()``, from Python's perspective
the annotation function is a "nested function". So "local
variable inside the 'current' function" and "free variable
from an enclosing function" are, from the perspective of
the annotation function, the same thing.
Annotations That Refer To Class Variables
-----------------------------------------
It's possible to write annotations that refer to
class variables, and even class variables that haven't
yet been defined. For example:
.. code-block::
from __future__ import co_annotations
class C:
def method(a:mytype): pass
mytype = str
print(C.method.__annotations__)
Internally, annotation functions are defined as
a new type of "block" in CPython's symbol table
called an ``AnnotationBlock``. An ``AnnotationBlock``
is almost identical to a ``FunctionBlock``. It differs
in that it's permitted to see names from an enclosing
class scope. (Again: annotation functions are functions,
and they're defined *inside* the same scope as
the thing they're being defined on. So in the above
example, the annotation function for ``C.method()``
is defined inside ``C``.)
If it's possible that an annotation function refers
to class variables--if all these conditions are true:
* The annotation function is being defined inside
a class scope.
* The generated code for the annotation function
has at least one ``LOAD_NAME`` instruction.
Then the annotation function is bound at the time
it's set on the class/function, and this binding
includes a reference to the class dict. The class
dict is pushed on the stack, and the ``MAKE_FUNCTION``
bytecode instruction takes a new second bitfield (0x20)
indicating that it should consume that stack argument
and store it as ``__locals__`` on the newly created
function object.
Then, at the time the function is executed, the
``f_locals`` field of the frame object is set to
the function's ``__locals__``, if set. This permits
``LOAD_NAME`` opcodes to work normally, which means
the code generated for annotation functions is nearly
identical to that generated for conventional Python
functions.
Interactive REPL Shell
----------------------
Everything works the same inside Python's interactive REPL shell,
except for module annotations in the interactive module (``__main__``)
itself. Since that module is never "finished", there's no specific
point where we can compile the ``__co_annotations__`` function.
For the sake of simplicity, in this case we forego delayed evaluation.
Module-level annotations in the REPL shell will continue to work
exactly as they do today, evaluating immediately and setting the
result directly inside the ``__annotations__`` dict.
(It might be possible to support delayed evaluation here.
But it gets complicated quickly, and for a nearly-non-existent
use case.)
Annotations On Local Variables Inside Functions
-----------------------------------------------
Python supports syntax for local variable annotations inside
functions. However, these annotations have no runtime
effect--they're discarded at compile-time. Therefore, this
PEP doesn't need to do anything to support them, the same
as stock semantics and :pep:`563`.
Performance Comparison
----------------------
Performance with this PEP should be favorable, when compared with either
stock behavior or :pep:`563`. In general, resources are only consumed
on demand—"you only pay for what you use".
There are three scenarios to consider:
* the runtime cost when annotations aren't defined,
* the runtime cost when annotations are defined but *not* referenced, and
* the runtime cost when annotations are defined *and* referenced.
We'll examine each of these scenarios in the context of all three
semantics for annotations: stock, :pep:`563`, and this PEP.
When there are no annotations, all three semantics have the same
runtime cost: zero. No annotations dict is created and no code is
generated for it. This requires no runtime processor time and
consumes no memory.
When annotations are defined but not referenced, the runtime cost
of Python with this PEP should be roughly equal to or slightly better
than :pep:`563` semantics, and slightly better than "stock" Python
semantics. The specifics depend on the object being annotated:
* With stock semantics, the annotations dict is always built, and
set as an attribute of the object being annotated.
* In :pep:`563` semantics, for function objects, a single constant
(a tuple) is set as an attribute of the function. For class and
module objects, the annotations dict is always built and set as
an attribute of the class or module.
* With this PEP, a single object is set as an attribute of the
object being annotated. Most often, this object is a constant
(a code object). In cases where the annotation refers to local
variables or class variables, the code object will be bound to
a function object, and the function object is set as the attribute
of the object being annotated.
When annotations are both defined and referenced, code using
this PEP should be much faster than code using :pep:`563` semantics,
and equivalent to or slightly improved over original Python
semantics. :pep:`563` semantics requires invoking ``eval()`` for
every value inside an annotations dict, which is enormously slow.
And, as already mentioned, this PEP generates measurably more
efficient bytecode for class and module annotations than stock
semantics; for function annotations, this PEP and stock semantics
should be roughly equivalent.
Memory use should also be comparable in all three scenarios across
all three semantic contexts. In the first and third scenarios,
memory usage should be roughly equivalent in all cases.
In the second scenario, when annotations are defined but not
referenced, using this PEP's semantics will mean the
function/class/module will store one unused code object (possibly
bound to an unused function object); with the other two semantics,
they'll store one unused dictionary (or constant tuple).
Bytecode Comparison
-------------------
The bytecode generated for annotations functions with
this PEP uses the efficient ``BUILD_CONST_KEY_MAP`` opcode
to build the dict for all annotatable objects:
functions, classes, and modules.
Stock semantics also uses ``BUILD_CONST_KEY_MAP`` bytecode
for function annotations. :pep:`563` has an even more efficient
method for building annotations dicts on functions, leveraging
the fact that its annotations dicts only contain strings for
both keys and values. At compile-time it constructs a tuple
containing pairs of keys and values at compile-time, then
at runtime it converts that tuple into a dict on demand.
This is a faster technique than either stock semantics
or this PEP can employ, because in those two cases
annotations dicts can contain Python values of any type.
Of course, this performance win is negated if the
annotations are examined, due to the overhead of ``eval()``.
For class and module annotations, both stock semantics
and :pep:`563` generate a longer and slightly-less-efficient
stanza of bytecode, creating the dict and setting the
annotations individually.
For Future Discussion
=====================
Circular Imports
----------------
There is one unfortunately-common scenario where :pep:`563`
currently provides a better experience, and it has to do
with large code bases, with circular dependencies and
imports, that examine their annotations at run-time.
:pep:`563` permitted defining *and examining* invalid
expressions as annotations. Its implementation requires
annotations to be legal Python expressions, which it then
converts into strings at compile-time. But legal Python
expressions may not be computable at runtime, if for
example the expression references a name that isn't defined.
This is a problem for stringized annotations if they're
evaluated, e.g. with ``typing.get_type_hints()``. But
any stringized annotation may be examined harmlessly at
any time--as long as you don't evaluate it, and only
examine it as a string.
Some large organizations have code bases that unfortunately
have circular dependency problems with their annotations--class
A has methods annotated with class B, but class B has methods
annotated with class A--that can be difficult to resolve.
Since :pep:`563` stringizes their annotations, it allows them
to leave these circular dependencies in place, and they can
sidestep the circular import problem by never importing the
module that defines the types used in the annotations. Their
annotations can no longer be evaluated, but this appears not
to be a concern in practice. They can then examine the
stringized form of the annotations at runtime and this seems
to be sufficient for their needs.
This PEP allows for many of the same behaviors.
Annotations must be legal Python expressions, which
are compiled into a function at compile-time.
And if the code never examines an annotation, it won't
have any runtime effect, so here too annotations can
harmlessly refer to undefined names. (It's exactly
like defining a function that refers to undefined
names--then never calling that function. Until you
call the function, nothing bad will happen.)
But examining an annotation when this PEP is active
means evaluating it, which means the names evaluated
in that expression must be defined. An undefined name
will throw a ``NameError`` in an annotation function,
just as it would with a stringized annotation passed
in to ``typing.get_type_hints()``, and just like any
other context in Python where an expression is evaluated.
In discussions we have yet to find a solution to this
problem that makes all the participants in the
conversation happy. There are various avenues to explore
here:
* One workaround is to continue to stringize one's
annotations, either by hand or done automatically
by the Python compiler (as it does today with
``from __future__ import annotations``). This might
mean preserving Python's current stringizing annotations
going forward, although leaving it turned off by default,
only available by explicit request (though likely with
a different mechanism than
``from __future__ import annotations``).
* Another possible workaround involves importing
the circularly-dependent modules separately, then
externally adding ("monkey-patching") their dependencies
to each other after the modules are loaded. As long
as the modules don't examine their annotations until
after they are completely loaded, this should work fine
and be maintainable with a minimum of effort.
* A third and more radical approach would be to change the
semantics of annotations so that they don't raise a
``NameError`` when an unknown name is evaluated,
but instead create some sort of proxy "reference" object.
* Of course, even if we do deprecate :pep:`563`, it will be
several releases before the functionality is removed,
giving us several years in which to research and innovate
new solutions for this problem.
In any case, the participants of the discussion agree that
this PEP should still move forward, even as this issue remains
currently unresolved [1]_.
.. [1] https://github.com/larryhastings/co_annotations/issues/1
cls.__globals__ and fn.__locals__
---------------------------------
Is it permissible to add the ``__globals__`` reference to class
objects as proposed here? It's not clear why this hasn't already
been done; :pep:`563` could have made use of class globals, but instead
made do with looking up classes inside ``sys.modules``. Python
seems strangely allergic to adding a ``__globals__`` reference to
class objects.
If adding ``__globals__`` to class objects is indeed a bad idea
(for reasons I don't know), here are two alternatives as to
how classes could get a reference to their globals for the
implementation of this PEP:
* The generate code for a class could bind its annotations code
object to a function at the time the class is bound, rather than
waiting for ``__annotations__`` to be referenced, making them an
exception to the rule (even though "special cases aren't special
enough to break the rules"). This would result in a small
additional runtime cost when annotations were defined but not
referenced on class objects. Honestly I'm more worried about
the lack of symmetry in semantics. (But I wouldn't want to
pre-bind all annotations code objects, as that would become
much more costly for function objects, even as annotations are
rarely used at runtime.)
* Use the class's ``__module__`` attribute to look up its module
by name in ``sys.modules``. This is what :pep:`563` advises.
While this is passable for userspace or library code, it seems
like a little bit of a code smell for this to be defined semantics
baked into the language itself.
Also, the prototype gets globals for class objects by calling
``globals()`` then storing the result. I'm sure there's a much
faster way to do this, I just didn't know what it was when I was
prototyping. I'm sure we can revise this to something much faster
and much more sanitary. I'd prefer to make it completely internal
anyway, and not make it visible to the user (via this new
__globals__ attribute). There's possibly already a good place to
put it anyway--``ht_module``.
Similarly, this PEP adds one new dunder member to functions,
classes, and modules (``__co_annotations__``), and a second new
dunder member to functions (``__locals__``). This might be
considered excessive.
Bikeshedding the name
---------------------
During most of the development of this PEP, user code actually
could see the raw annotation code objects. ``__co_annotations__``
could only be set to a code object; functions and other callables
weren't permitted. In that context the name ``co_annotations``
makes a lot of sense. But with this last-minute pivot where
``__co_annotations__`` now presents itself as a callable,
perhaps the name of the attribute and the name of the
``from __future__ import`` needs a re-think.
Acknowledgements
================
Thanks to Barry Warsaw, Eric V. Smith, Mark Shannon,
and Guido van Rossum for feedback and encouragement.
Thanks in particular to Mark Shannon for two key
suggestions—build the entire annotations dict inside
a single code object, and only bind it to a function
on demand—that quickly became among the best aspects
of this proposal. Also, thanks in particular to Guido
van Rossum for suggesting that ``__co_annotations__``
functions should duplicate the name visibility rules of
annotations under "stock" semantics--this resulted in
a sizeable improvement to the second draft. Finally,
special thanks to Jelle Zijlstra, who contributed not
just feedback--but code!
Copyright
=========
This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.
..
Local Variables:
mode: indented-text
indent-tabs-mode: nil
sentence-end-double-space: t
fill-column: 70
coding: utf-8
End:

View File

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from docutils.writers.html5_polyglot import HTMLTranslator
from sphinx import environment
from pep_sphinx_extensions.generate_rss import create_rss_feed
from pep_sphinx_extensions.pep_processor.html import pep_html_builder
from pep_sphinx_extensions.pep_processor.html import pep_html_translator
from pep_sphinx_extensions.pep_processor.parsing import pep_banner_directive
@ -27,11 +28,9 @@ def _update_config_for_builder(app: Sphinx) -> None:
app.env.document_ids = {} # For PEPReferenceRoleTitleText
app.env.settings["builder"] = app.builder.name
if app.builder.name == "dirhtml":
app.env.settings["pep_url"] = "pep-{:0>4}"
app.env.settings["pep_url"] = "pep-{:0>4}/"
# internal_builder exists if Sphinx is run by build.py
if "internal_builder" not in app.tags:
app.connect("build-finished", _post_build) # Post-build tasks
app.connect("build-finished", _post_build) # Post-build tasks
def _post_build(app: Sphinx, exception: Exception | None) -> None:
@ -41,7 +40,11 @@ def _post_build(app: Sphinx, exception: Exception | None) -> None:
if exception is not None:
return
create_index_file(Path(app.outdir), app.builder.name)
# internal_builder exists if Sphinx is run by build.py
if "internal_builder" not in app.tags:
create_index_file(Path(app.outdir), app.builder.name)
create_rss_feed(app.doctreedir, app.outdir)
def setup(app: Sphinx) -> dict[str, bool]:

View File

@ -0,0 +1,117 @@
# This file is placed in the public domain or under the
# CC0-1.0-Universal license, whichever is more permissive.
from __future__ import annotations
import datetime as dt
import pickle
from email.utils import format_datetime, getaddresses
from html import escape
from pathlib import Path
from docutils import nodes
RSS_DESCRIPTION = (
"Newest Python Enhancement Proposals (PEPs): "
"Information on new language features "
"and some meta-information like release procedure and schedules."
)
def _format_rfc_2822(datetime: dt.datetime) -> str:
datetime = datetime.replace(tzinfo=dt.timezone.utc)
return format_datetime(datetime, usegmt=True)
document_cache: dict[Path, dict[str, str]] = {}
def get_from_doctree(full_path: Path, text: str) -> str:
# Try and retrieve from cache
if full_path in document_cache:
return document_cache[full_path].get(text, "")
# Else load doctree
document = pickle.loads(full_path.read_bytes())
# Store the headers (populated in the PEPHeaders transform)
document_cache[full_path] = path_cache = document.get("headers", {})
# Store the Abstract
path_cache["Abstract"] = pep_abstract(document)
# Return the requested key
return path_cache.get(text, "")
def pep_creation(full_path: Path) -> dt.datetime:
created_str = get_from_doctree(full_path, "Created")
try:
return dt.datetime.strptime(created_str, "%d-%b-%Y")
except ValueError:
return dt.datetime.min
def pep_abstract(document: nodes.document) -> str:
"""Return the first paragraph of the PEP abstract"""
for node in document.findall(nodes.section):
title_node = node.next_node(nodes.title)
if title_node is None:
continue
if title_node.astext() == "Abstract":
return node.next_node(nodes.paragraph).astext().strip().replace("\n", " ")
return ""
def _generate_items(doctree_dir: Path):
# get list of peps with creation time (from "Created:" string in pep source)
peps_with_dt = sorted((pep_creation(path), path) for path in doctree_dir.glob("pep-????.doctree"))
# generate rss items for 10 most recent peps (in reverse order)
for datetime, full_path in reversed(peps_with_dt[-10:]):
try:
pep_num = int(get_from_doctree(full_path, "PEP"))
except ValueError:
continue
title = get_from_doctree(full_path, "Title")
url = f"https://peps.python.org/pep-{pep_num:0>4}/"
abstract = get_from_doctree(full_path, "Abstract")
author = get_from_doctree(full_path, "Author")
if "@" in author or " at " in author:
parsed_authors = getaddresses([author])
joined_authors = ", ".join(f"{name} ({email_address})" for name, email_address in parsed_authors)
else:
joined_authors = author
item = f"""\
<item>
<title>PEP {pep_num}: {escape(title, quote=False)}</title>
<link>{escape(url, quote=False)}</link>
<description>{escape(abstract, quote=False)}</description>
<author>{escape(joined_authors, quote=False)}</author>
<guid isPermaLink="true">{url}</guid>
<pubDate>{_format_rfc_2822(datetime)}</pubDate>
</item>"""
yield item
def create_rss_feed(doctree_dir: Path, output_dir: Path):
# The rss envelope
last_build_date = _format_rfc_2822(dt.datetime.now(dt.timezone.utc))
items = "\n".join(_generate_items(Path(doctree_dir)))
output = f"""\
<?xml version='1.0' encoding='UTF-8'?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
<channel>
<title>Newest Python PEPs</title>
<link>https://peps.python.org/peps.rss</link>
<description>{RSS_DESCRIPTION}</description>
<atom:link href="https://peps.python.org/peps.rss" rel="self"/>
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
<language>en</language>
<lastBuildDate>{last_build_date}</lastBuildDate>
{items}
</channel>
</rss>
"""
# output directory for target HTML files
Path(output_dir, "peps.rss").write_text(output, encoding="utf-8")

View File

@ -1,5 +1,3 @@
from pathlib import Path
from docutils import nodes
from docutils.frontend import OptionParser
from sphinx.builders.html import StandaloneHTMLBuilder
@ -22,6 +20,7 @@ class FileBuilder(StandaloneHTMLBuilder):
self.docwriter = HTMLWriter(self)
_opt_parser = OptionParser([self.docwriter], defaults=self.env.settings, read_config_files=True)
self.docsettings = _opt_parser.get_default_values()
self._orig_css_files = self._orig_js_files = []
def get_doc_context(self, docname: str, body: str, _metatags: str) -> dict:
"""Collect items for the template context of a page."""
@ -30,10 +29,6 @@ class FileBuilder(StandaloneHTMLBuilder):
except KeyError:
title = ""
# source filename
file_is_rst = Path(self.env.srcdir, docname + ".rst").exists()
source_name = f"{docname}.rst" if file_is_rst else f"{docname}.txt"
# local table of contents
toc_tree = self.env.tocs[docname].deepcopy()
if len(toc_tree) and len(toc_tree[0]) > 1:
@ -45,7 +40,7 @@ class FileBuilder(StandaloneHTMLBuilder):
else:
toc = "" # PEPs with no sections -- 9, 210
return {"title": title, "sourcename": source_name, "toc": toc, "body": body}
return {"title": title, "toc": toc, "body": body}
class DirectoryBuilder(FileBuilder):

View File

@ -5,7 +5,6 @@ from __future__ import annotations
from docutils import nodes
from docutils.parsers import rst
PYPA_SPEC_BASE_URL = "https://packaging.python.org/en/latest/specifications/"

View File

@ -1,4 +1,4 @@
import datetime
import time
from pathlib import Path
import subprocess
@ -23,7 +23,7 @@ class PEPFooter(transforms.Transform):
def apply(self) -> None:
pep_source_path = Path(self.document["source"])
if not pep_source_path.match("pep-*"):
if not pep_source_path.match("pep-????.???"):
return # not a PEP file, exit early
# Iterate through sections from the end of the document
@ -40,7 +40,7 @@ class PEPFooter(transforms.Transform):
types.add(type(node))
if isinstance(node, nodes.target):
to_hoist.append(node)
if types <= {nodes.title, nodes.target}:
if types <= {nodes.title, nodes.target, nodes.system_message}:
section.parent.extend(to_hoist)
section.parent.remove(section)
@ -54,7 +54,7 @@ class PEPFooter(transforms.Transform):
def _add_source_link(pep_source_path: Path) -> nodes.paragraph:
"""Add link to source text on VCS (GitHub)"""
source_link = f"https://github.com/python/peps/blob/main/{pep_source_path.name}"
source_link = f"https://github.com/python/peps/blob/main/peps/{pep_source_path.name}"
link_node = nodes.reference("", source_link, refuri=source_link)
return nodes.paragraph("", "Source: ", link_node)
@ -62,11 +62,10 @@ def _add_source_link(pep_source_path: Path) -> nodes.paragraph:
def _add_commit_history_info(pep_source_path: Path) -> nodes.paragraph:
"""Use local git history to find last modified date."""
try:
since_epoch = LAST_MODIFIED_TIMES[pep_source_path.name]
iso_time = _LAST_MODIFIED_TIMES[pep_source_path.stem]
except KeyError:
return nodes.paragraph()
iso_time = datetime.datetime.utcfromtimestamp(since_epoch).isoformat(sep=" ")
commit_link = f"https://github.com/python/peps/commits/main/{pep_source_path.name}"
link_node = nodes.reference("", f"{iso_time} GMT", refuri=commit_link)
return nodes.paragraph("", "Last modified: ", link_node)
@ -74,29 +73,36 @@ def _add_commit_history_info(pep_source_path: Path) -> nodes.paragraph:
def _get_last_modified_timestamps():
# get timestamps and changed files from all commits (without paging results)
args = ["git", "--no-pager", "log", "--format=#%at", "--name-only"]
with subprocess.Popen(args, stdout=subprocess.PIPE) as process:
all_modified = process.stdout.read().decode("utf-8")
process.stdout.close()
if process.wait(): # non-zero return code
return {}
args = ("git", "--no-pager", "log", "--format=#%at", "--name-only")
ret = subprocess.run(args, stdout=subprocess.PIPE, text=True, encoding="utf-8")
if ret.returncode: # non-zero return code
return {}
all_modified = ret.stdout
# remove "peps/" prefix from file names
all_modified = all_modified.replace("\npeps/", "\n")
# set up the dictionary with the *current* files
last_modified = {path.name: 0 for path in Path().glob("pep-*") if path.suffix in {".txt", ".rst"}}
peps_dir = Path(__file__, "..", "..", "..", "..", "peps").resolve()
last_modified = {path.stem: "" for path in peps_dir.glob("pep-????.rst")}
# iterate through newest to oldest, updating per file timestamps
change_sets = all_modified.removeprefix("#").split("#")
for change_set in change_sets:
timestamp, files = change_set.split("\n", 1)
for file in files.strip().split("\n"):
if file.startswith("pep-") and file[-3:] in {"txt", "rst"}:
if last_modified.get(file) == 0:
try:
last_modified[file] = float(timestamp)
except ValueError:
pass # if float conversion fails
if not file.startswith("pep-") or not file.endswith((".rst", ".txt")):
continue # not a PEP
file = file[:-4]
if last_modified.get(file) != "":
continue # most recent modified date already found
try:
y, m, d, hh, mm, ss, *_ = time.gmtime(float(timestamp))
except ValueError:
continue # if float conversion fails
last_modified[file] = f"{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}:{ss:02}"
return last_modified
LAST_MODIFIED_TIMES = _get_last_modified_timestamps()
_LAST_MODIFIED_TIMES = _get_last_modified_timestamps()

View File

@ -72,11 +72,11 @@ class PEPHeaders(transforms.Transform):
raise PEPParsingError("Document does not contain an RFC-2822 'PEP' header!")
# Extract PEP number
value = pep_field[1].astext()
pep_num_str = pep_field[1].astext()
try:
pep_num = int(value)
pep_num = int(pep_num_str)
except ValueError:
raise PEPParsingError(f"'PEP' header must contain an integer. '{value}' is invalid!")
raise PEPParsingError(f"PEP header must contain an integer. '{pep_num_str}' is invalid!")
# Special processing for PEP 0.
if pep_num == 0:
@ -89,7 +89,11 @@ class PEPHeaders(transforms.Transform):
raise PEPParsingError("No title!")
fields_to_remove = []
self.document["headers"] = headers = {}
for field in header:
row_attributes = {sub.tagname: sub.rawsource for sub in field}
headers[row_attributes["field_name"]] = row_attributes["field_body"]
name = field[0].astext().lower()
body = field[1]
if len(body) == 0:

View File

@ -2,25 +2,10 @@
/* Media Queries */
/* Further reduce width of fixed elements for smallest screens */
@media (max-width: 32em) {
section#pep-page-section {
padding: 0.25rem;
}
dl.footnote > dt,
dl.footnote > dd {
padding-left: 0;
padding-right: 0;
}
pre {
font-size: 0.75rem;
}
}
/* Reduce padding & margins for smaller screens */
@media (max-width: 40em) {
section#pep-page-section {
padding: 0.5rem;
padding: 1rem;
}
section#pep-page-section > header > h1 {
padding-right: 0;

View File

@ -61,11 +61,9 @@ img.invert-in-dark-mode {
:root {color-scheme: light dark}
html {
overflow-y: scroll;
margin: 0;
line-height: 1.4;
font-weight: normal;
line-height: 1.5;
font-size: 1rem;
font-family: "Source Sans Pro", Arial, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif;
}
body {
margin: 0;
@ -76,40 +74,27 @@ section#pep-page-section {
padding: 0.25rem;
}
/* Reduce margin sizes for body text */
p {margin: .5rem 0}
/* Header rules */
h1 {
font-size: 2rem;
font-weight: bold;
margin-top: 1.25rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1.6rem;
font-weight: bold;
margin-top: 1rem;
margin-bottom: .5rem;
}
h3 {
font-size: 1.4rem;
font-weight: normal;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
h4 {
font-size: 1.2rem;
font-weight: normal;
margin-top: .5rem;
margin-bottom: 0;
}
h5,
h6 {
font-size: 1rem;
font-weight: bold;
margin-top: 0;
margin-bottom: 0;
}
/* Anchor link rules */
@ -118,7 +103,6 @@ a:active,
a:visited {
color: var(--colour-links);
display: inline;
overflow-wrap: break-word;
overflow-wrap: anywhere;
text-decoration-color: var(--colour-background-accent-strong);
}
@ -131,7 +115,6 @@ a:focus {
blockquote {
font-style: italic;
border-left: 1px solid var(--colour-rule-strong);
margin: .5rem;
padding: .5rem 1rem;
}
blockquote em {
@ -145,13 +128,12 @@ cite {
/* Code rules (code literals and Pygments highlighting blocks) */
code,
pre {
font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", "DejaVu Sans Mono", Consolas, monospace;
font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace;
font-size: 0.875rem;
-webkit-hyphens: none;
hyphens: none;
}
code {
overflow-wrap: break-word;
overflow-wrap: anywhere;
}
code.literal {
@ -189,9 +171,8 @@ dl dd {
hr {
border: 0;
border-top: 1px solid var(--colour-rule-light);
margin: 0;
}
/*Image rules */
/* Image rules */
img {
max-width: 100%;
}
@ -201,13 +182,6 @@ a img {
}
/* List rules */
ul,
ol {
padding: 0;
margin: 0 0 0 1.5rem;
}
ul {list-style: square}
ol.arabic {list-style: decimal}
ol.loweralpha {list-style: lower-alpha}
ol.upperalpha {list-style: upper-alpha}
ol.lowerroman {list-style: lower-roman}
@ -256,6 +230,22 @@ table th + th,
table td + td {
border-left: 1px solid var(--colour-background-accent-strong);
}
/* Common column widths for PEP status tables */
table.pep-zero-table tr td:nth-child(1) {
width: 5.5%;
}
table.pep-zero-table tr td:nth-child(2) {
width: 6.5%;
}
table.pep-zero-table tr td:nth-child(3),
table.pep-zero-table tr td:nth-child(4){
width: 44%;
}
/* Authors & Sponsors table */
#authors-owners table td,
#authors-owners table th {
width: 50%;
}
/* Breadcrumbs rules */
section#pep-page-section > header {
@ -263,7 +253,6 @@ section#pep-page-section > header {
}
section#pep-page-section > header > h1 {
font-size: 1.1rem;
line-height: 1.4;
margin: 0;
display: inline-block;
padding-right: .6rem;
@ -379,7 +368,15 @@ dl.footnote > dd {
#pep-sidebar > h2 {
font-size: 1.4rem;
}
#contents ol,
#contents ul,
#pep-sidebar ol,
#pep-sidebar ul {
padding: 0;
margin: 0 0 0 1.5rem;
}
#pep-sidebar ul {
font-size: .9rem;
margin-left: 1rem;
}
#pep-sidebar ul a {
@ -410,4 +407,5 @@ dl.footnote > dd {
.sticky-banner {
top: 0;
position: sticky;
z-index: 1;
}

View File

@ -13,7 +13,6 @@
<link rel="stylesheet" href="{{ pathto('_static/pygments.css', resource=True) }}" type="text/css" media="(prefers-color-scheme: light)" id="pyg-light">
<link rel="stylesheet" href="{{ pathto('_static/pygments_dark.css', resource=True) }}" type="text/css" media="(prefers-color-scheme: dark)" id="pyg-dark">
<link rel="alternate" type="application/rss+xml" title="Latest PEPs" href="https://peps.python.org/peps.rss">
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<meta name="description" content="Python Enhancement Proposals (PEPs)">
</head>
<body>
@ -44,8 +43,8 @@
<h2>Contents</h2>
{{ toc }}
<br>
{%- if not (sourcename.startswith("pep-0000") or sourcename.startswith("topic")) %}
<a id="source" href="https://github.com/python/peps/blob/main/{{sourcename}}">Page Source (GitHub)</a>
{%- if not pagename.startswith(("pep-0000", "topic")) %}
<a id="source" href="https://github.com/python/peps/blob/main/peps/{{pagename}}.rst">Page Source (GitHub)</a>
{%- endif %}
</nav>
</section>

View File

@ -1,89 +0,0 @@
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.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 name_parts == [""]:
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

@ -2,13 +2,10 @@
from __future__ import annotations
import csv
import dataclasses
from email.parser import HeaderParser
from pathlib import Path
import re
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
@ -19,16 +16,12 @@ 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
# AUTHOR_OVERRIDES.csv is an exception file for PEP 0 name parsing
AUTHOR_OVERRIDES: dict[str, dict[str, str]] = {}
with open("AUTHOR_OVERRIDES.csv", "r", encoding="utf-8") as f:
for line in csv.DictReader(f):
full_name = line.pop("Overridden Name")
AUTHOR_OVERRIDES[full_name] = line
@dataclasses.dataclass(order=True, frozen=True)
class _Author:
"""Represent PEP authors."""
full_name: str # The author's name.
email: str # The author's email address.
class PEP:
@ -97,7 +90,9 @@ class PEP:
self.status: str = status
# Parse PEP authors
self.authors: list[Author] = _parse_authors(self, metadata["Author"], AUTHOR_OVERRIDES)
self.authors: list[_Author] = _parse_author(metadata["Author"])
if not self.authors:
raise _raise_pep_error(self, "no authors found", pep_num=True)
# Topic (for sub-indices)
_topic = metadata.get("Topic", "").lower().split(",")
@ -144,7 +139,7 @@ class PEP:
# a tooltip representing the type and status
"shorthand": self.shorthand,
# the author list as a comma-separated with only last names
"authors": ", ".join(author.nick for author in self.authors),
"authors": ", ".join(author.full_name for author in self.authors),
}
@property
@ -153,7 +148,7 @@ class PEP:
return {
"number": self.number,
"title": self.title,
"authors": ", ".join(author.nick for author in self.authors),
"authors": ", ".join(author.full_name for author in self.authors),
"discussions_to": self.discussions_to,
"status": self.status,
"type": self.pep_type,
@ -175,41 +170,27 @@ def _raise_pep_error(pep: PEP, msg: str, pep_num: bool = False) -> None:
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]
jr_placeholder = ",Jr"
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]]:
def _parse_author(data: str) -> list[_Author]:
"""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))
data = (data.replace("\n", " ")
.replace(", Jr", jr_placeholder)
.rstrip().removesuffix(","))
for author_email in data.split(", "):
if ' <' in author_email:
author, email = author_email.removesuffix(">").split(" <")
else:
author, email = author_email, ""
# If authors were found then stop searching as only expect one
# style of author citation.
if author_list:
break
author = author.strip()
if author == "":
raise ValueError("Name is empty!")
author = author.replace(jr_placeholder, ", Jr")
email = email.lower()
author_list.append(_Author(author, email))
return author_list

View File

@ -18,22 +18,22 @@ to allow it to be processed as normal.
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING
from pep_sphinx_extensions.pep_zero_generator.constants import SUBINDICES_BY_TOPIC
from pep_sphinx_extensions.pep_zero_generator import parser
from pep_sphinx_extensions.pep_zero_generator import subindices
from pep_sphinx_extensions.pep_zero_generator import writer
from pep_sphinx_extensions.pep_zero_generator.constants import SUBINDICES_BY_TOPIC
if TYPE_CHECKING:
from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment
def _parse_peps() -> list[parser.PEP]:
def _parse_peps(path: Path) -> list[parser.PEP]:
# Read from root directory
path = Path(".")
peps: list[parser.PEP] = []
for file_path in path.iterdir():
@ -41,7 +41,7 @@ def _parse_peps() -> list[parser.PEP]:
continue # Skip directories etc.
if file_path.match("pep-0000*"):
continue # Skip pre-existing PEP 0 files
if file_path.match("pep-????.???") and file_path.suffix in {".txt", ".rst"}:
if file_path.match("pep-????.rst"):
pep = parser.PEP(path.joinpath(file_path).absolute())
peps.append(pep)
@ -52,8 +52,16 @@ def create_pep_json(peps: list[parser.PEP]) -> str:
return json.dumps({pep.number: pep.full_details for pep in peps}, indent=1)
def write_peps_json(peps: list[parser.PEP], path: Path) -> None:
# Create peps.json
json_peps = create_pep_json(peps)
Path(path, "peps.json").write_text(json_peps, encoding="utf-8")
os.makedirs(os.path.join(path, "api"), exist_ok=True)
Path(path, "api", "peps.json").write_text(json_peps, encoding="utf-8")
def create_pep_zero(app: Sphinx, env: BuildEnvironment, docnames: list[str]) -> None:
peps = _parse_peps()
peps = _parse_peps(Path(app.srcdir))
pep0_text = writer.PEPZeroWriter().write_pep0(peps, builder=env.settings["builder"])
pep0_path = subindices.update_sphinx("pep-0000", pep0_text, docnames, env)
@ -61,7 +69,4 @@ def create_pep_zero(app: Sphinx, env: BuildEnvironment, docnames: list[str]) ->
subindices.generate_subindices(SUBINDICES_BY_TOPIC, peps, docnames, env)
# Create peps.json
json_path = Path(app.outdir, "api", "peps.json").resolve()
json_path.parent.mkdir(exist_ok=True)
json_path.write_text(create_pep_json(peps), encoding="utf-8")
write_peps_json(peps, Path(app.outdir))

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import TYPE_CHECKING
@ -14,8 +15,7 @@ if TYPE_CHECKING:
def update_sphinx(filename: str, text: str, docnames: list[str], env: BuildEnvironment) -> Path:
file_path = Path(f"{filename}.rst").resolve()
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path = Path(env.srcdir, f"{filename}.rst")
file_path.write_text(text, encoding="utf-8")
# Add to files for builder
@ -32,6 +32,9 @@ def generate_subindices(
docnames: list[str],
env: BuildEnvironment,
) -> None:
# create topic directory
os.makedirs(os.path.join(env.srcdir, "topic"), exist_ok=True)
# Create sub index page
generate_topic_contents(docnames, env)

View File

@ -2,14 +2,11 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
import unicodedata
from pep_sphinx_extensions.pep_processor.transforms.pep_headers import (
ABBREVIATED_STATUSES,
ABBREVIATED_TYPES,
)
from pep_sphinx_extensions.pep_processor.transforms.pep_headers import ABBREVIATED_STATUSES
from pep_sphinx_extensions.pep_processor.transforms.pep_headers import ABBREVIATED_TYPES
from pep_sphinx_extensions.pep_zero_generator.constants import DEAD_STATUSES
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_ACCEPTED
from pep_sphinx_extensions.pep_zero_generator.constants import STATUS_ACTIVE
@ -29,11 +26,10 @@ from pep_sphinx_extensions.pep_zero_generator.errors import PEPError
if TYPE_CHECKING:
from pep_sphinx_extensions.pep_zero_generator.parser import PEP
HEADER = f"""\
HEADER = """\
PEP: 0
Title: Index of Python Enhancement Proposals (PEPs)
Last-Modified: {datetime.date.today()}
Author: python-dev <python-dev@python.org>
Author: The PEP Editors
Status: Active
Type: Informational
Content-Type: text/x-rst
@ -149,7 +145,7 @@ class PEPZeroWriter:
target = (
f"topic/{subindex}.html"
if builder == "html"
else f"topic/{subindex}"
else f"../topic/{subindex}/"
)
self.emit_text(f"* `{subindex.title()} PEPs <{target}>`_")
self.emit_newline()
@ -241,7 +237,7 @@ class PEPZeroWriter:
self.emit_newline()
self.emit_newline()
pep0_string = "\n".join([str(s) for s in self.output])
pep0_string = "\n".join(map(str, self.output))
return pep0_string
@ -297,22 +293,22 @@ def _verify_email_addresses(peps: list[PEP]) -> dict[str, 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 author.full_name not in authors_dict:
authors_dict[author.full_name] = 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)
authors_dict[author.full_name].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():
for full_name, emails in authors_dict.items():
if len(emails) > 1:
too_many_emails.append((last_first, emails))
too_many_emails.append((full_name, emails))
else:
valid_authors_dict[last_first] = next(iter(emails), "")
valid_authors_dict[full_name] = next(iter(emails), "")
if too_many_emails:
err_output = []
for author, emails in too_many_emails:

View File

@ -0,0 +1,12 @@
import importlib.util
import sys
from pathlib import Path
_ROOT_PATH = Path(__file__, "..", "..", "..").resolve()
PEP_ROOT = _ROOT_PATH / "peps"
# Import "check-peps.py" as "check_peps"
CHECK_PEPS_PATH = _ROOT_PATH / "check-peps.py"
spec = importlib.util.spec_from_file_location("check_peps", CHECK_PEPS_PATH)
sys.modules["check_peps"] = check_peps = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check_peps)

View File

@ -0,0 +1,105 @@
import datetime as dt
import check_peps # NoQA: inserted into sys.modules in conftest.py
import pytest
@pytest.mark.parametrize(
"line",
[
# valid entries
"01-Jan-2000",
"29-Feb-2016",
"31-Dec-2000",
"01-Apr-2003",
"01-Apr-2007",
"01-Apr-2009",
"01-Jan-1990",
],
)
def test_validate_created(line: str):
warnings = [warning for (_, warning) in check_peps._validate_created(1, line)]
assert warnings == [], warnings
@pytest.mark.parametrize(
"date_str",
[
# valid entries
"01-Jan-2000",
"29-Feb-2016",
"31-Dec-2000",
"01-Apr-2003",
"01-Apr-2007",
"01-Apr-2009",
"01-Jan-1990",
],
)
def test_date_checker_valid(date_str: str):
warnings = [warning for (_, warning) in check_peps._date(1, date_str, "<Prefix>")]
assert warnings == [], warnings
@pytest.mark.parametrize(
"date_str",
[
# malformed
"2000-01-01",
"01 January 2000",
"1 Jan 2000",
"1-Jan-2000",
"1-January-2000",
"Jan-1-2000",
"January 1 2000",
"January 01 2000",
"01/01/2000",
"01/Jan/2000", # 🇬🇧, 🇦🇺, 🇨🇦, 🇳🇿, 🇮🇪 , ...
"Jan/01/2000", # 🇺🇸
"1st January 2000",
"The First day of January in the year of Our Lord Two Thousand",
"Jan, 1, 2000",
"2000-Jan-1",
"2000-Jan-01",
"2000-January-1",
"2000-January-01",
"00 Jan 2000",
"00-Jan-2000",
],
)
def test_date_checker_malformed(date_str: str):
warnings = [warning for (_, warning) in check_peps._date(1, date_str, "<Prefix>")]
expected = f"<Prefix> must be a 'DD-mmm-YYYY' date: {date_str!r}"
assert warnings == [expected], warnings
@pytest.mark.parametrize(
"date_str",
[
# too early
"31-Dec-1989",
"01-Apr-1916",
"01-Jan-0020",
"01-Jan-0023",
],
)
def test_date_checker_too_early(date_str: str):
warnings = [warning for (_, warning) in check_peps._date(1, date_str, "<Prefix>")]
expected = f"<Prefix> must not be before Python was invented: {date_str!r}"
assert warnings == [expected], warnings
@pytest.mark.parametrize(
"date_str",
[
# the future
"31-Dec-2999",
"01-Jan-2042",
"01-Jan-2100",
(dt.datetime.now() + dt.timedelta(days=15)).strftime("%d-%b-%Y"),
(dt.datetime.now() + dt.timedelta(days=100)).strftime("%d-%b-%Y"),
],
)
def test_date_checker_too_late(date_str: str):
warnings = [warning for (_, warning) in check_peps._date(1, date_str, "<Prefix>")]
expected = f"<Prefix> must not be in the future: {date_str!r}"
assert warnings == [expected], warnings

View File

@ -0,0 +1,30 @@
import check_peps # NoQA: inserted into sys.modules in conftest.py
import pytest
@pytest.mark.parametrize(
"line",
[
"http://www.python.org/dev/peps/pep-0000/",
"https://www.python.org/dev/peps/pep-0000/",
"http://peps.python.org/pep-0000/",
"https://peps.python.org/pep-0000/",
],
)
def test_check_direct_links_pep(line: str):
warnings = [warning for (_, warning) in check_peps.check_direct_links(1, line)]
assert warnings == ["Use the :pep:`NNN` role to refer to PEPs"], warnings
@pytest.mark.parametrize(
"line",
[
"http://www.rfc-editor.org/rfc/rfc2324",
"https://www.rfc-editor.org/rfc/rfc2324",
"http://datatracker.ietf.org/doc/html/rfc2324",
"https://datatracker.ietf.org/doc/html/rfc2324",
],
)
def test_check_direct_links_rfc(line: str):
warnings = [warning for (_, warning) in check_peps.check_direct_links(1, line)]
assert warnings == ["Use the :rfc:`NNN` role to refer to RFCs"], warnings

View File

@ -0,0 +1,238 @@
import check_peps # NoQA: inserted into sys.modules in conftest.py
import pytest
@pytest.mark.parametrize(
"line",
[
"Alice",
"Alice,",
"Alice, Bob, Charlie",
"Alice,\nBob,\nCharlie",
"Alice,\n Bob,\n Charlie",
"Alice,\n Bob,\n Charlie",
"Cardinal Ximénez",
"Alice <alice@domain.example>",
"Cardinal Ximénez <Cardinal.Ximenez@spanish.inquisition>",
],
ids=repr, # the default calls str and renders newlines.
)
def test_validate_author(line: str):
warnings = [warning for (_, warning) in check_peps._validate_author(1, line)]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"Alice,\n Bob,\n Charlie",
"Alice,\n Bob,\n Charlie",
"Alice,\n Bob,\n Charlie",
"Alice,\n Bob",
],
ids=repr, # the default calls str and renders newlines.
)
def test_validate_author_over__indented(line: str):
warnings = [warning for (_, warning) in check_peps._validate_author(1, line)]
assert {*warnings} == {"Author line must not be over-indented"}, warnings
@pytest.mark.parametrize(
"line",
[
"Cardinal Ximénez\nCardinal Biggles\nCardinal Fang",
"Cardinal Ximénez,\nCardinal Biggles\nCardinal Fang",
"Cardinal Ximénez\nCardinal Biggles,\nCardinal Fang",
],
ids=repr, # the default calls str and renders newlines.
)
def test_validate_author_continuation(line: str):
warnings = [warning for (_, warning) in check_peps._validate_author(1, line)]
assert {*warnings} == {"Author continuation lines must end with a comma"}, warnings
@pytest.mark.parametrize(
"line",
[
"Alice",
"Cardinal Ximénez",
"Alice <alice@domain.example>",
"Cardinal Ximénez <Cardinal.Ximenez@spanish.inquisition>",
],
)
def test_validate_sponsor(line: str):
warnings = [warning for (_, warning) in check_peps._validate_sponsor(1, line)]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"",
"Alice, Bob, Charlie",
"Alice, Bob, Charlie,",
"Alice <alice@domain.example>",
"Cardinal Ximénez <Cardinal.Ximenez@spanish.inquisition>",
],
)
def test_validate_delegate(line: str):
warnings = [warning for (_, warning) in check_peps._validate_delegate(1, line)]
assert warnings == [], warnings
@pytest.mark.parametrize(
("email", "expected_warnings"),
[
# ... entries must not contain multiple '...'
("Cardinal Ximénez <<", {"multiple <"}),
("Cardinal Ximénez <<<", {"multiple <"}),
("Cardinal Ximénez >>", {"multiple >"}),
("Cardinal Ximénez >>>", {"multiple >"}),
("Cardinal Ximénez <<<>>>", {"multiple <", "multiple >"}),
("Cardinal Ximénez @@", {"multiple @"}),
("Cardinal Ximénez <<@@@>", {"multiple <", "multiple @"}),
("Cardinal Ximénez <@@@>>", {"multiple >", "multiple @"}),
("Cardinal Ximénez <<@@>>", {"multiple <", "multiple >", "multiple @"}),
# valid names
("Cardinal Ximénez", set()),
(" Cardinal Ximénez", set()),
("\t\tCardinal Ximénez", set()),
("Cardinal Ximénez ", set()),
("Cardinal Ximénez\t\t", set()),
("Cardinal O'Ximénez", set()),
("Cardinal Ximénez, Inquisitor", set()),
("Cardinal Ximénez-Biggles", set()),
("Cardinal Ximénez-Biggles, Inquisitor", set()),
("Cardinal T. S. I. Ximénez", set()),
# ... entries must have a valid 'Name'
("Cardinal_Ximénez", {"valid name"}),
("Cardinal Ximénez 3", {"valid name"}),
("~ Cardinal Ximénez ~", {"valid name"}),
("Cardinal Ximénez!", {"valid name"}),
("@Cardinal Ximénez", {"valid name"}),
("Cardinal_Ximénez <>", {"valid name"}),
("Cardinal Ximénez 3 <>", {"valid name"}),
("~ Cardinal Ximénez ~ <>", {"valid name"}),
("Cardinal Ximénez! <>", {"valid name"}),
("@Cardinal Ximénez <>", {"valid name"}),
# ... entries must be formatted as 'Name <email@example.com>'
("Cardinal Ximénez<>", {"name <email>"}),
("Cardinal Ximénez<", {"name <email>"}),
("Cardinal Ximénez <", {"name <email>"}),
("Cardinal Ximénez <", {"name <email>"}),
("Cardinal Ximénez <>", {"name <email>"}),
# ... entries must contain a valid email address (missing)
("Cardinal Ximénez <>", {"valid email"}),
("Cardinal Ximénez <> ", {"valid email"}),
("Cardinal Ximénez <@> ", {"valid email"}),
("Cardinal Ximénez <at> ", {"valid email"}),
("Cardinal Ximénez < at > ", {"valid email"}),
# ... entries must contain a valid email address (local)
("Cardinal Ximénez <Cardinal.Ximénez@spanish.inquisition>", {"valid email"}),
("Cardinal Ximénez <Cardinal.Ximénez at spanish.inquisition>", {"valid email"}),
("Cardinal Ximénez <Cardinal.Ximenez AT spanish.inquisition>", {"valid email"}),
("Cardinal Ximénez <Cardinal.Ximenez @spanish.inquisition> ", {"valid email"}),
("Cardinal Ximénez <Cardinal Ximenez@spanish.inquisition> ", {"valid email"}),
("Cardinal Ximénez < Cardinal Ximenez @spanish.inquisition> ", {"valid email"}),
("Cardinal Ximénez <(Cardinal.Ximenez)@spanish.inquisition>", {"valid email"}),
("Cardinal Ximénez <Cardinal,Ximenez@spanish.inquisition>", {"valid email"}),
("Cardinal Ximénez <Cardinal:Ximenez@spanish.inquisition>", {"valid email"}),
("Cardinal Ximénez <Cardinal;Ximenez@spanish.inquisition>", {"valid email"}),
(
"Cardinal Ximénez <Cardinal><Ximenez@spanish.inquisition>",
{"multiple <", "multiple >", "valid email"},
),
(
"Cardinal Ximénez <Cardinal@Ximenez@spanish.inquisition>",
{"multiple @", "valid email"},
),
(r"Cardinal Ximénez <Cardinal\Ximenez@spanish.inquisition>", {"valid email"}),
("Cardinal Ximénez <[Cardinal.Ximenez]@spanish.inquisition>", {"valid email"}),
('Cardinal Ximénez <"Cardinal"Ximenez"@spanish.inquisition>', {"valid email"}),
("Cardinal Ximenez <Cardinal;Ximenez@spanish.inquisition>", {"valid email"}),
("Cardinal Ximénez <Cardinal£Ximénez@spanish.inquisition>", {"valid email"}),
("Cardinal Ximénez <Cardinal§Ximenez@spanish.inquisition>", {"valid email"}),
# ... entries must contain a valid email address (domain)
(
"Cardinal Ximénez <Cardinal.Ximenez@spanish+american.inquisition>",
{"valid email"},
),
("Cardinal Ximénez <Cardinal.Ximenez@spani$h.inquisition>", {"valid email"}),
("Cardinal Ximénez <Cardinal.Ximenez@spanish.inquisitioñ>", {"valid email"}),
(
"Cardinal Ximénez <Cardinal.Ximenez@th£.spanish.inquisition>",
{"valid email"},
),
# valid name-emails
("Cardinal Ximénez <Cardinal.Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal.Ximenez at spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal_Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal-Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal!Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal#Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal$Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal%Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal&Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal'Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal*Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal+Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal/Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal=Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal?Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal^Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <{Cardinal.Ximenez}@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal|Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal~Ximenez@spanish.inquisition>", set()),
("Cardinal Ximénez <Cardinal.Ximenez@español.inquisition>", set()),
("Cardinal Ximénez <Cardinal.Ximenez at español.inquisition>", set()),
("Cardinal Ximénez <Cardinal.Ximenez@spanish-american.inquisition>", set()),
],
# call str() on each parameterised value in the test ID.
ids=str,
)
def test_email_checker(email: str, expected_warnings: set):
warnings = [warning for (_, warning) in check_peps._email(1, email, "<Prefix>")]
found_warnings = set()
email = email.strip()
if "multiple <" in expected_warnings:
found_warnings.add("multiple <")
expected = f"<Prefix> entries must not contain multiple '<': {email!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "multiple >" in expected_warnings:
found_warnings.add("multiple >")
expected = f"<Prefix> entries must not contain multiple '>': {email!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "multiple @" in expected_warnings:
found_warnings.add("multiple @")
expected = f"<Prefix> entries must not contain multiple '@': {email!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "valid name" in expected_warnings:
found_warnings.add("valid name")
expected = f"<Prefix> entries must begin with a valid 'Name': {email!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "name <email>" in expected_warnings:
found_warnings.add("name <email>")
expected = f"<Prefix> entries must be formatted as 'Name <email@example.com>': {email!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "valid email" in expected_warnings:
found_warnings.add("valid email")
expected = f"<Prefix> entries must contain a valid email address: {email!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if expected_warnings == set():
assert warnings == [], warnings
assert found_warnings == expected_warnings

View File

@ -0,0 +1,408 @@
import check_peps # NoQA: inserted into sys.modules in conftest.py
import pytest
@pytest.mark.parametrize(
("test_input", "expected"),
[
# capitalisation
("Header:", "Header"),
("header:", "header"),
("hEADER:", "hEADER"),
("hEaDeR:", "hEaDeR"),
# trailing spaces
("Header: ", "Header"),
("Header: ", "Header"),
("Header: \t", "Header"),
# trailing content
("Header: Text", "Header"),
("Header: 123", "Header"),
("Header: !", "Header"),
# separators
("Hyphenated-Header:", "Hyphenated-Header"),
],
)
def test_header_pattern(test_input, expected):
assert check_peps.HEADER_PATTERN.match(test_input)[1] == expected
@pytest.mark.parametrize(
"test_input",
[
# trailing content
"Header:Text",
"Header:123",
"Header:!",
# colon position
"Header",
"Header : ",
"Header :",
"SemiColonHeader;",
# separators
"Underscored_Header:",
"Spaced Header:",
"Plus+Header:",
],
)
def test_header_pattern_no_match(test_input):
assert check_peps.HEADER_PATTERN.match(test_input) is None
def test_validate_required_headers():
found_headers = dict.fromkeys(
("PEP", "Title", "Author", "Status", "Type", "Created")
)
warnings = [
warning for (_, warning) in check_peps._validate_required_headers(found_headers)
]
assert warnings == [], warnings
def test_validate_required_headers_missing():
found_headers = dict.fromkeys(("PEP", "Title", "Author", "Type"))
warnings = [
warning for (_, warning) in check_peps._validate_required_headers(found_headers)
]
assert warnings == [
"Must have required header: Status",
"Must have required header: Created",
], warnings
def test_validate_required_headers_order():
found_headers = dict.fromkeys(
("PEP", "Title", "Sponsor", "Author", "Type", "Status", "Replaces", "Created")
)
warnings = [
warning for (_, warning) in check_peps._validate_required_headers(found_headers)
]
assert warnings == [
"Headers must be in PEP 12 order. Correct order: PEP, Title, Author, Sponsor, Status, Type, Created, Replaces"
], warnings
@pytest.mark.parametrize(
"line",
[
"!",
"The Zen of Python",
"A title that is exactly 79 characters long, but shorter than 80 characters long",
],
)
def test_validate_title(line: str):
warnings = [warning for (_, warning) in check_peps._validate_title(1, line)]
assert warnings == [], warnings
def test_validate_title_blank():
warnings = [warning for (_, warning) in check_peps._validate_title(1, "-" * 80)]
assert warnings == ["PEP title must be less than 80 characters"], warnings
def test_validate_title_too_long():
warnings = [warning for (_, warning) in check_peps._validate_title(1, "")]
assert warnings == ["PEP must have a title"], warnings
@pytest.mark.parametrize(
"line",
[
"Accepted",
"Active",
"April Fool!",
"Deferred",
"Draft",
"Final",
"Provisional",
"Rejected",
"Superseded",
"Withdrawn",
],
)
def test_validate_status_valid(line: str):
warnings = [warning for (_, warning) in check_peps._validate_status(1, line)]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"Standards Track",
"Informational",
"Process",
"accepted",
"active",
"april fool!",
"deferred",
"draft",
"final",
"provisional",
"rejected",
"superseded",
"withdrawn",
],
)
def test_validate_status_invalid(line: str):
warnings = [warning for (_, warning) in check_peps._validate_status(1, line)]
assert warnings == ["Status must be a valid PEP status"], warnings
@pytest.mark.parametrize(
"line",
[
"Standards Track",
"Informational",
"Process",
],
)
def test_validate_type_valid(line: str):
warnings = [warning for (_, warning) in check_peps._validate_type(1, line)]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"standards track",
"informational",
"process",
"Accepted",
"Active",
"April Fool!",
"Deferred",
"Draft",
"Final",
"Provisional",
"Rejected",
"Superseded",
"Withdrawn",
],
)
def test_validate_type_invalid(line: str):
warnings = [warning for (_, warning) in check_peps._validate_type(1, line)]
assert warnings == ["Type must be a valid PEP type"], warnings
@pytest.mark.parametrize(
("line", "expected_warnings"),
[
# valid entries
("Governance", set()),
("Packaging", set()),
("Typing", set()),
("Release", set()),
("Governance, Packaging", set()),
("Packaging, Typing", set()),
# duplicates
("Governance, Governance", {"duplicates"}),
("Release, Release", {"duplicates"}),
("Packaging, Packaging", {"duplicates"}),
("Spam, Spam", {"duplicates", "valid"}),
("lobster, lobster", {"duplicates", "capitalisation", "valid"}),
("governance, governance", {"duplicates", "capitalisation"}),
# capitalisation
("governance", {"capitalisation"}),
("packaging", {"capitalisation"}),
("typing", {"capitalisation"}),
("release", {"capitalisation"}),
("Governance, release", {"capitalisation"}),
# validity
("Spam", {"valid"}),
("lobster", {"capitalisation", "valid"}),
# sorted
("Packaging, Governance", {"sorted"}),
("Typing, Release", {"sorted"}),
("Release, Governance", {"sorted"}),
("spam, packaging", {"capitalisation", "valid", "sorted"}),
],
# call str() on each parameterised value in the test ID.
ids=str,
)
def test_validate_topic(line: str, expected_warnings: set):
warnings = [warning for (_, warning) in check_peps._validate_topic(1, line)]
found_warnings = set()
if "duplicates" in expected_warnings:
found_warnings.add("duplicates")
expected = "Topic must not contain duplicates"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "capitalisation" in expected_warnings:
found_warnings.add("capitalisation")
expected = "Topic must be properly capitalised (Title Case)"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "valid" in expected_warnings:
found_warnings.add("valid")
expected = "Topic must be for a valid sub-index"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "sorted" in expected_warnings:
found_warnings.add("sorted")
expected = "Topic must be sorted lexicographically"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if expected_warnings == set():
assert warnings == [], warnings
assert found_warnings == expected_warnings
def test_validate_content_type_valid():
warnings = [
warning for (_, warning) in check_peps._validate_content_type(1, "text/x-rst")
]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"text/plain",
"text/markdown",
"text/csv",
"text/rtf",
"text/javascript",
"text/html",
"text/xml",
],
)
def test_validate_content_type_invalid(line: str):
warnings = [warning for (_, warning) in check_peps._validate_content_type(1, line)]
assert warnings == ["Content-Type must be 'text/x-rst'"], warnings
@pytest.mark.parametrize(
"line",
[
"0, 1, 8, 12, 20,",
"101, 801,",
"3099, 9999",
],
)
def test_validate_pep_references(line: str):
warnings = [
warning for (_, warning) in check_peps._validate_pep_references(1, line)
]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"0,1,8, 12, 20,",
"101,801,",
"3099, 9998,9999",
],
)
def test_validate_pep_references_separators(line: str):
warnings = [
warning for (_, warning) in check_peps._validate_pep_references(1, line)
]
assert warnings == [
"PEP references must be separated by comma-spaces (', ')"
], warnings
@pytest.mark.parametrize(
("line", "expected_warnings"),
[
# valid entries
("1.0, 2.4, 2.7, 2.8, 3.0, 3.1, 3.4, 3.7, 3.11, 3.14", set()),
("2.x", set()),
("3.x", set()),
("3.0.1", set()),
# segments
("", {"segments"}),
("1", {"segments"}),
("1.2.3.4", {"segments"}),
# major
("0.0", {"major"}),
("4.0", {"major"}),
("9.0", {"major"}),
# minor number
("3.a", {"minor numeric"}),
("3.spam", {"minor numeric"}),
("3.0+", {"minor numeric"}),
("3.0-9", {"minor numeric"}),
("9.Z", {"major", "minor numeric"}),
# minor leading zero
("3.01", {"minor zero"}),
("0.00", {"major", "minor zero"}),
# micro empty
("3.x.1", {"micro empty"}),
("9.x.1", {"major", "micro empty"}),
# micro leading zero
("3.3.0", {"micro zero"}),
("3.3.00", {"micro zero"}),
("3.3.01", {"micro zero"}),
("3.0.0", {"micro zero"}),
("3.00.0", {"minor zero", "micro zero"}),
("0.00.0", {"major", "minor zero", "micro zero"}),
# micro number
("3.0.a", {"micro numeric"}),
("0.3.a", {"major", "micro numeric"}),
],
# call str() on each parameterised value in the test ID.
ids=str,
)
def test_validate_python_version(line: str, expected_warnings: set):
warnings = [
warning for (_, warning) in check_peps._validate_python_version(1, line)
]
found_warnings = set()
if "segments" in expected_warnings:
found_warnings.add("segments")
expected = f"Python-Version must have two or three segments: {line}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "major" in expected_warnings:
found_warnings.add("major")
expected = f"Python-Version major part must be 1, 2, or 3: {line}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "minor numeric" in expected_warnings:
found_warnings.add("minor numeric")
expected = f"Python-Version minor part must be numeric: {line}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "minor zero" in expected_warnings:
found_warnings.add("minor zero")
expected = f"Python-Version minor part must not have leading zeros: {line}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "micro empty" in expected_warnings:
found_warnings.add("micro empty")
expected = (
f"Python-Version micro part must be empty if minor part is 'x': {line}"
)
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "micro zero" in expected_warnings:
found_warnings.add("micro zero")
expected = f"Python-Version micro part must not have leading zeros: {line}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "micro numeric" in expected_warnings:
found_warnings.add("micro numeric")
expected = f"Python-Version micro part must be numeric: {line}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if expected_warnings == set():
assert warnings == [], warnings
assert found_warnings == expected_warnings

View File

@ -0,0 +1,48 @@
from pathlib import Path
import check_peps # NoQA: inserted into sys.modules in conftest.py
PEP_9002 = Path(__file__).parent.parent / "peps" / "pep-9002.rst"
def test_with_fake_pep():
content = PEP_9002.read_text(encoding="utf-8").splitlines()
warnings = list(check_peps.check_peps(PEP_9002, content))
assert warnings == [
(1, "PEP must begin with the 'PEP:' header"),
(9, "Must not have duplicate header: Sponsor "),
(10, "Must not have invalid header: Horse-Guards"),
(1, "Must have required header: PEP"),
(1, "Must have required header: Type"),
(
1,
"Headers must be in PEP 12 order. Correct order: Title, Version, "
"Author, Sponsor, BDFL-Delegate, Discussions-To, Status, Topic, "
"Content-Type, Requires, Created, Python-Version, Post-History, "
"Resolution",
),
(4, "Author continuation lines must end with a comma"),
(5, "Author line must not be over-indented"),
(7, "Python-Version major part must be 1, 2, or 3: 4.0"),
(
8,
"Sponsor entries must begin with a valid 'Name': "
r"'Sponsor:\nHorse-Guards: Parade'",
),
(11, "Created must be a 'DD-mmm-YYYY' date: '1-Jan-1989'"),
(12, "Delegate entries must begin with a valid 'Name': 'Barry!'"),
(13, "Status must be a valid PEP status"),
(14, "Topic must not contain duplicates"),
(14, "Topic must be properly capitalised (Title Case)"),
(14, "Topic must be for a valid sub-index"),
(14, "Topic must be sorted lexicographically"),
(15, "Content-Type must be 'text/x-rst'"),
(16, "PEP references must be separated by comma-spaces (', ')"),
(17, "Discussions-To must be a valid thread URL or mailing list"),
(18, "Post-History must be a 'DD-mmm-YYYY' date: '2-Feb-2000'"),
(18, "Post-History must be a valid thread URL"),
(19, "Post-History must be a 'DD-mmm-YYYY' date: '3-Mar-2001'"),
(19, "Post-History must be a valid thread URL"),
(20, "Resolution must be a valid thread URL"),
(23, "Use the :pep:`NNN` role to refer to PEPs"),
]

View File

@ -0,0 +1,108 @@
import check_peps # NoQA: inserted into sys.modules in conftest.py
import pytest
@pytest.mark.parametrize(
"line",
[
"PEP: 0",
"PEP: 12",
],
)
def test_validate_pep_number(line: str):
warnings = [warning for (_, warning) in check_peps._validate_pep_number(line)]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"0",
"PEP:12",
"PEP 0",
"PEP 12",
"PEP:0",
],
)
def test_validate_pep_number_invalid_header(line: str):
warnings = [warning for (_, warning) in check_peps._validate_pep_number(line)]
assert warnings == ["PEP must begin with the 'PEP:' header"], warnings
@pytest.mark.parametrize(
("pep_number", "expected_warnings"),
[
# valid entries
("0", set()),
("1", set()),
("12", set()),
("20", set()),
("101", set()),
("801", set()),
("3099", set()),
("9999", set()),
# empty
("", {"not blank"}),
# leading zeros
("01", {"leading zeros"}),
("001", {"leading zeros"}),
("0001", {"leading zeros"}),
("00001", {"leading zeros"}),
# non-numeric
("a", {"non-numeric"}),
("123abc", {"non-numeric"}),
("0123A", {"leading zeros", "non-numeric"}),
("", {"non-numeric"}),
("10", {"non-numeric"}),
("999", {"non-numeric"}),
("𝟎", {"non-numeric"}),
("𝟘", {"non-numeric"}),
("𝟏𝟚", {"non-numeric"}),
("𝟸𝟬", {"non-numeric"}),
("-1", {"non-numeric"}),
("+1", {"non-numeric"}),
# out of bounds
("10000", {"range"}),
("54321", {"range"}),
("99999", {"range"}),
("32768", {"range"}),
],
# call str() on each parameterised value in the test ID.
ids=str,
)
def test_pep_num_checker(pep_number: str, expected_warnings: set):
warnings = [
warning for (_, warning) in check_peps._pep_num(1, pep_number, "<Prefix>")
]
found_warnings = set()
pep_number = pep_number.strip()
if "not blank" in expected_warnings:
found_warnings.add("not blank")
expected = f"<Prefix> must not be blank: {pep_number!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "leading zeros" in expected_warnings:
found_warnings.add("leading zeros")
expected = f"<Prefix> must not contain leading zeros: {pep_number!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "non-numeric" in expected_warnings:
found_warnings.add("non-numeric")
expected = f"<Prefix> must be numeric: {pep_number!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if "range" in expected_warnings:
found_warnings.add("range")
expected = f"<Prefix> must be between 0 and 9999: {pep_number!r}"
matching = [w for w in warnings if w == expected]
assert matching == [expected], warnings
if expected_warnings == set():
assert warnings == [], warnings
assert found_warnings == expected_warnings

View File

@ -0,0 +1,305 @@
import check_peps # NoQA: inserted into sys.modules in conftest.py
import pytest
@pytest.mark.parametrize(
"line",
[
"list-name@python.org",
"distutils-sig@python.org",
"csv@python.org",
"python-3000@python.org",
"ipaddr-py-dev@googlegroups.com",
"python-tulip@googlegroups.com",
"https://discuss.python.org/t/thread-name/123456",
"https://discuss.python.org/t/thread-name/123456/",
"https://discuss.python.org/t/thread_name/123456",
"https://discuss.python.org/t/thread_name/123456/",
"https://discuss.python.org/t/123456/",
"https://discuss.python.org/t/123456",
],
)
def test_validate_discussions_to_valid(line: str):
warnings = [
warning for (_, warning) in check_peps._validate_discussions_to(1, line)
]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"$pecial+chars@python.org",
"a-discussions-to-list!@googlegroups.com",
],
)
def test_validate_discussions_to_list_name(line: str):
warnings = [
warning for (_, warning) in check_peps._validate_discussions_to(1, line)
]
assert warnings == ["Discussions-To must be a valid mailing list"], warnings
@pytest.mark.parametrize(
"line",
[
"list-name@python.org.uk",
"distutils-sig@mail-server.example",
],
)
def test_validate_discussions_to_invalid_list_domain(line: str):
warnings = [
warning for (_, warning) in check_peps._validate_discussions_to(1, line)
]
assert warnings == [
"Discussions-To must be a valid thread URL or mailing list"
], warnings
@pytest.mark.parametrize(
"body",
[
"",
(
"01-Jan-2001, 02-Feb-2002,\n "
"03-Mar-2003, 04-Apr-2004,\n "
"05-May-2005,"
),
(
"`01-Jan-2000 <https://mail.python.org/pipermail/list-name/0000-Month/0123456.html>`__,\n "
"`11-Mar-2005 <https://mail.python.org/archives/list/list-name@python.org/thread/abcdef0123456789/>`__,\n "
"`21-May-2010 <https://discuss.python.org/t/thread-name/123456/654321>`__,\n "
"`31-Jul-2015 <https://discuss.python.org/t/123456>`__,"
),
"01-Jan-2001, `02-Feb-2002 <https://discuss.python.org/t/123456>`__,\n03-Mar-2003",
],
)
def test_validate_post_history_valid(body: str):
warnings = [warning for (_, warning) in check_peps._validate_post_history(1, body)]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123/",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123/",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123#Anchor",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123/#Anchor",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123#Anchor123",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123/#Anchor123",
],
)
def test_validate_resolution_valid(line: str):
warnings = [warning for (_, warning) in check_peps._validate_resolution(1, line)]
assert warnings == [], warnings
@pytest.mark.parametrize(
"line",
[
"https://mail.python.org/archives/list/list-name@python.org/thread",
"https://mail.python.org/archives/list/list-name@python.org/message",
"https://mail.python.org/archives/list/list-name@python.org/thread/",
"https://mail.python.org/archives/list/list-name@python.org/message/",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123#anchor",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123/#anchor",
"https://mail.python.org/archives/list/list-name@python.org/message/#abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/message/#abcXYZ123/",
"https://mail.python.org/archives/list/list-name@python.org/spam/abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/spam/abcXYZ123/",
],
)
def test_validate_resolution_invalid(line: str):
warnings = [warning for (_, warning) in check_peps._validate_resolution(1, line)]
assert warnings == ["Resolution must be a valid thread URL"], warnings
@pytest.mark.parametrize(
"thread_url",
[
"https://discuss.python.org/t/thread-name/123456",
"https://discuss.python.org/t/thread-name/123456/",
"https://discuss.python.org/t/thread_name/123456",
"https://discuss.python.org/t/thread_name/123456/",
"https://discuss.python.org/t/thread-name/123456/654321/",
"https://discuss.python.org/t/thread-name/123456/654321",
"https://discuss.python.org/t/123456",
"https://discuss.python.org/t/123456/",
"https://discuss.python.org/t/123456/654321/",
"https://discuss.python.org/t/123456/654321",
"https://discuss.python.org/t/1",
"https://discuss.python.org/t/1/",
"https://mail.python.org/pipermail/list-name/0000-Month/0123456.html",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123/",
],
)
def test_thread_checker_valid(thread_url: str):
warnings = [
warning for (_, warning) in check_peps._thread(1, thread_url, "<Prefix>")
]
assert warnings == [], warnings
@pytest.mark.parametrize(
"thread_url",
[
"http://link.example",
"list-name@python.org",
"distutils-sig@python.org",
"csv@python.org",
"python-3000@python.org",
"ipaddr-py-dev@googlegroups.com",
"python-tulip@googlegroups.com",
"https://link.example",
"https://discuss.python.org",
"https://discuss.python.org/",
"https://discuss.python.org/c/category",
"https://discuss.python.org/t/thread_name/123456//",
"https://discuss.python.org/t/thread+name/123456",
"https://discuss.python.org/t/thread+name/123456#",
"https://discuss.python.org/t/thread+name/123456/#",
"https://discuss.python.org/t/thread+name/123456/#anchor",
"https://discuss.python.org/t/thread+name/",
"https://discuss.python.org/t/thread+name",
"https://discuss.python.org/t/thread-name/123abc",
"https://discuss.python.org/t/thread-name/123abc/",
"https://discuss.python.org/t/thread-name/123456/123abc",
"https://discuss.python.org/t/thread-name/123456/123abc/",
"https://discuss.python.org/t/123/456/789",
"https://discuss.python.org/t/123/456/789/",
"https://discuss.python.org/t/#/",
"https://discuss.python.org/t/#",
"https://mail.python.org/pipermail/list+name/0000-Month/0123456.html",
"https://mail.python.org/pipermail/list-name/YYYY-Month/0123456.html",
"https://mail.python.org/pipermail/list-name/0123456/0123456.html",
"https://mail.python.org/pipermail/list-name/0000-Month/0123456",
"https://mail.python.org/pipermail/list-name/0000-Month/0123456/",
"https://mail.python.org/pipermail/list-name/0000-Month/",
"https://mail.python.org/pipermail/list-name/0000-Month",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123#anchor",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123/#anchor",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123/",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123#anchor",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123/#anchor",
"https://mail.python.org/archives/list/list-name@python.org/spam/abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/spam/abcXYZ123/",
],
)
def test_thread_checker_invalid(thread_url: str):
warnings = [
warning for (_, warning) in check_peps._thread(1, thread_url, "<Prefix>")
]
assert warnings == ["<Prefix> must be a valid thread URL"], warnings
@pytest.mark.parametrize(
"thread_url",
[
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123/",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123/",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123#Anchor",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123/#Anchor",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123#Anchor123",
"https://mail.python.org/archives/list/list-name@python.org/message/abcXYZ123/#Anchor123",
],
)
def test_thread_checker_valid_allow_message(thread_url: str):
warnings = [
warning
for (_, warning) in check_peps._thread(
1, thread_url, "<Prefix>", allow_message=True
)
]
assert warnings == [], warnings
@pytest.mark.parametrize(
"thread_url",
[
"https://mail.python.org/archives/list/list-name@python.org/thread",
"https://mail.python.org/archives/list/list-name@python.org/message",
"https://mail.python.org/archives/list/list-name@python.org/thread/",
"https://mail.python.org/archives/list/list-name@python.org/message/",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123#anchor",
"https://mail.python.org/archives/list/list-name@python.org/thread/abcXYZ123/#anchor",
"https://mail.python.org/archives/list/list-name@python.org/message/#abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/message/#abcXYZ123/",
"https://mail.python.org/archives/list/list-name@python.org/spam/abcXYZ123",
"https://mail.python.org/archives/list/list-name@python.org/spam/abcXYZ123/",
],
)
def test_thread_checker_invalid_allow_message(thread_url: str):
warnings = [
warning
for (_, warning) in check_peps._thread(
1, thread_url, "<Prefix>", allow_message=True
)
]
assert warnings == ["<Prefix> must be a valid thread URL"], warnings
@pytest.mark.parametrize(
"thread_url",
[
"list-name@python.org",
"distutils-sig@python.org",
"csv@python.org",
"python-3000@python.org",
"ipaddr-py-dev@googlegroups.com",
"python-tulip@googlegroups.com",
"https://discuss.python.org/t/thread-name/123456",
"https://discuss.python.org/t/thread-name/123456/",
"https://discuss.python.org/t/thread_name/123456",
"https://discuss.python.org/t/thread_name/123456/",
"https://discuss.python.org/t/123456/",
"https://discuss.python.org/t/123456",
],
)
def test_thread_checker_valid_discussions_to(thread_url: str):
warnings = [
warning
for (_, warning) in check_peps._thread(
1, thread_url, "<Prefix>", discussions_to=True
)
]
assert warnings == [], warnings
@pytest.mark.parametrize(
"thread_url",
[
"https://discuss.python.org/t/thread-name/123456/000",
"https://discuss.python.org/t/thread-name/123456/000/",
"https://discuss.python.org/t/thread_name/123456/000",
"https://discuss.python.org/t/thread_name/123456/000/",
"https://discuss.python.org/t/123456/000/",
"https://discuss.python.org/t/12345656/000",
"https://discuss.python.org/t/thread-name",
"https://discuss.python.org/t/thread_name",
"https://discuss.python.org/t/thread+name",
],
)
def test_thread_checker_invalid_discussions_to(thread_url: str):
warnings = [
warning
for (_, warning) in check_peps._thread(
1, thread_url, "<Prefix>", discussions_to=True
)
]
assert warnings == ["<Prefix> must be a valid thread URL"], warnings
def test_thread_checker_allow_message_discussions_to():
with pytest.raises(ValueError, match="cannot both be True"):
list(
check_peps._thread(
1, "", "<Prefix>", allow_message=True, discussions_to=True
)
)

View File

@ -1,27 +1,29 @@
from pathlib import Path
import datetime as dt
from pep_sphinx_extensions.pep_processor.transforms import pep_footer
from ...conftest import PEP_ROOT
def test_add_source_link():
out = pep_footer._add_source_link(Path("pep-0008.txt"))
out = pep_footer._add_source_link(PEP_ROOT / "pep-0008.rst")
assert "https://github.com/python/peps/blob/main/pep-0008.txt" in str(out)
assert "https://github.com/python/peps/blob/main/peps/pep-0008.rst" in str(out)
def test_add_commit_history_info():
out = pep_footer._add_commit_history_info(Path("pep-0008.txt"))
out = pep_footer._add_commit_history_info(PEP_ROOT / "pep-0008.rst")
assert str(out).startswith(
"<paragraph>Last modified: "
'<reference refuri="https://github.com/python/peps/commits/main/pep-0008.txt">'
'<reference refuri="https://github.com/python/peps/commits/main/pep-0008.rst">'
)
# A variable timestamp comes next, don't test that
assert str(out).endswith("</reference></paragraph>")
def test_add_commit_history_info_invalid():
out = pep_footer._add_commit_history_info(Path("pep-not-found.txt"))
out = pep_footer._add_commit_history_info(PEP_ROOT / "pep-not-found.rst")
assert str(out) == "<paragraph/>"
@ -31,4 +33,4 @@ def test_get_last_modified_timestamps():
assert len(out) >= 585
# Should be a Unix timestamp and at least this
assert out["pep-0008.txt"] >= 1643124055
assert dt.datetime.fromisoformat(out["pep-0008"]).timestamp() >= 1643124055

View File

@ -18,7 +18,7 @@ from pep_sphinx_extensions.pep_zero_generator.constants import (
@pytest.mark.parametrize(
"test_input, expected",
("test_input", "expected"),
[
("my-mailing-list@example.com", "my-mailing-list@example.com"),
("python-tulip@googlegroups.com", "https://groups.google.com/g/python-tulip"),
@ -37,7 +37,7 @@ def test_generate_list_url(test_input, expected):
@pytest.mark.parametrize(
"test_input, expected",
("test_input", "expected"),
[
(
"https://mail.python.org/pipermail/python-3000/2006-November/004190.html",
@ -72,7 +72,7 @@ def test_process_pretty_url(test_input, expected):
@pytest.mark.parametrize(
"test_input, expected",
("test_input", "expected"),
[
(
"https://example.com/",
@ -94,7 +94,7 @@ def test_process_pretty_url_invalid(test_input, expected):
@pytest.mark.parametrize(
"test_input, expected",
("test_input", "expected"),
[
(
"https://mail.python.org/pipermail/python-3000/2006-November/004190.html",
@ -129,7 +129,7 @@ def test_make_link_pretty(test_input, expected):
@pytest.mark.parametrize(
"test_input, expected",
("test_input", "expected"),
[
(STATUS_ACCEPTED, "Normative proposal accepted for implementation"),
(STATUS_ACTIVE, "Currently valid informational guidance, or an in-use process"),
@ -155,7 +155,7 @@ def test_abbreviate_status_unknown():
@pytest.mark.parametrize(
"test_input, expected",
("test_input", "expected"),
[
(
TYPE_INFO,

View File

@ -5,7 +5,7 @@ from pep_sphinx_extensions.pep_processor.transforms import pep_zero
@pytest.mark.parametrize(
"test_input, expected",
("test_input", "expected"),
[
(
nodes.reference(

View File

@ -1,69 +0,0 @@
import pytest
from pep_sphinx_extensions.pep_zero_generator import author
from pep_sphinx_extensions.tests.utils import AUTHORS_OVERRIDES
@pytest.mark.parametrize(
"test_input, expected",
[
(
("First Last", "first@example.com"),
author.Author(
last_first="Last, First", nick="Last", email="first@example.com"
),
),
(
("Guido van Rossum", "guido@example.com"),
author.Author(
last_first="van Rossum, Guido (GvR)",
nick="GvR",
email="guido@example.com",
),
),
(
("Hugo van Kemenade", "hugo@example.com"),
author.Author(
last_first="van Kemenade, Hugo",
nick="van Kemenade",
email="hugo@example.com",
),
),
(
("Eric N. Vander Weele", "eric@example.com"),
author.Author(
last_first="Vander Weele, Eric N.",
nick="Vander Weele",
email="eric@example.com",
),
),
(
("Mariatta", "mariatta@example.com"),
author.Author(
last_first="Mariatta", nick="Mariatta", email="mariatta@example.com"
),
),
(
("First Last Jr.", "first@example.com"),
author.Author(
last_first="Last, First, Jr.", nick="Last", email="first@example.com"
),
),
pytest.param(
("First Last", "first at example.com"),
author.Author(
last_first="Last, First", nick="Last", email="first@example.com"
),
marks=pytest.mark.xfail,
),
],
)
def test_parse_author_email(test_input, expected):
out = author.parse_author_email(test_input, AUTHORS_OVERRIDES)
assert out == expected
def test_parse_author_email_empty_name():
with pytest.raises(ValueError, match="Name is empty!"):
author.parse_author_email(("", "user@example.com"), AUTHORS_OVERRIDES)

View File

@ -1,9 +1,6 @@
from pathlib import Path
import pytest
from pep_sphinx_extensions.pep_zero_generator import parser
from pep_sphinx_extensions.pep_zero_generator.author import Author
from pep_sphinx_extensions.pep_zero_generator.constants import (
STATUS_ACCEPTED,
STATUS_ACTIVE,
@ -18,35 +15,36 @@ from pep_sphinx_extensions.pep_zero_generator.constants import (
TYPE_PROCESS,
TYPE_STANDARDS,
)
from pep_sphinx_extensions.pep_zero_generator.errors import PEPError
from pep_sphinx_extensions.tests.utils import AUTHORS_OVERRIDES
from pep_sphinx_extensions.pep_zero_generator.parser import _Author
from ..conftest import PEP_ROOT
def test_pep_repr():
pep8 = parser.PEP(Path("pep-0008.txt"))
pep8 = parser.PEP(PEP_ROOT / "pep-0008.rst")
assert repr(pep8) == "<PEP 0008 - Style Guide for Python Code>"
def test_pep_less_than():
pep8 = parser.PEP(Path("pep-0008.txt"))
pep3333 = parser.PEP(Path("pep-3333.txt"))
pep8 = parser.PEP(PEP_ROOT / "pep-0008.rst")
pep3333 = parser.PEP(PEP_ROOT / "pep-3333.rst")
assert pep8 < pep3333
def test_pep_equal():
pep_a = parser.PEP(Path("pep-0008.txt"))
pep_b = parser.PEP(Path("pep-0008.txt"))
pep_a = parser.PEP(PEP_ROOT / "pep-0008.rst")
pep_b = parser.PEP(PEP_ROOT / "pep-0008.rst")
assert pep_a == pep_b
def test_pep_details(monkeypatch):
pep8 = parser.PEP(Path("pep-0008.txt"))
pep8 = parser.PEP(PEP_ROOT / "pep-0008.rst")
assert pep8.details == {
"authors": "GvR, Warsaw, Coghlan",
"authors": "Guido van Rossum, Barry Warsaw, Nick Coghlan",
"number": 8,
"shorthand": ":abbr:`PA (Process, Active)`",
"title": "Style Guide for Python Code",
@ -54,48 +52,43 @@ def test_pep_details(monkeypatch):
@pytest.mark.parametrize(
"test_input, expected",
("test_input", "expected"),
[
(
"First Last <user@example.com>",
[Author(last_first="Last, First", nick="Last", email="user@example.com")],
[_Author(full_name="First Last", email="user@example.com")],
),
(
"First Last",
[Author(last_first="Last, First", nick="Last", email="")],
),
(
"user@example.com (First Last)",
[Author(last_first="Last, First", nick="Last", email="user@example.com")],
[_Author(full_name="First Last", email="")],
),
pytest.param(
"First Last <user at example.com>",
[Author(last_first="Last, First", nick="Last", email="user@example.com")],
[_Author(full_name="First Last", email="user@example.com")],
marks=pytest.mark.xfail,
),
pytest.param(
" , First Last,",
{"First Last": ""},
marks=pytest.mark.xfail(raises=ValueError),
),
],
)
def test_parse_authors(test_input, expected):
# Arrange
dummy_object = parser.PEP(Path("pep-0160.txt"))
# Act
out = parser._parse_authors(dummy_object, test_input, AUTHORS_OVERRIDES)
out = parser._parse_author(test_input)
# Assert
assert out == expected
def test_parse_authors_invalid():
pep = parser.PEP(Path("pep-0008.txt"))
with pytest.raises(PEPError, match="no authors found"):
parser._parse_authors(pep, "", AUTHORS_OVERRIDES)
with pytest.raises(ValueError, match="Name is empty!"):
assert parser._parse_author("")
@pytest.mark.parametrize(
"test_type, test_status, expected",
("test_type", "test_status", "expected"),
[
(TYPE_INFO, STATUS_DRAFT, ":abbr:`I (Informational, Draft)`"),
(TYPE_INFO, STATUS_ACTIVE, ":abbr:`IA (Informational, Active)`"),
@ -113,7 +106,7 @@ def test_parse_authors_invalid():
)
def test_abbreviate_type_status(test_type, test_status, expected):
# set up dummy PEP object and monkeypatch attributes
pep = parser.PEP(Path("pep-0008.txt"))
pep = parser.PEP(PEP_ROOT / "pep-0008.rst")
pep.pep_type = test_type
pep.status = test_status

View File

@ -1,10 +1,10 @@
from pathlib import Path
from pep_sphinx_extensions.pep_zero_generator import parser, pep_index_generator
from ..conftest import PEP_ROOT
def test_create_pep_json():
peps = [parser.PEP(Path("pep-0008.txt"))]
peps = [parser.PEP(PEP_ROOT / "pep-0008.rst")]
out = pep_index_generator.create_pep_json(peps)

View File

@ -30,18 +30,18 @@ def test_pep_zero_writer_emit_title():
@pytest.mark.parametrize(
"test_input, expected",
("test_input", "expected"),
[
(
"pep-9000.rst",
{
"Fussyreverend, Francis": "one@example.com",
"Soulfulcommodore, Javier": "two@example.com",
"Francis Fussyreverend": "one@example.com",
"Javier Soulfulcommodore": "two@example.com",
},
),
(
"pep-9001.rst",
{"Fussyreverend, Francis": "", "Soulfulcommodore, Javier": ""},
{"Francis Fussyreverend": "", "Javier Soulfulcommodore": ""},
),
],
)

View File

@ -0,0 +1,23 @@
PEP:9002
Title: Nobody expects the example PEP!
Author: Cardinal Ximénez <Cardinal.Ximenez@spanish.inquisition>,
Cardinal Biggles
Cardinal Fang
Version: 4.0
Python-Version: 4.0
Sponsor:
Sponsor:
Horse-Guards: Parade
Created: 1-Jan-1989
BDFL-Delegate: Barry!
Status: Draught
Topic: Inquisiting, Governance, Governance, packaging
Content-Type: video/quicktime
Requires: 0020,1,2,3, 7, 8
Discussions-To: MR ALBERT SPIM, I,OOO,OO8 LONDON ROAD, OXFORD
Post-History: `2-Feb-2000 <FLIGHT LT. & PREBENDARY ETHEL MORRIS; THE DIMPLES; THAXTED; NR BUENOS AIRES>`__
`3-Mar-2001 <The Royal Frog Trampling Institute; 16 Rayners Lane; London>`__
Resolution:
https://peps.python.org/pep-9002.html

View File

@ -1,6 +0,0 @@
AUTHORS_OVERRIDES = {
"Guido van Rossum": {
"Surname First": "van Rossum, Guido (GvR)",
"Name Reference": "GvR",
},
}

View File

@ -3,10 +3,12 @@
"""Configuration for building PEPs using Sphinx."""
import os
from pathlib import Path
import sys
sys.path.append(str(Path("pep_sphinx_extensions").absolute()))
_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(os.fspath(_ROOT))
# -- Project information -----------------------------------------------------
@ -25,7 +27,6 @@ extensions = [
# The file extensions of source files. Sphinx uses these suffixes as sources.
source_suffix = {
".rst": "pep",
".txt": "pep",
}
# List of patterns (relative to source dir) to ignore when looking for source files.
@ -34,7 +35,6 @@ include_patterns = [
"contents.rst",
# PEP files
"pep-????.rst",
"pep-????.txt",
# PEP ancillary files
"pep-????/*.rst",
# Documentation
@ -45,10 +45,14 @@ exclude_patterns = [
"pep-0012/pep-NNNN.rst",
]
# Warn on missing references
nitpicky = True
# Intersphinx configuration
intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
'packaging': ('https://packaging.python.org/en/latest/', None),
'devguide': ('https://devguide.python.org/', None),
'py3.11': ('https://docs.python.org/3.11/', None),
'py3.12': ('https://docs.python.org/3.12/', None),
}
@ -56,11 +60,13 @@ intersphinx_disabled_reftypes = []
# -- Options for HTML output -------------------------------------------------
_PSE_PATH = _ROOT / "pep_sphinx_extensions"
# HTML output settings
html_math_renderer = "maths_to_html" # Maths rendering
# Theme settings
html_theme_path = ["pep_sphinx_extensions"]
html_theme_path = [os.fspath(_PSE_PATH)]
html_theme = "pep_theme" # The actual theme directory (child of html_theme_path)
html_use_index = False # Disable index (we use PEP 0)
html_style = "" # must be defined here or in theme.conf, but is unused
@ -68,4 +74,4 @@ html_permalinks = False # handled in the PEPContents transform
html_baseurl = "https://peps.python.org" # to create the CNAME file
gettext_auto_build = False # speed-ups
templates_path = ["pep_sphinx_extensions/pep_theme/templates"] # Theme template relative paths from `confdir`
templates_path = [os.fspath(_PSE_PATH / "pep_theme" / "templates")] # Theme template relative paths from `confdir`

View File

@ -14,6 +14,5 @@ This is an internal Sphinx page; please go to the :doc:`PEP Index <pep-0000>`.
:glob:
:caption: PEP Table of Contents (needed for Sphinx):
docs/*
pep-*
topic/*

View File

@ -185,8 +185,11 @@ corrected by the editors).
The standard PEP workflow is:
* You, the PEP author, fork the `PEP repository`_, and create a file named
``pep-9999.rst`` that contains your new PEP. Use "9999" as your draft PEP
number.
:file:`pep-{NNNN}.rst` that contains your new PEP. :samp:`{NNNN}` should be the next
available PEP number not used by a published or in-PR PEP.
* In the "PEP:" header field, enter the PEP number that matches your filename
as your draft PEP number.
* In the "Type:" header field, enter "Standards Track",
"Informational", or "Process" as appropriate, and for the "Status:"
@ -204,7 +207,7 @@ The standard PEP workflow is:
It also provides a complete introduction to reST markup that is used
in PEPs. Approval criteria are:
* It sound and complete. The ideas must make technical sense. The
* It is sound and complete. The ideas must make technical sense. The
editors do not consider whether they seem likely to be accepted.
* The title accurately describes the content.
* The PEP's language (spelling, grammar, sentence structure, etc.)
@ -293,7 +296,7 @@ pointing to this new thread.
If it is not chosen as the discussion venue,
a brief announcement post should be made to the `PEPs category`_
with at least a link to the rendered PEP and the `Discussions-To` thread
with at least a link to the rendered PEP and the ``Discussions-To`` thread
when the draft PEP is committed to the repository
and if a major-enough change is made to trigger a new thread.
@ -791,12 +794,11 @@ problem, ask the author(s) to use :pep:`12` as a template and resubmit.
Once the PEP is ready for the repository, a PEP editor will:
* Assign a PEP number (almost always just the next available number,
but sometimes it's a special/joke number, like 666 or 3141).
(Clarification: For Python 3, numbers in the 3000s were used for
Py3k-specific proposals. But now that all new features go into
Python 3 only, the process is back to using numbers in the 100s again.
Remember that numbers below 100 are meta-PEPs.)
* Check that the author has selected a valid PEP number or assign them a
number if they have not (almost always just the next available number, but
sometimes it's a special/joke number, like 666 or 3141).
Remember that numbers below 100 are meta-PEPs.
* Check that the author has correctly labeled the PEP's type
("Standards Track", "Informational", or "Process"), and marked its

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -45,7 +45,7 @@ Prohibitions
Bug fix releases are required to adhere to the following restrictions:
1. There must be zero syntax changes. All `.pyc` and `.pyo` files must
1. There must be zero syntax changes. All ``.pyc`` and ``.pyo`` files must
work (no regeneration needed) with all bugfix releases forked off
from a major release.

View File

@ -490,7 +490,7 @@ Avoid extraneous whitespace in the following situations:
# Wrong:
ham[lower + offset:upper + offset]
ham[1: 9], ham[1 :9], ham[1:9 :3]
ham[lower : : upper]
ham[lower : : step]
ham[ : upper]
- Immediately before the open parenthesis that starts the argument

View File

@ -8,7 +8,7 @@ Content-Type: text/x-rst
Created: 07-Jul-2002
Post-History: `18-Aug-2007 <https://mail.python.org/archives/list/python-dev@python.org/thread/DSSGXU5LBCMKYMZBRVB6RF3YAB6ST5AV/>`__,
`14-May-2014 <https://mail.python.org/archives/list/python-dev@python.org/thread/T7WTUJ6TD3IGYGWV3M4PHJWNLM2WPZAW/>`__,
`20-Feb-2015 <https://mail.python.org/archives/list/python-dev@python.org/thread/OEQHRR2COYZDL6LZ42RBZOMIUB32WI34/#L3K7IKGVT4ND45SKAJPJ3Q2ADVK5KP52>`__,
`20-Feb-2015 <https://mail.python.org/archives/list/python-dev@python.org/thread/OEQHRR2COYZDL6LZ42RBZOMIUB32WI34/>`__,
`10-Mar-2022 <https://mail.python.org/archives/list/python-committers@python.org/thread/K757345KX6W5ZLTWYBUXOXQTJJTL7GW5/>`__,
@ -42,7 +42,7 @@ platform to be considered supported by CPython as well as providing a
procedure to remove code for platforms with few or no CPython
users.
This PEP also lists what plaforms *are* supported by the CPython
This PEP also lists what platforms *are* supported by the CPython
interpreter. This lets people know what platforms are directly
supported by the CPython development team.

View File

@ -60,14 +60,10 @@ Once you've decided which type of PEP yours is going to be, follow the
directions below.
- Make a copy of this file (the ``.rst`` file, **not** the HTML!) and
perform the following edits. Name the new file ``pep-9999.rst`` if
you don't yet have a PEP number assignment, or ``pep-NNNN.rst`` if
you do. Those with push permissions are welcome to claim the next
available number (ignoring the special blocks 3000 and 8000, and a
handful of special allocations - currently 666, 754, and 801) and
push it directly.
perform the following edits. Name the new file :file:`pep-{NNNN}.rst`, using
the next available number (not used by a published or in-PR PEP).
- Replace the "PEP: 12" header with "PEP: 9999" or "PEP: NNNN",
- Replace the "PEP: 12" header with "PEP: NNNN",
matching the file name. Note that the file name should be padded with
zeros (eg ``pep-0012.rst``), but the header should not (``PEP: 12``).
@ -158,7 +154,7 @@ directions below.
non-inline link targets referenced by the text.
- Run ``./build.py`` to ensure the PEP is rendered without errors,
and check that the output in ``build/pep-9999.html`` looks as you intend.
and check that the output in :file:`build/pep-{NNNN}.html` looks as you intend.
- Create a pull request against the `PEPs repository`_.

View File

@ -78,6 +78,11 @@ Here's a hopefully-complete list.
* A subscription to the super secret release manager mailing list, which may
or may not be called ``python-cabal``. Bug Barry about this.
* A ``@python.org`` email address that you will use to sign your releases
with. Ask ``postmaster@`` for an address; you can either get a full
account, or a redirecting alias + SMTP credentials to send email from
this address that looks legit to major email providers.
Types of Releases
=================
@ -121,9 +126,10 @@ release. The roles and their current experts are:
* RM = Release Manager
- Łukasz Langa <lukasz@python.org> (Central Europe)
- Ned Deily <nad@python.org> (US)
- Thomas Wouters <thomas@python.org> (NL)
- Pablo Galindo Salgado <pablogsal@python.org> (UK)
- Łukasz Langa <lukasz@python.org> (PL)
- Ned Deily <nad@python.org> (US)
* WE = Windows - Steve Dower <steve.dower@python.org>
* ME = Mac - Ned Deily <nad@python.org> (US)
@ -206,7 +212,7 @@ to perform some manual editing steps.
within it (called the "release clone" from now on). You can use the same
GitHub fork you use for cpython development. Using the standard setup
recommended in the Python Developer's Guide, your fork would be referred
to as `origin` and the standard cpython repo as `upstream`. You will
to as ``origin`` and the standard cpython repo as ``upstream``. You will
use the branch on your fork to do the release engineering work, including
tagging the release, and you will use it to share with the other experts
for making the binaries.
@ -296,7 +302,7 @@ to perform some manual editing steps.
$ .../release-tools/release.py --tag X.Y.ZaN
This executes a `git tag` command with the `-s` option so that the
This executes a ``git tag`` command with the ``-s`` option so that the
release tag in the repo is signed with your gpg key. When prompted
choose the private key you use for signing release tarballs etc.
@ -321,6 +327,10 @@ to perform some manual editing steps.
tarballs and signatures in a subdirectory called ``X.Y.ZaN/src``, and the
built docs in ``X.Y.ZaN/docs`` (for **final** releases).
Note that the script will sign your release with Sigstore. Please use
your **@python.org** email address for this. See here for more information:
https://www.python.org/download/sigstore/.
- Now you want to perform the very important step of checking the
tarball you just created, to make sure a completely clean,
virgin build passes the regression test. Here are the best
@ -528,7 +538,7 @@ the main repo.
do some post-merge cleanup. Check the top-level ``README.rst``
and ``include/patchlevel.h`` files to ensure they now reflect
the desired post-release values for on-going development.
The patchlevel should be the release tag with a `+`.
The patchlevel should be the release tag with a ``+``.
Also, if you cherry-picked changes from the standard release
branch into the release engineering branch for this release,
you will now need to manual remove each blurb entry from
@ -536,8 +546,8 @@ the main repo.
into the release you are working on since that blurb entry
is now captured in the merged x.y.z.rst file for the new
release. Otherwise, the blurb entry will appear twice in
the `changelog.html` file, once under `Python next` and again
under `x.y.z`.
the ``changelog.html`` file, once under ``Python next`` and again
under ``x.y.z``.
- Review and commit these changes::
@ -689,6 +699,11 @@ with RevSys.)
(It's best to update add-to-pydotorg.py when file types
are removed, too.)
The script will also sign any remaining files that were not
signed with Sigstore until this point. Again, if this happens,
do use your @python.org address for this process. More info:
https://www.python.org/download/sigstore/
- In case the CDN already cached a version of the Downloads page
without the files present, you can invalidate the cache using::
@ -697,19 +712,19 @@ with RevSys.)
- If this is a **final** release:
- Add the new version to the *Python Documentation by Version*
page `https://www.python.org/doc/versions/` and
page ``https://www.python.org/doc/versions/`` and
remove the current version from any 'in development' section.
- For X.Y.Z, edit all the previous X.Y releases' page(s) to
point to the new release. This includes the content field of the
`Downloads -> Releases` entry for the release::
``Downloads -> Releases`` entry for the release::
Note: Python x.y.m has been superseded by
`Python x.y.n </downloads/release/python-xyn/>`_.
And, for those releases having separate release page entries
(phasing these out?), update those pages as well,
e.g. `download/releases/x.y.z`::
e.g. ``download/releases/x.y.z``::
Note: Python x.y.m has been superseded by
`Python x.y.n </download/releases/x.y.n/>`_.
@ -893,8 +908,8 @@ else does them. Some of those tasks include:
- Remove the release from the list of "Active Python Releases" on the Downloads
page. To do this, log in to the admin page for python.org, navigate to Boxes,
and edit the `downloads-active-releases` entry. Simply strip out the relevant
paragraph of HTML for your release. (You'll probably have to do the `curl -X PURGE`
and edit the ``downloads-active-releases`` entry. Simply strip out the relevant
paragraph of HTML for your release. (You'll probably have to do the ``curl -X PURGE``
trick to purge the cache if you want to confirm you made the change correctly.)
- Add retired notice to each release page on python.org for the retired branch.

View File

@ -46,8 +46,8 @@ Lockstep For-Loops
Lockstep for-loops are non-nested iterations over two or more
sequences, such that at each pass through the loop, one element from
each sequence is taken to compose the target. This behavior can
already be accomplished in Python through the use of the map() built-
in function::
already be accomplished in Python through the use of the map() built-in
function::
>>> a = (1, 2, 3)
>>> b = (4, 5, 6)

View File

@ -185,8 +185,8 @@ Implementation Strategy
=======================
The implementation of weak references will include a list of
reference containers that must be cleared for each weakly-
referencable object. If the reference is from a weak dictionary,
reference containers that must be cleared for each weakly-referencable
object. If the reference is from a weak dictionary,
the dictionary entry is cleared first. Then, any associated
callback is called with the object passed as a parameter. Once
all callbacks have been called, the object is finalized and

View File

@ -12,9 +12,9 @@ Post-History:
Abstract
========
This PEP proposes a redesign and re-implementation of the multi-
dimensional array module, Numeric, to make it easier to add new
features and functionality to the module. Aspects of Numeric 2
This PEP proposes a redesign and re-implementation of the
multi-dimensional array module, Numeric, to make it easier to add
new features and functionality to the module. Aspects of Numeric 2
that will receive special attention are efficient access to arrays
exceeding a gigabyte in size and composed of inhomogeneous data
structures or records. The proposed design uses four Python
@ -128,8 +128,8 @@ Some planned features are:
automatically handle alignment and representational issues
when data is accessed or operated on. There are two
approaches to implementing records; as either a derived array
class or a special array type, depending on your point-of-
view. We defer this discussion to the Open Issues section.
class or a special array type, depending on your point-of-view.
We defer this discussion to the Open Issues section.
2. Additional array types
@ -265,8 +265,8 @@ The design of Numeric 2 has four primary classes:
_ufunc.compute(slice, data, func, swap, conv)
The 'func' argument is a CFuncObject, while the 'swap' and 'conv'
arguments are lists of CFuncObjects for those arrays needing pre-
or post-processing, otherwise None is used. The data argument is
arguments are lists of CFuncObjects for those arrays needing pre- or
post-processing, otherwise None is used. The data argument is
a list of buffer objects, and the slice argument gives the number
of iterations for each dimension along with the buffer offset and
step size for each array and each dimension.

Some files were not shown because too many files have changed in this diff Show More