Lint: Add ``check-peps.py`` (#3275)

``check-peps`` codifies the rules in PEP 1 and PEP 12 into a single
place containing all of the PEP-specific checks. These are primarily
header and metadata validation, and ensuring that direct links to
RFCs and PEPs aren't used.

Reviewed-by: Hugo van Kemenade <hugovk@users.noreply.github.com>
Reviewed-by: C.A.M. Gerlach <cam.gerlach@gerlach.cam>
This commit is contained in:
Adam Turner 2023-09-05 04:44:46 +01:00 committed by GitHub
parent fe3993d64a
commit 814ceede97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1916 additions and 5 deletions

1
.github/CODEOWNERS vendored
View File

@ -20,6 +20,7 @@ contents.rst @AA-Turner
.codespell/ @CAM-Gerlach @hugovk
.codespellrc @CAM-Gerlach @hugovk
.pre-commit-config.yaml @CAM-Gerlach @hugovk
check-peps.py @AA-Turner @CAM-Gerlach @hugovk
# Git infrastructure
.gitattributes @CAM-Gerlach

View File

@ -35,3 +35,17 @@ jobs:
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

@ -101,6 +101,14 @@ repos:
# Local checks for PEP headers and more
- repo: local
hooks:
# # 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-no-tabs
name: "Check tabs not used in PEPs"
language: pygrep

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 itertools
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
PEP_ROOT = Path(__file__).resolve().parent
# 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 = itertools.chain(PEP_ROOT.glob("pep-????.txt"), 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(PEP_ROOT)
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

@ -17,9 +17,6 @@ RSS_DESCRIPTION = (
"and some meta-information like release procedure and schedules."
)
# get the directory with the PEP sources
PEP_ROOT = Path(__file__).parent
def _format_rfc_2822(datetime: dt.datetime) -> str:
datetime = datetime.replace(tzinfo=dt.timezone.utc)

View File

@ -1,3 +1,12 @@
import importlib.util
import sys
from pathlib import Path
PEP_ROOT = Path(__file__, "..", "..", "..").resolve()
_ROOT_PATH = Path(__file__, "..", "..", "..").resolve()
PEP_ROOT = _ROOT_PATH
# 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-2100",
"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 Ximénez <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"}),
("Release, Release", {"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,309 @@
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/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_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/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_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

@ -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,8 +1,16 @@
[pytest]
addopts = -r a --strict-config --strict-markers --import-mode=importlib --cov pep_sphinx_extensions --cov-report html --cov-report xml
# https://docs.pytest.org/en/7.3.x/reference/reference.html#command-line-flags
addopts =
-r a
--strict-config
--strict-markers
--import-mode=importlib
--cov check_peps --cov pep_sphinx_extensions
--cov-report html --cov-report xml
empty_parameter_set_mark = fail_at_collect
filterwarnings =
error
minversion = 6.0
testpaths = pep_sphinx_extensions
xfail_strict = True
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True