├── .coveragerc ├── .editorconfig ├── .github ├── labels.yml ├── release-drafter.yml ├── renovate.json └── workflows │ ├── deploy.yml │ ├── labels.yml │ ├── lint.yml │ ├── release-drafter.yml │ ├── require-pr-label.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── README.md ├── RELEASING.md ├── pyproject.toml ├── src └── pepotron │ ├── __init__.py │ ├── __main__.py │ ├── _cache.py │ ├── cli.py │ └── scripts │ ├── __init__.py │ └── run_command.py ├── tests ├── __init__.py ├── test_cache.py └── test_pepotron.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | 3 | [report] 4 | # Regexes for lines to exclude from consideration 5 | exclude_also = 6 | # Don't complain if non-runnable code isn't run: 7 | if __name__ == .__main__.: 8 | def main 9 | def _get_github_prs 10 | 11 | if TYPE_CHECKING: 12 | if not dry_run: 13 | 14 | except ImportError: 15 | except OSError: 16 | 17 | [run] 18 | omit = 19 | */pepotron/__main__.py 20 | */pepotron/cli.py 21 | */pepotron/scripts/* 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | 10 | # Four-space indentation 11 | [*.py] 12 | indent_size = 4 13 | indent_style = space 14 | trim_trailing_whitespace = true 15 | 16 | [expected_output.py] 17 | trim_trailing_whitespace = false 18 | 19 | # Two-space indentation 20 | [*.yml] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # Keep a Changelog labels 2 | # https://keepachangelog.com/en/1.0.0/ 3 | - color: 0e8a16 4 | description: "For new features" 5 | name: "changelog: Added" 6 | - color: af99e5 7 | description: "For changes in existing functionality" 8 | name: "changelog: Changed" 9 | - color: FFA500 10 | description: "For soon-to-be removed features" 11 | name: "changelog: Deprecated" 12 | - color: 00A800 13 | description: "For any bug fixes" 14 | name: "changelog: Fixed" 15 | - color: ff0000 16 | description: "For now removed features" 17 | name: "changelog: Removed" 18 | - color: 045aa0 19 | description: "In case of vulnerabilities" 20 | name: "changelog: Security" 21 | - color: fbca04 22 | description: "Exclude PR from release draft" 23 | name: "changelog: skip" 24 | 25 | # Other labels 26 | - color: 0366d6 27 | description: "For dependencies" 28 | name: dependencies 29 | - color: f4660e 30 | description: "" 31 | name: Hacktoberfest 32 | - color: f4660e 33 | description: "To credit accepted Hacktoberfest PRs" 34 | name: hacktoberfest-accepted 35 | - color: d65e88 36 | description: "Deploy and release" 37 | name: release 38 | - color: fbca04 39 | description: "Unit tests, linting, CI, etc." 40 | name: testing 41 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "$RESOLVED_VERSION" 2 | tag-template: "$RESOLVED_VERSION" 3 | 4 | categories: 5 | - title: "Added" 6 | labels: 7 | - "changelog: Added" 8 | - "enhancement" 9 | - title: "Changed" 10 | label: "changelog: Changed" 11 | - title: "Deprecated" 12 | label: "changelog: Deprecated" 13 | - title: "Removed" 14 | label: "changelog: Removed" 15 | - title: "Fixed" 16 | labels: 17 | - "changelog: Fixed" 18 | - "bug" 19 | - title: "Security" 20 | label: "changelog: Security" 21 | 22 | exclude-labels: 23 | - "changelog: skip" 24 | 25 | autolabeler: 26 | - label: "changelog: skip" 27 | branch: 28 | - "/pre-commit-ci-update-config/" 29 | 30 | template: | 31 | $CHANGES 32 | 33 | version-resolver: 34 | major: 35 | labels: 36 | - "changelog: Removed" 37 | minor: 38 | labels: 39 | - "changelog: Added" 40 | - "changelog: Changed" 41 | - "changelog: Deprecated" 42 | - "enhancement" 43 | 44 | patch: 45 | labels: 46 | - "changelog: Fixed" 47 | - "bug" 48 | default: minor 49 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "labels": ["changelog: skip", "dependencies"], 5 | "packageRules": [ 6 | { 7 | "groupName": "github-actions", 8 | "matchManagers": ["github-actions"], 9 | "separateMajorMinor": "false" 10 | } 11 | ], 12 | "schedule": ["on the first day of the month"] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ["*"] 7 | pull_request: 8 | branches: [main] 9 | release: 10 | types: 11 | - published 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | 17 | env: 18 | FORCE_COLOR: 1 19 | 20 | jobs: 21 | # Always build & lint package. 22 | build-package: 23 | name: Build & verify package 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - uses: hynek/build-and-inspect-python-package@v2 32 | 33 | # Upload to Test PyPI on every commit on main. 34 | release-test-pypi: 35 | name: Publish in-dev package to test.pypi.org 36 | if: | 37 | github.repository_owner == 'hugovk' 38 | && github.event_name == 'push' 39 | && github.ref == 'refs/heads/main' 40 | runs-on: ubuntu-latest 41 | needs: build-package 42 | 43 | permissions: 44 | id-token: write 45 | 46 | steps: 47 | - name: Download packages built by build-and-inspect-python-package 48 | uses: actions/download-artifact@v4 49 | with: 50 | name: Packages 51 | path: dist 52 | 53 | - name: Upload package to Test PyPI 54 | uses: pypa/gh-action-pypi-publish@release/v1 55 | with: 56 | attestations: true 57 | repository-url: https://test.pypi.org/legacy/ 58 | 59 | # Upload to real PyPI on GitHub Releases. 60 | release-pypi: 61 | name: Publish released package to pypi.org 62 | if: | 63 | github.repository_owner == 'hugovk' 64 | && github.event.action == 'published' 65 | runs-on: ubuntu-latest 66 | needs: build-package 67 | 68 | permissions: 69 | id-token: write 70 | 71 | steps: 72 | - name: Download packages built by build-and-inspect-python-package 73 | uses: actions/download-artifact@v4 74 | with: 75 | name: Packages 76 | path: dist 77 | 78 | - name: Upload package to PyPI 79 | uses: pypa/gh-action-pypi-publish@release/v1 80 | with: 81 | attestations: true 82 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | 3 | permissions: 4 | pull-requests: write 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | paths: 11 | - .github/labels.yml 12 | workflow_dispatch: 13 | 14 | jobs: 15 | sync: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: micnncim/action-label-syncer@v1 20 | with: 21 | prune: false 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | env: 6 | FORCE_COLOR: 1 7 | PIP_DISABLE_PIP_VERSION_CHECK: 1 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.x" 21 | cache: pip 22 | - uses: pre-commit/action@v3.0.1 23 | 24 | mypy: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Install uv 30 | uses: hynek/setup-cached-uv@v2 31 | - name: Mypy 32 | run: uvx --with tox-uv tox -e mypy 33 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - main 8 | # pull_request event is required only for autolabeler 9 | pull_request: 10 | # Only following types are handled by the action, but one can default to all as well 11 | types: [opened, reopened, synchronize] 12 | # pull_request_target event is required for autolabeler to support PRs from forks 13 | # pull_request_target: 14 | # types: [opened, reopened, synchronize] 15 | workflow_dispatch: 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | update_release_draft: 22 | if: github.repository_owner == 'hugovk' 23 | permissions: 24 | # write permission is required to create a GitHub Release 25 | contents: write 26 | # write permission is required for autolabeler 27 | # otherwise, read permission is required at least 28 | pull-requests: write 29 | runs-on: ubuntu-latest 30 | steps: 31 | # Drafts your next release notes as pull requests are merged into "main" 32 | - uses: release-drafter/release-drafter@v6 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/require-pr-label.yml: -------------------------------------------------------------------------------- 1 | name: Require PR label 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, labeled, unlabeled, synchronize] 6 | 7 | jobs: 8 | label: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | 15 | steps: 16 | - uses: mheap/github-action-required-labels@v5 17 | with: 18 | mode: minimum 19 | count: 1 20 | labels: 21 | "changelog: Added, changelog: Changed, changelog: Deprecated, changelog: 22 | Fixed, changelog: Removed, changelog: Security, changelog: skip" 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | FORCE_COLOR: 1 10 | 11 | jobs: 12 | test: 13 | runs-on: "${{ matrix.os }}-latest" 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["pypy3.10", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9"] 18 | os: [Windows, macOS, Ubuntu] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | allow-prereleases: true 28 | 29 | - name: Install uv 30 | uses: hynek/setup-cached-uv@v2 31 | 32 | - name: Tox tests 33 | run: | 34 | uvx --with tox-uv tox -e py 35 | 36 | - name: Test CLI 37 | run: | 38 | uvx --with tox-uv tox -e cli 39 | 40 | - name: Cog 41 | if: matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest' 42 | run: | 43 | uvx --with tox-uv tox -e cog 44 | 45 | - name: Upload coverage 46 | uses: codecov/codecov-action@v5 47 | with: 48 | flags: ${{ matrix.os }} 49 | name: ${{ matrix.os }} Python ${{ matrix.python-version }} 50 | 51 | success: 52 | needs: test 53 | runs-on: ubuntu-latest 54 | name: Test successful 55 | steps: 56 | - name: Success 57 | run: echo Test successful 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | # hatch-vcs 163 | src/*/_version.py 164 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.4 4 | hooks: 5 | - id: ruff 6 | args: [--exit-non-zero-on-fix] 7 | 8 | - repo: https://github.com/psf/black-pre-commit-mirror 9 | rev: 25.1.0 10 | hooks: 11 | - id: black 12 | 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v5.0.0 15 | hooks: 16 | - id: check-added-large-files 17 | - id: check-case-conflict 18 | - id: check-merge-conflict 19 | - id: check-json 20 | - id: check-toml 21 | - id: check-yaml 22 | - id: debug-statements 23 | - id: end-of-file-fixer 24 | - id: forbid-submodules 25 | - id: trailing-whitespace 26 | 27 | - repo: https://github.com/python-jsonschema/check-jsonschema 28 | rev: 0.32.1 29 | hooks: 30 | - id: check-github-workflows 31 | - id: check-renovate 32 | 33 | - repo: https://github.com/rhysd/actionlint 34 | rev: v1.7.7 35 | hooks: 36 | - id: actionlint 37 | 38 | - repo: https://github.com/tox-dev/pyproject-fmt 39 | rev: v2.5.1 40 | hooks: 41 | - id: pyproject-fmt 42 | 43 | - repo: https://github.com/abravalheri/validate-pyproject 44 | rev: v0.24.1 45 | hooks: 46 | - id: validate-pyproject 47 | 48 | - repo: https://github.com/tox-dev/tox-ini-fmt 49 | rev: 1.5.0 50 | hooks: 51 | - id: tox-ini-fmt 52 | 53 | - repo: https://github.com/rbubley/mirrors-prettier 54 | rev: v3.5.3 55 | hooks: 56 | - id: prettier 57 | args: [--prose-wrap=always, --print-width=88] 58 | 59 | - repo: meta 60 | hooks: 61 | - id: check-hooks-apply 62 | - id: check-useless-excludes 63 | 64 | ci: 65 | autoupdate_schedule: quarterly 66 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2022 Hugo van Kemenade 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pepotron 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/pepotron.svg?logo=pypi&logoColor=FFE873)](https://pypi.org/project/pepotron/) 4 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/pepotron.svg?logo=python&logoColor=FFE873)](https://pypi.org/project/pepotron/) 5 | [![PyPI downloads](https://img.shields.io/pypi/dm/pepotron.svg)](https://pypistats.org/packages/pepotron) 6 | [![Test](https://github.com/hugovk/pepotron/actions/workflows/test.yml/badge.svg)](https://github.com/hugovk/pepotron/actions) 7 | [![Codecov](https://codecov.io/gh/hugovk/pepotron/branch/main/graph/badge.svg)](https://codecov.io/gh/hugovk/pepotron) 8 | [![Licence](https://img.shields.io/github/license/hugovk/pepotron.svg)](LICENSE.txt) 9 | [![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black) 10 | 11 | CLI to open PEPs in your browser. 12 | 13 | ## Installation 14 | 15 | ### From PyPI 16 | 17 | ```bash 18 | python3 -m pip install --upgrade pepotron 19 | ``` 20 | 21 | ### With [pipx][pipx] 22 | 23 | ```bash 24 | pipx install pepotron 25 | ``` 26 | 27 | [pipx]: https://github.com/pypa/pipx 28 | 29 | ### From source 30 | 31 | ```bash 32 | git clone https://github.com/hugovk/pepotron 33 | cd pepotron 34 | python3 -m pip install . 35 | ``` 36 | 37 | ## Usage 38 | 39 | ### Open a PEP 40 | 41 | Run `pep` or `pepotron`, they do the same thing. 42 | 43 | 47 | 48 | ```console 49 | $ pep 8 50 | https://peps.python.org/pep-0008/ 51 | ``` 52 | 53 | 54 | 55 | ```console 56 | $ # Or: 57 | $ pepotron 8 58 | https://peps.python.org/pep-0008/ 59 | $ # Or: 60 | $ uvx pepotron 8 61 | https://peps.python.org/pep-0008/ 62 | ``` 63 | 64 | ### Open release schedule PEP for a Python version 65 | 66 | 67 | 68 | ```console 69 | $ pep 3.11 70 | https://peps.python.org/pep-0664/ 71 | ``` 72 | 73 | 74 | 75 | ### Open a PEP by searching for words in the title 76 | 77 | 78 | 79 | ```console 80 | $ pep dead batteries 81 | Score Result 82 | 90 PEP 594: Removing dead batteries from the standard library 83 | 61 PEP 767: Annotating Read-Only Attributes 84 | 58 PEP 415: Implement context suppression with exception attributes 85 | 55 PEP 476: Enabling certificate verification by default for stdlib http clients 86 | 55 PEP 500: A protocol for delegating datetime methods to their tzinfo implementations 87 | 88 | https://peps.python.org/pep-0594/ 89 | ``` 90 | 91 | 92 | 93 | ### Open a PEP topic 94 | 95 | 96 | 97 | ```console 98 | $ pep governance 99 | https://peps.python.org/topic/governance/ 100 | ``` 101 | 102 | 103 | 104 | 105 | 106 | ```console 107 | $ pep packaging 108 | https://peps.python.org/topic/packaging/ 109 | ``` 110 | 111 | 112 | 113 | 114 | 115 | ```console 116 | $ pep release 117 | https://peps.python.org/topic/release/ 118 | ``` 119 | 120 | 121 | 122 | 123 | 124 | ```console 125 | $ pep typing 126 | https://peps.python.org/topic/typing/ 127 | ``` 128 | 129 | 130 | 131 | 132 | 133 | ```console 134 | $ pep topics 135 | https://peps.python.org/topic/ 136 | ``` 137 | 138 | 139 | 140 | ### Open a build preview of a python/peps PR 141 | 142 | 143 | 144 | ```console 145 | $ pep 594 --pr 2440 146 | https://pep-previews--2440.org.readthedocs.build/pep-0594/ 147 | ``` 148 | 149 | 150 | 151 | ### Open the PEPs website 152 | 153 | 154 | 155 | ```console 156 | $ pep 157 | https://peps.python.org 158 | ``` 159 | 160 | 161 | 162 | 163 | 164 | ```console 165 | $ pep --pr 2440 166 | https://pep-previews--2440.org.readthedocs.build 167 | ``` 168 | 169 | 170 | 171 | ### Find the next available PEP number 172 | 173 | Check published PEPs and [open PRs](https://github.com/python/peps/pulls) to find the 174 | next available PEP number. 175 | 176 | 177 | 178 | ```console 179 | $ pep next 180 | Next available PEP: 769 181 | ``` 182 | 183 | 184 | 185 | ### Open a BPO issue in the browser 186 | 187 | Issues from [bugs.python.org](https://bugs.python.org/) have been migrated to 188 | [GitHub issues](https://github.com/python/cpython/issues) and have new numbers. This 189 | command will open the redirect page to take you to the new issue. 190 | 191 | 192 | 193 | ```console 194 | $ bpo 46208 195 | https://bugs.python.org/issue?@action=redirect&bpo=46208 196 | ``` 197 | 198 | 199 | 200 | This redirects to https://github.com/python/cpython/issues/90366 201 | 202 | ### Help 203 | 204 | 205 | 206 | ```console 207 | $ pep --help 208 | usage: pep [-h] [-u URL] [-p PR] [--clear-cache] [-n] [-v] [-V] [search ...] 209 | 210 | pepotron: CLI to open PEPs in your browser 211 | 212 | positional arguments: 213 | search PEP number, or Python version for its schedule, or words from title, or 'next' to 214 | find next available PEP number 215 | 216 | options: 217 | -h, --help show this help message and exit 218 | -u, --url URL Base URL for PEPs (default: https://peps.python.org) 219 | -p, --pr PR Open preview for python/peps PR 220 | --clear-cache Clear cache before running 221 | -n, --dry-run Don't open in browser 222 | -v, --verbose Verbose logging 223 | -V, --version show program's version number and exit 224 | ``` 225 | 226 | 227 | 228 | 229 | 230 | ```console 231 | $ bpo --help 232 | usage: bpo [-h] [-n] [-v] [-V] bpo 233 | 234 | Open this BPO in the browser 235 | 236 | positional arguments: 237 | bpo BPO number 238 | 239 | options: 240 | -h, --help show this help message and exit 241 | -n, --dry-run Don't open in browser 242 | -v, --verbose Verbose logging 243 | -V, --version show program's version number and exit 244 | ``` 245 | 246 | 247 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release Checklist 2 | 3 | - [ ] Get `main` to the appropriate code release state. 4 | [GitHub Actions](https://github.com/hugovk/pepotron/actions) should be running 5 | cleanly for all merges to `main`. 6 | [![GitHub Actions status](https://github.com/hugovk/pepotron/workflows/Test/badge.svg)](https://github.com/hugovk/pepotron/actions) 7 | 8 | - [ ] Edit release draft, adjust text if needed: 9 | https://github.com/hugovk/pepotron/releases 10 | 11 | - [ ] Check next tag is correct, amend if needed 12 | 13 | - [ ] Publish release 14 | 15 | - [ ] Check the tagged 16 | [GitHub Actions build](https://github.com/hugovk/pepotron/actions/workflows/deploy.yml) 17 | has deployed to [PyPI](https://pypi.org/project/pepotron/#history) 18 | 19 | - [ ] Check installation: 20 | 21 | ```bash 22 | pip3 uninstall -y pepotron && pip3 install -U pepotron && pep --version 23 | ``` 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs", 5 | "hatchling", 6 | ] 7 | 8 | [project] 9 | name = "pepotron" 10 | description = "CLI to open PEPs in your browser" 11 | readme = "README.md" 12 | keywords = [ 13 | "bpo", 14 | "cli", 15 | "pep", 16 | ] 17 | license = { text = "MIT" } 18 | authors = [ { name = "Hugo van Kemenade" } ] 19 | requires-python = ">=3.9" 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "Intended Audience :: Developers", 23 | "Intended Audience :: End Users/Desktop", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python :: 3 :: Only", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Programming Language :: Python :: 3.14", 33 | "Programming Language :: Python :: Implementation :: CPython", 34 | "Programming Language :: Python :: Implementation :: PyPy", 35 | ] 36 | dynamic = [ "version" ] 37 | dependencies = [ 38 | "ghapi", 39 | "platformdirs", 40 | "python-slugify", 41 | "rapidfuzz", 42 | "urllib3>=2", 43 | ] 44 | optional-dependencies.tests = [ 45 | "freezegun", 46 | "pytest", 47 | "pytest-cov", 48 | ] 49 | urls.Changelog = "https://github.com/hugovk/pepotron/releases" 50 | urls.Homepage = "https://github.com/hugovk/pepotron" 51 | urls.Source = "https://github.com/hugovk/pepotron" 52 | scripts.bpo = "pepotron.cli:bpo" 53 | scripts.pep = "pepotron.cli:main" 54 | scripts.pepotron = "pepotron.cli:main" 55 | 56 | [tool.hatch] 57 | version.source = "vcs" 58 | 59 | [tool.hatch.build.hooks.vcs] 60 | version-file = "src/pepotron/_version.py" 61 | 62 | [tool.hatch.version.raw-options] 63 | local_scheme = "no-local-version" 64 | 65 | [tool.ruff] 66 | fix = true 67 | 68 | lint.select = [ 69 | "C4", # flake8-comprehensions 70 | "E", # pycodestyle errors 71 | "EM", # flake8-errmsg 72 | "F", # pyflakes errors 73 | "I", # isort 74 | "ICN", # flake8-import-conventions 75 | "ISC", # flake8-implicit-str-concat 76 | "LOG", # flake8-logging 77 | "PGH", # pygrep-hooks 78 | "RUF022", # unsorted-dunder-all 79 | "RUF100", # unused noqa (yesqa) 80 | "UP", # pyupgrade 81 | "W", # pycodestyle warnings 82 | "YTT", # flake8-2020 83 | ] 84 | lint.ignore = [ 85 | "E203", # Whitespace before ':' 86 | "E221", # Multiple spaces before operator 87 | "E226", # Missing whitespace around arithmetic operator 88 | "E241", # Multiple spaces after ',' 89 | "UP038", # Makes code slower and more verbose 90 | ] 91 | lint.flake8-import-conventions.aliases.datetime = "dt" 92 | lint.flake8-import-conventions.banned-from = [ "datetime" ] 93 | lint.isort.known-first-party = [ "pepotron" ] 94 | lint.isort.required-imports = [ "from __future__ import annotations" ] 95 | 96 | [tool.pyproject-fmt] 97 | max_supported_python = "3.14" 98 | 99 | [tool.pytest.ini_options] 100 | addopts = "--color=yes" 101 | filterwarnings = [ 102 | # Python <= 3.11 103 | "ignore:sys.monitoring isn't available, using default core:coverage.exceptions.CoverageWarning", 104 | ] 105 | testpaths = [ "tests" ] 106 | 107 | [tool.mypy] 108 | pretty = true 109 | strict = true 110 | show_error_codes = true 111 | -------------------------------------------------------------------------------- /src/pepotron/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | CLI to open PEPs in your browser 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import logging 8 | from pathlib import Path 9 | from typing import Any 10 | 11 | from . import _cache, _version 12 | 13 | __version__ = _version.__version__ 14 | 15 | BASE_URL = "https://peps.python.org" 16 | JSON_PATH = "/api/peps.json" 17 | USER_AGENT = f"pepotron/{__version__}" 18 | 19 | TOPICS = ("governance", "packaging", "release", "typing") 20 | VERSION_TO_PEP = { 21 | "1.6": 160, 22 | "2.0": 200, 23 | "2.1": 226, 24 | "2.2": 251, 25 | "2.3": 283, 26 | "2.4": 320, 27 | "2.5": 356, 28 | "2.6": 361, 29 | "2.7": 373, 30 | "3.0": 361, 31 | "3.1": 375, 32 | "3.2": 392, 33 | "3.3": 398, 34 | "3.4": 429, 35 | "3.5": 478, 36 | "3.6": 494, 37 | "3.7": 537, 38 | "3.8": 569, 39 | "3.9": 596, 40 | "3.10": 619, 41 | "3.11": 664, 42 | "3.12": 693, 43 | "3.13": 719, 44 | "3.14": 745, 45 | "3.15": 790, 46 | } 47 | 48 | 49 | logger = logging.getLogger(__name__) 50 | 51 | 52 | def _download_peps_json(json_url: str = BASE_URL + JSON_PATH) -> Path: 53 | cache_file = _cache.filename(json_url) 54 | logger.info("Cache file: %s", cache_file) 55 | 56 | data = _cache.load(cache_file) 57 | if data == {}: 58 | # No cache, or couldn't load cache 59 | import urllib3 60 | 61 | resp = urllib3.request("GET", json_url, headers={"User-Agent": USER_AGENT}) 62 | 63 | # Raise if we made a bad request 64 | # (4XX client error or 5XX server error response) 65 | logger.info("HTTP status code: %s", resp.status) 66 | if resp.status != 200: 67 | msg = f"Unable to download {json_url}: status {resp.status}" 68 | raise RuntimeError(msg) 69 | 70 | data = resp.json() 71 | 72 | _cache.save(cache_file, data) 73 | 74 | logger.info("") 75 | return cache_file 76 | 77 | 78 | def _get_peps() -> _cache.PepData: 79 | import json 80 | 81 | peps_file = _download_peps_json() 82 | 83 | with open(peps_file) as f: 84 | peps: _cache.PepData = json.load(f) 85 | 86 | return peps 87 | 88 | 89 | def _get_published_peps() -> set[int]: 90 | peps = _get_peps() 91 | numbers = {int(number) for number, details in peps.items()} 92 | return numbers 93 | 94 | 95 | def _next_available_pep() -> int: 96 | try: 97 | # Python 3.10+ 98 | from itertools import pairwise 99 | except ImportError: 100 | # Python 3.9 and below 101 | def pairwise(iterable): # type: ignore[no-redef,no-untyped-def] 102 | from itertools import tee 103 | 104 | a, b = tee(iterable) 105 | next(b, None) 106 | return zip(a, b) 107 | 108 | published = _get_published_peps() 109 | proposed = _get_pr_peps() 110 | combined = published | proposed 111 | numbers = sorted(combined) 112 | 113 | start = 400 114 | next_pep = -1 115 | for x, y in pairwise(numbers): 116 | if x < start: 117 | continue 118 | if x + 1 != y: 119 | next_pep = x + 1 120 | break 121 | 122 | return next_pep 123 | 124 | 125 | def _get_github_prs() -> list[Any]: 126 | from ghapi.all import GhApi # type: ignore[import-untyped] 127 | 128 | api = GhApi(owner="python", repo="peps", authenticate=False) 129 | return api.pulls.list(per_page=100) # type: ignore[no-any-return] 130 | 131 | 132 | def _get_pr_peps() -> set[int]: 133 | import re 134 | 135 | pr_title_regex = re.compile(r"^PEP (\d+): .*") 136 | 137 | numbers = set() 138 | for pr in _get_github_prs(): 139 | if match := re.search(pr_title_regex, pr.title): 140 | number = match[1] 141 | numbers.add(int(number)) 142 | 143 | return numbers 144 | 145 | 146 | def word_search(search: str | None) -> int: 147 | from rapidfuzz import process 148 | 149 | peps = _get_peps() 150 | 151 | # Dict of title->number 152 | titles = {details["title"]: number for number, details in peps.items()} 153 | 154 | result = process.extract(search, titles.keys()) 155 | print("Score Result") 156 | for title, score, _ in result: 157 | print(f"{round(score):<8}PEP {titles[title]}: {title}") 158 | print() 159 | 160 | # Find PEP number of top match 161 | number: str = next( 162 | number for number, details in peps.items() if details["title"] == result[0][0] 163 | ) 164 | 165 | return int(number) 166 | 167 | 168 | def pep_url(search: str | None, base_url: str = BASE_URL, pr: int | None = None) -> str: 169 | """Get PEP URL""" 170 | if pr: 171 | base_url = f"https://pep-previews--{pr}.org.readthedocs.build" 172 | 173 | result = base_url.rstrip("/") 174 | 175 | if not search: 176 | return result 177 | 178 | if search.lower() in ("topic", "topics"): 179 | return result + "/topic/" 180 | 181 | if search.lower() in TOPICS: 182 | return result + f"/topic/{search}/" 183 | 184 | if search.lower() == "next": 185 | return f"Next available PEP: {_next_available_pep()}" 186 | 187 | try: 188 | # pep 8 189 | number = int(search) 190 | except ValueError: 191 | try: 192 | # pep 3.11 193 | number = VERSION_TO_PEP[search] 194 | except KeyError: 195 | # pep "dead batteries" 196 | number = word_search(search) 197 | 198 | return result + f"/pep-{number:0>4}/" 199 | 200 | 201 | def open_pep( 202 | search: str, base_url: str = BASE_URL, pr: int | None = None, dry_run: bool = False 203 | ) -> str: 204 | """Open this PEP in the browser""" 205 | url = pep_url(search, base_url, pr) 206 | if not dry_run and "Next available PEP: " not in url: 207 | import webbrowser 208 | 209 | webbrowser.open_new_tab(url) 210 | print(url) 211 | return url 212 | 213 | 214 | def open_bpo(number: int, dry_run: bool = False) -> str: 215 | """Open this BPO in the browser""" 216 | url = f"https://bugs.python.org/issue?@action=redirect&bpo={number}" 217 | if not dry_run: 218 | import webbrowser 219 | 220 | webbrowser.open_new_tab(url) 221 | print(url) 222 | return url 223 | -------------------------------------------------------------------------------- /src/pepotron/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pepotron import cli 4 | 5 | if __name__ == "__main__": 6 | cli.main() 7 | -------------------------------------------------------------------------------- /src/pepotron/_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cache functions 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import datetime as dt 8 | import json 9 | import logging 10 | import sys 11 | from pathlib import Path 12 | from typing import TYPE_CHECKING 13 | 14 | from platformdirs import user_cache_dir 15 | 16 | if TYPE_CHECKING: 17 | if sys.version_info >= (3, 10): 18 | from typing import TypeAlias 19 | else: 20 | from typing_extensions import TypeAlias 21 | 22 | PepData: TypeAlias = "dict[str, dict[str, str]]" 23 | 24 | CACHE_DIR = Path(user_cache_dir("pepotron")) 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | def filename(url: str) -> Path: 30 | """yyyy-mm-dd-url-slug.json""" 31 | from slugify import slugify 32 | 33 | today = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d") 34 | slug = slugify(url) 35 | return CACHE_DIR / f"{today}-{slug}.json" 36 | 37 | 38 | def load(cache_file: Path) -> PepData: 39 | """Load data from cache_file""" 40 | if not cache_file.exists(): 41 | return {} 42 | 43 | logger.info("Cache file exists") 44 | with cache_file.open("r") as f: 45 | try: 46 | data: PepData = json.load(f) 47 | except json.decoder.JSONDecodeError: 48 | return {} 49 | 50 | return data 51 | 52 | 53 | def save(cache_file: Path, data: PepData) -> None: 54 | """Save data to cache_file""" 55 | try: 56 | if not CACHE_DIR.exists(): 57 | CACHE_DIR.mkdir(parents=True) 58 | 59 | with cache_file.open("w") as f: 60 | json.dump(data, f) 61 | 62 | except OSError: 63 | pass 64 | 65 | 66 | def clear(clear_all: bool = False) -> None: 67 | """Delete all or old cache files""" 68 | cache_files = CACHE_DIR.glob("**/*.json") 69 | today = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d") 70 | for cache_file in cache_files: 71 | if clear_all or not cache_file.name.startswith(today): 72 | cache_file.unlink() 73 | -------------------------------------------------------------------------------- /src/pepotron/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | pepotron: CLI to open PEPs in your browser 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import argparse 8 | import atexit 9 | import logging 10 | 11 | from . import BASE_URL, __version__, _cache, open_bpo, open_pep 12 | 13 | atexit.register(_cache.clear) 14 | 15 | 16 | def add_common_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 17 | parser.add_argument( 18 | "-n", "--dry-run", action="store_true", help="Don't open in browser" 19 | ) 20 | parser.add_argument( 21 | "-v", 22 | "--verbose", 23 | action="store_const", 24 | dest="loglevel", 25 | const=logging.INFO, 26 | default=logging.WARNING, 27 | help="Verbose logging", 28 | ) 29 | parser.add_argument( 30 | "-V", "--version", action="version", version=f"%(prog)s {__version__}" 31 | ) 32 | return parser 33 | 34 | 35 | def main() -> None: 36 | parser = argparse.ArgumentParser( 37 | description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 38 | ) 39 | parser.color = True # type: ignore[attr-defined] 40 | parser.add_argument( 41 | "search", 42 | nargs="*", 43 | help="PEP number, or Python version for its schedule, or words from title, " 44 | "or 'next' to find next available PEP number", 45 | ) 46 | parser.add_argument( 47 | "-u", "--url", default=BASE_URL, help=f"Base URL for PEPs (default: {BASE_URL})" 48 | ) 49 | parser.add_argument("-p", "--pr", type=int, help="Open preview for python/peps PR") 50 | parser.add_argument( 51 | "--clear-cache", action="store_true", help="Clear cache before running" 52 | ) 53 | parser = add_common_arguments(parser) 54 | args = parser.parse_args() 55 | 56 | logging.basicConfig(level=args.loglevel, format="%(message)s") 57 | if args.search: 58 | args.search = " ".join(args.search) 59 | if args.clear_cache: 60 | _cache.clear(clear_all=True) 61 | 62 | open_pep(search=args.search, base_url=args.url, pr=args.pr, dry_run=args.dry_run) 63 | 64 | 65 | def bpo() -> None: 66 | parser = argparse.ArgumentParser( 67 | description="Open this BPO in the browser", 68 | formatter_class=argparse.RawDescriptionHelpFormatter, 69 | ) 70 | parser.add_argument("bpo", type=int, help="BPO number") 71 | parser = add_common_arguments(parser) 72 | args = parser.parse_args() 73 | 74 | logging.basicConfig(level=args.loglevel, format="%(message)s") 75 | open_bpo(number=args.bpo, dry_run=args.dry_run) 76 | 77 | 78 | if __name__ == "__main__": 79 | main() 80 | -------------------------------------------------------------------------------- /src/pepotron/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugovk/pepotron/322455f1ba7310874fb40fd7a6d798b02a5f135e/src/pepotron/scripts/__init__.py -------------------------------------------------------------------------------- /src/pepotron/scripts/run_command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shlex 4 | import subprocess 5 | 6 | 7 | def run(command: str, with_console: bool = True, line_limit: int | None = None) -> None: 8 | command_parts = shlex.split(command) 9 | command_parts.append("--dry-run") 10 | process = subprocess.run(command_parts, capture_output=True, text=True) 11 | print() 12 | if with_console: 13 | print("```console") 14 | print(f"$ {command}") 15 | 16 | output = process.stdout.strip() 17 | if line_limit: 18 | output = "".join(output.splitlines(keepends=True)[:line_limit]) + "..." 19 | print(output) 20 | 21 | if with_console: 22 | print("```") 23 | print() 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hugovk/pepotron/322455f1ba7310874fb40fd7a6d798b02a5f135e/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for _cache 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import tempfile 8 | from pathlib import Path 9 | 10 | from freezegun import freeze_time 11 | 12 | from pepotron import _cache 13 | 14 | 15 | class TestCache: 16 | def setup_method(self) -> None: 17 | # Choose a new cache dir that doesn't exist 18 | self.original_cache_dir = _cache.CACHE_DIR 19 | self.temp_dir = tempfile.TemporaryDirectory() 20 | _cache.CACHE_DIR = Path(self.temp_dir.name) / "pepotron" 21 | 22 | def teardown_method(self) -> None: 23 | # Reset original 24 | _cache.CACHE_DIR = self.original_cache_dir 25 | 26 | @freeze_time("2018-12-26") 27 | def test__cache_filename(self) -> None: 28 | # Arrange 29 | url = "https://peps.python.org/api/peps.json" 30 | 31 | # Act 32 | out = _cache.filename(url) 33 | 34 | # Assert 35 | assert str(out).endswith("2018-12-26-https-peps-python-org-api-peps-json.json") 36 | 37 | def test__load_cache_not_exist(self) -> None: 38 | # Arrange 39 | filename = Path("file-does-not-exist") 40 | 41 | # Act 42 | data = _cache.load(filename) 43 | 44 | # Assert 45 | assert data == {} 46 | 47 | def test__load_cache_bad_data(self) -> None: 48 | # Arrange 49 | with tempfile.NamedTemporaryFile(delete=False) as f: 50 | f.write(b"Invalid JSON!") 51 | 52 | # Act 53 | data = _cache.load(Path(f.name)) 54 | 55 | # Assert 56 | assert data == {} 57 | 58 | def test_cache_round_trip(self) -> None: 59 | # Arrange 60 | filename = _cache.CACHE_DIR / "test_cache_round_trip.json" 61 | data = {"1": {"authors": "bob"}} 62 | 63 | # Act 64 | _cache.save(filename, data) 65 | new_data = _cache.load(filename) 66 | 67 | # Tidy up 68 | filename.unlink() 69 | 70 | # Assert 71 | assert new_data == data 72 | 73 | @freeze_time("2021-10-25") 74 | def test__clear_cache_all(self) -> None: 75 | # Arrange 76 | # Create old cache file 77 | cache_file_old = _cache.CACHE_DIR / "2021-10-24-old-cache-file.json" 78 | cache_file_new = _cache.CACHE_DIR / "2021-10-25-new-cache-file.json" 79 | _cache.save(cache_file_old, data={}) 80 | _cache.save(cache_file_new, data={}) 81 | assert cache_file_new.exists() 82 | assert cache_file_old.exists() 83 | 84 | # Act 85 | _cache.clear(clear_all=True) 86 | 87 | # Assert 88 | assert not cache_file_old.exists() 89 | assert not cache_file_new.exists() 90 | 91 | @freeze_time("2021-10-25") 92 | def test__clear_cache_old(self) -> None: 93 | # Arrange 94 | # Create old cache file 95 | cache_file_old = _cache.CACHE_DIR / "2021-10-24-old-cache-file.json" 96 | cache_file_new = _cache.CACHE_DIR / "2021-10-25-new-cache-file.json" 97 | _cache.save(cache_file_old, data={}) 98 | _cache.save(cache_file_new, data={}) 99 | assert cache_file_new.exists() 100 | assert cache_file_old.exists() 101 | 102 | # Act 103 | _cache.clear() 104 | 105 | # Assert 106 | assert not cache_file_old.exists() 107 | assert cache_file_new.exists() 108 | -------------------------------------------------------------------------------- /tests/test_pepotron.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from collections import namedtuple 8 | 9 | import pytest 10 | 11 | import pepotron 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "search, expected_url", 16 | [ 17 | ("8", "https://peps.python.org/pep-0008/"), 18 | ("12", "https://peps.python.org/pep-0012/"), 19 | ("2.7", "https://peps.python.org/pep-0373/"), 20 | (None, "https://peps.python.org"), 21 | ("dead batteries", "https://peps.python.org/pep-0594/"), 22 | ("release", "https://peps.python.org/topic/release/"), 23 | ("typing", "https://peps.python.org/topic/typing/"), 24 | ("topics", "https://peps.python.org/topic/"), 25 | ("topic", "https://peps.python.org/topic/"), 26 | ], 27 | ) 28 | def test_url(search: str, expected_url: str) -> None: 29 | # Act 30 | pep_url = pepotron.pep_url(search) 31 | # Assert 32 | assert pep_url == expected_url 33 | 34 | 35 | def test_next() -> None: 36 | # Arrange 37 | Pull = namedtuple("Pull", ["title"]) 38 | prs = [ 39 | Pull(title="PEP 716: Seven One Six"), 40 | Pull(title="PEP 717: Seven One Seven"), 41 | ] 42 | # mock _get_github_prs: 43 | pepotron._get_github_prs = lambda: prs 44 | 45 | # Act 46 | next_pep = pepotron.pep_url("next") 47 | 48 | # Assert 49 | assert next_pep.startswith("Next available PEP: ") 50 | assert next_pep.split()[-1].isdigit() 51 | 52 | 53 | @pytest.mark.parametrize( 54 | "search, base_url, expected_url", 55 | [ 56 | ( 57 | "8", 58 | "https://hugovk.github.io/peps", 59 | "https://hugovk.github.io/peps/pep-0008/", 60 | ), 61 | ( 62 | "3.11", 63 | "https://hugovk.github.io/peps", 64 | "https://hugovk.github.io/peps/pep-0664/", 65 | ), 66 | ( 67 | None, 68 | "https://hugovk.github.io/peps", 69 | "https://hugovk.github.io/peps", 70 | ), 71 | ( 72 | "dead batteries", 73 | "https://hugovk.github.io/peps", 74 | "https://hugovk.github.io/peps/pep-0594/", 75 | ), 76 | ], 77 | ) 78 | def test_url_base_url(search: str, base_url: str, expected_url: str) -> None: 79 | # Act 80 | pep_url = pepotron.pep_url(search, base_url) 81 | # Assert 82 | assert pep_url == expected_url 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "search, expected_url", 87 | [ 88 | ("594", "https://pep-previews--2440.org.readthedocs.build/pep-0594/"), 89 | (None, "https://pep-previews--2440.org.readthedocs.build"), 90 | ], 91 | ) 92 | def test_url_pr(search: str | None, expected_url: str) -> None: 93 | # Arrange 94 | pr = 2440 95 | # Act 96 | pep_url = pepotron.pep_url(search, pr=pr) 97 | # Assert 98 | assert pep_url == expected_url 99 | 100 | 101 | def test__download_peps_json_ok() -> None: 102 | # Arrange 103 | pepotron._cache.clear(clear_all=True) 104 | # Act 105 | filename = pepotron._download_peps_json() 106 | # Assert 107 | assert filename.suffix == ".json" 108 | 109 | 110 | def test__download_peps_json_error() -> None: 111 | with pytest.raises(RuntimeError): 112 | pepotron._download_peps_json("https://httpbin.org/status/404") 113 | 114 | 115 | def test_pep() -> None: 116 | url = pepotron.open_pep("8", dry_run=True) 117 | assert url == "https://peps.python.org/pep-0008/" 118 | 119 | 120 | def test_open_bpo() -> None: 121 | url = pepotron.open_bpo(38374, dry_run=True) 122 | assert url == "https://bugs.python.org/issue?@action=redirect&bpo=38374" 123 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | cli 6 | cog 7 | lint 8 | mypy 9 | py{py3, 314, 313, 312, 311, 310, 39} 10 | 11 | [testenv] 12 | extras = 13 | tests 14 | set_env = 15 | COVERAGE_CORE = sysmon 16 | commands = 17 | {envpython} -m pytest \ 18 | --cov pepotron \ 19 | --cov tests \ 20 | --cov-report html \ 21 | --cov-report term \ 22 | --cov-report xml \ 23 | {posargs} 24 | 25 | [testenv:cli] 26 | commands = 27 | pep --version 28 | pep --help 29 | pep --dry-run 8 30 | pep --dry-run 3.13 31 | pep --dry-run dead batteries 32 | 33 | [testenv:cog] 34 | deps = 35 | cogapp 36 | commands = 37 | cog -Pr README.md 38 | 39 | [testenv:lint] 40 | skip_install = true 41 | deps = 42 | pre-commit 43 | pass_env = 44 | PRE_COMMIT_COLOR 45 | commands = 46 | pre-commit run --all-files --show-diff-on-failure 47 | 48 | [testenv:mypy] 49 | deps = 50 | mypy==1.12 51 | platformdirs 52 | pytest 53 | python-slugify 54 | rapidfuzz 55 | types-freezegun 56 | urllib3 57 | commands = 58 | mypy . {posargs} 59 | --------------------------------------------------------------------------------