├── .cruft.json ├── .gitattributes ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── feature_request.md │ └── general_question.md ├── PULL_REQUEST_TEMPLATE.md ├── actions │ └── setup-python-env │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── automerge.yml │ ├── codeql.yml │ ├── create-draft-release.yml │ ├── main.yml │ ├── pre-commit-autoupdate.yml │ ├── release.yml │ ├── validate-codecov-config.yml │ └── vhs.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── codecov.yml ├── codespell.txt ├── demo.gif ├── demo.tape ├── pyproject.toml ├── scripts ├── check_changelog_date.py └── tag_release.sh ├── tests ├── __init__.py ├── conftest.py ├── test_cli.py └── test_whatsonpypi.py ├── tox.ini ├── uv.lock └── whatsonpypi ├── __init__.py ├── __main__.py ├── _version.py ├── cli.py ├── client.py ├── constants.py ├── exceptions.py ├── utils.py └── whatsonpypi.py /.cruft.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "https://github.com/viseshrp/yapc", 3 | "commit": "b123cc504566db88232428f02c8c72f913433f9b", 4 | "checkout": null, 5 | "context": { 6 | "cookiecutter": { 7 | "author": "Visesh Prasad", 8 | "email": "viseshrprasad@gmail.com", 9 | "github_username": "viseshrp", 10 | "pypi_username": "viseshrp", 11 | "project_name": "whatsonpypi", 12 | "project_slug": "whatsonpypi", 13 | "project_description": "Get package info from PyPI.", 14 | "cli_tool": "y", 15 | "codecov": "y", 16 | "git_init": "n", 17 | "github_actions": "y", 18 | "_template": "https://github.com/viseshrp/yapc", 19 | "_commit": "b123cc504566db88232428f02c8c72f913433f9b" 20 | } 21 | }, 22 | "directory": null 23 | } 24 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Always use LF line endings in the repository, normalize line endings 2 | * text=auto eol=lf 3 | 4 | # Explicitly set for Python files 5 | *.py text eol=lf 6 | 7 | # Explicitly set for Markdown and YAML 8 | *.md text eol=lf 9 | *.yml text eol=lf 10 | *.yaml text eol=lf 11 | 12 | # Set for TOML 13 | *.toml text eol=lf 14 | 15 | # Set for GitHub Actions 16 | .github/** text eol=lf 17 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This is a comment. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # More details are here: https://help.github.com/articles/about-codeowners/ 5 | 6 | # These owners will be the default owners for everything in 7 | # the repo. Unless a later match takes precedence, 8 | # @viseshrp will be requested for review when someone opens 9 | # a pull request. 10 | * @viseshrp 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve 4 | title: "Bug Summary" 5 | labels: "bug" 6 | assignees: "viseshrp" 7 | --- 8 | 9 | ## Describe the bug 10 | 11 | 12 | 13 | ## To Reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. ... 18 | 2. ... 19 | 3. ... 20 | 21 | ## Expected behavior 22 | 23 | 24 | 25 | ## System [please complete the following information] 26 | 27 | - OS: e.g. [Ubuntu 18.04] 28 | - Language Version: [e.g. Python 3.8] 29 | - Virtual environment: [e.g. uv 1.0.0] 30 | 31 | ## Additional context 32 | 33 | 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: [] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature 4 | title: "Feature Request Summary" 5 | labels: "enhancement" 6 | assignees: "viseshrp" 7 | --- 8 | 9 | ## Is your feature request related to a problem? Please describe 10 | 11 | 13 | 14 | ## Describe the solution you would like 15 | 16 | 17 | 18 | ## Additional context 19 | 20 | 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general_question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General question 3 | about: Ask a question about anything related to this project 4 | title: "Question" 5 | labels: "question" 6 | assignees: "viseshrp" 7 | --- 8 | 9 | ## Question 10 | 11 | 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | Fixes # 4 | 5 | ## Proposed Changes 6 | 7 | - 8 | - 9 | - 10 | -------------------------------------------------------------------------------- /.github/actions/setup-python-env/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup Python Environment" 2 | description: "Set up Python environment for the given Python version" 3 | 4 | inputs: 5 | python-version: 6 | description: "Python version to use" 7 | required: false 8 | default: "3.13" 9 | uv-version: 10 | description: "uv version to use" 11 | required: false 12 | default: "latest" 13 | 14 | runs: 15 | using: "composite" 16 | steps: 17 | - name: Setup Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ inputs.python-version }} 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v6 24 | with: 25 | version: ${{ inputs.uv-version }} 26 | enable-cache: true 27 | cache-suffix: ${{ inputs.python-version }} 28 | 29 | - name: Cache .venv directory 30 | uses: actions/cache@v4 31 | with: 32 | path: .venv 33 | key: uv-venv-${{ inputs.python-version }}-${{ hashFiles('pyproject.toml', 'uv.lock') }} 34 | restore-keys: | 35 | uv-venv-${{ inputs.python-version }}- 36 | 37 | - name: Disable pip version check (optional) 38 | run: echo "PIP_DISABLE_PIP_VERSION_CHECK=1" >> $GITHUB_ENV 39 | shell: bash 40 | 41 | - name: Install Python dependencies 42 | run: make install 43 | shell: bash 44 | 45 | - name: Add .venv to PATH (cross-platform) 46 | run: | 47 | if [[ "$RUNNER_OS" == "Windows" ]]; then 48 | echo "$(pwd)/.venv/Scripts" >> $GITHUB_PATH 49 | else 50 | echo "$(pwd)/.venv/bin" >> $GITHUB_PATH 51 | fi 52 | shell: bash 53 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # 1. Python dependencies from pyproject.toml 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | open-pull-requests-limit: 1 9 | labels: 10 | - "dependencies" 11 | - "automerge" 12 | 13 | # 2. GitHub-hosted things: 14 | # - GitHub Actions in .github/workflows/ 15 | # - pre-commit hook revs in .pre-commit-config.yaml 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "daily" 20 | open-pull-requests-limit: 1 21 | labels: 22 | - "dependencies" 23 | - "automerge" 24 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Automerge 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: 7 | - labeled 8 | - synchronize 9 | - opened 10 | - reopened 11 | 12 | permissions: 13 | contents: write 14 | pull-requests: write 15 | issues: write 16 | 17 | jobs: 18 | automerge: 19 | if: contains(github.event.pull_request.labels.*.name, 'automerge') 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Debug PR info 23 | run: | 24 | echo "PR author: ${{ github.event.pull_request.user.login }}" 25 | echo "Labels: ${{ toJson(github.event.pull_request.labels) }}" 26 | 27 | - name: Auto-approve 28 | if: | 29 | github.event.pull_request.user.login == 'github-actions[bot]' || 30 | github.event.pull_request.user.login == 'dependabot[bot]' || 31 | github.event.pull_request.user.login == 'viseshrp' 32 | uses: hmarr/auto-approve-action@v4 33 | with: 34 | github-token: ${{ secrets.GITHUB_TOKEN }} 35 | pull-request-number: ${{ github.event.pull_request.number }} 36 | 37 | - name: Enable auto-merge 38 | uses: peter-evans/enable-pull-request-automerge@v3 39 | env: 40 | GH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 41 | with: 42 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 43 | merge-method: squash 44 | pull-request-number: ${{ github.event.pull_request.number }} 45 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL Python" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main" ] 7 | pull_request: 8 | branches: [ "main" ] 9 | schedule: 10 | # Every night at 11:00 PM Eastern / 04:00 AM UTC 11 | - cron: '0 4 * * *' 12 | 13 | jobs: 14 | analyze: 15 | name: CodeQL Python Analysis 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | # Required to upload code scanning results 20 | security-events: write 21 | 22 | # Only needed if you pull in private/internal packs 23 | packages: read 24 | 25 | # Required to read your repo’s contents 26 | actions: read 27 | contents: read 28 | 29 | steps: 30 | - name: Check out repository 31 | uses: actions/checkout@v4 32 | 33 | - name: Set up Python + Environment 34 | uses: ./.github/actions/setup-python-env 35 | 36 | - name: Initialize CodeQL for Python 37 | uses: github/codeql-action/init@v3 38 | with: 39 | languages: python 40 | # Python is interpreted; no build step required 41 | build-mode: none 42 | queries: +security-extended,security-and-quality 43 | 44 | - name: Perform CodeQL Analysis 45 | uses: github/codeql-action/analyze@v3 46 | with: 47 | # Tag results clearly for Python 48 | category: "/language:python" 49 | -------------------------------------------------------------------------------- /.github/workflows/create-draft-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Draft Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | draft-release: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write # needed to create a release & upload artifacts 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | fetch-tags: true 25 | 26 | - name: Set up Python Env 27 | uses: ./.github/actions/setup-python-env 28 | 29 | - name: Run version checks 30 | run: make check-version 31 | 32 | - name: Build package 33 | run: make build 34 | 35 | - name: Check for existing release 36 | run: | 37 | if gh release view "${{ github.ref_name }}" > /dev/null 2>&1; then 38 | echo "❌ Release for tag '${{ github.ref_name }}' already exists." 39 | exit 1 40 | fi 41 | env: 42 | GH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 43 | 44 | - name: Create Draft GitHub Release with Artifacts 45 | run: | 46 | gh release create "${{ github.ref_name }}" \ 47 | --title "Release ${GITHUB_REF_NAME}" \ 48 | --notes "See [CHANGELOG.md](./CHANGELOG.md)" \ 49 | --draft \ 50 | dist/* 51 | env: 52 | GH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 53 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | tags: 8 | - "v*" 9 | branches: 10 | - main 11 | - release/* 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | env: 18 | MAIN_PYTHON_VERSION: '3.13' 19 | 20 | jobs: 21 | quality: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Cache pre-commit hooks 28 | uses: actions/cache@v4 29 | with: 30 | path: ~/.cache/pre-commit 31 | key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} 32 | 33 | - name: Set up Python + Environment 34 | uses: ./.github/actions/setup-python-env 35 | 36 | - name: Run code quality checks 37 | run: make check 38 | 39 | tests: 40 | runs-on: ${{ matrix.os }} 41 | strategy: 42 | fail-fast: false 43 | max-parallel: 4 44 | matrix: 45 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] 46 | os: [ ubuntu-latest, macos-latest, windows-latest ] 47 | defaults: 48 | run: 49 | shell: bash 50 | steps: 51 | - name: Checkout repository 52 | uses: actions/checkout@v4 53 | 54 | - name: Set up Python + Environment 55 | uses: ./.github/actions/setup-python-env 56 | with: 57 | python-version: ${{ matrix.python-version }} 58 | 59 | - name: Run tests 60 | run: make test 61 | 62 | - name: Upload coverage to Codecov 63 | if: ${{ matrix.python-version == env.MAIN_PYTHON_VERSION && matrix.os == 'ubuntu-latest' }} 64 | uses: codecov/codecov-action@v5 65 | with: 66 | token: ${{ secrets.CODECOV_TOKEN }} 67 | 68 | publish-to-testpypi: 69 | if: github.ref == 'refs/heads/main' 70 | needs: [ quality, tests ] 71 | runs-on: ubuntu-latest 72 | environment: 73 | name: test-pypi 74 | permissions: 75 | contents: read 76 | steps: 77 | - name: Checkout repository 78 | uses: actions/checkout@v4 79 | with: 80 | fetch-depth: 0 # Required for hatch-vcs to generate full version 81 | fetch-tags: true 82 | 83 | - name: Set up Python + Environment 84 | uses: ./.github/actions/setup-python-env 85 | 86 | - name: Run version checks 87 | run: make check-version 88 | 89 | - name: Build project 90 | run: make build 91 | 92 | - name: Check dist 93 | run: make check-dist 94 | 95 | - name: Publish to TestPyPI 96 | run: make publish-test 97 | env: 98 | TEST_PYPI_TOKEN: ${{ secrets.TEST_PYPI_TOKEN }} 99 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit-autoupdate.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit Autoupdate 2 | 3 | on: 4 | schedule: 5 | - cron: '0 4 * * *' # Daily at 4 AM UTC / 11 PM Eastern 6 | workflow_dispatch: 7 | 8 | permissions: 9 | pull-requests: write 10 | contents: write 11 | issues: write # Needed to create labels 12 | 13 | jobs: 14 | autoupdate: 15 | if: startsWith(github.ref, 'refs/heads/main') # Ensure we only run from main 16 | name: Autoupdate Pre-commit Hooks 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | with: 22 | ref: main 23 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 24 | fetch-depth: 0 25 | 26 | - name: Cache pre-commit hooks 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/.cache/pre-commit 30 | key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} 31 | 32 | - name: Set up Python + Environment 33 | uses: ./.github/actions/setup-python-env 34 | 35 | - name: Run pre-commit autoupdate 36 | run: uv run pre-commit autoupdate 37 | continue-on-error: true 38 | 39 | - name: Run pre-commit checks 40 | run: make check 41 | 42 | - name: Show updated hooks (if any) 43 | run: git diff .pre-commit-config.yaml || true 44 | 45 | - name: Commit changes 46 | run: | 47 | git add .pre-commit-config.yaml 48 | git commit -m "chore(pre-commit): Update pre-commit hooks" || echo "No changes to commit" 49 | 50 | - name: Ensure labels exist 51 | run: | 52 | gh label create dependencies --description "Dependency updates" --color FFCD00 || echo "Label 'dependencies' already exists" 53 | gh label create chore --description "Chores and maintenance" --color 00CED1 || echo "Label 'chore' already exists" 54 | gh label create automerge --description "automerge" --color 3E0651 || echo "Label 'automerge' already exists" 55 | env: 56 | GH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 57 | 58 | - name: Create Pull Request for Updated Hooks 59 | uses: peter-evans/create-pull-request@v7 60 | with: 61 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 62 | base: main 63 | branch: chore/pre-commit-update 64 | title: "chore(pre-commit): Update pre-commit hooks" 65 | commit-message: "chore(pre-commit): Update pre-commit hooks" 66 | body: | 67 | # Update pre-commit hooks 68 | 69 | - This PR updates the versions of pre-commit hooks to their latest releases. 70 | labels: dependencies, chore, automerge 71 | delete-branch: true 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [ published ] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | release: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read # just checkout code 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 # Required for hatch-vcs to generate full version 26 | fetch-tags: true 27 | 28 | - name: Set up Python Env 29 | uses: ./.github/actions/setup-python-env 30 | 31 | - name: Run version checks 32 | run: make check-version 33 | 34 | - name: Build project 35 | run: make build 36 | 37 | - name: Check dist 38 | run: make check-dist 39 | 40 | - name: Publish package to PyPI 41 | run: make publish 42 | env: 43 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 44 | -------------------------------------------------------------------------------- /.github/workflows/validate-codecov-config.yml: -------------------------------------------------------------------------------- 1 | name: validate-codecov-config 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | paths: [ codecov.yml ] 7 | push: 8 | branches: [ main ] 9 | 10 | jobs: 11 | validate-codecov-config: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Validate codecov configuration 16 | run: curl -sSL --fail-with-body --data-binary @codecov.yml https://codecov.io/validate 17 | -------------------------------------------------------------------------------- /.github/workflows/vhs.yml: -------------------------------------------------------------------------------- 1 | name: Auto-update demo.gif 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths: 9 | - demo.tape 10 | - .github/workflows/vhs.yml 11 | - whatsonpypi/cli.py 12 | 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | 17 | jobs: 18 | vhs: 19 | runs-on: macos-latest 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 26 | 27 | - name: Set Git identity 28 | run: | 29 | git config user.name "vhs-action 📼" 30 | git config user.email "actions@github.com" 31 | 32 | - name: Set up Python + virtual environment 33 | uses: ./.github/actions/setup-python-env 34 | 35 | - name: Install VHS 36 | run: brew install vhs 37 | 38 | - name: Generate demo.gif 39 | run: vhs demo.tape 40 | 41 | - name: Ensure labels exist 42 | run: | 43 | gh label create chore --description "Chores and maintenance" --color 00CED1 || echo "Label 'chore' already exists" 44 | gh label create automerge --description "automerge" --color 3E0651 || echo "Label 'automerge' already exists" 45 | env: 46 | GH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 47 | 48 | - name: Create PR for updated demo.gif 49 | uses: peter-evans/create-pull-request@v7 50 | with: 51 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 52 | base: main 53 | branch: chore/demo-gif-update 54 | title: "chore(demo): update demo.gif" 55 | commit-message: "chore(demo): update demo.gif" 56 | body: | 57 | This PR updates the autogenerated `demo.gif` based on the latest `demo.tape`. 58 | 59 | - Regenerated using [VHS](https://github.com/charmbracelet/vhs) 60 | labels: automerge, chore 61 | delete-branch: true 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/source 2 | 3 | # From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 90 | __pypackages__/ 91 | 92 | # Celery stuff 93 | celerybeat-schedule 94 | celerybeat.pid 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mypy 116 | .mypy_cache/ 117 | .dmypy.json 118 | dmypy.json 119 | 120 | # Pyre type checker 121 | .pyre/ 122 | 123 | # pytype static type analyzer 124 | .pytype/ 125 | 126 | # Cython debug symbols 127 | cython_debug/ 128 | 129 | # Vscode config files 130 | .vscode/ 131 | 132 | # Exclude .DS_Store files from being added 133 | .DS_Store 134 | 135 | # PyCharm 136 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 137 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 138 | # and can be added to the global gitignore or merged into this file. For a more nuclear 139 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 140 | .idea/ 141 | 142 | # local tests 143 | local-test/ 144 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # 🧰 Built-in hooks 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-case-conflict 7 | - id: check-merge-conflict 8 | - id: check-toml 9 | - id: check-yaml 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | exclude: \.py$ # Ruff handles Python files 13 | 14 | # 🧪 Typing, dependency, security checks 15 | - repo: https://github.com/psf/black 16 | rev: 25.1.0 17 | hooks: 18 | - id: black 19 | 20 | # ✨ Ruff for lint + format 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: v0.11.13 23 | hooks: 24 | # Run the linter alone 25 | - id: ruff-check 26 | 27 | - repo: https://github.com/pypa/pip-audit 28 | rev: v2.9.0 29 | hooks: 30 | - id: pip-audit 31 | args: [ "--strict" ] 32 | pass_filenames: false 33 | 34 | - repo: https://github.com/PyCQA/bandit 35 | rev: 1.8.3 36 | hooks: 37 | - id: bandit 38 | args: [ 39 | "-c", "pyproject.toml", 40 | "-r", 41 | "-n", "3", 42 | "--severity-level", "high" 43 | ] 44 | additional_dependencies: [ "bandit[toml]" ] 45 | 46 | - repo: https://github.com/codespell-project/codespell 47 | rev: v2.4.1 48 | hooks: 49 | - id: codespell 50 | 51 | # 🧱 Local system hooks (for tools with no mirrors or your preference) 52 | - repo: local 53 | hooks: 54 | - id: mypy 55 | name: mypy 56 | entry: mypy 57 | language: system 58 | types: [ python ] 59 | args: [ "--config-file=pyproject.toml" ] 60 | 61 | - id: vulture 62 | name: vulture 63 | entry: vulture 64 | language: system 65 | types: [ python ] 66 | args: [ "--config", "pyproject.toml" ] 67 | pass_filenames: false 68 | 69 | - id: deptry 70 | name: deptry 71 | entry: uv run deptry . 72 | language: python 73 | language_version: python3 74 | types: [ python ] 75 | args: [ "--config", "pyproject.toml" ] 76 | pass_filenames: false 77 | 78 | - id: cog 79 | name: cog 80 | entry: cog 81 | language: system 82 | types: [ markdown ] 83 | require_serial: true 84 | args: [ "-r", "README.md" ] 85 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/). 7 | 8 | ## [0.4.3] - 2025-06-11 9 | 10 | ### Changed 11 | 12 | - Trim package/release info output. 13 | 14 | ### Security 15 | 16 | - Fixed a security vulnerability in the `requests` library dependency by upgrading to version 2.32.4. 17 | 18 | ## [0.4.2] - 2025-05-24 19 | 20 | ### Changed 21 | 22 | - Updated README to reflect new features and usage. 23 | - Changed output order. 24 | - Changed 'LATEST RELEASES' to last 10 releases. 25 | 26 | ## [0.4.1] - 2025-05-23 27 | 28 | ### Fixed 29 | 30 | - Fixed CLI help text. 31 | 32 | ## [0.4.0] - 2025-05-23 33 | 34 | ### Added 35 | 36 | - Added --history/-H flag to show package history. 37 | - Added `rich` support for better output formatting. 38 | - Use `pip install whatsonpypi[rich]` to enable, OR, 39 | - Make sure rich is installed in your environment. 40 | - Added a new shorter alias: `wopp`. You can now run `$ wopp requests` instead of `$ whatsonpypi requests`. 41 | 42 | ### Fixed 43 | 44 | - Fixed display of release information. 45 | - List dependencies as a proper list. 46 | - Improved output formatting. 47 | - Fixed browser launching on Windows. 48 | 49 | ### Changed 50 | 51 | - Removed deprecated info from output. 52 | - Modernized codebase structure and tooling. 53 | 54 | ### Removed 55 | 56 | - **BREAKING**: Removed support for adding packages to requirements files. 57 | - **BREAKING**: Dropped Python 3.7 and 3.8 support. 58 | - **BREAKING**: Renamed `--page` to `--open` for opening the PyPI page. 59 | - **BREAKING**: Version specific querying only supports `==` now. 60 | 61 | ## [0.3.7] - 2023-01-11 62 | 63 | ### Added 64 | 65 | - Added `-o/--page` flag to open PyPI page. 66 | 67 | ## [0.3.6] - 2023-01-11 68 | 69 | ### Fixed 70 | 71 | - Fixed handling of `None` values from the PyPI API. 72 | 73 | ## [0.3.5] - 2023-01-10 74 | 75 | ### Removed 76 | 77 | - Removed debug logs. 78 | 79 | ## [0.3.4] - 2023-01-09 80 | 81 | ### Fixed 82 | 83 | - Fixed null pointer errors. 84 | 85 | ## [0.3.3] - 2023-01-08 86 | 87 | ### Changed 88 | 89 | - Made version specifications more flexible. 90 | 91 | ## [0.3.2] - 2023-01-07 92 | 93 | ### Fixed 94 | 95 | - Fixed version and spec parsing logic. 96 | 97 | ## [0.3.1] - 2023-01-07 98 | 99 | ### Added 100 | 101 | - Added `--le`, `--ge`, `--ee`, and `--te` flags for version specifiers. 102 | 103 | ## [0.3.0] - 2023-01-06 104 | 105 | ### Removed 106 | 107 | - Dropped Python 2 support. Requires Python 3.7+ now. 108 | 109 | ## [0.2.8] - 2019-02-13 110 | 111 | ### Fixed 112 | 113 | - More Python 2 compatibility fixes. 114 | - Ensure UTF-8 encoding when opening files. 115 | 116 | ## [0.2.7] - 2019-02-12 117 | 118 | ### Fixed 119 | 120 | - Fix for `ImportError` on Python 2. 121 | 122 | ## [0.2.6] - 2019-02-06 123 | 124 | ### Fixed 125 | 126 | - Fixed missing newline characters. 127 | 128 | ## [0.2.5] - 2019-02-05 129 | 130 | ### Fixed 131 | 132 | - Fixed requirements file format validation. 133 | 134 | ## [0.2.4] - 2019-01-29 135 | 136 | ### Added 137 | 138 | - Added `--comment` to allow inserting comments alongside `--add`. 139 | 140 | ## [0.2.3] - 2019-01-26 141 | 142 | ### Added 143 | 144 | - Added `--req-pattern` to allow specifying the filename pattern for requirements files. 145 | 146 | ### Fixed 147 | 148 | - Raise error when no matching requirements files are found. 149 | 150 | ## [0.2.2] - 2019-01-23 151 | 152 | ### Fixed 153 | 154 | - Fixed display of empty dependencies. 155 | 156 | ## [0.2.1] - 2019-01-23 157 | 158 | ### Fixed 159 | 160 | - Miscellaneous small fixes. 161 | 162 | ## [0.2.0] - 2019-01-22 163 | 164 | ### Added 165 | 166 | - Added `-a/--add` to enable writing packages to requirement files. 167 | - Added `-d/--docs` to launch documentation/homepage URLs. 168 | - Support version-specific queries. 169 | 170 | ## [0.1.2] - 2019-01-20 171 | 172 | ### Added 173 | 174 | - Added `--more` / `-m` flag for detailed package output. 175 | - Displayed more metadata fields in default output. 176 | 177 | ## [0.1.1] - 2019-01-02 178 | 179 | ### Added 180 | 181 | - First release on PyPI. 182 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `whatsonpypi` 2 | 3 | Contributions are welcome, and they are greatly appreciated! 4 | Every little bit helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at 13 | 14 | If you are reporting a bug, please include: 15 | 16 | - Your operating system name and version. 17 | - Any details about your local setup that might be helpful in troubleshooting. 18 | - Detailed steps to reproduce the bug. 19 | 20 | ### Fix Bugs 21 | 22 | Look through the GitHub issues for bugs. 23 | Anything tagged with "bug" and "help wanted" is open to whoever wants to 24 | implement a fix for it. 25 | 26 | ### Implement Features 27 | 28 | Look through the GitHub issues for features. 29 | Anything tagged with "enhancement" and "help wanted" is open to whoever 30 | wants to implement it. 31 | 32 | ### Write Documentation 33 | 34 | `whatsonpypi` could always use more documentation, whether as part of the official docs, 35 | in docstrings, or even on the web in blog posts, articles, and such. 36 | 37 | ### Submit Feedback 38 | 39 | The best way to send feedback is to file an issue at 40 | . 41 | 42 | If you are proposing a new feature: 43 | 44 | - Explain in detail how it would work. 45 | - Keep the scope as narrow as possible, to make it easier to implement. 46 | - Remember that this is a volunteer-driven project, and that 47 | contributions are welcome :) 48 | 49 | ## Get Started 50 | 51 | Ready to contribute? Here's how to set up `whatsonpypi` for local development. 52 | Please note this documentation assumes you already have `uv` and `Git` installed. 53 | 54 | 1. Fork the `whatsonpypi` repo on GitHub. 55 | 56 | 2. Clone your fork locally: 57 | 58 | ```bash 59 | cd 60 | git clone git@github.com:YOUR_NAME/whatsonpypi.git 61 | ``` 62 | 63 | 3. Navigate into the project folder: 64 | 65 | ```bash 66 | cd whatsonpypi 67 | ``` 68 | 69 | 4. Install and activate the environment: 70 | 71 | ```bash 72 | uv sync 73 | ``` 74 | 75 | 5. Install pre-commit to run linters/formatters at commit time: 76 | 77 | ```bash 78 | uv run pre-commit install 79 | ``` 80 | 81 | 6. Create a branch for local development: 82 | 83 | ```bash 84 | git checkout -b name-of-your-bugfix-or-feature 85 | ``` 86 | 87 | 7. Add test cases for your changes in the `tests` directory. 88 | 89 | 8. Check formatting and style: 90 | 91 | ```bash 92 | make check 93 | ``` 94 | 95 | 9. Run unit tests: 96 | 97 | ```bash 98 | make test 99 | ``` 100 | 101 | 10. (Optional) Run `tox` to test against multiple Python versions: 102 | 103 | ```bash 104 | tox 105 | ``` 106 | 107 | 11. Commit your changes and push your branch: 108 | 109 | ```bash 110 | git add . 111 | git commit -m "Your detailed description of your changes." 112 | git push origin name-of-your-bugfix-or-feature 113 | ``` 114 | 115 | 12. Submit a pull request through the GitHub website. 116 | 117 | ## Pull Request Guidelines 118 | 119 | Before you submit a pull request, check that it meets these guidelines: 120 | 121 | 1. The pull request should include tests. 122 | 123 | 2. If the pull request adds functionality, update the documentation. 124 | Add a docstring, and update the feature list in `README.md`. 125 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Visesh Prasad 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include CHANGELOG.md 4 | 5 | recursive-include tests * 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := bash 2 | .SHELLFLAGS := -e -x -c 3 | 4 | # Cross-platform Bash 5 | ifeq ($(OS),Windows_NT) 6 | BASH := "C:/Program Files/Git/bin/bash.exe" 7 | else 8 | BASH := bash 9 | endif 10 | 11 | .PHONY: install 12 | install: ## 🚀 Set up environment and install project 13 | @echo "🚀 Syncing dependencies with uv..." 14 | uv sync --frozen 15 | @echo "🔧 Installing project in editable mode..." 16 | uv pip install -e . 17 | 18 | check-version: 19 | @echo "🔍 Checking if a Git tag exists..." 20 | @if git describe --tags --abbrev=0 >/dev/null 2>&1; then \ 21 | VERSION=$$(git describe --tags --abbrev=0); \ 22 | echo "✅ Git tag found: $$VERSION"; \ 23 | else \ 24 | echo "❌ No Git tag found. Please create one with: git tag v0.1.0"; \ 25 | exit 1; \ 26 | fi 27 | 28 | .PHONY: check 29 | check: ## Run all code quality checks 30 | @echo "🚀 Checking lock file consistency" 31 | uv lock --locked 32 | @echo "🚀 Running pre-commit hooks" 33 | uv run pre-commit run --all-files 34 | 35 | .PHONY: test 36 | test: ## Run tests using tox 37 | @echo "🚀 Testing code: Running tox across Python versions" 38 | tox 39 | 40 | .PHONY: test-local 41 | test-local: ## Run tests in current Python environment using uv 42 | @echo "🚀 Testing code locally" 43 | uv run python -m pytest -rvx tests --cov --cov-config=pyproject.toml --cov-report html:coverage-html 44 | 45 | .PHONY: build 46 | build: clean ## Build package using uv 47 | @echo "🚀 Building project" 48 | uv build 49 | 50 | .PHONY: clean 51 | clean: ## Clean build artifacts 52 | @echo "🚀 Removing build artifacts" 53 | rm -rf dist build *.egg-info 54 | rm -rf .coverage coverage-html coverage.xml .pytest_cache 55 | find . -name '*.pyc' -delete 56 | 57 | .PHONY: version 58 | version: ## Print the current project version 59 | uv run hatch version 60 | 61 | .PHONY: tag 62 | tag: ## 🏷 Tag the current release version (fixes changelog and pushes tag) 63 | $(BASH) scripts/tag_release.sh 64 | 65 | .PHONY: check-dist 66 | check-dist: ## Validate dist/ artifacts (long description, format) 67 | @echo "🔍 Validating dist/ artifacts..." 68 | uv run twine check dist/* 69 | 70 | .PHONY: publish 71 | publish: ## Publish to production PyPI 72 | @echo "🚀 Publishing to PyPI" 73 | UV_PUBLISH_TOKEN=$(PYPI_TOKEN) uv publish --publish-url=https://upload.pypi.org/legacy/ --no-cache 74 | 75 | .PHONY: publish-test 76 | publish-test: ## Publish to TestPyPI (for dry runs) 77 | @echo "🚀 Publishing to TestPyPI" 78 | UV_PUBLISH_TOKEN=$(TEST_PYPI_TOKEN) uv publish --publish-url=https://test.pypi.org/legacy/ --no-cache 79 | 80 | .PHONY: build-and-publish 81 | build-and-publish: build check-dist publish ## Build and publish in one step 82 | 83 | .PHONY: help 84 | help: 85 | uv run python -c "import re; \ 86 | [[print(f'\033[36m{m[0]:<20}\033[0m {m[1]}') for m in re.findall(r'^([a-zA-Z_-]+):.*?## (.*)$$', open(makefile).read(), re.M)] for makefile in ('$(MAKEFILE_LIST)').strip().split()]" 87 | 88 | .DEFAULT_GOAL := help 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # whatsonpypi 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/whatsonpypi.svg)](https://pypi.org/project/whatsonpypi/) 4 | [![Python versions](https://img.shields.io/pypi/pyversions/whatsonpypi.svg?logo=python&logoColor=white)](https://pypi.org/project/whatsonpypi/) 5 | [![CI](https://github.com/viseshrp/whatsonpypi/actions/workflows/main.yml/badge.svg)](https://github.com/viseshrp/whatsonpypi/actions/workflows/main.yml) 6 | [![Coverage](https://codecov.io/gh/viseshrp/whatsonpypi/branch/main/graph/badge.svg)](https://codecov.io/gh/viseshrp/whatsonpypi) 7 | [![License: MIT](https://img.shields.io/github/license/viseshrp/whatsonpypi)](https://github.com/viseshrp/whatsonpypi/blob/main/LICENSE) 8 | [![Code Style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://black.readthedocs.io/en/stable/) 9 | [![Lint: Ruff](https://img.shields.io/badge/lint-ruff-000000.svg)](https://docs.astral.sh/ruff/) 10 | [![Typing: mypy](https://img.shields.io/badge/typing-checked-blue.svg)](https://mypy.readthedocs.io/en/stable/) 11 | 12 | > Get package info from PyPI. 13 | 14 | ![Demo](https://raw.githubusercontent.com/viseshrp/whatsonpypi/main/demo.gif) 15 | 16 | ## 🚀 Why this project exists 17 | 18 | I find myself checking the PyPI page very frequently mostly when upgrading 19 | dependencies to get the latest versions. I'm inherently lazy and did not want 20 | to get my ass off my terminal window. 21 | 22 | ## 🧠 How this project works 23 | 24 | No real magic here. It uses the `requests` package to hit the public [PyPI 25 | REST API](https://docs.pypi.org/api/json/), parses the JSON and displays it. 26 | Embarrassingly simple. 27 | 28 | ## 📐 Requirements 29 | 30 | * Python >= 3.9 31 | 32 | ## 📦 Installation 33 | 34 | ```bash 35 | pip install whatsonpypi 36 | ``` 37 | 38 | **OR** 39 | 40 | ```bash 41 | pip install whatsonpypi[rich] 42 | ``` 43 | ... if you want to use the `rich` package for a nicer output. 44 | 45 | ## 🧪 Usage 46 | 47 | 61 | ``` {.bash} 62 | $ wopp --help 63 | Usage: wopp [OPTIONS] PACKAGE 64 | 65 | A CLI tool to get package info from PyPI. 66 | 67 | Example usages: 68 | 69 | $ whatsonpypi django 70 | 71 | OR 72 | 73 | $ wopp django 74 | 75 | Options: 76 | -v, --version Show the version and exit. 77 | -m, --more Flag to enable expanded output 78 | -d, --docs Flag to open docs or homepage of project 79 | -o, --open Flag to open PyPI page 80 | -H, --history INTEGER Show release history. Use positive number for most 81 | recent, negative for oldest. E.g. '--history -10' or ' 82 | --history 10' 83 | -h, --help Show this message and exit. 84 | 85 | ``` 86 | 87 | 88 | ## 🛠️ Features 89 | 90 | - Find information on a package on PyPI 91 | 92 | > Examples: 93 | > 94 | > ``` bash 95 | > $ wopp django 96 | > NAME 97 | > Django 98 | > ... 99 | > ``` 100 | 101 | - For more information.. 102 | 103 | > Examples: 104 | > 105 | > ``` bash 106 | > $ wopp django --more 107 | > ... 108 | > ``` 109 | 110 | - Version specific information.. 111 | 112 | > Examples: 113 | > 114 | > ``` bash 115 | > $ wopp django==2.1.4 --more 116 | > ... 117 | > ``` 118 | 119 | - Launch PyPI URL of project in a browser tab 120 | 121 | > Examples: 122 | > 123 | > ``` bash 124 | > $ wopp django --open 125 | > ``` 126 | 127 | - Launch documentation URL of project in a browser tab 128 | 129 | > Examples: 130 | > 131 | > ``` bash 132 | > $ wopp django --docs 133 | > ``` 134 | 135 | - Get release info of the last 5 versions 136 | 137 | > Examples: 138 | > 139 | > ``` bash 140 | > $ wopp django --history 5 141 | > ``` 142 | 143 | - Get release info of the first 5 versions 144 | 145 | > Examples: 146 | > 147 | > ``` bash 148 | > $ wopp django --history -5 149 | > ``` 150 | 151 | ## 🧾 Changelog 152 | 153 | See [CHANGELOG.md](https://github.com/viseshrp/whatsonpypi/blob/main/CHANGELOG.md) 154 | 155 | ## 🙏 Credits 156 | 157 | * [Click](https://click.palletsprojects.com), for enabling delightful CLI development. 158 | * Inspired by [Simon Willison](https://github.com/simonw)'s work. 159 | 160 | ## 📄 License 161 | 162 | MIT © [Visesh Prasad](https://github.com/viseshrp) 163 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 70..100 3 | round: down 4 | precision: 1 5 | status: 6 | patch: off 7 | project: 8 | default: 9 | target: 85% 10 | threshold: 0.5% 11 | -------------------------------------------------------------------------------- /codespell.txt: -------------------------------------------------------------------------------- 1 | whatsonpypi 2 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viseshrp/whatsonpypi/feb91bf9788940e4584674d40c7b42b646e12549/demo.gif -------------------------------------------------------------------------------- /demo.tape: -------------------------------------------------------------------------------- 1 | Set Shell bash 2 | Set FontSize 18 3 | Set Width 800 4 | Set Height 600 5 | Output demo.gif 6 | 7 | Hide Sleep 2s Show 8 | 9 | Hide 10 | Type "clear" 11 | Enter 12 | Show 13 | 14 | # CLI demo 15 | Type "wopp --help" 16 | Sleep 2s 17 | Enter 18 | Sleep 6s 19 | 20 | Hide 21 | Type "clear" 22 | Enter 23 | Show 24 | 25 | Type "wopp django" 26 | Sleep 2s 27 | Enter 28 | Sleep 6s 29 | 30 | 31 | Hide 32 | Type "clear" 33 | Enter 34 | Show 35 | 36 | Type "wopp django --more" 37 | Sleep 2s 38 | Enter 39 | Sleep 6s 40 | 41 | 42 | Hide 43 | Type "clear" 44 | Enter 45 | Show 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "whatsonpypi" 3 | dynamic = ["version"] 4 | description = "Get package info from PyPI." 5 | authors = [{ name = "Visesh Prasad", email = "viseshrprasad@gmail.com" }] 6 | readme = "README.md" 7 | keywords = [ 8 | "python", 9 | "whatsonpypi", 10 | "wopp", 11 | "pypi", 12 | "requirements", 13 | "virtualenv", 14 | "venv" 15 | ] 16 | requires-python = ">=3.9,<4.0" 17 | dependencies = [ 18 | "click>=8.1.8", 19 | "requests>=2.32.4", 20 | ] 21 | license = { text = "MIT" } 22 | classifiers = [ 23 | "Development Status :: 4 - Beta", 24 | "License :: OSI Approved :: MIT License", 25 | "Natural Language :: English", 26 | "Operating System :: OS Independent", 27 | "Intended Audience :: Developers", 28 | "Environment :: Console", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 3", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | ] 38 | 39 | [project.urls] 40 | Homepage = "https://github.com/viseshrp/whatsonpypi" 41 | Repository = "https://github.com/viseshrp/whatsonpypi" 42 | Documentation = "https://github.com/viseshrp/whatsonpypi/blob/main/README.md" 43 | Changelog = "https://github.com/viseshrp/whatsonpypi/blob/main/CHANGELOG.md" 44 | Bug-Tracker = "https://github.com/viseshrp/whatsonpypi/issues" 45 | CI = "https://github.com/viseshrp/whatsonpypi/actions" 46 | 47 | [project.scripts] 48 | whatsonpypi = "whatsonpypi.__main__:main" 49 | wopp = "whatsonpypi.__main__:main" 50 | 51 | [project.optional-dependencies] 52 | rich = ["rich>=13.0.0"] 53 | 54 | [dependency-groups] 55 | dev = [ 56 | "pre-commit>=4.2.0", 57 | "uv>=0.6.17", 58 | "tox>=4.25.0", 59 | "tox-uv>=1.25.0", 60 | "tox-gh-actions>=3.3.0", 61 | "mypy>=1.15.0", 62 | "deptry>=0.23.0", 63 | "pytest>=8.3.5", 64 | "pytest-cov>=6.1.1", 65 | "vulture>=2.14", 66 | "cogapp>=3.4.1", 67 | "twine>=6.1.0", 68 | "ipdb>=0.13.13", 69 | "hatch>=1.14.1", 70 | ] 71 | 72 | [build-system] 73 | requires = ["hatchling", "hatch-timestamp-version"] 74 | build-backend = "hatchling.build" 75 | 76 | [tool.hatch.version] 77 | path = "whatsonpypi/_version.py" 78 | source = "vcs-dev-timestamp" 79 | validate-bump = true 80 | 81 | [tool.hatch.version.raw-options] 82 | local_scheme = "no-local-version" 83 | timestamp_format = "long" 84 | 85 | [tool.hatch.build.targets.wheel] 86 | packages = ["whatsonpypi"] 87 | 88 | [tool.hatch.build.targets.editable] 89 | packages = ["whatsonpypi"] 90 | 91 | [tool.hatch.envs.default] 92 | path = ".venv" 93 | 94 | [tool.uv] 95 | default-groups = "all" 96 | 97 | [tool.mypy] 98 | files = [ 99 | "whatsonpypi" 100 | ] 101 | disallow_untyped_defs = true 102 | no_implicit_optional = true 103 | check_untyped_defs = true 104 | warn_return_any = true 105 | warn_unused_ignores = true 106 | show_error_codes = true 107 | ignore_missing_imports = true 108 | follow_imports = "silent" 109 | disable_error_code = ["import-untyped"] 110 | 111 | [tool.pytest.ini_options] 112 | tmp_path_retention_policy = "failed" 113 | testpaths = ["tests"] 114 | addopts = "--capture=tee-sys --tb=native -p no:warnings -ra -vv" 115 | markers = [ 116 | "integration:Run integration tests", 117 | "smoke:Run the smoke tests", 118 | "unit:Run the unit tests", 119 | ] 120 | norecursedirs = [ 121 | ".git", 122 | ".idea", 123 | ] 124 | filterwarnings = [ 125 | "ignore:.+:DeprecationWarning" 126 | ] 127 | 128 | # Configuration for coverage.py 129 | [tool.coverage.report] 130 | show_missing = true 131 | skip_covered = true 132 | # Regexes for lines to exclude from consideration 133 | exclude_lines = [ 134 | # Have to re-enable the standard pragma 135 | "pragma: no cover", 136 | # Don't complain about missing debug-only code: 137 | "def __repr__", 138 | "if self\\.debug", 139 | # Don't complain if tests don't hit defensive assertion code: 140 | "raise AssertionError", 141 | "raise NotImplementedError", 142 | # Don't complain if non-runnable code isn't run: 143 | "if 0:", 144 | "if __name__ == .__main__.:", 145 | # Don't complain about abstract methods, they aren't run: 146 | "@(abc\\.)?abstractmethod", 147 | ] 148 | 149 | [tool.coverage.run] 150 | branch = true 151 | omit = ["whatsonpypi/__main__.py"] 152 | source = [ 153 | "whatsonpypi" 154 | ] 155 | 156 | [tool.coverage.html] 157 | show_contexts = true 158 | 159 | [tool.ruff] 160 | target-version = "py39" 161 | line-length = 100 162 | fix = true 163 | exclude = [".venv", "__init__.py"] 164 | 165 | [tool.ruff.lint] 166 | fixable = ["ALL"] 167 | select = [ 168 | # flake8-2020 169 | "YTT", 170 | # flake8-bandit 171 | "S", 172 | # flake8-bugbear 173 | "B", 174 | # flake8-builtins 175 | "A", 176 | # flake8-comprehensions 177 | "C4", 178 | # flake8-debugger 179 | "T10", 180 | # flake8-simplify 181 | "SIM", 182 | # isort (keep this if you want Ruff to sort imports) 183 | "I", 184 | # mccabe 185 | "C90", 186 | # pycodestyle 187 | "E", "W", 188 | # pep8-naming rules 189 | "N", 190 | # pyflakes 191 | "F", 192 | # pygrep-hooks 193 | "PGH", 194 | # pyupgrade 195 | "UP", 196 | # ruff-native rules 197 | "RUF", 198 | # try/except linting 199 | "TRY", 200 | # Disallow print statements 201 | "T201", 202 | ] 203 | ignore = [ 204 | "C901", # Function is too complex 205 | "PGH003" # blanket-type-ignore 206 | ] 207 | 208 | [tool.ruff.lint.mccabe] 209 | max-complexity = 10 210 | 211 | [tool.ruff.lint.per-file-ignores] 212 | "tests/*" = ["S101"] 213 | "scripts/*.py" = ["T201"] 214 | 215 | [tool.ruff.lint.isort] 216 | known-first-party = ["whatsonpypi", "tests"] 217 | force-sort-within-sections = true 218 | 219 | [tool.black] 220 | line-length = 100 221 | target-version = ["py39"] 222 | 223 | [tool.codespell] 224 | ignore-words = "codespell.txt" 225 | skip = '*.pyc,*.xml,*.gif,*.png,*.jpg,*.js,*.html,*.json,*.gz,Makefile' 226 | quiet-level = 3 227 | 228 | [tool.bandit] 229 | targets = ["whatsonpypi"] 230 | exclude_dirs = ["venv", ".venv", "tests"] 231 | 232 | [tool.vulture] 233 | paths = ["whatsonpypi", "tests"] 234 | min_confidence = 80 235 | sort_by_size = true 236 | exclude = ["venv/", ".venv/"] 237 | 238 | [tool.deptry] 239 | exclude = ["venv/.*", ".venv/.*", "tests/.*"] 240 | 241 | [tool.pre-commit.default_language_versions] 242 | python = "3.13" 243 | -------------------------------------------------------------------------------- /scripts/check_changelog_date.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Check release date for a given version in CHANGELOG.md. 4 | """ 5 | 6 | from datetime import date 7 | from pathlib import Path 8 | import sys 9 | 10 | CHANGELOG_PATH = Path("CHANGELOG.md") 11 | 12 | 13 | def main(version: str) -> None: 14 | if not CHANGELOG_PATH.exists(): 15 | print(f"❌ ERROR: {CHANGELOG_PATH} does not exist.") 16 | sys.exit(1) 17 | 18 | today = date.today().isoformat() 19 | target_line = f"## [{version}] - {today}" 20 | 21 | found = False 22 | lines = CHANGELOG_PATH.read_text(encoding="utf-8").splitlines() 23 | 24 | for i, line in enumerate(lines): 25 | if line.strip() == target_line: 26 | print(f"🔍 Found line {i + 1}: {target_line}") 27 | found = True 28 | break 29 | 30 | if not found: 31 | print("❌ ERROR: CHANGELOG.md is not ready for release.") 32 | print(f" Expected line: {target_line}") 33 | print("Tip: Check if it's still marked as '[Unreleased]' and update it to today's date.") 34 | sys.exit(1) 35 | 36 | 37 | if __name__ == "__main__": 38 | if len(sys.argv) != 2: 39 | print("Usage: uv run python scripts/check_changelog_date.py ") 40 | sys.exit(1) 41 | 42 | main(sys.argv[1]) 43 | -------------------------------------------------------------------------------- /scripts/tag_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | VERSION=$(hatch version | sed 's/\.dev.*//') 5 | echo "🏷 Releasing version: $VERSION" 6 | 7 | # Update changelog date for this version 8 | uv run python scripts/check_changelog_date.py "$VERSION" 9 | 10 | # Assert we're on main and clean 11 | branch=$(git rev-parse --abbrev-ref HEAD) 12 | if [[ "$branch" != "main" ]]; then 13 | echo "❌ ERROR: Must be on main branch to tag a release." 14 | exit 1 15 | fi 16 | 17 | if ! git diff --quiet HEAD; then 18 | echo "❌ ERROR: Working directory is dirty. Commit your changes through a PR." 19 | exit 1 20 | fi 21 | 22 | # Create and push tag only 23 | git tag "v$VERSION" -m "Release v$VERSION" 24 | git push origin "v$VERSION" 25 | echo "✅ Tag v$VERSION pushed successfully." 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viseshrp/whatsonpypi/feb91bf9788940e4584674d40c7b42b646e12549/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator 2 | 3 | import pytest 4 | 5 | 6 | def do_cleanup() -> None: 7 | """Perform any necessary cleanup after tests.""" 8 | # Add your cleanup code here 9 | pass 10 | 11 | 12 | @pytest.fixture(scope="session", autouse=True) 13 | def cleanup() -> Generator[None, None, None]: 14 | yield 15 | do_cleanup() 16 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from click.testing import CliRunner 4 | import pytest 5 | 6 | from whatsonpypi import __version__, cli 7 | 8 | 9 | @pytest.mark.parametrize("options", [["-h"], ["--help"]]) 10 | def test_help(options: list[str]) -> None: 11 | runner = CliRunner() 12 | result = runner.invoke(cli.main, options) 13 | 14 | assert result.exit_code == 0, f"Help options {options} failed with exit code {result.exit_code}" 15 | assert result.output.startswith( 16 | "Usage: " 17 | ), f"Help output for {options} did not start with 'Usage:'" 18 | assert "-h, --help" in result.output, f"Help flags missing from output for {options}" 19 | 20 | 21 | @pytest.mark.parametrize("options", [["-v"], ["--version"]]) 22 | def test_version(options: list[str]) -> None: 23 | runner = CliRunner() 24 | result = runner.invoke(cli.main, options) 25 | 26 | assert ( 27 | result.exit_code == 0 28 | ), f"Version options {options} failed with exit code {result.exit_code}" 29 | assert ( 30 | __version__ in result.output 31 | ), f"Expected version {__version__} not found in output for {options}" 32 | 33 | 34 | def test_more_flag_output() -> None: 35 | result = CliRunner().invoke(cli.main, ["requests", "--more"]) 36 | assert result.exit_code == 0 37 | assert "DEPENDENCIES" in result.output # or other expanded field 38 | 39 | 40 | def test_docs_flag_opens_url(monkeypatch: pytest.MonkeyPatch) -> None: 41 | monkeypatch.setattr("webbrowser.open", lambda url: True) # simulate success 42 | result = CliRunner().invoke(cli.main, ["requests", "--docs"]) 43 | assert result.exit_code == 0 44 | 45 | 46 | def test_open_flag_opens_pypi(monkeypatch: pytest.MonkeyPatch) -> None: 47 | monkeypatch.setattr("webbrowser.open", lambda url: True) 48 | result = CliRunner().invoke(cli.main, ["requests", "--open"]) 49 | assert result.exit_code == 0 50 | 51 | 52 | @pytest.mark.parametrize("arg", ["--history=3", "--history=-3"]) 53 | def test_history_with_value(arg: str) -> None: 54 | result = CliRunner().invoke(cli.main, ["django", arg]) 55 | assert result.exit_code == 0 56 | assert "MD5" in result.output and "FILENAME" in result.output 57 | 58 | 59 | def test_history_old() -> None: 60 | result = CliRunner().invoke(cli.main, ["django", "--history=-5"]) 61 | assert result.exit_code == 0 62 | assert "1.2" in result.output 63 | 64 | 65 | def test_history_new() -> None: 66 | result = CliRunner().invoke(cli.main, ["django", "--history=5"]) 67 | assert result.exit_code == 0 68 | assert "1.2" not in result.output 69 | 70 | 71 | def test_invalid_package() -> None: 72 | result = CliRunner().invoke(cli.main, ["nonexistent_package_12345"]) 73 | assert result.exit_code != 0 74 | assert "couldn't be found" in result.output.lower() 75 | -------------------------------------------------------------------------------- /tests/test_whatsonpypi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from whatsonpypi.whatsonpypi import run_query 6 | 7 | 8 | @pytest.mark.parametrize("pkg", ["requests", "httpx", "rich"]) 9 | def test_run_query_returns_expected_keys(pkg: str) -> None: 10 | result = run_query(pkg, version=None, more_out=False, launch_docs=False, open_page=False) 11 | assert result is not None 12 | assert "name" in result 13 | assert "current_version" in result 14 | assert "summary" in result 15 | 16 | 17 | @pytest.mark.parametrize("pkg", ["requests", "httpx"]) 18 | def test_run_query_more_outputs(pkg: str) -> None: 19 | result = run_query(pkg, version=None, more_out=True, launch_docs=False, open_page=False) 20 | assert result is not None 21 | assert "dependencies" in result 22 | assert "project_urls" in result 23 | assert "license" in result 24 | assert "releases" in result 25 | assert isinstance(result["dependencies"], str) 26 | assert isinstance(result["project_urls"], dict) 27 | assert result["license"] is None or isinstance(result["license"], str) 28 | assert isinstance(result["releases"], str) 29 | 30 | 31 | @pytest.mark.parametrize("version", ["2.31.0", "1.0.0"]) 32 | def test_run_query_specific_version(version: str) -> None: 33 | result = run_query( 34 | "requests", version=version, more_out=True, launch_docs=False, open_page=False 35 | ) 36 | assert result is not None 37 | assert "current_version" in result 38 | assert result["current_version"] == version 39 | 40 | 41 | def test_run_query_missing_package_raises() -> None: 42 | with pytest.raises(Exception, match="couldn't be found"): 43 | run_query("this-package-does-not-exist-1234", None, False, False, False) 44 | 45 | 46 | def test_run_query_typical_with_more() -> None: 47 | result = run_query("rich", version=None, more_out=True, launch_docs=False, open_page=False) 48 | assert result is not None 49 | assert isinstance(result["dependencies"], str) 50 | assert "yanked" in str(result["current_package_info"]).lower() 51 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4.0 3 | env_list = py39, py310, py311, py312, py313 4 | skipsdist = true 5 | 6 | [gh-actions] 7 | python = 8 | 3.9: py39 9 | 3.10: py310 10 | 3.11: py311 11 | 3.12: py312 12 | 3.13: py313 13 | 14 | [testenv] 15 | pass_env = PYTHON_VERSION 16 | allowlist_externals = uv 17 | commands = 18 | uv sync --python {envpython} 19 | uv run python -m pytest tests --cov --cov-config=pyproject.toml --cov-report=xml:coverage.xml 20 | -------------------------------------------------------------------------------- /whatsonpypi/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | from .cli import main 3 | 4 | __all__ = ["__version__", "main"] 5 | -------------------------------------------------------------------------------- /whatsonpypi/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /whatsonpypi/_version.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | 3 | try: 4 | __version__ = version("whatsonpypi") 5 | except PackageNotFoundError: # pragma: no cover 6 | # Fallback for local dev or editable installs 7 | __version__ = "0.0.0" 8 | -------------------------------------------------------------------------------- /whatsonpypi/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | 5 | from . import __version__ 6 | from .utils import parse_pkg_string, pretty 7 | from .whatsonpypi import run_query 8 | 9 | 10 | @click.command(context_settings={"help_option_names": ["-h", "--help"]}) 11 | @click.version_option(__version__, "-v", "--version") 12 | @click.argument("package") 13 | @click.option( 14 | "-m", 15 | "--more", 16 | is_flag=True, 17 | required=False, 18 | default=False, 19 | show_default=True, 20 | help="Flag to enable expanded output", 21 | ) 22 | @click.option( 23 | "-d", 24 | "--docs", 25 | is_flag=True, 26 | required=False, 27 | default=False, 28 | help="Flag to open docs or homepage of project", 29 | ) 30 | @click.option( 31 | "-o", 32 | "--open", 33 | "page", 34 | is_flag=True, 35 | required=False, 36 | default=False, 37 | help="Flag to open PyPI page", 38 | ) 39 | @click.option( 40 | "-H", 41 | "--history", 42 | required=False, 43 | default=None, 44 | type=int, 45 | help="Show release history. Use positive number for most" 46 | " recent, negative for oldest. E.g. '--history -10' or '--history 10'", 47 | ) 48 | def main( 49 | package: str, 50 | more: bool, 51 | docs: bool, 52 | page: bool, 53 | history: int | None, 54 | ) -> None: 55 | """ 56 | A CLI tool to get package info from PyPI. 57 | 58 | Example usages: 59 | 60 | $ whatsonpypi django 61 | 62 | OR 63 | 64 | $ wopp django 65 | """ 66 | try: 67 | # get version if given 68 | package_, version, _ = parse_pkg_string(package) 69 | result = run_query( 70 | package_ or package, # parsed package name can be None 71 | version, 72 | more, 73 | docs, 74 | page, 75 | history, 76 | ) 77 | # output is not always expected and might be None sometimes. 78 | if result: 79 | pretty(result) 80 | except Exception as e: 81 | raise click.ClickException(str(e)) from e 82 | 83 | 84 | if __name__ == "__main__": 85 | main() # pragma: no cover 86 | -------------------------------------------------------------------------------- /whatsonpypi/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | API client for querying PyPI JSON endpoints. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from datetime import datetime 8 | from operator import itemgetter 9 | from typing import Any, TypeVar 10 | 11 | from requests import Request, Session, hooks 12 | from requests.adapters import HTTPAdapter 13 | from requests.exceptions import RequestException 14 | from requests.packages.urllib3.util.retry import Retry 15 | 16 | from .constants import PYPI_BASE_URL 17 | from .exceptions import PackageNotFoundError, PackageNotProvidedError 18 | 19 | T = TypeVar("T") 20 | 21 | 22 | class WoppResponse: 23 | """ 24 | A structured wrapper for PyPI JSON API responses. 25 | """ 26 | 27 | def __init__(self, status_code: int, json: dict[str, Any] | None) -> None: 28 | self.status_code: int = status_code 29 | self.json: dict[str, Any] = json or {} 30 | self._cache: dict[str, Any] = {} 31 | 32 | def _get(self, key: str, expected_type: type[T], default: T) -> T: 33 | value = self.json.get(key, default) 34 | return value if isinstance(value, expected_type) else default 35 | 36 | @property 37 | def name(self) -> str: 38 | return self._get("name", str, "") 39 | 40 | @property 41 | def latest_version(self) -> str: 42 | return self._get("latest_version", str, "") 43 | 44 | @property 45 | def summary(self) -> str: 46 | return self._get("summary", str, "") 47 | 48 | @property 49 | def package_url(self) -> str: 50 | return self._get("package_url", str, "") 51 | 52 | @property 53 | def project_urls(self) -> dict[str, Any]: 54 | return self._get("project_urls", dict, {}) 55 | 56 | @property 57 | def homepage(self) -> str: 58 | return self.project_urls.get("Homepage") or self._get("homepage", str, "") 59 | 60 | @property 61 | def project_docs(self) -> str: 62 | return self.project_urls.get("Documentation") or self.homepage 63 | 64 | @property 65 | def requires_python(self) -> str: 66 | return self._get("requires_python", str, "") 67 | 68 | @property 69 | def license(self) -> str: 70 | return self._get("license", str, "") 71 | 72 | @property 73 | def author(self) -> str: 74 | return self._get("author", str, "") 75 | 76 | @property 77 | def author_email(self) -> str: 78 | return self._get("author_email", str, "") 79 | 80 | @property 81 | def latest_release_url(self) -> str: 82 | return self._get("latest_release_url", str, "") 83 | 84 | @property 85 | def dependencies(self) -> list[str]: 86 | return self._get("dependencies", list, []) 87 | 88 | @property 89 | def latest_pkg_urls(self) -> dict[str, Any]: 90 | return self._get("latest_pkg_urls", dict, {}) 91 | 92 | @property 93 | def releases(self) -> list[str]: 94 | value = self.json.get("releases") 95 | return list(value) if isinstance(value, list) else [] 96 | 97 | @property 98 | def release_data(self) -> dict[str, dict[str, Any]]: 99 | """ 100 | Returns a dictionary of release data keyed by version. 101 | """ 102 | return self._get("release_info", dict, {}) 103 | 104 | def get_release_info(self, release_version: str) -> dict[str, Any]: 105 | """ 106 | Returns the release information for a specific release version. 107 | """ 108 | return self.release_data.get(release_version, {}) 109 | 110 | def get_releases_with_dates(self) -> list[tuple[str, datetime]]: 111 | """ 112 | Returns a list of releases with their upload dates. 113 | """ 114 | releases_with_dates = [] 115 | for release in self.releases: 116 | info = self.release_data.get(release, {}) 117 | if info: 118 | upload_time = info.get("upload_time") 119 | if isinstance(upload_time, str): 120 | try: 121 | release_date = datetime.fromisoformat(upload_time.replace("Z", "+00:00")) 122 | releases_with_dates.append((release, release_date)) 123 | except ValueError: 124 | continue 125 | return releases_with_dates 126 | 127 | def get_sorted_releases(self) -> list[str]: 128 | """ 129 | Returns a sorted list of releases based on their upload dates. 130 | Releases without dates are excluded from the list. 131 | The list is sorted in descending order (most recent first). 132 | 133 | :return: List of sorted release versions 134 | """ 135 | if "sorted_releases" not in self._cache: 136 | # filter and sort by datetime 137 | sorted_releases = sorted( 138 | self.get_releases_with_dates(), 139 | key=itemgetter(1), 140 | reverse=True, 141 | ) 142 | self._cache["sorted_releases"] = [ver for ver, _ in sorted_releases] 143 | sorted_versions: list[str] = self._cache["sorted_releases"] 144 | return sorted_versions 145 | 146 | def get_latest_releases(self, n: int = 10) -> list[str]: 147 | """ 148 | Returns the latest `n` releases sorted by upload time (most recent first). 149 | """ 150 | return self.get_sorted_releases()[:n] 151 | 152 | 153 | class WoppClient: 154 | """ 155 | Synchronous client for accessing the PyPI JSON API. 156 | """ 157 | 158 | def __init__( 159 | self, 160 | pool_connections: bool = True, 161 | request_hooks: dict[str, Any] | None = None, 162 | ) -> None: 163 | self.base_url: str = PYPI_BASE_URL 164 | self.session: Session | None = Session() if pool_connections else None 165 | self.request_hooks: dict[str, list[Any]] = request_hooks or hooks.default_hooks() 166 | 167 | def _build_url( 168 | self, 169 | package: str, 170 | version: str | None, 171 | ) -> str: 172 | """ 173 | Construct a fully qualified PyPI API URL. 174 | 175 | :param package: The package name 176 | :param version: Optional version 177 | :return: URL string 178 | """ 179 | 180 | return ( 181 | f"{self.base_url}/{package}/{version}/json" 182 | if version 183 | else f"{self.base_url}/{package}/json" 184 | ) 185 | 186 | def request( 187 | self, 188 | package: str | None = None, 189 | version: str | None = None, 190 | timeout: float = 3.1, 191 | max_retries: int = 3, 192 | ) -> WoppResponse: 193 | """ 194 | Sends a GET request to the PyPI API and returns a structured WoppResponse. 195 | 196 | :param package: The package name to query 197 | :param version: Optional version string 198 | :param timeout: Request timeout in seconds 199 | :param max_retries: Retry attempts for failed requests 200 | :return: WoppResponse object with parsed data 201 | :raises PackageNotProvidedError: if package is None 202 | :raises PackageNotFoundError: if the PyPI API returns 404 or 5xx status codes 203 | """ 204 | if package is None: 205 | raise PackageNotProvidedError 206 | 207 | url = self._build_url(package, version) 208 | req_kwargs = { 209 | "method": "GET", 210 | "url": url, 211 | "hooks": self.request_hooks, 212 | "headers": { 213 | "Accept": "application/json", 214 | "User-Agent": "https://github.com/viseshrp/whatsonpypi", 215 | }, 216 | } 217 | 218 | session = self.session or Session() 219 | request = Request(**req_kwargs) 220 | prepared_request = session.prepare_request(request) 221 | 222 | retries = Retry( 223 | total=max_retries, 224 | backoff_factor=0.1, 225 | status_forcelist=[500, 502, 503, 504], 226 | ) 227 | adapter = HTTPAdapter(max_retries=retries) 228 | session.mount("http://", adapter) 229 | session.mount("https://", adapter) 230 | 231 | try: 232 | response = session.send( 233 | prepared_request, 234 | timeout=timeout, 235 | allow_redirects=True, 236 | ) 237 | if response.status_code == 404 or response.status_code >= 500: 238 | raise PackageNotFoundError # Treat all 5xx as failure to find package 239 | except RequestException as e: 240 | raise PackageNotFoundError from e 241 | 242 | return WoppResponse(response.status_code, getattr(response, "cleaned_json", None)) 243 | -------------------------------------------------------------------------------- /whatsonpypi/constants.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | PYPI_BASE_URL: Final[str] = "https://pypi.org/pypi" 4 | REQ_LINE_REGEX: Final[str] = r"^(?P[A-Za-z0-9_\-\.]+)==(?P[A-Za-z0-9_\.\-]+)$" 5 | -------------------------------------------------------------------------------- /whatsonpypi/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class WoppError(Exception): 5 | """ 6 | Base exception. All other exceptions inherit from here. 7 | """ 8 | 9 | detail: str = "An unexpected error occurred." 10 | 11 | def __init__(self, extra_detail: str | None = None) -> None: 12 | super().__init__() 13 | self.extra_detail: str | None = extra_detail 14 | 15 | def __str__(self) -> str: 16 | if self.extra_detail: 17 | return f"{self.detail} :: {self.extra_detail}" 18 | return self.detail 19 | 20 | 21 | class PackageNotProvidedError(WoppError): 22 | """Raised when no package is available for the client to request.""" 23 | 24 | detail: str = "A package name is needed to proceed." 25 | 26 | 27 | class PackageNotFoundError(WoppError): 28 | """Raised when a package is not found on PyPI.""" 29 | 30 | detail: str = "Sorry, but that package/version couldn't be found on PyPI." 31 | 32 | 33 | class DocsNotFoundError(WoppError): 34 | """Raised when a package does not have documentation or homepage URLs.""" 35 | 36 | detail: str = "Could not find any documentation or homepage URL to launch." 37 | 38 | 39 | class PageNotFoundError(WoppError): 40 | """Raised when a package does not have the PyPI URL info.""" 41 | 42 | detail: str = "Could not find the URL to launch." 43 | 44 | 45 | class URLLaunchError(WoppError): 46 | """Raised when there's a problem opening a URL in the browser.""" 47 | 48 | detail: str = "There was a problem opening the URL in your browser." 49 | -------------------------------------------------------------------------------- /whatsonpypi/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | import re 5 | from typing import Any 6 | 7 | import click 8 | 9 | try: 10 | from rich import box 11 | from rich.console import Console 12 | from rich.panel import Panel 13 | from rich.table import Table 14 | 15 | _HAS_RICH: bool = True 16 | except ImportError: 17 | _HAS_RICH = False 18 | 19 | from .constants import REQ_LINE_REGEX 20 | 21 | 22 | def parse_pkg_string(in_str: str) -> tuple[str | None, str | None, str | None]: 23 | """ 24 | Extract package name and pinned version from a string using the '==' specifier. 25 | 26 | Only supports 'package==version' format. If no version is given, returns the package name alone. 27 | 28 | :param in_str: Raw input string (e.g. 'requests==2.31.0' or 'requests') 29 | :return: A tuple of (package name, version, specifier), or (name, None, None) if not matched 30 | """ 31 | match = re.match(REQ_LINE_REGEX, in_str.strip()) 32 | if match: 33 | groups = match.groupdict() 34 | return groups["package"], groups["version"], "==" 35 | return in_str.strip(), None, None 36 | 37 | 38 | def format_key(key_: str) -> str: 39 | return key_.upper().replace("_", " ") 40 | 41 | 42 | def format_value(val: Any) -> str: 43 | if isinstance(val, str): 44 | try: 45 | dt = datetime.fromisoformat(val.replace("Z", "+00:00")) 46 | return dt.strftime("%b %d, %Y %H:%M") 47 | except ValueError: 48 | return val 49 | return str(val) 50 | 51 | 52 | def pretty(data: dict[str, Any], indent: int = 0) -> None: 53 | """ 54 | Pretty print dictionary output. 55 | 56 | If `rich` is installed, renders a stylized table. 57 | Otherwise falls back to plain click-based indentation output. 58 | 59 | :param data: Dictionary to print 60 | :param indent: Indentation level (used only in fallback mode) 61 | """ 62 | 63 | if _HAS_RICH: 64 | 65 | def render_table(input_dict: dict[str, Any]) -> Table: 66 | table = Table( 67 | show_header=False, 68 | show_lines=True, 69 | box=box.ROUNDED, 70 | padding=(0, 1), 71 | ) 72 | table.add_column(justify="right", style="bold magenta", width=26, no_wrap=True) 73 | table.add_column(style="white", overflow="fold") 74 | 75 | for key, value in input_dict.items(): 76 | if value is None or value == "": 77 | continue 78 | key_label = format_key(str(key)) 79 | if isinstance(value, dict): 80 | nested_table = render_table(value) 81 | table.add_row(key_label, nested_table) 82 | elif isinstance(value, list): 83 | nested = "\n".join(format_value(item) for item in value) 84 | table.add_row(key_label, nested) 85 | else: 86 | table.add_row(key_label, format_value(value)) 87 | return table 88 | 89 | console = Console() 90 | main_table = render_table(data) 91 | console.print( 92 | Panel( 93 | main_table, 94 | title="📦 PyPI Package Info", 95 | title_align="left", 96 | border_style="yellow", 97 | ) 98 | ) 99 | else: 100 | if indent == 0: 101 | click.secho("📦 PyPI Package Info\n", fg="yellow", bold=True) 102 | 103 | for key, value in data.items(): 104 | if not value: 105 | continue 106 | 107 | click.secho("\t" * indent + format_key(key), fg="green", bold=True) 108 | 109 | if isinstance(value, dict): 110 | pretty(value, indent + 1) 111 | elif isinstance(value, list): 112 | for item in value: 113 | if isinstance(item, dict): 114 | pretty(item, indent + 2) 115 | else: 116 | click.echo("\t" * (indent + 2) + format_value(item)) 117 | else: 118 | click.echo("\t" * (indent + 1) + format_value(value)) 119 | 120 | 121 | def get_human_size(size_bytes: float) -> str | None: 122 | if size_bytes < 0: 123 | return None 124 | for unit in ["B", "KB", "MB", "GB"]: 125 | if size_bytes < 1024.0: 126 | return f"{size_bytes:.2f} {unit}" 127 | size_bytes /= 1024.0 128 | return f"{size_bytes:.2f} TB" 129 | 130 | 131 | def filter_release_info(pkg_url_list: list[dict[str, Any]]) -> dict[str, Any | None]: 132 | """ 133 | Converts a list of package info dicts into a dict. 134 | """ 135 | 136 | def _get_info(pkg_: dict[str, Any]) -> dict[str, Any | None]: 137 | return { 138 | "filename": pkg_.get("filename"), 139 | "size": get_human_size(float(pkg_.get("size", 0))), 140 | "upload_time": pkg_.get("upload_time_iso_8601"), 141 | "requires_python": pkg_.get("requires_python"), 142 | "url": pkg_.get("url"), 143 | "yanked": pkg_.get("yanked"), 144 | "md5": pkg_.get("digests", {}).get("md5"), 145 | } 146 | 147 | info = {} 148 | for pkg in pkg_url_list: 149 | package_type = pkg.get("packagetype", "").lower() 150 | if "wheel" in package_type: 151 | info = _get_info(pkg) 152 | else: 153 | # for other types, just use the first one found 154 | if not info: 155 | info = _get_info(pkg) 156 | 157 | return info 158 | 159 | 160 | def clean_response(r: Any, *_args: Any, **_kwargs: Any) -> Any: 161 | """ 162 | Hook called after a response is received. 163 | Used to modify response. 164 | 165 | :param r: requests.models.Response object 166 | :return: modified Response object 167 | """ 168 | if r.status_code != 200: 169 | return r 170 | 171 | dirty = r.json() 172 | clean = {} 173 | 174 | info = dirty.get("info") 175 | if info: 176 | clean = { 177 | "name": info.get("name"), 178 | "latest_version": info.get("version"), 179 | "summary": info.get("summary"), 180 | "homepage": info.get("home_page"), 181 | "package_url": info.get("project_url") or info.get("package_url"), 182 | "author": info.get("author") or info.get("author_email"), 183 | "project_urls": info.get("project_urls"), 184 | "requires_python": info.get("requires_python"), 185 | "license": info.get("license"), 186 | "latest_release_url": info.get("release_url"), 187 | "dependencies": info.get("requires_dist"), 188 | } 189 | 190 | releases = dirty.get("releases") 191 | if releases: 192 | release_list = list(releases.keys()) 193 | release_info = {} 194 | for release_version, file_info_list in releases.items(): 195 | if not file_info_list: 196 | continue 197 | release_info[release_version] = filter_release_info(file_info_list) 198 | clean.update( 199 | { 200 | "releases": release_list, 201 | "release_info": release_info, 202 | } 203 | ) 204 | 205 | urls = dirty.get("urls") 206 | if urls: 207 | clean["latest_pkg_urls"] = filter_release_info(urls) 208 | 209 | r.cleaned_json = clean 210 | return r 211 | -------------------------------------------------------------------------------- /whatsonpypi/whatsonpypi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | import webbrowser 5 | 6 | from .client import WoppClient, WoppResponse 7 | from .exceptions import ( 8 | DocsNotFoundError, 9 | PageNotFoundError, 10 | URLLaunchError, 11 | ) 12 | from .utils import clean_response 13 | 14 | 15 | def get_output(response: WoppResponse, more_out: bool = False) -> dict[str, Any]: 16 | """ 17 | Returns final output to display. 18 | 19 | :param response: WoppResponse object to get the info 20 | :param more_out: more or less information? 21 | :return: dict containing output information 22 | """ 23 | out_dict: dict[str, Any] = { 24 | "name": response.name, 25 | "current_version": response.latest_version, 26 | "requires_python": response.requires_python, 27 | "author": response.author, 28 | "summary": response.summary, 29 | "package_url": response.package_url, 30 | "homepage": response.homepage, 31 | } 32 | 33 | if more_out: 34 | out_dict.update( 35 | { 36 | "dependencies": ", ".join(response.dependencies), 37 | "project_urls": response.project_urls, 38 | "license": response.license, 39 | "current_release_url": response.latest_release_url, 40 | "current_package_info": response.latest_pkg_urls, 41 | "releases": ", ".join(response.get_sorted_releases()), 42 | } 43 | ) 44 | else: 45 | out_dict["latest_releases"] = ", ".join(response.get_latest_releases()) 46 | 47 | return out_dict 48 | 49 | 50 | def run_query( 51 | package: str, 52 | version: str | None, 53 | more_out: bool, 54 | launch_docs: bool, 55 | open_page: bool, 56 | history: int | None = None, 57 | ) -> dict[str, Any] | None: 58 | """ 59 | Run query against PyPI API and then do stuff based on user options. 60 | 61 | :param package: name of package 62 | :param version: version of package 63 | :param more_out: should output contain more detail? 64 | :param launch_docs: should doc URL be launched? 65 | :param open_page: should the PyPI page be launched? 66 | :param history: show release history 67 | 68 | :return: output if available, or None 69 | """ 70 | client = WoppClient(request_hooks={"response": clean_response}) 71 | response = client.request(package=package.lower(), version=version) 72 | 73 | if launch_docs or open_page: 74 | if launch_docs: 75 | url = response.project_docs 76 | if not url: 77 | raise DocsNotFoundError 78 | else: 79 | url = response.package_url 80 | if not url: 81 | raise PageNotFoundError 82 | success = webbrowser.open(url) 83 | if not success: 84 | raise URLLaunchError 85 | elif history is not None: 86 | if history < 0: 87 | # negative number means oldest releases. 88 | releases = response.get_sorted_releases()[history:] 89 | else: 90 | # positive number means newest releases. 91 | releases = response.get_sorted_releases()[:history] 92 | output = {} 93 | for release_version in releases: 94 | output[release_version] = response.get_release_info(release_version) 95 | return output 96 | else: 97 | return get_output(response, more_out=more_out) 98 | 99 | return None 100 | --------------------------------------------------------------------------------