pyselfupdate 0.5.2__tar.gz → 0.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,391 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyselfupdate
3
+ Version: 0.5.4
4
+ Summary: Self-update and update notification for Python CLIs installed with uv tool
5
+ Project-URL: Repository, https://github.com/datapointchris/pyselfupdate
6
+ Project-URL: Issues, https://github.com/datapointchris/pyselfupdate/issues
7
+ Project-URL: Changelog, https://github.com/datapointchris/pyselfupdate/releases
8
+ Author-email: Chris Birch <datapointchris@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,release,self-update,update-notifier,uv
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: System :: Software Distribution
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Provides-Extra: typer
21
+ Requires-Dist: typer>=0.12.0; extra == 'typer'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pyselfupdate
25
+
26
+ Self-update and update notification for Python CLIs installed with `uv tool`.
27
+
28
+ A CLI installed with `uv tool install` has no way to tell its user that a newer
29
+ release exists, so it silently drifts. This package adds two layers, used
30
+ independently. `notify` prints one line, at most once a day, when a newer
31
+ release exists. `update` installs that release when the user asks.
32
+
33
+ `uv tool upgrade` re-resolves the requirement a tool was installed with, so a
34
+ tool installed at a release tag stays at that tag. `update` asks the release
35
+ source which tag is newest and reinstalls at that tag. A git install is held to
36
+ that tag's `uv.lock`.
37
+
38
+ The package has no runtime dependencies. It uses `urllib` for the network,
39
+ `tomllib` for uv's receipt and lock, and its own semver implementation. CI
40
+ installs it alone into an empty virtual environment and imports every module
41
+ except the typer integration.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ uv add pyselfupdate # the library alone, with no dependencies
47
+ uv add 'pyselfupdate[typer]' # adds typer and the ready-made `update` command
48
+ ```
49
+
50
+ Python 3.11 is the floor. It is the first release carrying `tomllib`, which
51
+ reads uv's receipt and lock without a TOML dependency.
52
+
53
+ ## Starting from scratch
54
+
55
+ Wire both layers into a typer CLI. `tool` is the name `uv tool install` gives
56
+ the tool, and `owner` is the GitHub account its releases are published under.
57
+
58
+ ```python
59
+ import typer
60
+
61
+ from pyselfupdate import Config, notify
62
+ from pyselfupdate.typercmd import add_update_command
63
+
64
+ app = typer.Typer()
65
+ CONFIG = Config(tool='mytool', owner='you') # releases at github.com/you/mytool
66
+
67
+
68
+ @app.callback()
69
+ def main() -> None:
70
+ notify(CONFIG) # never raises; ignore what it returns
71
+
72
+
73
+ add_update_command(app, CONFIG) # registers `mytool update`
74
+ ```
75
+
76
+ Then publish a release, install the tool at its tag, and ask it:
77
+
78
+ ```bash
79
+ gh release create v1.0.0 --generate-notes # a GitHub release, not a bare tag
80
+ uv tool install 'mytool @ git+https://github.com/you/mytool@v1.0.0' # at a tag, never a branch
81
+ mytool update --check # ✓ mytool already at latest: v1.0.0
82
+ ```
83
+
84
+ The running version is read from the installed distribution's metadata, so a
85
+ release's tag has to match the version its package declares. A tool versioned
86
+ from its git tags gets that for free.
87
+
88
+ The install names a tag because an install from a branch, a commit or a local
89
+ checkout is never updated. Its version says nothing about how far behind it
90
+ is. [What may be updated](#what-may-be-updated-is-read-from-uvs-receipt-not-guessed-from-the-version)
91
+ lists every case.
92
+
93
+ A private repository needs a credential. `gh auth login` is enough, because
94
+ the check runs `gh auth token` by default.
95
+
96
+ **When the notice never appears, run `mytool update --check` first.** It runs
97
+ the two steps that can fail, reading uv's receipt and asking the release
98
+ source, and prints what stopped it. The notice stays silent on both. Every
99
+ other reason it stays silent is in the table under `notify`.
100
+
101
+ ## `notify` — one line after the command's output, never an error
102
+
103
+ Once per 24 hours, when a newer release exists, one line goes to stderr:
104
+
105
+ ```text
106
+ mytool v1.4.0 available (running v1.3.2) — run `mytool update`
107
+ ```
108
+
109
+ By default the line is printed at interpreter exit, so it lands after the
110
+ command's own output rather than inside it.
111
+
112
+ `notify` never raises, never installs anything, and never prints an error. A
113
+ check that fails is recorded in the state file and swallowed, because an update
114
+ notice must not be able to break the command the user typed.
115
+
116
+ Nothing is printed when any of these hold:
117
+
118
+ | Condition | Why |
119
+ | --- | --- |
120
+ | `NO_AUTO_UPDATE` or `MYTOOL_NO_AUTO_UPDATE` is set | Opted out |
121
+ | `--help` or `-h` comes before any `--` on the command line | typer runs the callback even for a subcommand's help screen, which is not work |
122
+ | stdout or stderr is not a terminal | `mytool list > out 2>&1` must stay clean |
123
+ | `CI`, `BUILD_NUMBER`, `RUN_ID`, `GITHUB_ACTIONS` or `CODESPACES` is set | Not a human |
124
+ | Installed from a local path, an editable checkout, a branch or a commit | Nothing to compare against |
125
+ | Checked within the interval | One request per interval, not per invocation |
126
+
127
+ The opt-out is presence-only, so `NO_AUTO_UPDATE=0` disables the notice, the
128
+ same way [`NO_COLOR`](https://no-color.org) works. The interval has its own
129
+ pair, `MYTOOL_AUTO_UPDATE_INTERVAL` falling back to `AUTO_UPDATE_INTERVAL`.
130
+ Each takes `30m`, `6h`, `7d` or a bare number of seconds. The tool's form of
131
+ each variable is its name uppercased, with `-` written as `_`.
132
+
133
+ A command writing into a pager, a log or a structured-output mode calls
134
+ `notify(config, interactive=False)`. Nothing about the streams reveals that.
135
+
136
+ ## `update` — rebuilds the environment it runs in, so it runs last
137
+
138
+ `update` runs `uv tool install --force`, which rebuilds the virtual environment
139
+ the running interpreter lives in. Replacing a Unix binary leaves a running
140
+ process untouched, because the process holds an inode. Rebuilding a venv pulls
141
+ modules out from under a live interpreter, so anything imported afterwards can
142
+ fail in ways that are hard to read.
143
+
144
+ So make `update` the last thing the process does, then call `exit_now`. Or call
145
+ `update_and_reexec`, which replaces the process with the release it installed,
146
+ running the same command line. Its caller never sees the `Result`, so it writes
147
+ `lock_warning` to stderr itself before the re-exec.
148
+
149
+ `check` reports what `update` would install, and installs nothing:
150
+
151
+ ```python
152
+ from pyselfupdate import Config, check, require_updatable
153
+
154
+ config = Config(tool='mytool', owner='you')
155
+
156
+ require_updatable(config) # reads uv's receipt; raises on an install update refuses
157
+ result = check(config) # one request to the release source
158
+ if result.update_available:
159
+ print(result.current, '->', result.latest)
160
+ ```
161
+
162
+ `check` compares the running version with the newest release, and reads
163
+ nothing else. A branch build's version can rank below the release it is ahead
164
+ of. Three commits past `v1.2.3`, a tool versioned from git tags builds as
165
+ `1.2.3-post.3+<commit>`, which semver ranks below `v1.2.3`. So call
166
+ `require_updatable` before reporting what `check` found. It refuses every
167
+ install `update` refuses.
168
+
169
+ The typer extra carries the whole command. `add_update_command(app, config)`
170
+ registers `mytool update`. `run_update(config, check_only=...)` is the same
171
+ reporting, for a CLI that declares the command itself. Both read the receipt
172
+ before the network, so `mytool update --check` refuses the same installs
173
+ `update` does, printing the refusal and exiting 1. Both fetch the changelog
174
+ before the install, because nothing can be imported after it.
175
+
176
+ A caller that needs its own steps between the check and the install composes
177
+ the three pieces `update` is made of:
178
+
179
+ ```python
180
+ import sys
181
+
182
+ from pyselfupdate import Config, changelog, check, exit_now, install_release, require_updatable
183
+
184
+ config = Config(tool='mytool', owner='you')
185
+
186
+ installation = require_updatable(config) # refuses before any request
187
+ result = check(config) # the network, while the environment is intact
188
+ notes = changelog(config, result.current, result.latest) # fetched before the install, printed after
189
+ installed = install_release(config, result, installation) # a no-op when nothing is newer
190
+ print('\n'.join(notes))
191
+ if installed.lock_warning:
192
+ print(installed.lock_warning, file=sys.stderr)
193
+ exit_now() # flushes, then exits without unwinding through code that may import
194
+ ```
195
+
196
+ Every error is a `SelfUpdateError`, and each cause has its own subclass, so a
197
+ caller matches a type rather than message text:
198
+
199
+ | Error | Raised when |
200
+ | --- | --- |
201
+ | `InvalidConfigError` | `tool` is empty, or `owner` is empty without a custom `source` |
202
+ | `LocalInstallError` | The install is a local checkout, a branch or a commit, or the running version is not a semantic version |
203
+ | `NotInstalledError` | uv's receipt is missing or unreadable, or holds a `--with` requirement an update would drop |
204
+ | `NoReleaseError` | The source publishes no release, or its newest tag is not a semantic version |
205
+ | `SourceError` | The source could not be reached, or answered with something unusable |
206
+ | `LockUnreadableError` | The release's `uv.lock` could not be read, so nothing was installed |
207
+ | `InstallFailedError` | `uv tool install` ran and failed, or uv is not on `PATH` |
208
+
209
+ ### A git install is held to the release's `uv.lock`
210
+
211
+ `uv tool install` never reads a lock. Handed a git requirement, it resolves
212
+ every dependency to its newest version, so a tool would run on a set its CI
213
+ never tested. `update` therefore clones the release's tag and exports its
214
+ `uv.lock`. It passes registry pins as `--constraints`, and URL pins, such as a
215
+ git dependency, as `--overrides`. uv records both in the tool's receipt, so a
216
+ later `uv tool upgrade` stays held to them.
217
+
218
+ | The tag | What `update` does |
219
+ | --- | --- |
220
+ | Carries a `uv.lock` | Installs the locked versions |
221
+ | Carries none | Installs unlocked and sets `lock_missing`. `lock_warning` names the tag, and `run_update` and `update_and_reexec` print it |
222
+ | Will not clone, uv will not export its lock, or git or uv is not on `PATH` | Raises `LockUnreadableError` and installs nothing |
223
+
224
+ An install from an index reads no lock, because a wheel carries none. It
225
+ takes the release as a floor, `mytool>=2.0.0`, rather than a pin. uv records
226
+ the specifier in the receipt, and `uv tool upgrade` refuses to move an exact
227
+ pin.
228
+
229
+ An update reinstalls the tool's own extras, as in `mytool[fast]`, with their
230
+ dependencies held to the lock. Every `--with` and `--with-editable`
231
+ requirement is passed back to uv as well.
232
+
233
+ `uv tool install --force` reads nothing back from the receipt it replaces. So
234
+ an update passes back the receipt's `--python` and the flags naming where its
235
+ packages come from: `--index`, `--default-index`, `--index-url`,
236
+ `--extra-index-url`, `--find-links`, `--no-index`, `--index-strategy` and
237
+ `--keyring-provider`. Without them, a tool installed with
238
+ `--index https://pypi.example.com/simple/` resolves its update against PyPI
239
+ alone and is not found. An `explicit` index is left out, because only a config
240
+ file can declare one and the reinstall reads that file again. Dropping any
241
+ other recorded option changes how the tool resolves or builds, never whether it
242
+ is found.
243
+
244
+ ## What may be updated is read from uv's receipt, not guessed from the version
245
+
246
+ uv writes `uv-receipt.toml` into the tool's directory at install time. It
247
+ records where the tool came from, which the running program cannot know:
248
+
249
+ | Receipt | Result |
250
+ | --- | --- |
251
+ | `git = "...git?rev=v1.2.3"` | Updatable |
252
+ | `name = "mytool"` (from an index) | Updatable |
253
+ | `git = "...git"` with no `rev` | Refused: it tracks the default branch, so its version says nothing about how far behind it is |
254
+ | `git = "...git?rev=main"`, or a `rev` naming a commit | Refused: uv writes `rev=` for whatever followed the `@`, so a `rev` counts as a release only when it is `tag_prefix` followed by a semantic version |
255
+ | `directory`, `path` or `editable` | Refused: reinstalling would discard a working copy |
256
+ | A `--with` requirement no requirements-file line reproduces, such as a relative path | Refused with `NotInstalledError`: the update would drop it |
257
+
258
+ `update` and `mytool update --check` refuse every row marked Refused. The
259
+ notice stays silent on each of them except the `--with` row, because a newer
260
+ release is still worth knowing about there.
261
+
262
+ A tool whose receipt is missing or unreadable fails closed. The notice treats
263
+ it as local, and `update` raises `NotInstalledError`.
264
+
265
+ A caller with a custom `source` whose refs carry a prefix sets `tag_prefix` to
266
+ match. Without it, a tag install from that source is refused as a branch
267
+ install.
268
+
269
+ ## `Config` — the tool's name, and every default derived from it
270
+
271
+ ```python
272
+ from pyselfupdate import Config
273
+
274
+ config = Config(
275
+ tool='mytool', # required: the uv tool name, the state directory, the variable prefix
276
+ owner='you', # GitHub owner; required unless source is given
277
+ repo='mytool', # defaults to tool
278
+ package='mytool', # the distribution the version is read from; defaults to tool
279
+ version='1.2.3', # defaults to that distribution's installed metadata
280
+ token='', # see Authentication below; you almost certainly want the default
281
+ token_func=None, # a source of your own, tried before $GITHUB_TOKEN_COMMAND
282
+ timeout=10.0, # seconds, per request
283
+ tag_prefix='', # 'cli/' for tags like cli/v1.2.3
284
+ allow_prerelease=False,
285
+ source=None, # a custom Source; defaults to a GitHubSource built from the fields above
286
+ )
287
+ ```
288
+
289
+ `source` is the seam for releases published somewhere other than GitHub. A
290
+ `Source` is anything with `latest_release()` returning a `Release`. A source
291
+ that also has `changelog(from_ref, to_ref)`, returning commit subjects, gets a
292
+ changelog in `mytool update`. One without it gets none, and that is not an
293
+ error.
294
+
295
+ ## Authentication — on by default, through `gh auth token`
296
+
297
+ **Configure nothing.** `GitHubSource` runs `gh auth token` when a request is
298
+ about to be made, and sends what it prints.
299
+
300
+ The alternative is not "no credential". It is 60 requests an hour, charged
301
+ **per IP address** and shared with every other anonymous caller behind the same
302
+ egress. A default that has to be opted into is a default nobody sets.
303
+
304
+ Four sources, first non-empty wins:
305
+
306
+ | Source | Set by | Default |
307
+ | --- | --- | --- |
308
+ | `Config.token` | you, in code | unset |
309
+ | `$GITHUB_TOKEN`, then `$GH_TOKEN` | whoever runs your CLI | unset |
310
+ | `token_func()` | you, in code | unset |
311
+ | `$GITHUB_TOKEN_COMMAND` | whoever runs your CLI | `gh auth token` |
312
+
313
+ `$GITHUB_TOKEN_COMMAND` both redirects and disables, which is what a switch has
314
+ to do to be worth having:
315
+
316
+ ```bash
317
+ GITHUB_TOKEN_COMMAND='pass show github/token' # use this instead
318
+ GITHUB_TOKEN_COMMAND='op read op://vault/gh/token'
319
+ GITHUB_TOKEN_COMMAND='' # run nothing, stay anonymous
320
+ ```
321
+
322
+ It never raises. A command that is not installed, exits non-zero, or takes
323
+ longer than ten seconds degrades to an unauthenticated request, which still
324
+ works against a public repository.
325
+
326
+ `token_func` is for a credential neither the environment nor a command can
327
+ produce. It is called lazily, for the same reason the command is. The notify
328
+ gate resolves a `Config` on every invocation and declines most of them without
329
+ reaching the network, and a subprocess in front of that gate is the entire
330
+ cost worth avoiding.
331
+
332
+ **The variable names and the default command belong to `GitHubSource`, not to
333
+ `Config`.** A credential is the host's business. A `Source` for another code
334
+ host brings its own variable and its own command, and nothing above the
335
+ `Source` protocol learns either name.
336
+
337
+ ## State — one file per tool per machine, stamped before the network call
338
+
339
+ `${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate-<machine>.json`, written
340
+ atomically:
341
+
342
+ ```json
343
+ {
344
+ "tool": "mytool",
345
+ "schema": 1,
346
+ "checked_at": "2026-07-26T15:07:15Z",
347
+ "checked_at_epoch": 1785078435,
348
+ "current_version": "v1.3.2",
349
+ "latest_version": "v1.4.0",
350
+ "last_error": ""
351
+ }
352
+ ```
353
+
354
+ State, not config and not cache: it persists across runs, it is not authored by
355
+ the user, and deleting it changes behavior rather than merely costing a
356
+ recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
357
+ is where `gh` puts the same thing.
358
+
359
+ `<machine>` is the bare hostname, domain dropped and lowercased, from
360
+ `pyselfupdate.state.machine()`. It is `unknown` when the hostname cannot be
361
+ read. It is in the name because both fields that vary describe one machine:
362
+ the version installed here, and the instant this box last checked. A state
363
+ directory shared between machines, by a file syncer or a network home
364
+ directory, otherwise has two writers on one path. It reports whichever wrote
365
+ last as the state of all of them.
366
+
367
+ The timestamp is written **before** the network call. `gh` stamps only on
368
+ success, so a rate-limited or offline user re-hits the API on every invocation
369
+ until the window resets. An interval exists to bound the request rate, and only
370
+ this ordering does that.
371
+
372
+ A gate that declines to check writes nothing, so a missing file means the
373
+ network was never reached. A check that failed leaves its error in
374
+ `last_error`. `read_state('mytool')` reads the file back.
375
+
376
+ ## Siblings — the same two layers for a Go binary and a git checkout
377
+
378
+ All three carry both layers. They share the `autoupdate-<machine>.json` schema,
379
+ the machine derivation that names it, and the `NO_AUTO_UPDATE` and
380
+ `AUTO_UPDATE_INTERVAL` variables. They do not share an API, because "update"
381
+ means a different operation in each.
382
+
383
+ - [goselfupdate](https://github.com/datapointchris/goselfupdate) — replaces a Go binary
384
+ - [bashselfupdate](https://github.com/datapointchris/bashselfupdate) — moves a git checkout to its newest tag
385
+
386
+ Version precedence is deliberately identical across all three, so a tool and
387
+ its siblings never disagree about which release is newer.
388
+
389
+ ## License
390
+
391
+ MIT