pyselfupdate 0.2.2__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyselfupdate
3
- Version: 0.2.2
3
+ Version: 0.3.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
@@ -167,34 +167,54 @@ Config(
167
167
  repo='mytool', # defaults to tool
168
168
  package='mytool', # distribution name, defaults to tool
169
169
  version='1.2.3', # defaults to the installed distribution's metadata
170
- token='', # defaults to $GITHUB_TOKEN, then $GH_TOKEN, then token_func
171
- token_func=None, # called only when a request is made; for a credential that costs a subprocess
170
+ token='', # see Authentication below; you almost certainly want the default
171
+ token_func=None, # a source of your own, tried before $GITHUB_TOKEN_COMMAND
172
172
  tag_prefix='', # e.g. 'cli/' for tags like cli/v1.2.3
173
173
  allow_prerelease=False,
174
174
  source=None, # a custom Source; anything with latest_release()
175
175
  )
176
176
  ```
177
177
 
178
- Without a token, GitHub allows 60 API requests per hour per IP and rejects
179
- private repositories outright. One check per day per tool is far inside that; a
180
- shared egress address is not.
178
+ ## Authentication
181
179
 
182
- A private repository therefore needs a real token, and the usual source is the
183
- `gh` CLI. Pass it as `token_func`, not `token`:
180
+ **Authenticated by default. Configure nothing.** `GitHubSource` runs `gh auth
181
+ token` when a request is about to be made, and sends what it prints.
184
182
 
185
- ```python
186
- def gh_token() -> str:
187
- result = subprocess.run(['gh', 'auth', 'token'], capture_output=True, text=True)
188
- return result.stdout.strip() if result.returncode == 0 else ''
183
+ The alternative is not "no credential". It is 60 requests an hour, charged **per
184
+ IP address** and shared with every other anonymous caller behind the same
185
+ egress. A default that has to be opted into is a default nobody sets.
186
+
187
+ Four sources, first non-empty wins:
188
+
189
+ | Source | Set by | Default |
190
+ | --- | --- | --- |
191
+ | `Config.token` | you, in code | unset |
192
+ | `$GITHUB_TOKEN`, then `$GH_TOKEN` | whoever runs your CLI | unset |
193
+ | `token_func()` | you, in code | unset |
194
+ | `$GITHUB_TOKEN_COMMAND` | whoever runs your CLI | `gh auth token` |
189
195
 
196
+ `$GITHUB_TOKEN_COMMAND` both redirects and disables, which is what a switch has
197
+ to do to be worth having:
190
198
 
191
- Config(tool='mytool', owner='you', token_func=gh_token)
199
+ ```bash
200
+ GITHUB_TOKEN_COMMAND='pass show github/token' # use this instead
201
+ GITHUB_TOKEN_COMMAND='op read op://vault/gh/token'
202
+ GITHUB_TOKEN_COMMAND='' # run nothing, stay anonymous
192
203
  ```
193
204
 
194
- `token_func` is called only when a request is actually about to be made.
195
- Resolving the token eagerly into `token` instead puts that subprocess in front
196
- of every invocation of your CLI — including the overwhelming majority where the
197
- notify gate declines to check at all, which is otherwise free.
205
+ It never raises. A command that is not installed, exits non-zero, or takes
206
+ longer than ten seconds degrades to an unauthenticated request, which still
207
+ works against a public repository.
208
+
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
211
+ gate resolves a `Config` on every invocation and declines most of them without
212
+ reaching the network, and a subprocess in front of that gate is the entire cost
213
+ worth avoiding.
214
+
215
+ **This lives on `GitHubSource`, not on `Config`.** A credential is the host's
216
+ business — a `Source` for another forge brings its own variable and its own
217
+ command, and nothing above the `Source` protocol learns either name.
198
218
 
199
219
  ## State
200
220
 
@@ -143,34 +143,54 @@ Config(
143
143
  repo='mytool', # defaults to tool
144
144
  package='mytool', # distribution name, defaults to tool
145
145
  version='1.2.3', # defaults to the installed distribution's metadata
146
- token='', # defaults to $GITHUB_TOKEN, then $GH_TOKEN, then token_func
147
- token_func=None, # called only when a request is made; for a credential that costs a subprocess
146
+ token='', # see Authentication below; you almost certainly want the default
147
+ token_func=None, # a source of your own, tried before $GITHUB_TOKEN_COMMAND
148
148
  tag_prefix='', # e.g. 'cli/' for tags like cli/v1.2.3
149
149
  allow_prerelease=False,
150
150
  source=None, # a custom Source; anything with latest_release()
151
151
  )
