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.
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/PKG-INFO +37 -17
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/README.md +36 -16
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/pyproject.toml +3 -3
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/pyproject.toml.orig +4 -4
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/github.py +132 -17
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/__init__.py +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/config.py +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/errors.py +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/install.py +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/notifier.py +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/py.typed +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/source.py +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/state.py +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/typercmd.py +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/updater.py +0 -0
- {pyselfupdate-0.2.2 → pyselfupdate-0.3.0}/src/pyselfupdate/version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pyselfupdate
|
|
3
|
-
Version: 0.
|
|
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='', #
|
|
171
|
-
token_func=None, #
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
`
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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='', #
|
|
147
|
-
token_func=None, #
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
`
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
|
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
|
-
"
|
|
219
|
+
"verbosity_assertions",
|
|
220
220
|
],
|
|
221
221
|
[
|
|
222
222
|
"pytest",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyselfupdate"
|
|
3
|
-
version = "0.
|
|
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
|
|
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", "
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
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
|
-
#
|
|
50
|
-
#
|
|
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
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
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.
|
|
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(
|
|
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='')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|