# Python SDK

`wherebox` is a small Python client for the API: it builds and signs map URLs for `<img>` tags, renders images to bytes or files, and looks up areas and places. It uses only the standard library and works on Python 3.9 or newer.

```bash
pip install wherebox
```

From a checkout of this repository (to try unreleased changes):

```bash
pip install ./sdk/python
```

## Quick start

```python
from wherebox import Client

client = Client(
    "https://maps.example.com",
    secret_key="sk_…",                  # server-side calls (render, lookups)
    publishable_key="pk_…",             # embeds
    signing_secret="<hex signing secret>",
)

spec = {
    "area": "Czechia",
    "pois": [{"place": "Chřiby", "label": "Chřiby", "marker": {"shape": "triangle"}}],
    "neighbors": {"show": "flag+code"},
    "width": 468,
    "height": 280,
}

img_src = client.url(spec)              # a signed GET URL for <img src="…">
result = client.render(spec)            # PNG bytes, rendered by POST /v1/map
```

A spec is the JSON body of `POST /v1/map` as a Python dict: see the [request body reference](/docs/reference/request-body.md). The same spec works for `url()` and `render()`. Names, defaults and ranges are in the [reference](/docs/reference/parameters.md); the guides explain what each part does ([locator maps](/docs/guides/locator-maps.md), [points and labels](/docs/guides/points-and-labels.md), [routes](/docs/guides/routes.md)).

Every key is optional. A self-hosted server without `WHEREBOX_AUTH=keys` needs none, and `url()` then returns an unsigned URL.

## Signed URLs for embeds

A page or email can only show a map through a URL, and a [publishable key](/docs/guides/authentication.md) is only accepted with a signature. `Client.url()` builds the URL from the spec and signs it. Do this on your server: the signing secret must never reach a browser.

```python
url = client.url(spec, format="webp", expires_in=3600)   # valid for an hour
```

`expires_in` is optional (seconds); the expiry is covered by the signature. Without it the URL works until you rotate the key. `url()` raises `PostOnlyError` when the spec uses something a URL cannot carry (a second route, a GeoJSON area, a custom font, a `|` in a label); its `paths` attribute lists the offending fields. Render those with `client.render()`, which uses `POST`.

To sign a URL you built yourself, use `sign_url(url, publishable_key, signing_secret, expires_at=None)`. The output is identical to the server's own `signUrl` (a cross-language test checks it) and to the snippet in the [authentication guide](/docs/guides/authentication.md#python).

### Django

```python
from django.shortcuts import render
from wherebox import Client

maps = Client("https://maps.example.com", publishable_key=settings.WHEREBOX_PK, signing_secret=settings.WHEREBOX_SIGNING_SECRET)

def office(request):
    src = maps.url({"area": "Czechia", "pois": [{"place": "Brno", "label": "Our office"}], "width": 468, "height": 280}, expires_in=3600)
    return render(request, "office.html", {"map_src": src})   # <img src="{{ map_src }}" width="468" height="280" alt="Map">
```

### Flask

```python
from flask import Flask, redirect
from wherebox import Client

app = Flask(__name__)
maps = Client("https://maps.example.com", publishable_key=PK, signing_secret=SIGNING_SECRET)

@app.get("/maps/<city>.png")
def city_map(city):
    # Sign on demand and redirect: the browser fetches the image from Wherebox, not through your server.
    return redirect(maps.url({"area": "Czechia", "pois": [{"place": city}], "width": 468, "height": 280}, expires_in=300))
```

## Rendering to a file

```python
from wherebox import Client

client = Client("https://maps.example.com", secret_key="sk_…")
result = client.render(spec, format="png")

with open("czechia.png", "wb") as f:
    f.write(result.image)

print(result.content_type, result.width, result.height, result.cached)
```

`RenderResult` has `image` (bytes), `content_type`, `width` and `height` (the final size from `X-Image-Width` / `X-Image-Height`, which can exceed `width × scale` when neighbor labels extend the canvas), `cached` (the server answered from its cache) and `etag`. `client.render_json(spec)` returns the JSON form instead: a durable `url` for the cached image, its `key` and size.

Keys for a commercial basemap or a routing provider go in headers, not in the spec, so they stay out of logs: `client.render(spec, provider_key="…", routing_key="…")`. See [HTTP: headers](/docs/reference/http.md).

Lookups share the client:

```python
client.search_areas("Zlín", limit=5)    # list of dicts with id, name, adminLevel, context
client.search_places("Chřiby")
client.get_area("osm:r51684")
```

## Errors

Every failed request raises `WhereboxError`, built from the [problem+json](/docs/guides/errors-and-limits.md) body:

| Attribute | Meaning |
|---|---|
| `status` | HTTP status (`0` when the server could not be reached; `code` is then `network`) |
| `code` | The catalog code, e.g. `"ambiguous"`; see the [error reference](/docs/errors.md) |
| `type`, `title`, `detail` | The RFC 7807 fields; `detail` says what happened |
| `issues` | `invalid-spec`: `[{path, message}]` per problem |
| `candidates` | `ambiguous`: matching areas with `id`, `adminLevel` and `context` |
| `suggestions` | `not-found`: close matches |
| `retry_after` | Seconds from `Retry-After` (`busy`, `rate-limited`), else `None` |
| `retryable` | `True` for `busy` and `rate-limited` |

Branch on `code`:

```python
from wherebox import WhereboxError

try:
    result = client.render({"area": "Nová Ves"})
except WhereboxError as e:
    if e.code == "ambiguous":
        for c in e.candidates:                       # pick one, then retry with area=c["id"]
            print(c["id"], c["context"])
    elif e.code == "not-found":
        print("did you mean:", [s["name"] for s in e.suggestions])
    elif e.code == "invalid-spec":
        for issue in e.issues:
            print(issue["path"], issue["message"])
    elif e.code in ("unauthorized", "forbidden"):
        raise RuntimeError("check the API key") from e
    else:
        raise
```

## Retries on 503 and 429

`busy` (503) and `rate-limited` (429) are transient: wait `retry_after` seconds and try again. `quota-exceeded` is also a 429, but it lasts until next month or until the limit is raised, so `retryable` is `False` for it.

```python
import time
from wherebox import WhereboxError

def render_with_retries(client, spec, attempts=4):
    for attempt in range(attempts):
        try:
            return client.render(spec)
        except WhereboxError as e:
            if not e.retryable or attempt == attempts - 1:
                raise
            time.sleep(e.retry_after if e.retry_after is not None else 2 ** attempt)
```

Cached images never hit the render queue, so identical requests are cheap to repeat. See [errors and limits](/docs/guides/errors-and-limits.md) for caching and ETags.
