publinker-cli 0.6.2__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.
- publinker_cli/__init__.py +14 -0
- publinker_cli/__main__.py +83 -0
- publinker_cli/audience.py +73 -0
- publinker_cli/auth.py +152 -0
- publinker_cli/client.py +166 -0
- publinker_cli/commands/__init__.py +0 -0
- publinker_cli/commands/auth_cmds.py +69 -0
- publinker_cli/commands/report_options.py +100 -0
- publinker_cli/commands/skill.py +46 -0
- publinker_cli/commands/token_cmd.py +97 -0
- publinker_cli/commands/update.py +207 -0
- publinker_cli/commands/use.py +100 -0
- publinker_cli/commands/write.py +72 -0
- publinker_cli/config.py +64 -0
- publinker_cli/dispatch_cli.py +394 -0
- publinker_cli/keyring_store.py +109 -0
- publinker_cli/manifest.py +409 -0
- publinker_cli/routes.yaml +1648 -0
- publinker_cli/skill/SKILL.md +61 -0
- publinker_cli/state.py +138 -0
- publinker_cli/version.py +58 -0
- publinker_cli/write_gate.py +51 -0
- publinker_cli-0.6.2.dist-info/METADATA +81 -0
- publinker_cli-0.6.2.dist-info/RECORD +26 -0
- publinker_cli-0.6.2.dist-info/WHEEL +4 -0
- publinker_cli-0.6.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Build a Typer command tree from the route manifest.
|
|
2
|
+
|
|
3
|
+
Given the list of :class:`~publinker_cli.manifest.Route` records, this module
|
|
4
|
+
materializes Typer ``Typer`` apps for each ``cli.group`` prefix and registers
|
|
5
|
+
one command per route. The body of each generated command is a thin wrapper
|
|
6
|
+
that:
|
|
7
|
+
|
|
8
|
+
1. Resolves the active site (when the route is site-scoped) via
|
|
9
|
+
:mod:`publinker_cli.state`.
|
|
10
|
+
2. Renders the API path with ``Route.render_path``.
|
|
11
|
+
3. Calls :class:`PublinkerClient` with the right method + query params.
|
|
12
|
+
4. Pretty-prints the response (or raw JSON if ``--json`` is passed).
|
|
13
|
+
|
|
14
|
+
Why a single generic command body: every read-only route has the same shape
|
|
15
|
+
(path args + query params + stage + json toggle). One handler keeps the
|
|
16
|
+
generated tree predictable and the failure mode the same everywhere.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import inspect
|
|
22
|
+
import json as _json
|
|
23
|
+
import os
|
|
24
|
+
from typing import Any, Callable, Optional
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
from rich.table import Table
|
|
29
|
+
|
|
30
|
+
from publinker_cli import state, write_gate
|
|
31
|
+
from publinker_cli.client import APIError, NotAuthenticatedError, PublinkerClient
|
|
32
|
+
from publinker_cli.config import available_stages
|
|
33
|
+
from publinker_cli.manifest import Route
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Command body — the generic execution path every route shares
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _execute_route(route: Route, params: dict[str, Any]) -> None:
|
|
44
|
+
"""Run one manifest-defined route. Called from each generated Typer command."""
|
|
45
|
+
stage = params.pop("stage", None)
|
|
46
|
+
json_out = params.pop("json_out", False)
|
|
47
|
+
explicit_site = params.pop("site", None) if route.is_site_scoped else None
|
|
48
|
+
|
|
49
|
+
# Gate mutations before doing any work — the user shouldn't get to "site
|
|
50
|
+
# missing" before "writes blocked". Reads bypass the gate entirely.
|
|
51
|
+
if route.is_mutation and not write_gate.is_allowed():
|
|
52
|
+
gate = write_gate.status()
|
|
53
|
+
console.print(
|
|
54
|
+
f"[red]Writes BLOCKED[/red] — {gate.reason}.\n"
|
|
55
|
+
f"Prefix one reviewed command with "
|
|
56
|
+
f"[cyan]{write_gate.ENV_VAR}=1[/cyan]; don't export it."
|
|
57
|
+
)
|
|
58
|
+
raise typer.Exit(code=2)
|
|
59
|
+
|
|
60
|
+
# A prod-only (customer) build has nothing to disambiguate.
|
|
61
|
+
if route.is_mutation and not stage and len(available_stages()) > 1:
|
|
62
|
+
console.print("[red]mutations require an explicit --stage[/red]")
|
|
63
|
+
raise typer.Exit(code=2)
|
|
64
|
+
stage = stage or "prod"
|
|
65
|
+
|
|
66
|
+
if route.is_mutation and route.is_site_scoped and not explicit_site:
|
|
67
|
+
console.print("[red]site-scoped mutations require an explicit --site[/red]")
|
|
68
|
+
raise typer.Exit(code=2)
|
|
69
|
+
|
|
70
|
+
path_values: dict[str, str] = {}
|
|
71
|
+
if route.is_site_scoped:
|
|
72
|
+
try:
|
|
73
|
+
path_values["site"] = state.resolve_site(explicit_site)
|
|
74
|
+
except state.NoSiteSelectedError as exc:
|
|
75
|
+
console.print(f"[red]{exc}[/red]")
|
|
76
|
+
raise typer.Exit(code=2)
|
|
77
|
+
|
|
78
|
+
for arg in route.extra_path_args:
|
|
79
|
+
value = params.pop(arg.name, None)
|
|
80
|
+
if value is None:
|
|
81
|
+
console.print(f"[red]missing argument: {arg.name}[/red]")
|
|
82
|
+
raise typer.Exit(code=2)
|
|
83
|
+
path_values[arg.name] = str(value)
|
|
84
|
+
|
|
85
|
+
query_params: dict[str, str] = {}
|
|
86
|
+
for q in route.query_args:
|
|
87
|
+
value = params.pop(q.name, None)
|
|
88
|
+
if value is not None:
|
|
89
|
+
query_params[q.name] = str(value)
|
|
90
|
+
|
|
91
|
+
body = _build_body(route, params)
|
|
92
|
+
extra_headers = _build_extra_headers(route)
|
|
93
|
+
|
|
94
|
+
path = route.render_path(path_values)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
client = PublinkerClient(stage=stage)
|
|
98
|
+
request_kwargs: dict[str, Any] = {}
|
|
99
|
+
if query_params:
|
|
100
|
+
request_kwargs["params"] = query_params
|
|
101
|
+
if body is not None:
|
|
102
|
+
request_kwargs["json"] = body
|
|
103
|
+
if extra_headers:
|
|
104
|
+
request_kwargs["headers"] = extra_headers
|
|
105
|
+
response = client.request(route.method, path, **request_kwargs)
|
|
106
|
+
except NotAuthenticatedError as exc:
|
|
107
|
+
console.print(f"[red]{exc}[/red]")
|
|
108
|
+
raise typer.Exit(code=1)
|
|
109
|
+
except (APIError, ValueError) as exc:
|
|
110
|
+
console.print(f"[red]{exc}[/red]")
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
if json_out:
|
|
114
|
+
typer.echo(_json.dumps(response, separators=(",", ":"), default=str))
|
|
115
|
+
else:
|
|
116
|
+
_pretty_print(route, response)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_extra_headers(route: Route) -> dict[str, str]:
|
|
120
|
+
"""Resolve route.extra_headers values from the environment.
|
|
121
|
+
|
|
122
|
+
Headers are intentionally never sourced from the manifest itself —
|
|
123
|
+
routes that need a secret (e.g. ``x-manual-deploy-key``) pull from the
|
|
124
|
+
caller's env so the value stays out of git. Missing-but-required
|
|
125
|
+
headers fail fast with a clear message before any network call.
|
|
126
|
+
"""
|
|
127
|
+
if not route.extra_headers:
|
|
128
|
+
return {}
|
|
129
|
+
|
|
130
|
+
resolved: dict[str, str] = {}
|
|
131
|
+
missing: list[str] = []
|
|
132
|
+
for h in route.extra_headers:
|
|
133
|
+
value = os.environ.get(h.env)
|
|
134
|
+
if value:
|
|
135
|
+
resolved[h.name] = value
|
|
136
|
+
elif h.required:
|
|
137
|
+
missing.append(h.env)
|
|
138
|
+
|
|
139
|
+
if missing:
|
|
140
|
+
flags = ", ".join(missing)
|
|
141
|
+
console.print(
|
|
142
|
+
f"[red]missing required env var(s) for request headers: {flags}.[/red]\n"
|
|
143
|
+
f"Set them in your shell, then retry. See "
|
|
144
|
+
f"`publinker site wrapper manual-deploy --help` for context."
|
|
145
|
+
)
|
|
146
|
+
raise typer.Exit(code=2)
|
|
147
|
+
|
|
148
|
+
return resolved
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _build_body(route: Route, params: dict[str, Any]) -> Any:
|
|
152
|
+
"""Assemble the JSON body from ``body_args`` values passed on the CLI.
|
|
153
|
+
|
|
154
|
+
Returns ``None`` when the route has no body args (or none were provided
|
|
155
|
+
and none were required), so ``requests`` doesn't send a body at all.
|
|
156
|
+
Validates required fields and decodes ``json=true`` args.
|
|
157
|
+
|
|
158
|
+
Special-case: a single body arg with ``raw=true`` is the *whole* payload
|
|
159
|
+
rather than a field inside it — used by routes whose server-side handler
|
|
160
|
+
accepts a freeform shape (e.g. ``put_pricing``: ``--body '[...floors]'``).
|
|
161
|
+
Manifest validation guarantees ``raw=true`` implies ``json=true`` and
|
|
162
|
+
that it's the only body arg.
|
|
163
|
+
"""
|
|
164
|
+
if not route.body_args:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
if len(route.body_args) == 1 and route.body_args[0].raw:
|
|
168
|
+
b = route.body_args[0]
|
|
169
|
+
value = params.pop(b.name, None)
|
|
170
|
+
if value is None:
|
|
171
|
+
if b.required:
|
|
172
|
+
console.print(
|
|
173
|
+
f"[red]missing required body: --{b.name.replace('_', '-')}[/red]"
|
|
174
|
+
)
|
|
175
|
+
raise typer.Exit(code=2)
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
return _json.loads(value)
|
|
179
|
+
except _json.JSONDecodeError as exc:
|
|
180
|
+
console.print(
|
|
181
|
+
f"[red]--{b.name.replace('_', '-')}: not valid JSON: "
|
|
182
|
+
f"{exc.msg}[/red]"
|
|
183
|
+
)
|
|
184
|
+
raise typer.Exit(code=2)
|
|
185
|
+
|
|
186
|
+
body: dict[str, Any] = {}
|
|
187
|
+
missing: list[str] = []
|
|
188
|
+
for b in route.body_args:
|
|
189
|
+
value = params.pop(b.name, None)
|
|
190
|
+
if value is None:
|
|
191
|
+
if b.required:
|
|
192
|
+
missing.append(b.name)
|
|
193
|
+
continue
|
|
194
|
+
if b.json:
|
|
195
|
+
try:
|
|
196
|
+
body[b.name] = _json.loads(value)
|
|
197
|
+
except _json.JSONDecodeError as exc:
|
|
198
|
+
console.print(
|
|
199
|
+
f"[red]--{b.name.replace('_', '-')}: not valid JSON: "
|
|
200
|
+
f"{exc.msg}[/red]"
|
|
201
|
+
)
|
|
202
|
+
raise typer.Exit(code=2)
|
|
203
|
+
else:
|
|
204
|
+
body[b.name] = value
|
|
205
|
+
|
|
206
|
+
if missing:
|
|
207
|
+
flags = ", ".join(f"--{m.replace('_', '-')}" for m in missing)
|
|
208
|
+
console.print(f"[red]missing required body args: {flags}[/red]")
|
|
209
|
+
raise typer.Exit(code=2)
|
|
210
|
+
|
|
211
|
+
return body if body else None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _pretty_print(route: Route, response: Any) -> None:
|
|
215
|
+
"""Best-effort tabular rendering with a JSON fallback.
|
|
216
|
+
|
|
217
|
+
The publinker API returns wildly varied shapes — lists of strings, dicts
|
|
218
|
+
keyed by id, deeply nested reports. Rather than maintain a per-route
|
|
219
|
+
formatter (which is exactly what the manifest is trying to avoid), we
|
|
220
|
+
pick the obvious shape and fall back to indented JSON for everything else.
|
|
221
|
+
"""
|
|
222
|
+
title = f"{route.id} — {route.method} {route.path}"
|
|
223
|
+
|
|
224
|
+
if isinstance(response, list) and all(isinstance(item, str) for item in response):
|
|
225
|
+
_print_string_list(title, response)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
if isinstance(response, dict):
|
|
229
|
+
for key in ("sites", "companyIds", "items", "data"):
|
|
230
|
+
inner = response.get(key)
|
|
231
|
+
if isinstance(inner, list) and all(isinstance(i, str) for i in inner):
|
|
232
|
+
_print_string_list(f"{title} — {key}", inner)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
typer.echo(_json.dumps(response, indent=2, default=str))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _print_string_list(title: str, items: list[str]) -> None:
|
|
239
|
+
table = Table(title=f"{title} ({len(items)})", show_lines=False)
|
|
240
|
+
table.add_column("#", style="dim", width=4, justify="right")
|
|
241
|
+
table.add_column("value", style="cyan")
|
|
242
|
+
for i, item in enumerate(items, start=1):
|
|
243
|
+
table.add_row(str(i), item)
|
|
244
|
+
console.print(table)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# Command factory — builds a Typer-compatible callback per route
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _make_callback(route: Route) -> Callable[..., None]:
|
|
253
|
+
"""Create a callback with a Typer-discoverable signature for one route.
|
|
254
|
+
|
|
255
|
+
Typer reads ``inspect.signature(callback)`` and turns each parameter's
|
|
256
|
+
default (a ``typer.Argument`` or ``typer.Option`` instance) into a CLI
|
|
257
|
+
arg/option. We build that signature programmatically from the route.
|
|
258
|
+
"""
|
|
259
|
+
params: list[inspect.Parameter] = []
|
|
260
|
+
|
|
261
|
+
for arg in route.extra_path_args:
|
|
262
|
+
params.append(
|
|
263
|
+
inspect.Parameter(
|
|
264
|
+
arg.name,
|
|
265
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
266
|
+
default=typer.Argument(..., help=arg.help),
|
|
267
|
+
annotation=str,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if route.is_site_scoped:
|
|
272
|
+
params.append(
|
|
273
|
+
inspect.Parameter(
|
|
274
|
+
"site",
|
|
275
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
276
|
+
default=typer.Option(
|
|
277
|
+
None,
|
|
278
|
+
"--site",
|
|
279
|
+
help="Override the active site for this call only.",
|
|
280
|
+
),
|
|
281
|
+
annotation=Optional[str],
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
for q in route.query_args:
|
|
286
|
+
params.append(
|
|
287
|
+
inspect.Parameter(
|
|
288
|
+
q.name,
|
|
289
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
290
|
+
default=typer.Option(
|
|
291
|
+
q.default,
|
|
292
|
+
f"--{q.name.replace('_', '-')}",
|
|
293
|
+
help=q.help or f"Query parameter '{q.name}'.",
|
|
294
|
+
),
|
|
295
|
+
annotation=Optional[str],
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
for b in route.body_args:
|
|
300
|
+
help_text = b.help or f"Body field '{b.name}'."
|
|
301
|
+
if b.json:
|
|
302
|
+
help_text = f"{help_text} (JSON-encoded value)"
|
|
303
|
+
params.append(
|
|
304
|
+
inspect.Parameter(
|
|
305
|
+
b.name,
|
|
306
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
307
|
+
default=typer.Option(
|
|
308
|
+
b.default,
|
|
309
|
+
f"--{b.name.replace('_', '-')}",
|
|
310
|
+
help=help_text,
|
|
311
|
+
),
|
|
312
|
+
annotation=Optional[str],
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
params.append(
|
|
317
|
+
inspect.Parameter(
|
|
318
|
+
"stage",
|
|
319
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
320
|
+
default=typer.Option(
|
|
321
|
+
None if route.is_mutation else "prod",
|
|
322
|
+
"--stage",
|
|
323
|
+
"-s",
|
|
324
|
+
help=(
|
|
325
|
+
f"API stage: {' | '.join(available_stages())}. "
|
|
326
|
+
"Required for mutations when more than one stage exists; "
|
|
327
|
+
"reads default to prod."
|
|
328
|
+
),
|
|
329
|
+
),
|
|
330
|
+
annotation=Optional[str],
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
params.append(
|
|
334
|
+
inspect.Parameter(
|
|
335
|
+
"json_out",
|
|
336
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
337
|
+
default=typer.Option(
|
|
338
|
+
False, "--json", help="Print raw JSON instead of a table."
|
|
339
|
+
),
|
|
340
|
+
annotation=bool,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def callback(**kwargs: Any) -> None:
|
|
345
|
+
_execute_route(route, dict(kwargs))
|
|
346
|
+
|
|
347
|
+
callback.__signature__ = inspect.Signature(parameters=params) # type: ignore[attr-defined]
|
|
348
|
+
callback.__name__ = route.cli.command.replace("-", "_") or route.id
|
|
349
|
+
callback.__doc__ = (
|
|
350
|
+
route.mcp.description.strip()
|
|
351
|
+
or f"{route.method} {route.path} (route id: {route.id})"
|
|
352
|
+
)
|
|
353
|
+
return callback
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Tree assembly
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def register_routes(
|
|
362
|
+
root: typer.Typer, routes: list[Route]
|
|
363
|
+
) -> dict[tuple[str, ...], typer.Typer]:
|
|
364
|
+
"""Attach every manifest route as a command under ``root``.
|
|
365
|
+
|
|
366
|
+
Groups are materialized lazily: the first route with ``cli.group == ("site",
|
|
367
|
+
"pricing")`` creates ``publinker site pricing`` as a Typer sub-app; later
|
|
368
|
+
routes in the same group reuse it. Top-level routes (``cli.group == ()``)
|
|
369
|
+
attach directly to ``root``.
|
|
370
|
+
|
|
371
|
+
Returns the group registry so the caller can hang hand-written commands
|
|
372
|
+
off existing groups (e.g. ``site report list-options``).
|
|
373
|
+
"""
|
|
374
|
+
group_registry: dict[tuple[str, ...], typer.Typer] = {(): root}
|
|
375
|
+
|
|
376
|
+
def _ensure_group(group: tuple[str, ...]) -> typer.Typer:
|
|
377
|
+
if group in group_registry:
|
|
378
|
+
return group_registry[group]
|
|
379
|
+
parent = _ensure_group(group[:-1])
|
|
380
|
+
sub = typer.Typer(
|
|
381
|
+
no_args_is_help=True,
|
|
382
|
+
help=f"`publinker {' '.join(group)}` commands.",
|
|
383
|
+
)
|
|
384
|
+
parent.add_typer(sub, name=group[-1])
|
|
385
|
+
group_registry[group] = sub
|
|
386
|
+
return sub
|
|
387
|
+
|
|
388
|
+
for route in routes:
|
|
389
|
+
target = _ensure_group(route.cli.group)
|
|
390
|
+
callback = _make_callback(route)
|
|
391
|
+
help_text = route.mcp.description or f"{route.method} {route.path}"
|
|
392
|
+
target.command(route.cli.command, help=help_text)(callback)
|
|
393
|
+
|
|
394
|
+
return group_registry
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Cross-platform OS-vault token storage via `keyring`.
|
|
2
|
+
|
|
3
|
+
macOS → Keychain
|
|
4
|
+
Linux → secret-service (GNOME Keyring, KWallet)
|
|
5
|
+
Windows → Credential Manager
|
|
6
|
+
|
|
7
|
+
No usable vault (slim Linux, Docker, SSH without D-Bus) → a plain JSON file
|
|
8
|
+
next to state.json, mode 0600, the same trade-off as ~/.aws/credentials.
|
|
9
|
+
|
|
10
|
+
The whole token bundle (access + refresh + id + expires_at) is stored as a single
|
|
11
|
+
JSON blob under one keyring item. One read/write per operation, simpler than
|
|
12
|
+
splitting across three items.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import keyring
|
|
26
|
+
|
|
27
|
+
from publinker_cli import state
|
|
28
|
+
from publinker_cli.config import KEYRING_ACCOUNT, KEYRING_SERVICE
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class TokenBundle:
|
|
33
|
+
access_token: str
|
|
34
|
+
refresh_token: str
|
|
35
|
+
id_token: str
|
|
36
|
+
expires_at: int # unix epoch seconds
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_token_response(
|
|
40
|
+
cls, body: dict, now: Optional[int] = None
|
|
41
|
+
) -> "TokenBundle":
|
|
42
|
+
"""Build a bundle from Auth0's `/oauth/token` response."""
|
|
43
|
+
now = now if now is not None else int(time.time())
|
|
44
|
+
return cls(
|
|
45
|
+
access_token=body["access_token"],
|
|
46
|
+
refresh_token=body.get("refresh_token", ""),
|
|
47
|
+
id_token=body.get("id_token", ""),
|
|
48
|
+
expires_at=now + int(body.get("expires_in", 0)),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def is_expired(self, now: Optional[int] = None, leeway: int = 60) -> bool:
|
|
52
|
+
"""True if the access token has expired (with `leeway` seconds buffer)."""
|
|
53
|
+
now = now if now is not None else int(time.time())
|
|
54
|
+
return now + leeway >= self.expires_at
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _token_file() -> Path:
|
|
58
|
+
return state._state_path().parent / f"{KEYRING_SERVICE}-token.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _warn_file_fallback() -> None:
|
|
62
|
+
print(
|
|
63
|
+
f"publinker: no OS keyring available; token kept in {_token_file()} (0600)",
|
|
64
|
+
file=sys.stderr,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save(bundle: TokenBundle) -> None:
|
|
69
|
+
raw = json.dumps(asdict(bundle))
|
|
70
|
+
try:
|
|
71
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_ACCOUNT, raw)
|
|
72
|
+
return
|
|
73
|
+
except Exception: # KeyringError, or a backend error such as pywintypes.error
|
|
74
|
+
_warn_file_fallback()
|
|
75
|
+
path = _token_file()
|
|
76
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
# Created 0600 rather than chmod-ed after, so it is never world-readable.
|
|
78
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
79
|
+
if hasattr(os, "fchmod"): # absent on Windows before Python 3.13
|
|
80
|
+
os.fchmod(fd, 0o600) # the mode above only applies when the file is new
|
|
81
|
+
with os.fdopen(fd, "w") as f:
|
|
82
|
+
f.write(raw)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load() -> Optional[TokenBundle]:
|
|
86
|
+
try:
|
|
87
|
+
raw = keyring.get_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
|
|
88
|
+
except Exception: # see save()
|
|
89
|
+
raw = None
|
|
90
|
+
if not raw:
|
|
91
|
+
try:
|
|
92
|
+
raw = _token_file().read_text()
|
|
93
|
+
except OSError:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(raw)
|
|
97
|
+
return TokenBundle(**data)
|
|
98
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
99
|
+
# Corrupted entry — treat as logged out. The next login will overwrite it.
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def clear() -> None:
|
|
104
|
+
try:
|
|
105
|
+
keyring.delete_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
|
|
106
|
+
except Exception:
|
|
107
|
+
# Already absent (PasswordDeleteError) or no vault — the desired state.
|
|
108
|
+
pass
|
|
109
|
+
_token_file().unlink(missing_ok=True)
|