geodeploy 1.3.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.
- geodeploy/__init__.py +52 -0
- geodeploy/__main__.py +13 -0
- geodeploy/admin.py +191 -0
- geodeploy/catalog.py +96 -0
- geodeploy/cli/__init__.py +7 -0
- geodeploy/cli/commands/__init__.py +2 -0
- geodeploy/cli/commands/_common.py +233 -0
- geodeploy/cli/commands/admin.py +317 -0
- geodeploy/cli/commands/auth.py +280 -0
- geodeploy/cli/commands/browse.py +224 -0
- geodeploy/cli/commands/catalog.py +107 -0
- geodeploy/cli/commands/imports.py +125 -0
- geodeploy/cli/commands/jobs.py +38 -0
- geodeploy/cli/commands/layers.py +536 -0
- geodeploy/cli/commands/portals.py +515 -0
- geodeploy/cli/commands/sources.py +97 -0
- geodeploy/cli/commands/upload.py +154 -0
- geodeploy/cli/main.py +263 -0
- geodeploy/cli/output.py +320 -0
- geodeploy/client.py +327 -0
- geodeploy/config.py +438 -0
- geodeploy/errors.py +93 -0
- geodeploy/imports.py +65 -0
- geodeploy/jobs.py +73 -0
- geodeploy/layers.py +433 -0
- geodeploy/portals.py +280 -0
- geodeploy/py.typed +0 -0
- geodeploy/sources.py +70 -0
- geodeploy/styles.py +467 -0
- geodeploy/transport.py +355 -0
- geodeploy/uploads.py +474 -0
- geodeploy-1.3.0.dist-info/METADATA +102 -0
- geodeploy-1.3.0.dist-info/RECORD +38 -0
- geodeploy-1.3.0.dist-info/WHEEL +5 -0
- geodeploy-1.3.0.dist-info/entry_points.txt +2 -0
- geodeploy-1.3.0.dist-info/licenses/LICENSE +202 -0
- geodeploy-1.3.0.dist-info/licenses/NOTICE +11 -0
- geodeploy-1.3.0.dist-info/top_level.txt +1 -0
geodeploy/config.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Where the CLI remembers your instances, and where it does NOT remember your tokens.
|
|
2
|
+
|
|
3
|
+
Three rules this module exists to keep:
|
|
4
|
+
|
|
5
|
+
1. **A token never lands in the config file.** `config.json` holds instance URLs, profile names and
|
|
6
|
+
the account each was logged in as — the things worth reading and editing by hand. Secrets go to
|
|
7
|
+
the OS keyring when there is one, and to a separate `credentials.json` (0600, in a 0700 parent)
|
|
8
|
+
when there is not. That separation is what lets someone paste their config into an issue.
|
|
9
|
+
|
|
10
|
+
2. **Writes are atomic.** temp file in the same directory, chmod, `os.replace`. A CLI that is
|
|
11
|
+
interrupted mid-write must not leave a truncated config that locks the user out of their own
|
|
12
|
+
instance.
|
|
13
|
+
|
|
14
|
+
3. **Explicit beats remembered.** Flags override the environment, which overrides the active
|
|
15
|
+
profile — so a CI job that sets `GEODEPLOY_URL`/`GEODEPLOY_TOKEN` needs no config file at all,
|
|
16
|
+
and `--url` in a one-off command never silently writes itself down.
|
|
17
|
+
|
|
18
|
+
The env var names are `GEODEPLOY_URL` and `GEODEPLOY_TOKEN`, unchanged from the reference script in
|
|
19
|
+
`examples/`, because people already have them exported.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from typing import Any, Dict, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
from .errors import ConfigError
|
|
30
|
+
|
|
31
|
+
APP_NAME = "geodeploy"
|
|
32
|
+
|
|
33
|
+
#: Service name under which tokens are filed in the OS keyring.
|
|
34
|
+
KEYRING_SERVICE = "geodeploy-cli"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Locations ────────────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def config_dir() -> str:
|
|
40
|
+
"""The per-user config directory, following each platform's own convention.
|
|
41
|
+
|
|
42
|
+
Hand-rolled rather than `platformdirs` for the no-dependency rule (see pyproject). The three
|
|
43
|
+
branches below are the whole of what that library would do for us.
|
|
44
|
+
"""
|
|
45
|
+
override = os.environ.get("GEODEPLOY_CONFIG_DIR")
|
|
46
|
+
if override:
|
|
47
|
+
return os.path.expanduser(override)
|
|
48
|
+
if sys.platform.startswith("win"):
|
|
49
|
+
base = os.environ.get("APPDATA") or os.path.expanduser("~")
|
|
50
|
+
return os.path.join(base, "GeoDeploy")
|
|
51
|
+
if sys.platform == "darwin":
|
|
52
|
+
return os.path.expanduser("~/Library/Application Support/geodeploy")
|
|
53
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
54
|
+
return os.path.join(base, APP_NAME)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def config_path() -> str:
|
|
58
|
+
return os.path.join(config_dir(), "config.json")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def credentials_path() -> str:
|
|
62
|
+
return os.path.join(config_dir(), "credentials.json")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Atomic, permission-aware writes ──────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def atomic_write(path: str, text: str, mode: int = 0o600, tighten_parent: bool = False) -> None:
|
|
68
|
+
"""Write `text` to `path` atomically, with `mode` on the file.
|
|
69
|
+
|
|
70
|
+
`tighten_parent` is for the credentials file only: 0700 on the directory keeps another account
|
|
71
|
+
on a shared machine from listing it. It is deliberately NOT applied to ordinary output files —
|
|
72
|
+
tightening a directory a user chose (their home, a project folder) is not ours to do.
|
|
73
|
+
"""
|
|
74
|
+
parent = os.path.dirname(path) or "."
|
|
75
|
+
os.makedirs(parent, exist_ok=True)
|
|
76
|
+
if tighten_parent:
|
|
77
|
+
try:
|
|
78
|
+
os.chmod(parent, 0o700)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass # Windows: chmod is largely a no-op, and NTFS ACLs already scope %APPDATA%.
|
|
81
|
+
fd, tmp = tempfile.mkstemp(dir=parent, prefix="." + os.path.basename(path) + ".", suffix=".tmp")
|
|
82
|
+
try:
|
|
83
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
84
|
+
fh.write(text)
|
|
85
|
+
try:
|
|
86
|
+
os.chmod(tmp, mode)
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
os.replace(tmp, path)
|
|
90
|
+
except Exception:
|
|
91
|
+
try:
|
|
92
|
+
os.unlink(tmp)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_json(path: str) -> Dict[str, Any]:
|
|
99
|
+
if not os.path.isfile(path):
|
|
100
|
+
return {}
|
|
101
|
+
try:
|
|
102
|
+
with open(path, "r", encoding="utf-8-sig") as fh: # -sig: a BOM from Notepad is not an error
|
|
103
|
+
data = json.load(fh)
|
|
104
|
+
return data if isinstance(data, dict) else {}
|
|
105
|
+
except (ValueError, OSError) as exc:
|
|
106
|
+
raise ConfigError("Could not read {0}: {1}".format(path, exc))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── URL handling ─────────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def normalize_url(url: str) -> str:
|
|
112
|
+
"""Canonical instance origin: scheme + host (+ port), no trailing slash, no `/api`.
|
|
113
|
+
|
|
114
|
+
Everything downstream builds `<origin>/api/...`, so `https://gd.example.org/api/` and
|
|
115
|
+
`gd.example.org` have to resolve to the same string — otherwise a token saved under one spelling
|
|
116
|
+
is invisible to the other, which is the single most confusing thing a multi-instance CLI can do.
|
|
117
|
+
"""
|
|
118
|
+
raw = (url or "").strip()
|
|
119
|
+
if not raw:
|
|
120
|
+
raise ConfigError("An instance URL is required (e.g. https://geodeploy.example.org).")
|
|
121
|
+
if "://" not in raw:
|
|
122
|
+
# A bare host is what people type. Default to https — the wrong guess is a TLS error the
|
|
123
|
+
# user can read, whereas defaulting to http would silently send their token in clear.
|
|
124
|
+
raw = "https://" + raw
|
|
125
|
+
scheme, _, rest = raw.partition("://")
|
|
126
|
+
if scheme not in ("http", "https"):
|
|
127
|
+
raise ConfigError("Instance URL must be http:// or https:// (got {0}://).".format(scheme))
|
|
128
|
+
rest = rest.rstrip("/")
|
|
129
|
+
for suffix in ("/api/docs", "/api/openapi.json", "/api"):
|
|
130
|
+
if rest.endswith(suffix):
|
|
131
|
+
rest = rest[: -len(suffix)]
|
|
132
|
+
break
|
|
133
|
+
if not rest:
|
|
134
|
+
raise ConfigError("Instance URL has no host: {0}".format(url))
|
|
135
|
+
return "{0}://{1}".format(scheme, rest.rstrip("/"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def split_portal_url(value: str) -> Tuple[Optional[str], str]:
|
|
139
|
+
"""A portal reference → `(instance URL or None, slug)`.
|
|
140
|
+
|
|
141
|
+
Accepts a bare slug (`field-sites-2026`) or the URL a person copies out of the address bar
|
|
142
|
+
(`https://gd.example.org/portals/field-sites-2026/`, with or without a trailing
|
|
143
|
+
`style.json`). The URL already names its instance, so demanding the slug be cut out of it by
|
|
144
|
+
hand — and the instance be supplied a second time — is friction with nothing behind it.
|
|
145
|
+
"""
|
|
146
|
+
raw = (value or "").strip()
|
|
147
|
+
if not raw:
|
|
148
|
+
raise ConfigError("A portal is required: a slug, or the URL of a published portal.")
|
|
149
|
+
if "://" not in raw:
|
|
150
|
+
if "/portals/" not in raw:
|
|
151
|
+
return None, raw.strip("/") # a plain slug, which is the common case
|
|
152
|
+
raw = "https://" + raw # `host/portals/slug` pasted without the scheme
|
|
153
|
+
head, sep, tail = raw.partition("/portals/")
|
|
154
|
+
if not sep:
|
|
155
|
+
raise ConfigError(
|
|
156
|
+
"That does not look like a portal URL: {0}\n"
|
|
157
|
+
"Expected something like https://gd.example.org/portals/<slug>/".format(value))
|
|
158
|
+
slug = tail.strip("/").split("/")[0]
|
|
159
|
+
if not slug:
|
|
160
|
+
raise ConfigError("That portal URL has no slug: {0}".format(value))
|
|
161
|
+
return normalize_url(head), slug
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── The config file ──────────────────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
class Config:
|
|
167
|
+
"""`config.json` — profiles and which one is active. No secrets, ever."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, data: Optional[Dict[str, Any]] = None, path: Optional[str] = None):
|
|
170
|
+
self.path = path or config_path()
|
|
171
|
+
data = data or {}
|
|
172
|
+
self.profiles = dict(data.get("profiles") or {}) # name -> {url, email, ...}
|
|
173
|
+
self.current = data.get("current") or None
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def load(cls, path: Optional[str] = None) -> "Config":
|
|
177
|
+
p = path or config_path()
|
|
178
|
+
return cls(_read_json(p), p)
|
|
179
|
+
|
|
180
|
+
def save(self) -> None:
|
|
181
|
+
payload = {"current": self.current, "profiles": self.profiles}
|
|
182
|
+
atomic_write(self.path, json.dumps(payload, indent=2, sort_keys=True) + "\n", mode=0o600)
|
|
183
|
+
|
|
184
|
+
# -- profiles --------------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def set_profile(self, name: str, url: str, email: Optional[str] = None,
|
|
187
|
+
make_current: bool = True, **extra: Any) -> Dict[str, Any]:
|
|
188
|
+
entry = dict(self.profiles.get(name) or {})
|
|
189
|
+
entry["url"] = normalize_url(url)
|
|
190
|
+
if email is not None:
|
|
191
|
+
entry["email"] = email
|
|
192
|
+
for key, value in extra.items():
|
|
193
|
+
if value is None:
|
|
194
|
+
entry.pop(key, None)
|
|
195
|
+
else:
|
|
196
|
+
entry[key] = value
|
|
197
|
+
self.profiles[name] = entry
|
|
198
|
+
if make_current or not self.current:
|
|
199
|
+
self.current = name
|
|
200
|
+
return entry
|
|
201
|
+
|
|
202
|
+
def remove_profile(self, name: str) -> bool:
|
|
203
|
+
existed = self.profiles.pop(name, None) is not None
|
|
204
|
+
if self.current == name:
|
|
205
|
+
self.current = next(iter(self.profiles), None)
|
|
206
|
+
return existed
|
|
207
|
+
|
|
208
|
+
def get(self, name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
209
|
+
key = name or self.current
|
|
210
|
+
if not key:
|
|
211
|
+
return None
|
|
212
|
+
entry = self.profiles.get(key)
|
|
213
|
+
if entry is None:
|
|
214
|
+
return None
|
|
215
|
+
out = dict(entry)
|
|
216
|
+
out["name"] = key
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
def resolve_name(self, name: Optional[str]) -> Optional[str]:
|
|
220
|
+
if name:
|
|
221
|
+
if name not in self.profiles:
|
|
222
|
+
raise ConfigError(
|
|
223
|
+
"No profile named {0!r}. Known profiles: {1}".format(
|
|
224
|
+
name, ", ".join(sorted(self.profiles)) or "(none)"))
|
|
225
|
+
return name
|
|
226
|
+
return self.current
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Credentials ──────────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
# Keyed by the NORMALIZED instance URL rather than the profile name, so two profiles pointing at
|
|
231
|
+
# one instance share the credential and deleting a profile does not orphan a secret.
|
|
232
|
+
|
|
233
|
+
def _keyring():
|
|
234
|
+
"""The `keyring` module if the host has it, else None.
|
|
235
|
+
|
|
236
|
+
Optional on purpose: an install that has it (most desktops, and QGIS on Windows/macOS) gets the
|
|
237
|
+
OS credential store; a bare server without it gets the 0600 file. Refusing to run without
|
|
238
|
+
keyring would make the CLI unusable in exactly the headless case it is most needed.
|
|
239
|
+
"""
|
|
240
|
+
if os.environ.get("GEODEPLOY_NO_KEYRING"):
|
|
241
|
+
return None
|
|
242
|
+
try:
|
|
243
|
+
import keyring # type: ignore
|
|
244
|
+
# A keyring with no usable backend raises only on USE, so probe the backend up front.
|
|
245
|
+
from keyring.backends import fail as _fail # type: ignore
|
|
246
|
+
if isinstance(keyring.get_keyring(), _fail.Keyring):
|
|
247
|
+
return None
|
|
248
|
+
return keyring
|
|
249
|
+
except Exception:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def save_credential(url: str, token: Optional[str] = None, jwt: Optional[str] = None,
|
|
254
|
+
email: Optional[str] = None, use_keyring: bool = True) -> str:
|
|
255
|
+
"""Store credentials for `url`. Returns where they went: "keyring" or the file path.
|
|
256
|
+
|
|
257
|
+
TWO kinds live side by side because the API deliberately treats them differently: an API token
|
|
258
|
+
(`gdp_…`) is scoped and cannot touch `/admin/*` or mint more tokens, while a password login
|
|
259
|
+
yields a session JWT that can. Someone who does both should not have to choose, so a `login
|
|
260
|
+
--password` does not wipe a stored token.
|
|
261
|
+
"""
|
|
262
|
+
origin = normalize_url(url)
|
|
263
|
+
entry = load_credential(origin)
|
|
264
|
+
if token is not None:
|
|
265
|
+
entry["token"] = token
|
|
266
|
+
if jwt is not None:
|
|
267
|
+
entry["jwt"] = jwt
|
|
268
|
+
if email is not None:
|
|
269
|
+
entry["email"] = email
|
|
270
|
+
return _store_credential(origin, entry, use_keyring)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _store_credential(origin: str, entry: Dict[str, Any], use_keyring: bool = True) -> str:
|
|
274
|
+
"""Write `entry` as the WHOLE credential for `origin` — no merge.
|
|
275
|
+
|
|
276
|
+
Separate from `save_credential` because deletion needs it: pruning a key and then saving
|
|
277
|
+
through the merging path re-reads the stored entry and puts the key straight back, which is a
|
|
278
|
+
"logged out" that leaves you logged in.
|
|
279
|
+
"""
|
|
280
|
+
if use_keyring:
|
|
281
|
+
kr = _keyring()
|
|
282
|
+
if kr is not None:
|
|
283
|
+
try:
|
|
284
|
+
kr.set_password(KEYRING_SERVICE, origin, _json_dumps(entry))
|
|
285
|
+
# Drop any older file copy, so a secret lives in ONE place, not two.
|
|
286
|
+
_write_credentials({k: v for k, v in _read_credentials().items() if k != origin})
|
|
287
|
+
return "keyring"
|
|
288
|
+
except Exception:
|
|
289
|
+
pass # Locked/denied keyring: fall back to the file rather than losing the login.
|
|
290
|
+
creds = _read_credentials()
|
|
291
|
+
creds[origin] = entry
|
|
292
|
+
_write_credentials(creds)
|
|
293
|
+
return credentials_path()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def load_credential(url: str) -> Dict[str, Any]:
|
|
297
|
+
"""`{token?, jwt?, email?}` for an instance — empty when nothing is stored."""
|
|
298
|
+
origin = normalize_url(url)
|
|
299
|
+
kr = _keyring()
|
|
300
|
+
if kr is not None:
|
|
301
|
+
try:
|
|
302
|
+
value = kr.get_password(KEYRING_SERVICE, origin)
|
|
303
|
+
if value:
|
|
304
|
+
try:
|
|
305
|
+
data = json.loads(value)
|
|
306
|
+
if isinstance(data, dict):
|
|
307
|
+
return dict(data)
|
|
308
|
+
except ValueError:
|
|
309
|
+
# Pre-1.3 entries stored the bare token string. Read it rather than making a
|
|
310
|
+
# working login look like no login at all.
|
|
311
|
+
return {"token": value}
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
entry = _read_credentials().get(origin) or {}
|
|
315
|
+
return dict(entry) if isinstance(entry, dict) else {}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def save_token(url: str, token: str, use_keyring: bool = True) -> str:
|
|
319
|
+
"""Back-compat shim for the common case of storing just an API token."""
|
|
320
|
+
return save_credential(url, token=token, use_keyring=use_keyring)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def load_token(url: str) -> Optional[str]:
|
|
324
|
+
return load_credential(url).get("token") or None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def delete_token(url: str, kind: Optional[str] = None) -> bool:
|
|
328
|
+
"""Forget stored credentials for an instance. `kind` limits it to "token" or "jwt"."""
|
|
329
|
+
origin = normalize_url(url)
|
|
330
|
+
entry = load_credential(origin)
|
|
331
|
+
if not entry:
|
|
332
|
+
return False
|
|
333
|
+
if kind:
|
|
334
|
+
if entry.pop(kind, None) is None:
|
|
335
|
+
return False
|
|
336
|
+
if any(entry.get(k) for k in ("token", "jwt")):
|
|
337
|
+
_store_credential(origin, entry)
|
|
338
|
+
return True
|
|
339
|
+
# Nothing left worth keeping — fall through and remove the instance entirely.
|
|
340
|
+
|
|
341
|
+
removed = False
|
|
342
|
+
kr = _keyring()
|
|
343
|
+
if kr is not None:
|
|
344
|
+
try:
|
|
345
|
+
if kr.get_password(KEYRING_SERVICE, origin):
|
|
346
|
+
kr.delete_password(KEYRING_SERVICE, origin)
|
|
347
|
+
removed = True
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
creds = _read_credentials()
|
|
351
|
+
if creds.pop(origin, None) is not None:
|
|
352
|
+
_write_credentials(creds)
|
|
353
|
+
removed = True
|
|
354
|
+
return removed
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _json_dumps(data: Dict[str, Any]) -> str:
|
|
358
|
+
return json.dumps(data, sort_keys=True)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _read_credentials() -> Dict[str, Any]:
|
|
362
|
+
data = _read_json(credentials_path())
|
|
363
|
+
return dict(data.get("instances") or {})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _write_credentials(instances: Dict[str, Any]) -> None:
|
|
367
|
+
atomic_write(credentials_path(),
|
|
368
|
+
json.dumps({"instances": instances}, indent=2, sort_keys=True) + "\n",
|
|
369
|
+
mode=0o600, tighten_parent=True)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ── Resolution ───────────────────────────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
class Resolved(object):
|
|
375
|
+
"""The instance + credential one command will actually use, and where each came from.
|
|
376
|
+
|
|
377
|
+
`source_*` is not decoration: "why is it talking to the wrong server" is the commonest CLI
|
|
378
|
+
support question there is, and `geodeploy profile show` answers it by printing these.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
__slots__ = ("url", "token", "jwt", "profile", "source_url", "source_token", "email")
|
|
382
|
+
|
|
383
|
+
def __init__(self, url: Optional[str], token: Optional[str], profile: Optional[str],
|
|
384
|
+
source_url: str, source_token: str, email: Optional[str] = None,
|
|
385
|
+
jwt: Optional[str] = None):
|
|
386
|
+
self.url = url
|
|
387
|
+
self.token = token
|
|
388
|
+
#: A session JWT from `login --password`, used for the routes that refuse API tokens.
|
|
389
|
+
self.jwt = jwt
|
|
390
|
+
self.profile = profile
|
|
391
|
+
self.source_url = source_url
|
|
392
|
+
self.source_token = source_token
|
|
393
|
+
self.email = email
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def resolve(url: Optional[str] = None, token: Optional[str] = None,
|
|
397
|
+
profile: Optional[str] = None, config: Optional[Config] = None) -> Resolved:
|
|
398
|
+
"""Flags → environment → active profile, for the URL and the token independently.
|
|
399
|
+
|
|
400
|
+
Independently matters: `GEODEPLOY_TOKEN=… geodeploy layers list` against the profile's URL is a
|
|
401
|
+
normal thing to do, and so is `--url` at a staging instance using the token already stored
|
|
402
|
+
for it.
|
|
403
|
+
"""
|
|
404
|
+
cfg = config or Config.load()
|
|
405
|
+
name = cfg.resolve_name(profile)
|
|
406
|
+
entry = cfg.get(name) if name else None
|
|
407
|
+
|
|
408
|
+
if url:
|
|
409
|
+
final_url, src_url = normalize_url(url), "flag"
|
|
410
|
+
elif os.environ.get("GEODEPLOY_URL"):
|
|
411
|
+
final_url, src_url = normalize_url(os.environ["GEODEPLOY_URL"]), "env"
|
|
412
|
+
elif entry and entry.get("url"):
|
|
413
|
+
final_url, src_url = entry["url"], "profile:" + str(name)
|
|
414
|
+
else:
|
|
415
|
+
final_url, src_url = None, "none"
|
|
416
|
+
|
|
417
|
+
# An API TOKEN is the normal credential — scoped, revocable, and what the docs tell people to
|
|
418
|
+
# mint. A session JWT only appears when someone has run `login --password`, and is kept for the
|
|
419
|
+
# routes that refuse tokens on purpose (`/admin/*`, `/tokens`); it never displaces a token.
|
|
420
|
+
jwt = None
|
|
421
|
+
if token:
|
|
422
|
+
final_token, src_token = token, "flag"
|
|
423
|
+
elif os.environ.get("GEODEPLOY_TOKEN"):
|
|
424
|
+
final_token, src_token = os.environ["GEODEPLOY_TOKEN"], "env"
|
|
425
|
+
elif final_url:
|
|
426
|
+
stored = load_credential(final_url)
|
|
427
|
+
jwt = stored.get("jwt") or None
|
|
428
|
+
if stored.get("token"):
|
|
429
|
+
final_token, src_token = stored["token"], "stored"
|
|
430
|
+
elif jwt:
|
|
431
|
+
final_token, src_token = None, "stored session"
|
|
432
|
+
else:
|
|
433
|
+
final_token, src_token = None, "none"
|
|
434
|
+
else:
|
|
435
|
+
final_token, src_token = None, "none"
|
|
436
|
+
|
|
437
|
+
return Resolved(final_url, final_token, name, src_url, src_token,
|
|
438
|
+
(entry or {}).get("email"), jwt)
|
geodeploy/errors.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Typed errors.
|
|
2
|
+
|
|
3
|
+
Every failure a caller can reasonably branch on gets its own class, because the alternative — one
|
|
4
|
+
exception carrying a status code — pushes `if err.status == 404` into every plugin and script that
|
|
5
|
+
uses this client. The QGIS plugin in particular needs to tell "your token is wrong" (stop and ask
|
|
6
|
+
the user) from "that layer is gone" (refresh the tree) from "the network blipped" (retry), and it
|
|
7
|
+
should not have to read status codes to do it.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GeoDeployError(Exception):
|
|
15
|
+
"""Base class — catching this catches everything this package raises."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(GeoDeployError):
|
|
19
|
+
"""Nothing usable to talk to: no instance URL, no credentials, unreadable profile file."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransportError(GeoDeployError):
|
|
23
|
+
"""The request never got an HTTP answer — DNS, TLS, connection reset, timeout.
|
|
24
|
+
|
|
25
|
+
Distinct from every `APIError` below: the server may or may not have done the work, so a caller
|
|
26
|
+
retrying this must think about idempotency, which is never true of a 4xx.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class APIError(GeoDeployError):
|
|
31
|
+
"""The instance answered, with a status we did not want."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, status: int, detail: str, url: str = "", payload: Any = None):
|
|
34
|
+
self.status = status
|
|
35
|
+
self.detail = detail
|
|
36
|
+
self.url = url
|
|
37
|
+
#: The decoded JSON body when there was one — FastAPI validation errors put the useful
|
|
38
|
+
#: part (which field, why) in a list here that no single-line message can carry.
|
|
39
|
+
self.payload = payload
|
|
40
|
+
super().__init__(f"HTTP {status}: {detail}" if detail else f"HTTP {status}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthError(APIError):
|
|
44
|
+
"""401 — no credentials, an expired session, or a revoked/expired API token."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PermissionError_(APIError):
|
|
48
|
+
"""403 — authenticated, but not allowed.
|
|
49
|
+
|
|
50
|
+
Two distinguishable causes, and the message says which: the caller's ROLE is too low, or the
|
|
51
|
+
API TOKEN lacks a scope ("Token missing scope: data:write"). The second is fixable by minting a
|
|
52
|
+
better token, the first is not — see `missing_scope`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def missing_scope(self) -> Optional[str]:
|
|
57
|
+
marker = "Token missing scope:"
|
|
58
|
+
if marker in (self.detail or ""):
|
|
59
|
+
return self.detail.split(marker, 1)[1].strip() or None
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NotFoundError(APIError):
|
|
64
|
+
"""404 — including "exists but you cannot see it": a private resource is hidden as absent."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConflictError(APIError):
|
|
68
|
+
"""409 — the state moved under you (a backup already running, an email already registered)."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ValidationError(APIError):
|
|
72
|
+
"""400 / 413 / 422 — the request itself was wrong: bad geometry columns, oversized file."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ServerError(APIError):
|
|
76
|
+
"""5xx — the instance failed. Worth retrying; not worth reformulating the request."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def from_status(status: int, detail: str, url: str = "", payload: Any = None) -> APIError:
|
|
80
|
+
"""Map an HTTP status onto the class above that a caller can act on."""
|
|
81
|
+
if status == 401:
|
|
82
|
+
return AuthError(status, detail, url, payload)
|
|
83
|
+
if status == 403:
|
|
84
|
+
return PermissionError_(status, detail, url, payload)
|
|
85
|
+
if status == 404:
|
|
86
|
+
return NotFoundError(status, detail, url, payload)
|
|
87
|
+
if status == 409:
|
|
88
|
+
return ConflictError(status, detail, url, payload)
|
|
89
|
+
if status in (400, 413, 422) or status == 415:
|
|
90
|
+
return ValidationError(status, detail, url, payload)
|
|
91
|
+
if status >= 500:
|
|
92
|
+
return ServerError(status, detail, url, payload)
|
|
93
|
+
return APIError(status, detail, url, payload)
|
geodeploy/imports.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Register data that is ALREADY there — in the database, or in the bucket.
|
|
2
|
+
|
|
3
|
+
"Import existing" is not an upload: nothing is copied and nothing is destroyed. A PostGIS table is
|
|
4
|
+
introspected and registered; a `.parquet`/`.tif`/`.csv` already in object storage is attached where
|
|
5
|
+
it lies. For GeoParquet the spatial prep writes its partitioned copy under `vectors/` and leaves the
|
|
6
|
+
source key alone — which is why `source_s3_key` exists, so re-scanning still recognises the file as
|
|
7
|
+
already imported after the prep repoints the layer.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from .errors import ValidationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Imports(object):
|
|
17
|
+
def __init__(self, client: Any):
|
|
18
|
+
self._c = client
|
|
19
|
+
|
|
20
|
+
# ── PostGIS ─────────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def database_tables(self) -> List[Dict[str, Any]]:
|
|
23
|
+
"""Spatial tables visible to the instance, each flagged if it is already registered."""
|
|
24
|
+
return self._c.get("/data/discover/database") or []
|
|
25
|
+
|
|
26
|
+
def database(self, tables: List[Dict[str, Any]]) -> Any:
|
|
27
|
+
"""Register tables. Each item needs `schema_name`, `table_name`, `geometry_column`;
|
|
28
|
+
`srid`, `geometry_type` and a `name` override are optional."""
|
|
29
|
+
if not tables:
|
|
30
|
+
raise ValidationError(400, "No tables selected.")
|
|
31
|
+
return self._c.post("/data/discover/database", {"tables": tables})
|
|
32
|
+
|
|
33
|
+
# ── Object storage ──────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
def storage_objects(self, kind: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
36
|
+
"""`.tif` (raster), `.parquet`/`.geoparquet` (geoparquet) and `.csv` files in the bucket."""
|
|
37
|
+
rows = self._c.get("/data/discover/storage") or []
|
|
38
|
+
if kind:
|
|
39
|
+
rows = [r for r in rows if (r.get("kind") or "") == kind]
|
|
40
|
+
return rows
|
|
41
|
+
|
|
42
|
+
def storage(self, items: List[Dict[str, Any]]) -> Any:
|
|
43
|
+
"""Attach objects by key. GeoParquet items come back with `jobs` to poll (inspect + prep);
|
|
44
|
+
rasters are registered from their header alone and need no job."""
|
|
45
|
+
if not items:
|
|
46
|
+
raise ValidationError(400, "No objects selected.")
|
|
47
|
+
return self._c.post("/data/discover/storage", {"items": items})
|
|
48
|
+
|
|
49
|
+
def csv_columns(self, key: str, delimiter: str = "comma") -> Any:
|
|
50
|
+
"""The header of a CSV in the bucket, so geometry columns can be chosen before importing."""
|
|
51
|
+
return self._c.get("/data/discover/storage/csv-columns",
|
|
52
|
+
{"key": key, "delimiter": delimiter})
|
|
53
|
+
|
|
54
|
+
def csv(self, key: str, name: Optional[str] = None, x_column: Optional[str] = None,
|
|
55
|
+
y_column: Optional[str] = None, wkt_column: Optional[str] = None, srid: int = 4326,
|
|
56
|
+
delimiter: str = "comma") -> Dict[str, Any]:
|
|
57
|
+
"""Build a PostGIS layer from a CSV already in storage (queued; returns a job to poll)."""
|
|
58
|
+
if not wkt_column and not (x_column and y_column):
|
|
59
|
+
raise ValidationError(400, "Pick X and Y columns, or a WKT column.")
|
|
60
|
+
body = {"key": key, "srid": srid, "delimiter": delimiter}
|
|
61
|
+
for field, value in (("name", name), ("x_column", x_column), ("y_column", y_column),
|
|
62
|
+
("wkt_column", wkt_column)):
|
|
63
|
+
if value is not None:
|
|
64
|
+
body[field] = value
|
|
65
|
+
return self._c.post("/data/discover/storage/csv", body)
|
geodeploy/jobs.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Ingest jobs — status, and waiting for one to finish.
|
|
2
|
+
|
|
3
|
+
Every heavy operation in GeoDeploy is queued to Celery and answers 202 with a job id: uploads,
|
|
4
|
+
conversions, PMTiles tiling, CSV imports, re-processing. So "did my upload work?" is always this
|
|
5
|
+
module, and a CLI that did not wait would report success for work that has not started.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Callable, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from .errors import GeoDeployError
|
|
13
|
+
|
|
14
|
+
#: Terminal job states. `ready` and `completed` are both used by the API depending on the pipeline,
|
|
15
|
+
#: and a client that knows only one of them polls forever on the other.
|
|
16
|
+
DONE = ("ready", "completed", "done")
|
|
17
|
+
FAILED = ("failed", "error")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JobFailed(GeoDeployError):
|
|
21
|
+
"""A job reached a terminal FAILED state. Carries the last status payload for the message."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, job: Dict[str, Any]):
|
|
24
|
+
self.job = job or {}
|
|
25
|
+
message = self.job.get("error_message") or self.job.get("current_step") or "Job failed"
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JobTimeout(GeoDeployError):
|
|
30
|
+
"""`wait` gave up. The job is still running server-side — this is a client-side deadline."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, job: Dict[str, Any], seconds: float):
|
|
33
|
+
self.job = job or {}
|
|
34
|
+
super().__init__("Still {0} after {1:.0f}s (job {2}). It keeps running on the server; "
|
|
35
|
+
"check with `geodeploy jobs show`."
|
|
36
|
+
.format(self.job.get("status") or "running", seconds,
|
|
37
|
+
self.job.get("id")))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Jobs(object):
|
|
41
|
+
def __init__(self, client: Any):
|
|
42
|
+
self._c = client
|
|
43
|
+
|
|
44
|
+
def get(self, job_id: str, layer_type: str = "vector") -> Dict[str, Any]:
|
|
45
|
+
return self._c.get("/data/{0}/jobs/{1}".format(
|
|
46
|
+
"raster" if layer_type == "raster" else "vector", job_id))
|
|
47
|
+
|
|
48
|
+
def wait(self, job_id: str, layer_type: str = "vector", interval: float = 2.0,
|
|
49
|
+
timeout: Optional[float] = 3600.0,
|
|
50
|
+
on_progress: Optional[Callable[[Dict[str, Any]], None]] = None) -> Dict[str, Any]:
|
|
51
|
+
"""Poll until the job finishes. Returns the final status; raises `JobFailed` if it failed.
|
|
52
|
+
|
|
53
|
+
`on_progress` is called only when the printable state CHANGES (percentage or step), not on
|
|
54
|
+
every poll — the difference between a readable log and 600 identical lines for a long
|
|
55
|
+
conversion.
|
|
56
|
+
"""
|
|
57
|
+
started = time.time()
|
|
58
|
+
last_seen = None
|
|
59
|
+
status = {} # type: Dict[str, Any]
|
|
60
|
+
while True:
|
|
61
|
+
status = self.get(job_id, layer_type) or {}
|
|
62
|
+
marker = (status.get("progress"), status.get("current_step"), status.get("status"))
|
|
63
|
+
if on_progress and marker != last_seen:
|
|
64
|
+
on_progress(status)
|
|
65
|
+
last_seen = marker
|
|
66
|
+
state = (status.get("status") or "").lower()
|
|
67
|
+
if state in FAILED:
|
|
68
|
+
raise JobFailed(status)
|
|
69
|
+
if state in DONE:
|
|
70
|
+
return status
|
|
71
|
+
if timeout is not None and (time.time() - started) > timeout:
|
|
72
|
+
raise JobTimeout(status, time.time() - started)
|
|
73
|
+
time.sleep(interval)
|