pyselfupdate 0.1.0__py3-none-any.whl
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/__init__.py +72 -0
- pyselfupdate/config.py +100 -0
- pyselfupdate/errors.py +39 -0
- pyselfupdate/github.py +182 -0
- pyselfupdate/install.py +176 -0
- pyselfupdate/notifier.py +261 -0
- pyselfupdate/py.typed +0 -0
- pyselfupdate/source.py +51 -0
- pyselfupdate/state.py +103 -0
- pyselfupdate/typercmd.py +86 -0
- pyselfupdate/updater.py +132 -0
- pyselfupdate/version.py +176 -0
- pyselfupdate-0.1.0.dist-info/METADATA +203 -0
- pyselfupdate-0.1.0.dist-info/RECORD +15 -0
- pyselfupdate-0.1.0.dist-info/WHEEL +4 -0
pyselfupdate/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Self-update and update notification for tools installed with `uv tool`.
|
|
2
|
+
|
|
3
|
+
Two layers, used independently:
|
|
4
|
+
|
|
5
|
+
from pyselfupdate import Config, notify, update
|
|
6
|
+
|
|
7
|
+
config = Config(tool='syncer', owner='datapointchris')
|
|
8
|
+
|
|
9
|
+
notify(config) # once a day, print one line if behind. Never raises.
|
|
10
|
+
update(config) # install the latest release. Raises on failure.
|
|
11
|
+
|
|
12
|
+
`notify` belongs in a CLI's root callback and its result should be ignored.
|
|
13
|
+
`update` belongs behind an explicit `<tool> update` command, which is the only
|
|
14
|
+
place update failures are ever reported.
|
|
15
|
+
|
|
16
|
+
The package has no third-party dependencies. The optional typer integration
|
|
17
|
+
lives in `pyselfupdate.typercmd` and is installed with the `typer` extra.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from pyselfupdate.config import Config
|
|
21
|
+
from pyselfupdate.errors import InstallFailedError
|
|
22
|
+
from pyselfupdate.errors import InvalidConfigError
|
|
23
|
+
from pyselfupdate.errors import LocalInstallError
|
|
24
|
+
from pyselfupdate.errors import NoReleaseError
|
|
25
|
+
from pyselfupdate.errors import NotInstalledError
|
|
26
|
+
from pyselfupdate.errors import SelfUpdateError
|
|
27
|
+
from pyselfupdate.errors import SourceError
|
|
28
|
+
from pyselfupdate.github import GitHubSource
|
|
29
|
+
from pyselfupdate.install import Installation
|
|
30
|
+
from pyselfupdate.install import InstallKind
|
|
31
|
+
from pyselfupdate.install import read_installation
|
|
32
|
+
from pyselfupdate.notifier import Outcome
|
|
33
|
+
from pyselfupdate.notifier import Skip
|
|
34
|
+
from pyselfupdate.notifier import enabled
|
|
35
|
+
from pyselfupdate.notifier import notify
|
|
36
|
+
from pyselfupdate.source import Release
|
|
37
|
+
from pyselfupdate.source import Source
|
|
38
|
+
from pyselfupdate.state import State
|
|
39
|
+
from pyselfupdate.state import read as read_state
|
|
40
|
+
from pyselfupdate.updater import Result
|
|
41
|
+
from pyselfupdate.updater import changelog
|
|
42
|
+
from pyselfupdate.updater import check
|
|
43
|
+
from pyselfupdate.updater import update
|
|
44
|
+
from pyselfupdate.updater import update_and_reexec
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
'Config',
|
|
48
|
+
'GitHubSource',
|
|
49
|
+
'InstallFailedError',
|
|
50
|
+
'InstallKind',
|
|
51
|
+
'Installation',
|
|
52
|
+
'InvalidConfigError',
|
|
53
|
+
'LocalInstallError',
|
|
54
|
+
'NoReleaseError',
|
|
55
|
+
'NotInstalledError',
|
|
56
|
+
'Outcome',
|
|
57
|
+
'Release',
|
|
58
|
+
'Result',
|
|
59
|
+
'SelfUpdateError',
|
|
60
|
+
'Skip',
|
|
61
|
+
'Source',
|
|
62
|
+
'SourceError',
|
|
63
|
+
'State',
|
|
64
|
+
'changelog',
|
|
65
|
+
'check',
|
|
66
|
+
'enabled',
|
|
67
|
+
'notify',
|
|
68
|
+
'read_installation',
|
|
69
|
+
'read_state',
|
|
70
|
+
'update',
|
|
71
|
+
'update_and_reexec',
|
|
72
|
+
]
|
pyselfupdate/config.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""What to update, and how to reach it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import field
|
|
7
|
+
|
|
8
|
+
from pyselfupdate.errors import InvalidConfigError
|
|
9
|
+
from pyselfupdate.github import GitHubSource
|
|
10
|
+
from pyselfupdate.install import current_version
|
|
11
|
+
from pyselfupdate.source import Source
|
|
12
|
+
|
|
13
|
+
DEFAULT_TIMEOUT = 10.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Config:
|
|
18
|
+
"""Describes one updatable tool.
|
|
19
|
+
|
|
20
|
+
`tool` is the only required field. Everything else either has a working
|
|
21
|
+
default or is derived from it, so the common case is `Config(tool='syncer',
|
|
22
|
+
owner='datapointchris')`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# The uv tool name. Names the receipt directory, the entry point, and the
|
|
26
|
+
# state file, and is what appears in messages.
|
|
27
|
+
tool: str
|
|
28
|
+
|
|
29
|
+
owner: str = ''
|
|
30
|
+
repo: str = ''
|
|
31
|
+
|
|
32
|
+
# The distribution name to read the running version from, when it differs
|
|
33
|
+
# from the tool name. Defaults to `tool`.
|
|
34
|
+
package: str = ''
|
|
35
|
+
|
|
36
|
+
# The running version. Defaults to the installed distribution's metadata,
|
|
37
|
+
# which is correct for anything installed as a uv tool.
|
|
38
|
+
version: str = ''
|
|
39
|
+
|
|
40
|
+
token: str = ''
|
|
41
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
42
|
+
allow_prerelease: bool = False
|
|
43
|
+
|
|
44
|
+
# Selects one release stream in a repository publishing several, as in
|
|
45
|
+
# "cli/" for tags of the form cli/v1.2.3. Configures the default GitHub
|
|
46
|
+
# source and is unused when `source` is supplied.
|
|
47
|
+
tag_prefix: str = ''
|
|
48
|
+
|
|
49
|
+
# Locates releases. Defaults to a GitHubSource built from the fields above.
|
|
50
|
+
source: Source | None = None
|
|
51
|
+
|
|
52
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def require_source(self) -> Source:
|
|
55
|
+
"""The resolved source.
|
|
56
|
+
|
|
57
|
+
`resolved()` always populates `source`, but the field stays optional so
|
|
58
|
+
that constructing a Config does not require one. This is the accessor
|
|
59
|
+
that expresses "past this point it is set", rather than each caller
|
|
60
|
+
asserting it.
|
|
61
|
+
"""
|
|
62
|
+
if self.source is None:
|
|
63
|
+
raise InvalidConfigError('config was not resolved before use')
|
|
64
|
+
return self.source
|
|
65
|
+
|
|
66
|
+
def resolved(self) -> Config:
|
|
67
|
+
"""A copy with every default filled in, so callers can assume they are set."""
|
|
68
|
+
if not self.tool:
|
|
69
|
+
raise InvalidConfigError('tool is required')
|
|
70
|
+
|
|
71
|
+
source = self.source
|
|
72
|
+
owner = self.owner
|
|
73
|
+
repo = self.repo or self.tool
|
|
74
|
+
|
|
75
|
+
if source is None:
|
|
76
|
+
if not owner:
|
|
77
|
+
raise InvalidConfigError('owner is required without a custom source')
|
|
78
|
+
source = GitHubSource(
|
|
79
|
+
owner=owner,
|
|
80
|
+
repo=repo,
|
|
81
|
+
token=self.token,
|
|
82
|
+
timeout=self.timeout,
|
|
83
|
+
allow_prerelease=self.allow_prerelease,
|
|
84
|
+
tag_prefix=self.tag_prefix,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
package = self.package or self.tool
|
|
88
|
+
return Config(
|
|
89
|
+
tool=self.tool,
|
|
90
|
+
owner=owner,
|
|
91
|
+
repo=repo,
|
|
92
|
+
package=package,
|
|
93
|
+
version=self.version or current_version(package),
|
|
94
|
+
token=self.token,
|
|
95
|
+
timeout=self.timeout,
|
|
96
|
+
allow_prerelease=self.allow_prerelease,
|
|
97
|
+
tag_prefix=self.tag_prefix,
|
|
98
|
+
source=source,
|
|
99
|
+
metadata=self.metadata.copy(),
|
|
100
|
+
)
|
pyselfupdate/errors.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Exceptions raised by pyselfupdate.
|
|
2
|
+
|
|
3
|
+
Every failure mode has its own class so callers match on a type rather than on
|
|
4
|
+
message text. The hierarchy is one level deep under `SelfUpdateError`, which is
|
|
5
|
+
what a caller catches when it does not care why an update did not happen.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SelfUpdateError(Exception):
|
|
10
|
+
"""Base class for every error this package raises."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvalidConfigError(SelfUpdateError):
|
|
14
|
+
"""A Config is missing a required field."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocalInstallError(SelfUpdateError):
|
|
18
|
+
"""The tool was installed from a local path or as an editable checkout.
|
|
19
|
+
|
|
20
|
+
Reinstalling would discard a working copy for a release that may be older,
|
|
21
|
+
with no way to tell which is newer. This is the analogue of goselfupdate's
|
|
22
|
+
ErrDevBuild.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotInstalledError(SelfUpdateError):
|
|
27
|
+
"""The tool is not installed as a uv tool, so there is nothing to update."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NoReleaseError(SelfUpdateError):
|
|
31
|
+
"""The source publishes no usable release."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SourceError(SelfUpdateError):
|
|
35
|
+
"""The release source could not be reached or returned something unusable."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InstallFailedError(SelfUpdateError):
|
|
39
|
+
"""The install command ran and failed."""
|
pyselfupdate/github.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""GitHub as a release source.
|
|
2
|
+
|
|
3
|
+
Uses `urllib.request` rather than httpx or requests, because the whole point of
|
|
4
|
+
this package is that adding it to a project adds nothing else.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from dataclasses import field
|
|
16
|
+
|
|
17
|
+
from pyselfupdate.errors import NoReleaseError
|
|
18
|
+
from pyselfupdate.errors import SelfUpdateError
|
|
19
|
+
from pyselfupdate.errors import SourceError
|
|
20
|
+
from pyselfupdate.source import Release
|
|
21
|
+
|
|
22
|
+
API = 'https://api.github.com'
|
|
23
|
+
DEFAULT_TIMEOUT = 10.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def token_from_env() -> str:
|
|
27
|
+
"""A token from the environment, or an empty string.
|
|
28
|
+
|
|
29
|
+
Deliberately does not shell out to `gh auth token`. A library should not
|
|
30
|
+
spawn a subprocess a caller did not ask for, and a caller who wants that
|
|
31
|
+
behaviour can pass the token in. This matches goselfupdate.
|
|
32
|
+
"""
|
|
33
|
+
return os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN') or ''
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class GitHubSource:
|
|
38
|
+
"""Releases published on GitHub.
|
|
39
|
+
|
|
40
|
+
Without a token GitHub allows 60 API requests per hour per IP address and
|
|
41
|
+
rejects private repositories outright.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
owner: str
|
|
45
|
+
repo: str
|
|
46
|
+
token: str = ''
|
|
47
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
48
|
+
allow_prerelease: bool = False
|
|
49
|
+
|
|
50
|
+
# Selects one release stream in a repository publishing several, as in
|
|
51
|
+
# "cli/" for tags of the form cli/v1.2.3. GitHub's /releases/latest cannot
|
|
52
|
+
# express this -- it returns whichever release is newest overall -- so a
|
|
53
|
+
# prefix switches to listing and filtering.
|
|
54
|
+
tag_prefix: str = ''
|
|
55
|
+
|
|
56
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
def latest_release(self) -> Release:
|
|
59
|
+
if self.tag_prefix or self.allow_prerelease:
|
|
60
|
+
return self._latest_from_list()
|
|
61
|
+
return self._latest_from_endpoint()
|
|
62
|
+
|
|
63
|
+
def changelog(self, from_ref: str, to_ref: str) -> list[str]:
|
|
64
|
+
"""Commit subjects between two tags, newest last.
|
|
65
|
+
|
|
66
|
+
Returns an empty list rather than raising: a missing changelog is not a
|
|
67
|
+
reason to fail an update that already succeeded.
|
|
68
|
+
"""
|
|
69
|
+
if not from_ref or not to_ref or from_ref == to_ref:
|
|
70
|
+
return []
|
|
71
|
+
path = f'/repos/{self.owner}/{self.repo}/compare/{_quote(from_ref)}...{_quote(to_ref)}'
|
|
72
|
+
try:
|
|
73
|
+
payload = self._get(path)
|
|
74
|
+
except SelfUpdateError:
|
|
75
|
+
# Every failure, not just transport ones. A tag that GitHub cannot
|
|
76
|
+
# compare -- because the older one was deleted, or the release was
|
|
77
|
+
# cut from a different branch -- returns 404, and reporting that as
|
|
78
|
+
# a failed update after the install already succeeded would be a lie.
|
|
79
|
+
return []
|
|
80
|
+
subjects = []
|
|
81
|
+
for commit in payload.get('commits') or []:
|
|
82
|
+
message = (commit.get('commit') or {}).get('message') or ''
|
|
83
|
+
subject = message.splitlines()[0].strip() if message else ''
|
|
84
|
+
if subject:
|
|
85
|
+
subjects.append(subject)
|
|
86
|
+
return subjects
|
|
87
|
+
|
|
88
|
+
def _latest_from_endpoint(self) -> Release:
|
|
89
|
+
payload = self._get(f'/repos/{self.owner}/{self.repo}/releases/latest')
|
|
90
|
+
tag = payload.get('tag_name') or ''
|
|
91
|
+
if not tag:
|
|
92
|
+
raise NoReleaseError(f'{self.owner}/{self.repo} publishes no release')
|
|
93
|
+
return self._release(payload, tag)
|
|
94
|
+
|
|
95
|
+
def _latest_from_list(self) -> Release:
|
|
96
|
+
# Releases come back newest-first, so the first match is the latest.
|
|
97
|
+
payload = self._get(f'/repos/{self.owner}/{self.repo}/releases?per_page=100')
|
|
98
|
+
if not isinstance(payload, list):
|
|
99
|
+
raise SourceError(f'unexpected response listing releases for {self.owner}/{self.repo}')
|
|
100
|
+
|
|
101
|
+
for entry in payload:
|
|
102
|
+
if entry.get('draft'):
|
|
103
|
+
continue
|
|
104
|
+
if entry.get('prerelease') and not self.allow_prerelease:
|
|
105
|
+
continue
|
|
106
|
+
tag = entry.get('tag_name') or ''
|
|
107
|
+
if not tag or not tag.startswith(self.tag_prefix):
|
|
108
|
+
continue
|
|
109
|
+
return self._release(entry, tag)
|
|
110
|
+
|
|
111
|
+
wanted = f' with prefix {self.tag_prefix!r}' if self.tag_prefix else ''
|
|
112
|
+
raise NoReleaseError(f'{self.owner}/{self.repo} publishes no release{wanted}')
|
|
113
|
+
|
|
114
|
+
def _release(self, payload: dict, tag: str) -> Release:
|
|
115
|
+
return Release(
|
|
116
|
+
tag=tag.removeprefix(self.tag_prefix),
|
|
117
|
+
ref=tag,
|
|
118
|
+
url=payload.get('html_url') or '',
|
|
119
|
+
notes=payload.get('body') or '',
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _get(self, path: str):
|
|
123
|
+
url = f'{API}{path}'
|
|
124
|
+
|
|
125
|
+
# urlopen honours file:, ftp: and data: as readily as http:. API is a
|
|
126
|
+
# constant here, but it is a module attribute a caller can reassign --
|
|
127
|
+
# tests do exactly that -- so the scheme is checked rather than assumed.
|
|
128
|
+
# Without this, setting it to a file: URL turns a release check into an
|
|
129
|
+
# arbitrary file read.
|
|
130
|
+
scheme = urllib.parse.urlparse(url).scheme
|
|
131
|
+
if scheme not in ('https', 'http'):
|
|
132
|
+
raise SourceError(f'refusing to fetch {scheme or "a schemeless URL"}: only http and https are allowed')
|
|
133
|
+
|
|
134
|
+
request = urllib.request.Request(url)
|
|
135
|
+
request.add_header('Accept', 'application/vnd.github+json')
|
|
136
|
+
request.add_header('X-GitHub-Api-Version', '2022-11-28')
|
|
137
|
+
request.add_header('User-Agent', 'pyselfupdate')
|
|
138
|
+
token = self.token or token_from_env()
|
|
139
|
+
if token:
|
|
140
|
+
request.add_header('Authorization', f'Bearer {token}')
|
|
141
|
+
for name, value in self.headers.items():
|
|
142
|
+
request.add_header(name, value)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# B310 is a call blacklist rather than a dataflow check, so it fires
|
|
146
|
+
# on urlopen regardless of the scheme guard above. That guard, and
|
|
147
|
+
# test_a_non_http_scheme_is_refused, are the actual defence.
|
|
148
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310 # nosec B310
|
|
149
|
+
return json.load(response)
|
|
150
|
+
except urllib.error.HTTPError as error:
|
|
151
|
+
raise _http_error(self.owner, self.repo, error) from error
|
|
152
|
+
except urllib.error.URLError as error:
|
|
153
|
+
raise SelfUpdateSourceFailure(f'cannot reach {API}: {error.reason}') from error
|
|
154
|
+
except json.JSONDecodeError as error:
|
|
155
|
+
raise SelfUpdateSourceFailure(f'{API}{path} returned invalid JSON') from error
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class SelfUpdateSourceFailure(SourceError):
|
|
159
|
+
"""A transport or protocol failure, as opposed to a missing release.
|
|
160
|
+
|
|
161
|
+
Distinct from NoReleaseError so a caller can tell "GitHub is unreachable"
|
|
162
|
+
apart from "this repository has published nothing", which are the same HTTP
|
|
163
|
+
status for a private repository and want different messages.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _http_error(owner: str, repo: str, error: urllib.error.HTTPError) -> SelfUpdateError:
|
|
168
|
+
if error.code == 404:
|
|
169
|
+
# A private repository reached without a token is indistinguishable
|
|
170
|
+
# from one that does not exist, and saying so is more useful than
|
|
171
|
+
# reporting a bare 404.
|
|
172
|
+
return NoReleaseError(f'{owner}/{repo} has no releases, or is private and no token was supplied')
|
|
173
|
+
if error.code in (401, 403):
|
|
174
|
+
remaining = error.headers.get('x-ratelimit-remaining') if error.headers else None
|
|
175
|
+
if remaining == '0':
|
|
176
|
+
return SelfUpdateSourceFailure('GitHub API rate limit exceeded; set GITHUB_TOKEN to raise it')
|
|
177
|
+
return SelfUpdateSourceFailure(f'GitHub refused the request for {owner}/{repo} ({error.code})')
|
|
178
|
+
return SelfUpdateSourceFailure(f'GitHub returned {error.code} for {owner}/{repo}')
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _quote(ref: str) -> str:
|
|
182
|
+
return urllib.parse.quote(ref, safe='')
|
pyselfupdate/install.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Reading and rewriting a uv tool installation.
|
|
2
|
+
|
|
3
|
+
This is the part with no analogue 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 reexec() -> None:
|
|
160
|
+
"""Replace this process with the newly installed one.
|
|
161
|
+
|
|
162
|
+
`uv tool install --force` rewrites the virtual environment this interpreter
|
|
163
|
+
is running inside. Unlike a binary rename -- where the process holds an
|
|
164
|
+
inode and is untouched -- that pulls modules out from under a live process,
|
|
165
|
+
so anything imported afterwards may fail in ways that are very hard to read.
|
|
166
|
+
Re-exec immediately, with everything already imported.
|
|
167
|
+
|
|
168
|
+
Never returns.
|
|
169
|
+
"""
|
|
170
|
+
sys.stdout.flush()
|
|
171
|
+
sys.stderr.flush()
|
|
172
|
+
# Re-exec is the operation, so B606 cannot be designed away. argv[0] is this
|
|
173
|
+
# program and argv is passed through unchanged -- no shell, no interpolation.
|
|
174
|
+
# subprocess plus sys.exit would satisfy the linter and be strictly worse:
|
|
175
|
+
# an extra process, signal forwarding to hand-roll, and both images resident.
|
|
176
|
+
os.execv(sys.argv[0], sys.argv) # noqa: S606 # nosec B606
|