Runtime

Checking PyPI for a Newer Version of Your Python CLI

Query the PyPI JSON API for the latest release of a CLI, skip yanked and pre-release versions, compare with packaging.version, and test it with respx.

Updated

The core of any update notifier is one question: is there a release newer than the one running right now? For a CLI published on PyPI, the answer is a single HTTPS request to the package index's JSON API — but getting it right takes more care than requests.get(...).json()["info"]["version"]. That field ignores pre-release policy, can point at a release the maintainer has since yanked, and the request itself can hang, fail or return a proxy's HTML error page. This guide builds a small, dependency-light function that answers the question correctly and never raises, and tests every failure mode. It is the first building block of the update checks topic; the caching and display layer comes in showing non-blocking update notices.

Prerequisites

  • Python 3.10+ with httpx and packaging (uv add httpx packaging); respx and pytest for the tests.
  • A package published on PyPI (or a private index that serves the same JSON API).
  • The installed version available through importlib.metadata, as described in exposing version info and build metadata.

What the JSON API returns

GET https://pypi.org/pypi/<project>/json returns metadata for the latest release plus a map of every release to its files. Only a few fields matter here:

The parts of the PyPI response that matter The PyPI JSON API response with the info version field and the releases map whose files carry yanked flags. The parts of the PyPI response that matter GET /pypi/mytool/json one public request info.version latest stable, per PyPI releases every version → files files[].yanked withdrawn uploads Skip versions with no files Skip versions fully yanked Apply your pre-release policy Computing "latest" from releases lets you apply your own rules; info.version is the fallback.
{
  "info": {"name": "mytool", "version": "1.6.0", "yanked": false},
  "releases": {
    "1.5.0": [{"filename": "mytool-1.5.0-py3-none-any.whl", "yanked": false}],
    "1.6.0": [{"filename": "mytool-1.6.0-py3-none-any.whl", "yanked": false}],
    "1.7.0b1": [{"filename": "mytool-1.7.0b1-py3-none-any.whl", "yanked": false}],
    "1.6.1": [{"filename": "mytool-1.6.1-py3-none-any.whl", "yanked": true}]
  }
}

info.version is PyPI's idea of the latest stable release, which is usually what you want — but computing the answer from releases lets you apply your own policy: include pre-releases for users already on one, skip versions whose files are all yanked, and ignore releases with no files at all (a version number can be registered without uploads). The releases key is the legacy part of the API and may be trimmed for very large projects someday, so fall back to info.version if it is missing.

The recipe

# src/mytool/updates.py
from __future__ import annotations

from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, version

import httpx
from packaging.version import InvalidVersion, Version

PYPI_URL = "https://pypi.org/pypi/{name}/json"


@dataclass(frozen=True)
class UpdateInfo:
    current: str
    latest: str

    @property
    def is_major(self) -> bool:
        return Version(self.latest).major > Version(self.current).major


def installed_version(dist: str) -> str | None:
    try:
        return version(dist)
    except PackageNotFoundError:          # running from a source checkout
        return None


def _usable_versions(payload: dict, *, allow_prerelease: bool) -> list[Version]:
    found = []
    for raw, files in (payload.get("releases") or {}).items():
        if not files or all(f.get("yanked") for f in files):
            continue                       # no uploads, or every file yanked
        try:
            v = Version(raw)
        except InvalidVersion:
            continue
        if v.is_prerelease and not allow_prerelease:
            continue
        found.append(v)
    return found


def latest_version(payload: dict, *, allow_prerelease: bool = False) -> Version | None:
    candidates = _usable_versions(payload, allow_prerelease=allow_prerelease)
    if candidates:
        return max(candidates)
    try:                                   # releases missing: trust info.version
        return Version(payload["info"]["version"])
    except (KeyError, TypeError, InvalidVersion):
        return None


