From d1a8e3c838e12b3f3cccbdbc85b88dfb371e1520 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sat, 8 Aug 2026 00:06:10 -0400 Subject: [PATCH 1/8] feat(python): expand layer management and headless camera API Scripting a map from Python needed a browser round trip or a raw project dict for common tasks: reordering, duplicating, renaming layers, reading attribute values, and moving the camera. These now work as plain project mutations on Map and Layer, and the project authoring helpers are exported from the top-level package for scripts that never display a widget. --- python/README.md | 37 ++++++++- python/src/geolibre/__init__.py | 21 ++++- python/src/geolibre/geolibre.py | 141 ++++++++++++++++++++++++++++++-- python/tests/test_project.py | 13 +++ python/tests/test_scripting.py | 71 ++++++++++++++++ 5 files changed, 273 insertions(+), 10 deletions(-) diff --git a/python/README.md b/python/README.md index 85a7af77ff..396d52f95b 100644 --- a/python/README.md +++ b/python/README.md @@ -86,9 +86,44 @@ m.to_project()["mapView"]["center"] | `set_center_zoom(lng, lat, zoom=None)` | Alias of `set_center` (leafmap compatibility). | | `zoom_to_bounds(bounds)` / `zoom_to_layer(layer)` | Fit the view to bounds or a layer id/name/handle. | | `layer_names` / `find_layer(name)` / `set_layer_visibility` / `set_layer_opacity` | Inspect and update layers conveniently. | -| `remove_layer(layer_id)` / `clear_layers()` | Remove layers. | +| `rename_layer` / `move_layer` / `duplicate_layer` / `show_layer` / `hide_layer` | Manage layers by id, name, or `Layer` handle. | +| `layer_properties(layer)` / `column_values(layer, column)` / `describe()` | Inspect inlined data and summarize a project without a browser round trip. | +| `remove_layer(layer)` / `clear_layers()` | Remove one layer by id, name, or handle, or remove all layers. | +| `center` / `zoom` / `basemap` / `name` | Read persisted project and camera state; `name` is writable. | +| `set_zoom` / `set_bearing` / `set_pitch` / `fit_project_bounds` | Persist camera changes without requiring the widget to be displayed. | | `to_project()` / `load_project(src)` / `save_project(path)` | Project I/O. | +Layer handles provide the same operations in an object-oriented form: + +```python +roads = m.find_layer("Roads") +roads.opacity = 0.6 +roads.set_style(lineColor="#e63946", lineWidth=3) +roads.move(0) + +print(roads.properties()) # sampled values for every property +print(roads.column("highway")) # one value per feature +roads_copy = roads.duplicate(name="Roads — proposed") +``` + +For headless authoring and scripts that do not need a widget, commonly used +project utilities are available directly from the top-level package: + +```python +from geolibre import ( + basemap_catalog, + builtin_legend_names, + color_ramp_names, + describe_project, + load_project, + save_project, +) + +project = load_project("my-map.geolibre.json") +print(describe_project(project)) +save_project("copy.geolibre.json", project) +``` + ## Notes - The bundled app is served from a localhost HTTP server, so the interactive diff --git a/python/src/geolibre/__init__.py b/python/src/geolibre/__init__.py index 13d49737a8..7214557b0c 100644 --- a/python/src/geolibre/__init__.py +++ b/python/src/geolibre/__init__.py @@ -2,10 +2,29 @@ from typing import Any +from .authoring import ( + basemap_catalog, + color_ramp_names, + describe_project, + load_project, + save_project, +) from .geolibre import Feature, Layer, Map +from .legends import builtin_legend_names __version__ = "2.5.0" -__all__ = ["Feature", "Layer", "Map", "__version__"] +__all__ = [ + "Feature", + "Layer", + "Map", + "basemap_catalog", + "builtin_legend_names", + "color_ramp_names", + "describe_project", + "load_project", + "save_project", + "__version__", +] def _jupyter_server_extension_points() -> list[dict[str, str]]: diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index 053dcb1eb8..f1ea096c6e 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -976,6 +976,54 @@ def set_layer_opacity(self, layer: str | Layer, opacity: float) -> None: """Set a layer's opacity in ``[0, 1]``.""" self._resolve_layer(layer).opacity = opacity + def rename_layer(self, layer: str | Layer, name: str) -> None: + """Rename a layer addressed by id, name, or handle.""" + handle = self._resolve_layer(layer) + self._update_project(lambda p: _authoring.update_layer(p, handle.id, name=name)) + + def move_layer(self, layer: str | Layer, index: int) -> None: + """Move a layer to ``index`` in the project's draw order. + + Negative indices follow normal Python insertion semantics: ``-1`` moves + the layer to the end. Out-of-range indices are clamped. + """ + handle = self._resolve_layer(layer) + + def _move(project: dict[str, Any]) -> None: + destination = int(index) + if destination < 0: + destination = max(0, len(project.get("layers", [])) + destination) + _authoring.update_layer(project, handle.id, index=destination) + + self._update_project(_move) + + def duplicate_layer(self, layer: str | Layer, *, name: str | None = None) -> str: + """Duplicate a layer, returning the new layer id.""" + source = copy.deepcopy(self._resolve_layer(layer)._layer()) + source["id"] = str(uuid.uuid4()) + source["name"] = name or f"{source.get('name', 'Layer')} copy" + return self._add_layer(source) + + def show_layer(self, layer: str | Layer) -> None: + """Show a layer.""" + self.set_layer_visibility(layer, True) + + def hide_layer(self, layer: str | Layer) -> None: + """Hide a layer.""" + self.set_layer_visibility(layer, False) + + def layer_properties(self, layer: str | Layer) -> dict[str, list[Any]]: + """Return sampled property values for an inlined GeoJSON layer.""" + return _authoring.layer_properties(self._resolve_layer(layer)._layer()) + + def column_values(self, layer: str | Layer, column: str) -> list[Any]: + """Return one property column from an inlined GeoJSON layer.""" + return _authoring.column_values(self._resolve_layer(layer)._layer(), column) + + def describe(self) -> dict[str, Any]: + """Return a compact, JSON-serializable project summary.""" + return _authoring.describe_project(copy.deepcopy(self.project)) + def _mutate_layer(self, layer_id: str, mutate: Callable[[dict[str, Any]], None]) -> None: """Apply an in-place mutation to one layer through the project trait.""" @@ -1901,17 +1949,15 @@ def add_video( url_list = [urls] if isinstance(urls, str) else list(urls) return self._add_layer(_project.video_layer(name, url_list, coordinates, **style)) - def remove_layer(self, layer_id: str) -> None: - """Remove a layer by id. + def remove_layer(self, layer_id: str | Layer) -> None: + """Remove a layer by id, display name, or handle. Args: - layer_id: The id returned when the layer was added. + layer_id: A layer id, display name, or :class:`Layer` handle. """ - def _drop(p: dict[str, Any]) -> None: - p["layers"] = [layer for layer in p["layers"] if layer.get("id") != layer_id] - - self._update_project(_drop) + resolved_id = self._resolve_layer(layer_id).id + self._update_project(lambda p: _authoring.remove_layer(p, resolved_id)) def clear_layers(self) -> None: """Remove all layers from the map.""" @@ -1956,6 +2002,54 @@ def mutate(p: dict[str, Any]) -> None: # leafmap compatibility alias for set_center set_center_zoom = set_center + def set_zoom(self, zoom: float) -> None: + """Set the map zoom while preserving the other camera fields.""" + self._update_project(lambda p: _authoring.set_view(p, zoom=zoom)) + + def set_bearing(self, bearing: float) -> None: + """Set clockwise camera bearing in degrees.""" + self._update_project(lambda p: _authoring.set_view(p, bearing=bearing)) + + def set_pitch(self, pitch: float) -> None: + """Set camera pitch in degrees (clamped to the supported range).""" + self._update_project(lambda p: _authoring.set_view(p, pitch=pitch)) + + def fit_project_bounds(self, bounds: list[float] | tuple[float, float, float, float]) -> None: + """Persist a fitted camera for ``[west, south, east, north]`` bounds. + + Unlike :meth:`fit_bounds`, this is a pure project mutation and does not + require a live browser connection. + """ + self._update_project(lambda p: _authoring.fit_bounds(p, bounds)) + + @property + def center(self) -> tuple[float, float]: + """The persisted ``(longitude, latitude)`` camera center.""" + center = self.project.get("mapView", {}).get("center", [0, 0]) + return float(center[0]), float(center[1]) + + @property + def zoom(self) -> float: + """The persisted camera zoom.""" + return float(self.project.get("mapView", {}).get("zoom", 0)) + + @property + def basemap(self) -> str | None: + """The current basemap style URL.""" + value = self.project.get("basemapStyleUrl") + return str(value) if value is not None else None + + @property + def name(self) -> str: + """The project name.""" + return str(self.project.get("name", "")) + + @name.setter + def name(self, value: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError("name must be a non-empty string") + self._update_project(lambda p: p.update(name=value.strip())) + # -- map controls: split map / legend / colorbar -------------------- @staticmethod @@ -2333,7 +2427,7 @@ def name(self) -> Any: @name.setter def name(self, value: str) -> None: - self._map._mutate_layer(self._id, lambda layer: layer.update(name=value)) + self._map.rename_layer(self, value) @property def visible(self) -> bool: @@ -2361,6 +2455,21 @@ def style(self) -> dict[str, Any]: """A copy of the layer's style object.""" return copy.deepcopy(self._layer().get("style", {})) + @property + def source(self) -> Any: + """A detached copy of the layer source configuration.""" + return copy.deepcopy(self._layer().get("source")) + + @property + def data(self) -> dict[str, Any]: + """A detached copy of the complete layer record.""" + return copy.deepcopy(self._layer()) + + @property + def index(self) -> int: + """The layer's current index in draw order.""" + return next(i for i, layer in enumerate(self._map.layers) if layer.id == self._id) + def set_style(self, **style: Any) -> None: """Merge style overrides into the layer (e.g. ``fillColor="#ff0000"``).""" @@ -2373,6 +2482,22 @@ def get_features(self, *, timeout: float = 10.0) -> list[Feature]: """Return this layer's features (see :meth:`Map.get_features`).""" return self._map.get_features(self._id, timeout=timeout) + def properties(self) -> dict[str, list[Any]]: + """Return sampled property values for inlined GeoJSON.""" + return self._map.layer_properties(self) + + def column(self, name: str) -> list[Any]: + """Return a property column from inlined GeoJSON.""" + return self._map.column_values(self, name) + + def move(self, index: int) -> None: + """Move this layer to an index in draw order.""" + self._map.move_layer(self, index) + + def duplicate(self, *, name: str | None = None) -> Layer: + """Duplicate this layer and return its new handle.""" + return self._map.get_layer(self._map.duplicate_layer(self, name=name)) + def zoom_to(self, *, timeout: float = 10.0) -> None: """Fit the map camera to this layer's extent.""" self._map.zoom_to_layer(self, timeout=timeout) diff --git a/python/tests/test_project.py b/python/tests/test_project.py index 5d040c1c54..662f4d7fdb 100644 --- a/python/tests/test_project.py +++ b/python/tests/test_project.py @@ -10,6 +10,7 @@ import pytest +import geolibre from geolibre import project POINT_FC = { @@ -33,6 +34,18 @@ def test_build_empty_project_defaults(): assert proj["preferences"] is not project.DEFAULT_PROJECT_PREFERENCES +def test_top_level_package_exports_headless_authoring_api(tmp_path): + proj = project.build_empty_project() + assert geolibre.basemap_catalog() + assert geolibre.builtin_legend_names() + assert geolibre.color_ramp_names() + + path = tmp_path / "map.geolibre.json" + geolibre.save_project(path, proj) + loaded = geolibre.load_project(path) + assert geolibre.describe_project(loaded)["layerCount"] == 0 + + def test_build_empty_project_overrides(): proj = project.build_empty_project(center=(10, 20), zoom=7, basemap_url="x") assert proj["mapView"]["center"] == [10.0, 20.0] diff --git a/python/tests/test_scripting.py b/python/tests/test_scripting.py index df10292ce9..76b8a30a5f 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -624,6 +624,77 @@ def test_layer_remove(m): assert m.project["layers"] == [] +def test_map_layer_management_and_introspection(m): + first = m.add_geojson( + { + "type": "FeatureCollection", + "features": [ + {"type": "Feature", "geometry": None, "properties": {"kind": "a", "value": 2}}, + {"type": "Feature", "geometry": None, "properties": {"kind": "b", "value": 3}}, + ], + }, + name="First", + ) + second = m.add_geojson({"type": "FeatureCollection", "features": []}, name="Second") + + m.rename_layer(first, "Renamed") + m.hide_layer("Renamed") + assert m.get_layer(first).name == "Renamed" + assert m.get_layer(first).visible is False + assert m.layer_properties(first)["kind"] == ["a", "b"] + assert m.column_values(first, "value") == [2, 3] + + m.move_layer(second, 0) + assert [layer.id for layer in m.layers] == [second, first] + copy_id = m.duplicate_layer(first, name="Clone") + assert m.get_layer(copy_id).name == "Clone" + assert m.get_layer(copy_id).data["geojson"] == m.get_layer(first).data["geojson"] + assert m.describe()["layerCount"] == 3 + + m.remove_layer("Clone") + assert m.find_layer("Clone") is None + + +def test_layer_handle_expanded_helpers(m): + layer_id = m.add_geojson( + { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": None, "properties": {"x": 1}}], + }, + name="Data", + ) + layer = m.get_layer(layer_id) + assert layer.index == 0 + assert layer.source == {"type": "geojson"} + assert layer.properties() == {"x": [1]} + assert layer.column("x") == [1] + duplicate = layer.duplicate() + assert duplicate.name == "Data copy" + duplicate.move(0) + assert duplicate.index == 0 + + +def test_persisted_camera_and_project_metadata_helpers(m): + m.name = "My analysis" + m.set_center(-80, 35, zoom=4) + m.set_zoom(6) + m.set_bearing(370) + m.set_pitch(100) + assert m.name == "My analysis" + assert m.center == (-80.0, 35.0) + assert m.zoom == 6 + assert m.project["mapView"]["bearing"] == 370 + assert m.project["mapView"]["pitch"] == 85 + with pytest.raises(ValueError, match="non-empty"): + m.name = " " + + +def test_fit_project_bounds_is_browser_independent(m): + m.fit_project_bounds([-10, -5, 10, 5]) + assert m.center == (0.0, 0.0) + assert m.zoom > 0 + + def test_layer_zoom_to_sends_command(m, monkeypatch): captured = {} monkeypatch.setattr( From f48f582d86e83b64badf3d14abcfdac5dc416e99 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sat, 8 Aug 2026 00:15:34 -0400 Subject: [PATCH 2/8] Address review feedback - Route Map._resolve_layer through authoring.find_layer so scripting and the MCP tools agree on what a layer reference means: id first, then exact name, then case-insensitive, with a duplicated name raising instead of silently picking one. Now that remove_layer accepts names, an arbitrary pick would delete the wrong layer. - Validate duplicate_layer's explicit name against the reserved basemap pseudo-id, which rename_layer already rejects via update_layer. - Route set_center through authoring.set_view so a manual recenter clears the bbox fit_project_bounds recorded, instead of leaving a stale extent behind. - Make Layer.index raise the documented ValueError for a removed handle rather than a bare StopIteration, matching the other accessors. - Add Map.bearing and Map.pitch read properties so every camera setter has a matching getter. - Correct the move_layer docstring: negative values follow sequence indexing, not list.insert semantics. - Sort __version__ into __all__ (RUF022) and make the README layer-handle example self-contained. --- python/README.md | 8 +++-- python/src/geolibre/__init__.py | 2 +- python/src/geolibre/geolibre.py | 56 ++++++++++++++++++++++----------- python/tests/test_scripting.py | 36 +++++++++++++++++++-- 4 files changed, 78 insertions(+), 24 deletions(-) diff --git a/python/README.md b/python/README.md index 396d52f95b..280b1fa374 100644 --- a/python/README.md +++ b/python/README.md @@ -96,14 +96,16 @@ m.to_project()["mapView"]["center"] Layer handles provide the same operations in an object-oriented form: ```python -roads = m.find_layer("Roads") +m.add_geojson("https://example.com/roads.geojson", name="Roads") + +roads = m.find_layer("Roads") # None when no layer has that name roads.opacity = 0.6 roads.set_style(lineColor="#e63946", lineWidth=3) roads.move(0) -print(roads.properties()) # sampled values for every property +print(roads.properties()) # sampled values for every property print(roads.column("highway")) # one value per feature -roads_copy = roads.duplicate(name="Roads — proposed") +roads_copy = roads.duplicate(name="Roads (proposed)") ``` For headless authoring and scripts that do not need a widget, commonly used diff --git a/python/src/geolibre/__init__.py b/python/src/geolibre/__init__.py index 7214557b0c..38c99856c4 100644 --- a/python/src/geolibre/__init__.py +++ b/python/src/geolibre/__init__.py @@ -17,13 +17,13 @@ "Feature", "Layer", "Map", + "__version__", "basemap_catalog", "builtin_legend_names", "color_ramp_names", "describe_project", "load_project", "save_project", - "__version__", ] diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index f1ea096c6e..e54b744337 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -960,13 +960,12 @@ def _resolve_layer(self, layer: str | Layer) -> Layer: # Access verifies that a stale handle has not been removed. layer._layer() return layer - try: - return self.get_layer(str(layer)) - except ValueError: - match = self.find_layer(str(layer)) - if match is not None: - return match - raise ValueError(f"No layer with id or name {layer!r}") + # Share the authoring resolver so scripting and the MCP tools agree on + # what a reference means: an id wins outright, then an exact name, then a + # case-insensitive one, and a name several layers share is an error rather + # than an arbitrary pick. `find_layer` returns the first name match by + # design (leafmap compatibility), so it is not the resolver for mutations. + return Layer(self, str(_authoring.find_layer(self.project, str(layer))["id"])) def set_layer_visibility(self, layer: str | Layer, visible: bool = True) -> None: """Show or hide a layer addressed by id, name, or layer handle.""" @@ -984,8 +983,9 @@ def rename_layer(self, layer: str | Layer, name: str) -> None: def move_layer(self, layer: str | Layer, index: int) -> None: """Move a layer to ``index`` in the project's draw order. - Negative indices follow normal Python insertion semantics: ``-1`` moves - the layer to the end. Out-of-range indices are clamped. + Negative indices count from the end the way sequence *indexing* does, so + ``-1`` moves the layer to the last position (not ``list.insert(-1, ...)``, + which would leave it second to last). Out-of-range indices are clamped. """ handle = self._resolve_layer(layer) @@ -998,7 +998,15 @@ def _move(project: dict[str, Any]) -> None: self._update_project(_move) def duplicate_layer(self, layer: str | Layer, *, name: str | None = None) -> str: - """Duplicate a layer, returning the new layer id.""" + """Duplicate a layer, returning the new layer id. + + Raises: + ValueError: If ``name`` is the reserved basemap pseudo-id. + """ + if name is not None: + # `_add_layer` appends straight to the project, so the check + # `rename_layer` gets from `update_layer` has to happen here. + _authoring._reject_reserved_name(name) source = copy.deepcopy(self._resolve_layer(layer)._layer()) source["id"] = str(uuid.uuid4()) source["name"] = name or f"{source.get('name', 'Layer')} copy" @@ -1991,13 +1999,9 @@ def set_center(self, lng: float, lat: float, zoom: float | None = None) -> None: lat: Latitude of the new center. zoom: Optional zoom level. """ - - def mutate(p: dict[str, Any]) -> None: - p["mapView"]["center"] = [float(lng), float(lat)] - if zoom is not None: - p["mapView"]["zoom"] = float(zoom) - - self._update_project(mutate) + self._update_project( + lambda p: _authoring.set_view(p, center=(lng, lat), zoom=zoom), + ) # leafmap compatibility alias for set_center set_center_zoom = set_center @@ -2033,6 +2037,16 @@ def zoom(self) -> float: """The persisted camera zoom.""" return float(self.project.get("mapView", {}).get("zoom", 0)) + @property + def bearing(self) -> float: + """The persisted clockwise camera bearing in degrees.""" + return float(self.project.get("mapView", {}).get("bearing", 0)) + + @property + def pitch(self) -> float: + """The persisted camera pitch in degrees.""" + return float(self.project.get("mapView", {}).get("pitch", 0)) + @property def basemap(self) -> str | None: """The current basemap style URL.""" @@ -2467,7 +2481,13 @@ def data(self) -> dict[str, Any]: @property def index(self) -> int: - """The layer's current index in draw order.""" + """The layer's current index in draw order. + + Raises: + ValueError: If the layer has been removed, matching the other + accessors rather than raising ``StopIteration``. + """ + self._layer() return next(i for i, layer in enumerate(self._map.layers) if layer.id == self._id) def set_style(self, **style: Any) -> None: diff --git a/python/tests/test_scripting.py b/python/tests/test_scripting.py index 76b8a30a5f..8b83b3f256 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -673,6 +673,33 @@ def test_layer_handle_expanded_helpers(m): duplicate.move(0) assert duplicate.index == 0 + duplicate.remove() + with pytest.raises(ValueError, match="no longer exists"): + duplicate.index + + +def test_layer_reference_matching_is_shared_with_authoring(m): + first = m.add_geojson({"type": "FeatureCollection", "features": []}, name="Rivers") + m.add_geojson({"type": "FeatureCollection", "features": []}, name="Roads") + + # Case-insensitive name matching, as `authoring.find_layer` defines it. + m.set_layer_opacity("rivers", 0.25) + assert m.get_layer(first).opacity == 0.25 + + # A name several layers share is an error, not an arbitrary pick. + m.add_geojson({"type": "FeatureCollection", "features": []}, name="Roads") + with pytest.raises(ValueError, match="2 layers are named"): + m.remove_layer("Roads") + with pytest.raises(ValueError, match="No layer matches"): + m.remove_layer("Nothing") + + +def test_duplicate_layer_rejects_the_reserved_basemap_name(m): + layer_id = m.add_geojson({"type": "FeatureCollection", "features": []}, name="Data") + with pytest.raises(ValueError): + m.duplicate_layer(layer_id, name="__basemap__") + assert len(m.layers) == 1 + def test_persisted_camera_and_project_metadata_helpers(m): m.name = "My analysis" @@ -683,8 +710,8 @@ def test_persisted_camera_and_project_metadata_helpers(m): assert m.name == "My analysis" assert m.center == (-80.0, 35.0) assert m.zoom == 6 - assert m.project["mapView"]["bearing"] == 370 - assert m.project["mapView"]["pitch"] == 85 + assert m.bearing == 370 + assert m.pitch == 85 with pytest.raises(ValueError, match="non-empty"): m.name = " " @@ -693,6 +720,11 @@ def test_fit_project_bounds_is_browser_independent(m): m.fit_project_bounds([-10, -5, 10, 5]) assert m.center == (0.0, 0.0) assert m.zoom > 0 + assert "bbox" in m.project["mapView"] + + # Recentering by hand leaves the fitted bbox describing a different extent. + m.set_center(20, 10) + assert "bbox" not in m.project["mapView"] def test_layer_zoom_to_sends_command(m, monkeypatch): From 827cee4096f2d7a790cd0533bb11b88d1f18c5a9 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sat, 8 Aug 2026 00:18:54 -0400 Subject: [PATCH 3/8] Address CodeRabbit review feedback - Bind the bare `duplicate.index` access to `_` so the statement reads as a deliberate property evaluation rather than dead code. - Match on the reserved-name message instead of accepting any ValueError, so the test cannot pass on an unrelated failure. --- python/tests/test_scripting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/tests/test_scripting.py b/python/tests/test_scripting.py index 8b83b3f256..6b387374f7 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -675,7 +675,7 @@ def test_layer_handle_expanded_helpers(m): duplicate.remove() with pytest.raises(ValueError, match="no longer exists"): - duplicate.index + _ = duplicate.index def test_layer_reference_matching_is_shared_with_authoring(m): @@ -696,7 +696,7 @@ def test_layer_reference_matching_is_shared_with_authoring(m): def test_duplicate_layer_rejects_the_reserved_basemap_name(m): layer_id = m.add_geojson({"type": "FeatureCollection", "features": []}, name="Data") - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="reserved for the basemap"): m.duplicate_layer(layer_id, name="__basemap__") assert len(m.layers) == 1 From 29332268c2e0a434b3cb41d1f333413e2afe9790 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sat, 8 Aug 2026 00:26:35 -0400 Subject: [PATCH 4/8] Address Claude review feedback - Route duplicate_layer through authoring.add_layer instead of reaching into the private _reject_reserved_name, and reject a blank explicit name rather than silently substituting the auto-generated one, matching the Map.name setter. - Stop deep-copying the whole project in describe(): copy the small summary instead, which detaches the live mapView it returns without duplicating every inlined GeoJSON blob to report a feature count. - Document that remove_layer now raises on an unresolved or ambiguous reference, where it used to be a silent no-op. - Cover move_layer's negative-index semantics, which differ from list.insert(-1, ...) and are easy to reintroduce a bug in. --- python/src/geolibre/geolibre.py | 30 ++++++++++++++++++++++-------- python/tests/test_scripting.py | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index e54b744337..f7f524f527 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -1000,17 +1000,22 @@ def _move(project: dict[str, Any]) -> None: def duplicate_layer(self, layer: str | Layer, *, name: str | None = None) -> str: """Duplicate a layer, returning the new layer id. + Args: + layer: The layer to copy, by id, name, or handle. + name: Name for the copy; defaults to the source name plus ``copy``. + Raises: - ValueError: If ``name`` is the reserved basemap pseudo-id. + ValueError: If ``name`` is blank or the reserved basemap pseudo-id. """ - if name is not None: - # `_add_layer` appends straight to the project, so the check - # `rename_layer` gets from `update_layer` has to happen here. - _authoring._reject_reserved_name(name) + if name is not None and not str(name).strip(): + raise ValueError("name must be a non-empty string") source = copy.deepcopy(self._resolve_layer(layer)._layer()) source["id"] = str(uuid.uuid4()) - source["name"] = name or f"{source.get('name', 'Layer')} copy" - return self._add_layer(source) + source["name"] = str(name) if name is not None else f"{source.get('name', 'Layer')} copy" + # `_add_layer` appends raw; `authoring.add_layer` is the entry point that + # applies the reserved-name check `rename_layer` gets from `update_layer`. + self._update_project(lambda p: _authoring.add_layer(p, source)) + return str(source["id"]) def show_layer(self, layer: str | Layer) -> None: """Show a layer.""" @@ -1030,7 +1035,11 @@ def column_values(self, layer: str | Layer, column: str) -> list[Any]: def describe(self) -> dict[str, Any]: """Return a compact, JSON-serializable project summary.""" - return _authoring.describe_project(copy.deepcopy(self.project)) + # Copy the summary, not the project: `describe_project` hands back the + # live `mapView`, so the result needs detaching, but deep-copying the + # project first would duplicate every inlined GeoJSON blob only to + # report a feature count. + return copy.deepcopy(_authoring.describe_project(self.project)) def _mutate_layer(self, layer_id: str, mutate: Callable[[dict[str, Any]], None]) -> None: """Apply an in-place mutation to one layer through the project trait.""" @@ -1962,6 +1971,11 @@ def remove_layer(self, layer_id: str | Layer) -> None: Args: layer_id: A layer id, display name, or :class:`Layer` handle. + + Raises: + ValueError: If the reference matches no layer, or matches a display + name several layers share. Removing an unknown layer used to be + a silent no-op; it now reports the miss. """ resolved_id = self._resolve_layer(layer_id).id diff --git a/python/tests/test_scripting.py b/python/tests/test_scripting.py index 6b387374f7..73cbeb03eb 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -698,9 +698,30 @@ def test_duplicate_layer_rejects_the_reserved_basemap_name(m): layer_id = m.add_geojson({"type": "FeatureCollection", "features": []}, name="Data") with pytest.raises(ValueError, match="reserved for the basemap"): m.duplicate_layer(layer_id, name="__basemap__") + with pytest.raises(ValueError, match="non-empty"): + m.duplicate_layer(layer_id, name=" ") assert len(m.layers) == 1 +def test_move_layer_negative_index_counts_from_the_end(m): + ids = [ + m.add_geojson({"type": "FeatureCollection", "features": []}, name=name) + for name in ("A", "B", "C") + ] + + # -1 lands the layer last, unlike `list.insert(-1, ...)` which would leave + # it second to last. + m.move_layer(ids[0], -1) + assert [layer.id for layer in m.layers] == [ids[1], ids[2], ids[0]] + + m.move_layer(ids[0], -2) + assert [layer.id for layer in m.layers] == [ids[1], ids[0], ids[2]] + + # Out-of-range negatives clamp to the front rather than wrapping. + m.move_layer(ids[2], -99) + assert [layer.id for layer in m.layers] == [ids[2], ids[1], ids[0]] + + def test_persisted_camera_and_project_metadata_helpers(m): m.name = "My analysis" m.set_center(-80, 35, zoom=4) From f0bfb93a71d2f8c3823564a81a63144e4d00d801 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sat, 8 Aug 2026 00:41:12 -0400 Subject: [PATCH 5/8] Address Claude review feedback - Strip an explicit duplicate_layer name before storing it, matching the Map.name setter, so " Clone " does not become a padded name that is awkward to reference back. - Document that the copy is appended to the draw order rather than placed next to its source, which was previously unstated either way. --- python/src/geolibre/geolibre.py | 18 ++++++++++++++---- python/tests/test_scripting.py | 3 +++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index f7f524f527..ad51adc9a4 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -1000,18 +1000,28 @@ def _move(project: dict[str, Any]) -> None: def duplicate_layer(self, layer: str | Layer, *, name: str | None = None) -> str: """Duplicate a layer, returning the new layer id. + The copy is appended to the draw order (drawn on top), the same place a + newly added layer lands, rather than next to its source. Use + :meth:`move_layer` to put it elsewhere. + Args: layer: The layer to copy, by id, name, or handle. - name: Name for the copy; defaults to the source name plus ``copy``. + name: Name for the copy, surrounding whitespace stripped; defaults + to the source name plus ``copy``. Raises: ValueError: If ``name`` is blank or the reserved basemap pseudo-id. """ - if name is not None and not str(name).strip(): - raise ValueError("name must be a non-empty string") + if name is not None: + # Strip before the blank check so `" "` is rejected, matching the + # `name` setter rather than storing a padded name that is awkward to + # reference back. + name = str(name).strip() + if not name: + raise ValueError("name must be a non-empty string") source = copy.deepcopy(self._resolve_layer(layer)._layer()) source["id"] = str(uuid.uuid4()) - source["name"] = str(name) if name is not None else f"{source.get('name', 'Layer')} copy" + source["name"] = name if name is not None else f"{source.get('name', 'Layer')} copy" # `_add_layer` appends raw; `authoring.add_layer` is the entry point that # applies the reserved-name check `rename_layer` gets from `update_layer`. self._update_project(lambda p: _authoring.add_layer(p, source)) diff --git a/python/tests/test_scripting.py b/python/tests/test_scripting.py index 73cbeb03eb..40115c6f9f 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -702,6 +702,9 @@ def test_duplicate_layer_rejects_the_reserved_basemap_name(m): m.duplicate_layer(layer_id, name=" ") assert len(m.layers) == 1 + padded = m.duplicate_layer(layer_id, name=" Clone ") + assert m.get_layer(padded).name == "Clone" + def test_move_layer_negative_index_counts_from_the_end(m): ids = [ From 0894ce791c63d9dad1fb35b054096e3bc3bbca30 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sat, 8 Aug 2026 00:48:51 -0400 Subject: [PATCH 6/8] Address Claude review feedback - Sweep credentials from Layer.source and Layer.data. Both hand a layer record straight to a caller, and a notebook auto-displays whatever a cell returns, so a source built with request_headers or a signed URL would print its secrets into an output that often gets committed. Factored the per-layer sweep redact_credentials already ran into project.redact_layer so the two paths cannot drift. - Reject a blank name in rename_layer (and so in the Layer.name setter, which delegates to it); authoring.update_layer guards only the reserved basemap pseudo-id. Shares one _clean_layer_name helper with duplicate_layer. - List the bearing and pitch read properties in the README table. --- python/README.md | 2 +- python/src/geolibre/geolibre.py | 51 +++++++++++++++++++++++++-------- python/src/geolibre/project.py | 35 +++++++++++++++++----- python/tests/test_scripting.py | 26 +++++++++++++++++ 4 files changed, 94 insertions(+), 20 deletions(-) diff --git a/python/README.md b/python/README.md index 280b1fa374..7b0e262e5f 100644 --- a/python/README.md +++ b/python/README.md @@ -89,7 +89,7 @@ m.to_project()["mapView"]["center"] | `rename_layer` / `move_layer` / `duplicate_layer` / `show_layer` / `hide_layer` | Manage layers by id, name, or `Layer` handle. | | `layer_properties(layer)` / `column_values(layer, column)` / `describe()` | Inspect inlined data and summarize a project without a browser round trip. | | `remove_layer(layer)` / `clear_layers()` | Remove one layer by id, name, or handle, or remove all layers. | -| `center` / `zoom` / `basemap` / `name` | Read persisted project and camera state; `name` is writable. | +| `center` / `zoom` / `bearing` / `pitch` / `basemap` / `name` | Read persisted project and camera state; `name` is writable. | | `set_zoom` / `set_bearing` / `set_pitch` / `fit_project_bounds` | Persist camera changes without requiring the widget to be displayed. | | `to_project()` / `load_project(src)` / `save_project(path)` | Project I/O. | diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index ad51adc9a4..17d1202a69 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -976,9 +976,31 @@ def set_layer_opacity(self, layer: str | Layer, opacity: float) -> None: self._resolve_layer(layer).opacity = opacity def rename_layer(self, layer: str | Layer, name: str) -> None: - """Rename a layer addressed by id, name, or handle.""" + """Rename a layer addressed by id, name, or handle. + + Args: + layer: The layer to rename, by id, name, or handle. + name: The new display name, surrounding whitespace stripped. + + Raises: + ValueError: If ``name`` is blank or the reserved basemap pseudo-id. + """ handle = self._resolve_layer(layer) - self._update_project(lambda p: _authoring.update_layer(p, handle.id, name=name)) + clean = self._clean_layer_name(name) + self._update_project(lambda p: _authoring.update_layer(p, handle.id, name=clean)) + + @staticmethod + def _clean_layer_name(name: str) -> str: + """Strip a display name and refuse a blank one. + + `authoring.update_layer` guards only the reserved basemap pseudo-id, so + emptiness is checked here, matching the `name` setter. A layer named "" + or " " renders as a blank row that cannot be referenced back by name. + """ + clean = str(name).strip() + if not clean: + raise ValueError("name must be a non-empty string") + return clean def move_layer(self, layer: str | Layer, index: int) -> None: """Move a layer to ``index`` in the project's draw order. @@ -1013,12 +1035,7 @@ def duplicate_layer(self, layer: str | Layer, *, name: str | None = None) -> str ValueError: If ``name`` is blank or the reserved basemap pseudo-id. """ if name is not None: - # Strip before the blank check so `" "` is rejected, matching the - # `name` setter rather than storing a padded name that is awkward to - # reference back. - name = str(name).strip() - if not name: - raise ValueError("name must be a non-empty string") + name = self._clean_layer_name(name) source = copy.deepcopy(self._resolve_layer(layer)._layer()) source["id"] = str(uuid.uuid4()) source["name"] = name if name is not None else f"{source.get('name', 'Layer')} copy" @@ -2495,13 +2512,23 @@ def style(self) -> dict[str, Any]: @property def source(self) -> Any: - """A detached copy of the layer source configuration.""" - return copy.deepcopy(self._layer().get("source")) + """A detached copy of the layer source configuration. + + Credentials are swept the way :meth:`Map.to_project` sweeps them: a + notebook auto-displays whatever a cell returns, and a source built with + ``request_headers`` or a signed URL would otherwise print its secrets + into an output that often gets committed or shared. Read + :attr:`Map.project` for the record exactly as stored. + """ + return _project.redact_layer(self._layer()).get("source") @property def data(self) -> dict[str, Any]: - """A detached copy of the complete layer record.""" - return copy.deepcopy(self._layer()) + """A detached copy of the complete layer record. + + Credentials are swept, as in :attr:`source`. + """ + return _project.redact_layer(self._layer()) @property def index(self) -> int: diff --git a/python/src/geolibre/project.py b/python/src/geolibre/project.py index 1a99ed369e..cdd557f5aa 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -157,6 +157,33 @@ def _publishable_plugin_settings(settings: dict[str, Any]) -> dict[str, Any]: return kept +#: The layer fields that can carry credentials: request headers, signed URLs, +#: and API keys all live under these. ``connection.lastError`` is free-form text +#: taken from a caught error, which a future refresh path could easily build +#: from the request URL. Sweeping it costs nothing and keeps the no-secret +#: guarantee from depending on how an error message is worded. +_LAYER_CREDENTIAL_FIELDS = ("source", "metadata", "sourcePath", "connection") + + +def _sweep_layer_credentials(layer: dict[str, Any]) -> None: + """Redact a layer's credential-bearing config fields in place.""" + for field in _LAYER_CREDENTIAL_FIELDS: + if field in layer: + layer[field] = _redact_config(layer[field]) + + +def redact_layer(layer: dict[str, Any]) -> dict[str, Any]: + """Return a detached copy of one layer, safe to display or hand to others. + + The same sweep :func:`redact_credentials` applies to every layer, for the + single-layer reads (:attr:`Layer.source`, :attr:`Layer.data`) that hand a + layer record back to a caller rather than writing a whole project out. + """ + safe = copy.deepcopy(layer) + _sweep_layer_credentials(safe) + return safe + + def redact_credentials(project: dict[str, Any]) -> dict[str, Any]: """Return a detached project safe to publish, export, or hand to others.""" safe = copy.deepcopy(project) @@ -176,13 +203,7 @@ def redact_credentials(project: dict[str, Any]) -> dict[str, Any]: for layer in layers: if not isinstance(layer, dict): continue - # `connection.lastError` is free-form text taken from a caught - # error, which a future refresh path could easily build from the - # request URL. Sweeping it costs nothing and keeps the no-secret - # guarantee from depending on how an error message is worded. - for field in ("source", "metadata", "sourcePath", "connection"): - if field in layer: - layer[field] = _redact_config(layer[field]) + _sweep_layer_credentials(layer) plugins = safe.get("plugins") if isinstance(plugins, dict): manifest_urls = plugins.get("manifestUrls") diff --git a/python/tests/test_scripting.py b/python/tests/test_scripting.py index 40115c6f9f..51fffff543 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -706,6 +706,32 @@ def test_duplicate_layer_rejects_the_reserved_basemap_name(m): assert m.get_layer(padded).name == "Clone" +def test_rename_layer_strips_and_rejects_a_blank_name(m): + layer = m.get_layer(m.add_geojson({"type": "FeatureCollection", "features": []}, name="Data")) + layer.name = " Renamed " + assert layer.name == "Renamed" + for blank in ("", " "): + with pytest.raises(ValueError, match="non-empty"): + m.rename_layer(layer, blank) + assert layer.name == "Renamed" + + +def test_layer_data_and_source_redact_credentials(m): + layer_id = m.add_3d_tiles( + "https://example.com/tileset.json?token=secret", + name="Secured", + request_headers={"Authorization": "Bearer hunter2"}, + ) + layer = m.get_layer(layer_id) + + # The stored record keeps the headers; the reads that hand one back do not. + assert "requestHeaders" in m.project["layers"][0]["source"] + assert "requestHeaders" not in layer.source + assert "requestHeaders" not in layer.data["source"] + assert "hunter2" not in json.dumps(layer.data) + assert "secret" not in json.dumps(layer.data) + + def test_move_layer_negative_index_counts_from_the_end(m): ids = [ m.add_geojson({"type": "FeatureCollection", "features": []}, name=name) From 279ce4e468c74b03c72ae091b140228e04ee7ed2 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sat, 8 Aug 2026 00:57:02 -0400 Subject: [PATCH 7/8] Address Claude review feedback - Redact Map.basemap the way Layer.source is redacted. MapTiler and Stadia put an API key in the style URL itself, so reading it back in a notebook printed the key; project.redact_url is the public entry point for the sweep redact_credentials already applied to that field. - Document that Layer.data copies an inlined geojson blob whole, and point at properties()/describe() for the cases a summary covers. --- python/src/geolibre/geolibre.py | 14 +++++++++++--- python/src/geolibre/project.py | 10 ++++++++++ python/tests/test_scripting.py | 6 ++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index 17d1202a69..269c94ce40 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -2090,9 +2090,14 @@ def pitch(self) -> float: @property def basemap(self) -> str | None: - """The current basemap style URL.""" + """The current basemap style URL, embedded credentials redacted. + + MapTiler, Stadia and others put an API key in the style URL itself, so + this is swept like :attr:`Layer.source` rather than printed into a + notebook cell. Read :attr:`project` for the URL exactly as stored. + """ value = self.project.get("basemapStyleUrl") - return str(value) if value is not None else None + return _project.redact_url(str(value)) if value is not None else None @property def name(self) -> str: @@ -2526,7 +2531,10 @@ def source(self) -> Any: def data(self) -> dict[str, Any]: """A detached copy of the complete layer record. - Credentials are swept, as in :attr:`source`. + Credentials are swept, as in :attr:`source`. "Complete" is literal: an + inlined ``geojson`` blob is copied whole, which for a large layer is + tens of megabytes to copy and to display. Use :meth:`properties` or + :meth:`Map.describe` when a summary will do. """ return _project.redact_layer(self._layer()) diff --git a/python/src/geolibre/project.py b/python/src/geolibre/project.py index cdd557f5aa..97b153deff 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -157,6 +157,16 @@ def _publishable_plugin_settings(settings: dict[str, Any]) -> dict[str, Any]: return kept +def redact_url(url: str) -> str: + """Return a URL with its userinfo and credential parameters stripped. + + The public entry point to the sweep :func:`redact_credentials` applies to + every URL it finds, for the single-value reads (:attr:`Map.basemap`) that + hand one back rather than writing a whole project out. + """ + return _redact_url(url) + + #: The layer fields that can carry credentials: request headers, signed URLs, #: and API keys all live under these. ``connection.lastError`` is free-form text #: taken from a caught error, which a future refresh path could easily build diff --git a/python/tests/test_scripting.py b/python/tests/test_scripting.py index 51fffff543..1db365c7de 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -732,6 +732,12 @@ def test_layer_data_and_source_redact_credentials(m): assert "secret" not in json.dumps(layer.data) +def test_basemap_property_redacts_an_embedded_key(m): + m.project = {**m.project, "basemapStyleUrl": "https://api.example.com/style.json?key=secret"} + assert m.basemap == "https://api.example.com/style.json" + assert m.project["basemapStyleUrl"].endswith("key=secret") + + def test_move_layer_negative_index_counts_from_the_end(m): ids = [ m.add_geojson({"type": "FeatureCollection", "features": []}, name=name) From ca95bd2acef41a4294943ab25d0583057c4345e3 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sat, 8 Aug 2026 01:04:31 -0400 Subject: [PATCH 8/8] Address Claude review feedback Redact URLs in layer_summary and describe_project rather than at the Map.describe call site. A summary exists to be shown, and both callers show it somewhere untrusted: a notebook cell that often gets committed, and an MCP tool result that goes to a model client. Fixing it in authoring.py closes the same gap in the MCP server's describe_project tool and keeps the one-place-per-change layering the repo documents. --- python/src/geolibre/authoring.py | 15 ++++++++++++--- python/tests/test_scripting.py | 9 +++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/python/src/geolibre/authoring.py b/python/src/geolibre/authoring.py index 37539796da..4f286b96cd 100644 --- a/python/src/geolibre/authoring.py +++ b/python/src/geolibre/authoring.py @@ -220,7 +220,10 @@ def layer_summary(layer: dict[str, Any]) -> dict[str, Any]: """Summarize one layer for display, omitting any inlined data. A GeoJSON layer's ``geojson`` blob can be tens of megabytes, so it is - reported as a feature count rather than echoed back. + reported as a feature count rather than echoed back. The source URL is + reported with its credentials stripped: a summary exists to be shown, and + both callers show it somewhere untrusted (a notebook cell that gets + committed, an MCP tool result that goes to a model client). Args: layer: A layer dict. @@ -239,7 +242,7 @@ def layer_summary(layer: dict[str, Any]) -> dict[str, Any]: if isinstance(source, dict): url = source.get("url") or (source.get("tiles") or [None])[0] if url: - summary["source"] = url + summary["source"] = _project.redact_url(str(url)) geojson = layer.get("geojson") if isinstance(geojson, dict): features = geojson.get("features") @@ -257,6 +260,9 @@ def layer_summary(layer: dict[str, Any]) -> dict[str, Any]: def describe_project(project: dict[str, Any]) -> dict[str, Any]: """Summarize a project: its camera, basemap, layers, and map controls. + URLs come back with their credentials stripped, as in :func:`layer_summary`; + several basemap providers put an API key in the style URL itself. + Args: project: The project dict. @@ -279,11 +285,14 @@ def describe_project(project: dict[str, Any]) -> dict[str, Any]: components = settings.get(_project.COMPONENTS_PLUGIN_ID) if isinstance(components, dict): controls.extend(key for key in ("legend", "colorbar") if key in components) + basemap_url = project.get("basemapStyleUrl") return { "name": project.get("name"), "version": project.get("version"), "mapView": project.get("mapView"), - "basemapStyleUrl": project.get("basemapStyleUrl"), + "basemapStyleUrl": ( + _project.redact_url(str(basemap_url)) if basemap_url is not None else basemap_url + ), "layerCount": len(layers_of(project)), "layers": [layer_summary(layer) for layer in layers_of(project) if isinstance(layer, dict)], "mapControls": controls, diff --git a/python/tests/test_scripting.py b/python/tests/test_scripting.py index 1db365c7de..c36db9e5de 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -738,6 +738,15 @@ def test_basemap_property_redacts_an_embedded_key(m): assert m.project["basemapStyleUrl"].endswith("key=secret") +def test_describe_redacts_credentials_like_its_sibling_accessors(m): + m.project = {**m.project, "basemapStyleUrl": "https://api.example.com/style.json?key=secret"} + m.add_tile_layer("https://api.example.com/{z}/{x}/{y}.png?key=secret", name="Tiles") + + summary = m.describe() + assert summary["basemapStyleUrl"] == "https://api.example.com/style.json" + assert "secret" not in json.dumps(summary) + + def test_move_layer_negative_index_counts_from_the_end(m): ids = [ m.add_geojson({"type": "FeatureCollection", "features": []}, name=name)