pyselfupdate 0.5.4__tar.gz → 0.6.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.4 → pyselfupdate-0.6.0}/PKG-INFO +5 -3
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/README.md +4 -2
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/pyproject.toml +18 -19
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/__init__.py +4 -0
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/errors.py +32 -2
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/github.py +16 -23
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/install.py +53 -17
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/notifier.py +0 -4
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/typercmd.py +39 -12
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/updater.py +4 -5
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/.gitignore +0 -0
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/LICENSE +0 -0
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/config.py +0 -0
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/py.typed +0 -0
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/source.py +0 -0
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/state.py +0 -0
- {pyselfupdate-0.5.4 → pyselfupdate-0.6.0}/src/pyselfupdate/version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyselfupdate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Self-update and update notification for Python CLIs installed with uv tool
|
|
5
5
|
Project-URL: Repository, https://github.com/datapointchris/pyselfupdate
|
|
6
6
|
Project-URL: Issues, https://github.com/datapointchris/pyselfupdate/issues
|
|
@@ -143,8 +143,8 @@ fail in ways that are hard to read.
|
|
|
143
143
|
|
|
144
144
|
So make `update` the last thing the process does, then call `exit_now`. Or call
|
|
145
145
|
`update_and_reexec`, which replaces the process with the release it installed,
|
|
146
|
-
running the same command line.
|
|
147
|
-
|
|
146
|
+
running the same command line. `update_and_reexec` writes `lock_warning` to
|
|
147
|
+
stderr itself before the re-exec, because its caller never sees the `Result`.
|
|
148
148
|
|
|
149
149
|
`check` reports what `update` would install, and installs nothing:
|
|
150
150
|
|
|
@@ -203,6 +203,8 @@ caller matches a type rather than message text:
|
|
|
203
203
|
| `NotInstalledError` | uv's receipt is missing or unreadable, or holds a `--with` requirement an update would drop |
|
|
204
204
|
| `NoReleaseError` | The source publishes no release, or its newest tag is not a semantic version |
|
|
205
205
|
| `SourceError` | The source could not be reached, or answered with something unusable |
|
|
206
|
+
| `SelfUpdateSourceFailure` | A `SourceError` from a transport or protocol failure, as opposed to a missing release |
|
|
207
|
+
| `RateLimitedError` | A `SelfUpdateSourceFailure` for a spent rate limit. `authenticated` says which ceiling, and `resets_at` when it lifts, or None |
|
|
206
208
|
| `LockUnreadableError` | The release's `uv.lock` could not be read, so nothing was installed |
|
|
207
209
|
| `InstallFailedError` | `uv tool install` ran and failed, or uv is not on `PATH` |
|
|
208
210
|
|
|
@@ -120,8 +120,8 @@ fail in ways that are hard to read.
|
|
|
120
120
|
|
|
121
121
|
So make `update` the last thing the process does, then call `exit_now`. Or call
|
|
122
122
|
`update_and_reexec`, which replaces the process with the release it installed,
|
|
123
|
-
running the same command line.
|
|
124
|
-
|
|
123
|
+
running the same command line. `update_and_reexec` writes `lock_warning` to
|
|
124
|
+
stderr itself before the re-exec, because its caller never sees the `Result`.
|
|
125
125
|
|
|
126
126
|
`check` reports what `update` would install, and installs nothing:
|
|
127
127
|
|
|
@@ -180,6 +180,8 @@ caller matches a type rather than message text:
|
|
|
180
180
|
| `NotInstalledError` | uv's receipt is missing or unreadable, or holds a `--with` requirement an update would drop |
|
|
181
181
|
| `NoReleaseError` | The source publishes no release, or its newest tag is not a semantic version |
|
|
182
182
|
| `SourceError` | The source could not be reached, or answered with something unusable |
|
|
183
|
+
| `SelfUpdateSourceFailure` | A `SourceError` from a transport or protocol failure, as opposed to a missing release |
|
|
184
|
+
| `RateLimitedError` | A `SelfUpdateSourceFailure` for a spent rate limit. `authenticated` says which ceiling, and `resets_at` when it lifts, or None |
|
|
183
185
|
| `LockUnreadableError` | The release's `uv.lock` could not be read, so nothing was installed |
|
|
184
186
|
| `InstallFailedError` | `uv tool install` ran and failed, or uv is not on `PATH` |
|
|
185
187
|
|
|
@@ -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==0.
|
|
45
|
+
"ruff==0.17.1",
|
|
46
46
|
"typer>=0.12.0",
|
|
47
47
|
]
|
|
48
48
|
|
|
@@ -95,6 +95,7 @@ exclude = [
|
|
|
95
95
|
"build",
|
|
96
96
|
"dist",
|
|
97
97
|
]
|
|
98
|
+
extend-exclude = ["*.md"]
|
|
98
99
|
|
|
99
100
|
[tool.ruff.format]
|
|
100
101
|
quote-style = "single"
|
|
@@ -158,12 +159,28 @@ major_on_zero = false
|
|
|
158
159
|
typeCheckingMode = "standard"
|
|
159
160
|
analyzeUnannotatedFunctions = true # or pylance stops analyzing in vscode
|
|
160
161
|
|
|
162
|
+
[tool.hatch.version]
|
|
163
|
+
source = "uv-dynamic-versioning"
|
|
164
|
+
|
|
165
|
+
[tool.uv-dynamic-versioning]
|
|
166
|
+
style = "semver"
|
|
167
|
+
|
|
168
|
+
# A new commit or tag changes the version, so it has to invalidate uv's cache.
|
|
169
|
+
[tool.uv]
|
|
170
|
+
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
|
|
171
|
+
|
|
172
|
+
# The published sdist holds the package and its metadata, and none of the
|
|
173
|
+
# repo's tooling or tests. hatchling's default takes the whole tree.
|
|
174
|
+
[tool.hatch.build.targets.sdist]
|
|
175
|
+
only-include = ["src"]
|
|
176
|
+
|
|
161
177
|
[tool.forge]
|
|
162
178
|
# Keys the shared toolchain standard owns, written by its generator.
|
|
163
179
|
# Dropping one from the template removes it here on the next sync; a key absent
|
|
164
180
|
# from this list belongs to the project and is never touched. Do not hand-edit.
|
|
165
181
|
managed = [
|
|
166
182
|
["ruff", "line-length"],
|
|
183
|
+
["ruff", "extend-exclude"],
|
|
167
184
|
["ruff", "format", "quote-style"],
|
|
168
185
|
["ruff", "format", "indent-style"],
|
|
169
186
|
["ruff", "format", "skip-magic-trailing-comma"],
|
|
@@ -197,24 +214,6 @@ managed = [
|
|
|
197
214
|
# and extra that names them, so a local run and CI cannot disagree.
|
|
198
215
|
pinned = ["ruff"]
|
|
199
216
|
|
|
200
|
-
# The release tag is the version. python-semantic-release finds no file to
|
|
201
|
-
# stamp, so it tags the merge commit itself and writes no release commit.
|
|
202
|
-
# uv_build cannot do this: its metadata has no dynamic version field.
|
|
203
|
-
[tool.hatch.version]
|
|
204
|
-
source = "uv-dynamic-versioning"
|
|
205
|
-
|
|
206
|
-
[tool.uv-dynamic-versioning]
|
|
207
|
-
style = "semver"
|
|
208
|
-
|
|
209
|
-
# A new commit or tag changes the version, so it has to invalidate uv's cache.
|
|
210
|
-
[tool.uv]
|
|
211
|
-
cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
|
|
212
|
-
|
|
213
|
-
# The published sdist holds the package and its metadata, and none of the
|
|
214
|
-
# repo's tooling or tests, as uv_build's did.
|
|
215
|
-
[tool.hatch.build.targets.sdist]
|
|
216
|
-
only-include = ["src"]
|
|
217
|
-
|
|
218
217
|
[build-system]
|
|
219
218
|
requires = ["hatchling", "uv-dynamic-versioning"]
|
|
220
219
|
build-backend = "hatchling.build"
|
|
@@ -24,7 +24,9 @@ from pyselfupdate.errors import LocalInstallError
|
|
|
24
24
|
from pyselfupdate.errors import LockUnreadableError
|
|
25
25
|
from pyselfupdate.errors import NoReleaseError
|
|
26
26
|
from pyselfupdate.errors import NotInstalledError
|
|
27
|
+
from pyselfupdate.errors import RateLimitedError
|
|
27
28
|
from pyselfupdate.errors import SelfUpdateError
|
|
29
|
+
from pyselfupdate.errors import SelfUpdateSourceFailure
|
|
28
30
|
from pyselfupdate.errors import SourceError
|
|
29
31
|
from pyselfupdate.github import GitHubSource
|
|
30
32
|
from pyselfupdate.install import Installation
|
|
@@ -59,9 +61,11 @@ __all__ = [
|
|
|
59
61
|
'NoReleaseError',
|
|
60
62
|
'NotInstalledError',
|
|
61
63
|
'Outcome',
|
|
64
|
+
'RateLimitedError',
|
|
62
65
|
'Release',
|
|
63
66
|
'Result',
|
|
64
67
|
'SelfUpdateError',
|
|
68
|
+
'SelfUpdateSourceFailure',
|
|
65
69
|
'Skip',
|
|
66
70
|
'Source',
|
|
67
71
|
'SourceError',
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
"""Exceptions raised by pyselfupdate.
|
|
2
2
|
|
|
3
3
|
Every failure mode has its own class so callers match on a type rather than on
|
|
4
|
-
message text.
|
|
5
|
-
|
|
4
|
+
message text. Every class derives from `SelfUpdateError`, which is what a caller
|
|
5
|
+
catches when it does not care why an update did not happen. A class that narrows
|
|
6
|
+
another derives from it, so catching `SourceError` still catches a rate limit.
|
|
6
7
|
"""
|
|
7
8
|
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
|
|
8
13
|
|
|
9
14
|
class SelfUpdateError(Exception):
|
|
10
15
|
"""Base class for every error this package raises."""
|
|
@@ -41,6 +46,31 @@ class SourceError(SelfUpdateError):
|
|
|
41
46
|
"""The release source could not be reached or returned something unusable."""
|
|
42
47
|
|
|
43
48
|
|
|
49
|
+
class SelfUpdateSourceFailure(SourceError):
|
|
50
|
+
"""A transport or protocol failure, as opposed to a missing release.
|
|
51
|
+
|
|
52
|
+
Distinct from NoReleaseError so a caller can tell "GitHub is unreachable"
|
|
53
|
+
apart from "this repository has published nothing", which are the same HTTP
|
|
54
|
+
status for a private repository and want different messages.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RateLimitedError(SelfUpdateSourceFailure):
|
|
59
|
+
"""The source's rate limit for this caller is spent.
|
|
60
|
+
|
|
61
|
+
`authenticated` says which ceiling was hit. Without a credential the limit
|
|
62
|
+
is shared by every host behind one address, and supplying a token lifts it.
|
|
63
|
+
With one, nothing is left to configure, and the caller can only wait.
|
|
64
|
+
`resets_at` is when the ceiling lifts, in UTC, or None when the response
|
|
65
|
+
did not say.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, message: str, *, authenticated: bool, resets_at: dt.datetime | None = None) -> None:
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.authenticated = authenticated
|
|
71
|
+
self.resets_at = resets_at
|
|
72
|
+
|
|
73
|
+
|
|
44
74
|
class InstallFailedError(SelfUpdateError):
|
|
45
75
|
"""The install command ran and failed."""
|
|
46
76
|
|
|
@@ -27,8 +27,11 @@ from dataclasses import field
|
|
|
27
27
|
from typing import IO
|
|
28
28
|
|
|
29
29
|
from pyselfupdate.errors import NoReleaseError
|
|
30
|
+
from pyselfupdate.errors import RateLimitedError
|
|
30
31
|
from pyselfupdate.errors import SelfUpdateError
|
|
32
|
+
from pyselfupdate.errors import SelfUpdateSourceFailure
|
|
31
33
|
from pyselfupdate.errors import SourceError
|
|
34
|
+
from pyselfupdate.install import run_command
|
|
32
35
|
from pyselfupdate.source import Release
|
|
33
36
|
|
|
34
37
|
API = 'https://api.github.com'
|
|
@@ -131,7 +134,7 @@ def token_from_command() -> str:
|
|
|
131
134
|
if not binary:
|
|
132
135
|
return ''
|
|
133
136
|
try:
|
|
134
|
-
result =
|
|
137
|
+
result = run_command([binary, *argv[1:]], timeout=COMMAND_TIMEOUT_SECONDS)
|
|
135
138
|
except (OSError, subprocess.SubprocessError):
|
|
136
139
|
return ''
|
|
137
140
|
return result.stdout.strip() if result.returncode == 0 else ''
|
|
@@ -306,15 +309,6 @@ class GitHubSource:
|
|
|
306
309
|
raise SelfUpdateSourceFailure(f'{API}{path} returned invalid JSON') from error
|
|
307
310
|
|
|
308
311
|
|
|
309
|
-
class SelfUpdateSourceFailure(SourceError):
|
|
310
|
-
"""A transport or protocol failure, as opposed to a missing release.
|
|
311
|
-
|
|
312
|
-
Distinct from NoReleaseError so a caller can tell "GitHub is unreachable"
|
|
313
|
-
apart from "this repository has published nothing", which are the same HTTP
|
|
314
|
-
status for a private repository and want different messages.
|
|
315
|
-
"""
|
|
316
|
-
|
|
317
|
-
|
|
318
312
|
def _http_error(owner: str, repo: str, error: urllib.error.HTTPError, *, authenticated: bool) -> SelfUpdateError:
|
|
319
313
|
if error.code == 404:
|
|
320
314
|
# A private repository reached without a token is indistinguishable
|
|
@@ -324,12 +318,14 @@ def _http_error(owner: str, repo: str, error: urllib.error.HTTPError, *, authent
|
|
|
324
318
|
if error.code in (401, 403):
|
|
325
319
|
remaining = error.headers.get('x-ratelimit-remaining') if error.headers else None
|
|
326
320
|
if remaining == '0':
|
|
327
|
-
|
|
321
|
+
resets_at = _reset_moment(error)
|
|
322
|
+
message = _rate_limit_message(authenticated=authenticated, resets_at=resets_at)
|
|
323
|
+
return RateLimitedError(message, authenticated=authenticated, resets_at=resets_at)
|
|
328
324
|
return SelfUpdateSourceFailure(f'GitHub refused the request for {owner}/{repo} ({error.code})')
|
|
329
325
|
return SelfUpdateSourceFailure(f'GitHub returned {error.code} for {owner}/{repo}')
|
|
330
326
|
|
|
331
327
|
|
|
332
|
-
def _rate_limit_message(
|
|
328
|
+
def _rate_limit_message(*, authenticated: bool, resets_at: dt.datetime | None) -> str:
|
|
333
329
|
"""Which ceiling was hit, and what to do about that one.
|
|
334
330
|
|
|
335
331
|
The two are different problems with different fixes, and one sentence for
|
|
@@ -341,7 +337,10 @@ def _rate_limit_message(error: urllib.error.HTTPError, *, authenticated: bool) -
|
|
|
341
337
|
The authenticated case should be rare, so it says when the ceiling lifts
|
|
342
338
|
rather than what to change -- there is nothing to change.
|
|
343
339
|
"""
|
|
344
|
-
|
|
340
|
+
# A wall-clock time rather than "in 43 minutes", because the message is read
|
|
341
|
+
# out of a state file long after the request that produced it and a duration
|
|
342
|
+
# would be counted from the wrong instant.
|
|
343
|
+
resets = f'; resets at {resets_at:%H:%M} UTC' if resets_at else ''
|
|
345
344
|
if authenticated:
|
|
346
345
|
return f"GitHub's authenticated rate limit (5,000/hour) is exhausted{resets}"
|
|
347
346
|
return (
|
|
@@ -350,19 +349,13 @@ def _rate_limit_message(error: urllib.error.HTTPError, *, authenticated: bool) -
|
|
|
350
349
|
)
|
|
351
350
|
|
|
352
351
|
|
|
353
|
-
def
|
|
354
|
-
"""
|
|
355
|
-
|
|
356
|
-
A wall-clock time rather than "in 43 minutes", because the message is read
|
|
357
|
-
out of a state file long after the request that produced it and a duration
|
|
358
|
-
would be counted from the wrong instant.
|
|
359
|
-
"""
|
|
352
|
+
def _reset_moment(error: urllib.error.HTTPError) -> dt.datetime | None:
|
|
353
|
+
"""When the spent limit lifts, in UTC, or None when the header is absent or unusable."""
|
|
360
354
|
header = error.headers.get('x-ratelimit-reset') if error.headers else None
|
|
361
355
|
try:
|
|
362
|
-
|
|
356
|
+
return dt.datetime.fromtimestamp(int(header or ''), tz=dt.UTC)
|
|
363
357
|
except (TypeError, ValueError, OSError, OverflowError):
|
|
364
|
-
return
|
|
365
|
-
return f'; resets at {moment:%H:%M} UTC'
|
|
358
|
+
return None
|
|
366
359
|
|
|
367
360
|
|
|
368
361
|
def _quote(ref: str) -> str:
|
|
@@ -47,6 +47,14 @@ on one line: uv writes every hash as a continuation line, and
|
|
|
47
47
|
`_pins_from_export` reads line by line.
|
|
48
48
|
"""
|
|
49
49
|
|
|
50
|
+
LOCK_READ_TIMEOUT_SECONDS = 120.0
|
|
51
|
+
"""The bound on each of the clone and the export that read a release's lock.
|
|
52
|
+
|
|
53
|
+
Both run before anything is installed, so a stalled remote or a credential
|
|
54
|
+
prompt nobody answers fails the update with `LockUnreadableError` rather than
|
|
55
|
+
holding the command someone typed.
|
|
56
|
+
"""
|
|
57
|
+
|
|
50
58
|
|
|
51
59
|
class InstallKind(Enum):
|
|
52
60
|
"""How a uv tool was installed, which decides whether it may be updated."""
|
|
@@ -64,10 +72,9 @@ class Installation:
|
|
|
64
72
|
kind: InstallKind
|
|
65
73
|
url: str = ''
|
|
66
74
|
|
|
67
|
-
# Whatever followed the `@` at install time
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
# tag or refuses it.
|
|
75
|
+
# Whatever followed the `@` at install time, read from the receipt by
|
|
76
|
+
# `_split_git`. `updater.installed_from_release` reads it as a release tag
|
|
77
|
+
# or refuses it.
|
|
71
78
|
revision: str = ''
|
|
72
79
|
|
|
73
80
|
extras: tuple[str, ...] = ()
|
|
@@ -95,6 +102,7 @@ def tool_dir() -> Path:
|
|
|
95
102
|
`UV_TOOL_DIR` first. Otherwise uv's data directory: `%APPDATA%\\uv` on
|
|
96
103
|
Windows, where uv ignores `XDG_DATA_HOME`, and `$XDG_DATA_HOME/uv` or
|
|
97
104
|
`~/.local/share/uv` everywhere else.
|
|
105
|
+
|
|
98
106
|
Resolved from the environment rather than by running `uv tool dir`, which
|
|
99
107
|
costs a subprocess on a path that runs before every command.
|
|
100
108
|
"""
|
|
@@ -120,7 +128,11 @@ def read_installation(tool: str) -> Installation:
|
|
|
120
128
|
"""
|
|
121
129
|
receipt = tool_dir() / tool / RECEIPT_NAME
|
|
122
130
|
if not receipt.is_file():
|
|
123
|
-
raise NotInstalledError(
|
|
131
|
+
raise NotInstalledError(
|
|
132
|
+
f'{tool} is not installed as a uv tool ({receipt} does not exist). '
|
|
133
|
+
f"Install a release with `uv tool install '{tool} @ git+<repository url>@<release tag>'`, "
|
|
134
|
+
f'or `uv tool install {tool}` from a package index'
|
|
135
|
+
)
|
|
124
136
|
|
|
125
137
|
try:
|
|
126
138
|
payload = tomllib.loads(receipt.read_text(encoding='utf-8'))
|
|
@@ -317,25 +329,37 @@ def read_lock(url: str, ref: str, *, extras: Sequence[str] = ()) -> Pins | None:
|
|
|
317
329
|
a dependent requests of a git dependency, which `_override_extras` restores.
|
|
318
330
|
|
|
319
331
|
Raises `LockUnreadableError` when git or uv is not on PATH, the ref will not
|
|
320
|
-
clone,
|
|
332
|
+
clone, uv will not export its lock, or either outlasts
|
|
333
|
+
`LOCK_READ_TIMEOUT_SECONDS`. None is not a failure:
|
|
321
334
|
`install_release` installs that tag unlocked and sets `lock_missing`.
|
|
322
335
|
"""
|
|
323
336
|
git = shutil.which('git')
|
|
324
337
|
uv = shutil.which('uv')
|
|
325
338
|
if not git or not uv:
|
|
326
339
|
missing = 'git' if not git else 'uv'
|
|
327
|
-
raise LockUnreadableError(
|
|
340
|
+
raise LockUnreadableError(
|
|
341
|
+
f'{missing} is not on PATH, so the uv.lock at {ref} cannot be read. '
|
|
342
|
+
f'Install {missing}, or add the directory holding it to PATH, then run the update again'
|
|
343
|
+
)
|
|
328
344
|
|
|
329
345
|
# A clone that cannot be deleted stays in the system temp, and the update goes ahead.
|
|
330
346
|
with tempfile.TemporaryDirectory(prefix='pyselfupdate-lock-', ignore_cleanup_errors=True) as scratch:
|
|
331
347
|
checkout = Path(scratch) / 'checkout'
|
|
332
|
-
|
|
348
|
+
clone = [git, 'clone', '--quiet', '--depth', '1', '--branch', ref, url, str(checkout)]
|
|
349
|
+
try:
|
|
350
|
+
cloned = run_command(clone, timeout=LOCK_READ_TIMEOUT_SECONDS)
|
|
351
|
+
except subprocess.TimeoutExpired as error:
|
|
352
|
+
raise LockUnreadableError(f'cloning {url} at {ref} to read its uv.lock took longer than {error.timeout:g}s') from error
|
|
333
353
|
if cloned.returncode != 0:
|
|
334
354
|
raise LockUnreadableError(f'could not clone {url} at {ref} to read its uv.lock: {_reason(cloned)}')
|
|
335
355
|
lock = checkout / LOCK_NAME
|
|
336
356
|
if not lock.is_file():
|
|
337
357
|
return None
|
|
338
|
-
|
|
358
|
+
export = [uv, *EXPORT, *chain.from_iterable(('--extra', extra) for extra in extras)]
|
|
359
|
+
try:
|
|
360
|
+
exported = run_command(export, cwd=checkout, timeout=LOCK_READ_TIMEOUT_SECONDS)
|
|
361
|
+
except subprocess.TimeoutExpired as error:
|
|
362
|
+
raise LockUnreadableError(f'uv export of the uv.lock at {ref} took longer than {error.timeout:g}s') from error
|
|
339
363
|
if exported.returncode != 0:
|
|
340
364
|
raise LockUnreadableError(f'uv export would not read the uv.lock at {ref}: {_reason(exported)}')
|
|
341
365
|
try:
|
|
@@ -416,10 +440,16 @@ def run_install(
|
|
|
416
440
|
the way `--with` and `--with-editable` install them. `--force` is what
|
|
417
441
|
allows an entry point that already exists to be replaced; without it uv
|
|
418
442
|
refuses rather than overwriting.
|
|
443
|
+
|
|
444
|
+
The install runs unbounded. Killed part way, it leaves the tool's
|
|
445
|
+
environment half rewritten, which is worse than a slow install.
|
|
419
446
|
"""
|
|
420
447
|
executable = shutil.which('uv')
|
|
421
448
|
if not executable:
|
|
422
|
-
raise InstallFailedError(
|
|
449
|
+
raise InstallFailedError(
|
|
450
|
+
'uv is not on PATH, so the tool cannot reinstall itself. '
|
|
451
|
+
'Add the directory holding uv to PATH, or install uv from https://docs.astral.sh/uv/, then run the update again'
|
|
452
|
+
)
|
|
423
453
|
|
|
424
454
|
with tempfile.TemporaryDirectory(prefix='pyselfupdate-pins-', ignore_cleanup_errors=True) as scratch:
|
|
425
455
|
held = pins.arguments(Path(scratch)) if pins else []
|
|
@@ -431,22 +461,28 @@ def run_install(
|
|
|
431
461
|
command = [executable, 'tool', 'install', '--force', *source_flags, *held, *beside, requirement]
|
|
432
462
|
if quiet:
|
|
433
463
|
command.insert(1, '--quiet')
|
|
434
|
-
completed =
|
|
464
|
+
completed = run_command(command)
|
|
435
465
|
|
|
436
466
|
if completed.returncode != 0:
|
|
437
467
|
raise InstallFailedError(f'uv tool install failed: {_reason(completed)}')
|
|
438
468
|
|
|
439
469
|
|
|
440
|
-
def
|
|
441
|
-
|
|
470
|
+
def run_command(command: list[str], *, cwd: Path | None = None, timeout: float | None = None) -> subprocess.CompletedProcess[str]:
|
|
471
|
+
"""Run a program to completion and capture what it printed.
|
|
472
|
+
|
|
473
|
+
Every process this package starts goes through here. `timeout` bounds the call in seconds. Past it the program is killed and
|
|
474
|
+
`subprocess.TimeoutExpired` is raised, which each caller turns into its own
|
|
475
|
+
failure. None runs unbounded.
|
|
476
|
+
"""
|
|
477
|
+
return subprocess.run(command, cwd=cwd, capture_output=True, text=True, check=False, timeout=timeout)
|
|
442
478
|
|
|
443
479
|
|
|
444
480
|
def _reason(completed: subprocess.CompletedProcess[str]) -> str:
|
|
445
|
-
"""Everything a failing command printed
|
|
481
|
+
"""Everything a failing command printed.
|
|
446
482
|
|
|
447
|
-
uv puts a lock's schema error on its
|
|
448
|
-
|
|
449
|
-
sentence.
|
|
483
|
+
No single line carries the reason: uv puts a lock's schema error on its
|
|
484
|
+
first line, git puts a missing ref on its last, and uv wraps a resolution
|
|
485
|
+
failure so its last line is half a sentence.
|
|
450
486
|
"""
|
|
451
487
|
return (completed.stderr or completed.stdout or '').strip() or f'exit status {completed.returncode}'
|
|
452
488
|
|
|
@@ -124,10 +124,6 @@ def _asking_for_help() -> bool:
|
|
|
124
124
|
screen. Nothing a notice is for happens on a help screen, and the check
|
|
125
125
|
costs a releases-API call and a state write every time.
|
|
126
126
|
|
|
127
|
-
It lands hardest on a reader that walks a tool's help. Reading every
|
|
128
|
-
command's help is one invocation per command plus one for the root, and
|
|
129
|
-
each of them reaches this.
|
|
130
|
-
|
|
131
127
|
A `--` ends the scan, because everything after it is the command's own
|
|
132
128
|
argument. Skipping when it should not have costs a missed notice, which is
|
|
133
129
|
the cheap direction to be wrong in.
|
|
@@ -32,10 +32,39 @@ from pyselfupdate.updater import install_release
|
|
|
32
32
|
from pyselfupdate.updater import require_updatable
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def update_help(tool: str, name: str, markup_mode: str | None) -> str:
|
|
36
|
+
"""The update command's help: one line on what it does, then annotated examples.
|
|
37
|
+
|
|
38
|
+
Each example keeps a line of its own under each of typer's markup modes,
|
|
39
|
+
which lay out a help's later paragraphs differently. Markdown joins a
|
|
40
|
+
paragraph's lines, so the examples sit in a fenced block there. Rich markup
|
|
41
|
+
keeps the lines as written and would print a fence's backticks. Without
|
|
42
|
+
rich, click rewraps every paragraph that does not open with its `\\b`
|
|
43
|
+
marker.
|
|
44
|
+
"""
|
|
45
|
+
command = f'{tool} {name}'
|
|
46
|
+
width = len(command) + len(' --no-changelog') + 2
|
|
47
|
+
examples = '\n'.join(
|
|
48
|
+
f'{call:<{width}}{purpose}'
|
|
49
|
+
for call, purpose in (
|
|
50
|
+
(command, 'install the newest and list its commits'),
|
|
51
|
+
(f'{command} --check', 'only report whether a newer one exists'),
|
|
52
|
+
(f'{command} --no-changelog', 'install it without listing its commits'),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
if markup_mode == 'markdown':
|
|
56
|
+
block = f'```text\n{examples}\n```'
|
|
57
|
+
elif markup_mode == 'rich':
|
|
58
|
+
block = examples
|
|
59
|
+
else:
|
|
60
|
+
block = f'\b\n{examples}'
|
|
61
|
+
return f'Update {tool} to the latest release\n\nExamples:\n\n{block}'
|
|
62
|
+
|
|
63
|
+
|
|
35
64
|
def add_update_command(app: typer.Typer, config: Config, *, name: str = 'update') -> None:
|
|
36
65
|
"""Register `<tool> update` on an existing typer app."""
|
|
37
66
|
|
|
38
|
-
@app.command(name=name, help=
|
|
67
|
+
@app.command(name=name, help=update_help(config.tool, name, app.rich_markup_mode))
|
|
39
68
|
def update_command(
|
|
40
69
|
check_only: bool = typer.Option(False, '--check', help='Report whether an update is available without installing it'),
|
|
41
70
|
skip_changelog: bool = typer.Option(False, '--no-changelog', help='Do not list the commits between versions'),
|
|
@@ -52,17 +81,15 @@ def run_update(config: Config, *, check_only: bool = False, skip_changelog: bool
|
|
|
52
81
|
library report an update in the same words.
|
|
53
82
|
|
|
54
83
|
`check_only` reads the receipt first, as an update does, and refuses the
|
|
55
|
-
same installs.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
transport module. The release it had just installed did not depend on that
|
|
65
|
-
module, so the install had deleted it.
|
|
84
|
+
same installs. `updater.installed_from_release` says why a version alone
|
|
85
|
+
cannot decide.
|
|
86
|
+
|
|
87
|
+
Everything that reaches the network or imports a module happens before the
|
|
88
|
+
install; after it, this process only prints what it already holds and exits
|
|
89
|
+
without unwinding. An import after the install can name a module the new
|
|
90
|
+
release no longer ships, which the install has already deleted. An HTTP
|
|
91
|
+
client's lazy import of its transport is one, and it raises ImportError
|
|
92
|
+
after the update has succeeded.
|
|
66
93
|
"""
|
|
67
94
|
tool = config.tool
|
|
68
95
|
try:
|
|
@@ -60,9 +60,9 @@ class Result:
|
|
|
60
60
|
def check(config: Config) -> Result:
|
|
61
61
|
"""Report whether a newer release exists, without installing anything.
|
|
62
62
|
|
|
63
|
-
Compares versions and reads nothing else. A
|
|
64
|
-
below the release it is ahead of, so a caller reporting the result runs
|
|
63
|
+
Compares versions and reads nothing else. A caller reporting the result runs
|
|
65
64
|
`require_updatable` first, as `update` and `typercmd.run_update` do.
|
|
65
|
+
`installed_from_release` says why a version alone cannot decide.
|
|
66
66
|
"""
|
|
67
67
|
resolved = config.resolved()
|
|
68
68
|
current = _current_version(resolved)
|
|
@@ -97,9 +97,8 @@ def installed_from_release(installation: Installation, tag_prefix: str) -> bool:
|
|
|
97
97
|
|
|
98
98
|
An index install is. A git install is when its revision is a release tag:
|
|
99
99
|
`tag_prefix` followed by a semantic version, the form a release's
|
|
100
|
-
`install_ref` takes.
|
|
101
|
-
|
|
102
|
-
branch from a tag.
|
|
100
|
+
`install_ref` takes. `Installation.revision` holds a branch or a commit in
|
|
101
|
+
the same field, so only the value tells them apart.
|
|
103
102
|
|
|
104
103
|
A build from a branch reports a version that ranks wrongly against a
|
|
105
104
|
release. Versioned from git tags, a branch three commits past v1.2.3 builds
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|