pyselfupdate 0.4.0__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.0
3
+ Version: 0.5.0
4
4
  Summary: Self-update and update notification for Python CLIs installed with uv tool
5
5
  Keywords: uv,cli,self-update,update-notifier,release
6
6
  Author: Chris Birch
7
7
  Author-email: Chris Birch <datapointchris@gmail.com>
8
- License: MIT
8
+ License-Expression: MIT
9
+ License-File: LICENSE
9
10
  Classifier: Development Status :: 4 - Beta
10
11
  Classifier: Environment :: Console
11
12
  Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Programming Language :: Python :: 3 :: Only
14
14
  Classifier: Topic :: Software Development :: Libraries
15
15
  Classifier: Topic :: System :: Software Distribution
@@ -56,9 +56,9 @@ version exists, so it silently drifts. The usual fix drags an HTTP client, a
56
56
  TOML parser and a version library into a tool that had none of them.
57
57
 
58
58
  This package has zero runtime dependencies — `urllib` for the network,
59
- `tomllib` for uv's receipt, and its own semver implementation — and 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.
@@ -234,7 +259,7 @@ atomically:
234
259
  ```
235
260
 
236
261
  State, not config and not cache: it persists across runs, it is not authored by
237
- the user, and deleting it changes behaviour rather than merely costing a
262
+ the user, and deleting it changes behavior rather than merely costing a
238
263
  recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
239
264
  is where `gh` puts the same thing.
240
265
 
@@ -263,6 +288,6 @@ names it, and the `NO_AUTO_UPDATE` contract. They do not share an API, because
263
288
  Version precedence is deliberately identical across all three, so a tool and
264
289
  its siblings never disagree about which release is newer.
265
290
 
266
- ## Licence
291
+ ## License
267
292
 
268
293
  MIT
@@ -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.
@@ -210,7 +235,7 @@ atomically:
210
235
  ```
211
236
 
212
237
  State, not config and not cache: it persists across runs, it is not authored by
213
- the user, and deleting it changes behaviour rather than merely costing a
238
+ the user, and deleting it changes behavior rather than merely costing a
214
239
  recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
215
240
  is where `gh` puts the same thing.
216
241
 
@@ -239,6 +264,6 @@ names it, and the `NO_AUTO_UPDATE` contract. They do not share an API, because
239
264
  Version precedence is deliberately identical across all three, so a tool and
240
265
  its siblings never disagree about which release is newer.
241
266
 
242
- ## Licence
267
+ ## License
243
268
 
244
269
  MIT
@@ -1,7 +1,9 @@
1
1
  [project]
2
2
  name = "pyselfupdate"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Self-update and update notification for Python CLIs installed with uv tool"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
5
7
  readme = "README.md"
