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/cli/output.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Printing, tables, progress and exit codes — the only module allowed to write to a terminal.
|
|
2
|
+
|
|
3
|
+
Three rules:
|
|
4
|
+
|
|
5
|
+
* **stdout is the answer, stderr is the commentary.** `geodeploy layers list --json | jq` must
|
|
6
|
+
receive JSON and nothing else, so progress, warnings and "publish to make this live" hints all go
|
|
7
|
+
to stderr. This is also why a progress bar can never appear on stdout.
|
|
8
|
+
* **`--json` is a contract.** With it, stdout is exactly one JSON document — including for errors,
|
|
9
|
+
which come back as `{"ok": false, "error": …}` so a script can read the failure instead of
|
|
10
|
+
scraping it.
|
|
11
|
+
* **The exit code is the other contract.** A CI job branches on it long before it parses anything.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json as _json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|
21
|
+
|
|
22
|
+
# ── Exit codes ───────────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
# Same matrix the docs publish. Separating AUTH from USAGE from SERVER is what lets a nightly job
|
|
24
|
+
# alert on "your token expired" without alerting on "the instance is restarting".
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_GENERIC = 1 # the operation failed (a 4xx that is not auth, a bad file, a failed job)
|
|
27
|
+
EXIT_USAGE = 2 # the command line itself was wrong — argparse's own convention
|
|
28
|
+
EXIT_AUTH = 3 # 401/403: no credential, expired token, missing scope, or too low a role
|
|
29
|
+
EXIT_NETWORK = 4 # never got an answer: DNS, TLS, connection reset, timeout
|
|
30
|
+
EXIT_SERVER = 5 # 5xx: the instance is up but broke
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _supports_color(stream) -> bool:
|
|
34
|
+
if os.environ.get("NO_COLOR"):
|
|
35
|
+
return False
|
|
36
|
+
if os.environ.get("GEODEPLOY_FORCE_COLOR"):
|
|
37
|
+
return True
|
|
38
|
+
if not hasattr(stream, "isatty") or not stream.isatty():
|
|
39
|
+
return False
|
|
40
|
+
if sys.platform.startswith("win"):
|
|
41
|
+
# Windows 10+ terminals understand ANSI once VT processing is on; enabling it is cheap and
|
|
42
|
+
# failing to enable it just means no colour, never mojibake.
|
|
43
|
+
try:
|
|
44
|
+
import ctypes
|
|
45
|
+
kernel32 = ctypes.windll.kernel32
|
|
46
|
+
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#: Typographic characters, and what to write instead on a console that cannot encode them.
|
|
53
|
+
#: A Windows console still running code page 437 raises UnicodeEncodeError on "✓" — which would
|
|
54
|
+
#: turn a SUCCESSFUL command into a traceback. Degrading the glyph is the only acceptable failure.
|
|
55
|
+
#: `–` (EN dash) is not the same character as `—` (EM dash) and both reach a console: the em dash
|
|
56
|
+
#: from our own output, the en dash from the SERVER, inside graduated legend labels ("10 – 90").
|
|
57
|
+
_FALLBACKS = {"✓": "OK", "→": "->", "—": "-", "–": "-", "…": "...", "·": "-",
|
|
58
|
+
"≥": ">=", "≤": "<="}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _encodable(stream) -> bool:
|
|
62
|
+
encoding = getattr(stream, "encoding", None) or "utf-8"
|
|
63
|
+
try:
|
|
64
|
+
"".join(_FALLBACKS).encode(encoding)
|
|
65
|
+
return True
|
|
66
|
+
except (UnicodeEncodeError, LookupError):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Formatter(object):
|
|
71
|
+
"""Writes everything the CLI shows, honouring --json / --quiet / --verbose / NO_COLOR."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, json_mode: bool = False, quiet: bool = False, verbose: bool = False,
|
|
74
|
+
stdout=None, stderr=None):
|
|
75
|
+
self.json_mode = json_mode
|
|
76
|
+
self.quiet = quiet
|
|
77
|
+
self.verbose = verbose
|
|
78
|
+
self.stdout = stdout or sys.stdout
|
|
79
|
+
self.stderr = stderr or sys.stderr
|
|
80
|
+
self._color = _supports_color(self.stdout) and not json_mode
|
|
81
|
+
self._color_err = _supports_color(self.stderr) and not json_mode
|
|
82
|
+
self._plain = not (_encodable(self.stdout) and _encodable(self.stderr))
|
|
83
|
+
|
|
84
|
+
def _text(self, text: str) -> str:
|
|
85
|
+
if not self._plain:
|
|
86
|
+
return text
|
|
87
|
+
for fancy, plain in _FALLBACKS.items():
|
|
88
|
+
text = text.replace(fancy, plain)
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
# -- styling ---------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def paint(self, text: str, code: str, err: bool = False) -> str:
|
|
94
|
+
enabled = self._color_err if err else self._color
|
|
95
|
+
return "\033[{0}m{1}\033[0m".format(code, text) if enabled else text
|
|
96
|
+
|
|
97
|
+
def dim(self, text: str, err: bool = False) -> str:
|
|
98
|
+
return self.paint(text, "2", err)
|
|
99
|
+
|
|
100
|
+
def bold(self, text: str, err: bool = False) -> str:
|
|
101
|
+
return self.paint(text, "1", err)
|
|
102
|
+
|
|
103
|
+
def green(self, text: str, err: bool = False) -> str:
|
|
104
|
+
return self.paint(text, "32", err)
|
|
105
|
+
|
|
106
|
+
def red(self, text: str, err: bool = False) -> str:
|
|
107
|
+
return self.paint(text, "31", err)
|
|
108
|
+
|
|
109
|
+
def yellow(self, text: str, err: bool = False) -> str:
|
|
110
|
+
return self.paint(text, "33", err)
|
|
111
|
+
|
|
112
|
+
# -- messages --------------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def out(self, text: str = "") -> None:
|
|
115
|
+
"""A line of the ANSWER — the thing the command was asked for."""
|
|
116
|
+
self.stdout.write(self._text(text) + "\n")
|
|
117
|
+
|
|
118
|
+
def info(self, text: str) -> None:
|
|
119
|
+
"""Commentary: what happened, what to do next. Silent under --json/--quiet."""
|
|
120
|
+
if self.json_mode or self.quiet:
|
|
121
|
+
return
|
|
122
|
+
self.stderr.write(self._text(text) + "\n")
|
|
123
|
+
|
|
124
|
+
def success(self, text: str) -> None:
|
|
125
|
+
if self.json_mode or self.quiet:
|
|
126
|
+
return
|
|
127
|
+
self.stderr.write(self._text(self.green("✓ ", err=True) + text) + "\n")
|
|
128
|
+
|
|
129
|
+
def warn(self, text: str) -> None:
|
|
130
|
+
if self.json_mode or self.quiet:
|
|
131
|
+
return
|
|
132
|
+
self.stderr.write(self._text(self.yellow("warning: ", err=True) + text) + "\n")
|
|
133
|
+
|
|
134
|
+
def error(self, text: str, hint: Optional[str] = None) -> None:
|
|
135
|
+
"""Errors always print, even under --quiet — silence on failure is how a CI job lies."""
|
|
136
|
+
if self.json_mode:
|
|
137
|
+
payload = {"ok": False, "error": text}
|
|
138
|
+
if hint:
|
|
139
|
+
payload["hint"] = hint
|
|
140
|
+
self.stdout.write(_json.dumps(payload, indent=2) + "\n")
|
|
141
|
+
return
|
|
142
|
+
self.stderr.write(self._text(self.red("error: ", err=True) + text) + "\n")
|
|
143
|
+
if hint:
|
|
144
|
+
self.stderr.write(self._text(self.dim(" " + hint, err=True)) + "\n")
|
|
145
|
+
|
|
146
|
+
def debug(self, text: str) -> None:
|
|
147
|
+
if self.verbose and not self.json_mode:
|
|
148
|
+
self.stderr.write(self._text(self.dim("· " + text, err=True)) + "\n")
|
|
149
|
+
|
|
150
|
+
# -- structured output -----------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def json(self, payload: Any) -> None:
|
|
153
|
+
self.stdout.write(_json.dumps(payload, indent=2, default=str, ensure_ascii=False) + "\n")
|
|
154
|
+
|
|
155
|
+
def render(self, payload: Any, columns: Optional[Sequence[Any]] = None,
|
|
156
|
+
empty: str = "Nothing to show.") -> None:
|
|
157
|
+
"""The default way a command answers: JSON under --json, otherwise a table or a record."""
|
|
158
|
+
if self.json_mode:
|
|
159
|
+
self.json(payload)
|
|
160
|
+
return
|
|
161
|
+
if isinstance(payload, list):
|
|
162
|
+
if not payload:
|
|
163
|
+
self.info(empty)
|
|
164
|
+
return
|
|
165
|
+
self.table(payload, columns)
|
|
166
|
+
elif isinstance(payload, dict):
|
|
167
|
+
self.record(payload, columns)
|
|
168
|
+
elif payload is not None:
|
|
169
|
+
self.out(str(payload))
|
|
170
|
+
|
|
171
|
+
def table(self, rows: List[Dict[str, Any]], columns: Optional[Sequence[Any]] = None) -> None:
|
|
172
|
+
"""A plain aligned table. `columns` is a list of keys, or (key, heading) pairs."""
|
|
173
|
+
if not rows:
|
|
174
|
+
return
|
|
175
|
+
if columns:
|
|
176
|
+
cols = [(c, c.replace("_", " ")) if isinstance(c, str) else c for c in columns]
|
|
177
|
+
else:
|
|
178
|
+
keys = [] # type: List[str]
|
|
179
|
+
for row in rows:
|
|
180
|
+
for key in row:
|
|
181
|
+
if key not in keys:
|
|
182
|
+
keys.append(key)
|
|
183
|
+
cols = [(k, k.replace("_", " ")) for k in keys]
|
|
184
|
+
|
|
185
|
+
cells = [[_cell(row.get(key)) for key, _ in cols] for row in rows]
|
|
186
|
+
widths = [len(str(head)) for _, head in cols]
|
|
187
|
+
for line in cells:
|
|
188
|
+
for i, value in enumerate(line):
|
|
189
|
+
widths[i] = max(widths[i], len(value))
|
|
190
|
+
# Keep the table inside the terminal: trim the widest column rather than wrapping, since a
|
|
191
|
+
# wrapped table is unreadable and the full value is one --json away.
|
|
192
|
+
budget = (shutil.get_terminal_size((100, 24)).columns
|
|
193
|
+
if hasattr(self.stdout, "isatty") and self.stdout.isatty() else 1000)
|
|
194
|
+
while sum(widths) + 2 * (len(widths) - 1) > budget and max(widths) > 8:
|
|
195
|
+
widths[widths.index(max(widths))] -= 1
|
|
196
|
+
|
|
197
|
+
self.out(self.dim(" ".join(
|
|
198
|
+
str(head).upper().ljust(widths[i]) for i, (_, head) in enumerate(cols))))
|
|
199
|
+
for line in cells:
|
|
200
|
+
self.out(" ".join(_fit(value, widths[i]) for i, value in enumerate(line)))
|
|
201
|
+
|
|
202
|
+
def record(self, row: Dict[str, Any], keys: Optional[Sequence[str]] = None) -> None:
|
|
203
|
+
"""One object as `key: value` lines — a portal, a layer, a job."""
|
|
204
|
+
items = [(k, row.get(k)) for k in keys] if keys else list(row.items())
|
|
205
|
+
width = max((len(str(k)) for k, _ in items), default=0)
|
|
206
|
+
for key, value in items:
|
|
207
|
+
if value is None and keys is None:
|
|
208
|
+
continue
|
|
209
|
+
self.out("{0} {1}".format(self.dim(str(key).ljust(width)), _cell(value, wide=True)))
|
|
210
|
+
|
|
211
|
+
def progress(self, label: str, total: Optional[int] = None) -> "Progress":
|
|
212
|
+
return Progress(self, label, total)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Progress(object):
|
|
216
|
+
"""A one-line progress bar on stderr, and a no-op when nobody is watching.
|
|
217
|
+
|
|
218
|
+
Rate and remaining bytes are shown rather than a spinner because the question during a 4 GB
|
|
219
|
+
upload is always "will this finish before I have to leave", and only a rate answers it.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self, fmt: Formatter, label: str, total: Optional[int] = None):
|
|
223
|
+
self._f = fmt
|
|
224
|
+
self.label = label
|
|
225
|
+
self.total = total
|
|
226
|
+
self._last = 0.0
|
|
227
|
+
self._started = time.time()
|
|
228
|
+
self._active = (not fmt.json_mode and not fmt.quiet
|
|
229
|
+
and hasattr(fmt.stderr, "isatty") and fmt.stderr.isatty())
|
|
230
|
+
self._done = False
|
|
231
|
+
|
|
232
|
+
def update(self, done: int, total: Optional[int] = None) -> None:
|
|
233
|
+
if not self._active:
|
|
234
|
+
return
|
|
235
|
+
if total:
|
|
236
|
+
self.total = total
|
|
237
|
+
now = time.time()
|
|
238
|
+
complete = self.total and done >= self.total
|
|
239
|
+
if not complete and now - self._last < 0.1: # ~10 fps is plenty and keeps the CPU idle
|
|
240
|
+
return
|
|
241
|
+
self._last = now
|
|
242
|
+
elapsed = max(now - self._started, 1e-6)
|
|
243
|
+
rate = done / elapsed
|
|
244
|
+
if self.total:
|
|
245
|
+
fraction = min(max(done / float(self.total), 0.0), 1.0)
|
|
246
|
+
filled = int(round(fraction * 24))
|
|
247
|
+
bar = "#" * filled + "-" * (24 - filled)
|
|
248
|
+
text = "{0} [{1}] {2:3.0f}% {3} / {4} {5}/s".format(
|
|
249
|
+
self.label, bar, fraction * 100, human_size(done), human_size(self.total),
|
|
250
|
+
human_size(rate))
|
|
251
|
+
else:
|
|
252
|
+
text = "{0} {1} {2}/s".format(self.label, human_size(done), human_size(rate))
|
|
253
|
+
self._f.stderr.write("\r\033[K" + text)
|
|
254
|
+
self._f.stderr.flush()
|
|
255
|
+
|
|
256
|
+
def finish(self, message: Optional[str] = None) -> None:
|
|
257
|
+
if self._done:
|
|
258
|
+
return
|
|
259
|
+
self._done = True
|
|
260
|
+
if self._active:
|
|
261
|
+
self._f.stderr.write("\r\033[K")
|
|
262
|
+
self._f.stderr.flush()
|
|
263
|
+
if message:
|
|
264
|
+
self._f.info(message)
|
|
265
|
+
|
|
266
|
+
def __enter__(self) -> "Progress":
|
|
267
|
+
return self
|
|
268
|
+
|
|
269
|
+
def __exit__(self, *exc: Any) -> None:
|
|
270
|
+
self.finish()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── value formatting ─────────────────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
def human_size(num: float) -> str:
|
|
276
|
+
"""Bytes as something readable. Binary units, because storage tooling reports binary units."""
|
|
277
|
+
value = float(num or 0)
|
|
278
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
279
|
+
if value < 1024 or unit == "TB":
|
|
280
|
+
return "{0:.0f} {1}".format(value, unit) if unit == "B" else "{0:.1f} {1}".format(value, unit)
|
|
281
|
+
value /= 1024.0
|
|
282
|
+
return "{0:.1f} TB".format(value) # pragma: no cover - unreachable, loop returns
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def human_count(num: Any) -> str:
|
|
286
|
+
try:
|
|
287
|
+
return "{0:,}".format(int(num))
|
|
288
|
+
except (TypeError, ValueError):
|
|
289
|
+
return "—"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _cell(value: Any, wide: bool = False) -> str:
|
|
293
|
+
if value is None:
|
|
294
|
+
return "—"
|
|
295
|
+
if isinstance(value, bool):
|
|
296
|
+
return "yes" if value else "no"
|
|
297
|
+
if isinstance(value, (list, tuple)):
|
|
298
|
+
if not value:
|
|
299
|
+
return "—"
|
|
300
|
+
if all(isinstance(v, (int, float)) for v in value):
|
|
301
|
+
return ", ".join("{0:g}".format(v) for v in value)
|
|
302
|
+
return _json.dumps(value) if wide else "{0} item{1}".format(
|
|
303
|
+
len(value), "" if len(value) == 1 else "s")
|
|
304
|
+
if isinstance(value, dict):
|
|
305
|
+
return _json.dumps(value) if wide else "{0} key{1}".format(
|
|
306
|
+
len(value), "" if len(value) == 1 else "s")
|
|
307
|
+
text = str(value)
|
|
308
|
+
if len(text) == 19 and text[4] == "-" and text[10] == "T": # an ISO timestamp
|
|
309
|
+
return text.replace("T", " ")
|
|
310
|
+
return text
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _fit(text: str, width: int) -> str:
|
|
314
|
+
if len(text) <= width:
|
|
315
|
+
return text.ljust(width)
|
|
316
|
+
return text[: max(1, width - 1)] + "…"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def iter_rows(payload: Any) -> Iterable[Dict[str, Any]]: # pragma: no cover - convenience
|
|
320
|
+
return payload if isinstance(payload, list) else [payload]
|
geodeploy/client.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""The GeoDeploy API client.
|
|
2
|
+
|
|
3
|
+
This is the piece the QGIS plugin imports. It is deliberately a *library*: it never prints, never
|
|
4
|
+
reads the config file, never calls `sys.exit` — it takes a URL and a credential, makes requests, and
|
|
5
|
+
raises the typed errors in `errors.py`. Everything user-facing lives in `geodeploy.cli`.
|
|
6
|
+
|
|
7
|
+
from geodeploy import Client
|
|
8
|
+
gd = Client("https://geodeploy.example.org", token="gdp_…")
|
|
9
|
+
gd.whoami()
|
|
10
|
+
gd.uploads.upload("roads.gpkg", wait=True)
|
|
11
|
+
gd.portals.publish(3)
|
|
12
|
+
|
|
13
|
+
Grouping is by API area (`gd.vector`, `gd.portals`, `gd.admin`, …) so that discovering the client
|
|
14
|
+
in an editor mirrors discovering the API in `/api/docs`.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json as _json
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
21
|
+
from urllib.parse import quote, urlencode, urljoin
|
|
22
|
+
|
|
23
|
+
from . import errors
|
|
24
|
+
from .transport import Request, Response, UrllibTransport
|
|
25
|
+
|
|
26
|
+
__all__ = ["Client"]
|
|
27
|
+
|
|
28
|
+
#: Sent on every request. An instance's access log is where an operator works out that "the API is
|
|
29
|
+
#: hammering us" is in fact someone's nightly CLI job, so it names the tool and its version.
|
|
30
|
+
def _default_user_agent() -> str:
|
|
31
|
+
from . import __version__
|
|
32
|
+
return "geodeploy-cli/{0}".format(__version__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Client(object):
|
|
36
|
+
"""A connection to one GeoDeploy instance.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
url: instance origin, e.g. ``https://geodeploy.example.org`` (with or without ``/api``).
|
|
40
|
+
token: a scoped API token (``gdp_…``) from Settings → API tokens.
|
|
41
|
+
jwt: a session JWT instead of a token — what ``geodeploy login --password`` obtains. Admin
|
|
42
|
+
routes (`/admin/*`, ownership transfer, token management) REJECT API tokens by design,
|
|
43
|
+
so anything under `gd.admin` needs this rather than a `gdp_` token.
|
|
44
|
+
transport: anything with ``send(Request) -> Response``. Swap in a Qt/QGIS network stack to
|
|
45
|
+
inherit the host's proxy and certificate configuration.
|
|
46
|
+
timeout: seconds for ordinary API calls.
|
|
47
|
+
upload_timeout: seconds for calls that move file bytes — a 5 GB PUT over a slow link is not
|
|
48
|
+
a hung request, and killing it at 120 s would make big uploads impossible.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, url: str, token: Optional[str] = None, jwt: Optional[str] = None,
|
|
52
|
+
transport: Optional[Any] = None, timeout: float = 120.0,
|
|
53
|
+
upload_timeout: float = 3600.0, user_agent: Optional[str] = None,
|
|
54
|
+
verify_tls: bool = True, retries: int = 2,
|
|
55
|
+
on_request: Optional[Callable[[str, str], None]] = None):
|
|
56
|
+
from .config import normalize_url
|
|
57
|
+
self.url = normalize_url(url)
|
|
58
|
+
self.token = token or None
|
|
59
|
+
self.jwt = jwt or None
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.upload_timeout = upload_timeout
|
|
62
|
+
self.user_agent = user_agent or _default_user_agent()
|
|
63
|
+
self.transport = transport or UrllibTransport(verify_tls=verify_tls, retries=retries)
|
|
64
|
+
#: Called with (method, url) before each request — the CLI's `-v` uses it, and a plugin can
|
|
65
|
+
#: route it to the QGIS message log without this module knowing what logging is.
|
|
66
|
+
self.on_request = on_request
|
|
67
|
+
|
|
68
|
+
# Namespaces. Imported here rather than at module scope because each one imports this
|
|
69
|
+
# module for typing; the cost is one attribute lookup at construction.
|
|
70
|
+
from .admin import Admin, Users
|
|
71
|
+
from .catalog import Catalog
|
|
72
|
+
from .imports import Imports
|
|
73
|
+
from .jobs import Jobs
|
|
74
|
+
from .layers import Layers, RasterLayers, VectorLayers
|
|
75
|
+
from .portals import Portals
|
|
76
|
+
from .sources import Sources
|
|
77
|
+
from .uploads import Uploads
|
|
78
|
+
|
|
79
|
+
self.vector = VectorLayers(self)
|
|
80
|
+
self.raster = RasterLayers(self)
|
|
81
|
+
#: Backend-agnostic helpers: resolve a layer by id/uid/name across both kinds.
|
|
82
|
+
self.layers = Layers(self)
|
|
83
|
+
self.portals = Portals(self)
|
|
84
|
+
self.sources = Sources(self)
|
|
85
|
+
self.imports = Imports(self)
|
|
86
|
+
self.jobs = Jobs(self)
|
|
87
|
+
self.uploads = Uploads(self)
|
|
88
|
+
self.admin = Admin(self)
|
|
89
|
+
self.users = Users(self)
|
|
90
|
+
self.catalog = Catalog(self)
|
|
91
|
+
|
|
92
|
+
# ── URLs ─────────────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def api_url(self, path: str, params: Optional[Dict[str, Any]] = None) -> str:
|
|
95
|
+
"""Absolute URL for an API path. `path` is relative to `/api` unless it already starts there."""
|
|
96
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
97
|
+
base = path
|
|
98
|
+
else:
|
|
99
|
+
clean = path if path.startswith("/") else "/" + path
|
|
100
|
+
if not clean.startswith("/api"):
|
|
101
|
+
clean = "/api" + clean
|
|
102
|
+
base = self.url + clean
|
|
103
|
+
query = _query(params)
|
|
104
|
+
return base + (("&" if "?" in base else "?") + query if query else "")
|
|
105
|
+
|
|
106
|
+
def absolute(self, url_or_path: str) -> str:
|
|
107
|
+
"""Resolve something the API handed back (`/s3/…?X-Amz-…`) against this instance.
|
|
108
|
+
|
|
109
|
+
Presigned upload URLs come back RELATIVE for a managed MinIO — nginx proxies `/s3/` with
|
|
110
|
+
the signed Host preserved — and absolute for an external S3 endpoint. Both must work, and
|
|
111
|
+
only the client knows the origin.
|
|
112
|
+
"""
|
|
113
|
+
if url_or_path.startswith("http://") or url_or_path.startswith("https://"):
|
|
114
|
+
return url_or_path
|
|
115
|
+
return urljoin(self.url + "/", url_or_path.lstrip("/"))
|
|
116
|
+
|
|
117
|
+
# ── Requests ─────────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None,
|
|
120
|
+
json: Any = None, body: Any = None, headers: Optional[Dict[str, str]] = None,
|
|
121
|
+
timeout: Optional[float] = None, auth: bool = True,
|
|
122
|
+
parse: bool = True, content_type: Optional[str] = None) -> Any:
|
|
123
|
+
"""One API call. Returns parsed JSON by default, or the raw `Response` when `parse=False`."""
|
|
124
|
+
url = self.api_url(path, params)
|
|
125
|
+
hdrs = {"Accept": "application/json", "User-Agent": self.user_agent}
|
|
126
|
+
if auth:
|
|
127
|
+
hdrs.update(self.auth_headers())
|
|
128
|
+
if headers:
|
|
129
|
+
hdrs.update(headers)
|
|
130
|
+
|
|
131
|
+
data = body
|
|
132
|
+
if json is not None:
|
|
133
|
+
data = _json.dumps(json).encode("utf-8")
|
|
134
|
+
hdrs.setdefault("Content-Type", "application/json")
|
|
135
|
+
if content_type:
|
|
136
|
+
hdrs["Content-Type"] = content_type
|
|
137
|
+
|
|
138
|
+
if self.on_request:
|
|
139
|
+
self.on_request(method.upper(), url)
|
|
140
|
+
response = self.transport.send(
|
|
141
|
+
Request(method, url, hdrs, data, timeout if timeout is not None else self.timeout))
|
|
142
|
+
return self._handle(response, parse)
|
|
143
|
+
|
|
144
|
+
def get(self, path: str, params: Optional[Dict[str, Any]] = None, **kw: Any) -> Any:
|
|
145
|
+
return self.request("GET", path, params=params, **kw)
|
|
146
|
+
|
|
147
|
+
def post(self, path: str, json: Any = None, **kw: Any) -> Any:
|
|
148
|
+
return self.request("POST", path, json=json, **kw)
|
|
149
|
+
|
|
150
|
+
def put(self, path: str, json: Any = None, **kw: Any) -> Any:
|
|
151
|
+
return self.request("PUT", path, json=json, **kw)
|
|
152
|
+
|
|
153
|
+
def delete(self, path: str, **kw: Any) -> Any:
|
|
154
|
+
return self.request("DELETE", path, **kw)
|
|
155
|
+
|
|
156
|
+
def send_absolute(self, method: str, url: str, body: Any = None,
|
|
157
|
+
headers: Optional[Dict[str, str]] = None,
|
|
158
|
+
timeout: Optional[float] = None) -> Response:
|
|
159
|
+
"""A request to a URL that is NOT the API — a presigned storage PUT.
|
|
160
|
+
|
|
161
|
+
Auth headers are deliberately absent: a presigned URL carries its own signature in the
|
|
162
|
+
query string, and an extra `Authorization` header makes S3 reject the request as
|
|
163
|
+
double-authenticated. (The UI's api client has the same note for the same reason.)
|
|
164
|
+
"""
|
|
165
|
+
hdrs = {"User-Agent": self.user_agent}
|
|
166
|
+
hdrs.update(headers or {})
|
|
167
|
+
if self.on_request:
|
|
168
|
+
self.on_request(method.upper(), url)
|
|
169
|
+
return self.transport.send(
|
|
170
|
+
Request(method, self.absolute(url), hdrs, body,
|
|
171
|
+
timeout if timeout is not None else self.upload_timeout))
|
|
172
|
+
|
|
173
|
+
def download(self, path: str, sink, params: Optional[Dict[str, Any]] = None,
|
|
174
|
+
auth: bool = True, timeout: Optional[float] = None) -> Response:
|
|
175
|
+
"""Stream a GET into a writable binary file object (COG, PMTiles, an export bundle)."""
|
|
176
|
+
url = self.api_url(path, params)
|
|
177
|
+
hdrs = {"User-Agent": self.user_agent}
|
|
178
|
+
if auth:
|
|
179
|
+
hdrs.update(self.auth_headers())
|
|
180
|
+
streamer = getattr(self.transport, "stream", None)
|
|
181
|
+
if streamer is None: # a custom transport without streaming: fall back to buffered
|
|
182
|
+
response = self.transport.send(Request("GET", url, hdrs, None,
|
|
183
|
+
timeout or self.upload_timeout))
|
|
184
|
+
self._handle(response, parse=False)
|
|
185
|
+
sink.write(response.content)
|
|
186
|
+
return response
|
|
187
|
+
response = streamer(Request("GET", url, hdrs, None, timeout or self.upload_timeout), sink)
|
|
188
|
+
return self._handle(response, parse=False)
|
|
189
|
+
|
|
190
|
+
def auth_headers(self) -> Dict[str, str]:
|
|
191
|
+
if self.token:
|
|
192
|
+
return {"Authorization": "Bearer " + self.token}
|
|
193
|
+
if self.jwt:
|
|
194
|
+
return {"Authorization": "Bearer " + self.jwt}
|
|
195
|
+
return {}
|
|
196
|
+
|
|
197
|
+
# ── Responses ────────────────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def _handle(self, response: Response, parse: bool = True) -> Any:
|
|
200
|
+
if response.status >= 400:
|
|
201
|
+
raise errors.from_status(response.status, _detail(response), response.url,
|
|
202
|
+
_safe_json(response))
|
|
203
|
+
if not parse:
|
|
204
|
+
return response
|
|
205
|
+
if response.status == 204 or not response.content:
|
|
206
|
+
return None
|
|
207
|
+
ctype = response.headers.get("content-type", "")
|
|
208
|
+
if "json" in ctype:
|
|
209
|
+
return response.json()
|
|
210
|
+
return response.text
|
|
211
|
+
|
|
212
|
+
# ── Identity ─────────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def whoami(self) -> Dict[str, Any]:
|
|
215
|
+
"""The authenticated user (`GET /auth/me`). Works for both a token and a session JWT."""
|
|
216
|
+
return self.get("/auth/me")
|
|
217
|
+
|
|
218
|
+
def login(self, email: str, password: str) -> str:
|
|
219
|
+
"""Exchange a password for a session JWT, store it on this client, and return it.
|
|
220
|
+
|
|
221
|
+
Needed for the routes that refuse API tokens on purpose (`/admin/*`, `/tokens`, ownership
|
|
222
|
+
transfer): a leaked token must not be able to reconfigure the instance or mint more tokens.
|
|
223
|
+
"""
|
|
224
|
+
form = urlencode({"username": email, "password": password}).encode()
|
|
225
|
+
data = self.request("POST", "/auth/login", body=form, auth=False,
|
|
226
|
+
content_type="application/x-www-form-urlencoded")
|
|
227
|
+
jwt = (data or {}).get("access_token")
|
|
228
|
+
if not jwt:
|
|
229
|
+
raise errors.AuthError(401, "Login did not return a token.", self.api_url("/auth/login"))
|
|
230
|
+
self.jwt = jwt
|
|
231
|
+
self.token = None # a session supersedes any token on this client instance
|
|
232
|
+
return jwt
|
|
233
|
+
|
|
234
|
+
def setup_status(self) -> Dict[str, Any]:
|
|
235
|
+
"""Public: whether the instance is set up, and which login methods it offers."""
|
|
236
|
+
return self.get("/setup/status", auth=False)
|
|
237
|
+
|
|
238
|
+
def tokens(self) -> Any:
|
|
239
|
+
"""List the acting user's API tokens (session auth only — a token cannot list tokens)."""
|
|
240
|
+
return self.get("/tokens")
|
|
241
|
+
|
|
242
|
+
def create_token(self, name: str, scopes, expires_in_days: int = 90) -> Dict[str, Any]:
|
|
243
|
+
"""Mint an API token. The raw `gdp_…` secret is in the response and never retrievable again."""
|
|
244
|
+
return self.post("/tokens", {"name": name, "scopes": list(scopes),
|
|
245
|
+
"expires_in_days": expires_in_days})
|
|
246
|
+
|
|
247
|
+
def revoke_token(self, token_id: int) -> Any:
|
|
248
|
+
return self.delete("/tokens/{0}".format(int(token_id)))
|
|
249
|
+
|
|
250
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
251
|
+
how = "token" if self.token else ("session" if self.jwt else "anonymous")
|
|
252
|
+
return "<geodeploy.Client {0} ({1})>".format(self.url, how)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
def _query(params: Optional[Dict[str, Any]]) -> str:
|
|
258
|
+
"""Encode query params, dropping Nones and flattening lists — `?bbox=…&ids=a&ids=b`."""
|
|
259
|
+
if not params:
|
|
260
|
+
return ""
|
|
261
|
+
pairs = []
|
|
262
|
+
for key, value in params.items():
|
|
263
|
+
if value is None:
|
|
264
|
+
continue
|
|
265
|
+
if isinstance(value, bool):
|
|
266
|
+
pairs.append((key, "true" if value else "false"))
|
|
267
|
+
elif isinstance(value, (list, tuple)):
|
|
268
|
+
for item in value:
|
|
269
|
+
if item is not None:
|
|
270
|
+
pairs.append((key, str(item)))
|
|
271
|
+
else:
|
|
272
|
+
pairs.append((key, str(value)))
|
|
273
|
+
return urlencode(pairs)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _safe_json(response: Response) -> Any:
|
|
277
|
+
try:
|
|
278
|
+
return response.json()
|
|
279
|
+
except ValueError:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _detail(response: Response) -> str:
|
|
284
|
+
"""The most useful one-line message an error response contains.
|
|
285
|
+
|
|
286
|
+
FastAPI puts a string in `detail` for a raised HTTPException and a LIST of field errors there
|
|
287
|
+
for a validation failure; nginx returns HTML. All three have to become something a person can
|
|
288
|
+
read on one line, because that line is what the CLI prints.
|
|
289
|
+
"""
|
|
290
|
+
payload = _safe_json(response)
|
|
291
|
+
if isinstance(payload, dict):
|
|
292
|
+
detail = payload.get("detail")
|
|
293
|
+
if isinstance(detail, str):
|
|
294
|
+
return detail
|
|
295
|
+
if isinstance(detail, list):
|
|
296
|
+
parts = []
|
|
297
|
+
for item in detail:
|
|
298
|
+
if isinstance(item, dict):
|
|
299
|
+
loc = ".".join(str(x) for x in (item.get("loc") or [])[1:])
|
|
300
|
+
parts.append("{0}: {1}".format(loc, item.get("msg")) if loc else str(item.get("msg")))
|
|
301
|
+
else:
|
|
302
|
+
parts.append(str(item))
|
|
303
|
+
return "; ".join(parts)
|
|
304
|
+
if detail is not None:
|
|
305
|
+
return str(detail)
|
|
306
|
+
for key in ("message", "error"):
|
|
307
|
+
if payload.get(key):
|
|
308
|
+
return str(payload[key])
|
|
309
|
+
text = (response.text or "").strip()
|
|
310
|
+
if text.startswith("<"): # an nginx/proxy HTML page — the status is the only real information
|
|
311
|
+
return {413: "Request body too large for the server or a proxy in front of it.",
|
|
312
|
+
502: "Bad gateway — the instance is up but the API did not answer.",
|
|
313
|
+
504: "Gateway timeout."}.get(response.status, "HTTP {0}".format(response.status))
|
|
314
|
+
return text[:500] or "HTTP {0}".format(response.status)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def path_segment(value: Any) -> str:
|
|
318
|
+
"""URL-quote one path segment (a slug, a uid, a storage key)."""
|
|
319
|
+
return quote(str(value), safe="")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def env_client(**kw: Any) -> Client:
|
|
323
|
+
"""A client from `GEODEPLOY_URL` / `GEODEPLOY_TOKEN` alone — for scripts and doctests."""
|
|
324
|
+
url = os.environ.get("GEODEPLOY_URL")
|
|
325
|
+
if not url:
|
|
326
|
+
raise errors.ConfigError("Set GEODEPLOY_URL (and usually GEODEPLOY_TOKEN).")
|
|
327
|
+
return Client(url, token=os.environ.get("GEODEPLOY_TOKEN"), **kw)
|