insightfactory-cli 1.0.0.dev1__py3-none-any.whl
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.
- if_cli/__init__.py +1 -0
- if_cli/__main__.py +4 -0
- if_cli/cache.py +206 -0
- if_cli/cli.py +68 -0
- if_cli/colour.py +24 -0
- if_cli/commands/__init__.py +0 -0
- if_cli/commands/api.py +177 -0
- if_cli/commands/config.py +45 -0
- if_cli/commands/login.py +75 -0
- if_cli/commands/logout.py +20 -0
- if_cli/commands/profiles.py +119 -0
- if_cli/commands/set_token.py +62 -0
- if_cli/commands/token.py +32 -0
- if_cli/config.py +188 -0
- if_cli/constants.py +5 -0
- if_cli/http.py +153 -0
- if_cli/main.py +76 -0
- if_cli/oauth.py +389 -0
- if_cli/runtime.py +94 -0
- insightfactory_cli-1.0.0.dev1.dist-info/METADATA +265 -0
- insightfactory_cli-1.0.0.dev1.dist-info/RECORD +23 -0
- insightfactory_cli-1.0.0.dev1.dist-info/WHEEL +4 -0
- insightfactory_cli-1.0.0.dev1.dist-info/entry_points.txt +2 -0
if_cli/oauth.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import secrets
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
13
|
+
|
|
14
|
+
from if_cli.cache import (
|
|
15
|
+
CacheEntry,
|
|
16
|
+
clear_refresh_token_if_matches,
|
|
17
|
+
is_fresh,
|
|
18
|
+
load_cache,
|
|
19
|
+
store_tokens,
|
|
20
|
+
)
|
|
21
|
+
from if_cli.config import Profile, meaningful
|
|
22
|
+
from if_cli.constants import DEFAULT_SCOPE, LOOPBACK_HOST
|
|
23
|
+
from if_cli.http import fetch_with_timeout, validate_http_url
|
|
24
|
+
from if_cli.runtime import CliError, die, escape_html, is_record
|
|
25
|
+
|
|
26
|
+
CALLBACK_TIMEOUT_SECONDS = 300
|
|
27
|
+
RECOVERY_POLL_ATTEMPTS = 10
|
|
28
|
+
RECOVERY_POLL_INTERVAL_MS = 40
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def base64url(data: bytes) -> str:
|
|
32
|
+
return base64.b64encode(data).decode("ascii").replace("+", "-").replace("/", "_").rstrip("=")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_pkce_challenge(verifier: str) -> str:
|
|
36
|
+
return base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def open_browser(url: str, launcher: str | None = None) -> None:
|
|
40
|
+
"""Open ``url`` with the platform browser launcher.
|
|
41
|
+
|
|
42
|
+
A missing desktop launcher must not terminate a headless login: the
|
|
43
|
+
authorization URL is always printed as a fallback.
|
|
44
|
+
"""
|
|
45
|
+
if launcher is not None:
|
|
46
|
+
command, args = launcher, [url]
|
|
47
|
+
elif sys.platform == "darwin":
|
|
48
|
+
command, args = "open", [url]
|
|
49
|
+
elif sys.platform == "win32":
|
|
50
|
+
command, args = "rundll32", ["url.dll,FileProtocolHandler", url]
|
|
51
|
+
else:
|
|
52
|
+
command, args = "xdg-open", [url]
|
|
53
|
+
try:
|
|
54
|
+
subprocess.Popen(
|
|
55
|
+
[command, *args],
|
|
56
|
+
stdout=subprocess.DEVNULL,
|
|
57
|
+
stderr=subprocess.DEVNULL,
|
|
58
|
+
start_new_session=True,
|
|
59
|
+
)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_callback_url(port: int) -> str:
|
|
65
|
+
return f"http://{LOOPBACK_HOST}:{port}/callback"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _discover(host: str) -> dict[str, Any]:
|
|
69
|
+
response = fetch_with_timeout(f"{host}/.well-known/oauth-authorization-server")
|
|
70
|
+
if not response.ok:
|
|
71
|
+
die(f"OAuth discovery failed for {host} (HTTP {response.status})")
|
|
72
|
+
try:
|
|
73
|
+
body: object = response.json()
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
die(f"OAuth discovery for {host} did not return valid JSON")
|
|
76
|
+
if not is_record(body):
|
|
77
|
+
die(f"OAuth discovery for {host} did not return a JSON object")
|
|
78
|
+
authorization_endpoint = body.get("authorization_endpoint")
|
|
79
|
+
token_endpoint = body.get("token_endpoint")
|
|
80
|
+
if not isinstance(authorization_endpoint, str) or not isinstance(token_endpoint, str):
|
|
81
|
+
die(f"OAuth discovery for {host} is missing authorization_endpoint or token_endpoint")
|
|
82
|
+
metadata: dict[str, Any] = dict(body)
|
|
83
|
+
metadata["authorization_endpoint"] = validate_http_url(authorization_endpoint, "OAuth authorization endpoint")
|
|
84
|
+
metadata["token_endpoint"] = validate_http_url(token_endpoint, "OAuth token endpoint")
|
|
85
|
+
return metadata
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TokenRequestError(Exception):
|
|
89
|
+
def __init__(self, status: int, body: str, oauth_error: str | None = None) -> None:
|
|
90
|
+
super().__init__(f"token request failed (HTTP {status}): {body}")
|
|
91
|
+
self.status = status
|
|
92
|
+
self.oauth_error = oauth_error
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def invalidates_refresh_token(self) -> bool:
|
|
96
|
+
return self.oauth_error == "invalid_grant"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_expires_in(value: object) -> float | None:
|
|
100
|
+
if isinstance(value, bool):
|
|
101
|
+
parsed = float("nan")
|
|
102
|
+
elif isinstance(value, (int, float)):
|
|
103
|
+
parsed = float(value)
|
|
104
|
+
elif isinstance(value, str) and value.strip():
|
|
105
|
+
try:
|
|
106
|
+
parsed = float(value)
|
|
107
|
+
except ValueError:
|
|
108
|
+
parsed = float("nan")
|
|
109
|
+
else:
|
|
110
|
+
parsed = float("nan")
|
|
111
|
+
if parsed == parsed and parsed >= 0:
|
|
112
|
+
return parsed
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _token_request(token_endpoint: str, form: dict[str, str]) -> dict[str, object]:
|
|
117
|
+
response = fetch_with_timeout(
|
|
118
|
+
token_endpoint,
|
|
119
|
+
method="POST",
|
|
120
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
121
|
+
body=urlencode(form),
|
|
122
|
+
)
|
|
123
|
+
if not response.ok:
|
|
124
|
+
body = response.text()
|
|
125
|
+
oauth_error: str | None = None
|
|
126
|
+
try:
|
|
127
|
+
parsed: object = json.loads(body)
|
|
128
|
+
if is_record(parsed):
|
|
129
|
+
error_value = parsed.get("error")
|
|
130
|
+
if isinstance(error_value, str):
|
|
131
|
+
oauth_error = error_value
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
pass
|
|
134
|
+
raise TokenRequestError(response.status, body, oauth_error)
|
|
135
|
+
try:
|
|
136
|
+
tokens = response.json()
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
die("token response did not include a valid access token")
|
|
139
|
+
if not is_record(tokens) or not isinstance(tokens.get("access_token"), str) or not tokens.get("access_token"):
|
|
140
|
+
die("token response did not include a valid access token")
|
|
141
|
+
expires_in = _parse_expires_in(tokens.get("expires_in"))
|
|
142
|
+
result: dict[str, object] = {"access_token": tokens["access_token"]}
|
|
143
|
+
if isinstance(tokens.get("refresh_token"), str):
|
|
144
|
+
result["refresh_token"] = tokens["refresh_token"]
|
|
145
|
+
if expires_in is not None:
|
|
146
|
+
result["expires_in"] = expires_in
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _CallbackServer:
|
|
151
|
+
def __init__(self, port: int, expected_state: str) -> None:
|
|
152
|
+
self.expected_state = expected_state
|
|
153
|
+
self.result: dict[str, str] | None = None
|
|
154
|
+
handler = self._handler()
|
|
155
|
+
try:
|
|
156
|
+
self.server = HTTPServer((LOOPBACK_HOST, port), handler)
|
|
157
|
+
except OSError as error:
|
|
158
|
+
die(
|
|
159
|
+
f"cannot listen on {LOOPBACK_HOST}:{port} ({error.strerror or error}) — "
|
|
160
|
+
"stop whatever is using the port and retry"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _handler(self) -> type[BaseHTTPRequestHandler]:
|
|
164
|
+
callback = self
|
|
165
|
+
|
|
166
|
+
class Handler(BaseHTTPRequestHandler):
|
|
167
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
def do_GET(self) -> None:
|
|
171
|
+
parsed = urlparse(self.path)
|
|
172
|
+
if parsed.path != "/callback":
|
|
173
|
+
self.send_response(404)
|
|
174
|
+
self.end_headers()
|
|
175
|
+
return
|
|
176
|
+
params = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
177
|
+
if params.get("state") != callback.expected_state:
|
|
178
|
+
self.send_response(400)
|
|
179
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
180
|
+
self.send_header("Connection", "close")
|
|
181
|
+
self.end_headers()
|
|
182
|
+
self.wfile.write(b"<h2>Login callback ignored because its state did not match.</h2>")
|
|
183
|
+
return
|
|
184
|
+
code = params.get("code")
|
|
185
|
+
self.send_response(200)
|
|
186
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
187
|
+
self.send_header("Connection", "close")
|
|
188
|
+
self.end_headers()
|
|
189
|
+
if code:
|
|
190
|
+
body = "<h2>Login complete — you can close this tab and return to the terminal.</h2>"
|
|
191
|
+
else:
|
|
192
|
+
error = escape_html(params.get("error") or "")
|
|
193
|
+
description = escape_html(params.get("error_description") or "")
|
|
194
|
+
body = f"<h2>Login failed.</h2><pre>{error}: {description}</pre>"
|
|
195
|
+
self.wfile.write(body.encode("utf-8"))
|
|
196
|
+
callback.result = params
|
|
197
|
+
|
|
198
|
+
return Handler
|
|
199
|
+
|
|
200
|
+
def wait(self) -> dict[str, str]:
|
|
201
|
+
deadline = time.monotonic() + CALLBACK_TIMEOUT_SECONDS
|
|
202
|
+
try:
|
|
203
|
+
while self.result is None:
|
|
204
|
+
remaining = deadline - time.monotonic()
|
|
205
|
+
if remaining <= 0:
|
|
206
|
+
die("timed out waiting for the browser callback (5 min)")
|
|
207
|
+
self.server.timeout = remaining
|
|
208
|
+
self.server.handle_request()
|
|
209
|
+
finally:
|
|
210
|
+
self.server.server_close()
|
|
211
|
+
result = self.result
|
|
212
|
+
if result is None:
|
|
213
|
+
die("timed out waiting for the browser callback (5 min)")
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def wait_for_callback(port: int, expected_state: str) -> dict[str, str]:
|
|
218
|
+
return _CallbackServer(port, expected_state).wait()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def login(profile: Profile, no_browser: bool, options: dict[str, str] | None = None) -> CacheEntry:
|
|
222
|
+
options = options or {}
|
|
223
|
+
metadata = _discover(profile["host"])
|
|
224
|
+
published_client_id = meaningful(
|
|
225
|
+
metadata.get("insightfactory_cli_client_id")
|
|
226
|
+
if isinstance(metadata.get("insightfactory_cli_client_id"), str)
|
|
227
|
+
else None
|
|
228
|
+
)
|
|
229
|
+
published_organization = meaningful(
|
|
230
|
+
metadata.get("insightfactory_organization_id")
|
|
231
|
+
if isinstance(metadata.get("insightfactory_organization_id"), str)
|
|
232
|
+
else None
|
|
233
|
+
)
|
|
234
|
+
stored_client_id = meaningful(profile.get("client_id"))
|
|
235
|
+
stored_organization = meaningful(profile.get("organization"))
|
|
236
|
+
pinned_client_id = meaningful(options.get("client_id_override"))
|
|
237
|
+
pinned_organization = meaningful(options.get("organization_override"))
|
|
238
|
+
|
|
239
|
+
superseded_client_id = (
|
|
240
|
+
pinned_client_id is None
|
|
241
|
+
and stored_client_id is not None
|
|
242
|
+
and published_client_id is not None
|
|
243
|
+
and stored_client_id != published_client_id
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
client_id = pinned_client_id or (
|
|
247
|
+
published_client_id if superseded_client_id else (stored_client_id or published_client_id)
|
|
248
|
+
)
|
|
249
|
+
organization = pinned_organization or (
|
|
250
|
+
(published_organization or stored_organization)
|
|
251
|
+
if superseded_client_id
|
|
252
|
+
else (stored_organization or published_organization)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if superseded_client_id:
|
|
256
|
+
organization_changed = organization != stored_organization and stored_organization is not None
|
|
257
|
+
sys.stderr.write(
|
|
258
|
+
f"warning: profile '{profile['name']}' has client_id {stored_client_id}, but {profile['host']} "
|
|
259
|
+
f"now advertises {published_client_id}.\n"
|
|
260
|
+
+ (
|
|
261
|
+
f" Its organization {organization} also replaces the profile's {stored_organization}.\n"
|
|
262
|
+
if organization_changed
|
|
263
|
+
else ""
|
|
264
|
+
)
|
|
265
|
+
+ " Using the advertised client. Remove client_id from the profile to silence this,\n"
|
|
266
|
+
+ " or pass --client-id (and --organization) on each login to keep overriding it.\n"
|
|
267
|
+
)
|
|
268
|
+
if not client_id:
|
|
269
|
+
die(
|
|
270
|
+
"no client id: this factory's discovery document does not publish insightfactory_cli_client_id — "
|
|
271
|
+
"pass --client-id (and --organization) with the factory's public client details"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
verifier = base64url(secrets.token_bytes(48))
|
|
275
|
+
challenge = create_pkce_challenge(verifier)
|
|
276
|
+
state = base64url(secrets.token_bytes(24))
|
|
277
|
+
redirect_uri = build_callback_url(profile["callback_port"])
|
|
278
|
+
params = {
|
|
279
|
+
"response_type": "code",
|
|
280
|
+
"client_id": client_id,
|
|
281
|
+
"redirect_uri": redirect_uri,
|
|
282
|
+
"scope": DEFAULT_SCOPE,
|
|
283
|
+
"audience": profile["audience"],
|
|
284
|
+
"state": state,
|
|
285
|
+
"code_challenge": challenge,
|
|
286
|
+
"code_challenge_method": "S256",
|
|
287
|
+
}
|
|
288
|
+
if organization:
|
|
289
|
+
params["organization"] = organization
|
|
290
|
+
|
|
291
|
+
parsed_auth = urlparse(metadata["authorization_endpoint"])
|
|
292
|
+
query = dict(parse_qsl(parsed_auth.query, keep_blank_values=True))
|
|
293
|
+
query.update(params)
|
|
294
|
+
authorization_url = urlunparse(parsed_auth._replace(query=urlencode(query)))
|
|
295
|
+
|
|
296
|
+
callback_server = _CallbackServer(profile["callback_port"], state)
|
|
297
|
+
if no_browser:
|
|
298
|
+
sys.stderr.write(f"Open this URL in a browser logged into the factory:\n\n {authorization_url}\n\n")
|
|
299
|
+
else:
|
|
300
|
+
sys.stderr.write(f"Opening browser for {profile['host']} …\n")
|
|
301
|
+
open_browser(authorization_url)
|
|
302
|
+
sys.stderr.write(f"If the browser did not open, visit:\n {authorization_url}\n")
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
callback_result = callback_server.wait()
|
|
306
|
+
except CliError:
|
|
307
|
+
raise
|
|
308
|
+
except Exception as error:
|
|
309
|
+
die(str(error))
|
|
310
|
+
|
|
311
|
+
if callback_result.get("state") != state:
|
|
312
|
+
die("state mismatch in callback — aborting")
|
|
313
|
+
if not callback_result.get("code"):
|
|
314
|
+
die(f"authorization failed: {callback_result.get('error')}: {callback_result.get('error_description')}")
|
|
315
|
+
|
|
316
|
+
tokens = _token_request(
|
|
317
|
+
metadata["token_endpoint"],
|
|
318
|
+
{
|
|
319
|
+
"grant_type": "authorization_code",
|
|
320
|
+
"client_id": client_id,
|
|
321
|
+
"code": callback_result["code"],
|
|
322
|
+
"code_verifier": verifier,
|
|
323
|
+
"redirect_uri": redirect_uri,
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
return store_tokens(profile["host"], tokens, metadata["token_endpoint"], client_id, replace=True)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _refresh(host: str, entry: CacheEntry, refresh_token: str, client_id: str) -> CacheEntry:
|
|
330
|
+
tokens = _token_request(
|
|
331
|
+
entry["token_endpoint"],
|
|
332
|
+
{
|
|
333
|
+
"grant_type": "refresh_token",
|
|
334
|
+
"client_id": client_id,
|
|
335
|
+
"refresh_token": refresh_token,
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
return store_tokens(host, tokens, entry["token_endpoint"], client_id)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _wait_for_fresh_token(host: str) -> str | None:
|
|
342
|
+
for attempt in range(RECOVERY_POLL_ATTEMPTS):
|
|
343
|
+
entry = load_cache()["tokens"].get(host)
|
|
344
|
+
if entry and is_fresh(entry):
|
|
345
|
+
return entry["access_token"]
|
|
346
|
+
if attempt + 1 < RECOVERY_POLL_ATTEMPTS:
|
|
347
|
+
time.sleep(RECOVERY_POLL_INTERVAL_MS / 1000)
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def get_valid_token(profile: Profile) -> str:
|
|
352
|
+
entry = load_cache()["tokens"].get(profile["host"])
|
|
353
|
+
if not entry:
|
|
354
|
+
die(f"no cached token for {profile['host']} — run: if-cli login -p {profile['name']}")
|
|
355
|
+
if is_fresh(entry):
|
|
356
|
+
return entry["access_token"]
|
|
357
|
+
refresh_token = entry.get("refresh_token")
|
|
358
|
+
if refresh_token:
|
|
359
|
+
client_id = entry.get("client_id")
|
|
360
|
+
if not client_id:
|
|
361
|
+
clear_refresh_token_if_matches(profile["host"], refresh_token)
|
|
362
|
+
die(
|
|
363
|
+
f"could not refresh token for {profile['host']}: no client id cached — "
|
|
364
|
+
f"run: if-cli login -p {profile['name']}"
|
|
365
|
+
)
|
|
366
|
+
try:
|
|
367
|
+
return _refresh(profile["host"], entry, refresh_token, client_id)["access_token"]
|
|
368
|
+
except Exception as error:
|
|
369
|
+
failure: BaseException = error
|
|
370
|
+
if isinstance(error, TokenRequestError) and error.invalidates_refresh_token:
|
|
371
|
+
cleared = clear_refresh_token_if_matches(profile["host"], refresh_token)
|
|
372
|
+
if cleared:
|
|
373
|
+
recovered_access_token = _wait_for_fresh_token(profile["host"])
|
|
374
|
+
if recovered_access_token:
|
|
375
|
+
return recovered_access_token
|
|
376
|
+
else:
|
|
377
|
+
recovered = load_cache()["tokens"].get(profile["host"])
|
|
378
|
+
recovered_refresh = recovered.get("refresh_token") if recovered else None
|
|
379
|
+
recovered_client_id = recovered.get("client_id") if recovered else None
|
|
380
|
+
if recovered and recovered_refresh and recovered_refresh != refresh_token and recovered_client_id:
|
|
381
|
+
try:
|
|
382
|
+
return _refresh(profile["host"], recovered, recovered_refresh, recovered_client_id)[
|
|
383
|
+
"access_token"
|
|
384
|
+
]
|
|
385
|
+
except Exception as retry_error:
|
|
386
|
+
failure = retry_error
|
|
387
|
+
detail = f": {failure}" if isinstance(failure, Exception) else ""
|
|
388
|
+
die(f"could not refresh token for {profile['host']}{detail} — run: if-cli login -p {profile['name']}")
|
|
389
|
+
die(f"token for {profile['host']} expired and no refresh token cached — run: if-cli login -p {profile['name']}")
|
if_cli/runtime.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import NoReturn, TypeGuard
|
|
10
|
+
|
|
11
|
+
_exit_code = 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CliError(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def die(message: str) -> NoReturn:
|
|
19
|
+
raise CliError(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_exit_code(code: int) -> None:
|
|
23
|
+
global _exit_code
|
|
24
|
+
_exit_code = code
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_exit_code() -> int:
|
|
28
|
+
return _exit_code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_record(value: object) -> TypeGuard[dict[str, object]]:
|
|
32
|
+
return isinstance(value, dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def jwt_exp(token: str) -> int | None:
|
|
36
|
+
try:
|
|
37
|
+
payload = token.split(".")[1]
|
|
38
|
+
padding = "=" * ((4 - len(payload) % 4) % 4)
|
|
39
|
+
data: object = json.loads(base64.urlsafe_b64decode(payload + padding))
|
|
40
|
+
if not is_record(data):
|
|
41
|
+
return None
|
|
42
|
+
exp = data.get("exp")
|
|
43
|
+
return exp if isinstance(exp, int) else None
|
|
44
|
+
except (IndexError, ValueError, json.JSONDecodeError, TypeError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_expiry(epoch_seconds: int) -> str:
|
|
49
|
+
return datetime.fromtimestamp(epoch_seconds).strftime("%c")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def escape_html(value: str) -> str:
|
|
53
|
+
return (
|
|
54
|
+
value.replace("&", "&")
|
|
55
|
+
.replace("<", "<")
|
|
56
|
+
.replace(">", ">")
|
|
57
|
+
.replace('"', """)
|
|
58
|
+
.replace("'", "'")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def install_broken_pipe_handlers() -> None:
|
|
63
|
+
"""Treat a closed stdout pipe as a normal end of output rather than a crash.
|
|
64
|
+
|
|
65
|
+
Downstream readers such as `if-cli profiles | head -1` close the pipe as
|
|
66
|
+
soon as they have the line they want. Ignoring SIGPIPE turns the subsequent
|
|
67
|
+
write into BrokenPipeError, which run_with_handlers converts into a quiet
|
|
68
|
+
exit that still reports any failure the command already recorded.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
|
72
|
+
except (AttributeError, ValueError, OSError):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def handle_broken_pipe() -> NoReturn:
|
|
77
|
+
try:
|
|
78
|
+
sys.stdout.flush()
|
|
79
|
+
except (BrokenPipeError, OSError):
|
|
80
|
+
pass
|
|
81
|
+
try:
|
|
82
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
83
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
84
|
+
except (OSError, ValueError, AttributeError):
|
|
85
|
+
pass
|
|
86
|
+
# Python 3.14 still flushes the original TextIOWrapper at shutdown. If that
|
|
87
|
+
# wrapper is already in an EPIPE error state, CPython maps the ignored
|
|
88
|
+
# exception to exit status 120 and overwrites SystemExit. Point sys.stdout
|
|
89
|
+
# at a fresh /dev/null stream so the shutdown flush succeeds.
|
|
90
|
+
try:
|
|
91
|
+
sys.stdout = open(os.devnull, "w", encoding="utf-8", errors="replace")
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
raise SystemExit(get_exit_code() if isinstance(get_exit_code(), int) else 0)
|