6
8
  keywords = [
7
9
  "uv",
@@ -14,7 +16,6 @@ classifiers = [
14
16
  "Development Status :: 4 - Beta",
15
17
  "Environment :: Console",
16
18
  "Intended Audience :: Developers",
17
- "License :: OSI Approved :: MIT License",
18
19
  "Programming Language :: Python :: 3 :: Only",
19
20
  "Topic :: Software Development :: Libraries",
20
21
  "Topic :: System :: Software Distribution",
@@ -27,9 +28,6 @@ dependencies = []
27
28
  name = "Chris Birch"
28
29
  email = "datapointchris@gmail.com"
29
30
 
30
- [project.license]
31
- text = "MIT"
32
-
33
31
  [project.optional-dependencies]
34
32
  typer = ["typer>=0.12.0"]
35
33
 
@@ -44,7 +42,7 @@ dev = [
44
42
  "mypy>=1.10.0",
45
43
  "pre-commit>=4.3.0",
46
44
  "pytest>=8.0.0",
47
- "ruff>=0.7.0",
45
+ "ruff==0.12.5",
48
46
  "typer>=0.12.0",
49
47
  ]
50
48
 
@@ -66,6 +64,9 @@ skips = [
66
64
  [tool.codespell]
67
65
  skip = "*.lock"
68
66
  check-filenames = true
67
+ check-hidden = true
68
+ builtin = "clear,rare"
69
+ uri-ignore-words-list = "*"
69
70
 
70
71
  [tool.mypy]
71
72
  pretty = true
@@ -109,6 +110,7 @@ select = [
109
110
  "B",
110
111
  "SIM",
111
112
  "I",
113
+ "ICN",
112
114
  ]
113
115
  ignore = ["SIM108"]
114
116
 
@@ -118,9 +120,21 @@ force-single-line = true
118
120
  [tool.ruff.lint.per-file-ignores]
119
121
  "__init__.py" = ["F401"]
120
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
+
121
136
  [tool.semantic_release]
122
137
  version_toml = ["pyproject.toml:project.version"]
123
- branch = "main"
124
138
  commit_message = "build(release): {version}"
125
139
  build_command = """
126
140
  pip install uv
@@ -178,6 +192,40 @@ managed = [
178
192
  "lint",
179
193
  "isort",
180
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",
181
229
  ],
182
230
  [
183
231
  "ruff",
@@ -212,6 +260,18 @@ managed = [
212
260
  [
213
261
  "codespell",
214
262
  "check-filenames",
263
+ ],
264
+ [
265
+ "codespell",
266
+ "check-hidden",
267
+ ],
268
+ [
269
+ "codespell",
270
+ "builtin",
271
+ ],
272
+ [
273
+ "codespell",
274
+ "uri-ignore-words-list",
215
275
  ],
216
276
  [
217
277
  "pytest",
@@ -229,11 +289,12 @@ managed = [
229
289
  "testpaths",
230
290
  ],
231
291
  ]
292
+ pinned = ["ruff"]
232
293
 
233
294
  [tool.uv.build-backend]
234
295
  module-name = "pyselfupdate"
235
296
  module-root = "src"
236
297
 
237
298
  [build-system]
238
- requires = ["uv_build>=0.11.32,<0.12.0"]
299
+ requires = ["uv_build>=0.12.2,<0.13.0"]
239
300
  build-backend = "uv_build"
@@ -1,16 +1,16 @@
1
1
  [project]
2
2
  name = "pyselfupdate"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Self-update and update notification for Python CLIs installed with uv tool"
5
5
  authors = [{ name = "Chris Birch", email = "datapointchris@gmail.com" }]
6
- license = { 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
 
@@ -58,6 +58,9 @@ skips = ["B311", "B404", "B603"]
58
58
  [tool.codespell]
59
59
  skip = '*.lock'
60
60
  check-filenames = true
61
+ check-hidden = true
62
+ builtin = "clear,rare"
63
+ uri-ignore-words-list = "*"
61
64
 
62
65
 
63
66
  [tool.mypy]
@@ -108,6 +111,7 @@ select = [
108
111
  "B", # flake8-bugbear
109
112
  "SIM", # flake8-simplify
110
113
  "I", # isort
114
+ "ICN", # flake8-import-conventions
111
115
  ]
112
116
  ignore = [
113
117
  "SIM108", # ternary operator — often less readable
@@ -119,9 +123,17 @@ force-single-line = true
119
123
  [tool.ruff.lint.per-file-ignores]
120
124
  "__init__.py" = ["F401"]
121
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
+
122
135
  [tool.semantic_release]
123
136
  version_toml = ["pyproject.toml:project.version"]
124
- branch = "main"
125
137
  commit_message = "build(release): {version}"
126
138
  build_command = """
127
139
  pip install uv
@@ -138,7 +150,7 @@ typeCheckingMode = "standard"
138
150
  analyzeUnannotatedFunctions = true # or pylance stops analyzing in vscode
139
151
 
140
152
  [tool.forge]
141
- # Keys the standard owns, written by `forge repos apply pyproject`.
153
+ # Keys the shared toolchain standard owns, written by its generator.
142
154
  # Dropping one from the template removes it here on the next sync; a key absent
143
155
  # from this list belongs to the project and is never touched. Do not hand-edit.
144
156
  managed = [
@@ -151,6 +163,11 @@ managed = [
151
163
  ["ruff", "lint", "select"],
152
164
  ["ruff", "lint", "ignore"],
153
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"],
154
171
  ["ruff", "lint", "per-file-ignores", "__init__.py"],
155
172
  ["mypy", "pretty"],
156
173
  ["mypy", "ignore_missing_imports"],
@@ -159,13 +176,19 @@ managed = [
159
176
  ["pyright", "typeCheckingMode"],
160
177
  ["pyright", "analyzeUnannotatedFunctions"],
161
178
  ["codespell", "check-filenames"],
179
+ ["codespell", "check-hidden"],
180
+ ["codespell", "builtin"],
181
+ ["codespell", "uri-ignore-words-list"],
162
182
  ["pytest", "ini_options", "verbosity_assertions"],
163
183
  ["pytest", "ini_options", "minversion"],
164
184
  ["pytest", "ini_options", "testpaths"],
165
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"]
166
189
 
167
190
  [build-system]
168
- requires = ["uv_build>=0.11.32,<0.12.0"]
191
+ requires = ["uv_build>=0.12.2,<0.13.0"]
169
192
  build-backend = "uv_build"
170
193
 
171
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',
@@ -18,13 +18,17 @@ class LocalInstallError(SelfUpdateError):
18
18
  """The tool was installed from a local path or as an editable checkout.
19
19
 
20
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
21
+ with no way to tell which is newer. This is the analog of goselfupdate's
22
22
  ErrDevBuild.
23
23
  """
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,19 @@
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
16
+ import http.client
9
17
  import json
10
18
  import os
11
19
  import shlex
@@ -17,8 +25,7 @@ import urllib.request
17
25
  from collections.abc import Callable
18
26
  from dataclasses import dataclass
19
27
  from dataclasses import field
20
- from datetime import UTC
21
- from datetime import datetime
28
+ from typing import IO
22
29
 
23
30
  from pyselfupdate.errors import NoReleaseError
24
31
  from pyselfupdate.errors import SelfUpdateError
@@ -29,6 +36,49 @@ API = 'https://api.github.com'
29
36
  DEFAULT_TIMEOUT = 10.0
30
37
 
31
38
 
39
+ def _origin(url: str) -> tuple[str, str]:
40
+ """Scheme and authority, which is what decides whether a credential travels.
41
+
42
+ Port is part of it. Two ports on one host are two origins, and treating them
43
+ as one is how a redirect to a service sharing a hostname gets the token.
44
+ """
45
+ split = urllib.parse.urlsplit(url)
46
+ return split.scheme, split.netloc
47
+
48
+
49
+ class _CredentialScopedRedirects(urllib.request.HTTPRedirectHandler):
50
+ """Drop `Authorization` when a redirect leaves the origin it was sent to.
51
+
52
+ `urllib.request` carries the header to the new host instead. Measured across
53
+ 3.11, 3.13 and 3.14 with two local servers: a `Bearer` sent to the first
54
+ arrives intact at the second on every one of them. So this is the library's
55
+ to do, at every version it supports, rather than something a floor bump ends.
56
+
57
+ It matters here because `API` is a reassignable module attribute and
58
+ `headers` is caller-supplied, which is what makes a redirect off GitHub
59
+ something other than hypothetical.
60
+ """
61
+
62
+ def redirect_request(
63
+ self,
64
+ req: urllib.request.Request,
65
+ fp: IO[bytes],
66
+ code: int,
67
+ msg: str,
68
+ headers: http.client.HTTPMessage,
69
+ newurl: str,
70
+ ) -> urllib.request.Request | None:
71
+ redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
72
+ if redirected is None or _origin(req.full_url) == _origin(newurl):
73
+ return redirected
74
+ redirected.headers = {name: value for name, value in redirected.headers.items() if name.lower() != 'authorization'}
75
+ return redirected
76
+
77
+
78
+ _OPENER = urllib.request.build_opener(_CredentialScopedRedirects)
79
+ """Built once. Passing a `HTTPRedirectHandler` subclass replaces the default one."""
80
+
81
+
32
82
  # B105 reads any name carrying "token" as a credential. Both hold the name of a
33
83
  # variable and the text of a command; neither ever holds a secret.
34
84
  TOKEN_COMMAND_ENV = 'GITHUB_TOKEN_COMMAND' # nosec B105
@@ -123,8 +173,8 @@ class GitHubSource:
123
173
  # notify gate declines to check at all. That gate is otherwise free, and a
124
174
  # spawn in front of it is the entire cost.
125
175
  #
126
- # Reaching for `gh` no longer needs one: that is the default below. This is
127
- # 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.
128
178
  token_func: Callable[[], str] | None = None
129
179
 
130
180
  timeout: float = DEFAULT_TIMEOUT
@@ -215,7 +265,7 @@ class GitHubSource:
215
265
  def _get(self, path: str):
216
266
  url = f'{API}{path}'
217
267
 
218
- # urlopen honours file:, ftp: and data: as readily as http:. API is a
268
+ # urlopen honors file:, ftp: and data: as readily as http:. API is a
219
269
  # constant here, but it is a module attribute a caller can reassign --
220
270
  # tests do exactly that -- so the scheme is checked rather than assumed.
221
271
  # Without this, setting it to a file: URL turns a release check into an
@@ -235,10 +285,7 @@ class GitHubSource:
235
285
  request.add_header(name, value)
236
286
 
237
287
  try:
238
- # B310 is a call blacklist rather than a dataflow check, so it fires
239
- # on urlopen regardless of the scheme guard above. That guard, and
240
- # test_a_non_http_scheme_is_refused, are the actual defence.
241
- with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310 # nosec B310
288
+ with _OPENER.open(request, timeout=self.timeout) as response:
242
289
  return json.load(response)
243
290
  except urllib.error.HTTPError as error:
244
291
  raise _http_error(self.owner, self.repo, error, authenticated=bool(token)) from error
@@ -301,7 +348,7 @@ def _reset_clock(error: urllib.error.HTTPError) -> str:
301
348
  """
302
349
  header = error.headers.get('x-ratelimit-reset') if error.headers else None
303
350
  try:
304
- moment = datetime.fromtimestamp(int(header or ''), tz=UTC)
351
+ moment = dt.datetime.fromtimestamp(int(header or ''), tz=dt.UTC)
305
352
  except (TypeError, ValueError, OSError, OverflowError):
306
353
  return ''
307
354
  return f'; resets at {moment:%H:%M} UTC'