Files
GeoLibre/docs/python.md
T
Qiusheng Wu 6f3f4ade7a feat(scripting): Python automation API + in-app Python Console (#327)
* 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>.
2026-06-13 22:42:27 -04:00

11 KiB

Python package (Jupyter)

image image image Conda Recipe

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.