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
httpxandpackaging(uv add httpx packaging);respxandpytestfor 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:
{
"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.
A few details deserve explanation:
allow_prerelease=cur.is_prereleaseimplements the usual policy: someone on2.0.0rc1hears about2.0.0rc2and2.0.0; someone on1.6.0never hears about a beta.max()overVersionobjects uses PEP 440 ordering, so1.10.0 > 1.9.0and1.6.0.post1 > 1.6.0behave as pip does.- A
User-Agentheader identifies your tool politely in the index's logs. It contains only the tool name and version — nothing about the user. response.json()can raiseValueErrorwhen a captive portal or proxy returns HTML with status 200. That is caught along with network errors.timeout=1.5covers 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 frompyproject.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_majorlets the notice say "2.0 is available — see what changed" instead of implying a routine upgrade. - Expose a manual command.
mytool version --checkthat callscheck_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_PROXYandNO_PROXYby default; keep that behaviour so corporate users are not left with a check that always times out.
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.