pyselfupdate 0.1.0__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.
- pyselfupdate-0.1.0/PKG-INFO +203 -0
- pyselfupdate-0.1.0/README.md +179 -0
- pyselfupdate-0.1.0/pyproject.toml +144 -0
- pyselfupdate-0.1.0/src/pyselfupdate/__init__.py +72 -0
- pyselfupdate-0.1.0/src/pyselfupdate/config.py +100 -0
- pyselfupdate-0.1.0/src/pyselfupdate/errors.py +39 -0
- pyselfupdate-0.1.0/src/pyselfupdate/github.py +182 -0
- pyselfupdate-0.1.0/src/pyselfupdate/install.py +176 -0
- pyselfupdate-0.1.0/src/pyselfupdate/notifier.py +261 -0
- pyselfupdate-0.1.0/src/pyselfupdate/py.typed +0 -0
- pyselfupdate-0.1.0/src/pyselfupdate/source.py +51 -0
- pyselfupdate-0.1.0/src/pyselfupdate/state.py +103 -0
- pyselfupdate-0.1.0/src/pyselfupdate/typercmd.py +86 -0
- pyselfupdate-0.1.0/src/pyselfupdate/updater.py +132 -0
- pyselfupdate-0.1.0/src/pyselfupdate/version.py +176 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pyselfupdate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-update and update notification for Python CLIs installed with uv tool
|
|
5
|
+
Keywords: uv,cli,self-update,update-notifier,release
|
|
6
|
+
Author: Chris Birch
|
|
7
|
+
Author-email: Chris Birch <datapointchris@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Topic :: System :: Software Distribution
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: typer>=0.12.0 ; extra == 'typer'
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Project-URL: Repository, https://github.com/datapointchris/pyselfupdate
|
|
20
|
+
Project-URL: Issues, https://github.com/datapointchris/pyselfupdate/issues
|
|
21
|
+
Project-URL: Changelog, https://github.com/datapointchris/pyselfupdate/blob/main/CHANGELOG.md
|
|
22
|
+
Provides-Extra: typer
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pyselfupdate
|
|
26
|
+
|
|
27
|
+
Self-update and update notification for Python CLIs installed with `uv tool`.
|
|
28
|
+
|
|
29
|
+
Two things, used independently: tell the user once a day that a newer release
|
|
30
|
+
exists, and install it when they ask. No runtime dependencies.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from pyselfupdate import Config, notify, update
|
|
34
|
+
|
|
35
|
+
config = Config(tool='mytool', owner='you')
|
|
36
|
+
|
|
37
|
+
notify(config) # once a day, one line if behind. Never raises.
|
|
38
|
+
update(config) # install the latest release. Raises on failure.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv add pyselfupdate
|
|
45
|
+
|
|
46
|
+
# with the ready-made typer command
|
|
47
|
+
uv add "pyselfupdate[typer]"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires Python 3.11+.
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
A CLI distributed with `uv tool install` has no way to tell its user a newer
|
|
55
|
+
version exists, so it silently drifts. The usual fix drags an HTTP client, a
|
|
56
|
+
TOML parser and a version library into a tool that had none of them.
|
|
57
|
+
|
|
58
|
+
This package has zero runtime dependencies — `urllib` for the network,
|
|
59
|
+
`tomllib` for uv's receipt, and its own semver implementation — and CI enforces
|
|
60
|
+
that by importing every module into a virtual environment containing nothing
|
|
61
|
+
else.
|
|
62
|
+
|
|
63
|
+
## notify
|
|
64
|
+
|
|
65
|
+
Put it in your CLI's root callback and ignore the result:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import typer
|
|
69
|
+
from pyselfupdate import Config, notify
|
|
70
|
+
|
|
71
|
+
app = typer.Typer()
|
|
72
|
+
CONFIG = Config(tool='mytool', owner='you')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.callback()
|
|
76
|
+
def main() -> None:
|
|
77
|
+
notify(CONFIG)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Once per 24 hours, if a newer release exists, one line goes to stderr **after**
|
|
81
|
+
your command's own output:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
mytool v1.4.0 available (running v1.3.2) — run `mytool update`
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It never raises, never installs anything, and never prints an error. A failed
|
|
88
|
+
check is recorded in the state file and swallowed, because an update notice
|
|
89
|
+
must not be able to break the command the user actually typed.
|
|
90
|
+
|
|
91
|
+
Nothing is printed when any of these hold:
|
|
92
|
+
|
|
93
|
+
| Condition | Why |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `NO_AUTO_UPDATE` or `MYTOOL_NO_AUTO_UPDATE` is set | Opted out |
|
|
96
|
+
| `CI`, `BUILD_NUMBER`, `RUN_ID`, `GITHUB_ACTIONS`, `CODESPACES` | Not a human |
|
|
97
|
+
| stdout or stderr is not a terminal | `mytool list > out 2>&1` must stay clean |
|
|
98
|
+
| Installed from a local path, an editable checkout, or a branch | Nothing to compare against |
|
|
99
|
+
| Checked within the interval | One request per day, not per invocation |
|
|
100
|
+
|
|
101
|
+
Presence-only, any value: `NO_AUTO_UPDATE=0` disables it, the same way
|
|
102
|
+
[`NO_COLOR`](https://no-color.org) works. Set the interval separately with
|
|
103
|
+
`AUTO_UPDATE_INTERVAL=6h` or `MYTOOL_AUTO_UPDATE_INTERVAL=30m`.
|
|
104
|
+
|
|
105
|
+
## update
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from pyselfupdate import Config, check, update
|
|
109
|
+
|
|
110
|
+
result = check(config) # no filesystem, no install
|
|
111
|
+
if result.update_available:
|
|
112
|
+
print(result.current, '->', result.latest)
|
|
113
|
+
|
|
114
|
+
result = update(config) # installs, raises on failure
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or take the whole command:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from pyselfupdate.typercmd import add_update_command
|
|
121
|
+
|
|
122
|
+
add_update_command(app, CONFIG) # gives you `mytool update [--check]`
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`update` runs `uv tool install --force`, which **rebuilds the virtual
|
|
126
|
+
environment the running interpreter lives in**. Unlike replacing a Unix binary
|
|
127
|
+
— where the process holds an inode and is untouched — this pulls modules out
|
|
128
|
+
from under a live process, so anything imported afterwards may fail in ways
|
|
129
|
+
that are hard to read. Make it the last thing your process does, or use
|
|
130
|
+
`update_and_reexec` to replace the process with the new version immediately.
|
|
131
|
+
|
|
132
|
+
## What will not be updated
|
|
133
|
+
|
|
134
|
+
Read from uv's own receipt, written at install time, rather than guessed at
|
|
135
|
+
runtime:
|
|
136
|
+
|
|
137
|
+
| Receipt | Result |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| `git = "...git?rev=v1.2.3"` | Updatable |
|
|
140
|
+
| `name = "mytool"` (from an index) | Updatable |
|
|
141
|
+
| `git = "...git"` with no `rev` | Refused — tracks a branch, so its version says nothing about how far behind it is |
|
|
142
|
+
| `directory` / `path` / `editable` | Refused — reinstalling would discard a working copy |
|
|
143
|
+
|
|
144
|
+
A tool that cannot be identified at all is treated as local and left alone.
|
|
145
|
+
|
|
146
|
+
## Configuration
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
Config(
|
|
150
|
+
tool='mytool', # required: uv tool name, state dir, env prefix
|
|
151
|
+
owner='you', # GitHub owner
|
|
152
|
+
repo='mytool', # defaults to tool
|
|
153
|
+
package='mytool', # distribution name, defaults to tool
|
|
154
|
+
version='1.2.3', # defaults to the installed distribution's metadata
|
|
155
|
+
token='', # defaults to $GITHUB_TOKEN, then $GH_TOKEN
|
|
156
|
+
tag_prefix='', # e.g. 'cli/' for tags like cli/v1.2.3
|
|
157
|
+
allow_prerelease=False,
|
|
158
|
+
source=None, # a custom Source; anything with latest_release()
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Without a token, GitHub allows 60 API requests per hour per IP and rejects
|
|
163
|
+
private repositories outright. One check per day per tool is far inside that; a
|
|
164
|
+
shared egress address is not.
|
|
165
|
+
|
|
166
|
+
## State
|
|
167
|
+
|
|
168
|
+
`${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate.json`, written atomically:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"schema": 1,
|
|
173
|
+
"tool": "mytool",
|
|
174
|
+
"checked_at": "2026-07-26T15:07:15Z",
|
|
175
|
+
"checked_at_epoch": 1785078435,
|
|
176
|
+
"current_version": "v1.3.2",
|
|
177
|
+
"latest_version": "v1.4.0",
|
|
178
|
+
"last_error": "",
|
|
179
|
+
"skip": ""
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
State, not config and not cache: it persists across runs, it is not authored by
|
|
184
|
+
the user, and deleting it changes behaviour rather than merely costing a
|
|
185
|
+
recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
|
|
186
|
+
is where `gh` puts the same thing.
|
|
187
|
+
|
|
188
|
+
The timestamp is written **before** the network call. `gh` stamps only on
|
|
189
|
+
success, so a rate-limited or offline user re-hits the API on every invocation
|
|
190
|
+
until the window resets; an interval exists to bound the request rate, and only
|
|
191
|
+
this ordering actually does that.
|
|
192
|
+
|
|
193
|
+
## Siblings
|
|
194
|
+
|
|
195
|
+
The same design in two other languages, sharing the state schema and the
|
|
196
|
+
environment-variable contract:
|
|
197
|
+
|
|
198
|
+
- [goselfupdate](https://github.com/datapointchris/goselfupdate) — replaces a Go binary
|
|
199
|
+
- [bashselfupdate](https://github.com/datapointchris/bashselfupdate) — moves a git checkout to its newest tag
|
|
200
|
+
|
|
201
|
+
## Licence
|
|
202
|
+
|
|
203
|
+
MIT
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# pyselfupdate
|
|
2
|
+
|
|
3
|
+
Self-update and update notification for Python CLIs installed with `uv tool`.
|
|
4
|
+
|
|
5
|
+
Two things, used independently: tell the user once a day that a newer release
|
|
6
|
+
exists, and install it when they ask. No runtime dependencies.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from pyselfupdate import Config, notify, update
|
|
10
|
+
|
|
11
|
+
config = Config(tool='mytool', owner='you')
|
|
12
|
+
|
|
13
|
+
notify(config) # once a day, one line if behind. Never raises.
|
|
14
|
+
update(config) # install the latest release. Raises on failure.
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv add pyselfupdate
|
|
21
|
+
|
|
22
|
+
# with the ready-made typer command
|
|
23
|
+
uv add "pyselfupdate[typer]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires Python 3.11+.
|
|
27
|
+
|
|
28
|
+
## Why
|
|
29
|
+
|
|
30
|
+
A CLI distributed with `uv tool install` has no way to tell its user a newer
|
|
31
|
+
version exists, so it silently drifts. The usual fix drags an HTTP client, a
|
|
32
|
+
TOML parser and a version library into a tool that had none of them.
|
|
33
|
+
|
|
34
|
+
This package has zero runtime dependencies — `urllib` for the network,
|
|
35
|
+
`tomllib` for uv's receipt, and its own semver implementation — and CI enforces
|
|
36
|
+
that by importing every module into a virtual environment containing nothing
|
|
37
|
+
else.
|
|
38
|
+
|
|
39
|
+
## notify
|
|
40
|
+
|
|
41
|
+
Put it in your CLI's root callback and ignore the result:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import typer
|
|
45
|
+
from pyselfupdate import Config, notify
|
|
46
|
+
|
|
47
|
+
app = typer.Typer()
|
|
48
|
+
CONFIG = Config(tool='mytool', owner='you')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.callback()
|
|
52
|
+
def main() -> None:
|
|
53
|
+
notify(CONFIG)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Once per 24 hours, if a newer release exists, one line goes to stderr **after**
|
|
57
|
+
your command's own output:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
mytool v1.4.0 available (running v1.3.2) — run `mytool update`
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
It never raises, never installs anything, and never prints an error. A failed
|
|
64
|
+
check is recorded in the state file and swallowed, because an update notice
|
|
65
|
+
must not be able to break the command the user actually typed.
|
|
66
|
+
|
|
67
|
+
Nothing is printed when any of these hold:
|
|
68
|
+
|
|
69
|
+
| Condition | Why |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `NO_AUTO_UPDATE` or `MYTOOL_NO_AUTO_UPDATE` is set | Opted out |
|
|
72
|
+
| `CI`, `BUILD_NUMBER`, `RUN_ID`, `GITHUB_ACTIONS`, `CODESPACES` | Not a human |
|
|
73
|
+
| stdout or stderr is not a terminal | `mytool list > out 2>&1` must stay clean |
|
|
74
|
+
| Installed from a local path, an editable checkout, or a branch | Nothing to compare against |
|
|
75
|
+
| Checked within the interval | One request per day, not per invocation |
|
|
76
|
+
|
|
77
|
+
Presence-only, any value: `NO_AUTO_UPDATE=0` disables it, the same way
|
|
78
|
+
[`NO_COLOR`](https://no-color.org) works. Set the interval separately with
|
|
79
|
+
`AUTO_UPDATE_INTERVAL=6h` or `MYTOOL_AUTO_UPDATE_INTERVAL=30m`.
|
|
80
|
+
|
|
81
|
+
## update
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from pyselfupdate import Config, check, update
|
|
85
|
+
|
|
86
|
+
result = check(config) # no filesystem, no install
|
|
87
|
+
if result.update_available:
|
|
88
|
+
print(result.current, '->', result.latest)
|
|
89
|
+
|
|
90
|
+
result = update(config) # installs, raises on failure
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or take the whole command:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from pyselfupdate.typercmd import add_update_command
|
|
97
|
+
|
|
98
|
+
add_update_command(app, CONFIG) # gives you `mytool update [--check]`
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`update` runs `uv tool install --force`, which **rebuilds the virtual
|
|
102
|
+
environment the running interpreter lives in**. Unlike replacing a Unix binary
|
|
103
|
+
— where the process holds an inode and is untouched — this pulls modules out
|
|
104
|
+
from under a live process, so anything imported afterwards may fail in ways
|
|
105
|
+
that are hard to read. Make it the last thing your process does, or use
|
|
106
|
+
`update_and_reexec` to replace the process with the new version immediately.
|
|
107
|
+
|
|
108
|
+
## What will not be updated
|
|
109
|
+
|
|
110
|
+
Read from uv's own receipt, written at install time, rather than guessed at
|
|
111
|
+
runtime:
|
|
112
|
+
|
|
113
|
+
| Receipt | Result |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `git = "...git?rev=v1.2.3"` | Updatable |
|
|
116
|
+
| `name = "mytool"` (from an index) | Updatable |
|
|
117
|
+
| `git = "...git"` with no `rev` | Refused — tracks a branch, so its version says nothing about how far behind it is |
|
|
118
|
+
| `directory` / `path` / `editable` | Refused — reinstalling would discard a working copy |
|
|
119
|
+
|
|
120
|
+
A tool that cannot be identified at all is treated as local and left alone.
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
Config(
|
|
126
|
+
tool='mytool', # required: uv tool name, state dir, env prefix
|
|
127
|
+
owner='you', # GitHub owner
|
|
128
|
+
repo='mytool', # defaults to tool
|
|
129
|
+
package='mytool', # distribution name, defaults to tool
|
|
130
|
+
version='1.2.3', # defaults to the installed distribution's metadata
|
|
131
|
+
token='', # defaults to $GITHUB_TOKEN, then $GH_TOKEN
|
|
132
|
+
tag_prefix='', # e.g. 'cli/' for tags like cli/v1.2.3
|
|
133
|
+
allow_prerelease=False,
|
|
134
|
+
source=None, # a custom Source; anything with latest_release()
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Without a token, GitHub allows 60 API requests per hour per IP and rejects
|
|
139
|
+
private repositories outright. One check per day per tool is far inside that; a
|
|
140
|
+
shared egress address is not.
|
|
141
|
+
|
|
142
|
+
## State
|
|
143
|
+
|
|
144
|
+
`${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate.json`, written atomically:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"schema": 1,
|
|
149
|
+
"tool": "mytool",
|
|
150
|
+
"checked_at": "2026-07-26T15:07:15Z",
|
|
151
|
+
"checked_at_epoch": 1785078435,
|
|
152
|
+
"current_version": "v1.3.2",
|
|
153
|
+
"latest_version": "v1.4.0",
|
|
154
|
+
"last_error": "",
|
|
155
|
+
"skip": ""
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
State, not config and not cache: it persists across runs, it is not authored by
|
|
160
|
+
the user, and deleting it changes behaviour rather than merely costing a
|
|
161
|
+
recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
|
|
162
|
+
is where `gh` puts the same thing.
|
|
163
|
+
|
|
164
|
+
The timestamp is written **before** the network call. `gh` stamps only on
|
|
165
|
+
success, so a rate-limited or offline user re-hits the API on every invocation
|
|
166
|
+
until the window resets; an interval exists to bound the request rate, and only
|
|
167
|
+
this ordering actually does that.
|
|
168
|
+
|
|
169
|
+
## Siblings
|
|
170
|
+
|
|
171
|
+
The same design in two other languages, sharing the state schema and the
|
|
172
|
+
environment-variable contract:
|
|
173
|
+
|
|
174
|
+
- [goselfupdate](https://github.com/datapointchris/goselfupdate) — replaces a Go binary
|
|
175
|
+
- [bashselfupdate](https://github.com/datapointchris/bashselfupdate) — moves a git checkout to its newest tag
|
|
176
|
+
|
|
177
|
+
## Licence
|
|
178
|
+
|
|
179
|
+
MIT
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyselfupdate"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Self-update and update notification for Python CLIs installed with uv tool"
|
|
5
|
+
authors = [{ name = "Chris Birch", email = "datapointchris@gmail.com" }]
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
keywords = ["uv", "cli", "self-update", "update-notifier", "release"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
15
|
+
"Topic :: Software Development :: Libraries",
|
|
16
|
+
"Topic :: System :: Software Distribution",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# The floor is deliberately below the 3.13 the author's own tools use. Raising
|
|
21
|
+
# it excludes callers and needs a reason beyond convenience. 3.11 is what
|
|
22
|
+
# `tomllib` requires, and reading uv's receipt without a TOML dependency is what
|
|
23
|
+
# keeps this package dependency-free.
|
|
24
|
+
requires-python = ">=3.11"
|
|
25
|
+
|
|
26
|
+
# Empty, and enforced empty by CI. This is the package's main differentiator:
|
|
27
|
+
# adding an update notice to a CLI should not drag an HTTP client, a TOML parser
|
|
28
|
+
# and a version library into its dependency tree.
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
typer = ["typer>=0.12.0"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/datapointchris/pyselfupdate"
|
|
36
|
+
Issues = "https://github.com/datapointchris/pyselfupdate/issues"
|
|
37
|
+
Changelog = "https://github.com/datapointchris/pyselfupdate/blob/main/CHANGELOG.md"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = [
|
|
41
|
+
"bandit>=1.7.8",
|
|
42
|
+
"mypy>=1.10.0",
|
|
43
|
+
"pre-commit>=4.3.0",
|
|
44
|
+
"pytest>=8.0.0",
|
|
45
|
+
"ruff>=0.7.0",
|
|
46
|
+
"typer>=0.12.0",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------- Tool Configurations ---------- #
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
[tool.bandit]
|
|
54
|
+
exclude_dirs = [".git", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".venv", "tests"]
|
|
55
|
+
skips = ["B311", "B404", "B603"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
[tool.codespell]
|
|
59
|
+
skip = '*.lock'
|
|
60
|
+
check-filenames = true
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
[tool.mypy]
|
|
64
|
+
pretty = true
|
|
65
|
+
ignore_missing_imports = true
|
|
66
|
+
check_untyped_defs = false
|
|
67
|
+
warn_return_any = false
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
[tool.pytest.ini_options]
|
|
71
|
+
addopts = "-vv"
|
|
72
|
+
minversion = "8.0"
|
|
73
|
+
testpaths = ["tests"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
[tool.refurb]
|
|
77
|
+
enable_all = true
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
line-length = 140
|
|
82
|
+
exclude = [
|
|
83
|
+
".git",
|
|
84
|
+
"__pycache__",
|
|
85
|
+
".mypy_cache",
|
|
86
|
+
".ruff_cache",
|
|
87
|
+
".vscode",
|
|
88
|
+
".venv",
|
|
89
|
+
"build",
|
|
90
|
+
"dist",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
[tool.ruff.format]
|
|
94
|
+
quote-style = "single"
|
|
95
|
+
indent-style = "space"
|
|
96
|
+
skip-magic-trailing-comma = false
|
|
97
|
+
line-ending = "auto"
|
|
98
|
+
docstring-code-format = true
|
|
99
|
+
|
|
100
|
+
[tool.ruff.lint]
|
|
101
|
+
select = [
|
|
102
|
+
# pycodestyle
|
|
103
|
+
"E",
|
|
104
|
+
# Pyflakes
|
|
105
|
+
"F",
|
|
106
|
+
# pyupgrade
|
|
107
|
+
"UP",
|
|
108
|
+
# flake8-bugbear
|
|
109
|
+
"B",
|
|
110
|
+
# flake8-simplify
|
|
111
|
+
"SIM",
|
|
112
|
+
# isort
|
|
113
|
+
"I",
|
|
114
|
+
]
|
|
115
|
+
ignore = ["SIM108"]
|
|
116
|
+
|
|
117
|
+
[tool.ruff.lint.isort]
|
|
118
|
+
force-single-line = true
|
|
119
|
+
|
|
120
|
+
[tool.ruff.lint.per-file-ignores]
|
|
121
|
+
"__init__.py" = ["F401"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
[tool.semantic_release]
|
|
125
|
+
version_toml = ["pyproject.toml:project.version"]
|
|
126
|
+
branch = "main"
|
|
127
|
+
commit_message = "build(release): {version}"
|
|
128
|
+
build_command = """
|
|
129
|
+
pip install uv
|
|
130
|
+
uv lock --upgrade-package pyselfupdate
|
|
131
|
+
git add uv.lock
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------- Build System ---------- #
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
[build-system]
|
|
139
|
+
requires = ["uv_build>=0.11.32,<0.12.0"]
|
|
140
|
+
build-backend = "uv_build"
|
|
141
|
+
|
|
142
|
+
[tool.uv.build-backend]
|
|
143
|
+
module-name = "pyselfupdate"
|
|
144
|
+
module-root = "src"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Self-update and update notification for tools installed with `uv tool`.
|
|
2
|
+
|
|
3
|
+
Two layers, used independently:
|
|
4
|
+
|
|
5
|
+
from pyselfupdate import Config, notify, update
|
|
6
|
+
|
|
7
|
+
config = Config(tool='syncer', owner='datapointchris')
|
|
8
|
+
|
|
9
|
+
notify(config) # once a day, print one line if behind. Never raises.
|
|
10
|
+
update(config) # install the latest release. Raises on failure.
|
|
11
|
+
|
|
12
|
+
`notify` belongs in a CLI's root callback and its result should be ignored.
|
|
13
|
+
`update` belongs behind an explicit `<tool> update` command, which is the only
|
|
14
|
+
place update failures are ever reported.
|
|
15
|
+
|
|
16
|
+
The package has no third-party dependencies. The optional typer integration
|
|
17
|
+
lives in `pyselfupdate.typercmd` and is installed with the `typer` extra.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from pyselfupdate.config import Config
|
|
21
|
+
from pyselfupdate.errors import InstallFailedError
|
|
22
|
+
from pyselfupdate.errors import InvalidConfigError
|
|
23
|
+
from pyselfupdate.errors import LocalInstallError
|
|
24
|
+
from pyselfupdate.errors import NoReleaseError
|
|
25
|
+
from pyselfupdate.errors import NotInstalledError
|
|
26
|
+
from pyselfupdate.errors import SelfUpdateError
|
|
27
|
+
from pyselfupdate.errors import SourceError
|
|
28
|
+
from pyselfupdate.github import GitHubSource
|
|
29
|
+
from pyselfupdate.install import Installation
|
|
30
|
+
from pyselfupdate.install import InstallKind
|
|
31
|
+
from pyselfupdate.install import read_installation
|
|
32
|
+
from pyselfupdate.notifier import Outcome
|
|
33
|
+
from pyselfupdate.notifier import Skip
|
|
34
|
+
from pyselfupdate.notifier import enabled
|
|
35
|
+
from pyselfupdate.notifier import notify
|
|
36
|
+
from pyselfupdate.source import Release
|
|
37
|
+
from pyselfupdate.source import Source
|
|
38
|
+
from pyselfupdate.state import State
|
|
39
|
+
from pyselfupdate.state import read as read_state
|
|
40
|
+
from pyselfupdate.updater import Result
|
|
41
|
+
from pyselfupdate.updater import changelog
|
|
42
|
+
from pyselfupdate.updater import check
|
|
43
|
+
from pyselfupdate.updater import update
|
|
44
|
+
from pyselfupdate.updater import update_and_reexec
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
'Config',
|
|
48
|
+
'GitHubSource',
|
|
49
|
+
'InstallFailedError',
|
|
50
|
+
'InstallKind',
|
|
51
|
+
'Installation',
|
|
52
|
+
'InvalidConfigError',
|
|
53
|
+
'LocalInstallError',
|
|
54
|
+
'NoReleaseError',
|
|
55
|
+
'NotInstalledError',
|
|
56
|
+
'Outcome',
|
|
57
|
+
'Release',
|
|
58
|
+
'Result',
|
|
59
|
+
'SelfUpdateError',
|
|
60
|
+
'Skip',
|
|
61
|
+
'Source',
|
|
62
|
+
'SourceError',
|
|
63
|
+
'State',
|
|
64
|
+
'changelog',
|
|
65
|
+
'check',
|
|
66
|
+
'enabled',
|
|
67
|
+
'notify',
|
|
68
|
+
'read_installation',
|
|
69
|
+
'read_state',
|
|
70
|
+
'update',
|
|
71
|
+
'update_and_reexec',
|
|
72
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""What to update, and how to reach it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import field
|
|
7
|
+
|
|
8
|
+
from pyselfupdate.errors import InvalidConfigError
|
|
9
|
+
from pyselfupdate.github import GitHubSource
|
|
10
|
+
from pyselfupdate.install import current_version
|
|
11
|
+
from pyselfupdate.source import Source
|
|
12
|
+
|
|
13
|
+
DEFAULT_TIMEOUT = 10.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Config:
|
|
18
|
+
"""Describes one updatable tool.
|
|
19
|
+
|
|
20
|
+
`tool` is the only required field. Everything else either has a working
|
|
21
|
+
default or is derived from it, so the common case is `Config(tool='syncer',
|
|
22
|
+
owner='datapointchris')`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# The uv tool name. Names the receipt directory, the entry point, and the
|
|
26
|
+
# state file, and is what appears in messages.
|
|
27
|
+
tool: str
|
|
28
|
+
|
|
29
|
+
owner: str = ''
|
|
30
|
+
repo: str = ''
|
|
31
|
+
|
|
32
|
+
# The distribution name to read the running version from, when it differs
|
|
33
|
+
# from the tool name. Defaults to `tool`.
|
|
34
|
+
package: str = ''
|
|
35
|
+
|
|
36
|
+
# The running version. Defaults to the installed distribution's metadata,
|
|
37
|
+
# which is correct for anything installed as a uv tool.
|
|
38
|
+
version: str = ''
|
|
39
|
+
|
|
40
|
+
token: str = ''
|
|
41
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
42
|
+
allow_prerelease: bool = False
|
|
43
|
+
|
|
44
|
+
# Selects one release stream in a repository publishing several, as in
|
|
45
|
+
# "cli/" for tags of the form cli/v1.2.3. Configures the default GitHub
|
|
46
|
+
# source and is unused when `source` is supplied.
|
|
47
|
+
tag_prefix: str = ''
|
|
48
|
+
|
|
49
|
+
# Locates releases. Defaults to a GitHubSource built from the fields above.
|
|
50
|
+
source: Source | None = None
|
|
51
|
+
|
|
52
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def require_source(self) -> Source:
|
|
55
|
+
"""The resolved source.
|
|
56
|
+
|
|
57
|
+
`resolved()` always populates `source`, but the field stays optional so
|
|
58
|
+
that constructing a Config does not require one. This is the accessor
|
|
59
|
+
that expresses "past this point it is set", rather than each caller
|
|
60
|
+
asserting it.
|
|
61
|
+
"""
|
|
62
|
+
if self.source is None:
|
|
63
|
+
raise InvalidConfigError('config was not resolved before use')
|
|
64
|
+
return self.source
|
|
65
|
+
|
|
66
|
+
def resolved(self) -> Config:
|
|
67
|
+
"""A copy with every default filled in, so callers can assume they are set."""
|
|
68
|
+
if not self.tool:
|
|
69
|
+
raise InvalidConfigError('tool is required')
|
|
70
|
+
|
|
71
|
+
source = self.source
|
|
72
|
+
owner = self.owner
|
|
73
|
+
repo = self.repo or self.tool
|
|
74
|
+
|
|
75
|
+
if source is None:
|
|
76
|
+
if not owner:
|
|
77
|
+
raise InvalidConfigError('owner is required without a custom source')
|
|
78
|
+
source = GitHubSource(
|
|
79
|
+
owner=owner,
|
|
80
|
+
repo=repo,
|
|
81
|
+
token=self.token,
|
|
82
|
+
timeout=self.timeout,
|
|
83
|
+
allow_prerelease=self.allow_prerelease,
|
|
84
|
+
tag_prefix=self.tag_prefix,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
package = self.package or self.tool
|
|
88
|
+
return Config(
|
|
89
|
+
tool=self.tool,
|
|
90
|
+
owner=owner,
|
|
91
|
+
repo=repo,
|
|
92
|
+
package=package,
|
|
93
|
+
version=self.version or current_version(package),
|
|
94
|
+
token=self.token,
|
|
95
|
+
timeout=self.timeout,
|
|
96
|
+
allow_prerelease=self.allow_prerelease,
|
|
97
|
+
tag_prefix=self.tag_prefix,
|
|
98
|
+
source=source,
|
|
99
|
+
metadata=self.metadata.copy(),
|
|
100
|
+
)
|