geopera-cli 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.
- geopera_cli/__init__.py +8 -0
- geopera_cli/__main__.py +398 -0
- geopera_cli/auth.py +269 -0
- geopera_cli/client.py +236 -0
- geopera_cli/config.py +62 -0
- geopera_cli-0.1.0.dist-info/METADATA +170 -0
- geopera_cli-0.1.0.dist-info/RECORD +10 -0
- geopera_cli-0.1.0.dist-info/WHEEL +4 -0
- geopera_cli-0.1.0.dist-info/entry_points.txt +2 -0
- geopera_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
geopera_cli/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""geopera-cli — command-line interface for the Geopera platform.
|
|
2
|
+
|
|
3
|
+
A thin auth + dispatch shell over the published `geopera` SDK. Every capability
|
|
4
|
+
is reached through /v1/op/{operation_id}; the only CLI-resident logic is auth
|
|
5
|
+
(device flow, token refresh, and the Bearer / X-API-Key choice).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
geopera_cli/__main__.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""geopera — command-line interface for the Geopera geospatial data platform.
|
|
2
|
+
|
|
3
|
+
The CLI is a thin auth + dispatch shell over the published `geopera` SDK. The
|
|
4
|
+
only CLI-resident logic is auth: the device flow, token refresh, and the
|
|
5
|
+
Bearer / X-API-Key choice. Every capability is reached through
|
|
6
|
+
/v1/op/{operation_id}, so a new backend operation is instantly usable as
|
|
7
|
+
`geopera op <new.op>` with zero CLI changes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import itertools
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import webbrowser
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from . import auth, client, config
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
name="geopera",
|
|
30
|
+
help="Command-line interface for the Geopera geospatial data platform.",
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
add_completion=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
err = typer.echo
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Shared options
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
ProfileOpt = typer.Option(
|
|
43
|
+
None,
|
|
44
|
+
"--profile",
|
|
45
|
+
help="Stored identity to use (env: GEOPERA_PROFILE). Default: 'default'.",
|
|
46
|
+
)
|
|
47
|
+
ApiUrlOpt = typer.Option(
|
|
48
|
+
None,
|
|
49
|
+
"--api-url",
|
|
50
|
+
help=f"API base URL override (env: GEOPERA_API_URL). Default: {config.DEFAULT_API_URL}.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fail(message: str, code: int = 1) -> None:
|
|
55
|
+
"""Print an error to stderr and exit non-zero."""
|
|
56
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
|
|
57
|
+
raise typer.Exit(code)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _print_json(value) -> None:
|
|
61
|
+
typer.echo(json.dumps(value, indent=2, sort_keys=False, default=str))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
65
|
+
"""Generate a PKCE (verifier, S256 challenge) pair."""
|
|
66
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
67
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
68
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
69
|
+
return verifier, challenge
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _Spinner:
|
|
73
|
+
"""A tiny stderr spinner used while polling for device authorization."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, message: str):
|
|
76
|
+
self.message = message
|
|
77
|
+
self._stop = threading.Event()
|
|
78
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
79
|
+
|
|
80
|
+
def _run(self) -> None:
|
|
81
|
+
for frame in itertools.cycle("|/-\\"):
|
|
82
|
+
if self._stop.is_set():
|
|
83
|
+
break
|
|
84
|
+
sys.stderr.write(f"\r{self.message} {frame}")
|
|
85
|
+
sys.stderr.flush()
|
|
86
|
+
time.sleep(0.1)
|
|
87
|
+
|
|
88
|
+
def __enter__(self) -> "_Spinner":
|
|
89
|
+
if sys.stderr.isatty():
|
|
90
|
+
self._thread.start()
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, *args) -> None:
|
|
94
|
+
self._stop.set()
|
|
95
|
+
if self._thread.is_alive():
|
|
96
|
+
self._thread.join(timeout=0.3)
|
|
97
|
+
if sys.stderr.isatty():
|
|
98
|
+
sys.stderr.write("\r" + " " * (len(self.message) + 4) + "\r")
|
|
99
|
+
sys.stderr.flush()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# login
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def login(
|
|
108
|
+
api_key: Optional[str] = typer.Option(
|
|
109
|
+
None,
|
|
110
|
+
"--api-key",
|
|
111
|
+
help="Skip the device flow and store an API key. Use '-' to read from stdin.",
|
|
112
|
+
),
|
|
113
|
+
api_url: Optional[str] = ApiUrlOpt,
|
|
114
|
+
no_browser: bool = typer.Option(
|
|
115
|
+
False, "--no-browser", help="Do not auto-open the verification URL."
|
|
116
|
+
),
|
|
117
|
+
scope: str = typer.Option(
|
|
118
|
+
config.DEFAULT_SCOPE, "--scope", help="OAuth scope to request."
|
|
119
|
+
),
|
|
120
|
+
profile: Optional[str] = ProfileOpt,
|
|
121
|
+
):
|
|
122
|
+
"""Authenticate. Device flow by default; --api-key for headless use."""
|
|
123
|
+
profile = config.resolve_profile(profile)
|
|
124
|
+
stored = auth.load_profile(profile)
|
|
125
|
+
resolved_url = config.resolve_api_url(api_url, stored.get("api_url"))
|
|
126
|
+
|
|
127
|
+
if api_key is not None:
|
|
128
|
+
_login_api_key(profile, resolved_url, api_key)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
_login_device(profile, resolved_url, scope, no_browser)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _login_api_key(profile: str, api_url: str, api_key: str) -> None:
|
|
135
|
+
"""Validate and store an API key (kept out of shell history when piped)."""
|
|
136
|
+
if api_key == "-":
|
|
137
|
+
api_key = sys.stdin.readline().strip()
|
|
138
|
+
if not api_key:
|
|
139
|
+
_fail("No API key provided.")
|
|
140
|
+
|
|
141
|
+
if not api_key.startswith("gpra_"):
|
|
142
|
+
typer.secho(
|
|
143
|
+
"Note: this key has no 'gpra_' prefix. It will still work, but new "
|
|
144
|
+
"Geopera keys are expected to start with 'gpra_'.",
|
|
145
|
+
fg=typer.colors.YELLOW,
|
|
146
|
+
err=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
ctx = auth.AuthContext(profile, api_url, {"type": "api_key", "api_key": api_key})
|
|
150
|
+
try:
|
|
151
|
+
info = client.userinfo(ctx)
|
|
152
|
+
except client.OpError as exc:
|
|
153
|
+
_fail(f"API key validation failed: {exc.message}")
|
|
154
|
+
|
|
155
|
+
auth.save_profile(
|
|
156
|
+
profile,
|
|
157
|
+
{"api_url": api_url, "auth": {"type": "api_key", "api_key": api_key}},
|
|
158
|
+
)
|
|
159
|
+
who = info.get("geopera_principal_id") or info.get("sub") or "api key"
|
|
160
|
+
typer.secho(f"Logged in as {who} (API key, profile '{profile}').", fg=typer.colors.GREEN)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _login_device(profile: str, api_url: str, scope: str, no_browser: bool) -> None:
|
|
164
|
+
"""RFC 8628 device authorization grant with PKCE."""
|
|
165
|
+
verifier, challenge = _pkce_pair()
|
|
166
|
+
try:
|
|
167
|
+
device = client.device_authorize(api_url, scope, challenge)
|
|
168
|
+
except client.OpError as exc:
|
|
169
|
+
_fail(exc.message)
|
|
170
|
+
|
|
171
|
+
user_code = device.get("user_code", "")
|
|
172
|
+
verification_uri = device.get("verification_uri", "")
|
|
173
|
+
complete = device.get("verification_uri_complete") or verification_uri
|
|
174
|
+
|
|
175
|
+
typer.echo()
|
|
176
|
+
typer.secho(" To sign in, visit:", bold=True)
|
|
177
|
+
typer.secho(f" {complete}", fg=typer.colors.CYAN)
|
|
178
|
+
typer.echo()
|
|
179
|
+
typer.secho(" And confirm this code:", bold=True)
|
|
180
|
+
typer.secho(f" {user_code}", fg=typer.colors.GREEN, bold=True)
|
|
181
|
+
typer.echo()
|
|
182
|
+
|
|
183
|
+
if not no_browser and complete:
|
|
184
|
+
try:
|
|
185
|
+
webbrowser.open(complete)
|
|
186
|
+
except Exception: # noqa: BLE001 - browser launch is best-effort
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
with _Spinner("Waiting for authorization..."):
|
|
190
|
+
try:
|
|
191
|
+
token = client.wait_for_device_authorization(api_url, device, verifier)
|
|
192
|
+
except client.OpError as exc:
|
|
193
|
+
_fail(exc.message)
|
|
194
|
+
|
|
195
|
+
auth_entry = auth._auth_from_token_response(api_url, token)
|
|
196
|
+
auth.save_profile(profile, {"api_url": api_url, "auth": auth_entry})
|
|
197
|
+
|
|
198
|
+
# Confirm with a userinfo round-trip.
|
|
199
|
+
ctx = auth.AuthContext(profile, api_url, auth_entry)
|
|
200
|
+
try:
|
|
201
|
+
info = client.userinfo(ctx)
|
|
202
|
+
who = info.get("sub") or "user"
|
|
203
|
+
except client.OpError:
|
|
204
|
+
who = "user"
|
|
205
|
+
typer.secho(f"Logged in as {who} (profile '{profile}').", fg=typer.colors.GREEN)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# logout
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
@app.command()
|
|
213
|
+
def logout(profile: Optional[str] = ProfileOpt):
|
|
214
|
+
"""Clear the active profile's stored credentials."""
|
|
215
|
+
profile = config.resolve_profile(profile)
|
|
216
|
+
entry = auth.load_profile(profile)
|
|
217
|
+
auth_entry = entry.get("auth", {})
|
|
218
|
+
|
|
219
|
+
if auth_entry.get("type") == "oauth":
|
|
220
|
+
client.logout_oauth(entry.get("api_url", config.DEFAULT_API_URL), auth_entry)
|
|
221
|
+
|
|
222
|
+
if auth.clear_profile(profile):
|
|
223
|
+
typer.secho(f"Logged out (profile '{profile}').", fg=typer.colors.GREEN)
|
|
224
|
+
else:
|
|
225
|
+
typer.echo(f"No stored credentials for profile '{profile}'.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# whoami
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
@app.command()
|
|
233
|
+
def whoami(
|
|
234
|
+
api_url: Optional[str] = ApiUrlOpt,
|
|
235
|
+
profile: Optional[str] = ProfileOpt,
|
|
236
|
+
raw: bool = typer.Option(False, "--json", help="Print the raw userinfo JSON."),
|
|
237
|
+
):
|
|
238
|
+
"""Show the authenticated principal, org, and scopes."""
|
|
239
|
+
try:
|
|
240
|
+
ctx = auth.load_context(profile, api_url)
|
|
241
|
+
info = client.userinfo(ctx)
|
|
242
|
+
except (auth.AuthError, client.OpError) as exc:
|
|
243
|
+
_fail(str(getattr(exc, "message", exc)))
|
|
244
|
+
|
|
245
|
+
if raw:
|
|
246
|
+
_print_json(info)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
typer.secho(f"sub: {info.get('sub', '-')}", fg=typer.colors.GREEN)
|
|
250
|
+
typer.echo(f"principal_type: {info.get('geopera_principal_type', '-')}")
|
|
251
|
+
typer.echo(f"org_id: {info.get('geopera_org_id', '-')}")
|
|
252
|
+
typer.echo(f"scope: {info.get('scope', '-')}")
|
|
253
|
+
typer.echo(f"api_url: {ctx.api_url}")
|
|
254
|
+
typer.echo(f"profile: {ctx.profile}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# op — generic operation dispatch
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
@app.command()
|
|
262
|
+
def op(
|
|
263
|
+
operation_id: Optional[str] = typer.Argument(
|
|
264
|
+
None, help="Operation id, e.g. orders.estimate or catalog.federated_search."
|
|
265
|
+
),
|
|
266
|
+
body: Optional[str] = typer.Argument(
|
|
267
|
+
None, help="JSON request body. Use '-' to read from stdin."
|
|
268
|
+
),
|
|
269
|
+
file: Optional[str] = typer.Option(
|
|
270
|
+
None, "--file", "-f", help="Read the JSON body from a file."
|
|
271
|
+
),
|
|
272
|
+
list_ops: bool = typer.Option(
|
|
273
|
+
False, "--list", help="List available operation ids and exit."
|
|
274
|
+
),
|
|
275
|
+
api_url: Optional[str] = ApiUrlOpt,
|
|
276
|
+
profile: Optional[str] = ProfileOpt,
|
|
277
|
+
):
|
|
278
|
+
"""Invoke any operation: POST /v1/op/OPERATION_ID with a JSON body."""
|
|
279
|
+
if list_ops:
|
|
280
|
+
_list_operations(api_url, profile)
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
if not operation_id:
|
|
284
|
+
_fail("OPERATION_ID is required (or use --list).")
|
|
285
|
+
|
|
286
|
+
payload = _resolve_body(body, file)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
ctx = auth.load_context(profile, api_url)
|
|
290
|
+
result = client.invoke_op(ctx, operation_id, payload)
|
|
291
|
+
except auth.AuthError as exc:
|
|
292
|
+
_fail(str(exc))
|
|
293
|
+
except client.OpError as exc:
|
|
294
|
+
# problem+json -> readable message + non-zero exit.
|
|
295
|
+
detail = exc.detail.get("detail") or exc.detail.get("title")
|
|
296
|
+
msg = f"[{exc.status}] {exc.message}"
|
|
297
|
+
if detail and detail != exc.message:
|
|
298
|
+
msg += f" — {detail}"
|
|
299
|
+
_fail(msg, code=2)
|
|
300
|
+
|
|
301
|
+
_print_json(result)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _resolve_body(body: Optional[str], file: Optional[str]):
|
|
305
|
+
"""Resolve the JSON body from --file, stdin ('-'), positional arg, or {}."""
|
|
306
|
+
if file:
|
|
307
|
+
try:
|
|
308
|
+
with open(file, encoding="utf-8") as fh:
|
|
309
|
+
raw = fh.read()
|
|
310
|
+
except OSError as exc:
|
|
311
|
+
_fail(f"Cannot read --file: {exc}")
|
|
312
|
+
elif body == "-":
|
|
313
|
+
raw = sys.stdin.read()
|
|
314
|
+
elif body:
|
|
315
|
+
raw = body
|
|
316
|
+
else:
|
|
317
|
+
return {}
|
|
318
|
+
|
|
319
|
+
raw = raw.strip()
|
|
320
|
+
if not raw:
|
|
321
|
+
return {}
|
|
322
|
+
try:
|
|
323
|
+
return json.loads(raw)
|
|
324
|
+
except json.JSONDecodeError as exc:
|
|
325
|
+
_fail(f"Invalid JSON body: {exc}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _list_operations(api_url: Optional[str], profile: Optional[str]) -> None:
|
|
329
|
+
"""List operation ids from the live OpenAPI document."""
|
|
330
|
+
try:
|
|
331
|
+
ctx_url = config.resolve_api_url(
|
|
332
|
+
api_url, auth.load_profile(config.resolve_profile(profile)).get("api_url")
|
|
333
|
+
)
|
|
334
|
+
except Exception: # noqa: BLE001
|
|
335
|
+
ctx_url = config.resolve_api_url(api_url, None)
|
|
336
|
+
|
|
337
|
+
import httpx
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
resp = httpx.get(f"{ctx_url}/openapi.json", timeout=30.0)
|
|
341
|
+
resp.raise_for_status()
|
|
342
|
+
paths = resp.json().get("paths", {})
|
|
343
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
344
|
+
_fail(f"Could not fetch operation list: {exc}")
|
|
345
|
+
|
|
346
|
+
ops = sorted(
|
|
347
|
+
p[len("/v1/op/") :] for p in paths if p.startswith("/v1/op/")
|
|
348
|
+
)
|
|
349
|
+
for op_id in ops:
|
|
350
|
+
typer.echo(op_id)
|
|
351
|
+
typer.secho(f"\n{len(ops)} operations.", fg=typer.colors.BLUE, err=True)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# orders — curated subcommand (thin alias over `op`)
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
orders_app = typer.Typer(help="Curated order commands (thin aliases over `op`).")
|
|
359
|
+
app.add_typer(orders_app, name="orders")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@orders_app.command("list")
|
|
363
|
+
def orders_list(
|
|
364
|
+
api_url: Optional[str] = ApiUrlOpt,
|
|
365
|
+
profile: Optional[str] = ProfileOpt,
|
|
366
|
+
raw: bool = typer.Option(False, "--json", help="Print raw JSON instead of a table."),
|
|
367
|
+
):
|
|
368
|
+
"""List your orders (alias for `op orders.list`)."""
|
|
369
|
+
try:
|
|
370
|
+
ctx = auth.load_context(profile, api_url)
|
|
371
|
+
result = client.invoke_op(ctx, "orders.list", {})
|
|
372
|
+
except auth.AuthError as exc:
|
|
373
|
+
_fail(str(exc))
|
|
374
|
+
except client.OpError as exc:
|
|
375
|
+
_fail(f"[{exc.status}] {exc.message}", code=2)
|
|
376
|
+
|
|
377
|
+
rows = result.get("orders", result) if isinstance(result, dict) else result
|
|
378
|
+
if raw or not isinstance(rows, list):
|
|
379
|
+
_print_json(result)
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
if not rows:
|
|
383
|
+
typer.echo("No orders.")
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
for row in rows:
|
|
387
|
+
oid = row.get("id", "-")
|
|
388
|
+
name = row.get("display_name") or row.get("name") or "-"
|
|
389
|
+
status = row.get("status", "-")
|
|
390
|
+
typer.echo(f"{oid:<38} {status:<14} {name}")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def main() -> None: # console-script-friendly entry (pyproject uses `app`)
|
|
394
|
+
app()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
main()
|
geopera_cli/auth.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Credential store + auth context.
|
|
2
|
+
|
|
3
|
+
The store is a single JSON file at ~/.config/geopera/credentials.json. Multiple
|
|
4
|
+
identities are namespaced as top-level keys by *profile* name, e.g.::
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"default": {
|
|
8
|
+
"api_url": "https://api.geopera.com",
|
|
9
|
+
"auth": {"type": "oauth", "access_token": "...", "refresh_token": "...",
|
|
10
|
+
"expires_at": 1234567890, "scope": "openid profile",
|
|
11
|
+
"issuer": "https://api.geopera.com"}
|
|
12
|
+
},
|
|
13
|
+
"staging": {
|
|
14
|
+
"api_url": "https://staging.api.geopera.com",
|
|
15
|
+
"auth": {"type": "api_key", "api_key": "gpra_..."}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
load_client() turns the active profile into a geopera.AuthenticatedClient,
|
|
20
|
+
transparently refreshing an OAuth access token that is at/near expiry.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
from . import config
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# The published SDK. The CLI is a thin shell over its AuthenticatedClient.
|
|
36
|
+
from geopera import AuthenticatedClient
|
|
37
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"The 'geopera' SDK is required. Install it with: pip install geopera"
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Store read / write (mode-hardened)
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def _ensure_dir() -> None:
|
|
48
|
+
"""Create ~/.config/geopera with mode 0700 (owner-only)."""
|
|
49
|
+
os.makedirs(config.CONFIG_DIR, mode=0o700, exist_ok=True)
|
|
50
|
+
# makedirs honours `mode` only for the leaf it creates and is subject to
|
|
51
|
+
# umask; chmod unconditionally to guarantee 0700.
|
|
52
|
+
try:
|
|
53
|
+
os.chmod(config.CONFIG_DIR, 0o700)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_store() -> dict[str, Any]:
|
|
59
|
+
"""Load the full credentials.json (all profiles). Empty dict if absent."""
|
|
60
|
+
if not config.CREDENTIALS_PATH.exists():
|
|
61
|
+
return {}
|
|
62
|
+
try:
|
|
63
|
+
with open(config.CREDENTIALS_PATH, encoding="utf-8") as fh:
|
|
64
|
+
data = json.load(fh)
|
|
65
|
+
return data if isinstance(data, dict) else {}
|
|
66
|
+
except (json.JSONDecodeError, OSError):
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_store(store: dict[str, Any]) -> None:
|
|
71
|
+
"""Atomically write the full store with file mode 0600."""
|
|
72
|
+
_ensure_dir()
|
|
73
|
+
tmp = config.CREDENTIALS_PATH.with_suffix(".json.tmp")
|
|
74
|
+
# Open with 0600 from the start so the secret is never briefly world-readable.
|
|
75
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
76
|
+
try:
|
|
77
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
78
|
+
json.dump(store, fh, indent=2, sort_keys=True)
|
|
79
|
+
fh.write("\n")
|
|
80
|
+
os.chmod(tmp, 0o600)
|
|
81
|
+
os.replace(tmp, config.CREDENTIALS_PATH)
|
|
82
|
+
os.chmod(config.CREDENTIALS_PATH, 0o600)
|
|
83
|
+
finally:
|
|
84
|
+
if os.path.exists(tmp):
|
|
85
|
+
os.unlink(tmp)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_profile(profile: str) -> dict[str, Any]:
|
|
89
|
+
"""Return the stored entry for a profile (api_url + auth), or {} if none."""
|
|
90
|
+
return _read_store().get(profile, {})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def save_profile(profile: str, entry: dict[str, Any]) -> None:
|
|
94
|
+
"""Persist a profile entry, leaving other profiles untouched."""
|
|
95
|
+
store = _read_store()
|
|
96
|
+
store[profile] = entry
|
|
97
|
+
_write_store(store)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def clear_profile(profile: str) -> bool:
|
|
101
|
+
"""Remove a profile's auth (keeps api_url). Returns True if anything changed."""
|
|
102
|
+
store = _read_store()
|
|
103
|
+
entry = store.get(profile)
|
|
104
|
+
if not entry or "auth" not in entry:
|
|
105
|
+
return False
|
|
106
|
+
entry.pop("auth", None)
|
|
107
|
+
store[profile] = entry
|
|
108
|
+
_write_store(store)
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Token refresh (OAuth)
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _needs_refresh(auth: dict[str, Any]) -> bool:
|
|
117
|
+
"""True if the OAuth access token is missing or within the leeway window."""
|
|
118
|
+
expires_at = auth.get("expires_at")
|
|
119
|
+
if not expires_at:
|
|
120
|
+
return False
|
|
121
|
+
return time.time() >= (float(expires_at) - config.REFRESH_LEEWAY_SECONDS)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def refresh_oauth(api_url: str, auth: dict[str, Any]) -> dict[str, Any]:
|
|
125
|
+
"""Exchange the stored refresh_token for a fresh access (and refresh) token.
|
|
126
|
+
|
|
127
|
+
The backend rotates the refresh token on every use, so BOTH tokens are
|
|
128
|
+
replaced. Returns the updated auth dict (caller persists it).
|
|
129
|
+
"""
|
|
130
|
+
refresh_token = auth.get("refresh_token")
|
|
131
|
+
if not refresh_token:
|
|
132
|
+
raise AuthError("Session expired and no refresh token is stored. Run 'geopera login'.")
|
|
133
|
+
|
|
134
|
+
resp = httpx.post(
|
|
135
|
+
config.realm_url(api_url, "token"),
|
|
136
|
+
data={
|
|
137
|
+
"grant_type": "refresh_token",
|
|
138
|
+
"refresh_token": refresh_token,
|
|
139
|
+
"client_id": config.CLIENT_ID,
|
|
140
|
+
},
|
|
141
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
142
|
+
timeout=30.0,
|
|
143
|
+
)
|
|
144
|
+
if resp.status_code != 200:
|
|
145
|
+
raise AuthError(
|
|
146
|
+
"Token refresh failed (your session may have been revoked). "
|
|
147
|
+
"Run 'geopera login' to sign in again."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
payload = resp.json()
|
|
151
|
+
return _auth_from_token_response(api_url, payload, fallback=auth)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _auth_from_token_response(
|
|
155
|
+
api_url: str, payload: dict[str, Any], fallback: dict[str, Any] | None = None
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
"""Build an oauth auth entry from a token endpoint response."""
|
|
158
|
+
fallback = fallback or {}
|
|
159
|
+
expires_in = payload.get("expires_in")
|
|
160
|
+
expires_at = time.time() + float(expires_in) if expires_in else None
|
|
161
|
+
return {
|
|
162
|
+
"type": "oauth",
|
|
163
|
+
"access_token": payload["access_token"],
|
|
164
|
+
# The backend rotates the refresh token; keep the old one only if the
|
|
165
|
+
# response omits a new one.
|
|
166
|
+
"refresh_token": payload.get("refresh_token", fallback.get("refresh_token")),
|
|
167
|
+
"expires_at": expires_at,
|
|
168
|
+
"scope": payload.get("scope", fallback.get("scope", config.DEFAULT_SCOPE)),
|
|
169
|
+
"issuer": api_url,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Building the SDK client
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
class AuthError(Exception):
|
|
178
|
+
"""Raised when no usable credentials are available."""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class AuthContext:
|
|
182
|
+
"""Resolved auth for the active profile + a ready-to-use SDK client."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, profile: str, api_url: str, auth: dict[str, Any]):
|
|
185
|
+
self.profile = profile
|
|
186
|
+
self.api_url = api_url
|
|
187
|
+
self.auth = auth
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def is_api_key(self) -> bool:
|
|
191
|
+
return self.auth.get("type") == "api_key"
|
|
192
|
+
|
|
193
|
+
def client(self) -> AuthenticatedClient:
|
|
194
|
+
"""Construct the SDK AuthenticatedClient for this auth context.
|
|
195
|
+
|
|
196
|
+
- api_key -> X-API-Key header, no prefix (API accepts X-API-Key)
|
|
197
|
+
- oauth -> Authorization: Bearer <access_token>
|
|
198
|
+
"""
|
|
199
|
+
if self.is_api_key:
|
|
200
|
+
return AuthenticatedClient(
|
|
201
|
+
base_url=self.api_url,
|
|
202
|
+
token=self.auth["api_key"],
|
|
203
|
+
prefix="",
|
|
204
|
+
auth_header_name="X-API-Key",
|
|
205
|
+
)
|
|
206
|
+
return AuthenticatedClient(
|
|
207
|
+
base_url=self.api_url,
|
|
208
|
+
token=self.auth["access_token"],
|
|
209
|
+
prefix="Bearer",
|
|
210
|
+
auth_header_name="Authorization",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def bearer_headers(self) -> dict[str, str]:
|
|
214
|
+
"""Raw auth header for direct httpx calls (userinfo, logout, op)."""
|
|
215
|
+
if self.is_api_key:
|
|
216
|
+
return {"X-API-Key": self.auth["api_key"]}
|
|
217
|
+
return {"Authorization": f"Bearer {self.auth['access_token']}"}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def load_context(
|
|
221
|
+
profile: str | None = None, api_url_flag: str | None = None
|
|
222
|
+
) -> AuthContext:
|
|
223
|
+
"""Load the active profile, refreshing the OAuth token if near expiry.
|
|
224
|
+
|
|
225
|
+
Honours the GEOPERA_API_TOKEN env override (opaque bearer/api-key) so an
|
|
226
|
+
ephemeral CI token can be used without writing to the store.
|
|
227
|
+
"""
|
|
228
|
+
profile = config.resolve_profile(profile)
|
|
229
|
+
entry = load_profile(profile)
|
|
230
|
+
|
|
231
|
+
# Env-token escape hatch: use an opaque token straight from the environment.
|
|
232
|
+
env_token = os.environ.get(config.ENV_API_TOKEN)
|
|
233
|
+
if env_token:
|
|
234
|
+
api_url = config.resolve_api_url(api_url_flag, entry.get("api_url"))
|
|
235
|
+
# A 'gpra_' value is an API key; anything else is treated as a bearer.
|
|
236
|
+
if env_token.startswith("gpra_"):
|
|
237
|
+
auth = {"type": "api_key", "api_key": env_token}
|
|
238
|
+
else:
|
|
239
|
+
auth = {"type": "oauth", "access_token": env_token, "expires_at": None}
|
|
240
|
+
return AuthContext(profile, api_url, auth)
|
|
241
|
+
|
|
242
|
+
auth = entry.get("auth")
|
|
243
|
+
if not auth:
|
|
244
|
+
raise AuthError(
|
|
245
|
+
f"Not logged in (profile '{profile}'). Run 'geopera login' first."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
api_url = config.resolve_api_url(api_url_flag, entry.get("api_url"))
|
|
249
|
+
|
|
250
|
+
# Proactive refresh for OAuth tokens near expiry.
|
|
251
|
+
if auth.get("type") == "oauth" and _needs_refresh(auth):
|
|
252
|
+
auth = refresh_oauth(api_url, auth)
|
|
253
|
+
entry["auth"] = auth
|
|
254
|
+
entry.setdefault("api_url", api_url)
|
|
255
|
+
save_profile(profile, entry)
|
|
256
|
+
|
|
257
|
+
return AuthContext(profile, api_url, auth)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def force_refresh(ctx: AuthContext) -> AuthContext:
|
|
261
|
+
"""Refresh after a 401 and persist. Raises AuthError if not refreshable."""
|
|
262
|
+
if ctx.is_api_key:
|
|
263
|
+
raise AuthError("API key rejected (401). Check the key or create a new one.")
|
|
264
|
+
new_auth = refresh_oauth(ctx.api_url, ctx.auth)
|
|
265
|
+
entry = load_profile(ctx.profile)
|
|
266
|
+
entry["auth"] = new_auth
|
|
267
|
+
entry.setdefault("api_url", ctx.api_url)
|
|
268
|
+
save_profile(ctx.profile, entry)
|
|
269
|
+
return AuthContext(ctx.profile, ctx.api_url, new_auth)
|
geopera_cli/client.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Wire-level calls layered over the SDK's AuthenticatedClient.
|
|
2
|
+
|
|
3
|
+
Two kinds of call live here:
|
|
4
|
+
|
|
5
|
+
* Operation dispatch (`invoke_op`) — POST /v1/op/{operation_id}. This goes
|
|
6
|
+
through the SDK's AuthenticatedClient.get_httpx_client(), which already
|
|
7
|
+
attaches the right auth header (Bearer or X-API-Key). A single helper
|
|
8
|
+
therefore reaches every one of the ~227 operations with zero
|
|
9
|
+
per-op code, honouring the backend-first principle: the CLI sends params,
|
|
10
|
+
the backend produces output.
|
|
11
|
+
|
|
12
|
+
* Identity / OAuth wire (`userinfo`, `device_authorize`, `poll_token`,
|
|
13
|
+
`logout`) — these are the only endpoints outside the generated op surface,
|
|
14
|
+
so they are issued with plain httpx against the realm path.
|
|
15
|
+
|
|
16
|
+
invoke_op transparently refreshes an expired OAuth token once on a 401 and
|
|
17
|
+
retries, so refresh is automatic for every command.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from . import auth, config
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OpError(Exception):
|
|
31
|
+
"""An operation returned a problem+json error or other non-2xx response."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, status: int, message: str, detail: dict[str, Any] | None = None):
|
|
34
|
+
self.status = status
|
|
35
|
+
self.message = message
|
|
36
|
+
self.detail = detail or {}
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Operation dispatch
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def invoke_op(ctx: auth.AuthContext, operation_id: str, body: Any) -> Any:
|
|
45
|
+
"""POST /v1/op/{operation_id} with `body`, returning the parsed JSON.
|
|
46
|
+
|
|
47
|
+
Uses the SDK's authenticated httpx client. On a 401 with OAuth creds, the
|
|
48
|
+
token is refreshed once and the call retried.
|
|
49
|
+
"""
|
|
50
|
+
sdk = ctx.client()
|
|
51
|
+
url = f"/v1/op/{operation_id}"
|
|
52
|
+
|
|
53
|
+
response = sdk.get_httpx_client().request(
|
|
54
|
+
"post", url, json=body, headers={"Content-Type": "application/json"}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if response.status_code == 401 and not ctx.is_api_key:
|
|
58
|
+
# Token might have expired between proactive check and the call.
|
|
59
|
+
ctx = auth.force_refresh(ctx)
|
|
60
|
+
sdk = ctx.client()
|
|
61
|
+
response = sdk.get_httpx_client().request(
|
|
62
|
+
"post", url, json=body, headers={"Content-Type": "application/json"}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return _parse(response, operation_id)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse(response: httpx.Response, operation_id: str) -> Any:
|
|
69
|
+
"""Return parsed JSON on 2xx; raise OpError (problem+json aware) otherwise."""
|
|
70
|
+
if 200 <= response.status_code < 300:
|
|
71
|
+
if not response.content:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return response.json()
|
|
75
|
+
except ValueError:
|
|
76
|
+
return response.text
|
|
77
|
+
|
|
78
|
+
# Error path — surface problem+json detail/title when present.
|
|
79
|
+
detail: dict[str, Any] = {}
|
|
80
|
+
try:
|
|
81
|
+
detail = response.json()
|
|
82
|
+
except ValueError:
|
|
83
|
+
detail = {"raw": response.text}
|
|
84
|
+
|
|
85
|
+
message = (
|
|
86
|
+
detail.get("detail")
|
|
87
|
+
or detail.get("title")
|
|
88
|
+
or detail.get("message")
|
|
89
|
+
or detail.get("error_description")
|
|
90
|
+
or detail.get("error")
|
|
91
|
+
or f"{operation_id} failed with HTTP {response.status_code}"
|
|
92
|
+
)
|
|
93
|
+
raise OpError(response.status_code, str(message), detail)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Identity / OAuth wire (outside the op surface)
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def userinfo(ctx: auth.AuthContext) -> dict[str, Any]:
|
|
101
|
+
"""GET userinfo with the loaded credentials. Refreshes once on a 401."""
|
|
102
|
+
url = config.realm_url(ctx.api_url, "userinfo")
|
|
103
|
+
resp = httpx.get(url, headers=ctx.bearer_headers(), timeout=30.0)
|
|
104
|
+
if resp.status_code == 401 and not ctx.is_api_key:
|
|
105
|
+
ctx = auth.force_refresh(ctx)
|
|
106
|
+
resp = httpx.get(url, headers=ctx.bearer_headers(), timeout=30.0)
|
|
107
|
+
if resp.status_code != 200:
|
|
108
|
+
raise OpError(resp.status_code, "userinfo failed — credentials may be invalid")
|
|
109
|
+
return resp.json()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def device_authorize(
|
|
113
|
+
api_url: str, scope: str, pkce_challenge: str | None = None
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
"""Start the RFC 8628 device flow. Returns the device authorization response."""
|
|
116
|
+
data: dict[str, str] = {"client_id": config.CLIENT_ID, "scope": scope}
|
|
117
|
+
if pkce_challenge:
|
|
118
|
+
data["code_challenge"] = pkce_challenge
|
|
119
|
+
data["code_challenge_method"] = "S256"
|
|
120
|
+
|
|
121
|
+
resp = httpx.post(
|
|
122
|
+
config.realm_url(api_url, "auth/device"),
|
|
123
|
+
data=data,
|
|
124
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
125
|
+
timeout=30.0,
|
|
126
|
+
)
|
|
127
|
+
if resp.status_code != 200:
|
|
128
|
+
raise OpError(
|
|
129
|
+
resp.status_code,
|
|
130
|
+
"Device authorization failed. The server may not support the device "
|
|
131
|
+
"flow yet — try 'geopera login --api-key <key>' instead.",
|
|
132
|
+
)
|
|
133
|
+
return resp.json()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Sentinel returns for poll_token so the caller's loop can branch cleanly.
|
|
137
|
+
PENDING = "authorization_pending"
|
|
138
|
+
SLOW_DOWN = "slow_down"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def poll_token(
|
|
142
|
+
api_url: str,
|
|
143
|
+
device_code: str,
|
|
144
|
+
pkce_verifier: str | None = None,
|
|
145
|
+
) -> dict[str, Any] | str:
|
|
146
|
+
"""Poll the token endpoint once.
|
|
147
|
+
|
|
148
|
+
Returns the token payload on success, or one of the sentinel strings
|
|
149
|
+
PENDING / SLOW_DOWN to keep polling. Raises OpError on a terminal error
|
|
150
|
+
(access_denied, expired_token, ...).
|
|
151
|
+
"""
|
|
152
|
+
data: dict[str, str] = {
|
|
153
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
154
|
+
"device_code": device_code,
|
|
155
|
+
"client_id": config.CLIENT_ID,
|
|
156
|
+
}
|
|
157
|
+
if pkce_verifier:
|
|
158
|
+
data["code_verifier"] = pkce_verifier
|
|
159
|
+
|
|
160
|
+
resp = httpx.post(
|
|
161
|
+
config.realm_url(api_url, "token"),
|
|
162
|
+
data=data,
|
|
163
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
164
|
+
timeout=30.0,
|
|
165
|
+
)
|
|
166
|
+
if resp.status_code == 200:
|
|
167
|
+
return resp.json()
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
payload = resp.json()
|
|
171
|
+
except ValueError:
|
|
172
|
+
payload = {}
|
|
173
|
+
err = payload.get("error", "")
|
|
174
|
+
|
|
175
|
+
if err == "authorization_pending":
|
|
176
|
+
return PENDING
|
|
177
|
+
if err == "slow_down":
|
|
178
|
+
return SLOW_DOWN
|
|
179
|
+
if err in ("access_denied", "expired_token"):
|
|
180
|
+
raise OpError(
|
|
181
|
+
resp.status_code,
|
|
182
|
+
{
|
|
183
|
+
"access_denied": "Login was denied in the browser.",
|
|
184
|
+
"expired_token": "The login request expired. Run 'geopera login' again.",
|
|
185
|
+
}[err],
|
|
186
|
+
)
|
|
187
|
+
raise OpError(
|
|
188
|
+
resp.status_code,
|
|
189
|
+
payload.get("error_description") or err or "Device login failed.",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def logout_oauth(api_url: str, auth_entry: dict[str, Any]) -> None:
|
|
194
|
+
"""Best-effort RP-initiated logout for an OAuth session (ignore failures)."""
|
|
195
|
+
refresh_token = auth_entry.get("refresh_token")
|
|
196
|
+
if not refresh_token:
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
httpx.post(
|
|
200
|
+
config.realm_url(api_url, "logout"),
|
|
201
|
+
data={"client_id": config.CLIENT_ID, "refresh_token": refresh_token},
|
|
202
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
203
|
+
timeout=10.0,
|
|
204
|
+
)
|
|
205
|
+
except httpx.HTTPError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def wait_for_device_authorization(
|
|
210
|
+
api_url: str,
|
|
211
|
+
device: dict[str, Any],
|
|
212
|
+
pkce_verifier: str | None,
|
|
213
|
+
on_tick=None,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
"""Poll until the user authorizes, the request expires, or it is denied.
|
|
216
|
+
|
|
217
|
+
`on_tick` (if given) is called once per poll so the caller can render a
|
|
218
|
+
spinner. Honours `interval`, `slow_down`, and `expires_in` as the deadline.
|
|
219
|
+
"""
|
|
220
|
+
interval = int(device.get("interval", 5))
|
|
221
|
+
deadline = time.time() + int(device.get("expires_in", 600))
|
|
222
|
+
device_code = device["device_code"]
|
|
223
|
+
|
|
224
|
+
while time.time() < deadline:
|
|
225
|
+
if on_tick:
|
|
226
|
+
on_tick()
|
|
227
|
+
time.sleep(interval)
|
|
228
|
+
result = poll_token(api_url, device_code, pkce_verifier)
|
|
229
|
+
if result == PENDING:
|
|
230
|
+
continue
|
|
231
|
+
if result == SLOW_DOWN:
|
|
232
|
+
interval += 5
|
|
233
|
+
continue
|
|
234
|
+
return result # token payload
|
|
235
|
+
|
|
236
|
+
raise OpError(408, "Login timed out before authorization completed.")
|
geopera_cli/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Configuration: defaults, env vars, and the credential store location.
|
|
2
|
+
|
|
3
|
+
Resolution precedence for the API base URL (highest first):
|
|
4
|
+
1. an explicit --api-url flag (passed into resolve_api_url)
|
|
5
|
+
2. the GEOPERA_API_URL environment variable
|
|
6
|
+
3. the api_url stored in the active profile of credentials.json
|
|
7
|
+
4. the built-in default (https://api.geopera.com)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Built-in default — the production Geopera API.
|
|
16
|
+
DEFAULT_API_URL = "https://api.geopera.com"
|
|
17
|
+
|
|
18
|
+
# OAuth realm path prefix. The backend mounts the Keycloak-shaped endpoints
|
|
19
|
+
# at /realms/public/protocol/openid-connect/* (see auth_keycloak.py).
|
|
20
|
+
REALM_PATH = "/realms/public/protocol/openid-connect"
|
|
21
|
+
|
|
22
|
+
# Public client id the CLI identifies as during the device flow. There is no
|
|
23
|
+
# client secret — this is a public (native) client per RFC 8628.
|
|
24
|
+
CLIENT_ID = "geopera-cli"
|
|
25
|
+
|
|
26
|
+
# Default OAuth scope requested at login.
|
|
27
|
+
DEFAULT_SCOPE = "openid profile"
|
|
28
|
+
|
|
29
|
+
# Refresh the access token this many seconds before it actually expires so a
|
|
30
|
+
# command never fires a request with a token that dies mid-flight.
|
|
31
|
+
REFRESH_LEEWAY_SECONDS = 30
|
|
32
|
+
|
|
33
|
+
# Environment variable names.
|
|
34
|
+
ENV_API_URL = "GEOPERA_API_URL"
|
|
35
|
+
ENV_API_TOKEN = "GEOPERA_API_TOKEN" # opaque bearer/api-key override (no store)
|
|
36
|
+
ENV_PROFILE = "GEOPERA_PROFILE"
|
|
37
|
+
|
|
38
|
+
# Credential store: ~/.config/geopera/credentials.json (dir 0700, file 0600).
|
|
39
|
+
CONFIG_DIR = Path(os.path.expanduser("~")) / ".config" / "geopera"
|
|
40
|
+
CREDENTIALS_PATH = CONFIG_DIR / "credentials.json"
|
|
41
|
+
|
|
42
|
+
DEFAULT_PROFILE = "default"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_profile(flag_profile: str | None = None) -> str:
|
|
46
|
+
"""Resolve the active profile name: --profile > GEOPERA_PROFILE > default."""
|
|
47
|
+
return flag_profile or os.environ.get(ENV_PROFILE) or DEFAULT_PROFILE
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_api_url(flag_api_url: str | None, stored_api_url: str | None) -> str:
|
|
51
|
+
"""Resolve the API base URL by precedence (see module docstring)."""
|
|
52
|
+
return (
|
|
53
|
+
flag_api_url
|
|
54
|
+
or os.environ.get(ENV_API_URL)
|
|
55
|
+
or stored_api_url
|
|
56
|
+
or DEFAULT_API_URL
|
|
57
|
+
).rstrip("/")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def realm_url(api_url: str, endpoint: str) -> str:
|
|
61
|
+
"""Build a full OAuth endpoint URL, e.g. realm_url(api, 'token')."""
|
|
62
|
+
return f"{api_url.rstrip('/')}{REALM_PATH}/{endpoint.lstrip('/')}"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geopera-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line interface for the Geopera geospatial data platform
|
|
5
|
+
Project-URL: Homepage, https://docs.geopera.com
|
|
6
|
+
Project-URL: Documentation, https://docs.geopera.com/cli
|
|
7
|
+
Project-URL: Source, https://github.com/geo-pera/geopera-cli
|
|
8
|
+
Author-email: Geopera <support@geopera.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,earth-observation,geopera,geospatial,satellite,stac
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Requires-Dist: geopera>=2.0.0
|
|
27
|
+
Requires-Dist: httpx<0.29.0,>=0.23.1
|
|
28
|
+
Requires-Dist: typer[all]>=0.12.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.5.0; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# geopera-cli
|
|
35
|
+
|
|
36
|
+
Command-line interface for the [Geopera](https://docs.geopera.com) geospatial
|
|
37
|
+
data platform.
|
|
38
|
+
|
|
39
|
+
`geopera-cli` is a **thin auth + dispatch shell** over the published
|
|
40
|
+
[`geopera` Python SDK](https://github.com/geo-pera/geopera-python). The only
|
|
41
|
+
logic that lives in the CLI is authentication — the OAuth device flow, token
|
|
42
|
+
refresh, and the choice between a `Bearer` token and an `X-API-Key` header.
|
|
43
|
+
Every actual capability is reached through the generic API endpoint
|
|
44
|
+
`POST /v1/op/{operation_id}`, so any of the ~227 operations is callable with no
|
|
45
|
+
per-command code, and a new backend operation is instantly usable as
|
|
46
|
+
`geopera op <new.op>` with zero CLI changes.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install geopera-cli
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This pulls in the `geopera` SDK, `typer`, and `httpx`.
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Sign in (opens your browser; RFC 8628 device flow)
|
|
60
|
+
geopera login
|
|
61
|
+
|
|
62
|
+
# Who am I?
|
|
63
|
+
geopera whoami
|
|
64
|
+
|
|
65
|
+
# Price-preview an order (generic op dispatch)
|
|
66
|
+
geopera op orders.estimate '{"aoi": {...}, "product": "..."}'
|
|
67
|
+
|
|
68
|
+
# Search across every registered public data source
|
|
69
|
+
geopera op catalog.federated_search '{"bbox": [...], "datetime": "..."}'
|
|
70
|
+
|
|
71
|
+
# List every available operation id
|
|
72
|
+
geopera op --list
|
|
73
|
+
|
|
74
|
+
# Curated alias with table output
|
|
75
|
+
geopera orders list
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
| Command | Description |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `geopera login` | Device-flow login (default). `--api-key KEY` stores a key instead (`-` reads from stdin). `--api-url URL`, `--no-browser`, `--scope`. |
|
|
83
|
+
| `geopera logout` | Clear the active profile's stored credentials (best-effort OAuth logout). |
|
|
84
|
+
| `geopera whoami` | Show principal / org / scope (validates the session). `--json` for raw output. |
|
|
85
|
+
| `geopera op OPERATION_ID [JSON]` | Generic operation dispatch. Body from positional arg, `--file`, or `-` (stdin). `--list` enumerates operations. |
|
|
86
|
+
| `geopera orders list` | Curated alias over `op orders.list` with table formatting. |
|
|
87
|
+
|
|
88
|
+
Global flags `--profile NAME` (env `GEOPERA_PROFILE`) and `--api-url URL`
|
|
89
|
+
(env `GEOPERA_API_URL`) are accepted on every command.
|
|
90
|
+
|
|
91
|
+
## Authentication
|
|
92
|
+
|
|
93
|
+
### Device flow (default)
|
|
94
|
+
|
|
95
|
+
`geopera login` performs the OAuth 2.0 Device Authorization Grant
|
|
96
|
+
([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)) with PKCE:
|
|
97
|
+
|
|
98
|
+
1. Requests a device + user code from
|
|
99
|
+
`{api_url}/realms/public/protocol/openid-connect/auth/device`.
|
|
100
|
+
2. Prints the user code and opens the verification URL in your browser
|
|
101
|
+
(skip with `--no-browser`).
|
|
102
|
+
3. Polls the token endpoint until you approve, then stores the access and
|
|
103
|
+
refresh tokens.
|
|
104
|
+
|
|
105
|
+
Access tokens are refreshed automatically — proactively when within 30s of
|
|
106
|
+
expiry, and reactively on any `401` — using the stored refresh token. The
|
|
107
|
+
backend rotates the refresh token, so both tokens are rewritten on each
|
|
108
|
+
refresh.
|
|
109
|
+
|
|
110
|
+
### API key (headless)
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
geopera login --api-key gpra_xxxxxxxx
|
|
114
|
+
# or, keeping the key out of shell history:
|
|
115
|
+
printf '%s' "$GEOPERA_KEY" | geopera login --api-key -
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
API keys are sent as `X-API-Key`, which the Geopera API accepts on every
|
|
119
|
+
authenticated endpoint.
|
|
120
|
+
|
|
121
|
+
### Profiles
|
|
122
|
+
|
|
123
|
+
Multiple identities are namespaced by profile:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
geopera login --profile staging --api-url https://staging.api.geopera.com
|
|
127
|
+
geopera --help # default profile
|
|
128
|
+
GEOPERA_PROFILE=staging geopera whoami
|
|
129
|
+
geopera whoami --profile staging
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Credential store
|
|
133
|
+
|
|
134
|
+
Credentials live in `~/.config/geopera/credentials.json` (directory `0700`,
|
|
135
|
+
file `0600`). Each top-level key is a profile:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"default": {
|
|
140
|
+
"api_url": "https://api.geopera.com",
|
|
141
|
+
"auth": {
|
|
142
|
+
"type": "oauth",
|
|
143
|
+
"access_token": "...",
|
|
144
|
+
"refresh_token": "...",
|
|
145
|
+
"expires_at": 1750000000,
|
|
146
|
+
"scope": "openid profile",
|
|
147
|
+
"issuer": "https://api.geopera.com"
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
For an API key profile the `auth` block is
|
|
154
|
+
`{"type": "api_key", "api_key": "gpra_..."}`.
|
|
155
|
+
|
|
156
|
+
### Environment overrides
|
|
157
|
+
|
|
158
|
+
- `GEOPERA_API_URL` — base URL override (below `--api-url`, above the stored value).
|
|
159
|
+
- `GEOPERA_PROFILE` — active profile name.
|
|
160
|
+
- `GEOPERA_API_TOKEN` — opaque bearer/API-key for ephemeral (e.g. CI) use,
|
|
161
|
+
bypassing the store. A value starting with `gpra_` is treated as an API key.
|
|
162
|
+
|
|
163
|
+
## Configuration precedence
|
|
164
|
+
|
|
165
|
+
API base URL: `--api-url` flag → `GEOPERA_API_URL` → stored `api_url` →
|
|
166
|
+
`https://api.geopera.com`.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
geopera_cli/__init__.py,sha256=71Q9Wo9NP-hf96zJxPrM1a2iRQ38sXBRXIpRTXH_6RM,319
|
|
2
|
+
geopera_cli/__main__.py,sha256=ltXT9i1VjiI4O_zLeK7eR3b7mm2wDBq2UrPSNJJZ-WE,13048
|
|
3
|
+
geopera_cli/auth.py,sha256=6tDHlOaEqSvQsLNVZvFe5HtI1KNhGFh1tizz4VJKhb8,9592
|
|
4
|
+
geopera_cli/client.py,sha256=JrYfixGVASARxzvo0XY228m8WpQG3XiLuHAApWjlakA,8010
|
|
5
|
+
geopera_cli/config.py,sha256=HMN2Gjj521rQ2Bu8kaxJz_dPmc2HhwNIjpDToR36NQE,2280
|
|
6
|
+
geopera_cli-0.1.0.dist-info/METADATA,sha256=yPKePVHuDWtBqDyHY9esO1OKf8QUTXg4A0bEXw2vgvk,5608
|
|
7
|
+
geopera_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
geopera_cli-0.1.0.dist-info/entry_points.txt,sha256=9G1qBmi1YwTZQFqHdk0XC1oJ-tnSsstLo8GfqSLagFE,53
|
|
9
|
+
geopera_cli-0.1.0.dist-info/licenses/LICENSE,sha256=5_TRQYmq2hkTIf7YKP9NeurTh9GhCpxzpAkxRTGBcGs,1064
|
|
10
|
+
geopera_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geopera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|