pyselfupdate 0.3.2__tar.gz → 0.4.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyselfupdate
3
- Version: 0.3.2
3
+ Version: 0.4.1
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
@@ -18,7 +18,7 @@ Requires-Dist: typer>=0.12.0 ; extra == 'typer'
18
18
  Requires-Python: >=3.11
19
19
  Project-URL: Repository, https://github.com/datapointchris/pyselfupdate
20
20
  Project-URL: Issues, https://github.com/datapointchris/pyselfupdate/issues
21
- Project-URL: Changelog, https://github.com/datapointchris/pyselfupdate/blob/main/CHANGELOG.md
21
+ Project-URL: Changelog, https://github.com/datapointchris/pyselfupdate/releases
22
22
  Provides-Extra: typer
23
23
  Description-Content-Type: text/markdown
24
24
 
@@ -218,7 +218,8 @@ command, and nothing above the `Source` protocol learns either name.
218
218
 
219
219
  ## State
220
220
 
221
- `${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate.json`, written atomically:
221
+ `${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate-<machine>.json`, written
222
+ atomically:
222
223
 
223
224
  ```json
224
225
  {
@@ -233,10 +234,17 @@ command, and nothing above the `Source` protocol learns either name.
233
234
  ```
234
235
 
235
236
  State, not config and not cache: it persists across runs, it is not authored by
236
- the user, and deleting it changes behaviour rather than merely costing a
237
+ the user, and deleting it changes behavior rather than merely costing a
237
238
  recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
238
239
  is where `gh` puts the same thing.
239
240
 
241
+ `<machine>` is the bare lowercased hostname, from `state.machine()`. It is in
242
+ the name because both fields that vary — the version installed here, and the
243
+ instant this box last checked — describe one machine. A state directory shared
244
+ between machines, by a file syncer or a network home directory, otherwise has
245
+ two writers on one path and reports whichever wrote last as the state of all of
246
+ them.
247
+
240
248
  The timestamp is written **before** the network call. `gh` stamps only on
241
249
  success, so a rate-limited or offline user re-hits the API on every invocation
242
250
  until the window resets; an interval exists to bound the request rate, and only
@@ -244,11 +252,10 @@ this ordering actually does that.
244
252
 
245
253
  ## Siblings
246
254
 
247
- The same two-layer design in other languages. **`update` exists in all three;
248
- the `notify` layer, the shared `autoupdate.json` schema and the
249
- `NO_AUTO_UPDATE` contract are implemented in this library and in its bash
250
- sibling, and are still to be added to goselfupdate** — until then goselfupdate
251
- provides the update half only.
255
+ The same two-layer design in other languages. All three carry both layers, and
256
+ they share the `autoupdate-<machine>.json` schema, the machine derivation that
257
+ names it, and the `NO_AUTO_UPDATE` contract. They do not share an API, because
258
+ "update" means a different operation in each.
252
259
 
