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.
- {geodeploy-1.4.0/geodeploy.egg-info → geodeploy-1.6.0}/PKG-INFO +1 -1
- {geodeploy-1.4.0 → geodeploy-1.6.0}/README.md +16 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/__init__.py +3 -1
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/_common.py +143 -4
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/portals.py +2 -1
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/sources.py +29 -9
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/client.py +71 -6
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/config.py +3 -3
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/errors.py +19 -1
- geodeploy-1.6.0/geodeploy/expressions.py +743 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/portals.py +4 -1
- geodeploy-1.6.0/geodeploy/sources.py +108 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/styles.py +289 -4
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/transport.py +0 -1
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/uploads.py +2 -2
- {geodeploy-1.4.0 → geodeploy-1.6.0/geodeploy.egg-info}/PKG-INFO +1 -1
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/SOURCES.txt +5 -0
- geodeploy-1.6.0/tests/test_expressions.py +374 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_layers_portals.py +15 -1
- geodeploy-1.6.0/tests/test_rate_limit.py +160 -0
- geodeploy-1.6.0/tests/test_sources_kinds.py +166 -0
- geodeploy-1.6.0/tests/test_style_pictures.py +144 -0
- geodeploy-1.4.0/geodeploy/sources.py +0 -70
- {geodeploy-1.4.0 → geodeploy-1.6.0}/LICENSE +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/MANIFEST.in +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/NOTICE +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/PYPI.md +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/__main__.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/admin.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/catalog.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/__init__.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/__init__.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/admin.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/auth.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/browse.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/catalog.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/imports.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/jobs.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/layers.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/commands/upload.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/main.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/cli/output.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/imports.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/jobs.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/layers.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy/py.typed +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/dependency_links.txt +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/entry_points.txt +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/requires.txt +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/geodeploy.egg-info/top_level.txt +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/pyproject.toml +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/setup.cfg +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/conftest.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_cli.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_config.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_output.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_styles_jobs.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_transport.py +0 -0
- {geodeploy-1.4.0 → geodeploy-1.6.0}/tests/test_uploads.py +0 -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.
|
|
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-
|
|
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
|
|
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
|
|
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 …` —
|
|
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", "
|
|
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
|
-
|
|
27
|
-
|
|
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=
|
|
32
|
-
add.add_argument("--layer-name",
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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:
|