def check_pypi(dist: str, *, current: str | None = None, timeout: float = 1.5,
               client: httpx.Client | None = None, index_url: str = PYPI_URL) -> UpdateInfo | None:
    """Return UpdateInfo if a newer release exists; None on 'no update' or any failure."""
    current = current or installed_version(dist)
    if current is None:
        return None
    try:
        cur = Version(current)
    except InvalidVersion:
        return None
    own_client = client is None
    client = client or httpx.Client(timeout=timeout, headers={"User-Agent": f"{dist}/{current}"})
    try:
        response = client.get(index_url.format(name=dist))
        response.raise_for_status()
        payload = response.json()
    except (httpx.HTTPError, ValueError):
        return None                        # offline, timeout, 404, HTML error page...
    finally:
        if own_client:
            client.close()
    if not isinstance(payload, dict):
        return None
    latest = latest_version(payload, allow_prerelease=cur.is_prerelease)
    if latest is None or latest <= cur:
        return None
    return UpdateInfo(current=str(cur), latest=str(latest))

The function's contract is deliberately narrow: it returns an UpdateInfo only when there is something to say, and None in every other case — including every kind of failure. Callers never need a try block, which is what you want for a feature that runs on every invocation and must never be the reason a command fails.

Which releases count? Pre-release policy for update checks: stable users only hear about stable releases, pre-release users hear about everything newer. Which releases count? What is the installed version? Stable, e.g. 1.6.0 Stable only never announce betas Pre-release, e.g. 2.0.0rc1 Include betas rc2 and 2.0.0 both count Not installed Stay silent source checkout Yanked releases and releases without files never count.

A few details deserve explanation:

  • allow_prerelease=cur.is_prerelease implements the usual policy: someone on 2.0.0rc1 hears about 2.0.0rc2 and 2.0.0; someone on 1.6.0 never hears about a beta.
  • max() over Version objects uses PEP 440 ordering, so 1.10.0 > 1.9.0 and 1.6.0.post1 > 1.6.0 behave as pip does.
  • A User-Agent header identifies your tool politely in the index's logs. It contains only the tool name and version — nothing about the user.
  • response.json() can raise ValueError when a captive portal or proxy returns HTML with status 200. That is caught along with network errors.
  • timeout=1.5 covers connect, read and write in httpx. Keep it short; this function will run in a background thread, but a short timeout limits how long that thread can keep the process alive.

Private indexes and other sources

Indexes such as devpi, Artifactory, Nexus and GitLab's package registry often serve the same /pypi/<name>/json endpoint; pass index_url= with their base. If an index only implements the Simple API (PEP 691 JSON), request https://<index>/simple/<name>/ with Accept: application/vnd.pypi.simple.v1+json and read versions from the versions list — the comparison logic stays identical. For binaries published on GitHub, the latest release tag comes from https://api.github.com/repos/<owner>/<repo>/releases/latest; strip a leading v before parsing.

UX considerations

  • Use the distribution name, not the import name. importlib.metadata.version() and PyPI both want the name from pyproject.toml (my-tool), which may differ from the package you import (my_tool).
  • Return early for source checkouts. When the package is not installed, there is no meaningful "current version"; staying silent avoids nagging contributors running from a clone.
  • Flag major releases. UpdateInfo.is_major lets the notice say "2.0 is available — see what changed" instead of implying a routine upgrade.
  • Expose a manual command. mytool version --check that calls check_pypi() synchronously and prints the result is useful for support ("which version are you on, and is there a newer one?") and costs nothing to add.
  • Respect proxies. httpx honours HTTPS_PROXY and NO_PROXY by default; keep that behaviour so corporate users are not left with a check that always times out.
A manual version check Terminal session running a version command with a check flag that reports an available major release. A manual version check bash $ mytool version --check mytool 1.9.0 newer release available: 2.0.0 (major — see the changelog) $ MYTOOL_NO_UPDATE_CHECK=1 mytool version --check mytool 1.9.0 An explicit check is synchronous because the user asked for it; automatic checks never are.

Testing the behaviour

respx mocks httpx at the transport layer, so the real check_pypi() runs against canned responses. Cover the happy path, each policy decision and each failure:

# tests/test_updates.py
import httpx
import pytest
import respx

from mytool.updates import check_pypi

URL = "https://pypi.org/pypi/mytool/json"


