Skip to content

Commit 81da469

Browse files
authored
feat(python): expand layer management and headless camera API (#1770)
* 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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.
1 parent d8b06e9 commit 81da469

7 files changed

Lines changed: 515 additions & 34 deletions

File tree

python/README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,46 @@ m.to_project()["mapView"]["center"]
8686
| `set_center_zoom(lng, lat, zoom=None)` | Alias of `set_center` (leafmap compatibility). |
8787
| `zoom_to_bounds(bounds)` / `zoom_to_layer(layer)` | Fit the view to bounds or a layer id/name/handle. |
8888
| `layer_names` / `find_layer(name)` / `set_layer_visibility` / `set_layer_opacity` | Inspect and update layers conveniently. |
89-
| `remove_layer(layer_id)` / `clear_layers()` | Remove layers. |
89+
| `rename_layer` / `move_layer` / `duplicate_layer` / `show_layer` / `hide_layer` | Manage layers by id, name, or `Layer` handle. |
90+
| `layer_properties(layer)` / `column_values(layer, column)` / `describe()` | Inspect inlined data and summarize a project without a browser round trip. |
91+
| `remove_layer(layer)` / `clear_layers()` | Remove one layer by id, name, or handle, or remove all layers. |
92+
| `center` / `zoom` / `bearing` / `pitch` / `basemap` / `name` | Read persisted project and camera state; `name` is writable. |
93+
| `set_zoom` / `set_bearing` / `set_pitch` / `fit_project_bounds` | Persist camera changes without requiring the widget to be displayed. |
9094
| `to_project()` / `load_project(src)` / `save_project(path)` | Project I/O. |
9195

96+
Layer handles provide the same operations in an object-oriented form:
97+
98+
```python
99+
m.add_geojson("https://example.com/roads.geojson", name="Roads")
100+
101+
roads = m.find_layer("Roads") # None when no layer has that name
102+
roads.opacity = 0.6
103+
roads.set_style(lineColor="#e63946", lineWidth=3)
104+
roads.move(0)
105+
106+
print(roads.properties()) # sampled values for every property
107+
print(roads.column("highway")) # one value per feature
108+
roads_copy = roads.duplicate(name="Roads (proposed)")
109+
```
110+
111+
For headless authoring and scripts that do not need a widget, commonly used
112+
project utilities are available directly from the top-level package:
113+
114+
```python
115+
from geolibre import (
116+
basemap_catalog,
117+
builtin_legend_names,
118+
color_ramp_names,
119+
describe_project,
120+
load_project,
121+
save_project,
122+
)
123+
124+
project = load_project("my-map.geolibre.json")
125+
print(describe_project(project))
126+
save_project("copy.geolibre.json", project)
127+
```
128+
92129
## Notes
93130

94131
- The bundled app is served from a localhost HTTP server, so the interactive

python/src/geolibre/__init__.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,29 @@
22

33
from typing import Any
44

5+
from .authoring import (
6+
basemap_catalog,
7+
color_ramp_names,
8+
describe_project,
9+
load_project,
10+
save_project,
11+
)
512
from .geolibre import Feature, Layer, Map
13+
from .legends import builtin_legend_names
614

715
__version__ = "2.5.0"
8-
__all__ = ["Feature", "Layer", "Map", "__version__"]
16+
__all__ = [
17+
"Feature",
18+
"Layer",
19+
"Map",
20+
"__version__",
21+
"basemap_catalog",
22+
"builtin_legend_names",
23+
"color_ramp_names",
24+
"describe_project",
25+
"load_project",
26+
"save_project",
27+
]
928

1029

1130
def _jupyter_server_extension_points() -> list[dict[str, str]]:

python/src/geolibre/authoring.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,10 @@ def layer_summary(layer: dict[str, Any]) -> dict[str, Any]:
220220
"""Summarize one layer for display, omitting any inlined data.
221221
222222
A GeoJSON layer's ``geojson`` blob can be tens of megabytes, so it is
223-
reported as a feature count rather than echoed back.
223+
reported as a feature count rather than echoed back. The source URL is
224+
reported with its credentials stripped: a summary exists to be shown, and
225+
both callers show it somewhere untrusted (a notebook cell that gets
226+
committed, an MCP tool result that goes to a model client).
224227
225228
Args:
226229
layer: A layer dict.
@@ -239,7 +242,7 @@ def layer_summary(layer: dict[str, Any]) -> dict[str, Any]:
239242
if isinstance(source, dict):
240243
url = source.get("url") or (source.get("tiles") or [None])[0]
241244
if url:
242-
summary["source"] = url
245+
summary["source"] = _project.redact_url(str(url))
243246
geojson = layer.get("geojson")
244247
if isinstance(geojson, dict):
245248
features = geojson.get("features")
@@ -257,6 +260,9 @@ def layer_summary(layer: dict[str, Any]) -> dict[str, Any]:
257260
def describe_project(project: dict[str, Any]) -> dict[str, Any]:
258261
"""Summarize a project: its camera, basemap, layers, and map controls.
259262
263+
URLs come back with their credentials stripped, as in :func:`layer_summary`;
264+
several basemap providers put an API key in the style URL itself.
265+
260266
Args:
261267
project: The project dict.
262268
@@ -279,11 +285,14 @@ def describe_project(project: dict[str, Any]) -> dict[str, Any]:
279285
components = settings.get(_project.COMPONENTS_PLUGIN_ID)
280286
if isinstance(components, dict):
281287
controls.extend(key for key in ("legend", "colorbar") if key in components)
288+
basemap_url = project.get("basemapStyleUrl")
282289
return {
283290
"name": project.get("name"),
284291
"version": project.get("version"),
285292
"mapView": project.get("mapView"),
286-
"basemapStyleUrl": project.get("basemapStyleUrl"),
293+
"basemapStyleUrl": (
294+
_project.redact_url(str(basemap_url)) if basemap_url is not None else basemap_url
295+
),
287296
"layerCount": len(layers_of(project)),
288297
"layers": [layer_summary(layer) for layer in layers_of(project) if isinstance(layer, dict)],
289298
"mapControls": controls,

0 commit comments

Comments
 (0)