Files
GeoLibre/docs/python.md
T
Qiusheng Wuandpre-commit-ci[bot] 43ae1b48cd fix: redact project credentials on egress (#1702)
* fix: redact project credentials on egress

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* Address Copilot review feedback

- Fail closed when project configuration exceeds the redaction depth cap.
- Redact subscription-key URL parameters from Python project egress.

* Address CodeRabbit review feedback

- redact credential parameters from basemap style URLs

- report individual redacted fields and correct Turkish wording

* Handle projects without basemap style URLs

* Address final automated review feedback

- sanitize basemap and plugin manifest URLs across TypeScript and Python

- harden URL parameter and userinfo redaction edge cases

- align collaboration snapshots and credential counts

* style: auto-format (ruff + oxfmt) [pre-commit.ci]

* Normalize decoded credential parameter names

* Close remaining credential egress gaps

- redact geocoder endpoints in TypeScript and Python

- fail closed at traversal depth in both implementations

- detach inline GeoJSON in redacted projects

* Unify credential URL redaction

- reuse the central URL sanitizer for Layer Library exports

- preserve legacy blank environment-variable counting

- document intentionally structural project fields

* Complete egress parity across export paths

- reuse the central structural-key registry for Layer Library exports

- redact Python layer source paths and document processing scope

* Preserve credentials in trusted widget sync

- distinguish the co-located anywidget host from external embed egress

- retain redaction for standalone HTML and iframe hosts

* Reconcile credential field and URL parameter registries

- Extend PROJECT_CREDENTIAL_FIELDS.layerConfiguration with the credential
  names the URL registry already stripped but the object-key denylist
  missed (secret, bearer, auth, authKey, sasToken, subscriptionKey,
  signature, pwd), so a layer source storing one as a plain JSON field is
  no longer shipped in a "redacted" export.
- Derive URL_CREDENTIAL_PARAMS from that same list plus the names that are
  credentials only in a query string (`key` and the Azure SAS positional
  parameters sv/sr/st/se/sp/sig/skoid), and document why they stay
  URL-only: as configuration field names they collide with ordinary state
  such as an ArcGIS `sr`.
- Match names through a shared normalizer so apiKey/api_key/api-key/APIKEY
  are one entry on both sides, and route the Layer Library export sweep
  through the same isCredentialFieldName predicate instead of its own
  lowercase set, so the two paths cannot drift.
- Cover the new spellings, and the deliberately-kept `sr`/`key` fields,
  in tests/project-credentials.test.ts.

* Address remaining review-summary items

- packages/core/src/layer-library.ts: `redactSourceValue` now fails closed
  past its depth cap instead of returning the value unswept, matching the
  fail-closed stance `redactConfigurationValue` takes in credentials.ts.
  Only the exported bundle loses the value; the local IndexedDB entry is
  untouched.
- useEmbedBridge.ts / embedHost.ts: rewrite the trust-model docs, which
  still claimed the host receives the entire project state. Snapshots are
  redacted by default, and the `trustedWidget` opt-in is documented as
  self-declared by the host — a fidelity switch for the co-located widget,
  not a security boundary; the boundary is still the framing context.
- useProjectFileActions.ts / ProjectFileDialogs.tsx: rename
  EnvStripPrompt/envStripPrompt/askStripEnvVars to the credential-neutral
  CredentialStripPrompt/credentialStripPrompt/askStripCredentials, since
  the prompt now covers every credential type, not just env vars.
- docs/python.md: document that to_project()/save_project()/to_html()
  redact by default and that keep_credentials=True opts back out.

* Cover the Layer Library export depth cap

- Add a regression test for the fail-closed depth cap in redactSourceValue:
  a credential nested past MAX_REDACT_DEPTH must not reach the exported
  bundle. Fails against the previous fail-open behavior.

* Mirror the credential registry into the Python widget

- python/src/geolibre/project.py: `_CREDENTIAL_FIELD_NAMES` was missing the
  names added to the TS registry (secret, bearer, auth, authKey, sasToken,
  subscriptionKey, signature, pwd), so `to_project()`/`save_project()`/
  `to_html()` could ship a credential the JS egress path strips. Derive
  `_CREDENTIAL_URL_PARAMS` from that same set plus the query-string-only
  names, and match through a `_normalize_credential_name` mirror of the JS
  normalizer, with a parity test in python/tests/test_scripting.py.
- useProjectFileActions.ts: reword the stale HTML-export comment — this call
  site no longer strips environment variables itself; buildProjectHtml runs
  the central redaction pass.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-04 17:35:18 -04:00

17 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() / add_kml() / add_gpkg() (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)

add_raster / add_cog also accept a local GeoTIFF path on the kernel host: the file is served by the bundled localhost server so the app can read it. This only works where the browser can reach the kernel's localhost (local Jupyter, VS Code); on remote/browser-separated setups (Colab, JupyterHub, remote servers) the localhost route is unreachable, so pass a hosted URL there. The served URL is also session-scoped, so a project saved with a local raster will not restore it when reopened later — pass a hosted URL for durable projects.

Add markers and data-driven symbology without precomputing styles:

m.add_marker(-122.4, 37.8, properties={"name": "San Francisco"})
m.add_marker_cluster([(-122.4, 37.8), (-122.3, 37.9), (-122.5, 37.7)])
m.add_heatmap([(-122.4, 37.8), (-122.3, 37.9)], radius=35)
m.add_csv("cities.csv", x="longitude", y="latitude", name="Cities")
m.add_choropleth(
    "https://example.com/counties.geojson",
    column="population",
    colormap="blues",
    scheme="quantile",
)

Add a legend, a colorbar, and a swipe (split-map) comparison:

# A built-in land-cover legend, or your own {label: color} dict.
m.add_legend(builtin="nlcd")
m.add_legend(legend_dict={"Water": "#0000ff", "Land": "#00ff00"})

# A colorbar for a continuous raster.
m.add_colorbar(colormap="terrain", vmin=0, vmax=4000, label="Elevation", units="m")

# Compare two layers (or a layer against the basemap) with a swipe slider.
before = m.add_cog("https://example.com/before.tif", name="Before")
after = m.add_cog("https://example.com/after.tif", name="After")
m.split_map(before, after)

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

to_project(), save_project(), and to_html() redact credentials — API keys, tokens, authenticated request headers, environment variables, geocoder keys, and credential URL parameters — so anything you serialize, commit, or share is safe by default. Pass keep_credentials=True to to_project() or save_project() for a trusted local file that must keep working without re-entering them:

m.save_project("private.geolibre.json", keep_credentials=True)

The project trait itself is unredacted, so live two-way sync with the widget keeps authenticated layers rendering.

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})

