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/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)