geolens-cli 1.0.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,17 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """GeoLens CLI.
3
+
4
+ Hand-maintained — NOT regenerated. Version is sourced from package metadata
5
+ so cli/pyproject.toml is the single source of truth for the version string.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
10
+
11
+ try:
12
+ __version__ = _pkg_version("geolens")
13
+ except PackageNotFoundError:
14
+ # Local dev tree before `pip install -e .` — fall back to a sentinel.
15
+ __version__ = "0.0.0+dev"
16
+
17
+ __all__ = ["__version__"]
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """SDK call helpers — Response → T translator + httpx-error → exit-code mapper.
3
+
4
+ Hand-maintained — NOT regenerated. Centralizes the SDK call boundary so each
5
+ command's body is free of error-mapping noise (CONTEXT.md D-32, D-33).
6
+
7
+ Note on httpx import: this module imports httpx ONLY for exception types
8
+ used in error mapping. The httpx instance comes from the SDK
9
+ (client.get_httpx_client()); the CLI never constructs an httpx.Client.
10
+ OCCLI-06 enforcement is on the dep list (cli/pyproject.toml has no httpx
11
+ direct dep — it's transitive via the geolens SDK). The `cli-lint` grep gate
12
+ in Plan 06 is scoped to `^(import|from) (httpx|requests)` lines that
13
+ construct clients; httpx exception imports here are explicitly allowed.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Callable, TypeVar
18
+
19
+ import typer
20
+
21
+ T = TypeVar("T")
22
+
23
+ # Exit codes per CONTEXT.md D-32
24
+ EXIT_OK = 0
25
+ EXIT_GENERIC = 1
26
+ EXIT_USAGE = 2
27
+ EXIT_AUTH = 3
28
+ EXIT_NETWORK = 4
29
+ EXIT_SERVER = 5
30
+
31
+
32
+ def unwrap(resp: Any, *, expected: int = 200) -> Any:
33
+ """Translate an SDK Response into either parsed model or typer.Exit.
34
+
35
+ Maps HTTP status to exit codes:
36
+ expected (default 200) → return resp.parsed
37
+ 401, 403 → exit 3 (EXIT_AUTH)
38
+ 5xx → exit 5 (EXIT_SERVER)
39
+ other → exit 1 (EXIT_GENERIC)
40
+ """
41
+ from geolens.models.problem_detail import ProblemDetail # lazy
42
+
43
+ sc = int(resp.status_code)
44
+ if sc == expected:
45
+ if isinstance(resp.parsed, ProblemDetail):
46
+ typer.secho(f"Error: {resp.parsed.detail}", fg="red", err=True)
47
+ raise typer.Exit(EXIT_SERVER if sc >= 500 else EXIT_GENERIC)
48
+ return resp.parsed
49
+
50
+ detail = ""
51
+ if isinstance(resp.parsed, ProblemDetail):
52
+ detail = f": {resp.parsed.detail}"
53
+
54
+ if sc == 401:
55
+ typer.secho(f"Authentication required{detail}. Run `geolens login` first.", fg="red", err=True)
56
+ raise typer.Exit(EXIT_AUTH)
57
+ if sc == 403:
58
+ typer.secho(f"Permission denied{detail}", fg="red", err=True)
59
+ raise typer.Exit(EXIT_AUTH)
60
+ if 500 <= sc <= 599:
61
+ typer.secho(f"Server error ({sc}){detail}", fg="red", err=True)
62
+ raise typer.Exit(EXIT_SERVER)
63
+ typer.secho(f"Request failed ({sc}){detail}", fg="red", err=True)
64
+ raise typer.Exit(EXIT_GENERIC)
65
+
66
+
67
+ def call_sdk(fn: Callable[..., Any], **kwargs: Any) -> Any:
68
+ """Run a sync_detailed call, mapping httpx exceptions to exit codes."""
69
+ import httpx # lazy — only for exception types
70
+
71
+ try:
72
+ return fn(**kwargs)
73
+ except httpx.TimeoutException:
74
+ typer.secho("Request timed out", fg="red", err=True)
75
+ raise typer.Exit(EXIT_NETWORK)
76
+ except httpx.NetworkError as exc:
77
+ typer.secho(f"Network error: {exc}", fg="red", err=True)
78
+ raise typer.Exit(EXIT_NETWORK)
geolens_cli/auth.py ADDED
@@ -0,0 +1,225 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Credential storage — OS keyring with credentials.toml fallback.
3
+
4
+ Hand-maintained — NOT regenerated. Mirrors sdks/python/geolens/auth.py's
5
+ "configure exactly one" discipline for BearerToken vs ApiKey.
6
+
7
+ Backend storage precedence (matches CONTEXT.md D-35):
8
+ CLI flag (handled in main.py)
9
+ > GEOLENS_TOKEN env var
10
+ > credentials.toml
11
+ > OS keyring
12
+
13
+ Storage backends:
14
+ Default: OS keyring via `keyring` (service="geolens", account=<instance_url>)
15
+ Fallback: ~/.config/geolens/credentials.toml (mode 0600)
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Optional
21
+
22
+ import keyring
23
+ import structlog
24
+ import tomli_w
25
+ import tomllib
26
+ from keyring.errors import KeyringError
27
+
28
+ from . import config as _config
29
+
30
+ log = structlog.get_logger()
31
+ SERVICE = "geolens"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class BearerToken:
36
+ value: str
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ApiKey:
41
+ value: str
42
+
43
+
44
+ # ---------- Internal helpers ----------
45
+
46
+ def _keyring_account_token(instance: str) -> str:
47
+ return instance
48
+
49
+
50
+ def _keyring_account_refresh(instance: str) -> str:
51
+ return f"{instance}:refresh"
52
+
53
+
54
+ def _keyring_account_api_key(instance: str) -> str:
55
+ return f"{instance}:api_key"
56
+
57
+
58
+ def _read_credentials_file() -> dict:
59
+ path = _config.credentials_path()
60
+ if not path.is_file():
61
+ return {}
62
+ try:
63
+ return tomllib.loads(path.read_text())
64
+ except tomllib.TOMLDecodeError:
65
+ return {}
66
+
67
+
68
+ def _write_credentials_file(data: dict) -> None:
69
+ _config.atomic_write_text(
70
+ _config.credentials_path(),
71
+ tomli_w.dumps(data),
72
+ mode=0o600,
73
+ )
74
+
75
+
76
+ def _set_credential_field(instance: str, field: str, value: str) -> None:
77
+ data = _read_credentials_file()
78
+ data.setdefault(instance, {})[field] = value
79
+ _write_credentials_file(data)
80
+
81
+
82
+ def _clear_credential_section(instance: str) -> None:
83
+ data = _read_credentials_file()
84
+ data.pop(instance, None)
85
+ if data:
86
+ _write_credentials_file(data)
87
+ else:
88
+ # Empty -> remove the file entirely so `geolens logout` leaves no trace.
89
+ path = _config.credentials_path()
90
+ if path.exists():
91
+ path.unlink()
92
+
93
+
94
+ # ---------- Store ----------
95
+
96
+ def store_bearer_token(instance: str, token: str, *, no_keyring: bool = False) -> str:
97
+ """Store the access token. Returns 'keyring' or 'file'."""
98
+ if not no_keyring:
99
+ try:
100
+ keyring.set_password(SERVICE, _keyring_account_token(instance), token)
101
+ return "keyring"
102
+ except KeyringError as exc:
103
+ log.warning("keyring_unavailable_falling_back_to_file", error=str(exc))
104
+ _set_credential_field(instance, "bearer_token", token)
105
+ return "file"
106
+
107
+
108
+ def store_api_key(instance: str, api_key: str, *, no_keyring: bool = False) -> str:
109
+ """Store an API key. Returns 'keyring' or 'file'."""
110
+ if not no_keyring:
111
+ try:
112
+ keyring.set_password(SERVICE, _keyring_account_api_key(instance), api_key)
113
+ return "keyring"
114
+ except KeyringError as exc:
115
+ log.warning("keyring_unavailable_falling_back_to_file", error=str(exc))
116
+ _set_credential_field(instance, "api_key", api_key)
117
+ return "file"
118
+
119
+
120
+ def store_refresh_token(instance: str, refresh: str, *, no_keyring: bool = False) -> str:
121
+ if not no_keyring:
122
+ try:
123
+ keyring.set_password(SERVICE, _keyring_account_refresh(instance), refresh)
124
+ return "keyring"
125
+ except KeyringError as exc:
126
+ log.warning("keyring_unavailable_falling_back_to_file", error=str(exc))
127
+ _set_credential_field(instance, "refresh_token", refresh)
128
+ return "file"
129
+
130
+
131
+ # ---------- Load ----------
132
+
133
+ def load_bearer_token(instance: str) -> Optional[BearerToken]:
134
+ """Return the active bearer token per the D-35 precedence."""
135
+ env_token = _config.get_token_from_env()
136
+ if env_token:
137
+ return BearerToken(env_token)
138
+ # credentials.toml > keyring (file is explicit; keyring is fallback)
139
+ data = _read_credentials_file().get(instance, {})
140
+ token = data.get("bearer_token")
141
+ if token:
142
+ return BearerToken(token)
143
+ try:
144
+ kr_token = keyring.get_password(SERVICE, _keyring_account_token(instance))
145
+ except KeyringError:
146
+ return None
147
+ return BearerToken(kr_token) if kr_token else None
148
+
149
+
150
+ def load_api_key(instance: str) -> Optional[ApiKey]:
151
+ data = _read_credentials_file().get(instance, {})
152
+ key = data.get("api_key")
153
+ if key:
154
+ return ApiKey(key)
155
+ try:
156
+ kr_key = keyring.get_password(SERVICE, _keyring_account_api_key(instance))
157
+ except KeyringError:
158
+ return None
159
+ return ApiKey(kr_key) if kr_key else None
160
+
161
+
162
+ def load_refresh_token(instance: str) -> Optional[str]:
163
+ data = _read_credentials_file().get(instance, {})
164
+ refresh = data.get("refresh_token")
165
+ if refresh:
166
+ return refresh
167
+ try:
168
+ return keyring.get_password(SERVICE, _keyring_account_refresh(instance))
169
+ except KeyringError:
170
+ return None
171
+
172
+
173
+ # ---------- Delete ----------
174
+
175
+ def delete_credentials(instance: str) -> None:
176
+ """Remove all three keyring entries AND the credentials.toml section.
177
+
178
+ Missing entries are silently ignored — logout is idempotent.
179
+ """
180
+ for account in (
181
+ _keyring_account_token(instance),
182
+ _keyring_account_refresh(instance),
183
+ _keyring_account_api_key(instance),
184
+ ):
185
+ try:
186
+ keyring.delete_password(SERVICE, account)
187
+ except Exception:
188
+ # PasswordDeleteError + KeyringError + missing entries all swallowed.
189
+ pass
190
+ _clear_credential_section(instance)
191
+
192
+
193
+ # ---------- Refresh ----------
194
+
195
+ def try_refresh(instance: str) -> Optional[str]:
196
+ """Attempt a single refresh; return new access token or None on failure.
197
+
198
+ Per CONTEXT D-13, this is called once on a 401. If it fails, the caller
199
+ prints "Session expired" and exits with EXIT_AUTH (3).
200
+ """
201
+ refresh = load_refresh_token(instance)
202
+ if not refresh:
203
+ return None
204
+ from geolens import GeolensClient
205
+ from geolens.api.auth import refresh_auth_refresh_post
206
+ from geolens.models.refresh_request import RefreshRequest
207
+
208
+ try:
209
+ sdk = GeolensClient(base_url=instance)
210
+ body = RefreshRequest(refresh_token=refresh)
211
+ resp = refresh_auth_refresh_post.sync_detailed(client=sdk.client, body=body)
212
+ except Exception as exc: # network or unexpected SDK error
213
+ log.warning("refresh_failed", error=str(exc))
214
+ return None
215
+ if int(resp.status_code) != 200:
216
+ return None
217
+ parsed = resp.parsed
218
+ if parsed is None or not getattr(parsed, "access_token", None):
219
+ return None
220
+ new_access = parsed.access_token
221
+ store_bearer_token(instance, new_access, no_keyring=False)
222
+ new_refresh = getattr(parsed, "refresh_token", None)
223
+ if new_refresh:
224
+ store_refresh_token(instance, new_refresh, no_keyring=False)
225
+ return new_access
geolens_cli/config.py ADDED
@@ -0,0 +1,118 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """XDG-compliant config + atomic TOML I/O for the GeoLens CLI.
3
+
4
+ Hand-maintained — NOT regenerated. config.toml stores the active instance
5
+ URL and username; credentials.toml stores tokens (only when --no-keyring
6
+ is set or the keyring is unavailable). Per CONTEXT.md D-11, tokens NEVER
7
+ appear in config.toml.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import tempfile
13
+ import tomllib
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import tomli_w
19
+ from platformdirs import user_config_dir
20
+
21
+ APP_NAME = "geolens"
22
+
23
+
24
+ def _config_dir() -> Path:
25
+ # platformdirs honors XDG_CONFIG_HOME on Linux, AppData on Windows.
26
+ # appauthor=False so the path is ~/.config/geolens (not ~/.config/geolens/geolens).
27
+ return Path(user_config_dir(APP_NAME, appauthor=False))
28
+
29
+
30
+ def config_path() -> Path:
31
+ return _config_dir() / "config.toml"
32
+
33
+
34
+ def credentials_path() -> Path:
35
+ return _config_dir() / "credentials.toml"
36
+
37
+
38
+ @dataclass
39
+ class AppConfig:
40
+ instance: Optional[str] = None
41
+ username: Optional[str] = None
42
+
43
+ @classmethod
44
+ def load(cls) -> "AppConfig":
45
+ path = config_path()
46
+ if not path.is_file():
47
+ return cls()
48
+ try:
49
+ data = tomllib.loads(path.read_text())
50
+ except tomllib.TOMLDecodeError:
51
+ return cls()
52
+ default = data.get("default", {})
53
+ return cls(
54
+ instance=default.get("instance"),
55
+ username=default.get("username"),
56
+ )
57
+
58
+
59
+ def load_config() -> AppConfig:
60
+ return AppConfig.load()
61
+
62
+
63
+ def write_default_instance(instance: str, username: Optional[str]) -> None:
64
+ """Write the active instance URL + username to config.toml.
65
+
66
+ Does NOT write tokens (D-11). Tokens go to keyring or credentials.toml.
67
+ """
68
+ section: dict[str, str] = {"instance": instance}
69
+ if username is not None:
70
+ section["username"] = username
71
+ payload = {"default": section}
72
+ atomic_write_text(config_path(), tomli_w.dumps(payload), mode=0o600)
73
+
74
+
75
+ def atomic_write_text(path: Path, content: str, *, mode: int = 0o600) -> None:
76
+ """Write content to path atomically with the given file mode.
77
+
78
+ Per RESEARCH Pattern 4: tempfile in same dir + chmod + os.replace. Parent
79
+ directory is created at 0o700 if missing. On any failure the tempfile is
80
+ removed before the exception propagates.
81
+ """
82
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
83
+ # Tighten parent dir mode in case it pre-existed at a different mode.
84
+ try:
85
+ os.chmod(path.parent, 0o700)
86
+ except OSError:
87
+ # On some platforms (Windows) chmod is a no-op; not fatal.
88
+ pass
89
+ fd, tmp_path = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
90
+ try:
91
+ os.write(fd, content.encode("utf-8"))
92
+ os.close(fd)
93
+ os.chmod(tmp_path, mode)
94
+ os.replace(tmp_path, path)
95
+ except Exception:
96
+ try:
97
+ os.unlink(tmp_path)
98
+ except OSError:
99
+ pass
100
+ raise
101
+
102
+
103
+ def get_instance_from_env() -> Optional[str]:
104
+ return os.environ.get("GEOLENS_INSTANCE")
105
+
106
+
107
+ def get_token_from_env() -> Optional[str]:
108
+ return os.environ.get("GEOLENS_TOKEN")
109
+
110
+
111
+ def normalize_instance_url(url: str) -> str:
112
+ """Strip trailing slash; reject non-http(s) schemes via ValueError."""
113
+ url = url.strip()
114
+ if not url:
115
+ raise ValueError("Instance URL must not be empty")
116
+ if not url.startswith(("http://", "https://")):
117
+ raise ValueError(f"Instance URL must use http or https scheme: got {url!r}")
118
+ return url.rstrip("/")
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """STAC export — fetch a STAC 1.1 item for a raster dataset and render it.
3
+
4
+ Hand-maintained — NOT regenerated. Pure SDK pass-through (D-25, D-28). The
5
+ backend at ``backend/app/standards/stac/router.py`` already produces
6
+ conformant STAC 1.1; the CLI is a pretty-printer. Vector datasets are
7
+ rejected pre-flight (D-26) so users see a clear message rather than a
8
+ confusing 404 or 422 from ``GET /stac/items/{id}``.
9
+
10
+ OCCLI-06 invariant: zero direct ``httpx`` / ``requests`` imports here —
11
+ every HTTP call goes through the generated SDK functions.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from . import config as _config
20
+ from ._sdk_helpers import call_sdk, unwrap
21
+
22
+
23
+ def fetch_record_type(client: Any, dataset_id: str) -> str:
24
+ """Pre-flight check: return the dataset's ``record_type``.
25
+
26
+ Per D-26: STAC export is raster-only. We GET ``/datasets/{id}`` first
27
+ to avoid a confusing error from ``/stac/items/{id}`` when the dataset
28
+ is vector-typed.
29
+
30
+ Returns:
31
+ The literal string ``"not_found"`` on a 404 (so the caller can
32
+ emit a friendly "Dataset not found" message and exit 1). For all
33
+ other non-200 statuses, ``unwrap`` translates to the appropriate
34
+ ``typer.Exit`` (auth/server/generic). On success, returns the
35
+ ``record_type`` string from ``DatasetResponse``
36
+ (e.g., ``"raster_dataset"``, ``"vector_dataset"``).
37
+ """
38
+ from geolens.api.datasets import (
39
+ get_single_dataset_datasets_dataset_id_get,
40
+ )
41
+
42
+ resp = call_sdk(
43
+ get_single_dataset_datasets_dataset_id_get.sync_detailed,
44
+ dataset_id=dataset_id,
45
+ client=client,
46
+ )
47
+ sc = int(resp.status_code)
48
+ if sc == 404:
49
+ return "not_found"
50
+ if sc != 200:
51
+ # Let unwrap() translate the error → exit code (401/403/5xx/etc.)
52
+ unwrap(resp, expected=200)
53
+
54
+ rec = getattr(resp.parsed, "record_type", None)
55
+ if rec is None:
56
+ # Defensive: try alternate field names if the OpenAPI shape changes.
57
+ rec = (
58
+ getattr(resp.parsed, "type", None)
59
+ or getattr(resp.parsed, "dataset_type", None)
60
+ )
61
+ return str(rec) if rec else "unknown"
62
+
63
+
64
+ def is_raster(record_type: str) -> bool:
65
+ """True iff ``record_type`` looks like a raster dataset.
66
+
67
+ Defensive: backend may use ``raster_dataset``, ``RasterDataset``, or
68
+ bare ``raster``. We accept any of these so a future rename of the
69
+ enum doesn't silently break the CLI guard.
70
+ """
71
+ if not record_type:
72
+ return False
73
+ return record_type.lower().startswith("raster")
74
+
75
+
76
+ def fetch_stac_item(client: Any, dataset_id: str) -> dict:
77
+ """Fetch the STAC item dict for a dataset.
78
+
79
+ Caller pre-checks ``record_type`` via :func:`fetch_record_type` so
80
+ this function assumes the dataset is raster-typed.
81
+
82
+ The SDK's ``get_item_stac`` is generated with ``Any`` as the 200
83
+ response type (the OpenAPI schema declares the response as a free-
84
+ form dict). The parsed body is therefore the STAC item dict directly
85
+ — no ``.to_dict()`` needed.
86
+
87
+ Defensive shape handling (in case future SDK regen wraps the body
88
+ in a generated model):
89
+ * If ``parsed`` is None, json-load ``resp.content``.
90
+ * If ``parsed`` has ``.to_dict()``, call it.
91
+ * If ``parsed`` is already a dict, return it.
92
+ """
93
+ from geolens.api.stac import get_item_stac_items_item_id_get
94
+
95
+ resp = call_sdk(
96
+ get_item_stac_items_item_id_get.sync_detailed,
97
+ item_id=dataset_id,
98
+ client=client,
99
+ )
100
+ item = unwrap(resp, expected=200)
101
+ if item is None:
102
+ return json.loads(resp.content.decode("utf-8"))
103
+ if isinstance(item, dict):
104
+ return item
105
+ if hasattr(item, "to_dict"):
106
+ return item.to_dict()
107
+ # Unknown shape — best-effort fall back to the raw response body.
108
+ return json.loads(resp.content.decode("utf-8"))
109
+
110
+
111
+ def render_stac_json(item: dict, *, compact: bool = False) -> str:
112
+ """Format a STAC dict as JSON.
113
+
114
+ Default (D-27): pretty-printed, ``indent=2``, sorted keys, trailing
115
+ newline — produces diff-stable output.
116
+
117
+ Compact: single-line JSON with no whitespace separators — for piping
118
+ into ``jq`` or ``curl --data``.
119
+ """
120
+ if compact:
121
+ return json.dumps(item, sort_keys=True, separators=(",", ":"))
122
+ return json.dumps(item, indent=2, sort_keys=True) + "\n"
123
+
124
+
125
+ def write_stac_to_file(item: dict, path: Path, *, compact: bool = False) -> None:
126
+ """Atomically write the rendered STAC JSON to ``path``.
127
+
128
+ Mode 0o644 — STAC payloads are not secrets. The atomic ``tempfile +
129
+ os.replace`` write prevents half-written files on Ctrl+C (D-27).
130
+ """
131
+ _config.atomic_write_text(
132
+ path,
133
+ render_stac_json(item, compact=compact),
134
+ mode=0o644,
135
+ )
136
+
137
+
138
+ def vector_rejection_message(record_type: str) -> str:
139
+ """User-facing rejection message for non-raster datasets (D-26)."""
140
+ return (
141
+ "STAC export is supported for raster datasets only — "
142
+ f"got record_type={record_type}"
143
+ )