geodeploy 1.4.0__tar.gz → 1.6.0__tar.gz

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.
Files changed (59) hide show
  1. {geodeploy-1.4.0/geodeploy.egg-info → geodeploy-1.6.0}/PKG-INFO +1 -1
  2. {geodeploy-1.4.0 → geodeploy-1.6.0}/README.md +16 -0
  3. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/__init__.py +3 -1
  4. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/_common.py +143 -4
  5. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/portals.py +2 -1
  6. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/sources.py +29 -9
  7. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/client.py +71 -6
  8. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/config.py +3 -3
  9. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/errors.py +19 -1
  10. geodeploy-1.6.0/geodeploy/expressions.py +743 -0
  11. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/portals.py +4 -1
  12. geodeploy-1.6.0/geodeploy/sources.py +108 -0
  13. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/styles.py +289 -4
  14. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/transport.py +0 -1
  15. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/uploads.py +2 -2
  16. {geodeploy-1.4.0 → geodeploy-1.6.0/geodeploy.egg-info}/PKG-INFO +1 -1
  17. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/SOURCES.txt +5 -0
  18. geodeploy-1.6.0/tests/test_expressions.py +374 -0
  19. {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_layers_portals.py +15 -1
  20. geodeploy-1.6.0/tests/test_rate_limit.py +160 -0
  21. geodeploy-1.6.0/tests/test_sources_kinds.py +166 -0
  22. geodeploy-1.6.0/tests/test_style_pictures.py +144 -0
  23. geodeploy-1.4.0/geodeploy/sources.py +0 -70
  24. {geodeploy-1.4.0 → geodeploy-1.6.0}/LICENSE +0 -0
  25. {geodeploy-1.4.0 → geodeploy-1.6.0}/MANIFEST.in +0 -0
  26. {geodeploy-1.4.0 → geodeploy-1.6.0}/NOTICE +0 -0
  27. {geodeploy-1.4.0 → geodeploy-1.6.0}/PYPI.md +0 -0
  28. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/__main__.py +0 -0
  29. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/admin.py +0 -0
  30. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/catalog.py +0 -0
  31. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/__init__.py +0 -0
  32. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/__init__.py +0 -0
  33. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/admin.py +0 -0
  34. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/auth.py +0 -0
  35. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/browse.py +0 -0
  36. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/catalog.py +0 -0
  37. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/imports.py +0 -0
  38. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/jobs.py +0 -0
  39. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/layers.py +0 -0
  40. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/upload.py +0 -0
  41. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/main.py +0 -0
  42. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/output.py +0 -0
  43. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/imports.py +0 -0
  44. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/jobs.py +0 -0
  45. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/layers.py +0 -0
  46. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/py.typed +0 -0
  47. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/dependency_links.txt +0 -0
  48. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/entry_points.txt +0 -0
  49. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/requires.txt +0 -0
  50. {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/top_level.txt +0 -0
  51. {geodeploy-1.4.0 → geodeploy-1.6.0}/pyproject.toml +0 -0
  52. {geodeploy-1.4.0 → geodeploy-1.6.0}/setup.cfg +0 -0
  53. {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/conftest.py +0 -0
  54. {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_cli.py +0 -0
  55. {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_config.py +0 -0
  56. {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_output.py +0 -0
  57. {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_styles_jobs.py +0 -0
  58. {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_transport.py +0 -0
  59. {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: geodeploy
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: Command-line client and Python API for GeoDeploy — upload spatial data, build and publish portals.
5
5
  Author: Koffi Dodji Noumonvi
6
6
  License-Expression: Apache-2.0
@@ -37,6 +37,14 @@ User documentation is `docs/cli.md`; this file is the technical note.
37
37
  - `portals.py` — portal CRUD and `layer_configs` surgery. `layer_configs[0]` = top of the list =
38
38
  drawn on top. `editable_config()` drops server-owned fields before a round-trip PUT.
39
39
  - `styles.py` — the style vocabulary of `api/geodeploy/services/symbology.py`, in both directions.
40
+ - `expressions.py` — **QGIS expressions ⇄ MapLibre expressions**, over a subset that is declared
41
+ rather than discovered. A rule is a filter, and so is a subset string and every data-defined
42
+ property, so this is what lets rule-based rendering travel at all. Anything outside the subset
43
+ raises `Unsupported` **naming the construct** — the fidelity report is built from those names, and
44
+ a translator that silently approximates publishes a map the author never saw. `to_maplibre` is
45
+ generous (its input is whatever somebody typed in QGIS); `from_maplibre` is deliberately narrow
46
+ (its input is a filter GeoDeploy itself stored). The SERVER never calls either: a rule arrives
47
+ already translated, so there is one translator rather than two that would drift.
40
48
  `build_style()` **assembles** it from plain arguments; `parse()`/`Style` **reads** one back
41
49
  (mode, field, classes, categories, size, extrusion, rescale) so a consumer — the QGIS plugin —
42
50
  does not re-decide what `color_mode: "graduated"` implies. `Style` is a reader, not a schema: it
@@ -192,5 +200,13 @@ the CLI yet; see below).
192
200
  run on, and only the second one is visible to a user with an old install.
193
201
 
194
202
  ## Last updated
203
+ 2026-09-05 (`styles.picture_data_uri` + `--marker-image` / `--fill-pattern` / `--line-marker` /
204
+ `--centroid-marker`, each taking a local image FILE. These four keys already *survived* a CLI
205
+ restyle — `build_style` merges onto the existing style, so a marker the QGIS plugin rendered was
206
+ never dropped by `--color red` — but there was no way to SET one without going through QGIS, which
207
+ made "can it all be done from the CLI?" a no for the part of the vocabulary that is pixels rather
208
+ than words. The size ceiling is the plugin's own 96 KB on RAW bytes, deliberately: a file the CLI
209
+ accepted that the plugin would have refused is two ceilings for one key.)
210
+
195
211
  2026-08-12 (created — packaged CLI + Python client, replacing `examples/geodeploy_cli.py`; then
196
212
  `browse` + anonymous layer download on top of the new `/api/public` and per-layer export endpoints)
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  # The CLI's version tracks the GeoDeploy release it ships with. A PyPI version can never be
15
15
  # re-uploaded, so a number is only spent once the release it names exists: 1.3.0b1 proved the
16
16
  # packaging against the real index, and this is the release it was rehearsing for.
17
- __version__ = "1.4.0"
17
+ __version__ = "1.6.0"
18
18
 
19
19
  from .client import Client # noqa: E402 (after __version__ — the user agent reads it)
20
20
  from .errors import ( # noqa: E402
@@ -25,6 +25,7 @@ from .errors import ( # noqa: E402
25
25
  GeoDeployError,
26
26
  NotFoundError,
27
27
  PermissionError_,
28
+ RateLimited,
28
29
  ServerError,
29
30
  TransportError,
30
31
  ValidationError,
@@ -43,6 +44,7 @@ __all__ = [
43
44
  "ConflictError",
44
45
  "NotFoundError",
45
46
  "PermissionError_",
47
+ "RateLimited",
46
48
  "ServerError",
47
49
  "TransportError",
48
50
  "ValidationError",
@@ -14,7 +14,8 @@ from typing import Any, Dict, List, Optional
14
14
 
15
15
  from ...errors import ValidationError
16
16
  from ...styles import (CLASSIFY_METHODS, COLOR_MODES, LINE_TYPES, MARKERS, RAMPS, build_style,
17
- parse_categories, parse_classes)
17
+ parse_categories, parse_classes, parse_number_list,
18
+ LINE_CAPS, LINE_JOINS, LABEL_FONTS)
18
19
 
19
20
 
20
21
  def add_style_args(parser, raster: bool = True) -> None:
@@ -46,7 +47,7 @@ def add_style_args(parser, raster: bool = True) -> None:
46
47
  driven.add_argument("--classify", nargs="?", const="quantile", choices=CLASSIFY_METHODS,
47
48
  help="compute classes from the data: quantile (default), equal, jenks")
48
49
  driven.add_argument("--classes", type=int, default=None,
49
- help="how many classes to compute (2-12, default 5)")
50
+ help="how many classes to compute (2-100, default 5)")
50
51
  driven.add_argument("--ramp", choices=RAMPS, help="colour ramp for computed classes")
51
52
  driven.add_argument("--reverse-ramp", action="store_true",
52
53
  help="run the ramp the other way (light end for the low values)")
@@ -99,6 +100,92 @@ def add_style_args(parser, raster: bool = True) -> None:
99
100
  help="high end of the contour colour range (defaults to --rescale)")
100
101
  rast.add_argument("--bidx", help="band selection: '1' or '3,2,1' for an RGB composite")
101
102
 
103
+ # The line and marker vocabulary MapLibre draws natively. Each round-trips exactly from QGIS.
104
+ line = parser.add_argument_group("lines and markers")
105
+ line.add_argument("--dash-pattern",
106
+ help="dash and gap lengths in MULTIPLES OF THE LINE WIDTH, e.g. '3,2' or "
107
+ "'3,2,1,2' — wins over --line-type")
108
+ line.add_argument("--no-dash-pattern", action="store_true",
109
+ help="drop a custom dash pattern, leaving --line-type")
110
+ line.add_argument("--line-cap", choices=LINE_CAPS, help="how a line ends")
111
+ line.add_argument("--line-join", choices=LINE_JOINS, help="how a line turns a corner")
112
+ line.add_argument("--line-offset", type=float,
113
+ help="draw the line this many pixels to one side (negative for the other)")
114
+ line.add_argument("--marker-rotation", type=float, help="turn each marker, in degrees")
115
+ line.add_argument("--marker-offset", help="move each marker, as 'x,y' in pixels")
116
+ line.add_argument("--marker-opacity", type=float, help="the marker's own opacity, 0-1")
117
+
118
+ # PICTURES FROM A FILE. These three keys already SURVIVED a CLI restyle — `build_style` merges
119
+ # onto the existing style, so a marker rendered by the QGIS plugin was never dropped — but there
120
+ # was no way to SET one without QGIS. A PNG or SVG on disk is the obvious other source, and the
121
+ # renderers cannot tell the two apart: both arrive as the same data URI.
122
+ pics = parser.add_argument_group("pictures (from a local image file)")
123
+ pics.add_argument("--marker-image", metavar="FILE",
124
+ help="draw each point as this image instead of a generated shape")
125
+ pics.add_argument("--fill-pattern", metavar="FILE",
126
+ help="tile this image across each polygon (it must tile seamlessly)")
127
+ pics.add_argument("--line-marker", metavar="FILE",
128
+ help="repeat this image along each line, rotated with it")
129
+ pics.add_argument("--centroid-marker", metavar="FILE",
130
+ help="place this image at each polygon's centre")
131
+ for flag, what in (("--no-marker-image", "the point picture"),
132
+ ("--no-fill-pattern", "the polygon pattern"),
133
+ ("--no-line-marker", "the markers along the line"),
134
+ ("--no-centroid-marker", "the centre marker")):
135
+ pics.add_argument(flag, action="store_true", help="remove {0}".format(what))
136
+ pics.add_argument("--line-marker-spacing", type=float,
137
+ help="pixels between repeated line markers")
138
+
139
+ # Scale range and "draws nothing" belong to the LAYER, not to one symbol.
140
+ scope = parser.add_argument_group("where the layer draws")
141
+ scope.add_argument("--min-zoom", type=float,
142
+ help="hide the layer below this zoom (QGIS's most-zoomed-OUT scale limit)")
143
+ scope.add_argument("--max-zoom", type=float, help="hide the layer above this zoom")
144
+ scope.add_argument("--no-symbol", action="store_true",
145
+ help="draw nothing, but keep the layer listed — QGIS's No symbols renderer")
146
+ scope.add_argument("--symbol", dest="no_symbol", action="store_false", default=None,
147
+ help="undo --no-symbol")
148
+
149
+ # LABELS. Their own group because a label is a second thing drawn for the same feature — its
150
+ # own text, font, colour and zoom range — and it becomes its own MapLibre layer.
151
+ lab = parser.add_argument_group("labels")
152
+ lab.add_argument("--label-field", help="the attribute to label with — turns labelling ON")
153
+ lab.add_argument("--no-labels", action="store_true", help="stop labelling this layer")
154
+ lab.add_argument("--label-size", type=float, help="label text size in pixels")
155
+ lab.add_argument("--label-color", help="label text colour")
156
+ lab.add_argument("--label-font", choices=LABEL_FONTS,
157
+ help="a portal can only draw the fonts its glyph set contains")
158
+ lab.add_argument("--label-halo-color", help="colour of the outline behind the text")
159
+ lab.add_argument("--label-halo-width", type=float, help="halo width in pixels (0 for none)")
160
+ lab.add_argument("--label-offset", help="move the text, as 'x,y' in pixels")
161
+ lab.add_argument("--label-placement", choices=("point", "line"),
162
+ help="place the text at a point, or bend it along the line")
163
+ lab.add_argument("--label-line-position", choices=("on", "above", "below"),
164
+ help="where along-the-line labels sit. A contour's height is written ON the "
165
+ "line; a river's name usually sits above it. Carried for QGIS, which is "
166
+ "the only surface that can draw the difference")
167
+ lab.add_argument("--label-per-part", action="store_true",
168
+ help="label every PART of a multi-part feature instead of the feature once — "
169
+ "an archipelago wants this, a country with two islands does not")
170
+ lab.add_argument("--label-transform", choices=("none", "uppercase", "lowercase"))
171
+ lab.add_argument("--label-max-width", type=float, help="wrap the text at this many ems")
172
+ lab.add_argument("--label-allow-overlap", action="store_true",
173
+ help="draw every label even where they collide")
174
+ lab.add_argument("--label-priority", type=float,
175
+ help="0-10, higher wins the space when labels collide")
176
+ lab.add_argument("--label-min-zoom", type=float, help="hide the labels below this zoom")
177
+ lab.add_argument("--label-max-zoom", type=float, help="hide the labels above this zoom")
178
+
179
+ # RULES. A rule list is not something anyone types at a shell — it comes out of QGIS, through
180
+ # the plugin — so the CLI's job is to move one around and to get rid of one, not to compose it
181
+ # field by field. `--rules @file.json` is how a rule-based style is scripted into an instance.
182
+ parser.add_argument("--rules",
183
+ help="a JSON list (or @file.json) of rule objects — "
184
+ '{label, expression, filter, style, minzoom, maxzoom} — usually '
185
+ "written by the QGIS plugin")
186
+ parser.add_argument("--no-rules", action="store_true",
187
+ help="drop the rule list, leaving the layer's own single symbol")
188
+
102
189
  parser.add_argument("--style-json",
103
190
  help="a JSON object (or @file.json) merged in last — the escape hatch for "
104
191
  "anything these flags do not cover")
@@ -133,7 +220,9 @@ def style_from_args(args, client=None, layer_ref: Optional[Any] = None,
133
220
  "rescale", "algorithm", "zfactor", "increment", "thickness", "minz", "maxz",
134
221
  "color_field", "color_mode", "size_field", "other_color", "size_stops",
135
222
  "extrude_field", "extrude_scale", "extrude_base", "extrude_color",
136
- "extrude_opacity", "extrude_radius"):
223
+ "extrude_opacity", "extrude_radius",
224
+ "line_cap", "line_join", "line_offset", "marker_rotation", "marker_opacity",
225
+ "min_zoom", "max_zoom"):
137
226
  kwargs[name] = getattr(args, name, None)
138
227
  if getattr(args, "bidx", None):
139
228
  kwargs["bidx"] = [int(b) for b in str(args.bidx).replace(" ", "").split(",") if b]
@@ -147,6 +236,48 @@ def style_from_args(args, client=None, layer_ref: Optional[Any] = None,
147
236
  kwargs["categories"] = parse_categories(args.categories)
148
237
  if getattr(args, "no_classification", False):
149
238
  kwargs["clear_classification"] = True
239
+ for arg, key in (("marker_image", "marker_image"), ("fill_pattern", "fill_pattern"),
240
+ ("line_marker", "line_marker"), ("centroid_marker", "centroid_marker")):
241
+ path = getattr(args, arg, None)
242
+ if path:
243
+ kwargs[key] = path
244
+ if getattr(args, "no_" + arg, False):
245
+ kwargs["no_" + key] = True
246
+ if getattr(args, "line_marker_spacing", None) is not None:
247
+ kwargs["line_marker_spacing"] = args.line_marker_spacing
248
+ for name in ("dash_pattern", "marker_offset", "no_dash_pattern", "no_symbol"):
249
+ value = getattr(args, name, None)
250
+ if value is not None and value is not False:
251
+ kwargs[name] = value
252
+ elif name == "no_symbol" and value is False:
253
+ kwargs[name] = False # `--symbol` explicitly turns it back on
254
+ labels = {}
255
+ for arg, key in (("label_field", "field"), ("label_size", "size"), ("label_color", "color"),
256
+ ("label_font", "font"), ("label_halo_color", "halo_color"),
257
+ ("label_halo_width", "halo_width"), ("label_placement", "placement"),
258
+ ("label_line_position", "line_position"),
259
+ ("label_transform", "transform"), ("label_max_width", "max_width"),
260
+ ("label_priority", "priority"), ("label_min_zoom", "minzoom"),
261
+ ("label_max_zoom", "maxzoom")):
262
+ value = getattr(args, arg, None)
263
+ if value is not None:
264
+ labels[key] = value
265
+ # A FLAG, so only its presence means anything: `store_true` leaves False for "not asked", and
266
+ # sending that would turn the setting OFF on a style that had it on.
267
+ if getattr(args, "label_per_part", False):
268
+ labels["label_per_part"] = True
269
+ if getattr(args, "label_offset", None):
270
+ labels["offset"] = parse_number_list(args.label_offset, "--label-offset", length=2)
271
+ if getattr(args, "label_allow_overlap", False):
272
+ labels["allow_overlap"] = True
273
+ if labels:
274
+ kwargs["labels"] = labels
275
+ if getattr(args, "no_labels", False):
276
+ kwargs["clear_labels"] = True
277
+ if getattr(args, "rules", None):
278
+ kwargs["rules"] = read_json_arg(args.rules, "--rules", allow_list=True)
279
+ if getattr(args, "no_rules", False):
280
+ kwargs["clear_rules"] = True
150
281
 
151
282
  style = build_style(style, **kwargs)
152
283
 
@@ -156,11 +287,15 @@ def style_from_args(args, client=None, layer_ref: Optional[Any] = None,
156
287
  return style
157
288
 
158
289
 
159
- def read_json_arg(value: str, label: str) -> Dict[str, Any]:
290
+ def read_json_arg(value: str, label: str, allow_list: bool = False):
160
291
  """A JSON object given inline, or `@path` to read it from a file (or `@-` for stdin).
161
292
 
162
293
  `utf-8-sig` on the file: PowerShell's `>` writes UTF-16 or a BOM, which is how the reference
163
294
  script's `portal-set` used to fail on Windows with an unreadable JSON error.
295
+
296
+ `allow_list` is for `--rules`, which is a JSON ARRAY rather than an object — the object check
297
+ below is otherwise the thing that catches a style file with the wrong shape, and loosening it
298
+ for everything would trade a clear message for a confusing one further down.
164
299
  """
165
300
  text = value
166
301
  if value.startswith("@"):
@@ -174,6 +309,10 @@ def read_json_arg(value: str, label: str) -> Dict[str, Any]:
174
309
  data = json.loads(text)
175
310
  except ValueError as exc:
176
311
  raise ValidationError(400, "{0} is not valid JSON: {1}".format(label, exc))
312
+ if allow_list:
313
+ if not isinstance(data, list):
314
+ raise ValidationError(400, "{0} must be a JSON list.".format(label))
315
+ return data
177
316
  if not isinstance(data, dict):
178
317
  raise ValidationError(400, "{0} must be a JSON object.".format(label))
179
318
  return data
@@ -39,13 +39,14 @@ examples:
39
39
  geodeploy portals create "Field sites 2026"
40
40
  geodeploy portals create "Catalogue" --experience catalog --access organization
41
41
  geodeploy portals create "Story" --experience storymap --template minimal
42
+ geodeploy portals create "Fleet" --experience dashboard --template dashboard-monitoring
42
43
  """)
43
44
  create.add_argument("title")
44
45
  create.add_argument("--description", help="About text, or @file.md")
45
46
  create.add_argument("--template", default="minimal", dest="template_id",
46
47
  help="template id (see `geodeploy catalog templates`)")
47
48
  create.add_argument("--experience", choices=ARCHETYPES,
48
- help="webmap (default), storymap or catalog")
49
+ help="webmap (default), storymap, catalog or dashboard")
49
50
  create.add_argument("--access", choices=ACCESS_TYPES, default="public",
50
51
  help="who may view the PUBLISHED portal")
51
52
  create.add_argument("--password", help="the password, when --access password")
@@ -1,11 +1,18 @@
1
- """`geodeploy sources …` — external WMS / XYZ / WFS services used without ingesting them."""
1
+ """`geodeploy sources …` — somebody else's service, used in a portal without ingesting it.
2
+
3
+ XYZ, WMS, WMTS, WFS, OGC API - Features, vector tiles and PMTiles. See
4
+ `geodeploy/sources.py` for what each one is and which of them are fetched through the
5
+ instance rather than straight from the provider (the answer is CORS).
6
+ """
2
7
  from __future__ import annotations
3
8
 
9
+ from ...sources import SOURCE_TYPES
4
10
  from ..main import add_command, group_parser
5
11
  from ..output import EXIT_GENERIC, EXIT_OK
6
12
  from ._common import confirm
7
13
 
8
- COLUMNS = ["id", "name", "source_type", "kind", "url", "layer_name", "visibility", "created_by"]
14
+ COLUMNS = ["id", "name", "source_type", "kind", "url", "layer_name", "source_layer",
15
+ "visibility", "created_by"]
9
16
 
10
17
 
11
18
  def register(subparsers) -> None:
@@ -22,16 +29,28 @@ examples:
22
29
  --layer-name ortho_2025 --version 1.3.0
23
30
  geodeploy sources add "Municipalities" https://wfs.example.org/ows --type wfs \\
24
31
  --layer-name ms:kommun
25
-
26
- A WFS is probed when it is registered, so a wrong typeName fails here rather than as an empty
27
- layer on a published map.
32
+ geodeploy sources add "Roads" https://example.org/ogc/collections/roads --type ogcapi
33
+ geodeploy sources add "Basemap" https://tiles.example.org/tiles.json --type vectortile
34
+ geodeploy sources add "Buildings" https://files.example.org/b.pmtiles --type pmtiles
35
+
36
+ Everything that can be checked is checked when it is registered — a WFS and an OGC API
37
+ collection are fetched, a TileJSON is read, a PMTiles header is parsed — so a wrong name or
38
+ an unreachable host fails here rather than as an empty layer on a published map. That is
39
+ also where the layer inside a tile set, its zoom range and its extent come from.
28
40
  """)
29
41
  add.add_argument("name")
30
42
  add.add_argument("service_url", metavar="url", help="the service endpoint")
31
- add.add_argument("--type", dest="source_type", required=True, choices=["xyz", "wms", "wfs"])
32
- add.add_argument("--layer-name", help="WMS `layers` / WFS `typeName` (required for both)")
43
+ add.add_argument("--type", dest="source_type", required=True, choices=list(SOURCE_TYPES))
44
+ add.add_argument("--layer-name",
45
+ help="which layer of the service: WMS/WMTS `layers`, WFS `typeName`, or an "
46
+ "OGC API collection id (optional when the URL already names it)")
47
+ add.add_argument("--source-layer",
48
+ help="the layer INSIDE a vector tile or PMTiles archive. Read from the "
49
+ "service where it publishes one (TileJSON, PMTiles metadata); give it "
50
+ "when it does not, because a style that names none draws nothing")
51
+ add.add_argument("--matrix-set", help="WMTS TileMatrixSet (default GoogleMapsCompatible)")
33
52
  add.add_argument("--version", help="WMS (default 1.3.0) or WFS (default 2.0.0) version")
34
- add.add_argument("--format", dest="image_format", help="WMS image format (default image/png)")
53
+ add.add_argument("--format", dest="image_format", help="WMS/WMTS image format (default image/png)")
35
54
  add.add_argument("--attribution")
36
55
 
37
56
  show = add_command(group, "show", cmd_show, "one source")
@@ -57,7 +76,8 @@ def cmd_list(ctx, args) -> int:
57
76
 
58
77
  def cmd_add(ctx, args) -> int:
59
78
  source = ctx.client().sources.create(
60
- args.name, args.source_type, args.service_url, layer_name=args.layer_name, version=args.version,
79
+ args.name, args.source_type, args.service_url, layer_name=args.layer_name,
80
+ source_layer=args.source_layer, matrix_set=args.matrix_set, version=args.version,
61
81
  image_format=args.image_format, attribution=args.attribution)
62
82
  ctx.out.render(source, COLUMNS + ["bbox", "geometry_type"])
63
83
  if not ctx.out.json_mode:
@@ -17,7 +17,8 @@ from __future__ import annotations
17
17
 
18
18
  import json as _json
19
19
  import os
20
- from typing import Any, Callable, Dict, Optional, Union
20
+ import time as _time
21
+ from typing import Any, Callable, Dict, Optional
21
22
  from urllib.parse import quote, urlencode, urljoin
22
23
 
23
24
  from . import errors
@@ -27,6 +28,22 @@ __all__ = ["Client"]
27
28
 
28
29
  #: Sent on every request. An instance's access log is where an operator works out that "the API is
29
30
  #: hammering us" is in fact someone's nightly CLI job, so it names the tool and its version.
31
+ def _retry_after(response) -> Optional[float]:
32
+ """`Retry-After` in seconds, when the server sent one and it is a number.
33
+
34
+ The HTTP-date form is deliberately not parsed: it needs a clock the client cannot trust to
35
+ agree with the server's, and every limiter in this stack sends seconds or nothing at all.
36
+ """
37
+ raw = (response.headers or {}).get("retry-after")
38
+ if raw is None:
39
+ return None
40
+ try:
41
+ seconds = float(str(raw).strip())
42
+ except (TypeError, ValueError):
43
+ return None
44
+ return max(0.0, min(300.0, seconds)) if seconds == seconds else None
45
+
46
+
30
47
  def _default_user_agent() -> str:
31
48
  from . import __version__
32
49
  return "geodeploy-cli/{0}".format(__version__)
@@ -52,7 +69,9 @@ class Client(object):
52
69
  transport: Optional[Any] = None, timeout: float = 120.0,
53
70
  upload_timeout: float = 3600.0, user_agent: Optional[str] = None,
54
71
  verify_tls: bool = True, retries: int = 2,
55
- on_request: Optional[Callable[[str, str], None]] = None):
72
+ on_request: Optional[Callable[[str, str], None]] = None,
73
+ rate_limit_retries: int = 6,
74
+ on_throttled: Optional[Callable[[float, int, int], None]] = None):
56
75
  from .config import normalize_url
57
76
  self.url = normalize_url(url)
58
77
  self.token = token or None
@@ -64,6 +83,14 @@ class Client(object):
64
83
  #: Called with (method, url) before each request — the CLI's `-v` uses it, and a plugin can
65
84
  #: route it to the QGIS message log without this module knowing what logging is.
66
85
  self.on_request = on_request
86
+ #: How many times a 429 is waited out before it is raised. Six covers a group push against
87
+ #: the shipped `rate=5r/m` upload limit; past that something is wrong that waiting will not
88
+ #: fix, and the caller deserves to hear about it.
89
+ self.rate_limit_retries = max(0, int(rate_limit_retries))
90
+ #: Called with (seconds, attempt, of) each time a request is held back. The point is that a
91
+ #: long wait should look like progress rather than like a hang — the CLI prints it and the
92
+ #: plugin puts it in the status bar.
93
+ self.on_throttled = on_throttled
67
94
 
68
95
  # Namespaces. Imported here rather than at module scope because each one imports this
69
96
  # module for typing; the cost is one attribute lookup at construction.
@@ -137,9 +164,47 @@ class Client(object):
137
164
 
138
165
  if self.on_request:
139
166
  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)
167
+
168
+ # RATE LIMITING IS A WAIT, NOT A FAILURE. An instance throttles its upload route — nginx
169
+ # ships `rate=5r/m` — and pushing a group of layers is exactly the burst that trips it. The
170
+ # user saw "HTTP 429" partway through and had to press the button again to get the rest,
171
+ # which is the client asking a person to do a computer's job.
172
+ #
173
+ # RETRYING A POST IS SAFE HERE, and that is worth stating because usually it would not be:
174
+ # the rejection happens at the front door, before the request is proxied to the application
175
+ # at all, so nothing was created and nothing was half-done. A 429 means "not yet".
176
+ deadline_attempts = self.rate_limit_retries + 1
177
+ for attempt in range(deadline_attempts):
178
+ response = self.transport.send(
179
+ Request(method, url, hdrs, data,
180
+ timeout if timeout is not None else self.timeout))
181
+ if response.status != 429 or attempt == deadline_attempts - 1:
182
+ return self._handle(response, parse)
183
+ wait = _retry_after(response) or self._backoff(attempt)
184
+ if self.on_throttled:
185
+ self.on_throttled(wait, attempt + 1, self.rate_limit_retries)
186
+ _time.sleep(wait)
187
+ # A BODY THAT WAS READ ONCE CANNOT BE SENT AGAIN. A file-like body is consumed by the
188
+ # first attempt, so it is rewound where that is possible and the retry abandoned where
189
+ # it is not — a silently truncated upload would be far worse than a 429.
190
+ if data is not None and not isinstance(data, (bytes, bytearray, str)):
191
+ seek = getattr(data, "seek", None)
192
+ if not callable(seek):
193
+ return self._handle(response, parse)
194
+ try:
195
+ seek(0)
196
+ except Exception: # noqa: BLE001
197
+ return self._handle(response, parse)
198
+ return self._handle(response, parse) # pragma: no cover - loop returns
199
+
200
+ def _backoff(self, attempt: int) -> float:
201
+ """How long to wait before retry `attempt`, when the server did not say.
202
+
203
+ The shipped limit is `rate=5r/m` — one request every twelve seconds — so the first wait is
204
+ just over that, and later ones grow in case several clients are queued behind the same
205
+ bucket. Capped, because a wait nobody can see the end of is indistinguishable from a hang.
206
+ """
207
+ return min(60.0, 13.0 * (attempt + 1))
143
208
 
144
209
  def get(self, path: str, params: Optional[Dict[str, Any]] = None, **kw: Any) -> Any:
145
210
  return self.request("GET", path, params=params, **kw)
@@ -199,7 +264,7 @@ class Client(object):
199
264
  def _handle(self, response: Response, parse: bool = True) -> Any:
200
265
  if response.status >= 400:
201
266
  raise errors.from_status(response.status, _detail(response), response.url,
202
- _safe_json(response))
267
+ _safe_json(response), _retry_after(response))
203
268
  if not parse:
204
269
  return response
205
270
  if response.status == 204 or not response.content:
@@ -285,7 +285,7 @@ def _store_credential(origin: str, entry: Dict[str, Any], use_keyring: bool = Tr
285
285
  # Drop any older file copy, so a secret lives in ONE place, not two.
286
286
  _write_credentials({k: v for k, v in _read_credentials().items() if k != origin})
287
287
  return "keyring"
288
- except Exception:
288
+ except Exception: # nosec B110 - intentional: a cosmetic failure must not take down the layer
289
289
  pass # Locked/denied keyring: fall back to the file rather than losing the login.
290
290
  creds = _read_credentials()
291
291
  creds[origin] = entry
@@ -309,7 +309,7 @@ def load_credential(url: str) -> Dict[str, Any]:
309
309
  # Pre-1.3 entries stored the bare token string. Read it rather than making a
310
310
  # working login look like no login at all.
311
311
  return {"token": value}
312
- except Exception:
312
+ except Exception: # nosec B110 - intentional: a cosmetic failure must not take down the layer
313
313
  pass
314
314
  entry = _read_credentials().get(origin) or {}
315
315
  return dict(entry) if isinstance(entry, dict) else {}
@@ -345,7 +345,7 @@ def delete_token(url: str, kind: Optional[str] = None) -> bool:
345
345
  if kr.get_password(KEYRING_SERVICE, origin):
346
346
  kr.delete_password(KEYRING_SERVICE, origin)
347
347
  removed = True
348
- except Exception:
348
+ except Exception: # nosec B110 - intentional: a cosmetic failure must not take down the layer
349
349
  pass
350
350
  creds = _read_credentials()
351
351
  if creds.pop(origin, None) is not None:
@@ -72,12 +72,30 @@ class ValidationError(APIError):
72
72
  """400 / 413 / 422 — the request itself was wrong: bad geometry columns, oversized file."""
73
73
 
74
74
 
75
+ class RateLimited(APIError):
76
+ """Too many requests, too quickly — the server refused this one and wants it later.
77
+
78
+ NOT A FAILURE OF THE REQUEST. The instance's front door rejects these before they ever reach the
79
+ application, so nothing was created, nothing was half-done, and sending the same request again
80
+ after a wait is safe even when it is a POST. That is what makes `Client` retry them for you.
81
+
82
+ `retry_after` is the server's own answer in seconds when it gave one.
83
+ """
84
+
85
+ def __init__(self, status, detail, url="", payload=None, retry_after=None):
86
+ super().__init__(status, detail, url, payload)
87
+ self.retry_after = retry_after
88
+
89
+
75
90
  class ServerError(APIError):
76
91
  """5xx — the instance failed. Worth retrying; not worth reformulating the request."""
77
92
 
78
93
 
79
- def from_status(status: int, detail: str, url: str = "", payload: Any = None) -> APIError:
94
+ def from_status(status: int, detail: str, url: str = "", payload: Any = None,
95
+ retry_after: Any = None) -> APIError:
80
96
  """Map an HTTP status onto the class above that a caller can act on."""
97
+ if status == 429:
98
+ return RateLimited(status, detail, url, payload, retry_after)
81
99
  if status == 401:
82
100
  return AuthError(status, detail, url, payload)
83
101
  if status == 403: