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/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""GeoDeploy — command-line client and Python API.
|
|
2
|
+
|
|
3
|
+
from geodeploy import Client
|
|
4
|
+
gd = Client("https://geodeploy.example.org", token="gdp_…")
|
|
5
|
+
layer = gd.uploads.upload("roads.gpkg", wait=True)
|
|
6
|
+
portal = gd.portals.create("Roads")
|
|
7
|
+
gd.portals.add_layer(portal["id"], layer.layer_id, "vector", {"color": "#e11d48"})
|
|
8
|
+
gd.portals.publish(portal["id"])
|
|
9
|
+
|
|
10
|
+
Zero runtime dependencies, Python 3.9+, so the QGIS plugin can vendor this package as-is.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
# The CLI's version tracks the GeoDeploy release it ships with. A PyPI version can never be
|
|
15
|
+
# re-uploaded, so a number is only spent once the release it names exists: 1.3.0b1 proved the
|
|
16
|
+
# packaging against the real index, and this is the release it was rehearsing for.
|
|
17
|
+
__version__ = "1.3.0"
|
|
18
|
+
|
|
19
|
+
from .client import Client # noqa: E402 (after __version__ — the user agent reads it)
|
|
20
|
+
from .errors import ( # noqa: E402
|
|
21
|
+
APIError,
|
|
22
|
+
AuthError,
|
|
23
|
+
ConfigError,
|
|
24
|
+
ConflictError,
|
|
25
|
+
GeoDeployError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
PermissionError_,
|
|
28
|
+
ServerError,
|
|
29
|
+
TransportError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
)
|
|
32
|
+
from .jobs import JobFailed, JobTimeout # noqa: E402
|
|
33
|
+
from .styles import Style, parse_style # noqa: E402
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Client",
|
|
37
|
+
"Style",
|
|
38
|
+
"parse_style",
|
|
39
|
+
"GeoDeployError",
|
|
40
|
+
"APIError",
|
|
41
|
+
"AuthError",
|
|
42
|
+
"ConfigError",
|
|
43
|
+
"ConflictError",
|
|
44
|
+
"NotFoundError",
|
|
45
|
+
"PermissionError_",
|
|
46
|
+
"ServerError",
|
|
47
|
+
"TransportError",
|
|
48
|
+
"ValidationError",
|
|
49
|
+
"JobFailed",
|
|
50
|
+
"JobTimeout",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
geodeploy/__main__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""`python -m geodeploy` — the same entry point as the `geodeploy` console script.
|
|
2
|
+
|
|
3
|
+
Worth having: inside QGIS's bundled Python, or a virtualenv whose `Scripts/` is not on PATH, the
|
|
4
|
+
module form is the one that works.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .cli.main import main
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
sys.exit(main())
|
geodeploy/admin.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Operating an instance: health, services, updates, backups, the audit log, and users.
|
|
2
|
+
|
|
3
|
+
**These routes refuse API tokens.** `deps.require_role`/`require_admin` reject a token-authenticated
|
|
4
|
+
request outright, so a leaked `gdp_…` cannot restart your database, read your storage credentials,
|
|
5
|
+
or mint more tokens. That is a deliberate security property, not an oversight — so everything in
|
|
6
|
+
this module needs a *session*: `geodeploy login --password`, or `Client(url, jwt=…)`.
|
|
7
|
+
|
|
8
|
+
`Users` is the exception: `/users/*` is scope-gated (`users:admin`), so a token with that scope can
|
|
9
|
+
manage members.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from .errors import PermissionError_, ValidationError
|
|
16
|
+
|
|
17
|
+
#: Services the health endpoint reports on. `api` is deliberately not controllable — it is the
|
|
18
|
+
#: process serving the request that would stop it.
|
|
19
|
+
SERVICES = ("postgres", "minio", "redis", "martin", "titiler", "nginx", "celery", "ui", "api")
|
|
20
|
+
ACTIONS = ("start", "stop", "restart")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _session_only(exc: PermissionError_) -> PermissionError_:
|
|
24
|
+
"""Turn the API's bare 403 into the actionable version of the same fact."""
|
|
25
|
+
if exc.status == 403 and "token" in (exc.detail or "").lower():
|
|
26
|
+
return PermissionError_(
|
|
27
|
+
exc.status,
|
|
28
|
+
"{0} — administration is session-only by design, so a leaked API token cannot "
|
|
29
|
+
"reconfigure the instance. Run `geodeploy login --password` first.".format(exc.detail),
|
|
30
|
+
exc.url, exc.payload)
|
|
31
|
+
return exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Admin(object):
|
|
35
|
+
def __init__(self, client: Any):
|
|
36
|
+
self._c = client
|
|
37
|
+
|
|
38
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
39
|
+
try:
|
|
40
|
+
return self._c.get(path, params)
|
|
41
|
+
except PermissionError_ as exc:
|
|
42
|
+
raise _session_only(exc)
|
|
43
|
+
|
|
44
|
+
def _post(self, path: str, json: Any = None) -> Any:
|
|
45
|
+
try:
|
|
46
|
+
return self._c.post(path, json)
|
|
47
|
+
except PermissionError_ as exc:
|
|
48
|
+
raise _session_only(exc)
|
|
49
|
+
|
|
50
|
+
# ── health & services ───────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def health(self) -> List[Dict[str, Any]]:
|
|
53
|
+
return self._get("/admin/health")
|
|
54
|
+
|
|
55
|
+
def service(self, name: str, action: str) -> Any:
|
|
56
|
+
if action not in ACTIONS:
|
|
57
|
+
raise ValidationError(400, "Action must be one of {0}.".format(", ".join(ACTIONS)))
|
|
58
|
+
return self._post("/admin/services/{0}/{1}".format(name, action))
|
|
59
|
+
|
|
60
|
+
def logs(self, name: str, tail: int = 200, timestamps: bool = True) -> Any:
|
|
61
|
+
return self._get("/admin/services/{0}/logs".format(name),
|
|
62
|
+
{"tail": tail, "timestamps": timestamps})
|
|
63
|
+
|
|
64
|
+
def reload_martin(self) -> Any:
|
|
65
|
+
"""Regenerate Martin's config from every ready PostGIS layer — the manual recovery hook
|
|
66
|
+
for a tile server that has ended up with a stale or empty config."""
|
|
67
|
+
return self._post("/admin/reload-martin")
|
|
68
|
+
|
|
69
|
+
def storage_stats(self) -> Dict[str, Any]:
|
|
70
|
+
"""Per-store usage. A store that could not be measured is `null`, NOT 0 — the difference
|
|
71
|
+
between "the database is unreachable" and "the database is empty"."""
|
|
72
|
+
return self._get("/admin/storage-stats")
|
|
73
|
+
|
|
74
|
+
# ── updates ─────────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def updates(self, refresh: bool = False) -> Dict[str, Any]:
|
|
77
|
+
"""What this instance could move to: `main`, the latest release, every release, branches.
|
|
78
|
+
|
|
79
|
+
`refresh=True` bypasses the 10-minute cache. That cache protects GitHub's unauthenticated
|
|
80
|
+
rate limit against page loads; a deliberate check must not answer from it, or a commit
|
|
81
|
+
pushed a minute ago looks like it never landed.
|
|
82
|
+
"""
|
|
83
|
+
return self._get("/admin/updates", {"refresh": refresh} if refresh else None)
|
|
84
|
+
|
|
85
|
+
def preflight(self) -> Dict[str, Any]:
|
|
86
|
+
"""Whether an update is safe to start right now (it refuses over work in progress)."""
|
|
87
|
+
return self._get("/admin/update/preflight")
|
|
88
|
+
|
|
89
|
+
def update(self, target: Optional[str] = None) -> Dict[str, Any]:
|
|
90
|
+
"""Start an update. The API container restarts as part of it, so the call that STARTS an
|
|
91
|
+
update is not the one that reports its outcome — poll `update_status`."""
|
|
92
|
+
return self._post("/admin/update", {"target": target} if target else None)
|
|
93
|
+
|
|
94
|
+
def update_status(self) -> Dict[str, Any]:
|
|
95
|
+
return self._get("/admin/update/status")
|
|
96
|
+
|
|
97
|
+
def deployments(self, limit: int = 20) -> Any:
|
|
98
|
+
return self._get("/admin/deployments", {"limit": limit})
|
|
99
|
+
|
|
100
|
+
def credentials(self) -> Dict[str, Any]:
|
|
101
|
+
"""Connection details for the managed PostGIS/MinIO. Owner-only, and audited."""
|
|
102
|
+
return self._get("/admin/credentials")
|
|
103
|
+
|
|
104
|
+
# ── the public listing ──────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def public_index(self) -> Dict[str, Any]:
|
|
107
|
+
"""Whether this instance publishes `GET /api/public` — its anonymous index."""
|
|
108
|
+
return self._get("/admin/public-index")
|
|
109
|
+
|
|
110
|
+
def set_public_index(self, enabled: bool) -> Dict[str, Any]:
|
|
111
|
+
"""List, or stop listing, what this instance publishes.
|
|
112
|
+
|
|
113
|
+
Discoverability, not access: a published public portal stays reachable by its link either
|
|
114
|
+
way. Audited, because "why did our datasets stop appearing" deserves an answer.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
return self._c.put("/admin/public-index", {"enabled": bool(enabled)})
|
|
118
|
+
except PermissionError_ as exc:
|
|
119
|
+
raise _session_only(exc)
|
|
120
|
+
|
|
121
|
+
# ── audit ───────────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def audit(self, limit: int = 20, offset: int = 0, action: Optional[str] = None,
|
|
124
|
+
resource_type: Optional[str] = None, resource_id: Optional[str] = None,
|
|
125
|
+
actor_id: Optional[int] = None, query: Optional[str] = None,
|
|
126
|
+
since: Optional[str] = None, until: Optional[str] = None) -> Dict[str, Any]:
|
|
127
|
+
"""A PAGE of the activity log. Every filter is applied server-side before the page is cut —
|
|
128
|
+
never fetch the log and filter locally, that searches only the slice you downloaded."""
|
|
129
|
+
return self._get("/audit", {"limit": limit, "offset": offset, "action": action,
|
|
130
|
+
"resource_type": resource_type, "resource_id": resource_id,
|
|
131
|
+
"actor_id": actor_id, "q": query, "since": since,
|
|
132
|
+
"until": until})
|
|
133
|
+
|
|
134
|
+
def audit_actions(self) -> List[str]:
|
|
135
|
+
return self._get("/audit/actions")
|
|
136
|
+
|
|
137
|
+
# ── backups ─────────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def backup_settings(self) -> Dict[str, Any]:
|
|
140
|
+
return self._get("/backups/settings")
|
|
141
|
+
|
|
142
|
+
def backup_runs(self, limit: int = 20) -> Any:
|
|
143
|
+
return self._get("/backups/runs", {"limit": limit})
|
|
144
|
+
|
|
145
|
+
def backup_stored(self) -> Any:
|
|
146
|
+
"""The destination's own manifests — the only trustworthy inventory, since our run table
|
|
147
|
+
lives in the state database that is itself part of what gets backed up."""
|
|
148
|
+
return self._get("/backups/stored")
|
|
149
|
+
|
|
150
|
+
def backup_run(self) -> Any:
|
|
151
|
+
return self._post("/backups/run")
|
|
152
|
+
|
|
153
|
+
def backup_test(self) -> Any:
|
|
154
|
+
return self._post("/backups/settings/test")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Users(object):
|
|
158
|
+
"""Members, roles and invitations — `users:admin` scope, so a token can do this."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, client: Any):
|
|
161
|
+
self._c = client
|
|
162
|
+
|
|
163
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
164
|
+
return self._c.get("/users")
|
|
165
|
+
|
|
166
|
+
def invite(self, email: str, role: str = "viewer") -> Dict[str, Any]:
|
|
167
|
+
"""Create an invitation. The raw token is returned ONCE — regenerate is the only way to get
|
|
168
|
+
a link again. It is emailed too when SMTP is configured, but the link always works."""
|
|
169
|
+
if role not in ("viewer", "editor", "admin"):
|
|
170
|
+
raise ValidationError(400, "Role must be viewer, editor or admin (owner is transferred).")
|
|
171
|
+
return self._c.post("/users/invitations", {"email": email, "role": role})
|
|
172
|
+
|
|
173
|
+
def invitations(self) -> List[Dict[str, Any]]:
|
|
174
|
+
return self._c.get("/users/invitations")
|
|
175
|
+
|
|
176
|
+
def revoke_invitation(self, invitation_id: int) -> Any:
|
|
177
|
+
return self._c.delete("/users/invitations/{0}".format(int(invitation_id)))
|
|
178
|
+
|
|
179
|
+
def regenerate_invitation(self, invitation_id: int) -> Any:
|
|
180
|
+
return self._c.post("/users/invitations/{0}/regenerate".format(int(invitation_id)))
|
|
181
|
+
|
|
182
|
+
def set_role(self, user_id: int, role: str) -> Dict[str, Any]:
|
|
183
|
+
return self._c.put("/users/{0}/role".format(int(user_id)), {"role": role})
|
|
184
|
+
|
|
185
|
+
def delete(self, user_id: int) -> Any:
|
|
186
|
+
"""Delete a member. Their layers, portals and sources are REASSIGNED to the owner —
|
|
187
|
+
nothing of theirs is destroyed."""
|
|
188
|
+
return self._c.delete("/users/{0}".format(int(user_id)))
|
|
189
|
+
|
|
190
|
+
def reset_link(self, user_id: int) -> Dict[str, Any]:
|
|
191
|
+
return self._c.post("/users/{0}/reset-password-link".format(int(user_id)))
|
geodeploy/catalog.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The public read surfaces: STAC, OGC API - Features, templates and basemaps.
|
|
2
|
+
|
|
3
|
+
These need no credentials — they are what an instance offers the rest of the world, and what QGIS,
|
|
4
|
+
ArcGIS, FME and GDAL connect to. Only layers explicitly shared as **public** appear here; the CLI
|
|
5
|
+
reaching them anonymously is therefore also the honest way to check what you have actually exposed:
|
|
6
|
+
if `geodeploy catalog collections` does not list it, nor can anyone else.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Catalog(object):
|
|
14
|
+
def __init__(self, client: Any):
|
|
15
|
+
self._c = client
|
|
16
|
+
|
|
17
|
+
# ── the instance index ──────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
def public(self) -> Dict[str, Any]:
|
|
20
|
+
"""Everything this instance offers anonymously: public portals, and public layers grouped
|
|
21
|
+
by storage kind (`raster`, `postgis`, `geoparquet`).
|
|
22
|
+
|
|
23
|
+
This is the "paste a URL and see what is there" call — the first screen of a plugin. An
|
|
24
|
+
instance may switch its index off, in which case this raises `NotFoundError`: that is a
|
|
25
|
+
decision ("no index here"), distinct from an empty one ("nothing published").
|
|
26
|
+
"""
|
|
27
|
+
return self._c.get("/public", auth=False)
|
|
28
|
+
|
|
29
|
+
def public_portals(self) -> List[Dict[str, Any]]:
|
|
30
|
+
return self._c.get("/public/portals", auth=False) or []
|
|
31
|
+
|
|
32
|
+
def portal_style(self, style_url: str) -> Dict[str, Any]:
|
|
33
|
+
"""A published portal's own `style.json` — sources, layers, folder tree, bounds.
|
|
34
|
+
|
|
35
|
+
The whole portal in one anonymous fetch, which is what makes "open this portal in QGIS"
|
|
36
|
+
possible without a token. The URL comes from `public()`; it is a static file in the
|
|
37
|
+
published bundle, not an API route.
|
|
38
|
+
"""
|
|
39
|
+
response = self._c.send_absolute("GET", style_url)
|
|
40
|
+
if response.status >= 400:
|
|
41
|
+
from .errors import from_status
|
|
42
|
+
raise from_status(response.status, "Could not read the portal style.", response.url)
|
|
43
|
+
return response.json()
|
|
44
|
+
|
|
45
|
+
# ── OGC API - Features ──────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def collections(self) -> List[Dict[str, Any]]:
|
|
48
|
+
"""One collection per public, ready vector layer. Ids mirror the STAC item ids."""
|
|
49
|
+
data = self._c.get("/ogc/collections", auth=False) or {}
|
|
50
|
+
return data.get("collections") or []
|
|
51
|
+
|
|
52
|
+
def collection(self, cid: str) -> Dict[str, Any]:
|
|
53
|
+
return self._c.get("/ogc/collections/{0}".format(cid), auth=False)
|
|
54
|
+
|
|
55
|
+
def items(self, cid: str, bbox: Optional[str] = None, limit: int = 10,
|
|
56
|
+
offset: int = 0) -> Dict[str, Any]:
|
|
57
|
+
return self._c.get("/ogc/collections/{0}/items".format(cid),
|
|
58
|
+
{"bbox": bbox, "limit": limit, "offset": offset}, auth=False)
|
|
59
|
+
|
|
60
|
+
def item(self, cid: str, fid: str) -> Dict[str, Any]:
|
|
61
|
+
return self._c.get("/ogc/collections/{0}/items/{1}".format(cid, fid), auth=False)
|
|
62
|
+
|
|
63
|
+
def conformance(self) -> Dict[str, Any]:
|
|
64
|
+
"""What the OGC endpoint claims to support — Core + GeoJSON, and nothing it does not do."""
|
|
65
|
+
return self._c.get("/ogc/conformance", auth=False)
|
|
66
|
+
|
|
67
|
+
# ── STAC ────────────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def stac(self) -> Dict[str, Any]:
|
|
70
|
+
return self._c.get("/stac", auth=False)
|
|
71
|
+
|
|
72
|
+
def stac_collections(self) -> List[Dict[str, Any]]:
|
|
73
|
+
data = self._c.get("/stac/collections", auth=False) or {}
|
|
74
|
+
return data.get("collections") or []
|
|
75
|
+
|
|
76
|
+
def stac_items(self, cid: str, bbox: Optional[str] = None, datetime: Optional[str] = None,
|
|
77
|
+
limit: int = 100, offset: int = 0) -> Dict[str, Any]:
|
|
78
|
+
return self._c.get("/stac/collections/{0}/items".format(cid),
|
|
79
|
+
{"bbox": bbox, "datetime": datetime, "limit": limit, "offset": offset},
|
|
80
|
+
auth=False)
|
|
81
|
+
|
|
82
|
+
def search(self, bbox: Optional[str] = None, collections: Optional[str] = None,
|
|
83
|
+
datetime: Optional[str] = None, ids: Optional[str] = None,
|
|
84
|
+
limit: int = 100) -> Dict[str, Any]:
|
|
85
|
+
return self._c.get("/stac/search", {"bbox": bbox, "collections": collections,
|
|
86
|
+
"datetime": datetime, "ids": ids, "limit": limit},
|
|
87
|
+
auth=False)
|
|
88
|
+
|
|
89
|
+
# ── portal building blocks ──────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def templates(self) -> List[Dict[str, Any]]:
|
|
92
|
+
"""Portal templates, each declaring the experiences (`archetypes`) it may be used for."""
|
|
93
|
+
return self._c.get("/templates", auth=False)
|
|
94
|
+
|
|
95
|
+
def basemaps(self) -> List[Dict[str, Any]]:
|
|
96
|
+
return self._c.get("/basemaps", auth=False)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The `geodeploy` command-line interface.
|
|
2
|
+
|
|
3
|
+
Everything user-facing lives under here — argument parsing, tables, progress bars, exit codes. The
|
|
4
|
+
package above it (`geodeploy.client` and friends) is a library and never prints or exits, which is
|
|
5
|
+
what lets the QGIS plugin import it without dragging a CLI along.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Argument groups and helpers shared by several command modules.
|
|
2
|
+
|
|
3
|
+
The styling flags live here because THREE commands take the same set — `portals add-layer`,
|
|
4
|
+
`portals style` and `layers style` (a layer's own default). Defining them once is not only less
|
|
5
|
+
code: it is the only way the three stay consistent, and inconsistency between them is exactly the
|
|
6
|
+
kind of thing nobody notices until someone's map is wrong.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from ...errors import ValidationError
|
|
16
|
+
from ...styles import (CLASSIFY_METHODS, COLOR_MODES, LINE_TYPES, MARKERS, RAMPS, build_style,
|
|
17
|
+
parse_categories, parse_classes)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def add_style_args(parser, raster: bool = True) -> None:
|
|
21
|
+
"""Every styling flag the API accepts, including the v1.1 data-driven symbology."""
|
|
22
|
+
single = parser.add_argument_group(
|
|
23
|
+
"symbology (single symbol)",
|
|
24
|
+
"Only the flags you pass are changed; everything else keeps its current value.")
|
|
25
|
+
single.add_argument("--color", help="main colour: polygon fill, line, or point (hex or name)")
|
|
26
|
+
single.add_argument("--fill-opacity", type=float, help="polygon fill opacity, 0-1")
|
|
27
|
+
single.add_argument("--outline-color",
|
|
28
|
+
help="outline colour, or 'none' for no outline at all")
|
|
29
|
+
single.add_argument("--outline-width", type=float,
|
|
30
|
+
help="point outline width as a FRACTION of the radius (0-1, default 0.28) "
|
|
31
|
+
"— a wide one on a small marker is how a ring is drawn")
|
|
32
|
+
single.add_argument("--line-width", type=float, help="line width in px")
|
|
33
|
+
single.add_argument("--radius", type=float, help="point radius in px")
|
|
34
|
+
single.add_argument("--marker", choices=MARKERS, help="point marker shape")
|
|
35
|
+
single.add_argument("--line-type", choices=LINE_TYPES, help="line dash pattern")
|
|
36
|
+
single.add_argument("--opacity", type=float, help="layer opacity, 0-1")
|
|
37
|
+
|
|
38
|
+
driven = parser.add_argument_group(
|
|
39
|
+
"symbology (data-driven, v1.1)",
|
|
40
|
+
"Colour or size from a feature property. --classify asks the instance to compute the "
|
|
41
|
+
"breaks with the same code the editor and the published portal use.")
|
|
42
|
+
driven.add_argument("--color-field", help="property to colour by")
|
|
43
|
+
driven.add_argument("--color-mode", choices=COLOR_MODES,
|
|
44
|
+
help="single | graduated (numeric classes) | categorized (text values)")
|
|
45
|
+
driven.add_argument("--classify", nargs="?", const="quantile", choices=CLASSIFY_METHODS,
|
|
46
|
+
help="compute classes from the data: quantile (default), equal, jenks")
|
|
47
|
+
driven.add_argument("--classes", type=int, default=None,
|
|
48
|
+
help="how many classes to compute (2-12, default 5)")
|
|
49
|
+
driven.add_argument("--ramp", choices=RAMPS, help="colour ramp for computed classes")
|
|
50
|
+
driven.add_argument("--reverse-ramp", action="store_true",
|
|
51
|
+
help="run the ramp the other way (light end for the low values)")
|
|
52
|
+
driven.add_argument("--class-breaks",
|
|
53
|
+
help="explicit classes instead of computing them: '0-10:#fee,10-50:#f00' "
|
|
54
|
+
"(* is an open edge)")
|
|
55
|
+
driven.add_argument("--categories",
|
|
56
|
+
help="explicit categories: 'forest:#2c7,water:#39f'")
|
|
57
|
+
driven.add_argument("--other-color", help="colour for values in no category")
|
|
58
|
+
driven.add_argument("--size-field", help="property to size points/lines by")
|
|
59
|
+
driven.add_argument("--size-stops", help="proportional size: 'value:size,value:size' "
|
|
60
|
+
"(at least two, ascending)")
|
|
61
|
+
driven.add_argument("--no-classification", action="store_true",
|
|
62
|
+
help="drop data-driven colouring and go back to a single symbol")
|
|
63
|
+
|
|
64
|
+
three_d = parser.add_argument_group("3D")
|
|
65
|
+
three_d.add_argument("--extrude", action="store_true", default=None,
|
|
66
|
+
help="extrude by a numeric field (polygons) or draw points as bars")
|
|
67
|
+
three_d.add_argument("--no-extrude", action="store_true", help="turn 3D off")
|
|
68
|
+
three_d.add_argument("--extrude-field", help="numeric property giving the height")
|
|
69
|
+
three_d.add_argument("--extrude-scale", type=float, help="multiply the height by this")
|
|
70
|
+
three_d.add_argument("--extrude-base", help="base height: a number, or a property name")
|
|
71
|
+
three_d.add_argument("--extrude-color", help="override the extrusion colour")
|
|
72
|
+
three_d.add_argument("--extrude-opacity", type=float, help="extrusion opacity, 0-1")
|
|
73
|
+
three_d.add_argument("--extrude-radius", type=float,
|
|
74
|
+
help="POINT bar footprint radius in metres (default: derived from the "
|
|
75
|
+
"layer's own extent, because a fixed one is invisible on a world map)")
|
|
76
|
+
|
|
77
|
+
if raster:
|
|
78
|
+
rast = parser.add_argument_group("raster")
|
|
79
|
+
rast.add_argument("--colormap", help="TiTiler colormap, e.g. viridis (see `layers colormaps`)")
|
|
80
|
+
rast.add_argument("--rescale", help="stretch as 'min,max' (see `layers stats` for a suggestion)")
|
|
81
|
+
rast.add_argument("--algorithm", help="e.g. hillshade (single-band)")
|
|
82
|
+
rast.add_argument("--zfactor", type=float, help="hillshade vertical exaggeration")
|
|
83
|
+
rast.add_argument("--bidx", help="band selection: '1' or '3,2,1' for an RGB composite")
|
|
84
|
+
|
|
85
|
+
parser.add_argument("--style-json",
|
|
86
|
+
help="a JSON object (or @file.json) merged in last — the escape hatch for "
|
|
87
|
+
"anything these flags do not cover")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def style_from_args(args, client=None, layer_ref: Optional[Any] = None,
|
|
91
|
+
base: Optional[Dict[str, Any]] = None, out=None) -> Dict[str, Any]:
|
|
92
|
+
"""Turn the parsed styling flags into a style dict, classifying against the layer if asked."""
|
|
93
|
+
style = dict(base or {})
|
|
94
|
+
|
|
95
|
+
if getattr(args, "classify", None):
|
|
96
|
+
if not getattr(args, "color_field", None):
|
|
97
|
+
raise ValidationError(400, "--classify needs --color-field.")
|
|
98
|
+
if client is None or layer_ref is None: # pragma: no cover - guarded by the callers
|
|
99
|
+
raise ValidationError(400, "Classification needs a layer to read.")
|
|
100
|
+
from ... import styles as styles_mod
|
|
101
|
+
style, stats = styles_mod.classify(
|
|
102
|
+
client, layer_ref, args.color_field, mode=getattr(args, "color_mode", None),
|
|
103
|
+
classes=getattr(args, "classes", None) or 5, method=args.classify,
|
|
104
|
+
ramp=getattr(args, "ramp", None) or "viridis",
|
|
105
|
+
reverse=bool(getattr(args, "reverse_ramp", False)), base=style)
|
|
106
|
+
if out is not None:
|
|
107
|
+
count = len(style.get("classes") or style.get("categories") or [])
|
|
108
|
+
out.info("Classified {0} on {1}: {2} {3} from {4} values.".format(
|
|
109
|
+
args.color_field, args.classify, count,
|
|
110
|
+
"classes" if style.get("color_mode") == "graduated" else "categories",
|
|
111
|
+
(stats or {}).get("count") or (stats or {}).get("total") or "the"))
|
|
112
|
+
|
|
113
|
+
kwargs = {} # type: Dict[str, Any]
|
|
114
|
+
for name in ("color", "fill_opacity", "outline_color", "outline_width", "line_width",
|
|
115
|
+
"radius", "marker", "line_type", "colormap", "rescale", "algorithm", "zfactor",
|
|
116
|
+
"color_field", "color_mode", "size_field", "other_color", "size_stops",
|
|
117
|
+
"extrude_field", "extrude_scale", "extrude_base", "extrude_color",
|
|
118
|
+
"extrude_opacity", "extrude_radius"):
|
|
119
|
+
kwargs[name] = getattr(args, name, None)
|
|
120
|
+
if getattr(args, "bidx", None):
|
|
121
|
+
kwargs["bidx"] = [int(b) for b in str(args.bidx).replace(" ", "").split(",") if b]
|
|
122
|
+
if getattr(args, "extrude", None):
|
|
123
|
+
kwargs["extrude"] = True
|
|
124
|
+
if getattr(args, "no_extrude", False):
|
|
125
|
+
kwargs["extrude"] = False
|
|
126
|
+
if getattr(args, "class_breaks", None):
|
|
127
|
+
kwargs["classes"] = parse_classes(args.class_breaks)
|
|
128
|
+
if getattr(args, "categories", None):
|
|
129
|
+
kwargs["categories"] = parse_categories(args.categories)
|
|
130
|
+
if getattr(args, "no_classification", False):
|
|
131
|
+
kwargs["clear_classification"] = True
|
|
132
|
+
|
|
133
|
+
style = build_style(style, **kwargs)
|
|
134
|
+
|
|
135
|
+
extra = getattr(args, "style_json", None)
|
|
136
|
+
if extra:
|
|
137
|
+
style.update(read_json_arg(extra, "--style-json"))
|
|
138
|
+
return style
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def read_json_arg(value: str, label: str) -> Dict[str, Any]:
|
|
142
|
+
"""A JSON object given inline, or `@path` to read it from a file (or `@-` for stdin).
|
|
143
|
+
|
|
144
|
+
`utf-8-sig` on the file: PowerShell's `>` writes UTF-16 or a BOM, which is how the reference
|
|
145
|
+
script's `portal-set` used to fail on Windows with an unreadable JSON error.
|
|
146
|
+
"""
|
|
147
|
+
text = value
|
|
148
|
+
if value.startswith("@"):
|
|
149
|
+
path = value[1:]
|
|
150
|
+
if path == "-":
|
|
151
|
+
text = sys.stdin.read()
|
|
152
|
+
else:
|
|
153
|
+
with open(path, "r", encoding="utf-8-sig") as fh:
|
|
154
|
+
text = fh.read()
|
|
155
|
+
try:
|
|
156
|
+
data = json.loads(text)
|
|
157
|
+
except ValueError as exc:
|
|
158
|
+
raise ValidationError(400, "{0} is not valid JSON: {1}".format(label, exc))
|
|
159
|
+
if not isinstance(data, dict):
|
|
160
|
+
raise ValidationError(400, "{0} must be a JSON object.".format(label))
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def read_text_arg(value: str) -> str:
|
|
165
|
+
"""Text, or `@file` to read it from disk (Markdown for an About page, typically)."""
|
|
166
|
+
if value.startswith("@"):
|
|
167
|
+
path = value[1:]
|
|
168
|
+
if path == "-":
|
|
169
|
+
return sys.stdin.read()
|
|
170
|
+
with open(path, "r", encoding="utf-8-sig") as fh:
|
|
171
|
+
return fh.read()
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def write_json_file(path: str, payload: Any) -> None:
|
|
176
|
+
"""Write JSON as UTF-8 ourselves rather than letting the shell redirect it.
|
|
177
|
+
|
|
178
|
+
PowerShell's `>` writes UTF-16 with a BOM, which then cannot be read back — the reference CLI
|
|
179
|
+
grew an explicit output-file argument for exactly this reason, and so does this one.
|
|
180
|
+
"""
|
|
181
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
182
|
+
if directory and not os.path.isdir(directory):
|
|
183
|
+
os.makedirs(directory, exist_ok=True)
|
|
184
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
185
|
+
json.dump(payload, fh, indent=2, ensure_ascii=False, default=str)
|
|
186
|
+
fh.write("\n")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def confirm(out, question: str, assume_yes: bool = False, expect: Optional[str] = None) -> bool:
|
|
190
|
+
"""Ask before something irreversible. `--yes` skips it; a non-interactive shell refuses.
|
|
191
|
+
|
|
192
|
+
Refusing rather than assuming yes is deliberate: a script piping into this has not consented to
|
|
193
|
+
a delete, and `--yes` is one character to add when it has.
|
|
194
|
+
"""
|
|
195
|
+
if assume_yes:
|
|
196
|
+
return True
|
|
197
|
+
if not sys.stdin.isatty():
|
|
198
|
+
out.error("Refusing to {0} without --yes when not attached to a terminal.".format(question))
|
|
199
|
+
return False
|
|
200
|
+
if expect:
|
|
201
|
+
answer = input("{0}\nType {1!r} to confirm: ".format(question, expect)).strip()
|
|
202
|
+
return answer == expect
|
|
203
|
+
answer = input("{0} [y/N] ".format(question)).strip().lower()
|
|
204
|
+
return answer in ("y", "yes")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def layer_ref_arg(parser, name: str = "layer", help_text: Optional[str] = None) -> None:
|
|
208
|
+
parser.add_argument(name, help=help_text or
|
|
209
|
+
"layer id, uid, or name (a unique part of the name is enough)")
|
|
210
|
+
parser.add_argument("--type", dest="layer_type", choices=["vector", "raster"],
|
|
211
|
+
help="disambiguate when a vector and a raster share a name")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def resolve_layer(ctx, args, ref_attr: str = "layer", public_ok: bool = False) -> Dict[str, Any]:
|
|
215
|
+
"""Find the layer a command was pointed at, by id, uid or name.
|
|
216
|
+
|
|
217
|
+
`public_ok` marks a command that works on public data alone (downloads, links to shared
|
|
218
|
+
artifacts). With no credential those resolve through the instance's PUBLIC INDEX instead of the
|
|
219
|
+
authenticated layer list, so `geodeploy --url … layers download roads` works for someone who
|
|
220
|
+
has no account — which is the whole point of a public layer.
|
|
221
|
+
"""
|
|
222
|
+
ref = getattr(args, ref_attr)
|
|
223
|
+
kind = getattr(args, "layer_type", None)
|
|
224
|
+
info = ctx.resolved
|
|
225
|
+
if public_ok and not (info.token or info.jwt):
|
|
226
|
+
return ctx.client(auth_required=False).layers.resolve_public(ref, kind)
|
|
227
|
+
return ctx.client().layers.resolve(ref, kind)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def parse_fields(value: Optional[str]) -> Optional[List[str]]:
|
|
231
|
+
if value is None:
|
|
232
|
+
return None
|
|
233
|
+
return [f.strip() for f in value.split(",") if f.strip()]
|