diff --git a/docs/python.md b/docs/python.md index 7942b2fb8..30a5e9175 100644 --- a/docs/python.md +++ b/docs/python.md @@ -253,7 +253,11 @@ m.on_layer_change(lambda e: print("layers", e["layerIds"])) | `add_colormap(colormap, vmin=, vmax=, label=, **kwargs)` | Add a colorbar from a named colormap (leafmap-style alias of `add_colorbar`). | | `set_center(lng, lat, zoom=None)` | Center (and optionally zoom) the map. | | `set_center_zoom(lng, lat, zoom=None)` | Alias of `set_center` (leafmap compatibility). | -| `remove_layer(layer_id)` / `clear_layers()` | Remove layers. | +| `set_zoom(zoom)` / `set_bearing(bearing)` / `set_pitch(pitch)` / `fit_project_bounds(bounds)` | Persist camera changes without requiring the widget to be displayed. | +| `center` / `zoom` / `bearing` / `pitch` / `basemap` / `name` | Read persisted project and camera state; `name` is writable. | +| `rename_layer(layer, name)` / `move_layer(layer, index)` / `duplicate_layer(layer, name=)` / `show_layer(layer)` / `hide_layer(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_id)` / `clear_layers()` | Remove one layer by id, name, or handle, or remove all layers. | | `to_project(keep_credentials=False)` | Return the current project as a dict, credentials redacted unless `keep_credentials=True`. | | `load_project(src)` | Replace the project from a dict, JSON string, or `.geolibre.json` path. | | `save_project(path, keep_credentials=False)` | Write the current project to a `.geolibre.json` file, credentials redacted unless `keep_credentials=True`. | diff --git a/python/README.md b/python/README.md index 7b0e262e5..fcbc8497e 100644 --- a/python/README.md +++ b/python/README.md @@ -88,7 +88,7 @@ m.to_project()["mapView"]["center"] | `layer_names` / `find_layer(name)` / `set_layer_visibility` / `set_layer_opacity` | Inspect and update layers conveniently. | | `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. | +| `remove_layer(layer_id)` / `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. | @@ -126,6 +126,12 @@ print(describe_project(project)) save_project("copy.geolibre.json", project) ``` +These are the lossless file primitives: unlike `Map.save_project`, the top-level +`save_project` writes the project **verbatim**, credentials included, so that +editing a project in place cannot strip your own API keys out of it. Pass a +project through `geolibre.project.redact_credentials` first if the file is going +anywhere untrusted, or use `Map.save_project`, which redacts by default. + ## Notes - The bundled app is served from a localhost HTTP server, so the interactive diff --git a/python/src/geolibre/authoring.py b/python/src/geolibre/authoring.py index 4f286b96c..1353f021c 100644 --- a/python/src/geolibre/authoring.py +++ b/python/src/geolibre/authoring.py @@ -115,9 +115,19 @@ def save_project(path: str | Path, project: dict[str, Any]) -> Path: can approach ``MAX_PROJECT_BYTES``, and the MCP server rewrites the whole file on every edit, so a truncating write is a real way to lose work. + Note: + This writes *verbatim*, credentials included. It is the lossless + primitive the MCP server round-trips a user's own project file through, + where stripping an API key on every small edit would quietly destroy the + file's usefulness. :meth:`geolibre.Map.save_project` is the counterpart + for producing a file to share: it redacts unless + ``keep_credentials=True``. Run a project through + :func:`geolibre.project.redact_credentials` before calling this if the + result is going anywhere untrusted. + Args: path: Destination path. - project: The project dict to serialize. + project: The project dict to serialize, written as given. Returns: The resolved path written to. diff --git a/python/src/geolibre/geolibre.py b/python/src/geolibre/geolibre.py index 269c94ce4..4346a2080 100644 --- a/python/src/geolibre/geolibre.py +++ b/python/src/geolibre/geolibre.py @@ -2525,7 +2525,9 @@ def source(self) -> Any: 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") + # Sweep the one field rather than the whole layer: `redact_layer` would + # copy an inlined geojson blob first, only to discard it here. + return _project.redact_layer_field(self._layer().get("source")) @property def data(self) -> dict[str, Any]: diff --git a/python/src/geolibre/project.py b/python/src/geolibre/project.py index 97b153def..48755026b 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -182,6 +182,15 @@ def _sweep_layer_credentials(layer: dict[str, Any]) -> None: layer[field] = _redact_config(layer[field]) +def redact_layer_field(value: Any) -> Any: + """Return one of a layer's config fields, detached and swept. + + The single-field counterpart to :func:`redact_layer`, for a read that wants + only ``source`` and should not pay to copy an inlined GeoJSON blob first. + """ + return _redact_config(value) + + def redact_layer(layer: dict[str, Any]) -> dict[str, Any]: """Return a detached copy of one layer, safe to display or hand to others.