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