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,224 @@
1
+ """`geodeploy browse` — point at an instance and see what it offers.
2
+
3
+ The one command that needs no account: give it a URL and it prints the public portals and the
4
+ public layers, grouped by how they are stored, with the URL each one is best consumed by. With a
5
+ token it also shows what that token can see — which is the same two-mode behaviour a desktop
6
+ plugin needs, and the reason this exists in the client rather than only in a plugin.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from ...config import split_portal_url
11
+ from ...errors import GeoDeployError, NotFoundError
12
+ from ..main import add_command
13
+ from ..output import EXIT_GENERIC, EXIT_OK
14
+
15
+ KIND_LABEL = {"postgis": "Vector (PostGIS)", "geoparquet": "Vector (GeoParquet)",
16
+ "raster": "Raster (COG)"}
17
+
18
+
19
+ def register(subparsers) -> None:
20
+ parser = add_command(
21
+ subparsers, "browse", cmd_browse,
22
+ "see what an instance publishes — no account needed",
23
+ epilog="""\
24
+ examples:
25
+ geodeploy browse https://geodeploy.example.org anyone can run this
26
+ geodeploy browse the instance you are logged in to
27
+ geodeploy browse --portal field-sites-2026 what one portal contains
28
+ geodeploy browse https://gd.example.org/portals/5b5c627cfd/ a portal URL, pasted whole
29
+ geodeploy browse --json | jq '.layers.raster' machine-readable
30
+
31
+ With a token stored (or --token), it also reports what that token can see beyond the public
32
+ surface, which is usually the point of having one.
33
+ """)
34
+ parser.add_argument("instance", nargs="?",
35
+ help="instance URL, or a published portal's URL "
36
+ "(default: the active profile)")
37
+ parser.add_argument("--portal", metavar="SLUG_OR_URL",
38
+ help="show one portal's layers, read from its published style")
39
+ parser.add_argument("--kind", choices=["raster", "postgis", "geoparquet"],
40
+ help="only this kind of layer")
41
+ parser.add_argument("--links", action="store_true",
42
+ help="print every access URL for each layer, not just a summary")
43
+
44
+
45
+ def cmd_browse(ctx, args) -> int:
46
+ instance = args.instance
47
+ if instance and not args.portal and "/portals/" in instance:
48
+ # A portal's own URL, pasted whole. It names both the instance and the portal, so it does
49
+ # not need --portal and must not be read as an instance URL.
50
+ instance, args.portal = split_portal_url(instance)
51
+ elif args.portal:
52
+ origin, args.portal = split_portal_url(args.portal)
53
+ instance = instance or origin # a full portal URL brings its instance with it
54
+ if instance:
55
+ args.url = instance # the positional is just a friendlier --url
56
+ ctx._resolved = None # re-resolve against it
57
+ client = ctx.client(auth_required=False)
58
+ out = ctx.out
59
+
60
+ if args.portal:
61
+ return _one_portal(ctx, client, args)
62
+
63
+ try:
64
+ index = client.catalog.public()
65
+ except NotFoundError:
66
+ out.error("{0} does not publish a public index.".format(client.url),
67
+ hint="An admin can turn it on in Settings, or you can list what your token can "
68
+ "see with `geodeploy layers list` and `geodeploy portals list`.")
69
+ return EXIT_GENERIC
70
+
71
+ if out.json_mode:
72
+ payload = dict(index)
73
+ private = _private_view(ctx)
74
+ if private:
75
+ payload["with_your_token"] = private
76
+ out.json(payload)
77
+ return EXIT_OK
78
+
79
+ counts = index.get("counts") or {}
80
+ out.out(out.bold("{0}".format(client.url)))
81
+ out.out(out.dim("{0} public portal(s), {1} public layer(s)".format(
82
+ counts.get("portals", 0),
83
+ sum(v for k, v in counts.items() if k != "portals"))))
84
+
85
+ portals = index.get("portals") or []
86
+ if portals:
87
+ out.out("")
88
+ out.out(out.bold("Portals"))
89
+ out.table([{"slug": p.get("slug"), "title": p.get("title"),
90
+ "experience": p.get("experience"), "layers": p.get("layer_count"),
91
+ "url": p.get("url")} for p in portals],
92
+ ["slug", "title", "experience", "layers", "url"])
93
+
94
+ layers = index.get("layers") or {}
95
+ for kind in ("postgis", "geoparquet", "raster"):
96
+ rows = layers.get(kind) or []
97
+ if not rows or (args.kind and args.kind != kind):
98
+ continue
99
+ out.out("")
100
+ out.out(out.bold("{0} — {1}".format(KIND_LABEL[kind], len(rows))))
101
+ out.table([{"id": r.get("id"), "name": r.get("name"),
102
+ "geometry": r.get("geometry_type") or ("{0} band(s)".format(r.get("band_count"))
103
+ if r.get("band_count") else None),
104
+ "features": r.get("feature_count"), "crs": r.get("crs"),
105
+ "licence": r.get("license")} for r in rows],
106
+ ["id", "name", "geometry", "features", "crs", "licence"])
107
+ if args.links:
108
+ for row in rows:
109
+ out.out("")
110
+ out.out(" " + out.bold(row.get("name") or "?"))
111
+ for link in row.get("links") or []:
112
+ out.out(" {0} {1}".format(link.get("label"), link.get("url")))
113
+
114
+ catalogs = index.get("catalogs") or {}
115
+ out.out("")
116
+ out.info("Standards: OGC API - Features {0} · STAC {1}".format(
117
+ catalogs.get("ogc_features"), catalogs.get("stac")))
118
+
119
+ private = _private_view(ctx)
120
+ if private:
121
+ out.info("With your token: {0} layer(s) and {1} portal(s) in total — "
122
+ "`geodeploy layers list` shows them.".format(private["layers"], private["portals"]))
123
+ elif not ctx.resolved.token:
124
+ out.info("No token in use: this is the anonymous view. `geodeploy login <url> --token …` "
125
+ "to see everything you have access to.")
126
+ return EXIT_OK
127
+
128
+
129
+ def _private_view(ctx):
130
+ """What the caller's own credential can see, when there is one. Best-effort by design:
131
+ browsing must not fail because a token expired."""
132
+ if not (ctx.resolved.token or ctx.resolved.jwt):
133
+ return None
134
+ try:
135
+ client = ctx.client(auth_required=False)
136
+ return {"layers": len(client.layers.list()), "portals": len(client.portals.list())}
137
+ except GeoDeployError:
138
+ return None
139
+
140
+
141
+ def _one_portal(ctx, client, args) -> int:
142
+ """Read a published portal's own style.json — the whole portal, anonymously."""
143
+ out = ctx.out
144
+ slug = args.portal
145
+ style_url = "{0}/portals/{1}/style.json".format(client.url, slug)
146
+ try:
147
+ style = client.catalog.portal_style(style_url)
148
+ except GeoDeployError as exc:
149
+ out.error("Could not read portal {0!r}: {1}".format(slug, exc),
150
+ hint="Public portals only — a password or members-only portal will not open "
151
+ "without a session.")
152
+ return EXIT_GENERIC
153
+
154
+ meta = style.get("geodeploy") or {}
155
+ if out.json_mode:
156
+ out.json(style)
157
+ return EXIT_OK
158
+
159
+ out.out(out.bold(meta.get("title") or slug))
160
+ out.out(out.dim("{0}/portals/{1}/".format(client.url, slug)))
161
+ out.out("")
162
+
163
+ sources = style.get("sources") or {}
164
+ rows, urls = [], {}
165
+ for layer in _data_layers(style):
166
+ lmeta = layer.get("metadata") or {}
167
+ source = layer.get("source")
168
+ row = {"name": lmeta.get("geodeploy:name") or layer.get("id"),
169
+ "kind": lmeta.get("geodeploy:geometry") or layer.get("type"),
170
+ "id": layer.get("id"),
171
+ "visible": "no" if _hidden(layer) else "yes"}
172
+ rows.append(row)
173
+ urls[row["id"]] = _source_url(sources.get(source) or {})
174
+ for deck in meta.get("deckLayers") or []:
175
+ ident = deck.get("id") or deck.get("name")
176
+ rows.append({"name": deck.get("name") or ident, "kind": "GeoParquet (deck.gl)",
177
+ "id": ident, "visible": "no" if deck.get("visible") is False else "yes"})
178
+ urls[ident] = deck.get("url") or deck.get("manifest") or ""
179
+
180
+ columns = ["name", "kind", "id"]
181
+ if any(r["visible"] == "no" for r in rows):
182
+ columns.append("visible") # only worth a column when something is actually off
183
+ out.table(rows, columns)
184
+
185
+ if args.links:
186
+ out.out("")
187
+ for row in rows:
188
+ if urls.get(row["id"]):
189
+ out.out(" {0} {1}".format(out.bold(row["name"]), urls[row["id"]]))
190
+ if meta.get("bounds"):
191
+ out.info("Extent: {0}".format(meta["bounds"]))
192
+ if not args.links:
193
+ out.info("`--links` adds the data URL behind each layer.")
194
+ return EXIT_OK
195
+
196
+
197
+ def _data_layers(style):
198
+ """The author's layers, without the basemap's and without counting one layer twice.
199
+
200
+ A layer can be drawn by several MapLibre layers (a fill plus its outline, a polygon plus its
201
+ 3D pillars); the generator flags the extras `geodeploy:part` and puts the metadata on the
202
+ first, which is exactly the rule the portal's own layer switcher follows.
203
+ """
204
+ layers = style.get("layers") or []
205
+ tagged = [l for l in layers
206
+ if (l.get("metadata") or {}).get("geodeploy:layer_id") is not None
207
+ and not (l.get("metadata") or {}).get("geodeploy:part")]
208
+ if tagged:
209
+ return tagged
210
+ # An older bundle, published before the metadata existed: fall back to "not the basemap".
211
+ return [l for l in layers
212
+ if l.get("source") and l.get("source") not in ("basemap", "composite")]
213
+
214
+
215
+ def _hidden(layer):
216
+ return ((layer.get("layout") or {}).get("visibility")) == "none"
217
+
218
+
219
+ def _source_url(source):
220
+ """Where a MapLibre source actually reads from — a tile template, a TileJSON or inline data."""
221
+ tiles = source.get("tiles")
222
+ if tiles:
223
+ return tiles[0]
224
+ return source.get("url") or (source.get("data") if isinstance(source.get("data"), str) else "")
@@ -0,0 +1,107 @@
1
+ """`geodeploy catalog …` — the public surfaces: OGC API - Features, STAC, templates, basemaps.
2
+
3
+ These need no credentials, which makes this group the honest way to check what you have actually
4
+ published: if a layer is not listed here, nobody outside your organisation can see it either.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+
10
+ from ..main import add_command, group_parser
11
+ from ..output import EXIT_OK
12
+
13
+
14
+ def register(subparsers) -> None:
15
+ group = group_parser(subparsers, "catalog",
16
+ "what this instance publishes: OGC API - Features, STAC, templates")
17
+
18
+ add_command(group, "collections", cmd_collections,
19
+ "OGC API - Features collections (one per public vector layer)")
20
+
21
+ items = add_command(group, "items", cmd_items, "features from a collection")
22
+ items.add_argument("collection", help="collection id, e.g. vector-a1b2c3d4e5f6")
23
+ items.add_argument("--bbox", help="minx,miny,maxx,maxy")
24
+ items.add_argument("--limit", type=int, default=10)
25
+ items.add_argument("--offset", type=int, default=0)
26
+
27
+ add_command(group, "conformance", cmd_conformance,
28
+ "what the OGC endpoint claims to support")
29
+
30
+ stac = add_command(group, "stac", cmd_stac, "the STAC catalog root and its collections")
31
+ stac.add_argument("--collection", help="list items in this collection (vectors | rasters)")
32
+ stac.add_argument("--limit", type=int, default=20)
33
+
34
+ search = add_command(group, "search", cmd_search, "STAC search across public layers")
35
+ search.add_argument("--bbox")
36
+ search.add_argument("--collections")
37
+ search.add_argument("--datetime", dest="datetime_", help="instant or start/end (.. is open)")
38
+ search.add_argument("--ids")
39
+ search.add_argument("--limit", type=int, default=20)
40
+
41
+ add_command(group, "templates", cmd_templates, "portal templates and the experiences they suit")
42
+ add_command(group, "basemaps", cmd_basemaps, "basemaps available to portals")
43
+
44
+
45
+ def _client(ctx):
46
+ # Public endpoints: a URL is enough, no credential required.
47
+ return ctx.client(auth_required=False)
48
+
49
+
50
+ def cmd_collections(ctx, args) -> int:
51
+ rows = _client(ctx).catalog.collections()
52
+ ctx.out.render([{"id": c.get("id"), "title": c.get("title"),
53
+ "extent": ((c.get("extent") or {}).get("spatial") or {}).get("bbox")}
54
+ for c in rows], ["id", "title", "extent"],
55
+ empty="No public collections — share a vector layer publicly to add one.")
56
+ return EXIT_OK
57
+
58
+
59
+ def cmd_items(ctx, args) -> int:
60
+ data = _client(ctx).catalog.items(args.collection, bbox=args.bbox, limit=args.limit,
61
+ offset=args.offset)
62
+ if ctx.out.json_mode:
63
+ ctx.out.json(data)
64
+ return EXIT_OK
65
+ ctx.out.info("{0} returned, {1} matched.".format(data.get("numberReturned"),
66
+ data.get("numberMatched", "unknown")))
67
+ ctx.out.out(json.dumps(data, indent=2, ensure_ascii=False)[:20000])
68
+ return EXIT_OK
69
+
70
+
71
+ def cmd_conformance(ctx, args) -> int:
72
+ ctx.out.render((_client(ctx).catalog.conformance() or {}).get("conformsTo") or [])
73
+ return EXIT_OK
74
+
75
+
76
+ def cmd_stac(ctx, args) -> int:
77
+ catalog = _client(ctx).catalog
78
+ if args.collection:
79
+ ctx.out.render(catalog.stac_items(args.collection, limit=args.limit))
80
+ return EXIT_OK
81
+ ctx.out.render([{"id": c.get("id"), "title": c.get("title"), "description": c.get("description")}
82
+ for c in catalog.stac_collections()], ["id", "title", "description"])
83
+ return EXIT_OK
84
+
85
+
86
+ def cmd_search(ctx, args) -> int:
87
+ data = _client(ctx).catalog.search(bbox=args.bbox, collections=args.collections,
88
+ datetime=args.datetime_, ids=args.ids, limit=args.limit)
89
+ if ctx.out.json_mode:
90
+ ctx.out.json(data)
91
+ return EXIT_OK
92
+ features = data.get("features") or []
93
+ ctx.out.table([{"id": f.get("id"), "collection": f.get("collection"),
94
+ "assets": ", ".join(sorted((f.get("assets") or {}).keys()))}
95
+ for f in features], ["id", "collection", "assets"])
96
+ return EXIT_OK
97
+
98
+
99
+ def cmd_templates(ctx, args) -> int:
100
+ ctx.out.render(_client(ctx).catalog.templates(),
101
+ ["id", "name", "archetypes", "archetype", "tags", "is_official", "version"])
102
+ return EXIT_OK
103
+
104
+
105
+ def cmd_basemaps(ctx, args) -> int:
106
+ ctx.out.render(_client(ctx).catalog.basemaps(), ["id", "name", "type", "attribution"])
107
+ return EXIT_OK
@@ -0,0 +1,125 @@
1
+ """`geodeploy import …` — register data that is already in the database or the bucket.
2
+
3
+ Nothing is copied and nothing is moved: a PostGIS table is introspected and registered where it
4
+ stands, and an object in storage is attached by key. This is how you point a fresh instance at
5
+ data that is already on the server, without a round trip through your laptop.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, List
10
+
11
+ from ..main import add_command, group_parser
12
+ from ..output import EXIT_GENERIC, EXIT_OK
13
+
14
+
15
+ def register(subparsers) -> None:
16
+ group = group_parser(subparsers, "import", "register existing PostGIS tables or storage objects",
17
+ aliases=["discover"])
18
+
19
+ db_list = add_command(group, "db-list", cmd_db_list,
20
+ "spatial tables in the database, and whether they are registered")
21
+ db_list.add_argument("--all", action="store_true", help="include already-imported tables")
22
+
23
+ db_add = add_command(group, "db-add", cmd_db_add, "register PostGIS tables as layers",
24
+ epilog="examples:\n geodeploy import db-add public.roads public.sites\n")
25
+ db_add.add_argument("tables", nargs="+", help="schema.table (as listed by db-list)")
26
+ db_add.add_argument("--name", help="display name (single table only)")
27
+
28
+ st_list = add_command(group, "storage-list", cmd_storage_list,
29
+ "GeoTIFF / GeoParquet / CSV objects in the bucket")
30
+ st_list.add_argument("--kind", choices=["raster", "geoparquet", "csv"])
31
+
32
+ st_add = add_command(group, "storage-add", cmd_storage_add,
33
+ "attach storage objects as layers (GeoParquet is inspected + prepared)")
34
+ st_add.add_argument("keys", nargs="+", help="object keys as listed by storage-list")
35
+ st_add.add_argument("--name", help="display name (single object only)")
36
+ st_add.add_argument("--wait", action="store_true", help="wait for any queued jobs")
37
+
38
+ csv_cols = add_command(group, "csv-columns", cmd_csv_columns, "header of a CSV in the bucket")
39
+ csv_cols.add_argument("key")
40
+ csv_cols.add_argument("--delimiter", default="comma",
41
+ choices=["comma", "semicolon", "tab", "pipe", "space"])
42
+
43
+ csv_add = add_command(group, "csv", cmd_csv, "build a PostGIS layer from a CSV in the bucket")
44
+ csv_add.add_argument("key")
45
+ csv_add.add_argument("--name")
46
+ csv_add.add_argument("--x", dest="x_column")
47
+ csv_add.add_argument("--y", dest="y_column")
48
+ csv_add.add_argument("--wkt", dest="wkt_column")
49
+ csv_add.add_argument("--srid", type=int, default=4326)
50
+ csv_add.add_argument("--delimiter", default="comma",
51
+ choices=["comma", "semicolon", "tab", "pipe", "space"])
52
+ csv_add.add_argument("--wait", action="store_true")
53
+
54
+
55
+ def cmd_db_list(ctx, args) -> int:
56
+ rows = ctx.client().imports.database_tables()
57
+ if not args.all:
58
+ rows = [r for r in rows if not r.get("imported")]
59
+ ctx.out.render(rows, ["schema_name", "table_name", "geometry_column", "geometry_type", "srid",
60
+ "imported"],
61
+ empty="No spatial tables found (or all of them are already registered).")
62
+ return EXIT_OK
63
+
64
+
65
+ def cmd_db_add(ctx, args) -> int:
66
+ available = {"{0}.{1}".format(r.get("schema_name"), r.get("table_name")): r
67
+ for r in ctx.client().imports.database_tables()}
68
+ tables = [] # type: List[Dict[str, Any]]
69
+ for ref in args.tables:
70
+ found = available.get(ref)
71
+ if not found:
72
+ ctx.out.error("No spatial table {0!r}. `geodeploy import db-list` shows what there is."
73
+ .format(ref))
74
+ return EXIT_GENERIC
75
+ entry = {"schema_name": found["schema_name"], "table_name": found["table_name"],
76
+ "geometry_column": found.get("geometry_column"),
77
+ "srid": found.get("srid") or 0,
78
+ "geometry_type": found.get("geometry_type")}
79
+ if args.name and len(args.tables) == 1:
80
+ entry["name"] = args.name
81
+ tables.append(entry)
82
+ result = ctx.client().imports.database(tables)
83
+ ctx.out.render(result)
84
+ if not ctx.out.json_mode:
85
+ ctx.out.success("Registered {0} table(s).".format(len(tables)))
86
+ return EXIT_OK
87
+
88
+
89
+ def cmd_storage_list(ctx, args) -> int:
90
+ ctx.out.render(ctx.client().imports.storage_objects(args.kind),
91
+ ["key", "kind", "size", "imported"], empty="Nothing importable in the bucket.")
92
+ return EXIT_OK
93
+
94
+
95
+ def cmd_storage_add(ctx, args) -> int:
96
+ items = [{"key": key} for key in args.keys]
97
+ if args.name and len(items) == 1:
98
+ items[0]["name"] = args.name
99
+ result = ctx.client().imports.storage(items)
100
+ jobs = (result or {}).get("jobs") or []
101
+ ctx.out.render(result)
102
+ if args.wait and jobs:
103
+ for job in jobs:
104
+ ctx.client().jobs.wait(job.get("id"), "vector",
105
+ on_progress=lambda st: ctx.out.info(" {0:3d}% {1}".format(
106
+ st.get("progress") or 0, st.get("current_step") or "")))
107
+ ctx.out.success("All queued imports finished.")
108
+ return EXIT_OK
109
+
110
+
111
+ def cmd_csv_columns(ctx, args) -> int:
112
+ ctx.out.render(ctx.client().imports.csv_columns(args.key, args.delimiter))
113
+ return EXIT_OK
114
+
115
+
116
+ def cmd_csv(ctx, args) -> int:
117
+ job = ctx.client().imports.csv(args.key, name=args.name, x_column=args.x_column,
118
+ y_column=args.y_column, wkt_column=args.wkt_column,
119
+ srid=args.srid, delimiter=args.delimiter)
120
+ if args.wait and job.get("id"):
121
+ job = ctx.client().jobs.wait(job["id"], "vector",
122
+ on_progress=lambda st: ctx.out.info(" {0:3d}% {1}".format(
123
+ st.get("progress") or 0, st.get("current_step") or "")))
124
+ ctx.out.render(job)
125
+ return EXIT_OK
@@ -0,0 +1,38 @@
1
+ """`geodeploy jobs …` — ingest jobs, and waiting for one."""
2
+ from __future__ import annotations
3
+
4
+ from ..main import add_command, group_parser
5
+ from ..output import EXIT_OK
6
+
7
+
8
+ def register(subparsers) -> None:
9
+ group = group_parser(subparsers, "jobs", "ingest job status")
10
+
11
+ show = add_command(group, "show", cmd_show, "the current state of a job")
12
+ show.add_argument("job_id")
13
+ show.add_argument("--type", dest="layer_type", choices=["vector", "raster"], default="vector")
14
+
15
+ watch = add_command(group, "watch", cmd_watch, "follow a job until it finishes")
16
+ watch.add_argument("job_id")
17
+ watch.add_argument("--type", dest="layer_type", choices=["vector", "raster"], default="vector")
18
+ watch.add_argument("--interval", type=float, default=2.0)
19
+ watch.add_argument("--max-wait", dest="max_wait", type=float, default=3600.0,
20
+ help="give up after this many seconds (the job keeps running server-side)")
21
+
22
+
23
+ def cmd_show(ctx, args) -> int:
24
+ ctx.out.render(ctx.client().jobs.get(args.job_id, args.layer_type),
25
+ ["id", "layer_id", "layer_type", "status", "progress", "current_step",
26
+ "error_message"])
27
+ return EXIT_OK
28
+
29
+
30
+ def cmd_watch(ctx, args) -> int:
31
+ final = ctx.client().jobs.wait(
32
+ args.job_id, args.layer_type, interval=args.interval, timeout=args.max_wait,
33
+ on_progress=lambda st: ctx.out.info(" {0:3d}% {1}".format(
34
+ st.get("progress") or 0, st.get("current_step") or st.get("status") or "")))
35
+ ctx.out.render(final, ["id", "layer_id", "layer_type", "status", "progress", "current_step"])
36
+ if not ctx.out.json_mode:
37
+ ctx.out.success("Job {0} finished.".format(args.job_id))
38
+ return EXIT_OK