253
260
  - [goselfupdate](https://github.com/datapointchris/goselfupdate) — replaces a Go binary
254
261
  - [bashselfupdate](https://github.com/datapointchris/bashselfupdate) — moves a git checkout to its newest tag
@@ -256,6 +263,6 @@ provides the update half only.
256
263
  Version precedence is deliberately identical across all three, so a tool and
257
264
  its siblings never disagree about which release is newer.
258
265
 
259
- ## Licence
266
+ ## License
260
267
 
261
268
  MIT
@@ -194,7 +194,8 @@ command, and nothing above the `Source` protocol learns either name.
194
194
 
195
195
  ## State
196
196
 
197
- `${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate.json`, written atomically:
197
+ `${XDG_STATE_HOME:-~/.local/state}/<tool>/autoupdate-<machine>.json`, written
198
+ atomically:
198
199
 
199
200
  ```json
200
201
  {
@@ -209,10 +210,17 @@ command, and nothing above the `Source` protocol learns either name.
209
210
  ```
210
211
 
211
212
  State, not config and not cache: it persists across runs, it is not authored by
212
- the user, and deleting it changes behaviour rather than merely costing a
213
+ the user, and deleting it changes behavior rather than merely costing a
213
214
  recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
214
215
  is where `gh` puts the same thing.
215
216
 
217
+ `<machine>` is the bare lowercased hostname, from `state.machine()`. It is in
218
+ the name because both fields that vary — the version installed here, and the
219
+ instant this box last checked — describe one machine. A state directory shared
220
+ between machines, by a file syncer or a network home directory, otherwise has
221
+ two writers on one path and reports whichever wrote last as the state of all of
222
+ them.
223
+
216
224
  The timestamp is written **before** the network call. `gh` stamps only on
217
225
  success, so a rate-limited or offline user re-hits the API on every invocation
218
226
  until the window resets; an interval exists to bound the request rate, and only
@@ -220,11 +228,10 @@ this ordering actually does that.
220
228
 
221
229
  ## Siblings
222
230
 
223
- The same two-layer design in other languages. **`update` exists in all three;
224
- the `notify` layer, the shared `autoupdate.json` schema and the
225
- `NO_AUTO_UPDATE` contract are implemented in this library and in its bash
226
- sibling, and are still to be added to goselfupdate** — until then goselfupdate
227
- provides the update half only.
231
+ The same two-layer design in other languages. All three carry both layers, and
232
+ they share the `autoupdate-<machine>.json` schema, the machine derivation that
233
+ names it, and the `NO_AUTO_UPDATE` contract. They do not share an API, because
234
+ "update" means a different operation in each.
228
235
 
229
236
  - [goselfupdate](https://github.com/datapointchris/goselfupdate) — replaces a Go binary
230
237
  - [bashselfupdate](https://github.com/datapointchris/bashselfupdate) — moves a git checkout to its newest tag
@@ -232,6 +239,6 @@ provides the update half only.
232
239
  Version precedence is deliberately identical across all three, so a tool and
233
240
  its siblings never disagree about which release is newer.
234
241
 
235
- ## Licence
242
+ ## License
236
243
 
237
244
  MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyselfupdate"
3
- version = "0.3.2"
3
+ version = "0.4.1"
4
4
  description = "Self-update and update notification for Python CLIs installed with uv tool"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -36,7 +36,7 @@ typer = ["typer>=0.12.0"]
36
36
  [project.urls]
37
37
  Repository = "https://github.com/datapointchris/pyselfupdate"
38
38
  Issues = "https://github.com/datapointchris/pyselfupdate/issues"
39
- Changelog = "https://github.com/datapointchris/pyselfupdate/blob/main/CHANGELOG.md"
39
+ Changelog = "https://github.com/datapointchris/pyselfupdate/releases"
40
40
 
41
41
  [dependency-groups]
42
42
  dev = [
@@ -66,6 +66,9 @@ skips = [
66
66
  [tool.codespell]
67
67
  skip = "*.lock"
68
68
  check-filenames = true
69
+ check-hidden = true
70
+ builtin = "clear,rare"
71
+ uri-ignore-words-list = "*"
69
72
 
70
73
  [tool.mypy]
71
74
  pretty = true
@@ -212,6 +215,18 @@ managed = [
212
215
  [
213
216
  "codespell",
214
217
  "check-filenames",
218
+ ],
219
+ [
220
+ "codespell",
221
+ "check-hidden",
222
+ ],
223
+ [
224
+ "codespell",
225
+ "builtin",
226
+ ],
227
+ [
228
+ "codespell",
229
+ "uri-ignore-words-list",
215
230
  ],
216
231
  [
217
232
  "pytest",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyselfupdate"
3
- version = "0.3.2"
3
+ version = "0.4.1"
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
6
  license = { text = "MIT" }
@@ -34,7 +34,7 @@ typer = ["typer>=0.12.0"]
34
34
  [project.urls]
35
35
  Repository = "https://github.com/datapointchris/pyselfupdate"
36
36
  Issues = "https://github.com/datapointchris/pyselfupdate/issues"
37
- Changelog = "https://github.com/datapointchris/pyselfupdate/blob/main/CHANGELOG.md"
37
+ Changelog = "https://github.com/datapointchris/pyselfupdate/releases"
38
38
 
39
39
  [dependency-groups]
40
40
  dev = [
@@ -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]
@@ -138,7 +141,7 @@ typeCheckingMode = "standard"
138
141
  analyzeUnannotatedFunctions = true # or pylance stops analyzing in vscode
139
142
 
140
143
  [tool.forge]
141
- # Keys the standard owns, written by `forge repos apply pyproject`.
144
+ # Keys the shared toolchain standard owns, written by its generator.
142
145
  # Dropping one from the template removes it here on the next sync; a key absent
143
146
  # from this list belongs to the project and is never touched. Do not hand-edit.
144
147
  managed = [
@@ -159,6 +162,9 @@ managed = [
159
162
  ["pyright", "typeCheckingMode"],
160
163
  ["pyright", "analyzeUnannotatedFunctions"],
161
164
  ["codespell", "check-filenames"],
165
+ ["codespell", "check-hidden"],
166
+ ["codespell", "builtin"],
167
+ ["codespell", "uri-ignore-words-list"],
162
168
  ["pytest", "ini_options", "verbosity_assertions"],
163
169
  ["pytest", "ini_options", "minversion"],
164
170
  ["pytest", "ini_options", "testpaths"],
@@ -18,7 +18,7 @@ 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
 
@@ -6,6 +6,7 @@ this package is that adding it to a project adds nothing else.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import http.client
9
10
  import json
10
11
  import os
11
12
  import shlex
@@ -19,6 +20,7 @@ from dataclasses import dataclass
19
20
  from dataclasses import field
20
21
  from datetime import UTC
21
22
  from datetime import datetime
23
+ from typing import IO
22
24
 
23
25
  from pyselfupdate.errors import NoReleaseError
24
26
  from pyselfupdate.errors import SelfUpdateError
@@ -29,6 +31,49 @@ API = 'https://api.github.com'
29
31
  DEFAULT_TIMEOUT = 10.0
30
32
 
31
33
 
34
+ def _origin(url: str) -> tuple[str, str]:
35
+ """Scheme and authority, which is what decides whether a credential travels.
36
+
37
+ Port is part of it. Two ports on one host are two origins, and treating them
38
+ as one is how a redirect to a service sharing a hostname gets the token.
39
+ """
40
+ split = urllib.parse.urlsplit(url)
41
+ return split.scheme, split.netloc
42
+
43
+
44
+ class _CredentialScopedRedirects(urllib.request.HTTPRedirectHandler):
45
+ """Drop `Authorization` when a redirect leaves the origin it was sent to.
46
+
47
+ `urllib.request` carries the header to the new host instead. Measured across
48
+ 3.11, 3.13 and 3.14 with two local servers: a `Bearer` sent to the first
49
+ arrives intact at the second on every one of them. So this is the library's
50
+ to do, at every version it supports, rather than something a floor bump ends.
51
+
52
+ It matters here because `API` is a reassignable module attribute and
53
+ `headers` is caller-supplied, which is what makes a redirect off GitHub
54
+ something other than hypothetical.
55
+ """
56
+
57
+ def redirect_request(
58
+ self,
59
+ req: urllib.request.Request,
60
+ fp: IO[bytes],
61
+ code: int,
62
+ msg: str,
63
+ headers: http.client.HTTPMessage,
64
+ newurl: str,
65
+ ) -> urllib.request.Request | None:
66
+ redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
67
+ if redirected is None or _origin(req.full_url) == _origin(newurl):
68
+ return redirected
69
+ redirected.headers = {name: value for name, value in redirected.headers.items() if name.lower() != 'authorization'}
70
+ return redirected
71
+
72
+
73
+ _OPENER = urllib.request.build_opener(_CredentialScopedRedirects)
74
+ """Built once. Passing a `HTTPRedirectHandler` subclass replaces the default one."""
75
+
76
+
32
77
  # B105 reads any name carrying "token" as a credential. Both hold the name of a
33
78
  # variable and the text of a command; neither ever holds a secret.
34
79
  TOKEN_COMMAND_ENV = 'GITHUB_TOKEN_COMMAND' # nosec B105
@@ -215,7 +260,7 @@ class GitHubSource:
215
260
  def _get(self, path: str):
216
261
  url = f'{API}{path}'
217
262
 
218
- # urlopen honours file:, ftp: and data: as readily as http:. API is a
263
+ # urlopen honors file:, ftp: and data: as readily as http:. API is a
219
264
  # constant here, but it is a module attribute a caller can reassign --
220
265
  # tests do exactly that -- so the scheme is checked rather than assumed.
221
266
  # Without this, setting it to a file: URL turns a release check into an
@@ -235,10 +280,7 @@ class GitHubSource:
235
280
  request.add_header(name, value)
236
281
 
237
282
  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
283
+ with _OPENER.open(request, timeout=self.timeout) as response:
242
284
  return json.load(response)
243
285
  except urllib.error.HTTPError as error:
244
286
  raise _http_error(self.owner, self.repo, error, authenticated=bool(token)) from error
@@ -1,6 +1,6 @@
1
1
  """Reading and rewriting a uv tool installation.
2
2
 
3
- This is the part with no analogue in goselfupdate. A Go tool updates by
3
+ This is the part with no analog in goselfupdate. A Go tool updates by
4
4
  replacing one file; a uv tool updates by rebuilding the virtual environment its
5
5
  own interpreter is running inside, which is why `update` must be the last thing
6
6
  a process does before it exits or re-execs.
@@ -1,11 +1,11 @@
1
- """The per-tool state file.
1
+ """The per-tool, per-machine state file.
2
2
 
3
3
  One schema, shared byte-for-byte with goselfupdate and bashselfupdate, so that
4
4
  any tool can read any other tool's state and a single dashboard can glob
5
- `~/.local/state/*/autoupdate.json` with no per-tool knowledge.
5
+ `~/.local/state/*/autoupdate-*.json` with no per-tool knowledge.
6
6
 
7
7
  State, not config and not cache: it persists across runs, it is not authored by
8
- the user, and deleting it changes behaviour rather than merely costing a
8
+ the user, and deleting it changes behavior rather than merely costing a
9
9
  recompute. That is `XDG_STATE_HOME` by the Base Directory specification, and it
10
10
  is where `gh` puts the same thing.
11
11
  """
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
  import os
17
+ import socket
17
18
  from dataclasses import asdict
18
19
  from dataclasses import dataclass
19
20
  from datetime import UTC
@@ -21,7 +22,45 @@ from datetime import datetime
21
22
  from pathlib import Path
22
23
 
23
24
  SCHEMA = 1
24
- FILENAME = 'autoupdate.json'
25
+
26
+ #: Names the file when the host cannot be read, so a filename is always
27
+ #: well-formed and two unidentifiable boxes collide only with each other.
28
+ UNKNOWN_MACHINE = 'unknown'
29
+
30
+
31
+ def machine() -> str:
32
+ """The box this process runs on: bare hostname, no domain, lowercased.
33
+
34
+ goselfupdate and bashselfupdate derive it the same way, so the three write
35
+ interleaved files a single reader can enumerate.
36
+ """
37
+ try:
38
+ return canonical_machine(socket.gethostname())
39
+ except OSError:
40
+ return UNKNOWN_MACHINE
41
+
42
+
43
+ def canonical_machine(name: str) -> str:
44
+ """A hostname from any source, reduced to the form `machine` records.
45
+
46
+ A hostname reaches a reader fully qualified as often as bare, so a
47
+ comparison against a recorded name has to canonicalize both sides or it
48
+ fails on every host.
49
+ """
50
+ return name.strip().lower().split('.')[0] or UNKNOWN_MACHINE
51
+
52
+
53
+ def filename(machine_name: str) -> str:
54
+ """The file one machine writes inside a tool's state directory.
55
+
56
+ The machine is part of the name because a state directory is a synced
57
+ directory on some installations, and a file syncer has no merge: two boxes
58
+ writing one path leaves one winner plus a conflict copy nobody reads. Both
59
+ fields this file carries — the version installed here and the instant this
60
+ box last checked — describe one machine, so the split costs nothing and
61
+ makes the collision unreachable.
62
+ """
63
+ return f'autoupdate-{machine_name}.json'
25
64
 
26
65
 
27
66
  @dataclass
@@ -54,7 +93,8 @@ def state_home() -> Path:
54
93
 
55
94
 
56
95
  def state_path(tool: str) -> Path:
57
- return state_home() / tool / FILENAME
96
+ """Where this machine's state for a tool lives."""
97
+ return state_home() / tool / filename(machine())
58
98
 
59
99
 
60
100
  def read(tool: str) -> State:
@@ -23,7 +23,7 @@ from pyselfupdate.source import Release
23
23
  class Result:
24
24
  """What an update found, whether or not it installed anything."""
25
25
 
26
- # The running version, canonicalised with a leading "v".
26
+ # The running version, canonicalized with a leading "v".
27
27
  current: str
28
28
 
29
29
  # The version now installed, or the one `update` would install after a
@@ -129,7 +129,7 @@ def changelog(config: Config, from_version: str, to_version: str) -> list[str]:
129
129
 
130
130
 
131
131
  def _current_version(config: Config) -> str:
132
- """The running version, canonicalised, rejecting one that is not a release.
132
+ """The running version, canonicalized, rejecting one that is not a release.
133
133
 
134
134
  An install that tracks a git branch rather than a tag reports whatever
135
135
  version was in pyproject.toml when it was built, which says nothing about
@@ -2,12 +2,12 @@
2
2
 
3
3
  Implemented here rather than taken from `packaging` so that this package has no
4
4
  third-party dependencies. `packaging` also implements PEP 440, whose precedence
5
- rules differ from semver's in ways that matter: it normalises `1.0.0-rc.1` to
5
+ rules differ from semver's in ways that matter: it normalizes `1.0.0-rc.1` to
6
6
  `1.0.0rc1` and orders `1.0.0.post1` above `1.0.0`, neither of which describes a
7
7
  git tag.
8
8
 
9
9
  Precedence follows https://semver.org section 11, and is byte-for-byte the same
10
- behaviour as goselfupdate's semver.go so that the two libraries never disagree
10
+ behavior as goselfupdate's semver.go so that the two libraries never disagree
11
11
  about which of two releases is newer.
12
12
  """
13
13