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,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)