Knowing that a newer version exists is half the job. The other half is telling the user without making the tool slower, noisier or less scriptable — and that half is where most update notifiers go wrong. A synchronous check adds a network round trip to every command; a notice printed before the output scrolls away; a notice on stdout corrupts JSON; a notice on every run gets the tool aliased with the check disabled. This guide builds the display layer properly: a daily cache, a background thread with a hard time budget, a single notice on stderr after the command finishes, and automatic silence wherever a notice does not belong. It uses the check_pypi() function from checking PyPI for a newer version and is part of the update checks topic.
Prerequisites
- Python 3.10+, Typer or Click, and
platformdirsfor the state directory. - A function that returns update information or
Noneand never raises. - Familiarity with detecting CI environments and non-interactive shells.
The timing model
The whole design follows from one constraint: the user's command must take the same time with the update check enabled as without it. That rules out checking before the command runs, and it means the result of a check may not be available until a later run.
On most runs the cache says a check is not due, and the notifier does nothing but read a small file. When a check is due, a daemon thread starts as the command starts, fetches and writes the cache in parallel with the real work, and the notifier waits for it at the end for at most a fraction of a second. If the thread has finished, its result can be shown immediately; if not, the process exits anyway — the thread is a daemon — and the next run shows the cached result.
The recipe
# src/mytool/notifier.py
from __future__ import annotations
import json
import os
import sys
import tempfile
import threading
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from pathlib import Path
DISABLE_VARS = ("MYTOOL_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER")
CI_VARS = ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "TF_BUILD")
def _now() -> datetime:
return datetime.now(timezone.utc)
class UpdateNotifier:
def __init__(self, current: str, cache_file: Path,
fetch_latest: Callable[[str], str | None], *,
interval: timedelta = timedelta(hours=24),
upgrade_hint: str = "pipx upgrade mytool",
clock: Callable[[], datetime] = _now) -> None:
self.current = current
self.cache_file = cache_file
self.fetch_latest = fetch_latest
self.interval = interval
self.upgrade_hint = upgrade_hint
self.clock = clock
self._thread: threading.Thread | None = None
# -- policy ---------------------------------------------------------
@staticmethod
def enabled(*, machine_output: bool = False) -> bool:
if machine_output or any(os.environ.get(v) for v in DISABLE_VARS):
return False
if any(os.environ.get(v) for v in CI_VARS):
return False
return sys.stderr.isatty()
# -- cache ----------------------------------------------------------
def _load(self) -> dict:
try:
data = json.loads(self.cache_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) and data.get("schema") == 1 else {}
def _save(self, data: dict) -> None:
try:
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=self.cache_file.parent, suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump({"schema": 1, **data}, fh)
os.replace(tmp, self.cache_file)
except OSError:
pass # a read-only home must not break the tool
def _stamp(self, data: dict, key: str) -> datetime | None:
try:
return datetime.fromisoformat(data[key])
except (KeyError, TypeError, ValueError):
return None
# -- lifecycle ------------------------------------------------------
def start(self) -> None:
data = self._load()
last = self._stamp(data, "checked_at")
due = data.get("current") != self.current or last is None \
or self.clock() - last >= self.interval
if due:
self._thread = threading.Thread(target=self._check, name="update-check", daemon=True)
self._thread.start()
def _check(self) -> None:
latest = self.fetch_latest(self.current)
data = self._load()
data.update(current=self.current, latest=latest,
checked_at=self.clock().isoformat())
self._save(data)
def finish(self, wait: float = 0.25) -> str | None:
"""Wait briefly for a running check, then print a notice if one is due."""
if self._thread is not None:
self._thread.join(wait)
data = self._load()
latest = data.get("latest")
if not latest or data.get("current") != self.current:
return None
notified = self._stamp(data, "notified_at")
if notified is not None and self.clock() - notified < self.interval:
return None
message = (f"A new version of mytool is available: {self.current} → {latest}\n"
f"Upgrade with: {self.upgrade_hint}")
print(f"\n{message}", file=sys.stderr)
data["notified_at"] = self.clock().isoformat()
self._save(data)
return message
The fetch_latest callable receives the current version and returns the newer version string or None — a thin adapter around check_pypi(). Injecting it, along with the clock, is what makes the class testable without a network or time.sleep().
Several decisions are encoded here. The notice is rate-limited separately from the check (notified_at), so heavy users see it once a day rather than on every run. A cache written for a different installed version is ignored, so the notice disappears the moment the user upgrades. Every file operation swallows OSError, because a full disk or read-only home directory is not a reason for a command to fail. And enabled() is a static policy function you call before constructing anything, so disabled runs pay nothing at all.
Wiring it into a Typer app
Start the notifier in the root callback and finish it when the context closes, which happens after the command has produced its output:
# src/mytool/cli.py
from pathlib import Path
import typer
from platformdirs import user_state_path
from mytool.notifier import UpdateNotifier
from mytool.updates import check_pypi, installed_version
app = typer.Typer()
def _latest(current: str) -> str | None:
info = check_pypi("mytool", current=current)
return info.latest if info else None
@app.callback()
def main(ctx: typer.Context,
json_output: bool = typer.Option(False, "--json", help="Machine-readable output.")) -> None:
"""My tool."""
current = installed_version("mytool")
if current and UpdateNotifier.enabled(machine_output=json_output):
notifier = UpdateNotifier(current, user_state_path("mytool") / "update-check.json", _latest)
notifier.start()
ctx.call_on_close(notifier.finish)
@app.command()
def status() -> None:
"""Show status."""
typer.echo("all systems nominal")
ctx.call_on_close runs when the root context is torn down — after the command returns, and also when it raises typer.Exit. For Click the same two lines go in the group function. If your tool has a global --json flag, as here, passing it to enabled() keeps notices out of machine-readable runs; per-command format options need the same treatment.
UX considerations
- After the output, on stderr, separated by a blank line. The user sees the result they asked for first, and pipes are untouched.
- Two lines at most. What changed and the exact command to upgrade. Link to release notes only if the upgrade is a major version.
- Once per day. Rate-limiting the notice is what makes users tolerate it.
- Honour every off switch. A tool-specific variable, a generic one, a config key, CI detection and non-TTY stderr. Document all of them in
--helpor the README. - Never block on exit. The 0.25-second join is a ceiling, not a target; a daemon thread that is still waiting on the network is simply abandoned.
- Match the hint to the install. Hard-coding
pipx upgradeis wrong for uv or Homebrew users; use the detection from self-upgrading a CLI installed with pipx or uv to buildupgrade_hint.
Testing the behaviour
Inject a fake fetcher and clock, point the cache at tmp_path, and the whole lifecycle becomes deterministic:
# tests/test_notifier.py
from datetime import datetime, timedelta, timezone
import pytest
from mytool.notifier import UpdateNotifier
T0 = datetime(2026, 10, 2, 9, 0, tzinfo=timezone.utc)
class Clock:
def __init__(self):
self.now = T0
def __call__(self):
return self.now
@pytest.fixture
def make(tmp_path):
calls = []
def factory(latest="1.6.0", clock=None):
def fetch(current):
calls.append(current)
return latest
return UpdateNotifier("1.4.2", tmp_path / "state.json", fetch, clock=clock or Clock())
factory.calls = calls
return factory
def test_check_runs_once_per_interval(make):
clock = Clock()
n = make(clock=clock)
n.start(); n.finish()
n.start(); n.finish()
assert len(make.calls) == 1
clock.now += timedelta(hours=25)
n.start(); n.finish()
assert len(make.calls) == 2
def test_notice_on_stderr_once_per_day(make, capsys):
clock = Clock()
n = make(clock=clock)
n.start()
assert "1.4.2 → 1.6.0" in n.finish()
assert capsys.readouterr().out == ""
n.start()
assert n.finish() is None # already notified today
def test_no_notice_when_up_to_date(make):
n = make(latest=None)
n.start()
assert n.finish() is None
def test_corrupt_cache_is_ignored(make, tmp_path):
(tmp_path / "state.json").write_text("{not json")
n = make()
n.start()
assert n.finish() is not None
@pytest.mark.parametrize("env", ["CI", "MYTOOL_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"])
def test_disabled_by_environment(monkeypatch, env):
monkeypatch.setattr("sys.stderr.isatty", lambda: True)
monkeypatch.setenv(env, "1")
assert UpdateNotifier.enabled() is False
def test_disabled_for_machine_output_and_pipes(monkeypatch):
for var in ("CI", "GITHUB_ACTIONS", "MYTOOL_NO_UPDATE_CHECK", "NO_UPDATE_NOTIFIER"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr("sys.stderr.isatty", lambda: True)
assert UpdateNotifier.enabled() is True
assert UpdateNotifier.enabled(machine_output=True) is False
monkeypatch.setattr("sys.stderr.isatty", lambda: False)
assert UpdateNotifier.enabled() is False
Add one timing test to the end-to-end suite: run the installed command with the check enabled and a fetcher that sleeps for five seconds, and assert it still finishes in well under a second. That is the regression test for the property users care about most.
Conclusion
A good update notice is invisible until it is useful: a cached check that runs at most daily, in a background thread that can never delay the command, and one short message on stderr after the output — silent in CI, pipes and machine-readable modes. Build it as a small class with an injected fetcher and clock, wire it to the root callback with call_on_close, and test the lifecycle deterministically.
Frequently asked questions
Why a thread rather than a separate background process?
A thread is simpler, portable and needs no cleanup. Its limitation is that a slow check is abandoned when the command exits, so the result arrives a run later. A detached subprocess survives the exit and can always complete, which matters for tools whose commands are typically very short; the cost is spawning a Python process, which is slow on Windows and harder to test.
Does a daemon thread delay interpreter shutdown?
No — daemon threads are abandoned at exit. The only care needed is that the thread does not hold a half-written file when that happens, which is why the cache is written atomically with a temporary file and os.replace.
Should the notice appear for every command, including --help?
Help and version commands usually exit before the callback runs, so the notice naturally skips them, which is fine. Some tools deliberately show the notice in mytool --version output, since a user checking the version is the one most likely to want to know.
How do I let users turn it off permanently?
Read a config key such as update_check = false in the same place as other settings and pass it into enabled(). Mention the setting in the notice's documentation, not in the notice itself — two lines is the budget.