* feat(scripting): Python automation API + in-app Python Console Add two PyQGIS-style ways to script GeoLibre programmatically. Notebook API (geolibre.Map anywidget): - Request/reply command channel + event channel over the anywidget custom message bus, relayed by _frontend.js into the embedded app. - App-side dispatcher (useCommandBridge) backed by a shared scriptingApi factory; MapController gains flyTo() and identifyFeatures(). - Python request()/on() primitives (blocking via jupyter-ui-poll) plus get_center, get_bounds, fly_to, fit_bounds, identify, get_features, run_algorithm, list_algorithms, to_image, and a Layer/Feature model. In-app Python Console: - Dockable, resizable bottom panel (Processing -> Python Console), not a modal. - Main-thread Pyodide runtime (minimal/fast startup, on-demand packages, async execution) exposing a `geolibre` facade that drives the live store and map, sharing the same scriptingApi handlers as the notebook bridge. Extracts the embed-host handshake into embedHost.ts (shared by both bridges). * Address Claude review feedback - pyodide-console: destroy the result JsProxy in a finally so a throwing toString() can't leak it. - map-controller (featureIdForLayer): return null when the rendered feature has no properties, instead of vacuously matching the first source feature. - _frontend.js: cap the pre-ready pendingCommands queue at 500 as a defensive bound. - console_api to_image: return PNG bytes to match the notebook Map.to_image() return type (host-path writing isn't available in-browser). * Address CodeRabbit review feedback - test_scripting: inject a fake jupyter_ui_poll via sys.modules instead of patching by string path, fixing the package-publish CI job which lacks the optional dependency (ModuleNotFoundError). - map-controller (featureIdForLayer): only return a property-matched id for an unambiguous single hit (compare full key sets), else null — supersedes the empty-properties guard and avoids wrong ids for non-unique features. - geolibre.request: move send() inside the try so a failing send() still pops the pending slot. - console_api Layer: sync self._info in the visible/opacity setters so later reads on the same handle aren't stale; add -> None annotations. - PythonConsolePanel: show runtime-init failures as an error (no spinner), separate from loading progress. - embedHost: document the ?embed=1 trust model / deployment constraint. - scriptingApi.toImage: note the synchronous PNG encode blocks the main thread. * Address review feedback (round 2) - embedHost.isEmbedded: auto-trust only a SAME-ORIGIN framing parent by probing window.parent.location.href (which throws cross-origin); `window.parent !== window` does not throw cross-origin, so the old check would auto-activate the bridge for a third-party framer. Doc corrected. - useCommandBridge: dispatch only own-property handlers (Object.hasOwn), so an inherited member like "constructor"/"toString" can't be invoked as a command. - scriptingApi.setBasemap: validate the url is an http(s)/root-relative string (rejects undefined -> "undefined" and javascript:/data: schemes). - console_api.load_geojson: raise on non-ok HTTP responses instead of parsing an error body as GeoJSON. - to_image (both notebook geolibre.py and console_api): raise on a data URL with no comma instead of silently returning empty bytes. - PythonConsolePanel: tear down in-flight drag listeners on unmount. - pyodide-console: drop the redundant scriptPromise reset in script.onerror. - geolibre.request: register the pending slot inside the try. * docs: add Python Console guide and interactive scripting API reference - New user-guide page (user-guide/python-console.md) covering the in-app Python Console: opening/docking, first-run runtime load, driving the map, async await, on-demand packages, and a full geolibre/Layer/Feature API reference. - python.md: document the interactive scripting surface (live queries, events, processing, to_image, Layer/Feature) that the notebook widget shares with the console, with a new API-reference subsection. - mkdocs.yml: add the Python Console page to the User Guide nav. * feat(python-console): command history, autocomplete, eraser icon - Up/Down arrows recall previous/next submitted commands (only when the caret is on the first/last line, so multi-line editing still works); a draft is saved when history navigation starts. - Tab / Ctrl+Space autocomplete the name or attribute at the caret, backed by a new _geolibre_complete() that introspects the live Pyodide namespace (real geolibre methods, user variables, builtins, keywords; private names hidden). A single match inserts directly; multiple show a dropdown (Up/Down to choose, Enter/Tab to accept, Esc to dismiss). - Swap the Clear-output icon from Trash2 to Eraser. - Docs: document the editing shortcuts in the Python Console guide. * feat(python-console): QGIS-style script editor alongside the console - "Show editor" toggle splits a script editor into the Python Console panel (left), with a draggable vertical divider; hidden by default. - The editor runs in the SAME Pyodide interpreter as the console (shared globals), so scripts and the REPL share state. Run executes the whole buffer or the current selection; output goes to the console scrollback. - Open/Save/Save As/New for .py files via the app's cross-platform file I/O (openLocalDataFileWithFallback / saveTextFileWithFallback); desktop Save writes in place via new tauri-io writeTextFileToPath, web downloads. Dirty marker + discard confirmation. - Editor keys: Ctrl/Cmd+Enter run, Ctrl/Cmd+S save, Tab indent, Ctrl+Space autocomplete. - Factor the console autocomplete into a shared usePyCompletion hook (with a tabTriggers option) reused by both the console input and the editor. - Add i18n strings and document the script editor in the user guide. * Address review feedback (round 3) - featureIdForLayer: return null for zero-property features (no fake index id), and compare object/array property values by JSON (MapLibre re-parses them to fresh refs, so === always failed). - scriptingApi.setOpacity: reject non-finite and clamp to [0,1]. - notebook Map: add set_basemap alias (name parity with the console facade). - PythonConsolePanel: dispatch PANEL_RESIZE_END on unmount mid-drag so MapCanvas clears panelResizeActive. - pyodide-console: serialize runs through a queue so overlapping calls can't clobber the instance-global stdout/stderr capture; document the merged stdout/stderr stream. - usePyCompletion: clamp completion splice offsets against current code. - geolibre._wait_for_result: check the deadline before and after poll() so a slow pump can't overrun a small timeout. * feat(python-editor): clear button; address review feedback (round 4) - Add a "Clear editor" eraser button to the script editor toolbar (empties the buffer, keeping the filename; confirms when there are unsaved changes). - PythonConsolePanel: throttle the editor splitter drag through requestAnimationFrame with direct DOM writes (commit to state on mouseup), and give the vertical and horizontal drags separate cleanup refs so one can't overwrite the other's teardown. - _frontend.js: warn when the pre-ready command queue is full instead of silently dropping, so a full queue is diagnosable. - geolibre._wait_for_result: poll at 20 Hz (sleep 0.05) instead of 100 Hz to cut idle CPU; deadline is still checked each iteration. * Address review feedback (round 5) - PythonEditorPane: Tab now block-indents every selected line with 4 spaces (PEP 8) instead of replacing a multi-line selection with two spaces; the selection is preserved. - scriptingApi: validate layerId is a non-empty string across the layer handlers (getLayerFeatures/removeLayer/setVisibility/setOpacity/setStyle/zoomToLayer) via a shared requireLayerId, matching identify. - _frontend.js: post commands straight through once ready instead of a push→flush cycle; only buffer before ready. - pyodide-console: remove the failed <script> element on load error so retries don't orphan dead tags in <head>.
11 KiB
Python package (Jupyter)
GeoLibre ships a Python package, geolibre, that embeds the full GeoLibre
app inside a Jupyter notebook cell as an anywidget,
with a leafmap-style API.
The widget loads the complete GeoLibre app (menus, panels, processing tools) in
an iframe. State syncs both ways through a single .geolibre.json project, so
data you add from Python appears in the UI, and edits you make in the UI
(panning, zooming, adding layers) are readable back from Python.
Install
pip install geolibre
Or with conda from conda-forge:
conda install -c conda-forge geolibre
Optional extras for add_geojson() from a GeoDataFrame and for reading local
vector files with add_vector() / add_geoparquet() / add_flatgeobuf() /
add_shp() (remote URLs for those formats need no extras):
pip install "geolibre[all]" # adds GeoPandas and Shapely
The optional [all] extra is pip-only. If you installed via conda, add it with
pip install "geolibre[all]" inside the same environment.
Quickstart
from geolibre import Map
m = Map(center=(-100, 40), zoom=4)
m.add_geojson("https://example.com/data.geojson", name="Data")
m
The full GeoLibre UI renders in the cell. Add more data and drive the view:
m.add_tile_layer(
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
name="OpenStreetMap",
attribution="(c) OpenStreetMap contributors",
)
m.add_cog("https://example.com/dem.tif", name="DEM", colormap="terrain")
m.add_basemap("dark")
m.set_center(-120, 47, zoom=8)
Two-way sync
Because the project syncs both ways, you can pan or zoom the map in the UI and then read the live state back from Python:
proj = m.to_project()
proj["mapView"]["center"] # reflects the live UI view
[layer["name"] for layer in proj["layers"]]
Save and reload projects, fully interchangeable with the desktop and web apps:
m.save_project("my-map.geolibre.json")
m2 = Map()
m2.load_project("my-map.geolibre.json")
m2
Map options
Map(
center=(-100, 40), # [lng, lat]
zoom=4,
basemap="dark", # a basemap name or a MapLibre style URL
height="800px",
layout="embed", # "embed" (compact UI), "full" (desktop UI), or "maponly"
theme="light", # "light" or "dark"
)
Interactive scripting
Beyond adding data, the widget can query the live app and react to it — the
same surface as the in-app Python Console. These
calls round-trip to the running map, so the map must be displayed first (show
m in a cell), then run the queries in a later cell.
m.get_center() # live [lng, lat], reflecting UI pans/zooms
m.get_bounds() # [west, south, east, north]
m.fly_to(-122.4, 37.8, zoom=10)
m.identify(-122.4, 37.8) # features at a point, like clicking the map
# Layer objects: read and mutate layers, read their features
for layer in m.layers:
layer.opacity = 0.6
features = m.layers[0].get_features() # list of GeoJSON Feature objects
# Run a processing algorithm; result layers are added to the map
m.list_algorithms()
m.run_algorithm("buffer", {"layer": layer_id, "distance": 1000})
png = m.to_image() # PNG bytes (or m.to_image("map.png"))
React to user interaction with event callbacks:
m.on_click(lambda e: print("clicked", e["lngLat"]))
m.on_selection_change(lambda e: print("selected", e))
m.on_layer_change(lambda e: print("layers", e["layerIds"]))
!!! note "Blocking queries"
Interactive queries block the kernel until the app replies (via
jupyter_ui_poll, installed automatically). Pass timeout= for slow calls,
e.g. m.run_algorithm(..., timeout=300).
API reference
Interactive queries, events & processing
| Method | Description |
|---|---|
get_view() / get_center() / get_bounds() |
Read the live camera / center / viewport bounds. |
fly_to(lng, lat, zoom=, bearing=, pitch=, duration=) |
Animate the camera. |
fit_bounds([w, s, e, n]) |
Fit the camera to a bounding box. |
identify(lng, lat, layer_id=None) |
Query rendered features at a point. |
get_features(layer_id) |
A layer's features as Feature objects. |
layers / get_layer(id) |
Layer handles (read state; set name/visible/opacity, set_style, get_features, zoom_to, remove). |
list_algorithms() |
Available processing algorithms (id, parameters, …). |
run_algorithm(id, parameters=None, timeout=) |
Run an algorithm; returns {logs, resultLayerIds}. |
to_image(path=None, timeout=) |
Capture the map as PNG bytes, or write to path. |
on(event, cb) / on_click / on_selection_change / on_layer_change |
Register event callbacks; returns an unsubscribe function. |
request(method, params=None, timeout=) |
Low-level command primitive behind the methods above. |
Data, view & projects
| Method | Description |
|---|---|
Map(center, zoom, basemap=, height=, layout=, theme=) |
Create a map. |
add_geojson(data, name=, **style) |
Add GeoJSON from a dict, file path, URL, JSON string, or GeoDataFrame. |
add_vector(data, name=, render_mode=, data_format=, source_layer=, **style) |
Add a vector dataset from a URL (GeoParquet, FlatGeobuf, zipped Shapefile, GeoJSON, …) or a local file (read via GeoPandas and inlined). |
add_geoparquet(data, name=, **style) |
Add a GeoParquet dataset (URL or local file). |
add_flatgeobuf(data, name=, **style) |
Add a FlatGeobuf dataset (URL or local file). |
add_shp(data, name=, **style) |
Add a Shapefile (zipped URL or local .shp). |
add_vector_tiles(url, name=, source_layers=, source_layer=, **style) |
Add a vector tile layer from a TileJSON endpoint. |
add_pmtiles(url, name=, tile_type=, source_layers=, **style) |
Add a PMTiles archive (vector or raster). |
add_tile_layer(url, name=, tile_size=, attribution=) |
Add a raster XYZ tile layer. |
add_wms(endpoint, layers, name=, styles=, image_format=, transparent=, tile_size=, **style) |
Add a WMS layer (GetMap, tiled raster). |
add_wmts(url, name=, tile_size=, **style) |
Add a WMTS layer from a tile URL template. |
add_wfs(endpoint, type_name, name=, version=, output_format=, srs_name=, max_features=, **style) |
Add a WFS layer (GetFeature GeoJSON, fetched and inlined). |
add_cog(url, name=, bands=, colormap=, rescale=) |
Add a Cloud Optimized GeoTIFF. |
add_raster(url, name=, bands=, colormap=, rescale=) |
Add a raster (COG/GeoTIFF); alias of add_cog. |
add_3d_tiles(url, name=, altitude_offset=, request_headers=, **style) |
Add a 3D Tiles tileset.json. |
add_video(urls, coordinates, name=, **style) |
Add a georeferenced video (four [lng, lat] corners). |
add_basemap(basemap) |
Set the background basemap. |
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. |
to_project() |
Return the current project as a dict. |
load_project(src) |
Replace the project from a dict, JSON string, or .geolibre.json path. |
save_project(path) |
Write the current project to a .geolibre.json file. |
Style keyword arguments (for example fillColor, strokeColor, strokeWidth,
circleRadius) map to the GeoLibre layer style fields.
How it works
The wheel bundles the GeoLibre web build. At import time the package starts a
small localhost static server that serves the bundled app; the widget renders
that app in an iframe and exchanges the project over window.postMessage.
Adding data from Python rewrites the synced project and pushes it into the app;
UI edits flow back the same way.
!!! note "Environment support"
The interactive widget works in **local Jupyter, VS Code, Google Colab, and
JupyterHub / remote servers**:
- **Local Jupyter / VS Code** - the app is served directly from localhost.
- **Google Colab** - routes through Colab's built-in port proxy
(`google.colab.kernel.proxyPort`) automatically.
- **JupyterHub** (including managed/shared hubs, detected at runtime via
`JUPYTERHUB_SERVICE_PREFIX`) - the front-end probes two same-origin routes
and uses whichever is live, so a host needs only **one** of them:
- the Jupyter Server extension bundled with `geolibre`, mounted at
`{base_url}geolibre/app/` on the notebook server's own origin. It is
enabled automatically on `pip install geolibre` and needs no
`jupyter-server-proxy` and no extra port, so it works on locked-down
hubs that block raw-port proxying -- but it only registers after the
Jupyter server restarts, since it loads from a startup config drop-in.
- `jupyter-server-proxy` at `{base_url}proxy/{port}/`, which reaches the
kernel's localhost bundle in the **running** server with no restart,
wherever `jupyter-server-proxy` is installed.
- **Other remote servers** (Binder, remote JupyterLab over SSH/network) -
pass `Map(server_proxy=True)` to use that same dual-route remote path.
Set `Map(server_proxy=False)` to force the direct localhost path. If the app
fails to load on a hub, either install `jupyter-server-proxy`, or confirm the
extension is enabled with `jupyter server extension list` (look for
`geolibre`; run `jupyter server extension enable geolibre` if absent) and
**restart** the Jupyter server so the extension loads.
!!! warning "URL fetching"
`add_geojson(url)` fetches the URL from the **kernel**, following redirects,
so a notebook can reach any host the kernel can (including private and
link-local addresses such as cloud metadata endpoints). This is intended for
single-user local notebooks, where you already control the kernel. Private
and localhost URLs are intentionally allowed so you can load from a local
tile server. Do not load untrusted `.geolibre.json` projects or URLs on a
shared/multi-tenant kernel.
Building from source
The package lives in python/.
The bundled app is produced from the monorepo with:
npm run build:embed # builds the app and stages it into the wheel
python -m build # builds the wheel
python -m twine upload dist/* # upload to PyPI
pip install -e python # editable install for development
Changes to the Python code are picked up on kernel restart. Changes to the app
(TypeScript) require re-running npm run build:embed and restarting the kernel.