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/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()