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/styles.py ADDED
@@ -0,0 +1,467 @@
1
+ """Symbology — the style dict a layer config carries, built from plain arguments.
2
+
3
+ The vocabulary is `api/geodeploy/services/symbology.py`'s, exactly: single-symbol keys (`color`,
4
+ `fill_opacity`, `outline_color`, `line_width`, `radius`, `marker`, `lineType`) plus the v1.1
5
+ data-driven ones (`color_mode`/`color_field`/`classes`/`categories`/`other_color`,
6
+ `size_mode`/`size_field`/`size_stops`, `extrusion`). This module only ASSEMBLES that dict.
7
+
8
+ **The classification maths is not reimplemented here, on purpose.** Breaks come from
9
+ `GET /data/vector/{ref}/field-stats`, which computes them with the same module the editor preview
10
+ and the published portal use. A second quantile implementation in the client would eventually
11
+ disagree with the server's, and the disagreement would first be visible as a published map whose
12
+ legend does not match its colours — the exact failure `services/symbology.py` was extracted to
13
+ prevent.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
18
+
19
+ from .errors import ValidationError
20
+
21
+ #: Named ramps the server knows (`symbology.RAMPS`). Listed so the CLI can reject a typo up front
22
+ #: instead of silently falling back to viridis on the server.
23
+ RAMPS = ("viridis", "magma", "blues", "reds", "greens", "oranges", "rdbu", "brbg", "spectral")
24
+
25
+ MARKERS = ("circle", "square", "triangle", "diamond", "star", "cross")
26
+ LINE_TYPES = ("solid", "dashed", "dotted")
27
+ CLASSIFY_METHODS = ("quantile", "equal", "jenks")
28
+ COLOR_MODES = ("single", "graduated", "categorized")
29
+
30
+ #: `outline_color="none"` means draw NO outline — a sentinel, not an empty string, because "" is
31
+ #: what an uninitialised colour input produces and would silently turn outlines off.
32
+ NO_OUTLINE = "none"
33
+
34
+ #: CLI/keyword name → the key the style dict actually uses. Only `line_type` differs, and it
35
+ #: differs because the stored key is camelCase (`lineType`) for historical reasons.
36
+ _SIMPLE_KEYS = (
37
+ ("color", "color"),
38
+ ("fill_opacity", "fill_opacity"),
39
+ ("outline_color", "outline_color"),
40
+ ("outline_width", "outline_width"),
41
+ ("line_width", "line_width"),
42
+ ("radius", "radius"),
43
+ ("marker", "marker"),
44
+ ("line_type", "lineType"),
45
+ ("colormap", "colormap"),
46
+ ("rescale", "rescale"),
47
+ ("algorithm", "algorithm"),
48
+ ("zfactor", "zfactor"),
49
+ ("bidx", "bidx"),
50
+ ("other_color", "other_color"),
51
+ ("color_field", "color_field"),
52
+ ("color_mode", "color_mode"),
53
+ ("color_ramp", "color_ramp"),
54
+ ("color_ramp_reverse", "color_ramp_reverse"),
55
+ ("classes_n", "classes_n"),
56
+ ("size_field", "size_field"),
57
+ ("size_mode", "size_mode"),
58
+ )
59
+
60
+
61
+ def build_style(base: Optional[Dict[str, Any]] = None, **kw: Any) -> Dict[str, Any]:
62
+ """Merge only the styling arguments that were actually given onto `base`.
63
+
64
+ "Only what was given" is the whole contract: `geodeploy portals style … --color red` must not
65
+ reset the marker shape and the classes someone spent ten minutes on, so an argument left at
66
+ None is absent, not a default.
67
+ """
68
+ style = dict(base or {})
69
+ for arg, key in _SIMPLE_KEYS:
70
+ value = kw.get(arg)
71
+ if value is not None:
72
+ style[key] = value
73
+
74
+ _validate(style)
75
+
76
+ stops = kw.get("size_stops")
77
+ if stops is not None:
78
+ style["size_stops"] = parse_size_stops(stops)
79
+ style.setdefault("size_mode", "proportional")
80
+ if style.get("size_mode") == "proportional" and not style.get("size_field"):
81
+ raise ValidationError(400, "Proportional size needs a field (--size-field).")
82
+
83
+ classes = kw.get("classes")
84
+ if classes is not None:
85
+ style["classes"] = classes
86
+ style["color_mode"] = "graduated"
87
+ categories = kw.get("categories")
88
+ if categories is not None:
89
+ style["categories"] = categories
90
+ style["color_mode"] = "categorized"
91
+
92
+ extrusion = build_extrusion(style.get("extrusion"), **kw)
93
+ if extrusion is not None:
94
+ style["extrusion"] = extrusion
95
+
96
+ if kw.get("clear_classification"):
97
+ for key in ("color_mode", "color_field", "classes", "categories", "other_color"):
98
+ style.pop(key, None)
99
+ return style
100
+
101
+
102
+ def build_extrusion(base: Optional[Dict[str, Any]] = None, **kw: Any) -> Optional[Dict[str, Any]]:
103
+ """The 3D block, or None when no 3D argument was given.
104
+
105
+ Points get a footprint `radius` in METRES (a point has no area, so extruding one needs a width
106
+ that polygons get for free); left unset, the server derives it from the layer's own extent —
107
+ which is why this does not invent a default: a fixed 30 m bar is invisible on a world map.
108
+ """
109
+ fields = (("extrude", "enabled"), ("extrude_field", "field"), ("extrude_scale", "scale"),
110
+ ("extrude_base", "base"), ("extrude_color", "color"),
111
+ ("extrude_opacity", "opacity"), ("extrude_radius", "radius"))
112
+ given = {key: kw.get(arg) for arg, key in fields if kw.get(arg) is not None}
113
+ if not given:
114
+ return None
115
+ out = dict(base or {})
116
+ out.update(given)
117
+ if out.get("enabled") and not (out.get("field") or out.get("height")):
118
+ raise ValidationError(400, "3D extrusion needs a numeric field (--extrude-field).")
119
+ return out
120
+
121
+
122
+ def parse_size_stops(raw: Any) -> List[List[float]]:
123
+ """`"0:2,100:12"` (or an already-parsed list) → `[[0, 2], [100, 12]]`, ascending.
124
+
125
+ Two stops minimum, because `interpolate` between one point is not an interpolation — the server
126
+ silently falls back to the fixed radius, which looks like the flag did nothing.
127
+ """
128
+ if isinstance(raw, (list, tuple)):
129
+ pairs = [list(p) for p in raw]
130
+ else:
131
+ pairs = []
132
+ for chunk in str(raw).split(","):
133
+ chunk = chunk.strip()
134
+ if not chunk:
135
+ continue
136
+ if ":" not in chunk:
137
+ raise ValidationError(400, "Size stops look like value:size — got {0!r}.".format(chunk))
138
+ value, _, size = chunk.partition(":")
139
+ try:
140
+ pairs.append([float(value), float(size)])
141
+ except ValueError:
142
+ raise ValidationError(400, "Size stop {0!r} is not numeric.".format(chunk))
143
+ if len(pairs) < 2:
144
+ raise ValidationError(400, "Give at least two size stops, e.g. 0:2,1000:12.")
145
+ return sorted(pairs, key=lambda p: p[0])
146
+
147
+
148
+ def parse_classes(raw: Any) -> List[Dict[str, Any]]:
149
+ """`"0-10:#fee,10-50:#f00"` → the `classes` list. `*` is an open edge (`<10`, `≥50`)."""
150
+ if isinstance(raw, list):
151
+ return raw
152
+ out = [] # type: List[Dict[str, Any]]
153
+ for chunk in str(raw).split(","):
154
+ chunk = chunk.strip()
155
+ if not chunk:
156
+ continue
157
+ span, _, color = chunk.rpartition(":")
158
+ if not span or not color:
159
+ raise ValidationError(400, "Classes look like min-max:#colour — got {0!r}.".format(chunk))
160
+ lo, _, hi = span.partition("-")
161
+ out.append({"min": _edge(lo), "max": _edge(hi), "color": color.strip()})
162
+ if not out:
163
+ raise ValidationError(400, "No classes parsed.")
164
+ return out
165
+
166
+
167
+ def parse_categories(raw: Any) -> List[Dict[str, Any]]:
168
+ """`"forest:#2c7,water:#39f"` → the `categories` list."""
169
+ if isinstance(raw, list):
170
+ return raw
171
+ out = [] # type: List[Dict[str, Any]]
172
+ for chunk in str(raw).split(","):
173
+ chunk = chunk.strip()
174
+ if not chunk:
175
+ continue
176
+ value, _, color = chunk.rpartition(":")
177
+ if not value or not color:
178
+ raise ValidationError(400, "Categories look like value:#colour — got {0!r}.".format(chunk))
179
+ out.append({"value": value.strip(), "color": color.strip()})
180
+ if not out:
181
+ raise ValidationError(400, "No categories parsed.")
182
+ return out
183
+
184
+
185
+ def _edge(text: str) -> Optional[float]:
186
+ text = (text or "").strip()
187
+ if text in ("", "*", "inf", "-inf", "none"):
188
+ return None
189
+ try:
190
+ return float(text)
191
+ except ValueError:
192
+ raise ValidationError(400, "{0!r} is not a number.".format(text))
193
+
194
+
195
+ def classify(client: Any, layer_ref: Any, field: str, mode: Optional[str] = None,
196
+ classes: int = 5, method: str = "quantile", ramp: str = "viridis",
197
+ reverse: bool = False,
198
+ base: Optional[Dict[str, Any]] = None) -> Tuple[Dict[str, Any], Dict[str, Any]]:
199
+ """Ask the server to classify `field`, and fold the answer into a style dict.
200
+
201
+ Returns `(style, stats)` — the stats carry the distribution the server saw, which is what makes
202
+ "why are all my features one colour" answerable (a column that is 90 % one value collapses its
203
+ quantile breaks, and the server drops the empty classes rather than publish a legend the map
204
+ does not match).
205
+ """
206
+ if method not in CLASSIFY_METHODS:
207
+ raise ValidationError(400, "Classification method must be one of {0}.".format(
208
+ ", ".join(CLASSIFY_METHODS)))
209
+ if ramp not in RAMPS:
210
+ raise ValidationError(400, "Unknown colour ramp {0!r}. Known: {1}".format(
211
+ ramp, ", ".join(RAMPS)))
212
+ stats = client.vector.field_stats(layer_ref, field, classes=classes, method=method, ramp=ramp,
213
+ reverse=reverse)
214
+ suggestion = (stats or {}).get("suggestion") or {}
215
+ kind = (stats or {}).get("kind")
216
+ resolved = mode or suggestion.get("color_mode") or (
217
+ "graduated" if kind == "numeric" else "categorized")
218
+
219
+ style = dict(base or {})
220
+ style["color_field"] = field
221
+ style["color_mode"] = resolved
222
+ style["color_ramp"] = ramp
223
+ # Recorded so the direction survives a re-classify: the class COLOURS are stored per class, so
224
+ # without this a later change of method or class count would silently un-reverse the ramp.
225
+ style["color_ramp_reverse"] = bool(reverse)
226
+ # What was ASKED for, which is not always what came back — repeated values collapse a break, and
227
+ # some columns yield one class however many you request. The editor shows this number in its
228
+ # Classes box; storing it here means a CLI-classified layer opens in the browser showing the
229
+ # count you asked for rather than the count you got.
230
+ style["classes_n"] = int(classes)
231
+ if resolved == "graduated":
232
+ found = suggestion.get("classes") or []
233
+ if not found:
234
+ raise ValidationError(
235
+ 400, "{0!r} produced no classes — it is {1}, and graduated colouring needs a "
236
+ "numeric column.".format(field, kind or "not numeric"))
237
+ style["classes"] = found
238
+ style.pop("categories", None)
239
+ else:
240
+ found = suggestion.get("categories") or []
241
+ if not found:
242
+ raise ValidationError(400, "{0!r} produced no categories.".format(field))
243
+ style["categories"] = found
244
+ style.pop("classes", None)
245
+ return style, stats
246
+
247
+
248
+ def _validate(style: Dict[str, Any]) -> None:
249
+ marker = style.get("marker")
250
+ if marker is not None and marker not in MARKERS:
251
+ raise ValidationError(400, "Marker must be one of {0}.".format(", ".join(MARKERS)))
252
+ line_type = style.get("lineType")
253
+ if line_type is not None and line_type not in LINE_TYPES:
254
+ raise ValidationError(400, "Line type must be one of {0}.".format(", ".join(LINE_TYPES)))
255
+ mode = style.get("color_mode")
256
+ if mode is not None and mode not in COLOR_MODES:
257
+ raise ValidationError(400, "Colour mode must be one of {0}.".format(", ".join(COLOR_MODES)))
258
+ for key in ("fill_opacity", "outline_width"):
259
+ value = style.get(key)
260
+ if value is not None and not 0 <= float(value) <= 1:
261
+ raise ValidationError(400, "{0} is a fraction between 0 and 1.".format(key))
262
+
263
+
264
+ class Style(object):
265
+ """A stored style, READ. The reverse direction of `build_style`.
266
+
267
+ `build_style` exists so a caller can assemble the vocabulary; nothing existed to take it apart
268
+ again, so anything rendering a GeoDeploy layer somewhere else — the QGIS plugin first — had to
269
+ reach into the dict and re-decide what `color_mode: "graduated"` implies, which keys are
270
+ authoritative, and what a missing one defaults to. That is a second definition of the
271
+ vocabulary, in a project that already keeps three surfaces in step by hand.
272
+
273
+ So this is a READER, not a schema: it never rejects, never fills in a server default it cannot
274
+ know, and keeps `.raw` so a caller can reach anything not modelled here. `to_dict()` returns the
275
+ original dict, which is what makes build → parse → build lossless.
276
+ """
277
+
278
+ __slots__ = ("raw",)
279
+
280
+ def __init__(self, style: Optional[Dict[str, Any]] = None):
281
+ self.raw = dict(style or {})
282
+
283
+ # -- what kind of symbology is this ------------------------------------------------------------
284
+
285
+ @property
286
+ def mode(self) -> str:
287
+ """`single` | `graduated` | `categorized` — never None, so a caller can switch on it."""
288
+ return self.raw.get("color_mode") or "single"
289
+
290
+ @property
291
+ def field(self) -> Optional[str]:
292
+ """The attribute the colours are driven by, or None for a single symbol."""
293
+ return self.raw.get("color_field") if self.mode != "single" else None
294
+
295
+ @property
296
+ def is_data_driven(self) -> bool:
297
+ return bool(self.field) and self.mode in ("graduated", "categorized")
298
+
299
+ # -- the pieces a renderer needs ---------------------------------------------------------------
300
+
301
+ @property
302
+ def color(self) -> Optional[str]:
303
+ return self.raw.get("color")
304
+
305
+ @property
306
+ def classes(self) -> List[Dict[str, Any]]:
307
+ """`[{min, max, color}]` for a graduated style. `min`/`max` may be None at the ends — that
308
+ is an OPEN bucket ("< 10", "≥ 90"), not missing data."""
309
+ return [c for c in (self.raw.get("classes") or []) if isinstance(c, dict)]
310
+
311
+ @property
312
+ def categories(self) -> List[Dict[str, Any]]:
313
+ """`[{value, color}]` for a categorized style. Anything not listed takes `other_color`."""
314
+ return [c for c in (self.raw.get("categories") or []) if isinstance(c, dict)]
315
+
316
+ @property
317
+ def other_color(self) -> Optional[str]:
318
+ return self.raw.get("other_color")
319
+
320
+ @property
321
+ def requested_classes(self) -> Optional[int]:
322
+ """How many classes were ASKED for. `len(style.classes)` is how many came back, and the two
323
+ differ whenever repeated values collapse a break — so a UI that shows the second number as
324
+ the input fights the user (issue #10)."""
325
+ value = self.raw.get("classes_n")
326
+ return int(value) if isinstance(value, (int, float)) else None
327
+
328
+ @property
329
+ def ramp(self) -> Optional[str]:
330
+ """The ramp the classes were generated from, when one was recorded. The class colours are
331
+ stored per class, so this is provenance — not what a renderer should read to draw."""
332
+ return self.raw.get("color_ramp")
333
+
334
+ @property
335
+ def ramp_reversed(self) -> bool:
336
+ return bool(self.raw.get("color_ramp_reverse"))
337
+
338
+ @property
339
+ def size(self) -> Optional[Dict[str, Any]]:
340
+ """`{"field": …, "stops": [[value, size], …]}` when size is data-driven, else None.
341
+
342
+ Both halves are required: a field with no stops cannot be interpolated, and stops with no
343
+ field have nothing to read.
344
+ """
345
+ field, stops = self.raw.get("size_field"), self.raw.get("size_stops")
346
+ if not field or not stops:
347
+ return None
348
+ return {"field": field, "stops": [list(s) for s in stops if len(s) == 2]}
349
+
350
+ @property
351
+ def extrusion(self) -> Optional[Dict[str, Any]]:
352
+ """The 3D block when it is switched ON. `{"enabled": False}` reads as None: a renderer that
353
+ checks truthiness on the dict alone would extrude a layer the author turned off."""
354
+ ext = self.raw.get("extrusion")
355
+ if isinstance(ext, dict) and ext.get("enabled"):
356
+ return dict(ext)
357
+ return None
358
+
359
+ # -- raster ------------------------------------------------------------------------------------
360
+
361
+ @property
362
+ def colormap(self) -> Optional[str]:
363
+ return self.raw.get("colormap")
364
+
365
+ @property
366
+ def rescale(self) -> Optional[List[float]]:
367
+ """The stretch as numbers. Stored as TiTiler wants it — the string "min,max" — so a caller
368
+ that wants to compute with it should not have to parse it again."""
369
+ value = self.raw.get("rescale")
370
+ if isinstance(value, str):
371
+ try:
372
+ return [float(v) for v in value.split(",")]
373
+ except ValueError:
374
+ return None
375
+ if isinstance(value, (list, tuple)) and len(value) == 2:
376
+ try:
377
+ return [float(v) for v in value]
378
+ except (TypeError, ValueError):
379
+ return None
380
+ return None
381
+
382
+ # -- output --------------------------------------------------------------------------------------
383
+
384
+ def legend(self) -> List[Dict[str, Any]]:
385
+ """Swatches and labels, WITHOUT asking the server — mirrors `symbology.legend_entries`.
386
+
387
+ Prefer `client.vector.legend(ref)`: the server's answer is the one the portal drew. This is
388
+ for a caller that already holds a style dict (a portal's layer_config, an unsaved edit in a
389
+ dialog) and cannot make a request per keystroke. The two agree; `test_styles_jobs` pins the
390
+ labels against the server's format.
391
+ """
392
+ if self.mode == "graduated":
393
+ out = []
394
+ for c in self.classes:
395
+ lo, hi = c.get("min"), c.get("max")
396
+ if lo is None and hi is None:
397
+ label = "all"
398
+ elif lo is None:
399
+ label = "< {0}".format(_legend_num(hi))
400
+ elif hi is None:
401
+ label = "≥ {0}".format(_legend_num(lo))
402
+ else:
403
+ label = "{0} – {1}".format(_legend_num(lo), _legend_num(hi))
404
+ out.append({"color": c.get("color"), "label": label})
405
+ return out
406
+ if self.mode == "categorized":
407
+ out = [{"color": c.get("color"), "label": str(c.get("value"))} for c in self.categories]
408
+ if out:
409
+ out.append({"color": self.other_color or "#9ca3af", "label": "Other"})
410
+ return out
411
+ return []
412
+
413
+ def to_dict(self) -> Dict[str, Any]:
414
+ """The style as stored — unchanged, so `build_style(**…)` round-trips through this."""
415
+ return dict(self.raw)
416
+
417
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
418
+ return "<Style {0}>".format(describe(self.raw))
419
+
420
+
421
+ def parse(style: Optional[Dict[str, Any]]) -> Style:
422
+ """Read a stored style dict. Accepts a layer's `default_style` wrapper or the inner style."""
423
+ if isinstance(style, dict) and "style" in style and isinstance(style["style"], dict):
424
+ # A layer record carries {opacity, style: {...}, popup_fields}; a portal layer_config the
425
+ # same. Taking either means a caller never has to remember which one they are holding.
426
+ return Style(style["style"])
427
+ return Style(style)
428
+
429
+
430
+ #: The package-level name. `styles.parse` reads well inside this module; at the top level, next to
431
+ #: `Client`, a bare `parse` says nothing about what it parses.
432
+ parse_style = parse
433
+
434
+
435
+ def _legend_num(value: Any) -> str:
436
+ """Legend numbers, formatted as `symbology._num` does — an integer stays an integer."""
437
+ try:
438
+ number = float(value)
439
+ except (TypeError, ValueError):
440
+ return str(value)
441
+ if number == int(number) and abs(number) < 1e15:
442
+ return str(int(number))
443
+ return "{0:,.2f}".format(number).rstrip("0").rstrip(".")
444
+
445
+
446
+ def describe(style: Dict[str, Any]) -> str:
447
+ """A one-line human summary of a style — what a table cell shows."""
448
+ if not style:
449
+ return "default"
450
+ mode = style.get("color_mode") or "single"
451
+ if mode == "graduated" and style.get("color_field"):
452
+ return "graduated on {0} ({1} classes)".format(style["color_field"],
453
+ len(style.get("classes") or []))
454
+ if mode == "categorized" and style.get("color_field"):
455
+ return "categorized on {0} ({1} values)".format(style["color_field"],
456
+ len(style.get("categories") or []))
457
+ bits = [] # type: List[str]
458
+ for key in ("color", "marker", "radius", "line_width", "colormap", "rescale"):
459
+ if style.get(key) is not None:
460
+ bits.append("{0}={1}".format(key, style[key]))
461
+ if (style.get("extrusion") or {}).get("enabled"):
462
+ bits.append("3D")
463
+ return ", ".join(bits) or "default"
464
+
465
+
466
+ def merge_iterable(pairs: Iterable[Tuple[str, Any]]) -> Dict[str, Any]: # pragma: no cover - util
467
+ return {k: v for k, v in pairs if v is not None}