152
152
  ```
153
153
 
154
- Without a token, GitHub allows 60 API requests per hour per IP and rejects
155
- private repositories outright. One check per day per tool is far inside that; a
156
- shared egress address is not.
154
+ ## Authentication
157
155
 
158
- A private repository therefore needs a real token, and the usual source is the
159
- `gh` CLI. Pass it as `token_func`, not `token`:
156
+ **Authenticated by default. Configure nothing.** `GitHubSource` runs `gh auth
157
+ token` when a request is about to be made, and sends what it prints.
160
158
 
161
- ```python
162
- def gh_token() -> str:
163
- result = subprocess.run(['gh', 'auth', 'token'], capture_output=True, text=True)
164
- return result.stdout.strip() if result.returncode == 0 else ''
159
+ The alternative is not "no credential". It is 60 requests an hour, charged **per
160
+ IP address** and shared with every other anonymous caller behind the same
161
+ egress. A default that has to be opted into is a default nobody sets.
162
+
163
+ Four sources, first non-empty wins:
164
+
165
+ | Source | Set by | Default |
166
+ | --- | --- | --- |
167
+ | `Config.token` | you, in code | unset |
168
+ | `$GITHUB_TOKEN`, then `$GH_TOKEN` | whoever runs your CLI | unset |
169
+ | `token_func()` | you, in code | unset |
170
+ | `$GITHUB_TOKEN_COMMAND` | whoever runs your CLI | `gh auth token` |
165
171
 
172
+ `$GITHUB_TOKEN_COMMAND` both redirects and disables, which is what a switch has
173
+ to do to be worth having:
166
174
 
167
- Config(tool='mytool', owner='you', token_func=gh_token)
175
+ ```bash
176
+ GITHUB_TOKEN_COMMAND='pass show github/token' # use this instead
177
+ GITHUB_TOKEN_COMMAND='op read op://vault/gh/token'
178
+ GITHUB_TOKEN_COMMAND='' # run nothing, stay anonymous
168
179
  ```
169
180
 
170
- `token_func` is called only when a request is actually about to be made.
171
- Resolving the token eagerly into `token` instead puts that subprocess in front
172
- of every invocation of your CLI — including the overwhelming majority where the
173
- notify gate declines to check at all, which is otherwise free.
181
+ It never raises. A command that is not installed, exits non-zero, or takes
182
+ longer than ten seconds degrades to an unauthenticated request, which still
183
+ works against a public repository.
184
+
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
187
+ gate resolves a `Config` on every invocation and declines most of them without
188
+ reaching the network, and a subprocess in front of that gate is the entire cost
189
+ worth avoiding.
190
+
191
+ **This lives on `GitHubSource`, not on `Config`.** A credential is the host's
192
+ business — a `Source` for another forge brings its own variable and its own
193
+ command, and nothing above the `Source` protocol learns either name.
174
194
 
175
195
  ## State
176
196
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyselfupdate"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Self-update and update notification for Python CLIs installed with uv tool"
5
5
  readme = "README.md"
