diff --git a/.coveragerc b/.coveragerc index bdf5a7e..ff82131 100644 --- a/.coveragerc +++ b/.coveragerc @@ -9,8 +9,13 @@ exclude_lines = # Don't complain if non-runnable code isn't run: if __name__ == .__main__.: def main + def _get_github_prs if TYPE_CHECKING: + if not dry_run: + + except ImportError: + except OSError: [run] omit = diff --git a/.flake8 b/.flake8 index f4546ad..2bcd70e 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,2 @@ [flake8] -max_line_length = 88 +max-line-length = 88 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86acf1a..45644c0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,22 +21,23 @@ repos: hooks: - id: flake8 additional_dependencies: - [flake8-2020, flake8-errmsg, flake8-implicit-str-concat] + [flake8-2020, flake8-errmsg, flake8-implicit-str-concat, flake8-logging] - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - id: python-check-blanket-noqa - - id: python-no-log-warn - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: check-case-conflict + - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-json - id: check-toml - id: check-yaml + - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace diff --git a/README.md b/README.md index 9241076..6264cea 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,20 @@ https://pep-previews--2440.org.readthedocs.build +### Find the next available PEP number + +Check published PEPs and [open PRs](https://github.com/python/peps/pulls) to find the +next available PEP number. + + + +```console +$ pep next +Next available PEP: 730 +``` + + + ### Open a BPO issue in the browser Issues from [bugs.python.org](https://bugs.python.org/) have been migrated to diff --git a/pyproject.toml b/pyproject.toml index 1ad66ee..60a043a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dynamic = [ "version", ] dependencies = [ + "ghapi", "platformdirs", "python-slugify", "rapidfuzz", diff --git a/src/pepotron/__init__.py b/src/pepotron/__init__.py index 36244a1..8c64951 100644 --- a/src/pepotron/__init__.py +++ b/src/pepotron/__init__.py @@ -6,8 +6,9 @@ import importlib.metadata import logging from pathlib import Path +from typing import Any -from pepotron import _cache +from . import _cache __version__ = importlib.metadata.version(__name__) @@ -45,7 +46,7 @@ def _download_peps_json(json_url: str = BASE_URL + JSON_PATH) -> Path: cache_file = _cache.filename(json_url) - logging.info(f"Cache file: {cache_file}") + logging.info("Cache file: %s", cache_file) data = _cache.load(cache_file) if data == {}: @@ -56,7 +57,7 @@ def _download_peps_json(json_url: str = BASE_URL + JSON_PATH) -> Path: # Raise if we made a bad request # (4XX client error or 5XX server error response) - logging.info(f"HTTP status code: {resp.status}") + logging.info("HTTP status code: %s", resp.status) if resp.status != 200: msg = f"Unable to download {json_url}: status {resp.status}" raise RuntimeError(msg) @@ -69,15 +70,78 @@ def _download_peps_json(json_url: str = BASE_URL + JSON_PATH) -> Path: return cache_file -def word_search(search: str | None) -> int: +def _get_peps() -> _cache.PepData: import json peps_file = _download_peps_json() + with open(peps_file) as f: + peps: _cache.PepData = json.load(f) + + return peps + + +def _get_published_peps() -> set[int]: + peps = _get_peps() + numbers = {int(number) for number, details in peps.items()} + return numbers + + +def _next_available_pep() -> int: + try: + # Python 3.10+ + from itertools import pairwise + except ImportError: + # Python 3.9 and below + def pairwise(iterable): # type: ignore + from itertools import tee + + a, b = tee(iterable) + next(b, None) + return zip(a, b) + + published = _get_published_peps() + proposed = _get_pr_peps() + combined = published | proposed + numbers = sorted(combined) + + start = 400 + next_pep = -1 + for x, y in pairwise(numbers): + if x < start: + continue + if x + 1 != y: + next_pep = x + 1 + break + + return next_pep + + +def _get_github_prs() -> list[Any]: + from ghapi.all import GhApi # type: ignore + + api = GhApi(owner="python", repo="peps", authenticate=False) + return api.pulls.list(per_page=100) # type: ignore[no-any-return] + + +def _get_pr_peps() -> set[int]: + import re + + pr_title_regex = re.compile(r"^PEP (\d+): .*") + + numbers = set() + for pr in _get_github_prs(): + if match := re.search(pr_title_regex, pr.title): + number = match[1] + numbers.add(int(number)) + + return numbers + + +def word_search(search: str | None) -> int: from rapidfuzz import process - with open(peps_file) as f: - peps = json.load(f) + peps = _get_peps() # Dict of title->number titles = {details["title"]: number for number, details in peps.items()} @@ -89,11 +153,11 @@ def word_search(search: str | None) -> int: print() # Find PEP number of top match - number: int = next( + number: str = next( number for number, details in peps.items() if details["title"] == result[0][0] ) - return number + return int(number) def pep_url(search: str | None, base_url: str = BASE_URL, pr: int | None = None) -> str: @@ -112,6 +176,9 @@ def pep_url(search: str | None, base_url: str = BASE_URL, pr: int | None = None) if search.lower() in TOPICS: return result + f"/topic/{search}/" + if search.lower() == "next": + return f"Next available PEP: {_next_available_pep()}" + try: # pep 8 number = int(search) diff --git a/src/pepotron/cli.py b/src/pepotron/cli.py index 1800cc7..d5f828a 100644 --- a/src/pepotron/cli.py +++ b/src/pepotron/cli.py @@ -7,7 +7,7 @@ import atexit import logging -from pepotron import BASE_URL, __version__, _cache, open_bpo, open_pep +from . import BASE_URL, __version__, _cache, open_bpo, open_pep atexit.register(_cache.clear) diff --git a/tests/test_pepotron.py b/tests/test_pepotron.py index f3b3160..768bde8 100644 --- a/tests/test_pepotron.py +++ b/tests/test_pepotron.py @@ -3,6 +3,8 @@ """ from __future__ import annotations +from collections import namedtuple + import pytest import pepotron @@ -29,6 +31,24 @@ def test_url(search: str, expected_url: str) -> None: assert pep_url == expected_url +def test_next() -> None: + # Arrange + Pull = namedtuple("Pull", ["title"]) + prs = [ + Pull(title="PEP 716: Seven One Six"), + Pull(title="PEP 717: Seven One Seven"), + ] + # mock _get_github_prs: + pepotron._get_github_prs = lambda: prs + + # Act + next_pep = pepotron.pep_url("next") + + # Assert + assert next_pep.startswith("Next available PEP: ") + assert next_pep.split()[-1].isdigit() + + @pytest.mark.parametrize( "search, base_url, expected_url", [ diff --git a/tox.ini b/tox.ini index 9cce921..79f9e6a 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,13 @@ env_list = extras = tests commands = - {envpython} -m pytest --cov pepotron --cov tests --cov-report html --cov-report term --cov-report xml {posargs} + {envpython} -m pytest \ + --cov pepotron \ + --cov tests \ + --cov-report html \ + --cov-report term \ + --cov-report xml \ + {posargs} pep --version pep --help