def payload(*versions, yanked=(), info="1.6.0"):
    return {
        "info": {"version": info},
        "releases": {v: [{"yanked": v in yanked}] for v in versions},
    }


@respx.mock
def test_reports_newer_stable_release():
    respx.get(URL).respond(json=payload("1.5.0", "1.6.0", "1.7.0b1"))
    info = check_pypi("mytool", current="1.5.0")
    assert (info.current, info.latest) == ("1.5.0", "1.6.0")


@respx.mock
def test_skips_yanked_release():
    respx.get(URL).respond(json=payload("1.6.0", "1.6.1", yanked={"1.6.1"}))
    assert check_pypi("mytool", current="1.6.0") is None


@respx.mock
def test_prerelease_users_hear_about_prereleases():
    respx.get(URL).respond(json=payload("1.6.0", "2.0.0rc1", "2.0.0rc2"))
    assert check_pypi("mytool", current="2.0.0rc1").latest == "2.0.0rc2"


@respx.mock
def test_versions_compare_numerically():
    respx.get(URL).respond(json=payload("1.9.0", "1.10.0"))
    assert check_pypi("mytool", current="1.9.0").latest == "1.10.0"


@respx.mock
def test_falls_back_to_info_version():
    respx.get(URL).respond(json={"info": {"version": "3.0.0"}})
    info = check_pypi("mytool", current="2.9.0")
    assert info.latest == "3.0.0" and info.is_major


@respx.mock
@pytest.mark.parametrize("response", [
    httpx.Response(404),
    httpx.Response(200, text="<html>Proxy login</html>"),
    httpx.Response(200, json=["not", "a", "dict"]),
])
def test_bad_responses_are_silent(response):
    respx.get(URL).mock(return_value=response)
    assert check_pypi("mytool", current="1.0.0") is None


@respx.mock
def test_timeouts_are_silent():
    respx.get(URL).mock(side_effect=httpx.ConnectTimeout("slow"))
    assert check_pypi("mytool", current="1.0.0") is None


def test_not_installed_is_silent():
    assert check_pypi("definitely-not-installed-xyz") is None

The parametrised test is the important one: proxies and captive portals return all sorts of things, and each must end in silence rather than a traceback. For broader HTTP testing patterns, see mocking HTTP in CLI tests with respx.

Conclusion

Checking PyPI for a newer version is one request, but a correct check filters yanked and file-less releases, applies a deliberate pre-release policy, compares with packaging.version, and turns every failure into "nothing to report". Wrapped in a function that never raises, it is safe to call from anywhere — and ready for the caching and background-thread layer that keeps it from ever slowing a command down.

Frequently asked questions

Can I use pip index versions instead of the JSON API?

It works interactively but is marked experimental, spawns a whole pip process and its output format is not a stable interface. The JSON API is faster, structured and designed for programs.

Is it acceptable to hit PyPI from every installation?

Yes, at a sensible rate. PyPI's JSON API is served from a CDN and used by many tools for exactly this. Cache the result for a day, send a descriptive User-Agent, and do not retry aggressively; that keeps the load trivial.

Should I use urllib.request to avoid the httpx dependency?

If your CLI does not already depend on an HTTP client, urllib.request.urlopen(url, timeout=1.5) with json.load() works fine and keeps the install lighter; the parsing and comparison code does not change. Most CLIs that talk to APIs already have httpx, in which case reuse it.

How do I handle a package that was renamed?

Check the new name, and for a transition period have the old package's final release print a one-time message pointing at the new one. The update check itself cannot follow renames, because PyPI has no redirect metadata for projects.

What if the user installed from a Git URL or a local wheel?

Then the installed version may not exist on PyPI at all — a development build such as 1.7.0.dev3+g1a2b3c or a fork with its own numbering. Local version labels (the part after +) compare as newer than the plain release, so 1.7.0.dev3+g1a2b3c is correctly treated as older than 1.7.0 but newer than 1.6.0. If your build process produces versions like that, consider skipping the check entirely when Version(current).local is set: someone running a custom build rarely wants to be told to install the public release.