# Read back what the user selected or drew on the map
m.get_selected_features()      # the clicked feature(s) as Feature objects
m.get_drawn_features()         # features sketched with the Geo Editor
m.user_rois                    # the drawn ROIs as a GeoJSON FeatureCollection
m.get_drawn_features(as_gdf=True)   # the same, as a GeoDataFrame (needs GeoPandas)

png = m.to_image()             # PNG bytes (or m.to_image("map.png"))
m.to_html("map.html")          # standalone HTML embedding the live project

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.
zoom_to_bounds([w, s, e, n]) / zoom_to_layer(layer) Leafmap-style view helpers; layers may be addressed by id, name, or handle.
identify(lng, lat, layer_id=None) Query rendered features at a point.
get_features(layer_id) A layer's features as Feature objects.
get_selected_features(as_gdf=False) The feature(s) selected in the app, as Feature objects (or a GeoDataFrame).
get_drawn_features(as_gdf=False) / user_rois Features drawn with the Geo Editor; user_rois returns them as a FeatureCollection.
layers / get_layer(id) Layer handles (read state; set name/visible/opacity, set_style, get_features, zoom_to, remove).
layer_names / find_layer(name) / find_layer_index(name) Inspect layers by display name.
set_layer_visibility(layer, visible) / set_layer_opacity(layer, opacity) Change a layer by id, name, or handle.
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.
to_html(path=None, title=, width=, height=, app_url=) Export a standalone HTML page that embeds the current project (credentials redacted); returns the HTML or writes 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_gdf(gdf, name=, column=None, **style) Add a GeoDataFrame, optionally as a choropleth.
add_csv(data, x="longitude", y="latitude", name=, **style) / add_xy_data(...) Add points from a CSV path, URL, text, DataFrame, or row mappings.
add_marker(lng, lat, name=, properties=, **style) Add a single point marker (shown as a circle; properties appear on click).
add_markers(points, name=, **style) Add point markers from (lng, lat) pairs, {lng/lon/x, lat/y, …} dicts, GeoJSON, or a GeoDataFrame.
add_circle_markers(points, name=, radius=, **style) Add circle markers with an explicit radius.
add_marker_cluster(points, name=, cluster_radius=, cluster_max_zoom=, **style) Add clustered point markers.
add_heatmap(points, name=, radius=, intensity=, **style) Add point data using the density heatmap renderer.
add_choropleth(data, column, name=, class_count=, colormap=, scheme=, **style) Add a GeoJSON layer with graduated symbology computed from a numeric column.
add_data(data, column=None, name=, **kwargs) Add data; a choropleth when column is given, else a plain GeoJSON layer (leafmap parity).
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_kml(data, name=, **style) / add_gpkg(data, name=, layer=None, **style) Add KML/KMZ or GeoPackage data.
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=, **style) Add a Cloud Optimized GeoTIFF (URL or a kernel-side local GeoTIFF path).
add_raster(url, name=, bands=, colormap=, rescale=, **style) Add a raster (COG/GeoTIFF), URL or local path; 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.
split_map(left_layers=None, right_layers=None, orientation=, position=, control_position=) Add a swipe (split-map) comparison slider between two layer sets.
add_legend(title=None, legend_dict=, labels=, colors=, builtin=, position=, shape=) Add a legend from a {label: color} dict, parallel labels/colors, or a builtin preset ("nlcd", "esa_worldcover").
add_colorbar(colormap=, vmin=, vmax=, label=, units=, colors=, orientation=, position=) Add a colorbar for a continuous raster, from a named colormap or custom colors.
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.
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.

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)`, `add_csv(url)` / `add_xy_data(url)`, and `add_wfs()`
fetch the URL from the **kernel**, following redirects, so a notebook can
reach any host the kernel can. Every hop is checked, and a URL that resolves
to a non-public address (private, loopback, or link-local — including cloud
metadata endpoints such as `169.254.169.254`) is refused; responses are
capped at 50 MB. Tile and service layers are fetched by the **browser**
instead, so those can still point at a local 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.