Runtime

Showing Non-Blocking Update Notices in a Python CLI

Run update checks in a background thread with a daily cache, print one short notice on stderr after the output, and switch it off for CI, pipes and JSON mode.

Updated

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

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.

When the check and the notice happen Timeline of a command run: the background check starts with the command, the command produces output, the notifier waits briefly, then prints the notice. When the check and the notice happen Command starts cache says check is due t = 0 Thread fetches PyPI, 1.5 s timeout parallel Command output stdout, unchanged work Join ≤ 0.25 s then give up close Notice stderr, once a day end one invocation, left to right If the thread is still waiting, the result is cached and shown on 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.

The notice in practice Terminal session where a command prints its output and then a two-line update notice, while the same command piped to another tool shows no notice. The notice in practice bash $ mytool status all systems nominal A new version of mytool is available: 1.4.2 → 1.6.0 Upgrade with: pipx upgrade mytool $ mytool status | cat all systems nominal The second run is piped, so stderr policy and the daily limit both keep it quiet.

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 --help or 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 upgrade is wrong for uv or Homebrew users; use the detection from self-upgrading a CLI installed with pipx or uv to build upgrade_hint.
When the notice stays silent Conditions under which an update notice is suppressed and the reason for each. When the notice stays silent Condition Why CI or GITHUB_ACTIONS set nobody reads it, logs get noisy stderr is not a TTY output is being captured --json or other machine mode scripts are reading MYTOOL_NO_UPDATE_CHECK=1 the user said so Notified in the last 24 h once a day is enough Every row is checked before any work is done, so disabled runs cost nothing.

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.