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.
- geolens_cli/__init__.py +17 -0
- geolens_cli/_sdk_helpers.py +78 -0
- geolens_cli/auth.py +225 -0
- geolens_cli/config.py +118 -0
- geolens_cli/export_stac.py +143 -0
- geolens_cli/main.py +492 -0
- geolens_cli/output.py +67 -0
- geolens_cli/publish.py +279 -0
- geolens_cli/scan.py +207 -0
- geolens_cli-1.0.0.dist-info/METADATA +53 -0
- geolens_cli-1.0.0.dist-info/RECORD +14 -0
- geolens_cli-1.0.0.dist-info/WHEEL +4 -0
- geolens_cli-1.0.0.dist-info/entry_points.txt +2 -0
- geolens_cli-1.0.0.dist-info/licenses/LICENSE +190 -0
geolens_cli/__init__.py
ADDED
|
@@ -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
|
+
)
|