Runtime

Self-Upgrading a Python CLI Installed with pipx or uv

Add a self-update command that detects whether the CLI came from pipx, uv tool, Homebrew, a venv or a frozen binary, and runs or prints the right upgrade.

Updated

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

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:

Detecting the install method The order of checks used to detect how a command line tool was installed, from frozen binary down to unknown. Detecting the install method first match wins sys.frozen is set binary PyInstaller, Nuitka — replace the binary pipx_metadata.json in sys.prefix pipx a pipx-managed environment uv-receipt.toml in sys.prefix uv tool a uv tool environment Cellar in the prefix path brew a Homebrew formula sys.prefix ≠ sys.base_prefix venv a project virtual environment — the lock file decides none of the above unknown print advice, change nothing Only the pipx, uv and Homebrew rows are safe to upgrade automatically.
  • 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.json file at its root.
  • uv tool — each uv tool install environment contains a uv-receipt.toml file at its root.
  • Homebrew — formula installs live under a Cellar directory 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 --user pip 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.

self-update in two environments Terminal session running self-update in a uv tool install, which runs the upgrade, and in a project virtual environment, which only prints advice. self-update in two environments bash $ mytool self-update installed via: uv tool running: uv tool upgrade mytool Updated mytool v1.4.2 -> v1.6.0 $ .venv/bin/mytool self-update installed via: virtual environment to upgrade, run: uv lock --upgrade-package mytool && uv sync Saying what was detected first lets the user stop a wrong guess.

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.
Run it, or print it? Whether a self-update command should run the upgrade automatically or only print instructions, by install method and platform. Run it, or print it? Situation Action pipx / uv / brew on PATH run the installer Same, on Windows print — the .exe is locked Project venv print the lock-file command Frozen binary print the download link --dry-run always print Printing is never wrong; running is only right when the installer owns the environment.

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.