6
6
  keywords = [
@@ -74,9 +74,9 @@ check_untyped_defs = false
74
74
  warn_return_any = false
75
75
 
76
76
  [tool.pytest.ini_options]
77
- addopts = "-vv"
78
77
  minversion = "8.0"
79
78
  testpaths = ["tests"]
79
+ verbosity_assertions = 2
80
80
 
81
81
  [tool.refurb]
82
82
  enable_all = true
@@ -216,7 +216,7 @@ managed = [
216
216
  [
217
217
  "pytest",
218
218
  "ini_options",
219
- "addopts",
219
+ "verbosity_assertions",
220
220
  ],
221
221
  [
222
222
  "pytest",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyselfupdate"
3
- version = "0.2.2"
3
+ version = "0.3.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
6
  license = { text = "MIT" }
@@ -71,9 +71,9 @@ warn_return_any = false
71
71
  # `pyright` so both basedpyright and Pylance read it.
72
72
 
73
73
  [tool.pytest.ini_options]
74
- addopts = "-vv"
75
74
  minversion = "8.0"
76
75
  testpaths = ["tests"]
76
+ verbosity_assertions = 2
77
77
 
78
78
 
79
79
  [tool.refurb]
@@ -138,7 +138,7 @@ typeCheckingMode = "standard"
138
138
  analyzeUnannotatedFunctions = true # or pylance stops analyzing in vscode
139
139
 
140
140
  [tool.forge]
141
- # Keys the standard owns, written by `forge dies run maintenance/sync-pyproject.sh`.
141
+ # Keys the standard owns, written by `forge repos apply pyproject`.
142
142
  # Dropping one from the template removes it here on the next sync; a key absent
143
143
  # from this list belongs to the project and is never touched. Do not hand-edit.
144
144
  managed = [
@@ -159,7 +159,7 @@ managed = [
159
159
  ["pyright", "typeCheckingMode"],
160
160
  ["pyright", "analyzeUnannotatedFunctions"],
161
161
  ["codespell", "check-filenames"],
162
- ["pytest", "ini_options", "addopts"],
162
+ ["pytest", "ini_options", "verbosity_assertions"],
163
163
  ["pytest", "ini_options", "minversion"],
164
164
  ["pytest", "ini_options", "testpaths"],
165
165
  ]
@@ -8,12 +8,17 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import os
11
+ import shlex
12
+ import shutil
13
+ import subprocess
11
14
  import urllib.error
12
15
  import urllib.parse
13
16
  import urllib.request
14
17
  from collections.abc import Callable
15
18
  from dataclasses import dataclass
16
19
  from dataclasses import field
20
+ from datetime import UTC
21
+ from datetime import datetime
17
22
 
18
23
  from pyselfupdate.errors import NoReleaseError
19
24
  from pyselfupdate.errors import SelfUpdateError
@@ -24,36 +29,100 @@ API = 'https://api.github.com'
24
29
  DEFAULT_TIMEOUT = 10.0
25
30
 
26
31
 
32
+ TOKEN_COMMAND_ENV = 'GITHUB_TOKEN_COMMAND'
33
+
34
+ DEFAULT_TOKEN_COMMAND = 'gh auth token'
35
+ """What runs when nothing overrides it.
36
+
37
+ Authenticating is the default because the alternative is not "no credential" but
38
+ "sixty requests an hour, charged per IP address and shared by every host behind
39
+ one egress". Measured 2026-08-21 across one household: two machines checking on a
40
+ timer held that pool at zero for whole hours, and every tool on the network that
41
+ asked anonymously was refused. A default that has to be opted into is a default
42
+ nobody sets, and eleven of fourteen tools here had not.
43
+ """
44
+
45
+
27
46
  def token_from_env() -> str:
28
- """A token from the environment, or an empty string.
47
+ """A token from the environment, or an empty string."""
48
+ return os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN') or ''
49
+
29
50
 
30
- Deliberately does not shell out to `gh auth token`. A library should not
31
- spawn a subprocess a caller did not ask for, and a caller who wants that
32
- behaviour can pass the token in. This matches goselfupdate.
51
+ def token_from_command() -> str:
52
+ """A token from `$GITHUB_TOKEN_COMMAND`, or from `gh auth token`.
53
+
54
+ One lever that both redirects and disables, which is what a switch has to do
55
+ to be worth having. Unset runs the default; set to a command runs that one;
56
+ set to empty runs nothing and the request goes out unauthenticated.
57
+
58
+ GITHUB_TOKEN_COMMAND='pass show github/token'
59
+ GITHUB_TOKEN_COMMAND=''
60
+
61
+ Named for the thing it produces rather than for turning something off. A
62
+ `NO_GH_TOKEN` cannot say "use this other source" and reads as a claim about
63
+ whether one exists rather than an instruction about whether to use one.
64
+
65
+ This lives on `GitHubSource` rather than on `Config`, because the credential
66
+ is the host's business. A source for another forge brings its own variable
67
+ and its own command, and nothing above `Source` learns either name.
68
+
69
+ Never raises. Every failure -- no such command, a non-zero exit, a binary
70
+ that is not installed -- degrades to an unauthenticated request, which still
71
+ works against a public repository.
33
72
  """
34
- return os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN') or ''
73
+ command = os.environ.get(TOKEN_COMMAND_ENV, DEFAULT_TOKEN_COMMAND)
74
+ argv = shlex.split(command)
75
+ if not argv:
76
+ return ''
77
+
78
+ # Resolved to a full path so the call is not a partial-path lookup, which is
79
+ # what bandit's B607 is about and what makes a PATH entry able to answer.
80
+ binary = shutil.which(argv[0])
81
+ if not binary:
82
+ return ''
83
+ try:
84
+ result = subprocess.run([binary, *argv[1:]], capture_output=True, text=True, check=False, timeout=COMMAND_TIMEOUT_SECONDS) # noqa: S603
85
+ except (OSError, subprocess.SubprocessError):
86
+ return ''
87
+ return result.stdout.strip() if result.returncode == 0 else ''
88
+
89
+
90
+ COMMAND_TIMEOUT_SECONDS = 10.0
91
+ """A credential helper that hangs must not hang the command someone typed.
92
+
93
+ `gh` is a local read, but the variable takes an arbitrary command and a password
94
+ manager can block on a locked vault or a touch prompt that nobody is there to
95
+ answer -- and the update check runs unattended on a timer.
96
+ """
35
97
 
36
98
 
37
99
  @dataclass
38
100
  class GitHubSource:
39
101
  """Releases published on GitHub.
40
102
 
41
- Without a token GitHub allows 60 API requests per hour per IP address and
42
- rejects private repositories outright.
103
+ Authenticates by default. Without a credential GitHub allows 60 API requests
104
+ an hour *per IP address* -- shared with every other anonymous caller behind
105
+ the same egress -- and rejects private repositories outright.
106
+
107
+ Four sources, first non-empty wins: `token`, `$GITHUB_TOKEN`/`$GH_TOKEN`,
108
+ `token_func`, then `$GITHUB_TOKEN_COMMAND` defaulting to `gh auth token`.
43
109
  """
44
110
 
45
111
  owner: str
46
112
  repo: str
47
113
  token: str = ''
48
114
 
49
- # Resolves a token when `token` is empty and neither environment variable is
50
- # set. Called only when a request is actually about to be made.
115
+ # A source of a caller's own, tried before the command. Called only when a
116
+ # request is actually about to be made.
51
117
  #
52
118
  # It exists because a credential can be expensive to obtain -- a keychain
53
- # prompt, a `gh auth token` subprocess -- and a caller that resolves such a
54
- # token eagerly into `token` pays for it on every invocation, including the
55
- # ones where the notify gate declines to check at all. That gate is
56
- # otherwise free, and a subprocess spawn in front of it is the entire cost.
119
+ # prompt, a subprocess -- and a caller that resolves such a token eagerly
120
+ # into `token` pays for it on every invocation, including the ones where the
121
+ # notify gate declines to check at all. That gate is otherwise free, and a
122
+ # spawn in front of it is the entire cost.
123
+ #
124
+ # Reaching for `gh` no longer needs one: that is the default below. This is
125
+ # for a credential neither the environment nor a command can produce.
57
126
  token_func: Callable[[], str] | None = None
58
127
 
59
128
  timeout: float = DEFAULT_TIMEOUT
@@ -67,6 +136,16 @@ class GitHubSource:
67
136
 
68
137
  headers: dict[str, str] = field(default_factory=dict)
69
138
 
139
+ # Resolved once per source, because a check that also fetches a changelog
140
+ # makes several requests and the command behind this can be a vault unlock.
141
+ # None means "not yet asked", which an empty string cannot say.
142
+ _resolved_token: str | None = field(default=None, init=False, repr=False, compare=False)
143
+
144
+ def _credential(self) -> str:
145
+ if self._resolved_token is None:
146
+ self._resolved_token = self.token or token_from_env() or (self.token_func() if self.token_func else '') or token_from_command()
147
+ return self._resolved_token
148
+
70
149
  def latest_release(self) -> Release:
71
150
  if self.tag_prefix or self.allow_prerelease:
72
151
  return self._latest_from_list()
@@ -147,7 +226,7 @@ class GitHubSource:
147
226
  request.add_header('Accept', 'application/vnd.github+json')
148
227
  request.add_header('X-GitHub-Api-Version', '2022-11-28')
149
228
  request.add_header('User-Agent', 'pyselfupdate')
150
- token = self.token or token_from_env() or (self.token_func() if self.token_func else '')
229
+ token = self._credential()
151
230
  if token:
152
231
  request.add_header('Authorization', f'Bearer {token}')
153
232
  for name, value in self.headers.items():
@@ -160,7 +239,7 @@ class GitHubSource:
160
239
  with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310 # nosec B310
161
240
  return json.load(response)
162
241
  except urllib.error.HTTPError as error:
163
- raise _http_error(self.owner, self.repo, error) from error
242
+ raise _http_error(self.owner, self.repo, error, authenticated=bool(token)) from error
164
243
  except urllib.error.URLError as error:
165
244
  raise SelfUpdateSourceFailure(f'cannot reach {API}: {error.reason}') from error
166
245
  except json.JSONDecodeError as error:
@@ -176,7 +255,7 @@ class SelfUpdateSourceFailure(SourceError):
176
255
  """
177
256
 
178
257
 
179
- def _http_error(owner: str, repo: str, error: urllib.error.HTTPError) -> SelfUpdateError:
258
+ def _http_error(owner: str, repo: str, error: urllib.error.HTTPError, *, authenticated: bool) -> SelfUpdateError:
180
259
  if error.code == 404:
181
260
  # A private repository reached without a token is indistinguishable
182
261
  # from one that does not exist, and saying so is more useful than
@@ -185,10 +264,46 @@ def _http_error(owner: str, repo: str, error: urllib.error.HTTPError) -> SelfUpd
185
264
  if error.code in (401, 403):
186
265
  remaining = error.headers.get('x-ratelimit-remaining') if error.headers else None
187
266
  if remaining == '0':
188
- return SelfUpdateSourceFailure('GitHub API rate limit exceeded; set GITHUB_TOKEN to raise it')
267
+ return SelfUpdateSourceFailure(_rate_limit_message(error, authenticated=authenticated))
189
268
  return SelfUpdateSourceFailure(f'GitHub refused the request for {owner}/{repo} ({error.code})')
190
269
  return SelfUpdateSourceFailure(f'GitHub returned {error.code} for {owner}/{repo}')
191
270
 
192
271
 
272
+ def _rate_limit_message(error: urllib.error.HTTPError, *, authenticated: bool) -> str:
273
+ """Which ceiling was hit, and what to do about that one.
274
+
275
+ The two are different problems with different fixes, and one sentence for
276
+ both sent the wrong instruction to whichever case it was not written for.
277
+ Telling someone to supply a token when the request already carried one is
278
+ the worse half: it reads as advice to go and configure something that is
279
+ already configured.
280
+
281
+ The authenticated case should be rare, so it says when the ceiling lifts
282
+ rather than what to change -- there is nothing to change.
283
+ """
284
+ resets = _reset_clock(error)
285
+ if authenticated:
286
+ return f"GitHub's authenticated rate limit (5,000/hour) is exhausted{resets}"
287
+ return (
288
+ "GitHub's anonymous rate limit (60/hour, shared by every host on this IP) is exhausted"
289
+ f'{resets}. Run `gh auth login`, or set GITHUB_TOKEN, for the 5,000/hour authenticated limit'
290
+ )
291
+
292
+
293
+ def _reset_clock(error: urllib.error.HTTPError) -> str:
294
+ """`; resets at 14:22 UTC`, or nothing when the header is absent or unusable.
295
+
296
+ A wall-clock time rather than "in 43 minutes", because the message is read
297
+ out of a state file long after the request that produced it and a duration
298
+ would be counted from the wrong instant.
299
+ """
300
+ header = error.headers.get('x-ratelimit-reset') if error.headers else None
301
+ try:
302
+ moment = datetime.fromtimestamp(int(header or ''), tz=UTC)
303
+ except (TypeError, ValueError, OSError, OverflowError):
304
+ return ''
305
+ return f'; resets at {moment:%H:%M} UTC'
306
+
307
+
193
308
  def _quote(ref: str) -> str:
194
309
  return urllib.parse.quote(ref, safe='')