glossa-cli 0.1.1__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.
- glossa_cli-0.1.1/PKG-INFO +82 -0
- glossa_cli-0.1.1/README.md +56 -0
- glossa_cli-0.1.1/pyproject.toml +64 -0
- glossa_cli-0.1.1/src/glossa_cli/__init__.py +7 -0
- glossa_cli-0.1.1/src/glossa_cli/auth.py +227 -0
- glossa_cli-0.1.1/src/glossa_cli/cli.py +376 -0
- glossa_cli-0.1.1/src/glossa_cli/client.py +176 -0
- glossa_cli-0.1.1/tests/test_cli.py +248 -0
- glossa_cli-0.1.1/tests/test_client.py +121 -0
- glossa_cli-0.1.1/uv.lock +356 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: glossa-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Command-line client for Glossa — analyze text and manage your vocabulary from the terminal
|
|
5
|
+
Project-URL: Homepage, https://glossa.pro
|
|
6
|
+
Project-URL: Repository, https://github.com/artemiy-rodionov/glossa
|
|
7
|
+
Project-URL: Issues, https://github.com/artemiy-rodionov/glossa/issues
|
|
8
|
+
Author-email: Artemiy Rodionov <artemiy.rodionov@gmail.com>
|
|
9
|
+
Keywords: cli,flashcards,fsrs,glossa,language-learning,vocabulary
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Education
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.28
|
|
23
|
+
Requires-Dist: rich>=13
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# glossa-cli
|
|
28
|
+
|
|
29
|
+
A standalone command-line client for [Glossa](https://glossa.pro) — analyze text
|
|
30
|
+
and manage your vocabulary from the terminal.
|
|
31
|
+
|
|
32
|
+
## Install (one command)
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pipx install glossa-cli # recommended — isolated install
|
|
36
|
+
# or:
|
|
37
|
+
pip install glossa-cli
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Both put the `glossa` command on your PATH. No `uv` or source checkout required.
|
|
41
|
+
|
|
42
|
+
## Sign in (OAuth — no token to copy)
|
|
43
|
+
|
|
44
|
+
Run `glossa login` first. It opens a browser to authorise you; after you
|
|
45
|
+
approve, your tokens are cached in `~/.config/glossa/credentials.json` and
|
|
46
|
+
refreshed automatically — you won't need to sign in again.
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
glossa login # opens a browser; prints an auth URL to approve
|
|
50
|
+
glossa login --force # clear the cached sign-in and authorise again
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Self-hosted / local dev:** point the CLI at your instance with
|
|
54
|
+
> `export GLOSSA_API_URL=http://localhost:8000` (defaults to
|
|
55
|
+
> `https://api.glossa.pro`).
|
|
56
|
+
>
|
|
57
|
+
> **CI / scripting:** skip the browser flow by exporting a static token from
|
|
58
|
+
> Glossa → Settings → Tokens: `export GLOSSA_API_TOKEN=glossa_xxx`.
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
# Analyze text (arg, --file, or stdin)
|
|
64
|
+
echo "The serendipitous discovery broke the ice." | glossa analyze --lang en
|
|
65
|
+
glossa analyze --file article.txt
|
|
66
|
+
glossa analyze "a short sentence" --json
|
|
67
|
+
|
|
68
|
+
# Add a word or phrase
|
|
69
|
+
glossa add serendipity --lang en --status learning
|
|
70
|
+
glossa add "break the ice" --lang en -t "сломать лёд"
|
|
71
|
+
|
|
72
|
+
# Change a word's state (upserts by lemma; --id targets one sense)
|
|
73
|
+
glossa set serendipity known --lang en
|
|
74
|
+
glossa set --id 1234 saved
|
|
75
|
+
|
|
76
|
+
# List your vocabulary
|
|
77
|
+
glossa list --status learning
|
|
78
|
+
glossa list --lang fr --json
|
|
79
|
+
|
|
80
|
+
# Interactive FSRS review session
|
|
81
|
+
glossa review --lang en
|
|
82
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# glossa-cli
|
|
2
|
+
|
|
3
|
+
A standalone command-line client for [Glossa](https://glossa.pro) — analyze text
|
|
4
|
+
and manage your vocabulary from the terminal.
|
|
5
|
+
|
|
6
|
+
## Install (one command)
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pipx install glossa-cli # recommended — isolated install
|
|
10
|
+
# or:
|
|
11
|
+
pip install glossa-cli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Both put the `glossa` command on your PATH. No `uv` or source checkout required.
|
|
15
|
+
|
|
16
|
+
## Sign in (OAuth — no token to copy)
|
|
17
|
+
|
|
18
|
+
Run `glossa login` first. It opens a browser to authorise you; after you
|
|
19
|
+
approve, your tokens are cached in `~/.config/glossa/credentials.json` and
|
|
20
|
+
refreshed automatically — you won't need to sign in again.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
glossa login # opens a browser; prints an auth URL to approve
|
|
24
|
+
glossa login --force # clear the cached sign-in and authorise again
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> **Self-hosted / local dev:** point the CLI at your instance with
|
|
28
|
+
> `export GLOSSA_API_URL=http://localhost:8000` (defaults to
|
|
29
|
+
> `https://api.glossa.pro`).
|
|
30
|
+
>
|
|
31
|
+
> **CI / scripting:** skip the browser flow by exporting a static token from
|
|
32
|
+
> Glossa → Settings → Tokens: `export GLOSSA_API_TOKEN=glossa_xxx`.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
# Analyze text (arg, --file, or stdin)
|
|
38
|
+
echo "The serendipitous discovery broke the ice." | glossa analyze --lang en
|
|
39
|
+
glossa analyze --file article.txt
|
|
40
|
+
glossa analyze "a short sentence" --json
|
|
41
|
+
|
|
42
|
+
# Add a word or phrase
|
|
43
|
+
glossa add serendipity --lang en --status learning
|
|
44
|
+
glossa add "break the ice" --lang en -t "сломать лёд"
|
|
45
|
+
|
|
46
|
+
# Change a word's state (upserts by lemma; --id targets one sense)
|
|
47
|
+
glossa set serendipity known --lang en
|
|
48
|
+
glossa set --id 1234 saved
|
|
49
|
+
|
|
50
|
+
# List your vocabulary
|
|
51
|
+
glossa list --status learning
|
|
52
|
+
glossa list --lang fr --json
|
|
53
|
+
|
|
54
|
+
# Interactive FSRS review session
|
|
55
|
+
glossa review --lang en
|
|
56
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "glossa-cli"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Command-line client for Glossa — analyze text and manage your vocabulary from the terminal"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [{ name = "Artemiy Rodionov", email = "artemiy.rodionov@gmail.com" }]
|
|
8
|
+
keywords = ["glossa", "vocabulary", "language-learning", "cli", "flashcards", "fsrs"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: End Users/Desktop",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Education",
|
|
20
|
+
"Topic :: Utilities",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"httpx>=0.28",
|
|
24
|
+
"typer>=0.12",
|
|
25
|
+
"rich>=13",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://glossa.pro"
|
|
30
|
+
Repository = "https://github.com/artemiy-rodionov/glossa"
|
|
31
|
+
Issues = "https://github.com/artemiy-rodionov/glossa/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
# Once published to PyPI, users install with a single command — no uv or
|
|
35
|
+
# source checkout required:
|
|
36
|
+
# pipx install glossa-cli (recommended, isolated)
|
|
37
|
+
# pip install glossa-cli
|
|
38
|
+
# This puts the `glossa` command on PATH.
|
|
39
|
+
glossa = "glossa_cli.cli:main"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/glossa_cli"]
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"pytest>=8.3",
|
|
51
|
+
"pytest-asyncio>=0.25",
|
|
52
|
+
"ruff>=0.8",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
asyncio_mode = "auto"
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
target-version = "py310"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "B", "UP", "SIM"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Glossa CLI — a standalone terminal client for the Glossa REST API.
|
|
2
|
+
|
|
3
|
+
Talks to the API over HTTP using the same auth as the MCP server
|
|
4
|
+
(``GLOSSA_API_TOKEN`` static token or OAuth device-code flow). Install with::
|
|
5
|
+
|
|
6
|
+
uv tool install glossa-cli # or: pipx install glossa-cli / uvx glossa-cli
|
|
7
|
+
"""
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""OAuth 2.0 Device-Code flow + credential storage for the Glossa CLI.
|
|
2
|
+
|
|
3
|
+
Vendored from ``backend/glossa_mcp/auth.py`` so the CLI ships as a standalone,
|
|
4
|
+
lightweight package (httpx + stdlib only) without depending on the heavy
|
|
5
|
+
``glossa-backend`` distribution.
|
|
6
|
+
|
|
7
|
+
Design:
|
|
8
|
+
- Credentials are stored in ``~/.config/glossa/credentials.json`` (shared with
|
|
9
|
+
the MCP client — a token approved once works for both).
|
|
10
|
+
- When a valid access token exists it's used directly.
|
|
11
|
+
- When the access token has expired the refresh token is used to get a new pair.
|
|
12
|
+
- When there are no credentials at all, the device-code flow is initiated:
|
|
13
|
+
1. POST /api/oauth/device_authorization → get device_code + user_code
|
|
14
|
+
2. Print verification_uri_complete to stderr so the user can approve it.
|
|
15
|
+
3. Poll /api/oauth/token every ``interval`` seconds until approved, denied
|
|
16
|
+
or the device code expires.
|
|
17
|
+
4. Save the resulting access + refresh tokens to disk.
|
|
18
|
+
|
|
19
|
+
Only ``ensure_token(base_url)`` is the public entry-point — it returns a valid
|
|
20
|
+
Bearer token string or raises ``AuthError`` (fatal, surfaced to the user).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
# ── Configuration ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
CREDENTIALS_PATH = Path.home() / ".config" / "glossa" / "credentials.json"
|
|
35
|
+
POLL_MAX_SECONDS = 600 # 10 min — matches backend DEVICE_CODE_TTL
|
|
36
|
+
DEFAULT_POLL_INTERVAL = 5 # seconds between /token poll attempts
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Data ─────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Credentials:
|
|
44
|
+
access_token: str
|
|
45
|
+
refresh_token: str
|
|
46
|
+
expires_at: float # Unix timestamp when access_token expires
|
|
47
|
+
base_url: str
|
|
48
|
+
|
|
49
|
+
def access_expired(self) -> bool:
|
|
50
|
+
# Give a 30-second buffer so we refresh before the token actually expires.
|
|
51
|
+
return time.time() >= (self.expires_at - 30)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Storage ──────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load() -> Credentials | None:
|
|
58
|
+
if not CREDENTIALS_PATH.exists():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(CREDENTIALS_PATH.read_text())
|
|
62
|
+
return Credentials(**data)
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _save(creds: Credentials) -> None:
|
|
68
|
+
CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
CREDENTIALS_PATH.write_text(json.dumps(asdict(creds), indent=2))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _clear() -> None:
|
|
73
|
+
if CREDENTIALS_PATH.exists():
|
|
74
|
+
CREDENTIALS_PATH.unlink()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def clear_credentials() -> bool:
|
|
78
|
+
"""Remove any cached OAuth credentials. Returns True if a file was removed."""
|
|
79
|
+
existed = CREDENTIALS_PATH.exists()
|
|
80
|
+
_clear()
|
|
81
|
+
return existed
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Errors ───────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AuthError(Exception):
|
|
88
|
+
"""Fatal auth failure — message is user-visible."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── OAuth HTTP helpers ────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _post(client: httpx.AsyncClient, path: str, body: dict) -> dict:
|
|
95
|
+
resp = await client.post(path, json=body)
|
|
96
|
+
try:
|
|
97
|
+
payload = resp.json()
|
|
98
|
+
except Exception:
|
|
99
|
+
payload = {}
|
|
100
|
+
if resp.is_success:
|
|
101
|
+
return payload
|
|
102
|
+
# Surface OAuth error_description if present
|
|
103
|
+
detail = payload.get("detail") or {}
|
|
104
|
+
if isinstance(detail, dict):
|
|
105
|
+
msg = detail.get("error_description") or detail.get("error") or str(payload)
|
|
106
|
+
elif isinstance(detail, str):
|
|
107
|
+
msg = detail
|
|
108
|
+
else:
|
|
109
|
+
msg = resp.text or resp.reason_phrase
|
|
110
|
+
raise httpx.HTTPStatusError(msg, request=resp.request, response=resp)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _refresh(client: httpx.AsyncClient, refresh_token: str, base_url: str) -> Credentials:
|
|
114
|
+
data = await _post(client, "/api/oauth/token", {
|
|
115
|
+
"grant_type": "refresh_token",
|
|
116
|
+
"refresh_token": refresh_token,
|
|
117
|
+
})
|
|
118
|
+
return Credentials(
|
|
119
|
+
access_token=data["access_token"],
|
|
120
|
+
refresh_token=data["refresh_token"],
|
|
121
|
+
expires_at=time.time() + data.get("expires_in", 3600),
|
|
122
|
+
base_url=base_url,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── Device-code flow ─────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _device_flow(base_url: str) -> Credentials:
|
|
130
|
+
"""Run the full RFC 8628 device-code flow interactively (via stderr)."""
|
|
131
|
+
async with httpx.AsyncClient(base_url=base_url, timeout=30) as client:
|
|
132
|
+
# 1 — Request codes
|
|
133
|
+
init = await _post(client, "/api/oauth/device_authorization", {})
|
|
134
|
+
device_code = init["device_code"]
|
|
135
|
+
user_code = init["user_code"]
|
|
136
|
+
verification_uri_complete = init["verification_uri_complete"]
|
|
137
|
+
interval = int(init.get("interval", DEFAULT_POLL_INTERVAL))
|
|
138
|
+
|
|
139
|
+
# 2 — Prompt user (stderr keeps stdout clean for piping / JSON output)
|
|
140
|
+
print(
|
|
141
|
+
f"\n{'─' * 60}\n"
|
|
142
|
+
f" Glossa CLI — authorisation required\n\n"
|
|
143
|
+
f" 1. Open this URL in your browser:\n"
|
|
144
|
+
f" {verification_uri_complete}\n\n"
|
|
145
|
+
f" 2. Code to confirm: {user_code}\n"
|
|
146
|
+
f"{'─' * 60}\n",
|
|
147
|
+
file=sys.stderr,
|
|
148
|
+
flush=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# 3 — Poll
|
|
152
|
+
deadline = time.time() + POLL_MAX_SECONDS
|
|
153
|
+
while time.time() < deadline:
|
|
154
|
+
await asyncio.sleep(interval)
|
|
155
|
+
try:
|
|
156
|
+
data = await _post(client, "/api/oauth/token", {
|
|
157
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
158
|
+
"device_code": device_code,
|
|
159
|
+
})
|
|
160
|
+
# Success
|
|
161
|
+
print(" ✓ Authorised — Glossa CLI is ready.\n", file=sys.stderr, flush=True)
|
|
162
|
+
return Credentials(
|
|
163
|
+
access_token=data["access_token"],
|
|
164
|
+
refresh_token=data["refresh_token"],
|
|
165
|
+
expires_at=time.time() + data.get("expires_in", 3600),
|
|
166
|
+
base_url=base_url,
|
|
167
|
+
)
|
|
168
|
+
except httpx.HTTPStatusError as exc:
|
|
169
|
+
# Parse the body that _post embeds in the exception message
|
|
170
|
+
try:
|
|
171
|
+
body = exc.response.json()
|
|
172
|
+
detail = body.get("detail", {})
|
|
173
|
+
error_code = detail.get("error") if isinstance(detail, dict) else None
|
|
174
|
+
except Exception:
|
|
175
|
+
error_code = None
|
|
176
|
+
|
|
177
|
+
if error_code == "authorization_pending":
|
|
178
|
+
continue # keep polling
|
|
179
|
+
if error_code == "slow_down":
|
|
180
|
+
interval += 5
|
|
181
|
+
continue
|
|
182
|
+
if error_code in ("access_denied", "expired_token", "invalid_grant"):
|
|
183
|
+
# Upgrade path: surface the upgrade URL if present
|
|
184
|
+
upgrade_url = (
|
|
185
|
+
(detail or {}).get("upgrade_url") if isinstance(detail, dict) else None
|
|
186
|
+
)
|
|
187
|
+
if upgrade_url:
|
|
188
|
+
raise AuthError(
|
|
189
|
+
f"Glossa access requires a Pro subscription.\n"
|
|
190
|
+
f"Upgrade at: {upgrade_url}"
|
|
191
|
+
) from exc
|
|
192
|
+
raise AuthError(f"Authorisation denied ({error_code}).") from exc
|
|
193
|
+
# Unknown error — surface verbatim
|
|
194
|
+
raise AuthError(f"Token error: {exc}") from exc
|
|
195
|
+
|
|
196
|
+
raise AuthError("Authorisation timed out — please run the command again.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── Public entry-point ────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def ensure_token(base_url: str) -> str:
|
|
203
|
+
"""Return a valid Bearer token for *base_url*, refreshing or re-authorising as needed."""
|
|
204
|
+
creds = _load()
|
|
205
|
+
|
|
206
|
+
# Mismatched base_url (e.g. switched from prod to dev) — start fresh
|
|
207
|
+
if creds is not None and creds.base_url.rstrip("/") != base_url.rstrip("/"):
|
|
208
|
+
_clear()
|
|
209
|
+
creds = None
|
|
210
|
+
|
|
211
|
+
if creds is not None and not creds.access_expired():
|
|
212
|
+
return creds.access_token
|
|
213
|
+
|
|
214
|
+
if creds is not None and creds.refresh_token:
|
|
215
|
+
try:
|
|
216
|
+
async with httpx.AsyncClient(base_url=base_url, timeout=30) as client:
|
|
217
|
+
creds = await _refresh(client, creds.refresh_token, base_url)
|
|
218
|
+
_save(creds)
|
|
219
|
+
return creds.access_token
|
|
220
|
+
except Exception:
|
|
221
|
+
# Refresh failed (revoked / expired / downgraded) — full re-auth
|
|
222
|
+
_clear()
|
|
223
|
+
|
|
224
|
+
# No usable credentials — run device-code flow
|
|
225
|
+
creds = await _device_flow(base_url)
|
|
226
|
+
_save(creds)
|
|
227
|
+
return creds.access_token
|