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
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""`geodeploy layers …` — the data on an instance.
|
|
2
|
+
|
|
3
|
+
A layer can be named by id, uid or name everywhere, because typing `roads` is what a person does
|
|
4
|
+
and looking up `7` is what a machine does.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from ...errors import GeoDeployError, NotFoundError, ValidationError
|
|
13
|
+
from ..main import add_command, group_parser
|
|
14
|
+
from ..output import EXIT_GENERIC, EXIT_OK, human_size
|
|
15
|
+
from ._common import (add_style_args, confirm, layer_ref_arg, parse_fields, resolve_layer,
|
|
16
|
+
style_from_args, write_json_file)
|
|
17
|
+
|
|
18
|
+
# `uid` earns its width: it is the identifier every public URL uses, it is unique across both
|
|
19
|
+
# layer kinds (integer ids are two separate sequences), and it survives a rename or a restore that
|
|
20
|
+
# renumbers everything. A listing that shows only the integer teaches people to script with the one
|
|
21
|
+
# handle that is none of those things.
|
|
22
|
+
LIST_COLUMNS = ["layer_type", "id", "uid", "name", "status", "geometry_type", "feature_count",
|
|
23
|
+
"storage_backend", "visibility"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register(subparsers) -> None:
|
|
27
|
+
group = group_parser(subparsers, "layers", "list, inspect, style, share and delete data layers",
|
|
28
|
+
aliases=["data"])
|
|
29
|
+
|
|
30
|
+
listing = add_command(group, "list", cmd_list, "list layers", aliases=["ls"])
|
|
31
|
+
listing.add_argument("--type", dest="kind", choices=["vector", "raster", "all"], default="all")
|
|
32
|
+
listing.add_argument("--status", help="only layers in this state (ready, processing, error)")
|
|
33
|
+
listing.add_argument("--visibility", choices=["private", "organization", "public"])
|
|
34
|
+
listing.add_argument("--query", dest="search",
|
|
35
|
+
help="match name, abstract or keywords")
|
|
36
|
+
|
|
37
|
+
show = add_command(group, "show", cmd_show, "everything known about one layer")
|
|
38
|
+
layer_ref_arg(show)
|
|
39
|
+
|
|
40
|
+
rename = add_command(group, "rename", cmd_rename, "change a layer's display name")
|
|
41
|
+
layer_ref_arg(rename)
|
|
42
|
+
rename.add_argument("name")
|
|
43
|
+
|
|
44
|
+
share = add_command(group, "share", cmd_share,
|
|
45
|
+
"set visibility and catalog metadata (public = STAC + OGC + raw asset)")
|
|
46
|
+
layer_ref_arg(share)
|
|
47
|
+
share.add_argument("--visibility", choices=["private", "organization", "public"])
|
|
48
|
+
share.add_argument("--abstract")
|
|
49
|
+
share.add_argument("--license")
|
|
50
|
+
share.add_argument("--attribution")
|
|
51
|
+
share.add_argument("--keywords", help="comma-separated")
|
|
52
|
+
|
|
53
|
+
style = add_command(group, "style", cmd_style,
|
|
54
|
+
"set a layer's DEFAULT styling — what a portal starts from",
|
|
55
|
+
epilog="""\
|
|
56
|
+
examples:
|
|
57
|
+
geodeploy layers style roads --color '#e11d48' --line-width 2
|
|
58
|
+
geodeploy layers style sites --marker star --radius 6 --outline-color none
|
|
59
|
+
geodeploy layers style parcels --color-field pop --classify jenks --classes 6 --ramp magma
|
|
60
|
+
geodeploy layers style dem --colormap terrain --rescale 0,2400
|
|
61
|
+
""")
|
|
62
|
+
layer_ref_arg(style)
|
|
63
|
+
style.add_argument("--popup-fields", help="comma-separated attribute names for popups")
|
|
64
|
+
style.add_argument("--replace", action="store_true",
|
|
65
|
+
help="replace the whole style instead of merging into it")
|
|
66
|
+
add_style_args(style)
|
|
67
|
+
|
|
68
|
+
fields = add_command(group, "fields", cmd_fields, "the attribute columns of a vector layer")
|
|
69
|
+
layer_ref_arg(fields)
|
|
70
|
+
|
|
71
|
+
stats = add_command(group, "stats", cmd_stats,
|
|
72
|
+
"distribution of one attribute (vector) or band statistics (raster)")
|
|
73
|
+
layer_ref_arg(stats)
|
|
74
|
+
stats.add_argument("--field", help="attribute to summarise (vector layers)")
|
|
75
|
+
stats.add_argument("--classes", type=int, default=5)
|
|
76
|
+
stats.add_argument("--method", choices=["quantile", "equal", "jenks"], default="quantile")
|
|
77
|
+
stats.add_argument("--ramp", default="viridis")
|
|
78
|
+
|
|
79
|
+
legend = add_command(group, "legend", cmd_legend,
|
|
80
|
+
"the swatches and labels this layer's style produces",
|
|
81
|
+
epilog="""\
|
|
82
|
+
The legend is computed by the INSTANCE, from the same function the portal and the About page use,
|
|
83
|
+
so what you see here is what a published map shows. A raster answers with its colour ramp and the
|
|
84
|
+
value range it is stretched over, since a continuous ramp has no swatches.
|
|
85
|
+
""")
|
|
86
|
+
layer_ref_arg(legend)
|
|
87
|
+
|
|
88
|
+
links = add_command(group, "links", cmd_links,
|
|
89
|
+
"share URLs for this layer, labelled by the tool each one suits")
|
|
90
|
+
layer_ref_arg(links)
|
|
91
|
+
|
|
92
|
+
usage = add_command(group, "usage", cmd_usage, "which portals include this layer")
|
|
93
|
+
layer_ref_arg(usage)
|
|
94
|
+
|
|
95
|
+
features = add_command(group, "features", cmd_features, "GeoJSON features from a vector layer")
|
|
96
|
+
layer_ref_arg(features)
|
|
97
|
+
features.add_argument("--bbox", help="minx,miny,maxx,maxy in EPSG:4326")
|
|
98
|
+
features.add_argument("--limit", type=int, default=1000)
|
|
99
|
+
features.add_argument("-o", "--output", help="write to a file (UTF-8) instead of stdout")
|
|
100
|
+
|
|
101
|
+
identify = add_command(group, "identify", cmd_identify,
|
|
102
|
+
"attributes of the features under a point")
|
|
103
|
+
layer_ref_arg(identify)
|
|
104
|
+
identify.add_argument("lng", type=float)
|
|
105
|
+
identify.add_argument("lat", type=float)
|
|
106
|
+
identify.add_argument("--tolerance", type=float, default=1e-4)
|
|
107
|
+
identify.add_argument("--limit", type=int, default=10)
|
|
108
|
+
|
|
109
|
+
download = add_command(group, "download", cmd_download, "download a layer as a file",
|
|
110
|
+
epilog="""examples:
|
|
111
|
+
geodeploy layers download roads the whole layer, GeoPackage
|
|
112
|
+
geodeploy layers download roads --format csv
|
|
113
|
+
geodeploy layers download parcels a GeoParquet layer: its own files, uncapped
|
|
114
|
+
geodeploy layers download roads --bbox 11,55,12,56 just that area
|
|
115
|
+
geodeploy layers download dem the Cloud-Optimized GeoTIFF itself
|
|
116
|
+
|
|
117
|
+
cog, pmtiles and a whole GeoParquet layer are the STORED files, streamed as they are: complete,
|
|
118
|
+
lossless and not subject to any row cap. gpkg / csv / geojson (and any clipped export) are BUILT by
|
|
119
|
+
the instance as a job and arrive as a zip; those stop at the server's row cap, which the command
|
|
120
|
+
reports when it happens.
|
|
121
|
+
""")
|
|
122
|
+
layer_ref_arg(download)
|
|
123
|
+
download.add_argument("-o", "--output", help="output path (default: the layer name)")
|
|
124
|
+
download.add_argument("--format",
|
|
125
|
+
choices=["auto", "gpkg", "csv", "geojson", "geoparquet", "cog",
|
|
126
|
+
"pmtiles"],
|
|
127
|
+
default="auto",
|
|
128
|
+
help="auto = GeoPackage for a vector layer, the COG for a raster")
|
|
129
|
+
download.add_argument("--bbox", help="minx,miny,maxx,maxy in EPSG:4326 (default: everything)")
|
|
130
|
+
download.add_argument("--crs", choices=["4326", "native"], default="4326",
|
|
131
|
+
help="native keeps the layer's own CRS where the format can carry it")
|
|
132
|
+
|
|
133
|
+
tile = add_command(group, "tile", cmd_tile, "(re)generate a vector layer's PMTiles archive")
|
|
134
|
+
layer_ref_arg(tile)
|
|
135
|
+
tile.add_argument("--wait", action="store_true")
|
|
136
|
+
|
|
137
|
+
prepare = add_command(group, "prepare", cmd_prepare, "re-run GeoParquet spatial preparation")
|
|
138
|
+
layer_ref_arg(prepare)
|
|
139
|
+
prepare.add_argument("--wait", action="store_true")
|
|
140
|
+
|
|
141
|
+
reprocess = add_command(group, "reprocess", cmd_reprocess,
|
|
142
|
+
"restart a stalled or failed layer without re-uploading it")
|
|
143
|
+
layer_ref_arg(reprocess)
|
|
144
|
+
reprocess.add_argument("--wait", action="store_true")
|
|
145
|
+
|
|
146
|
+
delete = add_command(group, "delete", cmd_delete,
|
|
147
|
+
"delete a layer (and remove it from every portal that used it)",
|
|
148
|
+
aliases=["rm"])
|
|
149
|
+
layer_ref_arg(delete)
|
|
150
|
+
delete.add_argument("--yes", action="store_true", help="skip the confirmation")
|
|
151
|
+
|
|
152
|
+
add_command(group, "colormaps", cmd_colormaps, "raster colormaps this instance offers")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ── read ─────────────────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def cmd_list(ctx, args) -> int:
|
|
158
|
+
rows = ctx.client().layers.list(args.kind, status=args.status, query=args.search,
|
|
159
|
+
visibility=args.visibility)
|
|
160
|
+
if not ctx.out.json_mode:
|
|
161
|
+
for row in rows:
|
|
162
|
+
if row.get("status") == "processing" and row.get("progress") is not None:
|
|
163
|
+
row["status"] = "{0}% {1}".format(row["progress"], row.get("current_step") or "")
|
|
164
|
+
ctx.out.render(rows, LIST_COLUMNS, empty="No layers yet — `geodeploy upload <file>`.")
|
|
165
|
+
return EXIT_OK
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_show(ctx, args) -> int:
|
|
169
|
+
layer = resolve_layer(ctx, args)
|
|
170
|
+
if not ctx.out.json_mode:
|
|
171
|
+
layer = dict(layer)
|
|
172
|
+
if layer.get("file_size"):
|
|
173
|
+
layer["file_size"] = human_size(layer["file_size"])
|
|
174
|
+
ctx.out.render(layer)
|
|
175
|
+
return EXIT_OK
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_legend(ctx, args) -> int:
|
|
179
|
+
"""What a legend for this layer shows — asked of the server, not derived here.
|
|
180
|
+
|
|
181
|
+
A legend that disagrees with the map is worse than no legend, and the only way to guarantee
|
|
182
|
+
agreement is for one implementation to produce both.
|
|
183
|
+
"""
|
|
184
|
+
layer = resolve_layer(ctx, args, public_ok=True)
|
|
185
|
+
client = ctx.client(auth_required=False)
|
|
186
|
+
ref = layer.get("uid") or layer["id"]
|
|
187
|
+
legend = client.layers.api(layer["layer_type"]).legend(ref)
|
|
188
|
+
|
|
189
|
+
if ctx.out.json_mode:
|
|
190
|
+
ctx.out.json(legend)
|
|
191
|
+
return EXIT_OK
|
|
192
|
+
if legend.get("ramp"):
|
|
193
|
+
ctx.out.record(legend, ["layer", "colormap", "rescale", "algorithm", "bidx", "band_count"])
|
|
194
|
+
ctx.out.info("A raster legend is a continuous ramp, so there are no swatches to list.")
|
|
195
|
+
return EXIT_OK
|
|
196
|
+
ctx.out.out(ctx.out.bold(legend.get("layer") or ""))
|
|
197
|
+
mode = legend.get("color_mode") or "single"
|
|
198
|
+
ctx.out.out(ctx.out.dim("{0}{1}".format(
|
|
199
|
+
mode, " on " + legend["field"] if legend.get("field") else "")))
|
|
200
|
+
ctx.out.out("")
|
|
201
|
+
ctx.out.table(legend.get("entries") or [], ["color", "label"])
|
|
202
|
+
size = legend.get("size")
|
|
203
|
+
if size:
|
|
204
|
+
ctx.out.info("Size varies with {0}: {1}".format(
|
|
205
|
+
size.get("field"), ", ".join("{0}→{1}px".format(v, s) for v, s in size["stops"])))
|
|
206
|
+
return EXIT_OK
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_fields(ctx, args) -> int:
|
|
210
|
+
layer = resolve_layer(ctx, args)
|
|
211
|
+
columns = layer.get("columns") or []
|
|
212
|
+
if not columns:
|
|
213
|
+
ctx.out.info("No column information for this layer (rasters have bands, not columns).")
|
|
214
|
+
return EXIT_OK
|
|
215
|
+
ctx.out.render(columns)
|
|
216
|
+
return EXIT_OK
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_stats(ctx, args) -> int:
|
|
220
|
+
layer = resolve_layer(ctx, args)
|
|
221
|
+
client = ctx.client()
|
|
222
|
+
if layer["layer_type"] == "raster":
|
|
223
|
+
ctx.out.render(client.raster.stats(layer["id"]))
|
|
224
|
+
return EXIT_OK
|
|
225
|
+
if not args.field:
|
|
226
|
+
ctx.out.error("Which attribute? Pass --field (see `geodeploy layers fields`).")
|
|
227
|
+
return EXIT_GENERIC
|
|
228
|
+
stats = client.vector.field_stats(layer["id"], args.field, classes=args.classes,
|
|
229
|
+
method=args.method, ramp=args.ramp)
|
|
230
|
+
if ctx.out.json_mode:
|
|
231
|
+
ctx.out.json(stats)
|
|
232
|
+
return EXIT_OK
|
|
233
|
+
ctx.out.record({k: v for k, v in stats.items() if k not in ("suggestion", "categories")})
|
|
234
|
+
suggestion = stats.get("suggestion") or {}
|
|
235
|
+
if suggestion.get("classes"):
|
|
236
|
+
ctx.out.out("")
|
|
237
|
+
ctx.out.table([{"min": c.get("min"), "max": c.get("max"), "color": c.get("color")}
|
|
238
|
+
for c in suggestion["classes"]], ["min", "max", "color"])
|
|
239
|
+
elif stats.get("categories"):
|
|
240
|
+
ctx.out.out("")
|
|
241
|
+
ctx.out.table(stats["categories"][:25])
|
|
242
|
+
return EXIT_OK
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_links(ctx, args) -> int:
|
|
246
|
+
layer = resolve_layer(ctx, args)
|
|
247
|
+
data = ctx.client().layers.api(layer["layer_type"]).links(layer["id"])
|
|
248
|
+
if ctx.out.json_mode:
|
|
249
|
+
ctx.out.json(data)
|
|
250
|
+
return EXIT_OK
|
|
251
|
+
if not data.get("public"):
|
|
252
|
+
ctx.out.warn("This layer is not public, so these URLs 404 for everyone else. "
|
|
253
|
+
"`geodeploy layers share {0} --visibility public` opens it up."
|
|
254
|
+
.format(layer["id"]))
|
|
255
|
+
ctx.out.table(data.get("links") or [], ["label", "url", "hint"])
|
|
256
|
+
return EXIT_OK
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_usage(ctx, args) -> int:
|
|
260
|
+
layer = resolve_layer(ctx, args)
|
|
261
|
+
ctx.out.render(ctx.client().layers.api(layer["layer_type"]).usage(layer["id"]),
|
|
262
|
+
["id", "title", "published"], empty="Not used by any portal.")
|
|
263
|
+
return EXIT_OK
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_features(ctx, args) -> int:
|
|
267
|
+
layer = resolve_layer(ctx, args, )
|
|
268
|
+
if layer["layer_type"] != "vector":
|
|
269
|
+
ctx.out.error("Features are a vector thing; {0} is a raster.".format(layer["name"]))
|
|
270
|
+
return EXIT_GENERIC
|
|
271
|
+
data = ctx.client().vector.features(layer["id"], bbox=args.bbox, limit=args.limit)
|
|
272
|
+
if args.output:
|
|
273
|
+
write_json_file(args.output, data)
|
|
274
|
+
ctx.out.success("Wrote {0} ({1} features).".format(
|
|
275
|
+
args.output, len(data.get("features") or [])))
|
|
276
|
+
return EXIT_OK
|
|
277
|
+
ctx.out.out(json.dumps(data, ensure_ascii=False))
|
|
278
|
+
return EXIT_OK
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cmd_identify(ctx, args) -> int:
|
|
282
|
+
layer = resolve_layer(ctx, args)
|
|
283
|
+
ref = layer.get("uid") or layer["id"]
|
|
284
|
+
ctx.out.render(ctx.client().vector.identify(ref, args.lng, args.lat, args.tolerance,
|
|
285
|
+
args.limit))
|
|
286
|
+
return EXIT_OK
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def cmd_download(ctx, args) -> int:
|
|
290
|
+
"""Two very different mechanisms behind one command.
|
|
291
|
+
|
|
292
|
+
A COG or a PMTiles archive already IS a file: it streams straight out of storage. A GeoPackage
|
|
293
|
+
or a CSV has to be built, so the instance does it as a job and hands back a zip. `auto` picks
|
|
294
|
+
the one that gives you the data rather than a rendering of it.
|
|
295
|
+
"""
|
|
296
|
+
layer = resolve_layer(ctx, args, public_ok=True)
|
|
297
|
+
client = ctx.client(auth_required=False)
|
|
298
|
+
ref = layer.get("uid") or layer["id"]
|
|
299
|
+
api = client.layers.api(layer["layer_type"])
|
|
300
|
+
kind = args.format
|
|
301
|
+
geoparquet_layer = layer.get("storage_backend") == "geoparquet"
|
|
302
|
+
if kind == "auto":
|
|
303
|
+
if layer["layer_type"] == "raster":
|
|
304
|
+
kind = "cog"
|
|
305
|
+
else:
|
|
306
|
+
# A GeoParquet layer's own files are the best copy of it there is: complete, lossless
|
|
307
|
+
# and not subject to the export row cap. Building a GeoPackage from them by default
|
|
308
|
+
# would be slower AND worse.
|
|
309
|
+
kind = "geoparquet" if geoparquet_layer else "gpkg"
|
|
310
|
+
|
|
311
|
+
if kind == "geoparquet" and geoparquet_layer and not args.bbox:
|
|
312
|
+
done = _download_parquet_dataset(ctx, client, layer, ref, args)
|
|
313
|
+
if done is not None:
|
|
314
|
+
return done
|
|
315
|
+
|
|
316
|
+
if kind in ("cog", "pmtiles"):
|
|
317
|
+
if kind == "cog" and layer["layer_type"] != "raster":
|
|
318
|
+
ctx.out.error("A COG is a raster thing; {0} is a vector layer.".format(layer["name"]))
|
|
319
|
+
return EXIT_GENERIC
|
|
320
|
+
path = args.output or "{0}.{1}".format(_safe(layer["name"]),
|
|
321
|
+
"tif" if kind == "cog" else "pmtiles")
|
|
322
|
+
endpoint = ("/data/raster/{0}/cog" if kind == "cog"
|
|
323
|
+
else "/data/vector/{0}/pmtiles").format(ref)
|
|
324
|
+
progress = ctx.out.progress(path)
|
|
325
|
+
try:
|
|
326
|
+
with open(path, "wb") as fh:
|
|
327
|
+
client.download(endpoint, _Counting(fh, progress), auth=False)
|
|
328
|
+
except GeoDeployError:
|
|
329
|
+
progress.finish()
|
|
330
|
+
raise
|
|
331
|
+
progress.finish()
|
|
332
|
+
ctx.out.render({"ok": True, "file": path, "format": kind})
|
|
333
|
+
if not ctx.out.json_mode:
|
|
334
|
+
ctx.out.success("Wrote {0}.".format(path))
|
|
335
|
+
return EXIT_OK
|
|
336
|
+
|
|
337
|
+
# Built server-side: the result is a zip, because an export can be more than one file (and
|
|
338
|
+
# carries a MANIFEST.txt saying whether the row cap truncated it).
|
|
339
|
+
path = args.output or "{0}.zip".format(_safe(layer["name"]))
|
|
340
|
+
ctx.out.info("Building {0} on the instance{1}…".format(
|
|
341
|
+
kind, " for " + args.bbox if args.bbox else " (whole layer)"))
|
|
342
|
+
cut = []
|
|
343
|
+
api.export_to_file(ref, path, format=kind, bbox=args.bbox, target_crs=args.crs,
|
|
344
|
+
on_status=lambda state: ctx.out.debug("export: {0}".format(state)),
|
|
345
|
+
on_ready=lambda status: cut.extend(status.get("truncated") or []))
|
|
346
|
+
ctx.out.render({"ok": True, "file": path, "format": kind, "truncated": cut})
|
|
347
|
+
if not ctx.out.json_mode:
|
|
348
|
+
ctx.out.success("Wrote {0}.".format(path))
|
|
349
|
+
if cut:
|
|
350
|
+
# Not a warning buried in the archive: the whole failure mode is that a truncated export
|
|
351
|
+
# LOOKS finished. Say it on stderr, and leave a non-zero exit for anything scripted.
|
|
352
|
+
# In --json mode the report is already IN the document (stdout stays one document), so the
|
|
353
|
+
# exit code carries it there.
|
|
354
|
+
if not ctx.out.json_mode:
|
|
355
|
+
for item in cut:
|
|
356
|
+
ctx.out.error("INCOMPLETE — {0} stopped at the server's {1:,}-row cap.".format(
|
|
357
|
+
item.get("file"), item.get("cap") or 0))
|
|
358
|
+
ctx.out.error(
|
|
359
|
+
"The rows you have are whichever the scan reached first.",
|
|
360
|
+
hint=("Complete instead: `geodeploy layers download {0}` (the stored GeoParquet "
|
|
361
|
+
"files, uncapped)".format(layer["name"]) if geoparquet_layer else
|
|
362
|
+
"Complete instead: ogr2ogr -f GPKG out.gpkg \"OAPIF:{0}/api/ogc\" {1} — "
|
|
363
|
+
"paged, no cap. Or ask for a --bbox.".format(client.url, layer["name"])))
|
|
364
|
+
return EXIT_GENERIC
|
|
365
|
+
if not ctx.out.json_mode:
|
|
366
|
+
ctx.out.info("MANIFEST.txt inside the zip records the row counts.")
|
|
367
|
+
return EXIT_OK
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _download_parquet_dataset(ctx, client, layer, ref, args):
|
|
371
|
+
"""The whole GeoParquet layer, straight from storage — no export job, no row cap.
|
|
372
|
+
|
|
373
|
+
Returns None when this layer has no partitioned dataset (a single uploaded `.parquet`, or a
|
|
374
|
+
re-prep that has not run), so the caller falls back to building one.
|
|
375
|
+
"""
|
|
376
|
+
directory = args.output or "{0}_parquet".format(_safe(layer["name"]))
|
|
377
|
+
try:
|
|
378
|
+
manifest = client.vector.parquet_manifest(ref)
|
|
379
|
+
parts = client.vector.parquet_parts(manifest)
|
|
380
|
+
except NotFoundError:
|
|
381
|
+
ctx.out.debug("No partition manifest for this layer; building an export instead.")
|
|
382
|
+
return None
|
|
383
|
+
if not parts:
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
ctx.out.info("{0} partition file(s), {1} — reading the stored files, not rebuilding them.".format(
|
|
387
|
+
len(parts), "{0:,} features".format(manifest["feature_count"])
|
|
388
|
+
if manifest.get("feature_count") else "row count unknown"))
|
|
389
|
+
progress = ctx.out.progress(directory)
|
|
390
|
+
|
|
391
|
+
def tick(done, total, part, done_bytes):
|
|
392
|
+
# Bytes, not files: partitions differ wildly in size, so "3/27 files" would jump around
|
|
393
|
+
# while a rate in MB/s answers the only question anyone has during a big download.
|
|
394
|
+
progress.label = "{0} {1}/{2} files".format(directory, done, total)
|
|
395
|
+
progress.update(done_bytes)
|
|
396
|
+
|
|
397
|
+
result = client.vector.download_dataset(ref, directory, on_file=tick)
|
|
398
|
+
progress.finish()
|
|
399
|
+
ctx.out.render({"ok": True, "directory": result["directory"], "files": result["parts"],
|
|
400
|
+
"format": "geoparquet", "rows": result.get("rows"), "truncated": []})
|
|
401
|
+
if not ctx.out.json_mode:
|
|
402
|
+
ctx.out.success("Wrote {0} file(s) to {1}{2}.".format(
|
|
403
|
+
result["parts"], directory, os.sep))
|
|
404
|
+
ctx.out.info("Complete and uncapped. Read it with:")
|
|
405
|
+
ctx.out.info(" SELECT * FROM read_parquet('{0}/**/*.parquet')".format(
|
|
406
|
+
directory.replace("\\", "/")))
|
|
407
|
+
return EXIT_OK
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class _Counting(object):
|
|
411
|
+
"""Wraps the output file so a download can show progress without buffering it."""
|
|
412
|
+
|
|
413
|
+
def __init__(self, fh, progress):
|
|
414
|
+
self._fh = fh
|
|
415
|
+
self._progress = progress
|
|
416
|
+
self._done = 0
|
|
417
|
+
|
|
418
|
+
def write(self, chunk: bytes) -> int:
|
|
419
|
+
self._done += len(chunk)
|
|
420
|
+
self._progress.update(self._done)
|
|
421
|
+
return self._fh.write(chunk)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def cmd_colormaps(ctx, args) -> int:
|
|
425
|
+
ctx.out.render(ctx.client().raster.colormaps())
|
|
426
|
+
return EXIT_OK
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ── write ────────────────────────────────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
def cmd_rename(ctx, args) -> int:
|
|
432
|
+
layer = resolve_layer(ctx, args)
|
|
433
|
+
updated = ctx.client().layers.api(layer["layer_type"]).rename(layer["id"], args.name)
|
|
434
|
+
ctx.out.render(updated, ["id", "name", "status"])
|
|
435
|
+
if not ctx.out.json_mode:
|
|
436
|
+
ctx.out.success("Renamed to {0!r}.".format(args.name))
|
|
437
|
+
ctx.out.info("Published portals keep the old name until they are re-published.")
|
|
438
|
+
return EXIT_OK
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def cmd_share(ctx, args) -> int:
|
|
442
|
+
layer = resolve_layer(ctx, args)
|
|
443
|
+
updated = ctx.client().layers.api(layer["layer_type"]).share(
|
|
444
|
+
layer["id"], visibility=args.visibility, abstract=args.abstract, license=args.license,
|
|
445
|
+
attribution=args.attribution, keywords=args.keywords)
|
|
446
|
+
ctx.out.render(updated, ["id", "name", "visibility", "is_public", "license", "attribution",
|
|
447
|
+
"keywords", "abstract"])
|
|
448
|
+
if not ctx.out.json_mode and args.visibility == "public":
|
|
449
|
+
ctx.out.success("{0} is public — it now appears in the STAC catalog and OGC API - Features."
|
|
450
|
+
.format(updated.get("name")))
|
|
451
|
+
ctx.out.info("`geodeploy layers links {0}` lists the URLs to hand out.".format(layer["id"]))
|
|
452
|
+
return EXIT_OK
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def cmd_style(ctx, args) -> int:
|
|
456
|
+
layer = resolve_layer(ctx, args)
|
|
457
|
+
client = ctx.client()
|
|
458
|
+
current = (layer.get("default_style") or {})
|
|
459
|
+
base = {} if args.replace else dict(current.get("style") or {})
|
|
460
|
+
style = style_from_args(args, client, layer.get("uid") or layer["id"], base, ctx.out)
|
|
461
|
+
|
|
462
|
+
body = {"style": style,
|
|
463
|
+
"opacity": args.opacity if args.opacity is not None else current.get("opacity", 1.0)}
|
|
464
|
+
fields = parse_fields(args.popup_fields)
|
|
465
|
+
if layer["layer_type"] == "vector":
|
|
466
|
+
body["popup_fields"] = fields if fields is not None else (current.get("popup_fields") or [])
|
|
467
|
+
else:
|
|
468
|
+
# The raster default-style body is flat: colormap/rescale/algorithm live at the top level.
|
|
469
|
+
body = {"opacity": body["opacity"]}
|
|
470
|
+
for key in ("colormap", "rescale", "algorithm", "zfactor", "bidx"):
|
|
471
|
+
if style.get(key) is not None:
|
|
472
|
+
body[key] = style[key]
|
|
473
|
+
|
|
474
|
+
updated = client.layers.api(layer["layer_type"]).set_default_style(layer["id"], body)
|
|
475
|
+
ctx.out.render(updated.get("default_style") or body)
|
|
476
|
+
if not ctx.out.json_mode:
|
|
477
|
+
ctx.out.success("Default style saved for {0}.".format(layer["name"]))
|
|
478
|
+
ctx.out.info("Portals already using this layer keep their own styling; re-publish to "
|
|
479
|
+
"refresh a portal that follows the default.")
|
|
480
|
+
return EXIT_OK
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def cmd_tile(ctx, args) -> int:
|
|
484
|
+
return _job_command(ctx, args, "tile")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def cmd_prepare(ctx, args) -> int:
|
|
488
|
+
return _job_command(ctx, args, "prepare")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def cmd_reprocess(ctx, args) -> int:
|
|
492
|
+
return _job_command(ctx, args, "reprocess")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _job_command(ctx, args, action: str) -> int:
|
|
496
|
+
layer = resolve_layer(ctx, args)
|
|
497
|
+
if layer["layer_type"] != "vector":
|
|
498
|
+
ctx.out.error("`{0}` applies to vector layers.".format(action))
|
|
499
|
+
return EXIT_GENERIC
|
|
500
|
+
client = ctx.client()
|
|
501
|
+
result = getattr(client.vector, action)(layer["id"])
|
|
502
|
+
job_id = (result or {}).get("id") if isinstance(result, dict) else None
|
|
503
|
+
if args.wait and job_id:
|
|
504
|
+
final = client.jobs.wait(job_id, "vector",
|
|
505
|
+
on_progress=lambda st: ctx.out.info(" {0:3d}% {1}".format(
|
|
506
|
+
st.get("progress") or 0, st.get("current_step") or "")))
|
|
507
|
+
ctx.out.render(final)
|
|
508
|
+
return EXIT_OK
|
|
509
|
+
ctx.out.render(result)
|
|
510
|
+
if not ctx.out.json_mode:
|
|
511
|
+
ctx.out.success("{0} started for {1}.".format(action.capitalize(), layer["name"]))
|
|
512
|
+
return EXIT_OK
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def cmd_delete(ctx, args) -> int:
|
|
516
|
+
client = ctx.client()
|
|
517
|
+
layer = resolve_layer(ctx, args)
|
|
518
|
+
portals = client.layers.api(layer["layer_type"]).usage(layer["id"])
|
|
519
|
+
if portals and not ctx.out.json_mode:
|
|
520
|
+
ctx.out.warn("Used by {0} portal(s): {1}. They will be updated and re-published."
|
|
521
|
+
.format(len(portals), ", ".join(p.get("title") or "?" for p in portals)))
|
|
522
|
+
if not confirm(ctx.out, "Delete {0} layer {1!r} (id {2})?".format(
|
|
523
|
+
layer["layer_type"], layer["name"], layer["id"]), args.yes):
|
|
524
|
+
ctx.out.info("Nothing deleted.")
|
|
525
|
+
return EXIT_GENERIC
|
|
526
|
+
client.layers.api(layer["layer_type"]).delete(layer["id"])
|
|
527
|
+
ctx.out.render({"ok": True, "deleted": layer["id"], "name": layer["name"]})
|
|
528
|
+
if not ctx.out.json_mode:
|
|
529
|
+
ctx.out.success("Deleted {0}.".format(layer["name"]))
|
|
530
|
+
return EXIT_OK
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _safe(name: str) -> str:
|
|
534
|
+
keep = "-_. abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
535
|
+
cleaned = "".join(c if c in keep else "_" for c in (name or "layer")).strip()
|
|
536
|
+
return cleaned or "layer"
|