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.
@@ -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.3
1
+ Metadata-Version: 2.4
2
2
  Name: pyselfupdate
3
- Version: 0.4.1
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 CI enforces
60
- that by importing every module into a virtual environment containing nothing
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 now only for a credential neither the environment nor a command
210
- can produce. It is called lazily, for the same reason the command is: the notify
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 CI enforces
36
- that by importing every module into a virtual environment containing nothing
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 now only for a credential neither the environment nor a command
186
- can produce. It is called lazily, for the same reason the command is: the notify
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.4.1"
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>=0.7.0",
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.11.32,<0.12.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.4.1"
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 = { text = "MIT" }
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>=0.7.0",
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.11.32,<0.12.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
- """The tool is not installed as a uv tool, so there is nothing to update."""
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 httpx or requests, because the whole point of
4
- this package is that adding it to a project adds nothing else.
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
- # Reaching for `gh` no longer needs one: that is the default below. This is
172
- # for a credential neither the environment nor a command can produce.
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
- requirement = requirement_for(installation, result.release.install_ref())
83
- run_install(requirement, quiet=quiet)
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