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:
parent
fe3993d64a
commit
814ceede97
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"),
|
||||
]
|
|
@ -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"}),
|
||||
("0", {"non-numeric"}),
|
||||
("101", {"non-numeric"}),
|
||||
("9999", {"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
|
|
@ -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
|
||||
)
|
||||
)
|
|
@ -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
|
10
pytest.ini
10
pytest.ini
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue