Alpha — GeoCanvas is in early development. Expect rough edges and breaking changes.
Developer tools
GeoCanvas Python SDK
A Pythonic wrapper around the GeoCanvas REST API — create maps, upload geospatial data, manage layers, and run analytics queries from scripts, notebooks, and pipelines.
Status — planned / target design
The geocanvas Python package is not yet published to PyPI.
This page documents the intended SDK surface and pairs every method with the
real, shipping HTTP endpoint it calls, so the JSON shapes and routes
below are accurate today even before the package exists. Until it ships you can
call the same endpoints directly with requests — the
“Plain requests” example at the bottom works against a
running server right now.
Getting started
Installation
Once published, install from PyPI. The SDK targets Python 3.9+ and depends on
requests and pydantic.
bash
# Target install (after the package is published)
pip install geocanvas
# Optional extras for GeoDataFrame integration
pip install "geocanvas[geo]"
Until then, install requests and call the API
directly (see the last example): pip install requests.
Authentication
Authentication
GeoCanvas signs users in with emailed magic links
(POST /auth/request → click the link → a gc_session
cookie) — passwordless, so there is no credential a script can POST.
Crucially, the data routes
(/maps, /layers, /upload-layer,
/analytics/query) do not currently enforce auth,
so on a local or self-hosted instance you can call them without a session.
There is no API-key mechanism yet. The realistic path the SDK
will follow once API keys land:
Mint a key in the dashboard, send it as
Authorization: Bearer <key> on every request.
The client will read it from the GEOCANVAS_TOKEN env var or a
token= constructor argument.
python
from geocanvas import GeoCanvasClient
# Local dev: no token needed yet (data routes are currently open).
client = GeoCanvasClient("http://localhost:4001")
# Target shape once API keys are enforced — sent as Bearer on every call:
# client = GeoCanvasClient("http://localhost:4001", token="...")
Quickstart
Quickstart
Create a client, make a map, and list what is on the server. Maps are stored in
PostgreSQL; layers reference tiled data served by the C++ backend.
python
from geocanvas import GeoCanvasClient
client = GeoCanvasClient("http://localhost:4001")
# Create a map (only "name" is required by POST /maps).
m = client.create_map("My First Map")
print(m.id, m.name) # -> "5af5..." "My First Map"
# List all maps (GET /maps returns lightweight rows + layer_count).
for row in client.list_maps():
print(row.id, row.name, row.layer_count)
Reference
Core API
Every SDK method maps to one shipping endpoint. The table is the contract; the
JSON shapes below it are exactly what the server reads and returns.
SDK method
HTTP
Notes
list_maps()
GET /maps
Array of {id, name, slug, created_at, layer_count}
create_map(name, ...)
POST /maps
Body needs name; returns {id, name, created_at}
get_map(id)
GET /maps/:id
Full map incl. layers_json, view_state
update_map(id, ...)
PUT /maps/:id
name is required; sends the fulllayers_json
delete_map(id)
DELETE /maps/:id
404 if missing
list_layers()
GET /layers
The ingested-data catalog (uploaded tile layers)
upload_layer(geojson, key=...)
POST /upload-layer
ETL → PMTiles + analytics parquet
query(sql, params=...)
POST /analytics/query
SELECT/WITH only (DuckDB)
Map JSON shape
GET /maps/:id returns the full record. POST /maps
(create) and PUT /maps/:id (update) read these fields from the body —
note that update is a full replace of layers_json,
and the server requires name on every update.
Layers live inside a map's layers_json array. Configs are
immutable by convention — to change one, replace it. A
tile basemap and an mvt layer pointing at an uploaded
dataset are the two common cases:
The server stores layers_json verbatim (it is
rendered client-side by deck.gl), so any field deck.gl understands is allowed.
Tile URLs for uploaded data are always
/tiles/<key>/{z}/{x}/{y}.pbf.
Data ingest
Uploading data
The real ingest endpoint is POST /upload-layer (the older
/upload-geojson from early drafts no longer exists). The request body
is the raw file; the target layer key and source format are query parameters:
text
POST /upload-layer?key=cities-ca&format=geojson
Content-Type: application/geo+json
<...raw GeoJSON bytes...>
Supported format values: geojson,
shapefile (a single .zip), geopackage,
csv (auto-detects lon/lat or a WKT column; geocodes addresses when
present), and geoparquet. The key must be 1–80 chars of
[a-z0-9_-]. The server runs tippecanoe to produce PMTiles, builds a
companion analytics parquet, and registers the layer. The response:
Note: /upload-layer needs S3 + DuckDB configured on
the server (it returns 503 otherwise). Tiles for the new layer are then served at
/tiles/cities-ca/{z}/{x}/{y}.pbf, ready to drop into a map's
layers_json as an mvt layer.
geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [-122.4194, 37.7749]},
"properties": {"name": "San Francisco", "population": 873965},
}
],
}
# POST /upload-layer?key=cities-ca&format=geojson
result = client.upload_layer(geojson, key="cities-ca", format="geojson")
print(f"Ingested {result.feature_count} features as '{result.key}'")
# Add the freshly-tiled layer to a map (full layers_json replace).
m = client.create_map("CA Cities")
client.add_layer(m.id, LayerConfig(
id="lyr-cities",
name="Cities CA",
type="mvt",
url=f"/tiles/{result.key}/{{z}}/{{x}}/{{y}}.pbf",
))
3 — Run an analytics query (DuckDB)
/analytics/query accepts SELECT/WITH only,
with bound parameters. Reads the uploaded layer's analytics parquet via
read_parquet:
python
res = client.query(
"SELECT name, population FROM read_parquet($1) ORDER BY population DESC LIMIT 5",
params=["s3://geocanvas/analytics/cities-ca.parquet"],
)
for row in zip(*[iter(res["rows"])]):
print(row)
# res = {"columns": [...], "rows": [[...], ...], "row_count": N, "truncated": false}
Plain requests (works today, no SDK needed)
Because the SDK is just a thin wrapper, you can drive the same endpoints right
now. This script runs end-to-end against a local server:
python
import requests
BASE = "http://localhost:4001"
# Create a map.
m = requests.post(f"{BASE}/maps", json={"name": "Made via requests"}).json()
map_id = m["id"]
print("created", map_id)
# Add a basemap layer (PUT replaces the whole map; name is required).
requests.put(f"{BASE}/maps/{map_id}", json={
"name": "Made via requests",
"layers_json": [{
"id": "lyr-basemap", "type": "tile", "name": "Carto Light",
"url": "/proxy/tiles/carto-light/{z}/{x}/{y}", "tileSize": 256,
}],
"view_state": {"longitude": -98.5, "latitude": 39.8, "zoom": 4},
})
# Read it back.
full = requests.get(f"{BASE}/maps/{map_id}").json()
print("layers:", len(full["layers_json"]))
# List the data catalog (uploaded tile layers).
print("catalog:", [l["key"] for l in requests.get(f"{BASE}/layers").json()])
Gotchas
Gotchas
Update is a full replace.PUT /maps/:id overwrites
layers_json wholesale and requires a non-empty
name — there is no partial PATCH. Fetch the map, modify the list,
send it all back. The SDK's add_layer() / remove_layer()
do exactly this read-modify-write.
Map IDs are UUIDs. The :id routes 400 on anything
that is not a valid UUID before they touch the database.
Create returns a slim object.POST /maps responds
with only {id, name, created_at} (no slug/layers_json);
call get_map(id) for the full record.
Slugs are unique. Setting a duplicate slug on update
returns 409 Conflict.
Analytics is read-only./analytics/query rejects
anything but SELECT/WITH, blocks stacked statements, and
denylists file-reading functions. Use bound params, not string
interpolation.
Upload needs the full stack./upload-layer returns
503 unless S3 and DuckDB are configured; it forks tippecanoe and is serialized one
at a time, so large/dense uploads can be slow.
Auth is not enforced yet. Do not rely on the mock token for
access control. Treat any reachable instance as open until JWT support ships.