Skip to main content

pyselfupdate

Self-update and update notification for Python CLIs installed with uv tool.

Two things, used independently: tell the user once a day that a newer release exists, and install it when they ask. No runtime dependencies.

from pyselfupdate import Config, notify, update

config = Config(tool='mytool', owner='you')

notify(config)  # once a day, one line if behind. Never raises.
update(config)  # install the latest release. Raises on failure.

Install

uv add pyselfupdate

# with the ready-made typer command
uv add "pyselfupdate[typer]"

Requires Python 3.11+.

Why

A CLI distributed with uv tool install has no way to tell its user a newer version exists, so it silently drifts. The usual fix drags an HTTP client, a TOML parser and a version library into a tool that had none of them.

This package has zero runtime dependencies — urllib for the network, tomllib for uv's receipt and lock, and its own semver implementation — and CI enforces that by importing every module into a virtual environment containing nothing else.

notify

Put it in your CLI's root callback and ignore the result:

import typer
from pyselfupdate import Config, notify

app = typer.Typer()
CONFIG = Config(tool='mytool', owner='you')


@app.callback()
def main() -> None:
    notify(CONFIG)

Once per 24 hours, if a newer release exists, one line goes to stderr after your command's own output:

mytool v1.4.0 available (running v1.3.2) — run `mytool update`

It never raises, never installs anything, and never prints an error. A failed check is recorded in the state file and swallowed, because an update notice must not be able to break the command the user actually typed.

Nothing is printed when any of these hold:

Condition Why
NO_AUTO_UPDATE or MYTOOL_NO_AUTO_UPDATE is set Opted out
CI, BUILD_NUMBER, RUN_ID, GITHUB_ACTIONS, CODESPACES Not a human
stdout or stderr is not a terminal mytool list > out 2>&1 must stay clean
Installed from a local path, an editable checkout, or a branch Nothing to compare against
Checked within the interval One request per day, not per invocation

Presence-only, any value: NO_AUTO_UPDATE=0 disables it, the same way NO_COLOR works. Set the interval separately with AUTO_UPDATE_INTERVAL=6h or MYTOOL_AUTO_UPDATE_INTERVAL=30m.

update

from pyselfupdate import Config, check, update

result = check(config)  # no filesystem, no install
if result.update_available:
    print(result.current, '->', result.latest)

result = update(config)  # installs, raises on failure

Or take the whole command:

from pyselfupdate.typercmd import add_update_command

add_update_command(app, CONFIG)  # gives you `mytool update [--check]`

update runs uv tool install --force, which rebuilds the virtual environment the running interpreter lives in. Unlike replacing a Unix binary — where the process holds an inode and is untouched — this pulls modules out from under a live process, so anything imported afterwards may fail in ways that are hard to read. Make it the last thing your process does, then call exit_now — or use update_and_reexec to replace the process with the new version immediately. Its caller never sees the Result, so it writes lock_warning to stderr itself before the re-exec.

That cuts both ways: anything you want to print after the install has to be fetched before it. run_update resolves its changelog first for exactly this reason, and a caller that needs its own steps in between composes the three pieces update is made of rather than working around it:

installation = require_updatable(config)  # refuses a checkout, costs nothing
result = check(config)  # network, environment still intact
notes = changelog(config, result.current, result.latest)
installed = install_release(config, result, installation)
print(notes)
if installed.lock_warning:
    print(installed.lock_warning, file=sys.stderr)
exit_now()

A git install is held to the release's uv.lock

uv tool install never reads a lock. Handed a git requirement, it resolves every dependency to the newest version, so a tool would run on a set its CI never tested. update therefore clones the release's tag, exports its uv.lock, and passes registry pins as --constraints and git pins as --overrides. uv records both in the tool's receipt, so a later uv tool upgrade stays held to them.

The tag What update does
Carries a uv.lock Installs the locked versions
Carries none Installs unlocked and sets lock_missing. lock_warning names the tag, and run_update and update_and_reexec print it
Will not clone, or uv will not export its lock Raises LockUnreadableError and installs nothing

An install from an index reads no lock, because a wheel carries none.

An update reinstalls the tool's own extras, as in mytool[fast], with their dependencies held to the lock. Every --with and --with-editable requirement is passed back to uv as well.

What will not be updated

Read from uv's own receipt, written at install time, rather than guessed at runtime:

Receipt Result
git = "...git?rev=v1.2.3" Updatable
name = "mytool" (from an index) Updatable
git = "...git" with no rev Refused — tracks a branch, so its version says nothing about how far behind it is
directory / path / editable Refused — reinstalling would discard a working copy
A --with requirement no requirements-file line reproduces, such as a relative path Refused with NotInstalledError — the update would drop it

