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.
@@ -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='')
@@ -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