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/layers.py ADDED
@@ -0,0 +1,433 @@
1
+ """Data layers — vector, raster, and the resolver that lets you name one.
2
+
3
+ The API addresses a layer three ways and they are not interchangeable: authenticated routes take
4
+ the integer **id**, public routes take the stable **uid** (`models.new_uid`, 12 hex chars — an
5
+ integer is unique only within one layer kind and one database, so a shared URL must never use
6
+ one), and a person thinks in **names**. `Layers.resolve` accepts all three plus a `vector-3` /
7
+ `raster-7` prefix, so every command in the CLI can take whatever the user has to hand.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from .errors import NotFoundError, ValidationError
14
+
15
+ #: Layer fields worth showing in a list, in the order a table should print them.
16
+ SUMMARY_FIELDS = ("id", "uid", "name", "status", "geometry_type", "feature_count",
17
+ "crs", "storage_backend", "visibility", "created_by")
18
+
19
+
20
+ class _LayerBase(object):
21
+ """Shared implementation for the two layer kinds — the routes are deliberately parallel."""
22
+
23
+ kind = "" # "vector" | "raster"
24
+ base = "" # "/data/vector" | "/data/raster"
25
+
26
+ def __init__(self, client: Any):
27
+ self._c = client
28
+
29
+ # -- read ------------------------------------------------------------------------------------
30
+
31
+ def list(self, status: Optional[str] = None, query: Optional[str] = None,
32
+ visibility: Optional[str] = None) -> List[Dict[str, Any]]:
33
+ """Every layer of this kind that the caller can see.
34
+
35
+ Filtering is client-side because the API has no filter params on the list endpoints (and
36
+ `routers/README.md` explicitly asks that no `?created_by=` be added). At the scale a list
37
+ endpoint is still un-paginated, filtering here is honest and instant.
38
+ """
39
+ rows = self._c.get(self.base) or []
40
+ if status:
41
+ rows = [r for r in rows if (r.get("status") or "") == status]
42
+ if visibility:
43
+ rows = [r for r in rows if (r.get("visibility") or "") == visibility]
44
+ if query:
45
+ needle = query.lower()
46
+ rows = [r for r in rows
47
+ if needle in (r.get("name") or "").lower()
48
+ or needle in (r.get("abstract") or "").lower()
49
+ or needle in (r.get("keywords") or "").lower()]
50
+ return rows
51
+
52
+ def get(self, layer_id: Any) -> Dict[str, Any]:
53
+ """One layer. There is no by-id authed GET, so this reads the list — the same data the
54
+ dashboard shows, including live `progress`/`current_step` for a layer still ingesting."""
55
+ wanted = str(layer_id)
56
+ for row in self.list():
57
+ if str(row.get("id")) == wanted or str(row.get("uid") or "") == wanted:
58
+ return row
59
+ raise NotFoundError(404, "No {0} layer {1}.".format(self.kind, layer_id))
60
+
61
+ def usage(self, layer_id: Any) -> List[Dict[str, Any]]:
62
+ """The portals that include this layer — what the UI shows before a delete."""
63
+ return self._c.get("{0}/{1}/usage".format(self.base, int(layer_id))) or []
64
+
65
+ def links(self, layer_id: Any) -> Dict[str, Any]:
66
+ """Tool-labelled share URLs (OGC API - Features, WMTS, TileJSON, PMTiles, COG, …).
67
+
68
+ The server decides which artifact suits which backend, so this is always current — the CLI
69
+ must not build these URLs itself.
70
+ """
71
+ return self._c.get("{0}/{1}/links".format(self.base, int(layer_id)))
72
+
73
+ # -- write -----------------------------------------------------------------------------------
74
+
75
+ def rename(self, layer_id: Any, name: str) -> Dict[str, Any]:
76
+ return self._c.put("{0}/{1}/rename".format(self.base, int(layer_id)), {"name": name})
77
+
78
+ def share(self, layer_id: Any, visibility: Optional[str] = None, abstract: Optional[str] = None,
79
+ license: Optional[str] = None, attribution: Optional[str] = None,
80
+ keywords: Optional[str] = None) -> Dict[str, Any]:
81
+ """Visibility + catalog metadata. Partial: only what you pass is applied.
82
+
83
+ `visibility="public"` is the opt-IN that puts a layer in the STAC catalog and OGC API -
84
+ Features collections and makes its raw asset readable — nothing is public by default.
85
+ """
86
+ body = {}
87
+ for key, value in (("visibility", visibility), ("abstract", abstract), ("license", license),
88
+ ("attribution", attribution), ("keywords", keywords)):
89
+ if value is not None:
90
+ body[key] = value
91
+ if not body:
92
+ raise ValidationError(400, "Nothing to change — pass a visibility or a metadata field.")
93
+ return self._c.put("{0}/{1}/sharing".format(self.base, int(layer_id)), body)
94
+
95
+ def set_default_style(self, layer_id: Any, style: Dict[str, Any]) -> Dict[str, Any]:
96
+ """The layer's own default styling — what a portal starts from when the layer is added."""
97
+ return self._c.put("{0}/{1}/default-style".format(self.base, int(layer_id)), style)
98
+
99
+ def delete(self, layer_id: Any) -> Any:
100
+ """Delete the layer AND prune it from every portal that used it (the API re-publishes the
101
+ published ones, so no ghost layer is left on a live map)."""
102
+ return self._c.delete("{0}/{1}".format(self.base, int(layer_id)))
103
+
104
+ # -- download --------------------------------------------------------------------------------
105
+
106
+ def export(self, ref: Any, format: Optional[str] = None, bbox: Optional[str] = None,
107
+ target_crs: str = "4326") -> Dict[str, Any]:
108
+ """Queue a file export of this layer. No bbox = the whole layer.
109
+
110
+ Public on the same terms as the layer's other artifacts, so this works with no token for a
111
+ shared layer — which is the point: it is how an outside client downloads a PostGIS layer,
112
+ the one backend that has no file to fetch directly.
113
+ """
114
+ body = {"format": format, "bbox": bbox, "target_crs": target_crs}
115
+ return self._c.post("{0}/{1}/export".format(self.base, ref),
116
+ {k: v for k, v in body.items() if v is not None}, auth=False)
117
+
118
+ def export_status(self, ref: Any, job_id: str) -> Dict[str, Any]:
119
+ return self._c.get("{0}/{1}/export-status/{2}".format(self.base, ref, job_id), auth=False)
120
+
121
+ def export_download(self, ref: Any, job_id: str, sink) -> Any:
122
+ return self._c.download("{0}/{1}/export-download/{2}".format(self.base, ref, job_id),
123
+ sink, auth=False)
124
+
125
+ def export_to_file(self, ref: Any, path: str, format: Optional[str] = None,
126
+ bbox: Optional[str] = None, target_crs: str = "4326",
127
+ interval: float = 2.0, timeout: Optional[float] = 1800.0,
128
+ on_status: Optional[Any] = None,
129
+ on_ready: Optional[Any] = None) -> str:
130
+ """Queue, wait, download. Returns the path written (a zip: an export may be several files).
131
+
132
+ Waiting is the caller's time either way — the work is a Celery job — so doing it here keeps
133
+ the common case to one line for both a script and a plugin.
134
+
135
+ `on_ready(status)` receives the final status document, whose `truncated` list names any
136
+ file that stopped at the server's row cap. A caller that ignores it gets a file that looks
137
+ complete and is not, so the CLI does not ignore it.
138
+ """
139
+ import time
140
+
141
+ from .errors import GeoDeployError
142
+
143
+ job_id = (self.export(ref, format, bbox, target_crs) or {}).get("job_id")
144
+ if not job_id:
145
+ raise GeoDeployError("The instance did not return an export job id.")
146
+ started = time.time()
147
+ while True:
148
+ status = self.export_status(ref, job_id) or {}
149
+ state = status.get("status")
150
+ if on_status:
151
+ on_status(state)
152
+ if state == "ready":
153
+ if on_ready:
154
+ on_ready(status)
155
+ break
156
+ if state in ("error", "failed"):
157
+ raise GeoDeployError("The export failed on the server.")
158
+ if timeout is not None and time.time() - started > timeout:
159
+ raise GeoDeployError(
160
+ "The export is still running after {0:.0f}s. It continues on the server; "
161
+ "poll export_status(job_id={1!r}).".format(time.time() - started, job_id))
162
+ time.sleep(interval)
163
+ with open(path, "wb") as fh:
164
+ self.export_download(ref, job_id, fh)
165
+ return path
166
+
167
+
168
+ class VectorLayers(_LayerBase):
169
+ kind = "vector"
170
+ base = "/data/vector"
171
+
172
+ def features(self, ref: Any, bbox: Optional[str] = None, limit: int = 50000,
173
+ public: bool = False) -> Dict[str, Any]:
174
+ """GeoJSON for a viewport. `public=True` uses the unauthenticated `.geojson` route (which
175
+ takes a uid) — useful for checking what a published portal actually serves."""
176
+ params = {"bbox": bbox, "limit": limit}
177
+ if public:
178
+ return self._c.get("/data/vector/{0}/features.geojson".format(ref), params, auth=False)
179
+ return self._c.get("/data/vector/{0}/features".format(int(ref)), params)
180
+
181
+ def identify(self, ref: Any, lng: float, lat: float, tol: float = 1e-4,
182
+ limit: int = 10) -> Any:
183
+ """Attributes of the features under a point — the same call a portal popup makes."""
184
+ return self._c.get("/data/vector/{0}/identify".format(ref),
185
+ {"lng": lng, "lat": lat, "tol": tol, "limit": limit}, auth=False)
186
+
187
+ def tilejson(self, ref: Any) -> Dict[str, Any]:
188
+ return self._c.get("/data/vector/{0}/tilejson".format(ref), auth=False)
189
+
190
+ def legend(self, ref: Any) -> Dict[str, Any]:
191
+ """The swatches and labels for this layer's default style, as the SERVER computes them.
192
+
193
+ `styles.Style.legend()` produces the same thing locally; this is the authoritative copy —
194
+ the portal draws from the same function — so anything that has to match a published map
195
+ should ask rather than derive.
196
+ """
197
+ return self._c.get("/data/vector/{0}/legend".format(ref), auth=False)
198
+
199
+ # -- the whole GeoParquet dataset, straight from storage ---------------------------------------
200
+
201
+ def parquet_manifest(self, ref: Any) -> Dict[str, Any]:
202
+ """The partition map of a prepared GeoParquet layer: grid, CRS, columns and file keys.
203
+
204
+ Raises `NotFoundError` when the layer has no partitioned dataset — a PostGIS layer, or a
205
+ single `.parquet` uploaded as-is. Callers treat that as "use the export job instead".
206
+ """
207
+ return self._c.get("/data/vector/{0}/parquet/manifest.json".format(ref), auth=False)
208
+
209
+ def parquet_parts(self, manifest: Dict[str, Any]) -> List[Dict[str, Any]]:
210
+ """Every partition file the manifest lists, flattened and in a stable order."""
211
+ parts: List[Dict[str, Any]] = []
212
+ for cell, entries in sorted((manifest.get("cells") or {}).items(),
213
+ key=lambda kv: (len(kv[0]), kv[0])):
214
+ for entry in entries or []:
215
+ key = entry.get("key")
216
+ if not key:
217
+ continue
218
+ if key.startswith("/") or ".." in key.split("/") or ":" in key:
219
+ # The key becomes a local path. The server is not hostile, but a path from
220
+ # over the network must not be able to name a file outside the target folder.
221
+ raise ValidationError(400, "Refusing a suspicious partition key: {0!r}".format(key))
222
+ parts.append({"cell": cell, "key": key, "rows": entry.get("rows")})
223
+ return parts
224
+
225
+ def download_dataset(self, ref: Any, directory: str,
226
+ on_file: Optional[Any] = None) -> Dict[str, Any]:
227
+ """Download a prepared GeoParquet layer WHOLE — manifest plus every partition file.
228
+
229
+ This is the complete, lossless, **uncapped** copy: the files are what the instance stores,
230
+ byte for byte, with no worker, no row limit and no format conversion. `layers export` is
231
+ the other path, and the one to use for a clip or another format; it builds a new file and
232
+ stops at the server's row cap, which for exactly these layers (the big ones — GeoParquet is
233
+ where the millions of features live) is a real ceiling.
234
+
235
+ The result reads directly in DuckDB or GDAL:
236
+
237
+ SELECT * FROM read_parquet('<directory>/**/*.parquet')
238
+ """
239
+ import json as _json
240
+ import os
241
+
242
+ manifest = self.parquet_manifest(ref)
243
+ parts = self.parquet_parts(manifest)
244
+ if not parts:
245
+ raise NotFoundError(404, "That GeoParquet dataset lists no partition files.")
246
+
247
+ os.makedirs(directory, exist_ok=True)
248
+ with open(os.path.join(directory, "manifest.json"), "w", encoding="utf-8") as fh:
249
+ _json.dump(manifest, fh, indent=1)
250
+
251
+ written, done_bytes = [], 0
252
+ for index, part in enumerate(parts):
253
+ dest = os.path.join(directory, *part["key"].split("/"))
254
+ parent = os.path.dirname(dest)
255
+ if parent:
256
+ os.makedirs(parent, exist_ok=True)
257
+ with open(dest, "wb") as fh:
258
+ self._c.download("/data/vector/{0}/parquet/{1}".format(ref, part["key"]),
259
+ fh, auth=False)
260
+ written.append(dest)
261
+ done_bytes += os.path.getsize(dest)
262
+ if on_file: # (files done, files total, this part, bytes so far)
263
+ on_file(index + 1, len(parts), part, done_bytes)
264
+ return {"directory": directory, "files": written, "parts": len(written),
265
+ "bytes": done_bytes, "rows": manifest.get("feature_count"),
266
+ "crs": manifest.get("crs")}
267
+
268
+ def field_stats(self, ref: Any, field: str, classes: int = 5, method: str = "quantile",
269
+ ramp: str = "viridis", reverse: bool = False) -> Dict[str, Any]:
270
+ """Distribution of ONE attribute plus a ready-made classification `suggestion`.
271
+
272
+ The classification maths lives on the server (`services/symbology.py`) and is shared with
273
+ the editor and the published portal. The CLI asks for the suggestion rather than computing
274
+ breaks itself, so a CLI-styled layer lands in exactly the classes the editor would show —
275
+ two implementations of quantile breaks would eventually disagree, and the disagreement
276
+ would only be visible on a published map.
277
+ """
278
+ params = {"field": field, "classes": classes, "method": method, "ramp": ramp}
279
+ if reverse:
280
+ # Only when asked: an older instance has no `reverse` parameter, and sending
281
+ # `reverse=false` to it would be a pointless difference in every request.
282
+ params["reverse"] = "true"
283
+ return self._c.get("/data/vector/{0}/field-stats".format(ref), params)
284
+
285
+ def tile(self, layer_id: Any) -> Dict[str, Any]:
286
+ """(Re)generate the layer's PMTiles archive — the fallback display path for heavy layers."""
287
+ return self._c.post("/data/vector/{0}/tile".format(int(layer_id)))
288
+
289
+ def prepare(self, layer_id: Any) -> Dict[str, Any]:
290
+ """Re-run the GeoParquet spatial prep (partitioning + covering column)."""
291
+ return self._c.post("/data/vector/{0}/prepare".format(int(layer_id)))
292
+
293
+ def reprocess(self, layer_id: Any) -> Dict[str, Any]:
294
+ """Restart a stalled/failed layer's background processing without re-uploading it.
295
+
296
+ The usual cause is the worker being recreated mid-convert, which leaves the layer stuck at
297
+ whatever percentage it had reached.
298
+ """
299
+ return self._c.post("/data/vector/{0}/reprocess".format(int(layer_id)))
300
+
301
+
302
+ class RasterLayers(_LayerBase):
303
+ kind = "raster"
304
+ base = "/data/raster"
305
+
306
+ def stats(self, layer_id: Any) -> Dict[str, Any]:
307
+ """TiTiler statistics plus a suggested 2–98 % `rescale` — the auto-stretch the UI offers."""
308
+ return self._c.get("/data/raster/{0}/stats".format(int(layer_id)))
309
+
310
+ def colormaps(self) -> List[str]:
311
+ return self._c.get("/data/raster/colormaps")
312
+
313
+ def tilejson(self, ref: Any) -> Dict[str, Any]:
314
+ return self._c.get("/data/raster/{0}/tilejson".format(ref), auth=False)
315
+
316
+ def legend(self, ref: Any) -> Dict[str, Any]:
317
+ """A raster legend is a continuous RAMP, so this returns the ingredients to draw one —
318
+ `colormap`, `rescale`, `algorithm`, `bidx` — not a list of swatches."""
319
+ return self._c.get("/data/raster/{0}/legend".format(ref), auth=False)
320
+
321
+ def wmts(self, ref: Any) -> str:
322
+ """The WMTS capabilities document — the URL to paste into QGIS, because it is the only one
323
+ of our raster surfaces that carries an extent, so *Zoom to Layer* works."""
324
+ return self._c.get("/data/raster/{0}/wmts".format(ref), auth=False, parse=False).text
325
+
326
+
327
+ class Layers(object):
328
+ """Kind-agnostic helpers: resolve a reference, list both kinds, delete whatever it is."""
329
+
330
+ def __init__(self, client: Any):
331
+ self._c = client
332
+
333
+ def list(self, kind: Optional[str] = None, **kw: Any) -> List[Dict[str, Any]]:
334
+ out = [] # type: List[Dict[str, Any]]
335
+ if kind in (None, "all", "vector"):
336
+ out += [dict(r, layer_type="vector") for r in self._c.vector.list(**kw)]
337
+ if kind in (None, "all", "raster"):
338
+ out += [dict(r, layer_type="raster") for r in self._c.raster.list(**kw)]
339
+ return out
340
+
341
+ def resolve(self, ref: Any, kind: Optional[str] = None) -> Dict[str, Any]:
342
+ """Find a layer from an id, a uid, a `vector-3` style reference, or a name.
343
+
344
+ Name matching is exact first, then case-insensitively, then as a unique substring. An
345
+ ambiguous name raises rather than guessing — picking one of two layers called "roads" and
346
+ publishing it is not a mistake the user can see.
347
+ """
348
+ text = str(ref).strip()
349
+ if kind is None:
350
+ for prefix in ("vector-", "raster-"):
351
+ if text.lower().startswith(prefix):
352
+ kind, text = prefix[:-1], text[len(prefix):]
353
+ break
354
+ rows = self.list(kind)
355
+
356
+ by_uid = [r for r in rows if str(r.get("uid") or "") == text]
357
+ if by_uid:
358
+ return by_uid[0] # uids are unique across both kinds
359
+ by_id = [r for r in rows if str(r.get("id")) == text]
360
+ if len(by_id) == 1:
361
+ return by_id[0]
362
+ if len(by_id) > 1:
363
+ # Vector and raster ids are two separate sequences, so "1" can be two different
364
+ # layers. Returning whichever the listing happened to put first is how you download a
365
+ # vector while asking for a raster.
366
+ raise ValidationError(
367
+ 400, "Layer id {0} is ambiguous — vector and raster layers are numbered "
368
+ "separately. Use {1}, or the layer's uid.".format(
369
+ text, " or ".join(sorted(
370
+ "{0}-{1}".format(r.get("layer_type"), r.get("id")) for r in by_id))))
371
+ exact = [r for r in rows if (r.get("name") or "") == text]
372
+ if len(exact) == 1:
373
+ return exact[0]
374
+ ci = [r for r in rows if (r.get("name") or "").lower() == text.lower()]
375
+ if len(ci) == 1:
376
+ return ci[0]
377
+ partial = [r for r in rows if text.lower() in (r.get("name") or "").lower()]
378
+ if len(partial) == 1:
379
+ return partial[0]
380
+
381
+ candidates = exact or ci or partial
382
+ if len(candidates) > 1:
383
+ names = ", ".join("{0} (id {1}, {2})".format(r.get("name"), r.get("id"),
384
+ r.get("layer_type"))
385
+ for r in candidates[:8])
386
+ raise ValidationError(400, "{0!r} matches several layers: {1}. Use the id.".format(
387
+ ref, names))
388
+ raise NotFoundError(404, "No layer matching {0!r}.".format(ref))
389
+
390
+ def resolve_public(self, ref: Any, kind: Optional[str] = None) -> Dict[str, Any]:
391
+ """Resolve a layer WITHOUT a credential, from the instance's public index.
392
+
393
+ Without this, an anonymous client could only ever address a layer by its opaque uid — but
394
+ the public artifacts are readable by anyone, so "download the layer called roads" should
395
+ work for anyone too. Only public layers are visible here, which is the correct limit.
396
+ """
397
+ text = str(ref).strip()
398
+ index = self._c.catalog.public() or {}
399
+ rows = []
400
+ for group, entries in (index.get("layers") or {}).items():
401
+ layer_type = "raster" if group == "raster" else "vector"
402
+ if kind and kind != layer_type:
403
+ continue
404
+ for entry in entries:
405
+ rows.append(dict(entry, layer_type=layer_type, uid=entry.get("id")))
406
+
407
+ for row in rows:
408
+ if str(row.get("id")) == text:
409
+ return row
410
+ matches = [r for r in rows if (r.get("name") or "").lower() == text.lower()]
411
+ if len(matches) == 1:
412
+ return matches[0]
413
+ partial = [r for r in rows if text.lower() in (r.get("name") or "").lower()]
414
+ if len(partial) == 1:
415
+ return partial[0]
416
+ if len(matches or partial) > 1:
417
+ raise ValidationError(400, "{0!r} matches several public layers; use the id.".format(ref))
418
+ raise NotFoundError(
419
+ 404, "No PUBLIC layer matching {0!r}. Only shared layers are visible without a "
420
+ "token — log in to reach the rest.".format(ref))
421
+
422
+ def api(self, layer_type: str):
423
+ """The namespace for a layer kind — lets kind-agnostic code stay short."""
424
+ if layer_type == "raster":
425
+ return self._c.raster
426
+ if layer_type == "vector":
427
+ return self._c.vector
428
+ raise ValidationError(400, "Unknown layer type {0!r}.".format(layer_type))
429
+
430
+ def delete(self, ref: Any, kind: Optional[str] = None) -> Dict[str, Any]:
431
+ layer = self.resolve(ref, kind)
432
+ self.api(layer["layer_type"]).delete(layer["id"])
433
+ return layer