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 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, or use
update_and_reexec to replace the process with the new version immediately.
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 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='', # defaults to $GITHUB_TOKEN, then $GH_TOKEN, then token_func
token_func=None, # called only when a request is made; for a credential that costs a subprocess
tag_prefix='', # e.g. 'cli/' for tags like cli/v1.2.3
allow_prerelease=False,
source=None, # a custom Source; anything with latest_release()
)
Without a token, GitHub allows 60 API requests per hour per IP and rejects private repositories outright. One check per day per tool is far inside that; a shared egress address is not.
A private repository therefore needs a real token, and the usual source is the
gh CLI. Pass it as token_func, not token:
def gh_token() -> str:
result = subprocess.run(['gh', 'auth', 'token'], capture_output=True, text=True)
return result.stdout.strip() if result.returncode == 0 else ''
Config(tool='mytool', owner='you', token_func=gh_token)
token_func is called only when a request is actually about to be made.
Resolving the token eagerly into token instead puts that subprocess in front
of every invocation of your CLI — including the overwhelming majority where the
notify gate declines to check at all, which is otherwise free.
State
${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate.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 behaviour 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.
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. update exists in all three;
the notify layer, the shared autoupdate.json schema and the
NO_AUTO_UPDATE contract are implemented in this library and in its bash
sibling, and are still to be added to goselfupdate — until then goselfupdate
provides the update half only.
- goselfupdate — replaces a Go binary
- bashselfupdate — moves a git checkout to its newest tag
Version precedence is deliberately identical across all three, so a tool and its siblings never disagree about which release is newer.
Licence
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyselfupdate-0.2.0.tar.gz.
File metadata
- Download URL: pyselfupdate-0.2.0.tar.gz
- Upload date:
- Size: 20.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
091bb07df67ef26ac9620fae385b3cceb2902d1c165f775eacd8fd3fe42547e9
|
|
| MD5 |
c8604f3960203f5165000bdefb1036d3
|
|
| BLAKE2b-256 |
9aaf6bda7cb41a2760b48a66c3673b2869a416541664923162c768c42bc12302
|
File details
Details for the file pyselfupdate-0.2.0-py3-none-any.whl.
File metadata
- Download URL: pyselfupdate-0.2.0-py3-none-any.whl
- Upload date:
- Size: 25.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5bef537e6c9ca09406aab1c9881246c43e216518cd33e2fc3220a89c23bf52e7
|
|
| MD5 |
57948955e30ef0d9ccd0bfd5835d634b
|
|
| BLAKE2b-256 |
9131ecd642b165ccf6d78f550cd1c1b444d53cb9f7454896c8e694d827c1f9fb
|