diff --git a/python/README.md b/python/README.md index 85a7af77f..7b0e262e5 100644 --- a/python/README.md +++ b/python/README.md @@ -86,9 +86,46 @@ 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` / `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. | +Layer handles provide the same operations in an object-oriented form: + +```python +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.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 13d49737a..38c99856c 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", + "__version__", + "basemap_catalog", + "builtin_legend_names", + "color_ramp_names", + "describe_project", + "load_project", + "save_project", +] def _jupyter_server_extension_points() -> list[dict[str, str]]: diff --git a/python/src/geolibre/authoring.py b/python/src/geolibre/authoring.py index 37539796d..4f286b96c 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/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index 053dcb1eb..269c94ce4 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.""" @@ -976,6 +975,99 @@ 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. + + 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) + 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. + + 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) + + 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. + + 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, 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: + 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" + # `_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.""" + 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.""" + # 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.""" @@ -1901,17 +1993,20 @@ 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] + 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. + """ - 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.""" @@ -1945,17 +2040,76 @@ 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 + 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 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, 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 _project.redact_url(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 +2487,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 +2515,40 @@ 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. + + 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. + + 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()) + + @property + def index(self) -> int: + """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: """Merge style overrides into the layer (e.g. ``fillColor="#ff0000"``).""" @@ -2373,6 +2561,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/src/geolibre/project.py b/python/src/geolibre/project.py index 1a99ed369..97b153def 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -157,6 +157,43 @@ 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 +#: 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 +213,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_project.py b/python/tests/test_project.py index 5d040c1c5..662f4d7fd 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 df10292ce..c36db9e5d 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -624,6 +624,174 @@ 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 + + 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, 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 + + padded = m.duplicate_layer(layer_id, name=" Clone ") + 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_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_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) + 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) + 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.bearing == 370 + assert m.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 + 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): captured = {} monkeypatch.setattr(