A tool that cannot be identified at all is treated as local and left alone.

Configuration

Config(
    tool='mytool',  # required: uv tool name, state dir, env prefix
    owner='you',  # GitHub owner
    repo='mytool',  # defaults to tool
    package='mytool',  # distribution name, defaults to tool
    version='1.2.3',  # defaults to the installed distribution's metadata
    token='',  # see Authentication below; you almost certainly want the default
    token_func=None,  # a source of your own, tried before $GITHUB_TOKEN_COMMAND
    tag_prefix='',  # e.g. 'cli/' for tags like cli/v1.2.3
    allow_prerelease=False,
    source=None,  # a custom Source; anything with latest_release()
)

Authentication

Authenticated by default. Configure nothing. GitHubSource runs gh auth token when a request is about to be made, and sends what it prints.

The alternative is not "no credential". It is 60 requests an hour, charged per IP address and shared with every other anonymous caller behind the same egress. A default that has to be opted into is a default nobody sets.

Four sources, first non-empty wins:

Source Set by Default
Config.token you, in code unset
$GITHUB_TOKEN, then $GH_TOKEN whoever runs your CLI unset
token_func() you, in code unset
$GITHUB_TOKEN_COMMAND whoever runs your CLI gh auth token

$GITHUB_TOKEN_COMMAND both redirects and disables, which is what a switch has to do to be worth having:

GITHUB_TOKEN_COMMAND='pass show github/token'   # use this instead
GITHUB_TOKEN_COMMAND='op read op://vault/gh/token'
GITHUB_TOKEN_COMMAND=''                         # run nothing, stay anonymous

It never raises. A command that is not installed, exits non-zero, or takes longer than ten seconds degrades to an unauthenticated request, which still works against a public repository.

token_func is for a credential neither the environment nor a command can produce. It is called lazily, for the same reason the command is: the notify gate resolves a Config on every invocation and declines most of them without reaching the network, and a subprocess in front of that gate is the entire cost worth avoiding.

This lives on GitHubSource, not on Config. A credential is the host's business — a Source for another forge brings its own variable and its own command, and nothing above the Source protocol learns either name.

State

${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate-<machine>.json, written atomically:

{
  "schema": 1,
  "tool": "mytool",
  "checked_at": "2026-07-26T15:07:15Z",
  "checked_at_epoch": 1785078435,
  "current_version": "v1.3.2",
  "latest_version": "v1.4.0",
  "last_error": ""
}

State, not config and not cache: it persists across runs, it is not authored by the user, and deleting it changes behavior rather than merely costing a recompute. That is XDG_STATE_HOME by the Base Directory specification, and it is where gh puts the same thing.

<machine> is the bare lowercased hostname, from state.machine(). It is in the name because both fields that vary — the version installed here, and the instant this box last checked — describe one machine. A state directory shared between machines, by a file syncer or a network home directory, otherwise has two writers on one path and reports whichever wrote last as the state of all of them.

The timestamp is written before the network call. gh stamps only on success, so a rate-limited or offline user re-hits the API on every invocation until the window resets; an interval exists to bound the request rate, and only this ordering actually does that.

Siblings

The same two-layer design in other languages. All three carry both layers, and they share the autoupdate-<machine>.json schema, the machine derivation that names it, and the NO_AUTO_UPDATE contract. They do not share an API, because "update" means a different operation in each.

Version precedence is deliberately identical across all three, so a tool and its siblings never disagree about which release is newer.

License

MIT

Metadata

Release files for pyselfupdate 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyselfupdate 0.5.0
File Size Uploaded
pyselfupdate-0.5.0.tar.gz 32.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyselfupdate 0.5.0
File Interpreter ABI Platform
pyselfupdate-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 69.1 kB

Release files / pyselfupdate-0.5.0.tar.gz

Download URL pyselfupdate-0.5.0.tar.gz
Size 32.2 kB
Tags Source
SHA-256 checksum
How to use checksums
c14f221cd445e90f7be6d0977c3662682f8e1857e819456f5bb9be8cec4384e8
BLAKE2b-256 checksum
How to use checksums
f25637363aeddfe8d3b3e40ed9000a13be524d5b28fd735cda29a99b0b086460
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.24 {"installer":{"name":"uv","version":"0.12.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / pyselfupdate-0.5.0-py3-none-any.whl

Download URL pyselfupdate-0.5.0-py3-none-any.whl
Size 36.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ccbad29973e308fef2eb21b5714e0cbce1855aec791ed2424c20551e8e2d478e
BLAKE2b-256 checksum
How to use checksums
a6880378a3d6a164e2da5dc06eccfa16818fead2520e55a6a04b38beca01f350
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.24 {"installer":{"name":"uv","version":"0.12.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page