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.
@@ -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"
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ geopera = geopera_cli.__main__:app
@@ -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.