Users who see "a new version is available" want one obvious next step, and mytool self-update is the most obvious of all. The trouble is that a Python CLI does not own the environment it runs in. It might live in a pipx-managed virtual environment, a uv tool environment, a Homebrew Cellar, a project's .venv, the user site-packages, or inside a PyInstaller binary — and each of those has exactly one correct way to upgrade. Running pip install --upgrade mytool from inside the tool is wrong for nearly all of them: it bypasses the manager's records, breaks Homebrew's checksums, and changes a pinned dependency in a project environment. This guide builds a self-update command that detects how the tool was installed and delegates to the right installer, or prints instructions when it cannot be sure. It belongs to the update checks topic.
Prerequisites
- Python 3.10+ and Typer or Click.
- Your CLI published as a package, ideally installable with pipx or
uv tool— see installing and distributing CLIs with pipx and uv tool install vs pipx. - Familiarity with calling external commands safely with subprocess.
How to tell where you were installed
Every installer leaves fingerprints in or around sys.prefix, the root of the environment the running interpreter belongs to. Checked in the right order, they identify the method reliably:
- Frozen binary — PyInstaller, Nuitka and similar tools set
sys.frozen. There is no Python environment to upgrade; the binary must be replaced by downloading a release or through the package manager that installed it. - pipx — each pipx environment contains a
pipx_metadata.jsonfile at its root. - uv tool — each
uv tool installenvironment contains auv-receipt.tomlfile at its root. - Homebrew — formula installs live under a
Cellardirectory inside the Homebrew prefix (/opt/homebrew/Cellar/...on Apple silicon,/usr/local/Cellar/...on Intel macs,/home/linuxbrew/.linuxbrew/Cellar/...on Linux). - A virtual environment —
sys.prefix != sys.base_prefix. This is usually a developer's project environment, where the lock file, not the tool, decides versions. - Anything else — a system or
--userpip install, conda, a distribution package. Too varied to automate safely.
The recipe
# src/mytool/selfupdate.py
from __future__ import annotations
import shutil
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
DIST = "mytool"
class Method(str, Enum):
frozen = "binary"
pipx = "pipx"
uv = "uv tool"
homebrew = "Homebrew"
venv = "virtual environment"
unknown = "unknown"
@dataclass(frozen=True)
class Plan:
method: Method
command: list[str] | None # None: we can only print advice
advice: str
def detect(prefix: Path | None = None, *, frozen: bool | None = None,
base_prefix: Path | None = None) -> Method:
prefix = Path(prefix or sys.prefix)
base_prefix = Path(base_prefix or sys.base_prefix)
frozen = getattr(sys, "frozen", False) if frozen is None else frozen
if frozen:
return Method.frozen
if (prefix / "pipx_metadata.json").is_file():
return Method.pipx
if (prefix / "uv-receipt.toml").is_file():
return Method.uv
if "Cellar" in prefix.resolve().parts:
return Method.homebrew
if prefix != base_prefix:
return Method.venv
return Method.unknown
def plan(method: Method) -> Plan:
if method is Method.pipx:
return Plan(method, ["pipx", "upgrade", DIST], f"pipx upgrade {DIST}")
if method is Method.uv:
return Plan(method, ["uv", "tool", "upgrade", DIST], f"uv tool upgrade {DIST}")
if method is Method.homebrew:
return Plan(method, ["brew", "upgrade", DIST], f"brew upgrade {DIST}")
if method is Method.frozen:
return Plan(method, None,
"download the latest release from https://example.com/mytool/releases")
if method is Method.venv:
return Plan(method, None,
f"this is a project environment: update {DIST} in its lock file "
f"(for example `uv lock --upgrade-package {DIST} && uv sync`)")
return Plan(method, None, f"upgrade with the tool you installed it with, e.g. "
f"`python -m pip install --upgrade {DIST}`")
def runnable(p: Plan) -> bool:
"""A plan can run here if it has a command, its tool is on PATH, and we are not on Windows."""
return bool(p.command) and shutil.which(p.command[0]) is not None \
and not sys.platform.startswith("win")
Detection and planning are pure functions of a few inputs, which keeps them easy to test. Keeping plan() separate from running means the same logic produces the hint in the update notice and the action in self-update.
Windows is excluded from running the upgrade in-process for a practical reason: the running mytool.exe launcher is locked while it executes, and an installer trying to replace it fails or leaves the environment half-upgraded. On Windows, print the command and exit; the user runs it from a fresh shell.
The command
# src/mytool/cli.py
import subprocess
import typer
from mytool import selfupdate
app = typer.Typer()
@app.callback()
def main() -> None:
"""My tool."""
@app.command("self-update")
def self_update(
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would run."),
) -> None:
"""Upgrade mytool using the installer that installed it."""
p = selfupdate.plan(selfupdate.detect())
typer.echo(f"installed via: {p.method.value}", err=True)
if dry_run or not selfupdate.runnable(p):
typer.echo(f"to upgrade, run: {p.advice}")
raise typer.Exit(0 if dry_run or p.command else 1)
typer.echo(f"running: {' '.join(p.command)}", err=True)
result = subprocess.run(p.command, check=False)
if result.returncode != 0:
typer.echo(f"upgrade failed (exit {result.returncode}); try running it yourself: {p.advice}",
err=True)
raise typer.Exit(result.returncode)
The subprocess call uses an argument list, never a shell string, and passes the installer's own output straight through so users see its progress and errors. Exit codes follow a simple rule: success is 0, an installer failure propagates its own code, and "cannot upgrade automatically" is 1 with clear instructions — scripts can tell the cases apart.
UX considerations
- Say what you detected. "installed via: pipx" before doing anything lets a user stop you if the detection is wrong.
- Offer
--dry-run. It is the safest way to answer "how do I upgrade this?" and costs one line. - Never escalate privileges. Do not run
sudo, and do not upgrade a system Python's packages. If the install is not user-owned, print advice and stop. - Do not touch project environments. In a virtual environment, the project's lock file is the source of truth; upgrading the CLI in place makes the environment disagree with it. Point at the lock-file command instead — uv lock and sync covers it.
- Pin pre-releases explicitly. Installers upgrade to the latest stable release by default. If a user is on a beta, say so and show the command that keeps them on the pre-release channel (
pipx upgrade --pip-args=--pre mytool). - Exit right after upgrading. The running process still has the old code loaded; do not continue with other work in the same invocation.
Testing the behaviour
Detection is tested by building fake environment directories; the command is tested by stubbing subprocess.run and shutil.which:
# tests/test_selfupdate.py
import subprocess
from typer.testing import CliRunner
from mytool import selfupdate
from mytool.cli import app
from mytool.selfupdate import Method, detect
def test_detects_each_installer(tmp_path):
base = tmp_path / "python"
pipx = tmp_path / "pipx" / "venvs" / "mytool"
pipx.mkdir(parents=True)
(pipx / "pipx_metadata.json").write_text("{}")
uv = tmp_path / "uv" / "tools" / "mytool"
uv.mkdir(parents=True)
(uv / "uv-receipt.toml").write_text("[tool]\n")
brew = tmp_path / "homebrew" / "Cellar" / "mytool" / "1.4.2" / "libexec"
brew.mkdir(parents=True)
venv = tmp_path / "project" / ".venv"
venv.mkdir(parents=True)
assert detect(pipx, frozen=False, base_prefix=base) is Method.pipx
assert detect(uv, frozen=False, base_prefix=base) is Method.uv
assert detect(brew, frozen=False, base_prefix=base) is Method.homebrew
assert detect(venv, frozen=False, base_prefix=base) is Method.venv
assert detect(base, frozen=False, base_prefix=base) is Method.unknown
assert detect(pipx, frozen=True, base_prefix=base) is Method.frozen
def test_runs_installer_command(monkeypatch):
calls = []
monkeypatch.setattr(selfupdate, "detect", lambda: Method.uv)
monkeypatch.setattr(selfupdate.shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(selfupdate.sys, "platform", "linux")
monkeypatch.setattr(subprocess, "run",
lambda cmd, check: calls.append(cmd) or subprocess.CompletedProcess(cmd, 0))
result = CliRunner().invoke(app, ["self-update"])
assert result.exit_code == 0
assert calls == [["uv", "tool", "upgrade", "mytool"]]
def test_project_venv_only_prints_advice(monkeypatch):
monkeypatch.setattr(selfupdate, "detect", lambda: Method.venv)
result = CliRunner().invoke(app, ["self-update"])
assert result.exit_code == 1
assert "lock file" in result.stdout
def test_dry_run_never_executes(monkeypatch):
monkeypatch.setattr(selfupdate, "detect", lambda: Method.pipx)
monkeypatch.setattr(subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(AssertionError))
result = CliRunner().invoke(app, ["self-update", "--dry-run"])
assert result.exit_code == 0
assert "pipx upgrade mytool" in result.stdout
For confidence that detection matches reality, add a CI job that installs the built wheel with pipx install and with uv tool install, then runs mytool self-update --dry-run and checks the detected method — the same idea as smoke-testing the built wheel in CI.
Conclusion
A self-update command is only safe if it upgrades through the tool that owns the environment. Detect the install method from the fingerprints each installer leaves — sys.frozen, pipx_metadata.json, uv-receipt.toml, a Homebrew Cellar path, a virtual environment — then run that installer's upgrade command with an argument list, or print precise advice when automation would be unsafe. Share the same plan with the update notice, and users always get the right next step.
Frequently asked questions
Can a frozen binary replace itself?
It can, by downloading the new binary next to itself and swapping on exit, but it is hard to do safely: signatures must be verified, Windows locks the running file, and macOS Gatekeeper may quarantine the download. Unless self-replacement is a core feature, point users at the release page or their package manager, as covered in Homebrew and Scoop packaging for Python CLIs.
What about conda installs?
conda environments look like plain virtual environments by the checks above, but contain a conda-meta directory at the prefix. If you publish on conda-forge, add a branch that detects it and suggests conda update mytool.
Should self-update also check whether an update exists?
It can call the same check from checking PyPI for a newer version first and print "already up to date" without running the installer. Installers handle "nothing to do" gracefully too, so this is a nicety rather than a requirement.
Is detection reliable inside Docker images?
In an image, the CLI is usually installed into the system environment or a single virtual environment, and upgrading inside a container is the wrong move anyway — rebuild the image. Detection will report venv or unknown, both of which print advice rather than act.