diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5b02ed791..cecf3d623 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f5afc0740..37ccaee03 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index df7e3625f..8775917c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/check-peps.py b/check-peps.py new file mode 100755 index 000000000..623bdc040 --- /dev/null +++ b/check-peps.py @@ -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] + +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 , …'""" + + 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 '""" + + yield from _email(line_num, line, "Sponsor") + + +def _validate_delegate(line_num: int, line: str) -> MessageIterator: + """'Delegate' must have format 'Name '""" + + 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 `__, …'""" + + 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 ': {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)) diff --git a/pep_sphinx_extensions/generate_rss.py b/pep_sphinx_extensions/generate_rss.py index a7120c9d6..5e5e0b8bc 100644 --- a/pep_sphinx_extensions/generate_rss.py +++ b/pep_sphinx_extensions/generate_rss.py @@ -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) diff --git a/pep_sphinx_extensions/tests/conftest.py b/pep_sphinx_extensions/tests/conftest.py index e1417e08e..2c207ebcd 100644 --- a/pep_sphinx_extensions/tests/conftest.py +++ b/pep_sphinx_extensions/tests/conftest.py @@ -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) diff --git a/pep_sphinx_extensions/tests/pep_lint/__init__.py b/pep_sphinx_extensions/tests/pep_lint/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pep_sphinx_extensions/tests/pep_lint/test_date.py b/pep_sphinx_extensions/tests/pep_lint/test_date.py new file mode 100644 index 000000000..3ce466610 --- /dev/null +++ b/pep_sphinx_extensions/tests/pep_lint/test_date.py @@ -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, "")] + 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, "")] + expected = f" 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, "")] + expected = f" 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, "")] + expected = f" must not be in the future: {date_str!r}" + assert warnings == [expected], warnings diff --git a/pep_sphinx_extensions/tests/pep_lint/test_direct_links.py b/pep_sphinx_extensions/tests/pep_lint/test_direct_links.py new file mode 100644 index 000000000..66edc0552 --- /dev/null +++ b/pep_sphinx_extensions/tests/pep_lint/test_direct_links.py @@ -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 diff --git a/pep_sphinx_extensions/tests/pep_lint/test_email.py b/pep_sphinx_extensions/tests/pep_lint/test_email.py new file mode 100644 index 000000000..2ff4ba61f --- /dev/null +++ b/pep_sphinx_extensions/tests/pep_lint/test_email.py @@ -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 ", + "Cardinal Ximénez ", + ], + 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 ", + "Cardinal Ximénez ", + ], +) +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 ", + "Cardinal Ximénez ", + ], +) +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 ' + ("Cardinal Ximénez<>", {"name "}), + ("Cardinal Ximénez<", {"name "}), + ("Cardinal Ximénez <", {"name "}), + ("Cardinal Ximénez <", {"name "}), + ("Cardinal Ximénez <>", {"name "}), + # ... 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 ", {"valid email"}), + ("Cardinal Ximénez < at > ", {"valid email"}), + # ... entries must contain a valid email address (local) + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez < Cardinal Ximenez @spanish.inquisition> ", {"valid email"}), + ("Cardinal Ximénez <(Cardinal.Ximenez)@spanish.inquisition>", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ( + "Cardinal Ximénez ", + {"multiple <", "multiple >", "valid email"}, + ), + ( + "Cardinal Ximénez ", + {"multiple @", "valid email"}, + ), + (r"Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez <[Cardinal.Ximenez]@spanish.inquisition>", {"valid email"}), + ('Cardinal Ximénez <"Cardinal"Ximenez"@spanish.inquisition>', {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + # ... entries must contain a valid email address (domain) + ( + "Cardinal Ximénez ", + {"valid email"}, + ), + ("Cardinal Ximénez ", {"valid email"}), + ("Cardinal Ximénez ", {"valid email"}), + ( + "Cardinal Ximénez ", + {"valid email"}, + ), + # valid name-emails + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez <{Cardinal.Ximenez}@spanish.inquisition>", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", set()), + ("Cardinal Ximénez ", 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, "")] + + found_warnings = set() + email = email.strip() + + if "multiple <" in expected_warnings: + found_warnings.add("multiple <") + expected = f" 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" 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" 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" entries must begin with a valid 'Name': {email!r}" + matching = [w for w in warnings if w == expected] + assert matching == [expected], warnings + + if "name " in expected_warnings: + found_warnings.add("name ") + expected = f" entries must be formatted as 'Name ': {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" 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 diff --git a/pep_sphinx_extensions/tests/pep_lint/test_headers.py b/pep_sphinx_extensions/tests/pep_lint/test_headers.py new file mode 100644 index 000000000..8246271e3 --- /dev/null +++ b/pep_sphinx_extensions/tests/pep_lint/test_headers.py @@ -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 diff --git a/pep_sphinx_extensions/tests/pep_lint/test_pep_lint.py b/pep_sphinx_extensions/tests/pep_lint/test_pep_lint.py new file mode 100644 index 000000000..2c52fa397 --- /dev/null +++ b/pep_sphinx_extensions/tests/pep_lint/test_pep_lint.py @@ -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"), + ] diff --git a/pep_sphinx_extensions/tests/pep_lint/test_pep_number.py b/pep_sphinx_extensions/tests/pep_lint/test_pep_number.py new file mode 100644 index 000000000..3443f7144 --- /dev/null +++ b/pep_sphinx_extensions/tests/pep_lint/test_pep_number.py @@ -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, "") + ] + + found_warnings = set() + pep_number = pep_number.strip() + + if "not blank" in expected_warnings: + found_warnings.add("not blank") + expected = f" 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" 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" 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" 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 diff --git a/pep_sphinx_extensions/tests/pep_lint/test_post_url.py b/pep_sphinx_extensions/tests/pep_lint/test_post_url.py new file mode 100644 index 000000000..dd04b4c39 --- /dev/null +++ b/pep_sphinx_extensions/tests/pep_lint/test_post_url.py @@ -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 `__,\n " + "`11-Mar-2005 `__,\n " + "`21-May-2010 `__,\n " + "`31-Jul-2015 `__," + ), + "01-Jan-2001, `02-Feb-2002 `__,\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, "") + ] + 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, "") + ] + assert warnings == [" 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, "", 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, "", allow_message=True + ) + ] + assert warnings == [" 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, "", 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, "", discussions_to=True + ) + ] + assert warnings == [" 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, "", "", allow_message=True, discussions_to=True + ) + ) diff --git a/pep_sphinx_extensions/tests/peps/pep-9002.rst b/pep_sphinx_extensions/tests/peps/pep-9002.rst new file mode 100644 index 000000000..208569e03 --- /dev/null +++ b/pep_sphinx_extensions/tests/peps/pep-9002.rst @@ -0,0 +1,23 @@ +PEP:9002 +Title: Nobody expects the example PEP! +Author: Cardinal Ximénez , + 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 `__ + `3-Mar-2001 `__ +Resolution: + + +https://peps.python.org/pep-9002.html diff --git a/pytest.ini b/pytest.ini index b872465bf..10404cc0b 100644 --- a/pytest.ini +++ b/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