Metadata-Version: 2.4
Name: navvo
Version: 0.2.0
Summary: Official Python client for the Navvo Web Service — geocoding, search, places, routing, traffic, weather, EV & transit.
Author-email: Navvo <developers@navvo.io>
License: MIT
Project-URL: Homepage, https://navvo.io
Project-URL: Documentation, https://docs.navvo.io
Project-URL: API Reference, https://docs.navvo.io/en/api-reference
Project-URL: Source, https://github.com/navvo/navvo-python
Keywords: navvo,maps,geocoding,routing,places,directions,traffic,isochrone,optimization,ev,transit,jordan,mena
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: httpx
Requires-Dist: httpx>=0.23; extra == "httpx"
Provides-Extra: dev
Requires-Dist: build; extra == "dev"
Requires-Dist: wheel; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Dynamic: license-file

# Navvo Python SDK

Official Python client for the [Navvo](https://navvo.io) Web Service — geocoding,
search, places, routing, traffic, incidents, weather, EV charging, and transit.

It is a **pure server-side** client: typed, retry-aware, and dependency-free. By
default it uses only the Python standard library (`urllib`), so it installs and
runs anywhere; if [`httpx`](https://www.python-httpx.org) is installed it is used
automatically for connection pooling. There is **no map UI or tile fetching** —
for rendering a map use Navvo Maps in the browser.

- API reference: <https://docs.navvo.io/en/api-reference>
- Base URL: `https://navvo.io/api/v1`
- Python 3.8+

## Install

```bash
pip install navvo
# optional pooled HTTP transport:
pip install "navvo[httpx]"
```

## Authentication

Every call is authenticated with an API key of the form `nvvo_<48 hex>`, sent as
the `x-api-key` header. The SDK never puts the key in a URL — **except** for
`style_url()`, which builds a browser-fetchable map style URL where the key must
ride as `?api_key=` (browsers can't set headers on tile/style requests).

The key is read from `NAVVO_API_KEY` by default, or pass it explicitly:

```python
from navvo import NavvoClient

navvo = NavvoClient()                         # uses $NAVVO_API_KEY
navvo = NavvoClient("nvvo_your_key_here")     # explicit
```

Keys carry one or more of 11 **scopes** (`search`, `geocode`, `places`,
`routing`, `traffic`, `telemetry`, `tiles`, `imagery`, `geo`, `insights`,
`optimize`). A missing scope surfaces as a `NavvoForbiddenError`.

## Quickstart

```python
from navvo import NavvoClient, LatLng

with NavvoClient() as navvo:
    # Forward geocode (string coords are parsed to floats for you)
    hits = navvo.geocode("Abdali Hospital, Amman", limit=5)
    print(hits[0].lat, hits[0].lng, hits[0].display_name)

    # Plan a route (you pass lat/lng; the SDK serialises lon/lat on the wire)
    plan = navvo.route(locations=[
        {"lat": 31.95, "lng": 35.91, "name": "A"},
        {"lat": 31.99, "lng": 35.95, "name": "B"},
    ], costing="auto")
    print("ETA seconds:", plan.trip["summary"]["time"])
    path = plan.geometry()          # decoded polyline -> list[LatLng]

    # Search places (Arabic, English and Arabizi; typo-tolerant)
    res = navvo.search("coffee in Abdoun", limit=10)
    for hit in res.places.items:
        print(hit.position, hit.name_en or hit.name_ar)
```

## Search

`search()` runs Navvo place search and returns a typed `SearchResponse`. Only the
query is required; every option is left out of the request unless you pass it, so
the API's own defaults apply.

```python
from navvo import NavvoClient, LatLng

navvo = NavvoClient()
res = navvo.search(
    "pharmacy",
    locale="en",                              # "ar…" answers in Arabic
    near=LatLng(lat=31.9539, lng=35.9106),    # rank nearer places higher (or lat=…, lon=…)
    radius_km=3, sort="distance",             # both need an origin
    open_now=True, min_rating=4,
    amenities=["wheelchair-accessible-entrance"],
    facets=["category", "amenities"],
    highlight=True,
    limit=10,
)
for hit in res.places.items:                  # typed SearchPlace objects
    print(hit.position, hit.name_en, hit.distance_km, hit.image_url)

navvo.search("rumi caf", mode="typeahead")    # while the user types ("submit" is the default)
navvo.search("quiet place to work with good coffee", semantic="on")   # meaning-based matching
```

| Option | Sent as | Notes |
| --- | --- | --- |
| `q` | `q` | Required, must not be blank. |
| `locale` | `locale` | `en` / `ar` or a full tag such as `ar-JO`. `lang` is a deprecated alias. |
| `page`, `limit` | `page`, `limit` | 1-based page; `limit` is clamped to 1–100 by the API. |
| `bbox` | `bbox` | "Search this area" — a `BBox`, a 4-tuple or a `west,south,east,north` string. |
| `near` (or `lat` + `lon`) | `lat`, `lon` | Ranking origin. Pass one form or the other. |
| `origin_type` | `originType` | `"device"` (default) or `"map"` (a map centre: weaker distance bias, no `distance_km`). |
| `category` | `category` | A category slug; sub-categories are included. |
| `mode` | `mode` | `"submit"` (default) or `"typeahead"`. |
| `semantic` | `semantic` | `"auto"`, `"off"` or `"on"`. With an API key `auto` behaves like `off`; pass `"on"` to opt in. |
| `facets` | `facets` | `"category"`, `"amenities"` or both (a list or a comma string). |
| `highlight` | `highlight` | `True` adds `hit.highlight` to items whose name matches. |
| `open_now` | `openNow` | `True` returns only places open right now. |
| `min_rating` | `minRating` | 0–5. |
| `sort` | `sort` | `"relevance"` (default), `"distance"` (needs an origin) or `"rating"`. |
| `radius_km` | `radiusKm` | 0.1–500, needs an origin. |
| `amenities` | `amenities` | A list (or comma string) of amenity slugs; a place must have all of them. |
| `analytics` | `analytics` + `X-Navvo-Analytics` | `False` keeps the search out of search analytics. |
| `session` | `X-Navvo-Session` header | The end user's consented session id — see [Search analytics](#search-analytics--sessions-events-and-opting-out). |

The SDK checks arguments before sending: a blank `q`, an unknown `mode` / `sort` /
`semantic` / `origin_type` / facet name, a non-boolean flag, coordinates off the
globe or an unusable `session` raise `NavvoValidationError` without a request.

### The response

`search()` returns a `SearchResponse`. It **is** the response `dict` — code written
for 0.1 (`res["places"]["items"][0]["nameEn"]`, `json.dumps(res)`) keeps working —
with the documented fields also parsed into typed attributes:

| Attribute | What it is |
| --- | --- |
| `res.query`, `res.locale` | Your query, and the resolved locale (`en` / `ar`). |
| `res.query_id` | Opaque id of this result list; pass it to `send_search_event()`. A new one is issued per request, so never cache a response. |
| `res.places` | `SearchPage`: `items`, `total` (an estimate, capped at 1,000), `page`, `limit`. |
| `res.suggestions` | Only when nothing matched: popular places to offer instead (`list[SearchPlace]`). |
| `res.did_you_mean` | Only on a zero-match that looks like a misspelling: a corrected query. |
| `res.facets` | `SearchFacets` with `category` / `amenities` counts, or `None` if you did not ask. |
| `res.understanding` | How the query was read: `language`, `intent`, `category`, `categories`, `near`, `open_now`, `locality`, `qualifier`. |
| `res.near`, `res.bbox`, `res.category` | Echo of the origin, box and category you sent. |
| `res.index` | `SearchIndexInfo`: `provider`, `configured`, `available`, `mode` (`"hybrid"` when meaning-based matching contributed, else `"keyword"`). |

Each item is a `SearchPlace` — a `Place` (`id`, `slug`, `name_en`, `name_ar`,
`categories`, `canonical_category_slug`, `center`, `rating`, `sponsored`) plus
`raw_categories`, `lat` / `lng` (and `latlng`), `icon_light_url` / `icon_dark_url`,
`image_url`, `distance_km`, `highlight`, `promoted`, `name_en_transliterated` and
`position` (its 1-based rank, page offset included). Fields the SDK does not know
are ignored, and every object keeps the untouched payload on `.raw`.

```python
from navvo import parse_highlight

res = navvo.search("farmacy", highlight=True)
if not res.places.items:
    print("Did you mean:", res.did_you_mean, "| popular:", [p.name_en for p in res.suggestions])
for hit in res.places.items:
    if hit.promoted:
        print("Promoted")
    if hit.highlight and hit.highlight.name_en:
        # "<em>Coffee</em> Factory &amp; Co." -> plain-text segments, entities decoded
        print([(seg.text, seg.match) for seg in parse_highlight(hit.highlight.name_en)])
```

### Suggestions and similar places

```python
# Autocomplete for a search box (an empty q gives a starter list)
for s in navvo.search_suggestions("abd", limit=5).suggestions:
    print(s.kind, s.text, s.slug or s.category_slug)     # kind: category | place | area | term

# "More like this" for a place page
similar = navvo.similar_places(slug="jordan-beans-cafe", limit=6)   # or id=…
print(similar.source)        # "vectors" (compared by meaning) or "nearby" (the fallback)
print([p.name_en for p in similar.items])
```

`similar_places()` also takes `near=` (or `lat=` + `lon=`) and `radius_km=`. An
unknown place raises `NavvoNotFoundError`.

### Search analytics — sessions, events and opting out

Report which result your end user opened and what they did next, so search quality
can be measured for your project. Events are accepted only for searches sent with a
**session id**, with the **same API key**, at most 7 days earlier.

```python
from navvo import SearchEvent, create_search_session_id

# One id per end-user session, and only if your user agreed to it.
session = create_search_session_id()

res = navvo.search("rumi cafe", session=session)
hit = res.places.items[0]                      # the result the user opened

navvo.send_search_event(SearchEvent(
    query_id=res.query_id, type="click", place_id=hit.id, position=hit.position))
navvo.send_search_event(SearchEvent(
    query_id=res.query_id, type="conversion", name="directions",   # also: navigate, save, call, website, share
    place_id=hit.id, position=hit.position))

result = navvo.send_search_events([           # several at once; plain dicts work too
    {"query_id": res.query_id, "type": "conversion", "name": "save", "slug": hit.slug},
    {"query_id": res.query_id, "type": "conversion", "name": "call", "slug": hit.slug},
])
print(result.accepted, result.rejected, result.reasons)
```

- **Consent first.** Pass `session` only after the user has opted in. Without it the
  search works exactly the same, but no events can be attached to it. Never derive a
  session id from a user id, email address, phone number or device identifier, and
  leave `session` out for a user whose browser sent `Sec-GPC: 1` or `DNT: 1`.
- A session id is 16–64 characters of `A-Z a-z 0-9 _ -` with no run of six or more
  digits. `create_search_session_id()` always makes a valid one,
  `is_valid_search_session_id()` checks one, and `search()` raises
  `NavvoValidationError` for an invalid one (the API would silently ignore it).
- `send_search_events()` validates every event first, then sends them in requests of
  at most 20 events and 16 KB (the API's limits) and sums the results. Events are
  dicts or `SearchEvent`s; a `click` defaults to `name="open_place"`, a `conversion`
  needs a `name`, and each needs a `place_id` or a `slug`.
- Rejected events come back as counts (`result.reasons`, e.g. `{"not_eligible": 1}`);
  do not retry them. Searches about sensitive topics never accept events.

**Opting out.** For automated tests, monitoring probes and bulk jobs, keep your
searches out of search analytics altogether:

```python
navvo.search("x", analytics=False)                      # this call only
navvo = NavvoClient(search_analytics=False)             # everything this client searches
```

An opted-out search is not recorded, so events sent for it are not accepted.
`analytics=True` on one `search()` call overrides a client-wide opt-out for that search only.
Events have no such override: a client built with `search_analytics=False` sends every
event request with the opt-out header, and the API rejects those events (`not_eligible`).
To record clicks for a search you opted back in, send the events from a client that was
not built with `search_analytics=False`.

**Headers.** Every search, suggestions, similar-places and events request carries
`X-Navvo-Source: sdk`, which labels it as SDK traffic. `X-Navvo-Session` is sent
only when you pass `session`, and `X-Navvo-Analytics: off` only when you opted out.

## Coordinates — you never get them backwards

You always supply points as `LatLng(lat=..., lng=...)` (or a `{"lat":.., "lng":..}`
dict / `(lat, lng)` tuple). The SDK owns every wire-format difference for you:

| Surface | Wire format |
| --- | --- |
| `route()`, `traffic_route()` | `{lon, lat, name?}` objects |
| `simple_route()`/`matrix()`/`isochrone()`/`map_match()`/`optimize()` payloads | `{lat, lon}` |
| reverse geocode | separate `lat` & `lon` query params |
| `bbox` params | `west,south,east,north` (use `BBox(west, south, east, north)`) |
| GeoJSON outputs | `[lng, lat]` |
| place `center` / entrances | WKT `POINT(lng lat)` (use `place.center_latlng`) |

```python
from navvo import LatLng, BBox, parse_wkt_point

amman = LatLng(lat=31.9539, lng=35.9106)
box = BBox(west=35.7, south=31.8, east=36.1, north=32.1)
center = parse_wkt_point("POINT(35.913 31.953)")   # -> LatLng(lat=31.953, lng=35.913)
```

## Configuration

```python
from navvo import NavvoClient, RetryConfig

navvo = NavvoClient(
    api_key="nvvo_...",
    base_url="https://navvo.io/api/v1",   # override for staging/self-hosted
    timeout=15.0,                          # per-attempt seconds
    retry=RetryConfig(retries=3, base_delay=0.5, factor=2.0,
                      max_delay=20.0, jitter=True, respect_retry_after=True),
    user_agent_suffix="acme-dispatch/2.3",
    default_headers={"x-trace-id": "..."},
    search_analytics=True,                 # False keeps this client's searches out of search analytics
)
```

Transient failures — `5xx`, `429`/quota, network blips, and envelope
`available:false` (when `retry_unavailable=True`) — are retried with exponential
backoff and full jitter, honouring `Retry-After`. Client errors (`400`/`401`/
`403`/`404`) are **never** retried.

## Error handling

All errors subclass `NavvoError`:

```python
from navvo.errors import (
    NavvoRateLimitError, NavvoForbiddenError, NavvoServiceUnavailableError,
)

try:
    r = navvo.geocode("Abdali Hospital, Amman")
except NavvoRateLimitError as e:
    print("rate limited, retry after", e.retry_after, "s")
except NavvoForbiddenError as e:
    print("scope/quota problem:", e.reason or e.message)   # e.scope when known
except NavvoServiceUnavailableError:
    print("geocoder upstream is down — fall back")
```

| Exception | Maps from |
| --- | --- |
| `NavvoConfigError` | bad/missing key or option (client-side) |
| `NavvoValidationError` | `400` / `422`, or an argument the SDK rejected before sending (then `e.status` is `None`) |
| `NavvoAuthError` | `401` (no/unknown key) |
| `NavvoForbiddenError` | `403` (revoked key, missing scope, quota) |
| `NavvoRateLimitError` | `429` |
| `NavvoNotFoundError` | true `404` |
| `NavvoServiceUnavailableError` | envelope `available:false`, `502`/`503` |
| `NavvoServerError` | other `5xx` |
| `NavvoTimeoutError` | per-attempt timeout, retries exhausted |
| `NavvoNetworkError` | connection failure |

The geocoder/routing **envelope** (`{provider, available, data}`) is unwrapped
for you. `place(slug)` is a **soft-null getter**: an unknown slug answers with a
true `404`, which the SDK catches and returns as `None` (not a raised error), so
you can simply check `if place is None`.

## Method reference

### Tiles & map config
- `styles()` — `GET /tiles/styles`
- `style_url(style="standard", version=None, terrain=None)` — builds a browser style URL with `?api_key=`
- `map_config()` — `GET /map/config`

### Search
See [Search](#search) for the options, the response model and examples.
- `search(q, locale=, page=, limit=, bbox=, near=, lat=, lon=, origin_type=, category=, mode=, semantic=, facets=, highlight=, open_now=, min_rating=, sort=, radius_km=, amenities=, analytics=, session=)` — `GET /search` → `SearchResponse` (`lang=` is a deprecated alias of `locale=`)
  ```python
  res = navvo.search("coffee in Abdoun", limit=10)
  for hit in res.places.items: print(hit.name_en)
  for item in res["places"]["items"]: print(item["nameEn"])   # the raw dict, as in 0.1
  ```
- `search_suggestions(q="", locale=, limit=)` — `GET /search/suggestions` → `SearchSuggestions`
- `similar_places(id= | slug=, limit=, near=, lat=, lon=, radius_km=)` — `GET /search/similar` → `SimilarPlaces`
- `send_search_event(event)` / `send_search_events(events)` — `POST /search/events` → `SearchEventsResult` (batched: ≤20 events and ≤16 KB per request)
- Helpers: `create_search_session_id()`, `is_valid_search_session_id(value)`, `parse_highlight(value)`

### Geocoding & places
- `geocode(q, limit=, locale=, near=, bbox=)` — `GET /geocode/search` → `list[GeocodeResult]`
- `reverse_geocode(lat, lon)` — `GET /geocode/reverse` → `GeocodeResult | None`
- `geocode_batch(queries, locale=, bbox=)` — `POST /geocode/batch` (auto-chunked ≤50)
- `reverse_geocode_batch(points)` — `POST /geocode/reverse-batch` (auto-chunked ≤50)
- `places(q=, page=, limit=, category=, amenities=, open_now=, sort=, min_rating=, near=, radius_km=, sponsored=)` — `GET /places` → `PlacePage`
- `iter_places(limit=50, **filters)` — auto-paginate every matching place
  ```python
  for p in navvo.iter_places(category="restaurants", limit=100):
      print(p.name_en)
  ```
- `place(slug)` — `GET /places/{slug}` → `Place | None` (soft-null: an unknown slug returns `None`, never raises)
  ```python
  place = navvo.place("jordan-beans-cafe")
  if place is None:
      ...                                              # unknown slug
  dest = place.route_target() or place.center_latlng   # route to the entrance
  ```
- `place_thumb(slug)` — `GET /places/thumb`
- `place_categories()` — `GET /places/categories`
- `place_facets(q=)` — `GET /places/facets`
- `place_hours(slug, at=)` — `GET /places/{slug}/hours`
- `place_popular_times(slug)` — `GET /places/{slug}/popular-times`
- `place_attributes(slug)` — `GET /places/{slug}/attributes`
- `place_reviews_summary(slug)` — `GET /places/{slug}/reviews-summary`
- `place_photos(slug, page=, limit=)` — `GET /places/{slug}/photos`
- `place_nearby(slug, radius_km=, same_category=, limit=)` — `GET /places/{slug}/nearby`
- `places_reachable(origin, minutes=, costing=, q=, categories=, amenities=, limit=)` — `POST /places/reachable`

### Routing
- `route(locations=[...], costing=, preference=, avoid=, optimize=, avoid_incidents=, avoid_reported=, disregard=, constraints_text=, include_labels=, language=, vehicle=, truck=, departure_time=)` — `POST /routing/plan` → `RoutePlan`
- `simple_route(payload)` — `POST /routing/route` (raw engine passthrough)
- `matrix(payload)` — `POST /routing/matrix` → `Matrix` with `.cell(i, j)`
  ```python
  m = navvo.matrix({"sources": [{"lat":31.95,"lon":35.91}],
                    "targets": [{"lat":31.9,"lon":35.93}], "costing": "auto"})
  print(m.cell(0, 0)["time"])
  ```
- `isochrone(payload)` — `POST /routing/isochrone` (GeoJSON)
- `map_match(payload)` — `POST /routing/map-match`
- `optimize(payload)` — `POST /routing/optimize` (scope `optimize`)
- `routing_profiles()` — `GET /routing/profiles`

### Traffic & incidents
- `traffic_route(locations=[...], costing=, departure_time=, language=)` — `POST /traffic/route` (scopes `routing`+`traffic`)
- `traffic_summary(bbox=)` — `GET /traffic/summary`
- `incidents(bbox=, type=, limit=, zoom=)` — `GET /incidents/geojson`
- `incident_types()` — `GET /incidents/types`
- `report_incident(type=, lat=, lng=, title=, description=, severity=, ttl_minutes=, dedup_meters=)` — `POST /incidents/report`
- `road_edits(bbox=)` — `GET /road-edits/geojson`
- `route_risk(geometry, route_id=, time_offset=)` — `POST /map/route-risk`
  ```python
  risk = navvo.route_risk([[35.91,31.95],[35.93,31.97],[35.95,31.99]], time_offset="+1h")
  print(risk["scores"]["overall"], risk["severity"])
  ```

### Weather & air quality
- `weather_point(lat, lng, units=)` — `GET /map/weather/point`
- `weather_forecast(lat, lng, hours=, units=)` — `GET /map/weather/forecast`
- `weather_cities(bbox, zoom, units=, limit=)` — `GET /map/weather/cities`
- `air_quality_cities(bbox, zoom, limit=)` — `GET /map/airquality/cities`

### EV & transit
- `ev_stations(bbox, min_kw=, connector=, available=, network=, limit=)` — `GET /ev/stations`
- `transit_stops(lat, lng, radius=)` — `GET /transit/stops`
- `gbfs(lat, lng, radius=, type=, min_bikes=, min_docks=)` — `GET /transit/gbfs`

## Typed models

`geocode`, `places`, `place`, `route` and the search methods return typed objects
(`GeocodeResult`, `PlacePage`/`Place`, `RoutePlan`, `Matrix`, `SearchResponse`,
`SearchSuggestions`, `SimilarPlaces`, `SearchEventsResult`) with the common
fields parsed and the full payload preserved on `.raw`. Methods whose shapes are
highly variable return the parsed `dict`/`list` directly so no field is ever
hidden from you.

## Changelog

See [CHANGELOG.md](CHANGELOG.md).

## License

MIT © Navvo. See [LICENSE](LICENSE).
