simklcli 0.1.0__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.
- simklcli/__init__.py +3 -0
- simklcli/__main__.py +4 -0
- simklcli/api.py +431 -0
- simklcli/catalog.py +167 -0
- simklcli/cli.py +496 -0
- simklcli/commands.py +437 -0
- simklcli/filesystem.py +31 -0
- simklcli/media.py +190 -0
- simklcli/output.py +55 -0
- simklcli/rate_control.py +99 -0
- simklcli/runtime.py +242 -0
- simklcli/snapshots.py +162 -0
- simklcli/storage.py +347 -0
- simklcli/targets.py +117 -0
- simklcli/tracking.py +484 -0
- simklcli-0.1.0.dist-info/METADATA +217 -0
- simklcli-0.1.0.dist-info/RECORD +20 -0
- simklcli-0.1.0.dist-info/WHEEL +4 -0
- simklcli-0.1.0.dist-info/entry_points.txt +2 -0
- simklcli-0.1.0.dist-info/licenses/LICENSE +21 -0
simklcli/cli.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from contextlib import nullcontext
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
|
|
13
|
+
from simklcli.api import (
|
|
14
|
+
InvalidAccessTokenError,
|
|
15
|
+
PollSlowDown,
|
|
16
|
+
SimklClient,
|
|
17
|
+
SimklResponseError,
|
|
18
|
+
TokenPair,
|
|
19
|
+
)
|
|
20
|
+
from simklcli.commands import register_commands
|
|
21
|
+
from simklcli.output import JsonGroup
|
|
22
|
+
from simklcli.runtime import Runtime, default_runtime
|
|
23
|
+
from simklcli.storage import Account, CredentialSource, CredentialStorageError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CredentialStorage(StrEnum):
|
|
27
|
+
KEYRING = "keyring"
|
|
28
|
+
FILE = "file"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthenticationStateError(RuntimeError):
|
|
32
|
+
"""The requested authentication transition is not currently allowed."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _complete_pin_authorization(
|
|
36
|
+
runtime: Runtime,
|
|
37
|
+
credential_source: CredentialSource,
|
|
38
|
+
*,
|
|
39
|
+
no_open_browser: bool,
|
|
40
|
+
json_output: bool = False,
|
|
41
|
+
) -> Account:
|
|
42
|
+
previous = runtime.credentials.read()
|
|
43
|
+
previous_account = runtime.credentials.read_account()
|
|
44
|
+
if (
|
|
45
|
+
previous is not None
|
|
46
|
+
and not previous.legacy
|
|
47
|
+
and not previous.invalidated
|
|
48
|
+
and (previous.refresh_expires_at is None or previous.refresh_expires_at > runtime.clock())
|
|
49
|
+
):
|
|
50
|
+
raise AuthenticationStateError(
|
|
51
|
+
"An Authenticated Account is already stored. "
|
|
52
|
+
"Run simkl auth logout before switching accounts."
|
|
53
|
+
)
|
|
54
|
+
runtime.credentials.ensure_available(credential_source)
|
|
55
|
+
api = runtime.api_client()
|
|
56
|
+
pin = api.start_pin_authorization()
|
|
57
|
+
Console(stderr=json_output).print(
|
|
58
|
+
Panel(
|
|
59
|
+
f"Open {pin.verification_url_complete or pin.verification_url}\n"
|
|
60
|
+
f"Or open {pin.verification_url} and enter "
|
|
61
|
+
f"[bold cyan]{pin.user_code}[/bold cyan]\n"
|
|
62
|
+
f"Expires in {pin.expires_in} seconds · poll every {pin.interval} seconds",
|
|
63
|
+
title="PIN Authorization",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
if not no_open_browser and runtime.interactive and not json_output:
|
|
67
|
+
try:
|
|
68
|
+
runtime.open_browser(pin.verification_url_complete or pin.verification_url)
|
|
69
|
+
except Exception:
|
|
70
|
+
Console(stderr=True).print(
|
|
71
|
+
"[yellow]Could not open a browser; continue with the URL above.[/yellow]"
|
|
72
|
+
)
|
|
73
|
+
deadline = runtime.monotonic() + pin.expires_in
|
|
74
|
+
pair: TokenPair | None = None
|
|
75
|
+
interval = pin.interval
|
|
76
|
+
while pair is None:
|
|
77
|
+
remaining = deadline - runtime.monotonic()
|
|
78
|
+
if remaining <= 0:
|
|
79
|
+
raise SimklResponseError("PIN Authorization expired before approval.")
|
|
80
|
+
runtime.sleep(min(float(interval), remaining))
|
|
81
|
+
if runtime.monotonic() >= deadline:
|
|
82
|
+
raise SimklResponseError("PIN Authorization expired before approval.")
|
|
83
|
+
try:
|
|
84
|
+
pair = api.poll_pin_authorization(pin)
|
|
85
|
+
except PollSlowDown:
|
|
86
|
+
interval += 5
|
|
87
|
+
if pair is None:
|
|
88
|
+
remaining_seconds = max(0, int(deadline - runtime.monotonic()))
|
|
89
|
+
Console(stderr=json_output).print(
|
|
90
|
+
f"Waiting for approval… {remaining_seconds}s remaining"
|
|
91
|
+
)
|
|
92
|
+
account = api.get_authenticated_account(pair.access_token)
|
|
93
|
+
if previous_account is not None and account.id != previous_account.id:
|
|
94
|
+
raise AuthenticationStateError(
|
|
95
|
+
"Authorization belongs to a different account. Existing credentials and Library "
|
|
96
|
+
"Snapshot were retained; log out explicitly before switching accounts."
|
|
97
|
+
)
|
|
98
|
+
runtime.credentials.write(
|
|
99
|
+
access_token=pair.access_token,
|
|
100
|
+
account=account,
|
|
101
|
+
source=credential_source,
|
|
102
|
+
refresh_token=pair.refresh_token,
|
|
103
|
+
expires_at=pair.expires_at,
|
|
104
|
+
refresh_expires_at=pair.refresh_expires_at,
|
|
105
|
+
client_id=runtime.client_id(),
|
|
106
|
+
scope=pair.scope,
|
|
107
|
+
)
|
|
108
|
+
return account
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_app(runtime_factory: Callable[[], Runtime] = default_runtime) -> typer.Typer:
|
|
112
|
+
app = typer.Typer(
|
|
113
|
+
cls=JsonGroup,
|
|
114
|
+
name="simkl",
|
|
115
|
+
help="Track movies, shows, and anime on Simkl.",
|
|
116
|
+
no_args_is_help=True,
|
|
117
|
+
pretty_exceptions_enable=False,
|
|
118
|
+
)
|
|
119
|
+
auth_app = typer.Typer(
|
|
120
|
+
help="Authorize this CLI for one Simkl account.",
|
|
121
|
+
no_args_is_help=True,
|
|
122
|
+
)
|
|
123
|
+
app.add_typer(auth_app, name="auth")
|
|
124
|
+
|
|
125
|
+
@auth_app.command("login")
|
|
126
|
+
def auth_login(
|
|
127
|
+
no_open_browser: Annotated[
|
|
128
|
+
bool,
|
|
129
|
+
typer.Option(
|
|
130
|
+
"--no-open-browser",
|
|
131
|
+
help="Print the PIN URL and code without opening a browser.",
|
|
132
|
+
),
|
|
133
|
+
] = False,
|
|
134
|
+
storage: Annotated[
|
|
135
|
+
CredentialStorage,
|
|
136
|
+
typer.Option(help="Credential storage; file must be chosen explicitly."),
|
|
137
|
+
] = CredentialStorage.KEYRING,
|
|
138
|
+
json_output: Annotated[bool, typer.Option("--json", help="Emit one JSON result.")] = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Start Simkl PIN Authorization."""
|
|
141
|
+
runtime = runtime_factory()
|
|
142
|
+
credential_source = CredentialSource(storage.value)
|
|
143
|
+
if runtime.environ.get("SIMKL_ACCESS_TOKEN", "").strip():
|
|
144
|
+
if json_output:
|
|
145
|
+
typer.echo(json.dumps({"error": "environment_override_active"}))
|
|
146
|
+
Console(stderr=True).print(
|
|
147
|
+
"[red]SIMKL_ACCESS_TOKEN is active.[/red] Remove it before persistent login; "
|
|
148
|
+
"a stored credential would be shadowed."
|
|
149
|
+
)
|
|
150
|
+
raise typer.Exit(1)
|
|
151
|
+
try:
|
|
152
|
+
with runtime.credentials.account_transaction():
|
|
153
|
+
account = _complete_pin_authorization(
|
|
154
|
+
runtime,
|
|
155
|
+
credential_source,
|
|
156
|
+
no_open_browser=no_open_browser,
|
|
157
|
+
json_output=json_output,
|
|
158
|
+
)
|
|
159
|
+
except KeyboardInterrupt:
|
|
160
|
+
if json_output:
|
|
161
|
+
typer.echo(json.dumps({"error": "cancelled"}))
|
|
162
|
+
Console(stderr=True).print(
|
|
163
|
+
"[yellow]PIN Authorization cancelled; nothing was stored.[/yellow]"
|
|
164
|
+
)
|
|
165
|
+
raise typer.Exit(130) from None
|
|
166
|
+
except (
|
|
167
|
+
AuthenticationStateError,
|
|
168
|
+
SimklResponseError,
|
|
169
|
+
CredentialStorageError,
|
|
170
|
+
httpx.HTTPError,
|
|
171
|
+
OSError,
|
|
172
|
+
) as exc:
|
|
173
|
+
if json_output:
|
|
174
|
+
typer.echo(json.dumps({"error": "login_failed", "message": str(exc)}))
|
|
175
|
+
Console(stderr=True).print(f"[red]{exc}[/red]")
|
|
176
|
+
raise typer.Exit(1) from None
|
|
177
|
+
if json_output:
|
|
178
|
+
typer.echo(
|
|
179
|
+
json.dumps(
|
|
180
|
+
{
|
|
181
|
+
"authenticated": True,
|
|
182
|
+
"account": {"id": account.id, "name": account.name},
|
|
183
|
+
"credential_source": storage.value,
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
Console().print(
|
|
189
|
+
Panel(
|
|
190
|
+
f"{account.name} · account {account.id}\nCredential source: {storage.value}",
|
|
191
|
+
title="Authenticated Account",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _auth_status_impl(
|
|
196
|
+
runtime: Runtime,
|
|
197
|
+
*,
|
|
198
|
+
check: bool,
|
|
199
|
+
json_output: bool,
|
|
200
|
+
) -> None:
|
|
201
|
+
try:
|
|
202
|
+
credential = runtime.active_credential()
|
|
203
|
+
except CredentialStorageError as exc:
|
|
204
|
+
if json_output:
|
|
205
|
+
typer.echo(
|
|
206
|
+
json.dumps(
|
|
207
|
+
{
|
|
208
|
+
"authenticated": False,
|
|
209
|
+
"account": None,
|
|
210
|
+
"credential_source": None,
|
|
211
|
+
"checked_online": False,
|
|
212
|
+
"error": "credential_storage_error",
|
|
213
|
+
},
|
|
214
|
+
separators=(",", ":"),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
Console(stderr=True).print(f"[red]{exc}[/red]")
|
|
218
|
+
raise typer.Exit(1) from None
|
|
219
|
+
if credential is None:
|
|
220
|
+
if json_output:
|
|
221
|
+
typer.echo(
|
|
222
|
+
json.dumps(
|
|
223
|
+
{
|
|
224
|
+
"authenticated": False,
|
|
225
|
+
"account": None,
|
|
226
|
+
"credential_source": None,
|
|
227
|
+
"checked_online": False,
|
|
228
|
+
},
|
|
229
|
+
separators=(",", ":"),
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
raise typer.Exit(1)
|
|
233
|
+
Console().print(
|
|
234
|
+
Panel(
|
|
235
|
+
"Run [bold]simkl auth login[/bold] to authorize this CLI.",
|
|
236
|
+
title="No Authenticated Account",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
raise typer.Exit(1)
|
|
240
|
+
account = credential.account
|
|
241
|
+
if not check and (credential.legacy or credential.invalidated):
|
|
242
|
+
payload = {
|
|
243
|
+
"authenticated": False,
|
|
244
|
+
"account": {"id": account.id, "name": account.name} if account else None,
|
|
245
|
+
"credential_source": credential.source.value,
|
|
246
|
+
"checked_online": False,
|
|
247
|
+
"requires_login": True,
|
|
248
|
+
"auth_version": 1 if credential.legacy else 2,
|
|
249
|
+
}
|
|
250
|
+
if json_output:
|
|
251
|
+
typer.echo(json.dumps(payload))
|
|
252
|
+
else:
|
|
253
|
+
Console().print(
|
|
254
|
+
"AUTH V2 authorization required. Run simkl auth login; "
|
|
255
|
+
"your Library Snapshot is retained."
|
|
256
|
+
)
|
|
257
|
+
raise typer.Exit(1)
|
|
258
|
+
if check:
|
|
259
|
+
try:
|
|
260
|
+
credential, api = runtime.authenticated_client()
|
|
261
|
+
checked_account = api.get_authenticated_account(credential.access_token)
|
|
262
|
+
if (
|
|
263
|
+
credential.source is not CredentialSource.ENVIRONMENT
|
|
264
|
+
and account is not None
|
|
265
|
+
and checked_account.id != account.id
|
|
266
|
+
):
|
|
267
|
+
raise SimklResponseError(
|
|
268
|
+
"The Access Token belongs to a different account than local metadata."
|
|
269
|
+
)
|
|
270
|
+
account = checked_account
|
|
271
|
+
if credential.source is not CredentialSource.ENVIRONMENT:
|
|
272
|
+
current = runtime.credentials.read() or credential
|
|
273
|
+
runtime.credentials.save(replace(current, account=checked_account))
|
|
274
|
+
except InvalidAccessTokenError as exc:
|
|
275
|
+
if credential.source is not CredentialSource.ENVIRONMENT:
|
|
276
|
+
try:
|
|
277
|
+
current = runtime.credentials.read() or credential
|
|
278
|
+
runtime.credentials.save(replace(current, invalidated=True))
|
|
279
|
+
except (CredentialStorageError, OSError) as cleanup_error:
|
|
280
|
+
if json_output:
|
|
281
|
+
typer.echo(
|
|
282
|
+
json.dumps(
|
|
283
|
+
{
|
|
284
|
+
"authenticated": False,
|
|
285
|
+
"account": None,
|
|
286
|
+
"credential_source": credential.source.value,
|
|
287
|
+
"checked_online": True,
|
|
288
|
+
"error": "cleanup_failed",
|
|
289
|
+
},
|
|
290
|
+
separators=(",", ":"),
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
Console(stderr=True).print(
|
|
294
|
+
f"[red]{exc} Local cleanup failed: {cleanup_error}[/red]"
|
|
295
|
+
)
|
|
296
|
+
raise typer.Exit(1) from None
|
|
297
|
+
message = (
|
|
298
|
+
f"{exc} The environment variable was not removed."
|
|
299
|
+
if credential.source is CredentialSource.ENVIRONMENT
|
|
300
|
+
else f"{exc} Run simkl auth login; the Library Snapshot was retained."
|
|
301
|
+
)
|
|
302
|
+
if json_output:
|
|
303
|
+
typer.echo(
|
|
304
|
+
json.dumps(
|
|
305
|
+
{
|
|
306
|
+
"authenticated": False,
|
|
307
|
+
"account": None,
|
|
308
|
+
"credential_source": credential.source.value,
|
|
309
|
+
"checked_online": True,
|
|
310
|
+
"error": "invalid_or_revoked",
|
|
311
|
+
},
|
|
312
|
+
separators=(",", ":"),
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
Console(stderr=True).print(f"[red]{message}[/red]")
|
|
316
|
+
raise typer.Exit(1) from None
|
|
317
|
+
except (SimklResponseError, CredentialStorageError, httpx.HTTPError, OSError) as exc:
|
|
318
|
+
if json_output:
|
|
319
|
+
typer.echo(
|
|
320
|
+
json.dumps(
|
|
321
|
+
{
|
|
322
|
+
"authenticated": False,
|
|
323
|
+
"account": None,
|
|
324
|
+
"credential_source": credential.source.value,
|
|
325
|
+
"checked_online": False,
|
|
326
|
+
"error": "check_failed",
|
|
327
|
+
},
|
|
328
|
+
separators=(",", ":"),
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
Console(stderr=True).print(f"[red]{exc}[/red]")
|
|
332
|
+
raise typer.Exit(1) from None
|
|
333
|
+
payload = {
|
|
334
|
+
"authenticated": True,
|
|
335
|
+
"account": ({"id": account.id, "name": account.name} if account is not None else None),
|
|
336
|
+
"credential_source": credential.source.value,
|
|
337
|
+
"checked_online": check,
|
|
338
|
+
}
|
|
339
|
+
if json_output:
|
|
340
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
341
|
+
return
|
|
342
|
+
account_text = (
|
|
343
|
+
f"{account.name} · account {account.id}"
|
|
344
|
+
if account is not None
|
|
345
|
+
else "Account identity not recorded locally"
|
|
346
|
+
)
|
|
347
|
+
Console().print(
|
|
348
|
+
Panel(
|
|
349
|
+
f"{account_text}\nCredential source: {credential.source.value}\n"
|
|
350
|
+
+ (
|
|
351
|
+
"Access Token valid (checked online)"
|
|
352
|
+
if check
|
|
353
|
+
else "Access Token not checked online"
|
|
354
|
+
),
|
|
355
|
+
title="Authenticated Account",
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
@auth_app.command("status")
|
|
360
|
+
def auth_status(
|
|
361
|
+
check: Annotated[
|
|
362
|
+
bool,
|
|
363
|
+
typer.Option(help="Validate the Access Token online and refresh the display name."),
|
|
364
|
+
] = False,
|
|
365
|
+
json_output: Annotated[
|
|
366
|
+
bool,
|
|
367
|
+
typer.Option("--json", help="Emit one machine-readable payload on stdout."),
|
|
368
|
+
] = False,
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Show the locally recorded Authenticated Account."""
|
|
371
|
+
runtime = runtime_factory()
|
|
372
|
+
environment_active = bool(runtime.environ.get("SIMKL_ACCESS_TOKEN", "").strip())
|
|
373
|
+
transaction = (
|
|
374
|
+
runtime.credentials.account_transaction()
|
|
375
|
+
if check and not environment_active
|
|
376
|
+
else nullcontext()
|
|
377
|
+
)
|
|
378
|
+
try:
|
|
379
|
+
with transaction:
|
|
380
|
+
_auth_status_impl(runtime, check=check, json_output=json_output)
|
|
381
|
+
except OSError as exc:
|
|
382
|
+
if json_output:
|
|
383
|
+
typer.echo(
|
|
384
|
+
json.dumps(
|
|
385
|
+
{
|
|
386
|
+
"authenticated": False,
|
|
387
|
+
"account": None,
|
|
388
|
+
"credential_source": None,
|
|
389
|
+
"checked_online": False,
|
|
390
|
+
"error": "credential_storage_error",
|
|
391
|
+
},
|
|
392
|
+
separators=(",", ":"),
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
Console(stderr=True).print(f"[red]{exc}[/red]")
|
|
396
|
+
raise typer.Exit(1) from None
|
|
397
|
+
|
|
398
|
+
@auth_app.command("logout")
|
|
399
|
+
def auth_logout(
|
|
400
|
+
yes: Annotated[
|
|
401
|
+
bool,
|
|
402
|
+
typer.Option("--yes", "-y", help="Confirm local credential deletion."),
|
|
403
|
+
] = False,
|
|
404
|
+
json_output: Annotated[bool, typer.Option("--json", help="Emit one JSON result.")] = False,
|
|
405
|
+
local_only: Annotated[
|
|
406
|
+
bool, typer.Option(help="Remove local credentials without remote revocation.")
|
|
407
|
+
] = False,
|
|
408
|
+
) -> None:
|
|
409
|
+
"""Remove local credentials and request revocation of the stored V2 grant."""
|
|
410
|
+
runtime = runtime_factory()
|
|
411
|
+
environment_active = bool(runtime.environ.get("SIMKL_ACCESS_TOKEN", "").strip())
|
|
412
|
+
credential = None
|
|
413
|
+
try:
|
|
414
|
+
with runtime.credentials.account_transaction():
|
|
415
|
+
account = runtime.credentials.read_account()
|
|
416
|
+
if account is None:
|
|
417
|
+
if json_output:
|
|
418
|
+
typer.echo(json.dumps({"error": "no_local_credentials"}))
|
|
419
|
+
if environment_active:
|
|
420
|
+
Console(stderr=True).print(
|
|
421
|
+
"[yellow]SIMKL_ACCESS_TOKEN is active and cannot be removed by "
|
|
422
|
+
"logout. Unset the environment variable in its source.[/yellow]"
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
Console(stderr=True).print(
|
|
426
|
+
"[yellow]No local credentials are stored.[/yellow]"
|
|
427
|
+
)
|
|
428
|
+
raise typer.Exit(1)
|
|
429
|
+
if not yes and (json_output or not runtime.interactive):
|
|
430
|
+
raise AuthenticationStateError("Noninteractive logout requires --yes.")
|
|
431
|
+
if not yes and not typer.confirm(
|
|
432
|
+
"Delete local credentials and Library Snapshot, and request V2 revocation?"
|
|
433
|
+
):
|
|
434
|
+
raise typer.Abort()
|
|
435
|
+
credential = runtime.credentials.read()
|
|
436
|
+
with runtime.snapshots.reversible_delete(account.id):
|
|
437
|
+
runtime.credentials.delete()
|
|
438
|
+
except KeyboardInterrupt:
|
|
439
|
+
if json_output:
|
|
440
|
+
typer.echo(json.dumps({"error": "cancelled"}))
|
|
441
|
+
Console(stderr=True).print(
|
|
442
|
+
"[yellow]Logout cancelled; local credentials and snapshot were preserved.[/yellow]"
|
|
443
|
+
)
|
|
444
|
+
raise typer.Exit(130) from None
|
|
445
|
+
except (AuthenticationStateError, CredentialStorageError, OSError) as exc:
|
|
446
|
+
if json_output:
|
|
447
|
+
typer.echo(json.dumps({"error": "logout_failed", "message": str(exc)}))
|
|
448
|
+
Console(stderr=True).print(f"[red]{exc}[/red]")
|
|
449
|
+
raise typer.Exit(1) from None
|
|
450
|
+
acknowledged = False
|
|
451
|
+
if credential is not None and credential.client_id and not local_only:
|
|
452
|
+
try:
|
|
453
|
+
SimklClient(
|
|
454
|
+
client_id=credential.client_id,
|
|
455
|
+
transport=runtime.transport,
|
|
456
|
+
sleep=runtime.sleep,
|
|
457
|
+
rate_gate=runtime.rate_gate,
|
|
458
|
+
).revoke_token(credential.refresh_token or credential.access_token)
|
|
459
|
+
acknowledged = True
|
|
460
|
+
except (SimklResponseError, httpx.HTTPError, OSError, KeyboardInterrupt):
|
|
461
|
+
Console(stderr=True).print(
|
|
462
|
+
"Local logout completed, but remote revocation was not acknowledged. "
|
|
463
|
+
"Disconnect this grant in Simkl Connected Apps if needed."
|
|
464
|
+
)
|
|
465
|
+
message = "Local credentials, account metadata, and Library Snapshot were removed. " + (
|
|
466
|
+
"Simkl acknowledged the revocation request; its response does not prove "
|
|
467
|
+
"which token was revoked."
|
|
468
|
+
if acknowledged
|
|
469
|
+
else "The remote Access Token remains active or unverified; revoke it in "
|
|
470
|
+
"Simkl Connected Apps if needed."
|
|
471
|
+
)
|
|
472
|
+
if json_output:
|
|
473
|
+
typer.echo(
|
|
474
|
+
json.dumps(
|
|
475
|
+
{
|
|
476
|
+
"logged_out": True,
|
|
477
|
+
"remote_token_revoked": False,
|
|
478
|
+
"remote_revocation_acknowledged": acknowledged,
|
|
479
|
+
"environment_override_active": environment_active,
|
|
480
|
+
"message": message,
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
Console().print(message)
|
|
486
|
+
if environment_active:
|
|
487
|
+
Console(stderr=True).print(
|
|
488
|
+
"[yellow]SIMKL_ACCESS_TOKEN is still active and cannot be removed by "
|
|
489
|
+
"logout.[/yellow]"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
register_commands(app, runtime_factory)
|
|
493
|
+
return app
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
app = create_app()
|