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.
@@ -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"