pyselfupdate 0.4.1__tar.gz → 0.5.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.5.0/LICENSE +21 -0
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/PKG-INFO +36 -11
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/README.md +32 -7
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/pyproject.toml +54 -8
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/pyproject.toml.orig +23 -6
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/__init__.py +2 -0
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/errors.py +14 -1
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/github.py +12 -7
- pyselfupdate-0.5.0/src/pyselfupdate/install.py +426 -0
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/notifier.py +8 -8
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/state.py +3 -4
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/typercmd.py +3 -1
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/updater.py +36 -3
- pyselfupdate-0.4.1/src/pyselfupdate/install.py +0 -195
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/config.py +0 -0
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/py.typed +0 -0
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/source.py +0 -0
- {pyselfupdate-0.4.1 → pyselfupdate-0.5.0}/src/pyselfupdate/version.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chris Birch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyselfupdate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Self-update and update notification for Python CLIs installed with uv tool
|
|
5
5
|
Keywords: uv,cli,self-update,update-notifier,release
|
|
6
6
|
Author: Chris Birch
|
|
7
7
|
Author-email: Chris Birch <datapointchris@gmail.com>
|
|
8
|
-
License: MIT
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
9
10
|
Classifier: Development Status :: 4 - Beta
|
|
10
11
|
Classifier: Environment :: Console
|
|
11
12
|
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
13
13
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
14
|
Classifier: Topic :: Software Development :: Libraries
|
|
15
15
|
Classifier: Topic :: System :: Software Distribution
|
|
@@ -56,9 +56,9 @@ version exists, so it silently drifts. The usual fix drags an HTTP client, a
|
|
|
56
56
|
TOML parser and a version library into a tool that had none of them.
|
|
57
57
|
|
|
58
58
|
This package has zero runtime dependencies — `urllib` for the network,
|
|
59
|
-
`tomllib` for uv's receipt, and its own semver implementation — and
|
|
60
|
-
that by importing every module into a virtual environment
|
|
61
|
-
else.
|
|
59
|
+
`tomllib` for uv's receipt and lock, and its own semver implementation — and
|
|
60
|
+
CI enforces that by importing every module into a virtual environment
|
|
61
|
+
containing nothing else.
|
|
62
62
|
|
|
63
63
|
## notify
|
|
64
64
|
|
|
@@ -128,7 +128,8 @@ environment the running interpreter lives in**. Unlike replacing a Unix binary
|
|
|
128
128
|
from under a live process, so anything imported afterwards may fail in ways
|
|
129
129
|
that are hard to read. Make it the last thing your process does, then call
|
|
130
130
|
`exit_now` — or use `update_and_reexec` to replace the process with the new
|
|
131
|
-
version immediately.
|
|
131
|
+
version immediately. Its caller never sees the `Result`, so it writes
|
|
132
|
+
`lock_warning` to stderr itself before the re-exec.
|
|
132
133
|
|
|
133
134
|
That cuts both ways: anything you want to *print* after the install has to be
|
|
134
135
|
fetched before it. `run_update` resolves its changelog first for exactly this
|
|
@@ -139,11 +140,34 @@ pieces `update` is made of rather than working around it:
|
|
|
139
140
|
installation = require_updatable(config) # refuses a checkout, costs nothing
|
|
140
141
|
result = check(config) # network, environment still intact
|
|
141
142
|
notes = changelog(config, result.current, result.latest)
|
|
142
|
-
install_release(config, result, installation)
|
|
143
|
+
installed = install_release(config, result, installation)
|
|
143
144
|
print(notes)
|
|
145
|
+
if installed.lock_warning:
|
|
146
|
+
print(installed.lock_warning, file=sys.stderr)
|
|
144
147
|
exit_now()
|
|
145
148
|
```
|
|
146
149
|
|
|
150
|
+
### A git install is held to the release's `uv.lock`
|
|
151
|
+
|
|
152
|
+
`uv tool install` never reads a lock. Handed a git requirement, it resolves
|
|
153
|
+
every dependency to the newest version, so a tool would run on a set its CI
|
|
154
|
+
never tested. `update` therefore clones the release's tag, exports its
|
|
155
|
+
`uv.lock`, and passes registry pins as `--constraints` and git pins as
|
|
156
|
+
`--overrides`. uv records both in the tool's receipt, so a later
|
|
157
|
+
`uv tool upgrade` stays held to them.
|
|
158
|
+
|
|
159
|
+
| The tag | What `update` does |
|
|
160
|
+
| --- | --- |
|
|
161
|
+
| Carries a `uv.lock` | Installs the locked versions |
|
|
162
|
+
| Carries none | Installs unlocked and sets `lock_missing`. `lock_warning` names the tag, and `run_update` and `update_and_reexec` print it |
|
|
163
|
+
| Will not clone, or uv will not export its lock | Raises `LockUnreadableError` and installs nothing |
|
|
164
|
+
|
|
165
|
+
An install from an index reads no lock, because a wheel carries none.
|
|
166
|
+
|
|
167
|
+
An update reinstalls the tool's own extras, as in `mytool[fast]`, with their
|
|
168
|
+
dependencies held to the lock. Every `--with` and `--with-editable`
|
|
169
|
+
requirement is passed back to uv as well.
|
|
170
|
+
|
|
147
171
|
## What will not be updated
|
|
148
172
|
|
|
149
173
|
Read from uv's own receipt, written at install time, rather than guessed at
|
|
@@ -155,6 +179,7 @@ runtime:
|
|
|
155
179
|
| `name = "mytool"` (from an index) | Updatable |
|
|
156
180
|
| `git = "...git"` with no `rev` | Refused — tracks a branch, so its version says nothing about how far behind it is |
|
|
157
181
|
| `directory` / `path` / `editable` | Refused — reinstalling would discard a working copy |
|
|
182
|
+
| A `--with` requirement no requirements-file line reproduces, such as a relative path | Refused with `NotInstalledError` — the update would drop it |
|
|
158
183
|
|
|
159
184
|
A tool that cannot be identified at all is treated as local and left alone.
|
|
160
185
|
|
|
@@ -206,8 +231,8 @@ It never raises. A command that is not installed, exits non-zero, or takes
|
|
|
206
231
|
longer than ten seconds degrades to an unauthenticated request, which still
|
|
207
232
|
works against a public repository.
|
|
208
233
|
|
|
209
|
-
`token_func` is
|
|
210
|
-
|
|
234
|
+
`token_func` is for a credential neither the environment nor a command can
|
|
235
|
+
produce. It is called lazily, for the same reason the command is: the notify
|
|
211
236
|
gate resolves a `Config` on every invocation and declines most of them without
|
|
212
237
|
reaching the network, and a subprocess in front of that gate is the entire cost
|
|
213
238
|
worth avoiding.
|
|
@@ -32,9 +32,9 @@ version exists, so it silently drifts. The usual fix drags an HTTP client, a
|
|
|
32
32
|
TOML parser and a version library into a tool that had none of them.
|
|
33
33
|
|
|
34
34
|
This package has zero runtime dependencies — `urllib` for the network,
|
|
35
|
-
`tomllib` for uv's receipt, and its own semver implementation — and
|
|
36
|
-
that by importing every module into a virtual environment
|
|
37
|
-
else.
|
|
35
|
+
`tomllib` for uv's receipt and lock, and its own semver implementation — and
|
|
36
|
+
CI enforces that by importing every module into a virtual environment
|
|
37
|
+
containing nothing else.
|
|
38
38
|
|
|
39
39
|
## notify
|
|
40
40
|
|
|
@@ -104,7 +104,8 @@ environment the running interpreter lives in**. Unlike replacing a Unix binary
|
|
|
104
104
|
from under a live process, so anything imported afterwards may fail in ways
|
|
105
105
|
that are hard to read. Make it the last thing your process does, then call
|
|
106
106
|
`exit_now` — or use `update_and_reexec` to replace the process with the new
|
|
107
|
-
version immediately.
|
|
107
|
+
version immediately. Its caller never sees the `Result`, so it writes
|
|
108
|
+
`lock_warning` to stderr itself before the re-exec.
|
|
108
109
|
|
|
109
110
|
That cuts both ways: anything you want to *print* after the install has to be
|
|
110
111
|
fetched before it. `run_update` resolves its changelog first for exactly this
|
|
@@ -115,11 +116,34 @@ pieces `update` is made of rather than working around it:
|
|
|
115
116
|
installation = require_updatable(config) # refuses a checkout, costs nothing
|
|
116
117
|
result = check(config) # network, environment still intact
|
|
117
118
|
notes = changelog(config, result.current, result.latest)
|
|
118
|
-
install_release(config, result, installation)
|
|
119
|
+
installed = install_release(config, result, installation)
|
|
119
120
|
print(notes)
|
|
121
|
+
if installed.lock_warning:
|
|
122
|
+
print(installed.lock_warning, file=sys.stderr)
|
|
120
123
|
exit_now()
|
|
121
124
|
```
|
|
122
125
|
|
|
126
|
+
### A git install is held to the release's `uv.lock`
|
|
127
|
+
|
|
128
|
+
`uv tool install` never reads a lock. Handed a git requirement, it resolves
|
|
129
|
+
every dependency to the newest version, so a tool would run on a set its CI
|
|
130
|
+
never tested. `update` therefore clones the release's tag, exports its
|
|
131
|
+
`uv.lock`, and passes registry pins as `--constraints` and git pins as
|
|
132
|
+
`--overrides`. uv records both in the tool's receipt, so a later
|
|
133
|
+
`uv tool upgrade` stays held to them.
|
|
134
|
+
|
|
135
|
+
| The tag | What `update` does |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| Carries a `uv.lock` | Installs the locked versions |
|
|
138
|
+
| Carries none | Installs unlocked and sets `lock_missing`. `lock_warning` names the tag, and `run_update` and `update_and_reexec` print it |
|
|
139
|
+
| Will not clone, or uv will not export its lock | Raises `LockUnreadableError` and installs nothing |
|
|
140
|
+
|
|
141
|
+
An install from an index reads no lock, because a wheel carries none.
|
|
142
|
+
|
|
143
|
+
An update reinstalls the tool's own extras, as in `mytool[fast]`, with their
|
|
144
|
+
dependencies held to the lock. Every `--with` and `--with-editable`
|
|
145
|
+
requirement is passed back to uv as well.
|
|
146
|
+
|
|
123
147
|
## What will not be updated
|
|
124
148
|
|
|
125
149
|
Read from uv's own receipt, written at install time, rather than guessed at
|
|
@@ -131,6 +155,7 @@ runtime:
|
|
|
131
155
|
| `name = "mytool"` (from an index) | Updatable |
|
|
132
156
|
| `git = "...git"` with no `rev` | Refused — tracks a branch, so its version says nothing about how far behind it is |
|
|
133
157
|
| `directory` / `path` / `editable` | Refused — reinstalling would discard a working copy |
|
|
158
|
+
| A `--with` requirement no requirements-file line reproduces, such as a relative path | Refused with `NotInstalledError` — the update would drop it |
|
|
134
159
|
|
|
135
160
|
A tool that cannot be identified at all is treated as local and left alone.
|
|
136
161
|
|
|
@@ -182,8 +207,8 @@ It never raises. A command that is not installed, exits non-zero, or takes
|
|
|
182
207
|
longer than ten seconds degrades to an unauthenticated request, which still
|
|
183
208
|
works against a public repository.
|
|
184
209
|
|
|
185
|
-
`token_func` is
|
|
186
|
-
|
|
210
|
+
`token_func` is for a credential neither the environment nor a command can
|
|
211
|
+
produce. It is called lazily, for the same reason the command is: the notify
|
|
187
212
|
gate resolves a `Config` on every invocation and declines most of them without
|
|
188
213
|
reaching the network, and a subprocess in front of that gate is the entire cost
|
|
189
214
|
worth avoiding.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyselfupdate"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Self-update and update notification for Python CLIs installed with uv tool"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
5
7
|
readme = "README.md"
|
|
6
8
|
keywords = [
|
|
7
9
|
"uv",
|
|
@@ -14,7 +16,6 @@ classifiers = [
|
|
|
14
16
|
"Development Status :: 4 - Beta",
|
|
15
17
|
"Environment :: Console",
|
|
16
18
|
"Intended Audience :: Developers",
|
|
17
|
-
"License :: OSI Approved :: MIT License",
|
|
18
19
|
"Programming Language :: Python :: 3 :: Only",
|
|
19
20
|
"Topic :: Software Development :: Libraries",
|
|
20
21
|
"Topic :: System :: Software Distribution",
|
|
@@ -27,9 +28,6 @@ dependencies = []
|
|
|
27
28
|
name = "Chris Birch"
|
|
28
29
|
email = "datapointchris@gmail.com"
|
|
29
30
|
|
|
30
|
-
[project.license]
|
|
31
|
-
text = "MIT"
|
|
32
|
-
|
|
33
31
|
[project.optional-dependencies]
|
|
34
32
|
typer = ["typer>=0.12.0"]
|
|
35
33
|
|
|
@@ -44,7 +42,7 @@ dev = [
|
|
|
44
42
|
"mypy>=1.10.0",
|
|
45
43
|
"pre-commit>=4.3.0",
|
|
46
44
|
"pytest>=8.0.0",
|
|
47
|
-
"ruff
|
|
45
|
+
"ruff==0.12.5",
|
|
48
46
|
"typer>=0.12.0",
|
|
49
47
|
]
|
|
50
48
|
|
|
@@ -112,6 +110,7 @@ select = [
|
|
|
112
110
|
"B",
|
|
113
111
|
"SIM",
|
|
114
112
|
"I",
|
|
113
|
+
"ICN",
|
|
115
114
|
]
|
|
116
115
|
ignore = ["SIM108"]
|
|
117
116
|
|
|
@@ -121,9 +120,21 @@ force-single-line = true
|
|
|
121
120
|
[tool.ruff.lint.per-file-ignores]
|
|
122
121
|
"__init__.py" = ["F401"]
|
|
123
122
|
|
|
123
|
+
[tool.ruff.lint.flake8-import-conventions]
|
|
124
|
+
banned-from = [
|
|
125
|
+
"datetime",
|
|
126
|
+
"pyspark.sql.functions",
|
|
127
|
+
"pyspark.sql.types",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
|
|
131
|
+
datetime = "dt"
|
|
132
|
+
sqlalchemy = "sa"
|
|
133
|
+
"pyspark.sql.functions" = "sf"
|
|
134
|
+
"pyspark.sql.types" = "st"
|
|
135
|
+
|
|
124
136
|
[tool.semantic_release]
|
|
125
137
|
version_toml = ["pyproject.toml:project.version"]
|
|
126
|
-
branch = "main"
|
|
127
138
|
commit_message = "build(release): {version}"
|
|
128
139
|
build_command = """
|
|
129
140
|
pip install uv
|
|
@@ -181,6 +192,40 @@ managed = [
|
|
|
181
192
|
"lint",
|
|
182
193
|
"isort",
|
|
183
194
|
"force-single-line",
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
"ruff",
|
|
198
|
+
"lint",
|
|
199
|
+
"flake8-import-conventions",
|
|
200
|
+
"banned-from",
|
|
201
|
+
],
|
|
202
|
+
[
|
|
203
|
+
"ruff",
|
|
204
|
+
"lint",
|
|
205
|
+
"flake8-import-conventions",
|
|
206
|
+
"extend-aliases",
|
|
207
|
+
"datetime",
|
|
208
|
+
],
|
|
209
|
+
[
|
|
210
|
+
"ruff",
|
|
211
|
+
"lint",
|
|
212
|
+
"flake8-import-conventions",
|
|
213
|
+
"extend-aliases",
|
|
214
|
+
"sqlalchemy",
|
|
215
|
+
],
|
|
216
|
+
[
|
|
217
|
+
"ruff",
|
|
218
|
+
"lint",
|
|
219
|
+
"flake8-import-conventions",
|
|
220
|
+
"extend-aliases",
|
|
221
|
+
"pyspark.sql.functions",
|
|
222
|
+
],
|
|
223
|
+
[
|
|
224
|
+
"ruff",
|
|
225
|
+
"lint",
|
|
226
|
+
"flake8-import-conventions",
|
|
227
|
+
"extend-aliases",
|
|
228
|
+
"pyspark.sql.types",
|
|
184
229
|
],
|
|
185
230
|
[
|
|
186
231
|
"ruff",
|
|
@@ -244,11 +289,12 @@ managed = [
|
|
|
244
289
|
"testpaths",
|
|
245
290
|
],
|
|
246
291
|
]
|
|
292
|
+
pinned = ["ruff"]
|
|
247
293
|
|
|
248
294
|
[tool.uv.build-backend]
|
|
249
295
|
module-name = "pyselfupdate"
|
|
250
296
|
module-root = "src"
|
|
251
297
|
|
|
252
298
|
[build-system]
|
|
253
|
-
requires = ["uv_build>=0.
|
|
299
|
+
requires = ["uv_build>=0.12.2,<0.13.0"]
|
|
254
300
|
build-backend = "uv_build"
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyselfupdate"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Self-update and update notification for Python CLIs installed with uv tool"
|
|
5
5
|
authors = [{ name = "Chris Birch", email = "datapointchris@gmail.com" }]
|
|
6
|
-
license =
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
7
8
|
readme = "README.md"
|
|
8
9
|
keywords = ["uv", "cli", "self-update", "update-notifier", "release"]
|
|
9
10
|
classifiers = [
|
|
10
11
|
"Development Status :: 4 - Beta",
|
|
11
12
|
"Environment :: Console",
|
|
12
13
|
"Intended Audience :: Developers",
|
|
13
|
-
"License :: OSI Approved :: MIT License",
|
|
14
14
|
"Programming Language :: Python :: 3 :: Only",
|
|
15
15
|
"Topic :: Software Development :: Libraries",
|
|
16
16
|
"Topic :: System :: Software Distribution",
|
|
@@ -42,7 +42,7 @@ dev = [
|
|
|
42
42
|
"mypy>=1.10.0",
|
|
43
43
|
"pre-commit>=4.3.0",
|
|
44
44
|
"pytest>=8.0.0",
|
|
45
|
-
"ruff
|
|
45
|
+
"ruff==0.12.5",
|
|
46
46
|
"typer>=0.12.0",
|
|
47
47
|
]
|
|
48
48
|
|
|
@@ -111,6 +111,7 @@ select = [
|
|
|
111
111
|
"B", # flake8-bugbear
|
|
112
112
|
"SIM", # flake8-simplify
|
|
113
113
|
"I", # isort
|
|
114
|
+
"ICN", # flake8-import-conventions
|
|
114
115
|
]
|
|
115
116
|
ignore = [
|
|
116
117
|
"SIM108", # ternary operator — often less readable
|
|
@@ -122,9 +123,17 @@ force-single-line = true
|
|
|
122
123
|
[tool.ruff.lint.per-file-ignores]
|
|
123
124
|
"__init__.py" = ["F401"]
|
|
124
125
|
|
|
126
|
+
[tool.ruff.lint.flake8-import-conventions]
|
|
127
|
+
banned-from = ["datetime", "pyspark.sql.functions", "pyspark.sql.types"]
|
|
128
|
+
|
|
129
|
+
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
|
|
130
|
+
datetime = "dt"
|
|
131
|
+
sqlalchemy = "sa"
|
|
132
|
+
"pyspark.sql.functions" = "sf"
|
|
133
|
+
"pyspark.sql.types" = "st"
|
|
134
|
+
|
|
125
135
|
[tool.semantic_release]
|
|
126
136
|
version_toml = ["pyproject.toml:project.version"]
|
|
127
|
-
branch = "main"
|
|
128
137
|
commit_message = "build(release): {version}"
|
|
129
138
|
build_command = """
|
|
130
139
|
pip install uv
|
|
@@ -154,6 +163,11 @@ managed = [
|
|
|
154
163
|
["ruff", "lint", "select"],
|
|
155
164
|
["ruff", "lint", "ignore"],
|
|
156
165
|
["ruff", "lint", "isort", "force-single-line"],
|
|
166
|
+
["ruff", "lint", "flake8-import-conventions", "banned-from"],
|
|
167
|
+
["ruff", "lint", "flake8-import-conventions", "extend-aliases", "datetime"],
|
|
168
|
+
["ruff", "lint", "flake8-import-conventions", "extend-aliases", "sqlalchemy"],
|
|
169
|
+
["ruff", "lint", "flake8-import-conventions", "extend-aliases", "pyspark.sql.functions"],
|
|
170
|
+
["ruff", "lint", "flake8-import-conventions", "extend-aliases", "pyspark.sql.types"],
|
|
157
171
|
["ruff", "lint", "per-file-ignores", "__init__.py"],
|
|
158
172
|
["mypy", "pretty"],
|
|
159
173
|
["mypy", "ignore_missing_imports"],
|
|
@@ -169,9 +183,12 @@ managed = [
|
|
|
169
183
|
["pytest", "ini_options", "minversion"],
|
|
170
184
|
["pytest", "ini_options", "testpaths"],
|
|
171
185
|
]
|
|
186
|
+
# Dependencies held at the version the pre-commit hook runs, in every group
|
|
187
|
+
# and extra that names them, so a local run and CI cannot disagree.
|
|
188
|
+
pinned = ["ruff"]
|
|
172
189
|
|
|
173
190
|
[build-system]
|
|
174
|
-
requires = ["uv_build>=0.
|
|
191
|
+
requires = ["uv_build>=0.12.2,<0.13.0"]
|
|
175
192
|
build-backend = "uv_build"
|
|
176
193
|
|
|
177
194
|
[tool.uv.build-backend]
|
|
@@ -21,6 +21,7 @@ from pyselfupdate.config import Config
|
|
|
21
21
|
from pyselfupdate.errors import InstallFailedError
|
|
22
22
|
from pyselfupdate.errors import InvalidConfigError
|
|
23
23
|
from pyselfupdate.errors import LocalInstallError
|
|
24
|
+
from pyselfupdate.errors import LockUnreadableError
|
|
24
25
|
from pyselfupdate.errors import NoReleaseError
|
|
25
26
|
from pyselfupdate.errors import NotInstalledError
|
|
26
27
|
from pyselfupdate.errors import SelfUpdateError
|
|
@@ -54,6 +55,7 @@ __all__ = [
|
|
|
54
55
|
'Installation',
|
|
55
56
|
'InvalidConfigError',
|
|
56
57
|
'LocalInstallError',
|
|
58
|
+
'LockUnreadableError',
|
|
57
59
|
'NoReleaseError',
|
|
58
60
|
'NotInstalledError',
|
|
59
61
|
'Outcome',
|
|
@@ -24,7 +24,11 @@ class LocalInstallError(SelfUpdateError):
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class NotInstalledError(SelfUpdateError):
|
|
27
|
-
"""
|
|
27
|
+
"""uv's receipt is missing, unreadable, or names no requirement for the tool.
|
|
28
|
+
|
|
29
|
+
`update` raises it too when the receipt lists a `--with` requirement that no
|
|
30
|
+
requirements-file line reproduces, because reinstalling would drop it.
|
|
31
|
+
"""
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
class NoReleaseError(SelfUpdateError):
|
|
@@ -37,3 +41,12 @@ class SourceError(SelfUpdateError):
|
|
|
37
41
|
|
|
38
42
|
class InstallFailedError(SelfUpdateError):
|
|
39
43
|
"""The install command ran and failed."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LockUnreadableError(SelfUpdateError):
|
|
47
|
+
"""The release's `uv.lock` could not be read, so nothing was installed.
|
|
48
|
+
|
|
49
|
+
Raised when git or uv is not on PATH, the tag will not clone, or `uv export`
|
|
50
|
+
refuses the lock. A tag with no `uv.lock` at all is not this error: it
|
|
51
|
+
installs unlocked and sets `Result.lock_missing`.
|
|
52
|
+
"""
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
"""GitHub as a release source.
|
|
2
2
|
|
|
3
|
-
Uses `urllib.request` rather than
|
|
4
|
-
|
|
3
|
+
Uses `urllib.request` rather than httpx2. The package makes one JSON request and
|
|
4
|
+
downloads nothing, and tools with no HTTP client of their own take it for
|
|
5
|
+
self-update, so a client library would land in their dependency tree for this
|
|
6
|
+
one call. `_CredentialScopedRedirects` covers the redirect hazard a client would.
|
|
7
|
+
|
|
8
|
+
It moves to httpx2 when this package needs a second request shape — a client
|
|
9
|
+
object, streaming, an asset download — or when every consumer already declares
|
|
10
|
+
httpx2.
|
|
5
11
|
"""
|
|
6
12
|
|
|
7
13
|
from __future__ import annotations
|
|
8
14
|
|
|
15
|
+
import datetime as dt
|
|
9
16
|
import http.client
|
|
10
17
|
import json
|
|
11
18
|
import os
|
|
@@ -18,8 +25,6 @@ import urllib.request
|
|
|
18
25
|
from collections.abc import Callable
|
|
19
26
|
from dataclasses import dataclass
|
|
20
27
|
from dataclasses import field
|
|
21
|
-
from datetime import UTC
|
|
22
|
-
from datetime import datetime
|
|
23
28
|
from typing import IO
|
|
24
29
|
|
|
25
30
|
from pyselfupdate.errors import NoReleaseError
|
|
@@ -168,8 +173,8 @@ class GitHubSource:
|
|
|
168
173
|
# notify gate declines to check at all. That gate is otherwise free, and a
|
|
169
174
|
# spawn in front of it is the entire cost.
|
|
170
175
|
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
176
|
+
# `gh` needs no `token_func`, because it is the default below. This is for a
|
|
177
|
+
# credential neither the environment nor a command can produce.
|
|
173
178
|
token_func: Callable[[], str] | None = None
|
|
174
179
|
|
|
175
180
|
timeout: float = DEFAULT_TIMEOUT
|
|
@@ -343,7 +348,7 @@ def _reset_clock(error: urllib.error.HTTPError) -> str:
|
|
|
343
348
|
"""
|
|
344
349
|
header = error.headers.get('x-ratelimit-reset') if error.headers else None
|
|
345
350
|
try:
|
|
346
|
-
moment = datetime.fromtimestamp(int(header or ''), tz=UTC)
|
|
351
|
+
moment = dt.datetime.fromtimestamp(int(header or ''), tz=dt.UTC)
|
|
347
352
|
except (TypeError, ValueError, OSError, OverflowError):
|
|
348
353
|
return ''
|
|
349
354
|
return f'; resets at {moment:%H:%M} UTC'
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Reading and rewriting a uv tool installation.
|
|
2
|
+
|
|
3
|
+
This is the part with no analog in goselfupdate. A Go tool updates by
|
|
4
|
+
replacing one file; a uv tool updates by rebuilding the virtual environment its
|
|
5
|
+
own interpreter is running inside, which is why `update` must be the last thing
|
|
6
|
+
a process does before it exits or re-execs.
|
|
7
|
+
|
|
8
|
+
`uv tool install` never reads a lock. Handed `<tool> @ git+<url>@<tag>`, it
|
|
9
|
+
resolves every dependency to its newest version, while the tool's CI tested the
|
|
10
|
+
versions in its `uv.lock`. So a git install is held to the lock at the tag being
|
|
11
|
+
installed: `read_lock` turns it into constraints and overrides, and
|
|
12
|
+
`run_install` passes them to uv. uv records both in the receipt, so a later
|
|
13
|
+
`uv tool upgrade` stays held to them.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import tomllib
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from dataclasses import field
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from importlib.metadata import PackageNotFoundError
|
|
29
|
+
from importlib.metadata import version as installed_version
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from pyselfupdate.errors import InstallFailedError
|
|
33
|
+
from pyselfupdate.errors import LockUnreadableError
|
|
34
|
+
from pyselfupdate.errors import NotInstalledError
|
|
35
|
+
|
|
36
|
+
RECEIPT_NAME = 'uv-receipt.toml'
|
|
37
|
+
|
|
38
|
+
LOCK_NAME = 'uv.lock'
|
|
39
|
+
|
|
40
|
+
EXPORT = ('export', '--frozen', '--no-default-groups', '--no-emit-workspace', '--no-hashes', '--no-header', '--no-annotate')
|
|
41
|
+
"""The lock's runtime dependencies, one requirement per line.
|
|
42
|
+
|
|
43
|
+
`--no-emit-workspace` leaves out the tool itself and any workspace member, which
|
|
44
|
+
the commit being installed already pins. `--no-hashes` keeps each requirement
|
|
45
|
+
on one line: uv writes every hash as a continuation line, and
|
|
46
|
+
`_pins_from_export` reads line by line.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InstallKind(Enum):
|
|
51
|
+
"""How a uv tool was installed, which decides whether it may be updated."""
|
|
52
|
+
|
|
53
|
+
GIT = 'git'
|
|
54
|
+
INDEX = 'index'
|
|
55
|
+
LOCAL = 'local'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Installation:
|
|
60
|
+
"""What uv's own receipt says about an installed tool."""
|
|
61
|
+
|
|
62
|
+
tool: str
|
|
63
|
+
kind: InstallKind
|
|
64
|
+
url: str = ''
|
|
65
|
+
|
|
66
|
+
# The requested revision, empty when the install tracks the default branch.
|
|
67
|
+
# An empty value on a GIT install is the interesting case: the tool was
|
|
68
|
+
# installed from a moving target, so "up to date" has no meaning.
|
|
69
|
+
revision: str = ''
|
|
70
|
+
|
|
71
|
+
extras: tuple[str, ...] = ()
|
|
72
|
+
|
|
73
|
+
# The receipt's other requirements, from `--with` and `--with-editable`, as
|
|
74
|
+
# requirements-file lines, because a `-e` line is the only way to pass an
|
|
75
|
+
# editable back.
|
|
76
|
+
with_requirements: tuple[str, ...] = ()
|
|
77
|
+
|
|
78
|
+
# Names of the receipt requirements `_requirement_line` returns None for.
|
|
79
|
+
# `read_installation` records them rather than raising: the notify gate
|
|
80
|
+
# treats a failed read as a local install and prints no notice. `update`
|
|
81
|
+
# raises NotInstalledError on them instead of dropping them.
|
|
82
|
+
_unrebuildable: tuple[str, ...] = field(default=(), repr=False)
|
|
83
|
+
|
|
84
|
+
def is_updatable(self) -> bool:
|
|
85
|
+
return self.kind is not InstallKind.LOCAL
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def tool_dir() -> Path:
|
|
89
|
+
"""uv's tool directory.
|
|
90
|
+
|
|
91
|
+
Resolved from the environment rather than by running `uv tool dir`, which
|
|
92
|
+
costs a subprocess on a path that runs before every command.
|
|
93
|
+
"""
|
|
94
|
+
override = os.environ.get('UV_TOOL_DIR')
|
|
95
|
+
if override:
|
|
96
|
+
return Path(override).expanduser()
|
|
97
|
+
data_home = os.environ.get('XDG_DATA_HOME')
|
|
98
|
+
base = Path(data_home).expanduser() if data_home else Path.home() / '.local' / 'share'
|
|
99
|
+
return base / 'uv' / 'tools'
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_installation(tool: str) -> Installation:
|
|
103
|
+
"""Parse uv's receipt for a tool.
|
|
104
|
+
|
|
105
|
+
The receipt is uv's own record of how it installed something, written at
|
|
106
|
+
install time. Reading it beats inferring the same thing at runtime from the
|
|
107
|
+
executable's path, which cannot tell "uv put it there" apart from "someone
|
|
108
|
+
dropped a binary in the same directory".
|
|
109
|
+
"""
|
|
110
|
+
receipt = tool_dir() / tool / RECEIPT_NAME
|
|
111
|
+
if not receipt.is_file():
|
|
112
|
+
raise NotInstalledError(f'{tool} is not installed as a uv tool ({receipt} does not exist)')
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
payload = tomllib.loads(receipt.read_text(encoding='utf-8'))
|
|
116
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
117
|
+
raise NotInstalledError(f'cannot read {receipt}: {error}') from error
|
|
118
|
+
|
|
119
|
+
requirements = (payload.get('tool') or {}).get('requirements') or []
|
|
120
|
+
own = next((requirement for requirement in requirements if requirement.get('name') == tool), None)
|
|
121
|
+
if own is None:
|
|
122
|
+
raise NotInstalledError(f'{receipt} lists no requirement named {tool}')
|
|
123
|
+
|
|
124
|
+
rebuilt: list[str] = []
|
|
125
|
+
unrebuildable: list[str] = []
|
|
126
|
+
for requirement in requirements:
|
|
127
|
+
if requirement is own:
|
|
128
|
+
continue
|
|
129
|
+
line = _requirement_line(requirement)
|
|
130
|
+
if line is None:
|
|
131
|
+
unrebuildable.append(str(requirement.get('name') or requirement))
|
|
132
|
+
else:
|
|
133
|
+
rebuilt.append(line)
|
|
134
|
+
|
|
135
|
+
def installed_as(kind: InstallKind, url: str = '', revision: str = '') -> Installation:
|
|
136
|
+
extras = tuple(own.get('extras') or ())
|
|
137
|
+
return Installation(tool, kind, url, revision, extras, tuple(rebuilt), tuple(unrebuildable))
|
|
138
|
+
|
|
139
|
+
# A local checkout, however uv spelled it. Reinstalling one would
|
|
140
|
+
# discard the working copy the user is developing against.
|
|
141
|
+
for key in ('directory', 'path', 'editable'):
|
|
142
|
+
if own.get(key):
|
|
143
|
+
return installed_as(InstallKind.LOCAL, url=str(own[key]))
|
|
144
|
+
|
|
145
|
+
git = own.get('git')
|
|
146
|
+
if git:
|
|
147
|
+
url, query, _ = _split_git(str(git))
|
|
148
|
+
return installed_as(InstallKind.GIT, url=url, revision=query.get('rev', ''))
|
|
149
|
+
|
|
150
|
+
return installed_as(InstallKind.INDEX)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _split_git(git: str) -> tuple[str, dict[str, str], str]:
|
|
154
|
+
"""A receipt's git source as its URL, its query and the commit after `#`.
|
|
155
|
+
|
|
156
|
+
uv writes `...git?rev=v1.2.3` for a pinned install and omits the query
|
|
157
|
+
entirely for one that follows the default branch.
|
|
158
|
+
"""
|
|
159
|
+
rest, _, commit = git.partition('#')
|
|
160
|
+
url, _, query = rest.partition('?')
|
|
161
|
+
pairs = (part.partition('=') for part in query.split('&') if part)
|
|
162
|
+
return url, {key: value for key, _, value in pairs if value}, commit
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_LINE_KEYS = frozenset({'name', 'extras', 'marker', 'specifier', 'git', 'subdirectory', 'url', 'path', 'directory', 'editable'})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _requirement_line(requirement: dict) -> str | None:
|
|
169
|
+
"""A receipt requirement as a requirements-file line, or None when no line reproduces it.
|
|
170
|
+
|
|
171
|
+
None covers a key outside `_LINE_KEYS`, an editable carrying extras or a
|
|
172
|
+
marker, and a relative path.
|
|
173
|
+
"""
|
|
174
|
+
name = requirement.get('name')
|
|
175
|
+
if not name or set(requirement) - _LINE_KEYS:
|
|
176
|
+
return None
|
|
177
|
+
extras = requirement.get('extras') or ()
|
|
178
|
+
named = f'{name}[{",".join(extras)}]' if extras else name
|
|
179
|
+
marker = f' ; {requirement["marker"]}' if requirement.get('marker') else ''
|
|
180
|
+
|
|
181
|
+
if requirement.get('editable'):
|
|
182
|
+
uri = _file_uri(requirement['editable'])
|
|
183
|
+
return f'-e {uri}' if uri and not extras and not marker else None
|
|
184
|
+
if requirement.get('git'):
|
|
185
|
+
url, query, commit = _split_git(str(requirement['git']))
|
|
186
|
+
ref = query.get('rev') or query.get('tag') or query.get('branch') or commit
|
|
187
|
+
subdirectory = query.get('subdirectory') or requirement.get('subdirectory')
|
|
188
|
+
return f'{named} @ git+{url}{f"@{ref}" if ref else ""}{f"#subdirectory={subdirectory}" if subdirectory else ""}{marker}'
|
|
189
|
+
if requirement.get('url'):
|
|
190
|
+
subdirectory = requirement.get('subdirectory')
|
|
191
|
+
return f'{named} @ {requirement["url"]}{f"#subdirectory={subdirectory}" if subdirectory else ""}{marker}'
|
|
192
|
+
for key in ('directory', 'path'):
|
|
193
|
+
if requirement.get(key):
|
|
194
|
+
uri = _file_uri(requirement[key])
|
|
195
|
+
return f'{named} @ {uri}{marker}' if uri else None
|
|
196
|
+
return f'{named}{requirement.get("specifier") or ""}{marker}'
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _file_uri(path: str) -> str:
|
|
200
|
+
"""A `file://` URI for an absolute path, or an empty string for a relative one.
|
|
201
|
+
|
|
202
|
+
The receipt does not record what a relative path is relative to.
|
|
203
|
+
"""
|
|
204
|
+
candidate = Path(path)
|
|
205
|
+
return candidate.as_uri() if candidate.is_absolute() else ''
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def current_version(package: str) -> str:
|
|
209
|
+
"""The running build's version, or an empty string when unknown."""
|
|
210
|
+
try:
|
|
211
|
+
return installed_version(package)
|
|
212
|
+
except PackageNotFoundError:
|
|
213
|
+
return ''
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def requirement_for(installation: Installation, ref: str) -> str:
|
|
217
|
+
"""The requirement string that installs `ref` of an already-installed tool, with the extras it has."""
|
|
218
|
+
named = f'{installation.tool}[{",".join(installation.extras)}]' if installation.extras else installation.tool
|
|
219
|
+
if installation.kind is InstallKind.GIT:
|
|
220
|
+
return f'{named} @ git+{installation.url}@{ref}'
|
|
221
|
+
return f'{named}=={ref.removeprefix("v")}'
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class Pins:
|
|
226
|
+
"""What a `uv.lock` pins, as `--constraints` lines and `--overrides` lines.
|
|
227
|
+
|
|
228
|
+
A registry pin is a constraint, which holds a package to the locked version
|
|
229
|
+
without adding it. A URL pin has to be an override. A lock records
|
|
230
|
+
`git+<repo>@<commit>` where the package declared `git+<repo>`, and uv
|
|
231
|
+
refuses a constraint whose URL differs from the declared one with
|
|
232
|
+
`Requirements contain conflicting URLs`.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
constraints: tuple[str, ...] = ()
|
|
236
|
+
overrides: tuple[str, ...] = ()
|
|
237
|
+
|
|
238
|
+
def arguments(self, directory: Path) -> list[str]:
|
|
239
|
+
"""Writes each non-empty list into `directory` and returns the flags naming them.
|
|
240
|
+
|
|
241
|
+
An empty file is left out rather than passed, because uv warns on one.
|
|
242
|
+
"""
|
|
243
|
+
flags: list[str] = []
|
|
244
|
+
for flag, lines in (('--constraints', self.constraints), ('--overrides', self.overrides)):
|
|
245
|
+
if not lines:
|
|
246
|
+
continue
|
|
247
|
+
path = directory / f'{flag.removeprefix("--")}.txt'
|
|
248
|
+
path.write_text(''.join(f'{line}\n' for line in lines), encoding='utf-8')
|
|
249
|
+
flags += [flag, str(path)]
|
|
250
|
+
return flags
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def read_lock(url: str, ref: str, *, extras: Sequence[str] = ()) -> Pins | None:
|
|
254
|
+
"""What the `uv.lock` at `ref` of a git repository pins, or None when it has none.
|
|
255
|
+
|
|
256
|
+
`extras` are the extras the tool is installed with. Each is passed to
|
|
257
|
+
`uv export` as `--extra`, since a default export leaves an extra's
|
|
258
|
+
dependencies out. `--all-extras` fails on a project that declares two
|
|
259
|
+
extras as conflicting.
|
|
260
|
+
|
|
261
|
+
Reads a shallow clone of the one ref and lets `uv export` interpret the lock.
|
|
262
|
+
The lock is also read directly for one thing the export drops: the extras
|
|
263
|
+
a dependent requests of a git dependency, which `_override_extras` restores.
|
|
264
|
+
|
|
265
|
+
Raises `LockUnreadableError` when git or uv is not on PATH, the ref will not
|
|
266
|
+
clone, or uv will not export its lock. None is not a failure:
|
|
267
|
+
`install_release` installs that tag unlocked and sets `lock_missing`.
|
|
268
|
+
"""
|
|
269
|
+
git = shutil.which('git')
|
|
270
|
+
uv = shutil.which('uv')
|
|
271
|
+
if not git or not uv:
|
|
272
|
+
missing = 'git' if not git else 'uv'
|
|
273
|
+
raise LockUnreadableError(f'{missing} is not on PATH, so the uv.lock at {ref} cannot be read')
|
|
274
|
+
|
|
275
|
+
# A clone that cannot be deleted stays in the system temp, and the update goes ahead.
|
|
276
|
+
with tempfile.TemporaryDirectory(prefix='pyselfupdate-lock-', ignore_cleanup_errors=True) as scratch:
|
|
277
|
+
checkout = Path(scratch) / 'checkout'
|
|
278
|
+
cloned = _run([git, 'clone', '--quiet', '--depth', '1', '--branch', ref, url, str(checkout)])
|
|
279
|
+
if cloned.returncode != 0:
|
|
280
|
+
raise LockUnreadableError(f'could not clone {url} at {ref} to read its uv.lock: {_reason(cloned)}')
|
|
281
|
+
lock = checkout / LOCK_NAME
|
|
282
|
+
if not lock.is_file():
|
|
283
|
+
return None
|
|
284
|
+
exported = _run([uv, *EXPORT, *(flag for extra in extras for flag in ('--extra', extra))], cwd=checkout)
|
|
285
|
+
if exported.returncode != 0:
|
|
286
|
+
raise LockUnreadableError(f'uv export would not read the uv.lock at {ref}: {_reason(exported)}')
|
|
287
|
+
try:
|
|
288
|
+
requested = _override_extras(tomllib.loads(lock.read_text(encoding='utf-8')), extras)
|
|
289
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
290
|
+
raise LockUnreadableError(f'cannot read the uv.lock at {ref}: {error}') from error
|
|
291
|
+
return _pins_from_export(exported.stdout, requested)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _override_extras(lock: dict, extras: Sequence[str]) -> dict[str, tuple[str, ...]]:
|
|
295
|
+
"""The extras requested of each package, walking dependency edges from the project at the lock's root.
|
|
296
|
+
|
|
297
|
+
An override replaces a requirement whole, extras included, and `uv export`
|
|
298
|
+
writes `gitdep @ git+...` where the tool declared `gitdep[x] @ git+...`.
|
|
299
|
+
Without this, an update leaves out the packages `x` pulls in. The tool then
|
|
300
|
+
raises ModuleNotFoundError on its first import of one, after its old
|
|
301
|
+
environment is gone. The lock keeps the extras on the edge that requests
|
|
302
|
+
them: `{ name = "gitdep", extra = ["x"] }`.
|
|
303
|
+
|
|
304
|
+
Only requested extras are followed, so an override never names an extra
|
|
305
|
+
the installed tool did not ask for.
|
|
306
|
+
"""
|
|
307
|
+
packages: dict[str, list[dict]] = {}
|
|
308
|
+
for package in lock.get('package') or []:
|
|
309
|
+
packages.setdefault(package.get('name', ''), []).append(package)
|
|
310
|
+
project = ({'editable': '.'}, {'virtual': '.'})
|
|
311
|
+
roots = [name for name, entries in packages.items() for package in entries if package.get('source') in project]
|
|
312
|
+
|
|
313
|
+
active: dict[str, set[str]] = {root: set(extras) for root in roots}
|
|
314
|
+
pending = list(roots)
|
|
315
|
+
while pending:
|
|
316
|
+
name = pending.pop()
|
|
317
|
+
for package in packages.get(name, ()):
|
|
318
|
+
groups = package.get('optional-dependencies') or {}
|
|
319
|
+
edges = [*(package.get('dependencies') or ()), *(edge for extra in sorted(active[name]) for edge in groups.get(extra, ()))]
|
|
320
|
+
for edge in edges:
|
|
321
|
+
target, wanted = edge.get('name', ''), set(edge.get('extra') or ())
|
|
322
|
+
if target not in active or not wanted <= active[target]:
|
|
323
|
+
active.setdefault(target, set()).update(wanted)
|
|
324
|
+
pending.append(target)
|
|
325
|
+
return {name: tuple(sorted(wanted)) for name, wanted in active.items() if wanted}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _pins_from_export(exported: str, extras: dict[str, tuple[str, ...]]) -> Pins:
|
|
329
|
+
"""Sorts `uv export` lines: ` @ ` before any marker makes an override, `==` a constraint.
|
|
330
|
+
|
|
331
|
+
Anything else is a path requirement and is dropped. Its code is inside the
|
|
332
|
+
commit being installed.
|
|
333
|
+
"""
|
|
334
|
+
constraints: list[str] = []
|
|
335
|
+
overrides: list[str] = []
|
|
336
|
+
for line in exported.splitlines():
|
|
337
|
+
line = line.strip()
|
|
338
|
+
requirement = line.split(';', 1)[0]
|
|
339
|
+
if ' @ ' in requirement:
|
|
340
|
+
name, _, rest = line.partition(' @ ')
|
|
341
|
+
wanted = extras.get(name.strip())
|
|
342
|
+
overrides.append(f'{name.strip()}[{",".join(wanted)}] @ {rest}' if wanted else line)
|
|
343
|
+
elif '==' in requirement:
|
|
344
|
+
constraints.append(line)
|
|
345
|
+
return Pins(tuple(constraints), tuple(overrides))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def run_install(requirement: str, *, quiet: bool = True, pins: Pins | None = None, with_requirements: Sequence[str] = ()) -> None:
|
|
349
|
+
"""Install a requirement over any existing tool of that name, held to `pins` when given.
|
|
350
|
+
|
|
351
|
+
`with_requirements` are requirements-file lines installed beside the tool,
|
|
352
|
+
the way `--with` and `--with-editable` install them. `--force` is what
|
|
353
|
+
allows an entry point that already exists to be replaced; without it uv
|
|
354
|
+
refuses rather than overwriting.
|
|
355
|
+
"""
|
|
356
|
+
executable = shutil.which('uv')
|
|
357
|
+
if not executable:
|
|
358
|
+
raise InstallFailedError('uv is not on PATH, so the tool cannot reinstall itself')
|
|
359
|
+
|
|
360
|
+
with tempfile.TemporaryDirectory(prefix='pyselfupdate-pins-', ignore_cleanup_errors=True) as scratch:
|
|
361
|
+
held = pins.arguments(Path(scratch)) if pins else []
|
|
362
|
+
beside: list[str] = []
|
|
363
|
+
if with_requirements:
|
|
364
|
+
listed = Path(scratch) / 'with.txt'
|
|
365
|
+
listed.write_text(''.join(f'{line}\n' for line in with_requirements), encoding='utf-8')
|
|
366
|
+
beside = ['--with-requirements', str(listed)]
|
|
367
|
+
command = [executable, 'tool', 'install', '--force', *held, *beside, requirement]
|
|
368
|
+
if quiet:
|
|
369
|
+
command.insert(1, '--quiet')
|
|
370
|
+
completed = _run(command)
|
|
371
|
+
|
|
372
|
+
if completed.returncode != 0:
|
|
373
|
+
raise InstallFailedError(f'uv tool install failed: {_reason(completed)}')
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _run(command: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
|
377
|
+
return subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _reason(completed: subprocess.CompletedProcess[str]) -> str:
|
|
381
|
+
"""Everything a failing command printed, rather than one line of it.
|
|
382
|
+
|
|
383
|
+
uv puts a lock's schema error on its first line, git puts a missing ref on
|
|
384
|
+
its last, and uv wraps a resolution failure so its last line is half a
|
|
385
|
+
sentence.
|
|
386
|
+
"""
|
|
387
|
+
return (completed.stderr or completed.stdout or '').strip() or f'exit status {completed.returncode}'
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def exit_now(code: int = 0) -> None:
|
|
391
|
+
"""End this process immediately, after flushing what it has written.
|
|
392
|
+
|
|
393
|
+
The sibling of `reexec` for when there is nothing left to run. Both exist
|
|
394
|
+
for the same reason: the environment has been rewritten, so no further
|
|
395
|
+
import can be trusted. `sys.exit` raises SystemExit, which unwinds through
|
|
396
|
+
whatever CLI framework called us and then through interpreter shutdown --
|
|
397
|
+
and both are free to import a module they had not needed yet, from a
|
|
398
|
+
directory that is no longer the one this process started in. `os._exit`
|
|
399
|
+
skips both, which is why the flushes are done here rather than left to the
|
|
400
|
+
shutdown that no longer happens.
|
|
401
|
+
|
|
402
|
+
Never returns.
|
|
403
|
+
"""
|
|
404
|
+
sys.stdout.flush()
|
|
405
|
+
sys.stderr.flush()
|
|
406
|
+
os._exit(code)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def reexec() -> None:
|
|
410
|
+
"""Replace this process with the newly installed one.
|
|
411
|
+
|
|
412
|
+
`uv tool install --force` rewrites the virtual environment this interpreter
|
|
413
|
+
is running inside. Unlike a binary rename -- where the process holds an
|
|
414
|
+
inode and is untouched -- that pulls modules out from under a live process,
|
|
415
|
+
so anything imported afterwards may fail in ways that are very hard to read.
|
|
416
|
+
Re-exec immediately, with everything already imported.
|
|
417
|
+
|
|
418
|
+
Never returns.
|
|
419
|
+
"""
|
|
420
|
+
sys.stdout.flush()
|
|
421
|
+
sys.stderr.flush()
|
|
422
|
+
# Re-exec is the operation, so B606 cannot be designed away. argv[0] is this
|
|
423
|
+
# program and argv is passed through unchanged -- no shell, no interpolation.
|
|
424
|
+
# subprocess plus sys.exit would satisfy the linter and be strictly worse:
|
|
425
|
+
# an extra process, signal forwarding to hand-roll, and both images resident.
|
|
426
|
+
os.execv(sys.argv[0], sys.argv) # noqa: S606 # nosec B606
|
|
@@ -9,12 +9,12 @@ update failure on every invocation.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import atexit
|
|
12
|
+
import datetime as dt
|
|
12
13
|
import os
|
|
13
14
|
import sys
|
|
14
15
|
import time
|
|
15
16
|
from collections.abc import Iterable
|
|
16
17
|
from dataclasses import dataclass
|
|
17
|
-
from datetime import timedelta
|
|
18
18
|
from enum import Enum
|
|
19
19
|
from typing import Any
|
|
20
20
|
from typing import TextIO
|
|
@@ -26,7 +26,7 @@ from pyselfupdate.install import InstallKind
|
|
|
26
26
|
from pyselfupdate.install import read_installation
|
|
27
27
|
from pyselfupdate.updater import check
|
|
28
28
|
|
|
29
|
-
DEFAULT_INTERVAL = timedelta(hours=24)
|
|
29
|
+
DEFAULT_INTERVAL = dt.timedelta(hours=24)
|
|
30
30
|
|
|
31
31
|
# Environment variables. Presence-only, any value including empty -- the
|
|
32
32
|
# NO_COLOR convention -- so that NO_AUTO_UPDATE=0 cannot mean "on". The interval
|
|
@@ -66,7 +66,7 @@ class Outcome:
|
|
|
66
66
|
def notify(
|
|
67
67
|
config: Config,
|
|
68
68
|
*,
|
|
69
|
-
interval: timedelta | None = None,
|
|
69
|
+
interval: dt.timedelta | None = None,
|
|
70
70
|
out: TextIO | None = None,
|
|
71
71
|
defer: bool = True,
|
|
72
72
|
interactive: bool | None = None,
|
|
@@ -141,7 +141,7 @@ def _asking_for_help() -> bool:
|
|
|
141
141
|
def _notify(
|
|
142
142
|
config: Config,
|
|
143
143
|
*,
|
|
144
|
-
interval: timedelta | None,
|
|
144
|
+
interval: dt.timedelta | None,
|
|
145
145
|
out: TextIO | None,
|
|
146
146
|
defer: bool,
|
|
147
147
|
interactive: bool | None,
|
|
@@ -248,7 +248,7 @@ def _is_local_install(tool: str) -> bool:
|
|
|
248
248
|
return installation.kind is InstallKind.GIT and not installation.revision
|
|
249
249
|
|
|
250
250
|
|
|
251
|
-
def _interval(tool: str, override: timedelta | None) -> timedelta:
|
|
251
|
+
def _interval(tool: str, override: dt.timedelta | None) -> dt.timedelta:
|
|
252
252
|
if override is not None:
|
|
253
253
|
return override
|
|
254
254
|
for name in (_tool_variable(tool, 'AUTO_UPDATE_INTERVAL'), FLEET_INTERVAL):
|
|
@@ -260,7 +260,7 @@ def _interval(tool: str, override: timedelta | None) -> timedelta:
|
|
|
260
260
|
return DEFAULT_INTERVAL
|
|
261
261
|
|
|
262
262
|
|
|
263
|
-
def _parse_interval(raw: str) -> timedelta | None:
|
|
263
|
+
def _parse_interval(raw: str) -> dt.timedelta | None:
|
|
264
264
|
"""Parse `30m`, `24h`, `7d`, or a bare number of seconds."""
|
|
265
265
|
raw = raw.strip().lower()
|
|
266
266
|
if not raw:
|
|
@@ -274,10 +274,10 @@ def _parse_interval(raw: str) -> timedelta | None:
|
|
|
274
274
|
return None
|
|
275
275
|
if value < 0:
|
|
276
276
|
return None
|
|
277
|
-
return timedelta(seconds=value * (multiplier or 1))
|
|
277
|
+
return dt.timedelta(seconds=value * (multiplier or 1))
|
|
278
278
|
|
|
279
279
|
|
|
280
|
-
def _is_due(stored: state_module.State, window: timedelta) -> bool:
|
|
280
|
+
def _is_due(stored: state_module.State, window: dt.timedelta) -> bool:
|
|
281
281
|
if not stored.checked_at_epoch:
|
|
282
282
|
return True
|
|
283
283
|
return time.time() - stored.checked_at_epoch >= window.total_seconds()
|
|
@@ -12,13 +12,12 @@ is where `gh` puts the same thing.
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import datetime as dt
|
|
15
16
|
import json
|
|
16
17
|
import os
|
|
17
18
|
import socket
|
|
18
19
|
from dataclasses import asdict
|
|
19
20
|
from dataclasses import dataclass
|
|
20
|
-
from datetime import UTC
|
|
21
|
-
from datetime import datetime
|
|
22
21
|
from pathlib import Path
|
|
23
22
|
|
|
24
23
|
SCHEMA = 1
|
|
@@ -134,9 +133,9 @@ def write(state: State) -> None:
|
|
|
134
133
|
return
|
|
135
134
|
|
|
136
135
|
|
|
137
|
-
def stamp(state: State, moment: datetime | None = None) -> State:
|
|
136
|
+
def stamp(state: State, moment: dt.datetime | None = None) -> State:
|
|
138
137
|
"""Set both timestamp fields to the same instant."""
|
|
139
|
-
now = moment or datetime.now(UTC)
|
|
138
|
+
now = moment or dt.datetime.now(dt.UTC)
|
|
140
139
|
state.checked_at = now.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
141
140
|
state.checked_at_epoch = int(now.timestamp())
|
|
142
141
|
return state
|
|
@@ -82,12 +82,14 @@ def run_update(config: Config, *, check_only: bool = False, skip_changelog: bool
|
|
|
82
82
|
return
|
|
83
83
|
|
|
84
84
|
try:
|
|
85
|
-
install_release(config, result, installation)
|
|
85
|
+
installed = install_release(config, result, installation)
|
|
86
86
|
except SelfUpdateError as error:
|
|
87
87
|
typer.echo(f'✗ {tool} update failed: {error}', err=True)
|
|
88
88
|
raise typer.Exit(1) from error
|
|
89
89
|
|
|
90
90
|
typer.echo(f'✓ {tool} updated: {result.current} → {result.latest}')
|
|
91
|
+
if installed.lock_warning:
|
|
92
|
+
typer.echo(f'! {tool}: {installed.lock_warning}', err=True)
|
|
91
93
|
_echo_changes(subjects)
|
|
92
94
|
exit_now()
|
|
93
95
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from dataclasses import field
|
|
7
8
|
|
|
@@ -9,9 +10,11 @@ from pyselfupdate import version as semver
|
|
|
9
10
|
from pyselfupdate.config import Config
|
|
10
11
|
from pyselfupdate.errors import LocalInstallError
|
|
11
12
|
from pyselfupdate.errors import NoReleaseError
|
|
13
|
+
from pyselfupdate.errors import NotInstalledError
|
|
12
14
|
from pyselfupdate.install import Installation
|
|
13
15
|
from pyselfupdate.install import InstallKind
|
|
14
16
|
from pyselfupdate.install import read_installation
|
|
17
|
+
from pyselfupdate.install import read_lock
|
|
15
18
|
from pyselfupdate.install import reexec
|
|
16
19
|
from pyselfupdate.install import requirement_for
|
|
17
20
|
from pyselfupdate.install import run_install
|
|
@@ -34,10 +37,25 @@ class Result:
|
|
|
34
37
|
release: Release | None = None
|
|
35
38
|
metadata: dict[str, str] = field(default_factory=dict)
|
|
36
39
|
|
|
40
|
+
# True when a git install ran unlocked because its tag carries no uv.lock.
|
|
41
|
+
# An index install reads no lock, so it leaves this False.
|
|
42
|
+
lock_missing: bool = False
|
|
43
|
+
|
|
37
44
|
@property
|
|
38
45
|
def update_available(self) -> bool:
|
|
39
46
|
return self.current != self.latest
|
|
40
47
|
|
|
48
|
+
@property
|
|
49
|
+
def lock_warning(self) -> str:
|
|
50
|
+
"""A line naming the tag when `lock_missing` is set, else an empty string.
|
|
51
|
+
|
|
52
|
+
`run_update` and `update_and_reexec` print it to stderr.
|
|
53
|
+
"""
|
|
54
|
+
if not self.lock_missing:
|
|
55
|
+
return ''
|
|
56
|
+
tag = self.release.install_ref() if self.release else self.latest
|
|
57
|
+
return f'{tag} has no uv.lock, so its dependencies were installed at their newest versions'
|
|
58
|
+
|
|
41
59
|
|
|
42
60
|
def check(config: Config) -> Result:
|
|
43
61
|
"""Report whether a newer release exists, without installing anything."""
|
|
@@ -75,18 +93,24 @@ def install_release(config: Config, result: Result, installation: Installation,
|
|
|
75
93
|
success this interpreter's environment has been rewritten underneath it, so
|
|
76
94
|
the caller may not import anything afterwards -- see
|
|
77
95
|
`pyselfupdate.install.reexec` and `pyselfupdate.install.exit_now`.
|
|
96
|
+
|
|
97
|
+
A git install is held to the `uv.lock` at the release's tag. A tag without
|
|
98
|
+
one installs unlocked and comes back with `lock_missing` set. A lock that
|
|
99
|
+
cannot be read raises `LockUnreadableError` before anything is installed.
|
|
78
100
|
"""
|
|
79
101
|
if not result.update_available or result.release is None:
|
|
80
102
|
return result
|
|
81
103
|
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
ref = result.release.install_ref()
|
|
105
|
+
pins = read_lock(installation.url, ref, extras=installation.extras) if installation.kind is InstallKind.GIT else None
|
|
106
|
+
run_install(requirement_for(installation, ref), quiet=quiet, pins=pins, with_requirements=installation.with_requirements)
|
|
84
107
|
|
|
85
108
|
return Result(
|
|
86
109
|
current=result.current,
|
|
87
110
|
latest=result.latest,
|
|
88
111
|
applied=True,
|
|
89
112
|
release=result.release,
|
|
113
|
+
lock_missing=installation.kind is InstallKind.GIT and pins is None,
|
|
90
114
|
)
|
|
91
115
|
|
|
92
116
|
|
|
@@ -108,10 +132,13 @@ def update_and_reexec(config: Config, *, quiet: bool = True) -> Result:
|
|
|
108
132
|
"""`update`, then replace this process when anything was installed.
|
|
109
133
|
|
|
110
134
|
Returns normally only when nothing was installed; otherwise it does not
|
|
111
|
-
return at all.
|
|
135
|
+
return at all. The caller never sees the `Result` of an install, so this
|
|
136
|
+
writes its `lock_warning` to stderr before the re-exec.
|
|
112
137
|
"""
|
|
113
138
|
result = update(config, quiet=quiet)
|
|
114
139
|
if result.applied:
|
|
140
|
+
if result.lock_warning:
|
|
141
|
+
print(f'! {config.tool}: {result.lock_warning}', file=sys.stderr)
|
|
115
142
|
reexec()
|
|
116
143
|
return result
|
|
117
144
|
|
|
@@ -153,3 +180,9 @@ def _require_updatable(installation: Installation) -> None:
|
|
|
153
180
|
f'so a release version cannot be compared against it; '
|
|
154
181
|
f'reinstall from a tagged release to enable updates'
|
|
155
182
|
)
|
|
183
|
+
if installation._unrebuildable:
|
|
184
|
+
raise NotInstalledError(
|
|
185
|
+
f'{installation.tool} was installed with {", ".join(installation._unrebuildable)}, '
|
|
186
|
+
f'which an update cannot pass back to uv and would drop. '
|
|
187
|
+
f'Reinstall {installation.tool} by hand with `uv tool install --force`'
|
|
188
|
+
)
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
"""Reading and rewriting a uv tool installation.
|
|
2
|
-
|
|
3
|
-
This is the part with no analog in goselfupdate. A Go tool updates by
|
|
4
|
-
replacing one file; a uv tool updates by rebuilding the virtual environment its
|
|
5
|
-
own interpreter is running inside, which is why `update` must be the last thing
|
|
6
|
-
a process does before it exits or re-execs.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import os
|
|
12
|
-
import shutil
|
|
13
|
-
import subprocess
|
|
14
|
-
import sys
|
|
15
|
-
import tomllib
|
|
16
|
-
from dataclasses import dataclass
|
|
17
|
-
from enum import Enum
|
|
18
|
-
from importlib.metadata import PackageNotFoundError
|
|
19
|
-
from importlib.metadata import version as installed_version
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
|
|
22
|
-
from pyselfupdate.errors import InstallFailedError
|
|
23
|
-
from pyselfupdate.errors import NotInstalledError
|
|
24
|
-
|
|
25
|
-
RECEIPT_NAME = 'uv-receipt.toml'
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class InstallKind(Enum):
|
|
29
|
-
"""How a uv tool was installed, which decides whether it may be updated."""
|
|
30
|
-
|
|
31
|
-
GIT = 'git'
|
|
32
|
-
INDEX = 'index'
|
|
33
|
-
LOCAL = 'local'
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@dataclass(frozen=True)
|
|
37
|
-
class Installation:
|
|
38
|
-
"""What uv's own receipt says about an installed tool."""
|
|
39
|
-
|
|
40
|
-
tool: str
|
|
41
|
-
kind: InstallKind
|
|
42
|
-
url: str = ''
|
|
43
|
-
|
|
44
|
-
# The requested revision, empty when the install tracks the default branch.
|
|
45
|
-
# An empty value on a GIT install is the interesting case: the tool was
|
|
46
|
-
# installed from a moving target, so "up to date" has no meaning.
|
|
47
|
-
revision: str = ''
|
|
48
|
-
|
|
49
|
-
def is_updatable(self) -> bool:
|
|
50
|
-
return self.kind is not InstallKind.LOCAL
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def tool_dir() -> Path:
|
|
54
|
-
"""uv's tool directory.
|
|
55
|
-
|
|
56
|
-
Resolved from the environment rather than by running `uv tool dir`, which
|
|
57
|
-
costs a subprocess on a path that runs before every command.
|
|
58
|
-
"""
|
|
59
|
-
override = os.environ.get('UV_TOOL_DIR')
|
|
60
|
-
if override:
|
|
61
|
-
return Path(override).expanduser()
|
|
62
|
-
data_home = os.environ.get('XDG_DATA_HOME')
|
|
63
|
-
base = Path(data_home).expanduser() if data_home else Path.home() / '.local' / 'share'
|
|
64
|
-
return base / 'uv' / 'tools'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def read_installation(tool: str) -> Installation:
|
|
68
|
-
"""Parse uv's receipt for a tool.
|
|
69
|
-
|
|
70
|
-
The receipt is uv's own record of how it installed something, written at
|
|
71
|
-
install time. Reading it beats inferring the same thing at runtime from the
|
|
72
|
-
executable's path, which cannot tell "uv put it there" apart from "someone
|
|
73
|
-
dropped a binary in the same directory".
|
|
74
|
-
"""
|
|
75
|
-
receipt = tool_dir() / tool / RECEIPT_NAME
|
|
76
|
-
if not receipt.is_file():
|
|
77
|
-
raise NotInstalledError(f'{tool} is not installed as a uv tool ({receipt} does not exist)')
|
|
78
|
-
|
|
79
|
-
try:
|
|
80
|
-
payload = tomllib.loads(receipt.read_text(encoding='utf-8'))
|
|
81
|
-
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
82
|
-
raise NotInstalledError(f'cannot read {receipt}: {error}') from error
|
|
83
|
-
|
|
84
|
-
requirements = (payload.get('tool') or {}).get('requirements') or []
|
|
85
|
-
for requirement in requirements:
|
|
86
|
-
if requirement.get('name') != tool:
|
|
87
|
-
continue
|
|
88
|
-
|
|
89
|
-
# A local checkout, however uv spelled it. Reinstalling one would
|
|
90
|
-
# discard the working copy the user is developing against.
|
|
91
|
-
for key in ('directory', 'path', 'editable'):
|
|
92
|
-
if requirement.get(key):
|
|
93
|
-
return Installation(tool, InstallKind.LOCAL, url=str(requirement[key]))
|
|
94
|
-
|
|
95
|
-
git = requirement.get('git')
|
|
96
|
-
if git:
|
|
97
|
-
url, _, query = str(git).partition('?')
|
|
98
|
-
return Installation(tool, InstallKind.GIT, url=url, revision=_revision(query))
|
|
99
|
-
|
|
100
|
-
return Installation(tool, InstallKind.INDEX)
|
|
101
|
-
|
|
102
|
-
raise NotInstalledError(f'{receipt} lists no requirement named {tool}')
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _revision(query: str) -> str:
|
|
106
|
-
"""The `rev=` from a git requirement's query string.
|
|
107
|
-
|
|
108
|
-
uv writes `...git?rev=v1.2.3` for a pinned install and omits the query
|
|
109
|
-
entirely for one that follows the default branch.
|
|
110
|
-
"""
|
|
111
|
-
for part in query.split('&'):
|
|
112
|
-
key, _, value = part.partition('=')
|
|
113
|
-
if key == 'rev' and value:
|
|
114
|
-
return value
|
|
115
|
-
return ''
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def current_version(package: str) -> str:
|
|
119
|
-
"""The running build's version, or an empty string when unknown."""
|
|
120
|
-
try:
|
|
121
|
-
return installed_version(package)
|
|
122
|
-
except PackageNotFoundError:
|
|
123
|
-
return ''
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def requirement_for(installation: Installation, ref: str) -> str:
|
|
127
|
-
"""The requirement string that installs `ref` of an already-installed tool."""
|
|
128
|
-
if installation.kind is InstallKind.GIT:
|
|
129
|
-
return f'{installation.tool} @ git+{installation.url}@{ref}'
|
|
130
|
-
return f'{installation.tool}=={ref.removeprefix("v")}'
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def run_install(requirement: str, *, quiet: bool = True) -> None:
|
|
134
|
-
"""Install a requirement over the existing tool.
|
|
135
|
-
|
|
136
|
-
`--force` is what allows an entry point that already exists to be replaced;
|
|
137
|
-
without it uv refuses rather than overwriting.
|
|
138
|
-
"""
|
|
139
|
-
executable = shutil.which('uv')
|
|
140
|
-
if not executable:
|
|
141
|
-
raise InstallFailedError('uv is not on PATH, so the tool cannot reinstall itself')
|
|
142
|
-
|
|
143
|
-
command = [executable, 'tool', 'install', '--force', requirement]
|
|
144
|
-
if quiet:
|
|
145
|
-
command.insert(1, '--quiet')
|
|
146
|
-
|
|
147
|
-
completed = subprocess.run( # noqa: S603 - argv is built here, never a shell string
|
|
148
|
-
command,
|
|
149
|
-
capture_output=True,
|
|
150
|
-
text=True,
|
|
151
|
-
check=False,
|
|
152
|
-
)
|
|
153
|
-
if completed.returncode != 0:
|
|
154
|
-
detail = (completed.stderr or completed.stdout or '').strip().splitlines()
|
|
155
|
-
message = detail[-1] if detail else f'exit status {completed.returncode}'
|
|
156
|
-
raise InstallFailedError(f'uv tool install failed: {message}')
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def exit_now(code: int = 0) -> None:
|
|
160
|
-
"""End this process immediately, after flushing what it has written.
|
|
161
|
-
|
|
162
|
-
The sibling of `reexec` for when there is nothing left to run. Both exist
|
|
163
|
-
for the same reason: the environment has been rewritten, so no further
|
|
164
|
-
import can be trusted. `sys.exit` raises SystemExit, which unwinds through
|
|
165
|
-
whatever CLI framework called us and then through interpreter shutdown --
|
|
166
|
-
and both are free to import a module they had not needed yet, from a
|
|
167
|
-
directory that is no longer the one this process started in. `os._exit`
|
|
168
|
-
skips both, which is why the flushes are done here rather than left to the
|
|
169
|
-
shutdown that no longer happens.
|
|
170
|
-
|
|
171
|
-
Never returns.
|
|
172
|
-
"""
|
|
173
|
-
sys.stdout.flush()
|
|
174
|
-
sys.stderr.flush()
|
|
175
|
-
os._exit(code)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def reexec() -> None:
|
|
179
|
-
"""Replace this process with the newly installed one.
|
|
180
|
-
|
|
181
|
-
`uv tool install --force` rewrites the virtual environment this interpreter
|
|
182
|
-
is running inside. Unlike a binary rename -- where the process holds an
|
|
183
|
-
inode and is untouched -- that pulls modules out from under a live process,
|
|
184
|
-
so anything imported afterwards may fail in ways that are very hard to read.
|
|
185
|
-
Re-exec immediately, with everything already imported.
|
|
186
|
-
|
|
187
|
-
Never returns.
|
|
188
|
-
"""
|
|
189
|
-
sys.stdout.flush()
|
|
190
|
-
sys.stderr.flush()
|
|
191
|
-
# Re-exec is the operation, so B606 cannot be designed away. argv[0] is this
|
|
192
|
-
# program and argv is passed through unchanged -- no shell, no interpolation.
|
|
193
|
-
# subprocess plus sys.exit would satisfy the linter and be strictly worse:
|
|
194
|
-
# an extra process, signal forwarding to hand-roll, and both images resident.
|
|
195
|
-
os.execv(sys.argv[0], sys.argv) # noqa: S606 # nosec B606
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|