Skip to main content

thinwrap-location (Python)

Unified, SDK-free, zero-runtime-dependency Python wrapper for routing, distance matrix, geocoding, and isochrone across Google, Mapbox, HERE, ESRI, TomTom, and OSRM. Switch vendor by changing the config type; the input and output shapes stay identical. Synchronous, stateless, and standard-library-only — bring your own HTTP transport if you want httpx/requests.

pip install thinwrap-location

Requires Python ≥ 3.10. No runtime dependencies.

Two-minute route

from thinwrap.location import Routing, RoutingOptions, GoogleConfig, LatLng, ConnectorError

r = Routing(GoogleConfig(api_key="..."))  # misuse raises TypeError/ValueError here, not ConnectorError
try:
    res = r.route(RoutingOptions(waypoints=[
        LatLng(40.7128, -74.0060),  # New York
        LatLng(41.4173, -73.0001),  # Bridgeport
    ]))
    print(f"{res.total_distance_meters / 1000:.1f} km, {res.total_duration_seconds / 60:.0f} min")
except ConnectorError as e:
    print(e.provider_code.value, e.provider_message)

Operations & providers

The config type selects the provider. An operation a provider does not support raises ValueError at facade construction (e.g. Geocoding(OsrmConfig(...))); anything that is not a provider config raises TypeError (see Error handling).

Facade Method(s) Providers
Routing route Google, Mapbox, HERE, ESRI, OSRM, TomTom
Matrix matrix (flat cells) Google, Mapbox, HERE, ESRI, OSRM, TomTom
Geocoding geocode, reverse_geocode, autocomplete 5 (no OSRM)
Isochrone isochrone Mapbox, HERE, ESRI, TomTom

Configs: GoogleConfig(api_key=...), MapboxConfig(access_token=...), HereConfig(api_key=...), EsriConfig(api_key=... | arcgis_token=...) (exactly one), OsrmConfig(base_url=...) (required), TomTomConfig(api_key=...).

Per-provider details (endpoints, auth, error mapping, passthrough) live in docs/providers/ — one page per provider.

Routing options that cost money

Three inputs exist because the cheap thing and the correct thing are not always the same request. All three default to the lean option; you opt up explicitly.

Option Default What it changes
polyline_quality PolylineQuality.SIMPLIFIED Geometry fidelity. DETAILED returned a 30x larger polyline on Mapbox and 31x on OSRM in measurement, with identical distances and durations. Honoured by Google/Mapbox/OSRM; silently ignored by HERE/TomTom/Esri.
traffic_mode TrafficMode.NONE Whether to route against traffic (see Traffic). TRAFFIC selects a Pro-tier SKU on Google and HERE's Time-Aware Routing rate, so it is never enabled implicitly — not even by passing departure_time. LIVE is a deprecated alias of TRAFFIC.
include () Which optional output fields to fetch. Each token maps 1:1 onto one optional result field.
from thinwrap.location import PolylineQuality, RoutingInclude, RoutingOptions, TrafficMode

result = routing.route(RoutingOptions(
    waypoints=waypoints,
    traffic_mode=TrafficMode.TRAFFIC,                          # traffic-aware routing
    polyline_quality=PolylineQuality.DETAILED,                 # full geometry
    include=[RoutingInclude.DURATION_WITHOUT_TRAFFIC],         # the extra output field
))

# Present only when requested AND returned natively — never synthesized, so None
# tells you this provider did not supply it.
congestion = (
    result.total_duration_seconds - result.total_duration_without_traffic_seconds
    if result.total_duration_without_traffic_seconds is not None
    else None
)

DURATION_WITHOUT_TRAFFIC is native on Google (staticDuration), HERE (baseDuration) and TomTom (noTrafficTravelTimeInSeconds); Mapbox, OSRM and Esri do not return it, so the field stays None there rather than being faked.

Traffic: NONE vs TRAFFIC

traffic_mode applies to routing and matrix. NONE (the default) uses the provider's time-independent speeds, and a departure_time never adds traffic. TRAFFIC uses the traffic expected at departure_time: live close to now, typical later, and now when no time is given. LIVE is a deprecated alias of TRAFFIC and behaves identically.

Provider NONE TRAFFIC
Google TRAFFIC_UNAWARE; the time is not sent (Google rejects it there) TRAFFIC_AWARE + time (Pro SKU)
HERE departureTime=any, or the time + traffic[mode]=disabled HERE's default, with the time if given (Time-Aware SKU on routing)
Esri the time is not sent the time, or now
Mapbox Directions driving, the time is not sent driving-traffic + time
Mapbox Matrix driving, the time is not sent driving-traffic; driving with a time → unsupported_field (depart_at is a beta)
TomTom routing traffic=false + time. Approximate: TomTom still applies typical traffic traffic=true + time
TomTom matrix historical + departAt: "any" (the time is not sent) live + the time, or "now"
OSRM, Mapbox Optimization, walking/cycling no traffic ignored (no traffic available). Mapbox Optimization driving + time → unsupported_field

Making OSRM's avoid-flags work

Whether OSRM accepts exclude=toll is a property of your server, not of OSRM. The same request was verified live against two builds with opposite results: the public demo build rejects it with InvalidValue, while a self-hosted instance honoured it and genuinely rerouted (138075 m / 5890 s via the toll road → 130421 m / 6513 s without).

Stock OSRM compiles no exclude classes, so the flags are rejected up front by default. If your profile was built with them, declare it:

routing = Routing(OsrmConfig(
    base_url="https://routing.internal",
    supported_exclude_classes=("toll", "ferry"),
))

Autocomplete → place details

autocomplete() returns predictions; place_details() resolves one into a full candidate. "Place details" and "geocode by place id" are the same vendor call on all five providers, so this is one operation, not two — and it returns an ordinary GeocodeCandidate.

geocoding = Geocoding(GoogleConfig(api_key=key))

predictions = geocoding.autocomplete(AutocompleteOptions(input="blue bottle")).predictions

# Render the usual two-line suggestion without splitting `description` on a comma.
for p in predictions:
    sf = p.structured_format
    print(sf.main_text if sf else p.description)
    print(sf.secondary_text if sf and sf.secondary_text else "")

details = geocoding.place_details(PlaceDetailsOptions(place_id=predictions[0].place_id))

place_id values are provider-scoped — a Google place id is meaningless to Mapbox.

details.candidate is None when the provider does not know the id (HERE and Mapbox answer 404, TomTom and Esri an empty result, Google a 404 NOT_FOUND) or returns the place without a location — ids expire and places close, so check for it. raw still carries the vendor body. An id the vendor rejects as invalid is still an invalid_request error — and Google answers most unknown ids that way (400 INVALID_ARGUMENT, the same as a malformed one), as does Esri for a stale magicKey.

Two things that cost money here

Google's Place Details SKU is driven by the field mask, so name (displayName) is a Pro-tier field and only requested behind an opt-in:

geocoding.place_details(PlaceDetailsOptions(
    place_id=place_id,
    include=[PlaceDetailsInclude.NAME],
))

Note this is the opposite of Compute Routes, whose SKU is driven by request features — check per API rather than generalizing.

Mapbox Search Box bills per session, not per request. A suggest and the retrieve that follows count as one billable session only when they carry the same token:

token = uuid.uuid4().hex   # one per user interaction

mapbox.autocomplete(AutocompleteOptions(input=text, session_token=token))
mapbox.place_details(PlaceDetailsOptions(place_id=place_id, session_token=token))

The wrapper cannot remember that token across calls — it holds no state. Mapbox requires session_token on both calls, so when you omit it the connector generates a fresh UUIDv4 for that one call, which is valid but billed as its own session.

structured_format support

Provider main_text / secondary_text
Google structuredFormat.mainText / .secondaryText — default-on, free
Mapbox name / place_formatted
HERE title / address.label — secondary_text None for query-type suggestions, which carry no address
TomTom poi.name / address.freeformAddress — None for street results, which have no poi.name
Esri not supported — returns a single flat text

It is never synthesized: a None structured_format means the provider gave no distinct main part, and description remains the thing to render.

Switching providers

from thinwrap.location import Routing, MapboxConfig, HereConfig, OsrmConfig

# Same .route(opts) call, same RoutingResult — only the config changes.
mapbox = Routing(MapboxConfig(access_token=token))
here   = Routing(HereConfig(api_key=key))
osrm   = Routing(OsrmConfig(base_url="https://osrm.example.com"))

Normalized surface

Distances are meters, durations are seconds, coordinates are LatLng(lat, lng) (lat-first), and route geometry is a Google precision-5 polyline string. Every result carries a raw escape hatch holding the decoded vendor body. Isochrone contour geometry is a GeoJSON Polygon ([exterior, *holes], rings closed, exterior counter-clockwise and holes clockwise per RFC 7946), or a MultiPolygon when the provider returns a contour in several disconnected parts (islands, areas joined only by a ferry) — check geometry.type before reading coordinates.

Sync + bring your own HTTP client

The default transport is standard-library urllib (never follows redirects) and is HTTPS-only: an http:// URL — e.g. a self-hosted OSRM at http://localhost:5000 — is refused with ConnectorError (invalid_request) before any network I/O. To reach a cleartext endpoint, or to use httpx/requests/a traced client, implement the tiny Transport protocol and inject it — the library never imports a third-party HTTP client, so it stays zero-dependency:

Contract: a non-2xx must be RETURNED, not raised. Each connector's status mapping (429 → RATE_LIMITED, 401 → AUTH_FAILED, …) reads the response, and the bundled urllib transport honours this. A transport calling requests/httpx raise_for_status() violates it, so that case is handled defensively — the response is rebuilt from the exception and classification still runs — but returning it is correct.

import httpx
from thinwrap.location import Routing, GoogleConfig
from thinwrap.location.transport import HttpRequest, HttpResponse

_client = httpx.Client()

class HttpxTransport:
    def send(self, req: HttpRequest) -> HttpResponse:
        r = _client.request(req.method, req.url, headers=dict(req.headers), content=req.body)
        return HttpResponse(status=r.status_code, headers={k.lower(): v for k, v in r.headers.items()}, body=r.content)

r = Routing(GoogleConfig(api_key=key), transport=HttpxTransport())

The wrapper holds no state — no caching, retries, idempotency keys, or telemetry. (The HERE/TomTom async-matrix submit/poll/retrieve cycle is transient, within a single matrix call.)

_passthrough escape hatch

Forward vendor-specific fields the normalized input doesn't expose via Passthrough. body is deep-merged into the request body; headers/query are shallow-merged. Consumer values win (including over connector-set values). Header names match case-insensitively, as HTTP defines them: your x-goog-fieldmask replaces the connector's X-Goog-FieldMask and is sent in your spelling.

from thinwrap.location import Passthrough
r.route(RoutingOptions(waypoints=wps, passthrough=Passthrough(query={"alternatives": "true"})))

A list-valued vendor parameter the connector already sets — Google's X-Goog-FieldMask, HERE routing's return, a matrix annotations list, Esri's accumulateAttributeNames — is replaced by your value, not merged, so you can also remove a default. To append instead, start from the published default the connector itself sends:

from thinwrap.location import GOOGLE_ROUTES_FIELD_MASK
mask = ",".join(GOOGLE_ROUTES_FIELD_MASK + ("routes.travelAdvisory",))
r.route(RoutingOptions(waypoints=wps, passthrough=Passthrough(headers={"X-Goog-FieldMask": mask})))

The full set of constants, and which normalized outputs each field feeds, is in the "Replacing a default list" section of the Google, HERE, Esri, Mapbox and OSRM provider docs.

Polyline utilities

Four locked, cross-language-parity helpers (stdlib-only):

from thinwrap.location import encode_polyline, decode_polyline, decode_flex_polyline, encode_esri_paths

Error handling

Every operation failure is a ConnectorError carrying a typed ProviderCode: a vendor error, a transport failure, a timeout, request data the library rejects before sending (non-finite coordinates, too few waypoints, an empty matrix, a malformed country code, an unparseable date, …) or a capability the provider lacks (unsupported_*). Pre-flight rejections have status_code None. Compose your retry strategy from provider_code and cause.

Misusing the API is a bug in your code, so it raises the native exception as early as possible and is deliberately not a ConnectorError:

  • ValueError — the provider lacks the operation; a string outside a closed vocabulary (travel_mode="flying", traffic_mode, polyline_quality, an include token, isochrone type), raised when the options are constructed. Valid raw strings such as "walking" are accepted.
  • TypeError — None or a non-config argument, a transport without send(), a wrong argument type (e.g. a str routing/matrix departure_time on any provider but Esri).

HERE's transport_mode is forwarded verbatim, so HERE judges it and a bad value is a ConnectorError.

ProviderCode has the 6 canonical values (invalid_recipient, rate_limited, auth_failed, provider_unavailable, invalid_request, unknown) plus 7 location-extended (unsupported_field, unsupported_option, unsupported_travel_mode, profile_not_configured, matrix_polling_timeout, no_route, timeout) — byte-identical to the TS, PHP, and Go siblings.

no_route — "there is no route", normalized

The providers agree on nothing here. Google answers HTTP 200 with the routes key absent; HERE 200 with routes: [] plus a notices[].code; Mapbox code: "NoRoute" on either 200 or 422; OSRM the same codes on a 400; TomTom a 400 with detailedError.code; Esri a 200 whose in-body error.code: 400 names an unlocated stop in details[]. Branching on "no usable route" used to mean reimplementing all six.

In practice it almost always means a waypoint could not be matched to the road network rather than the road network is disconnected: every provider tested happily routes Reykjavik→Oslo via ferry.

timeout

Separated from provider_unavailable because it is the one transport failure a caller acts on differently — back off and retry, versus treat the provider as down.

The default Transport carries a 30-second timeout; a TimeoutError (including one wrapped by urllib as URLError(TimeoutError(...))) classifies as TIMEOUT.

There is no top-level retry_after_seconds: the raw Retry-After header rides in cause["retryAfter"], with its parsed seconds woven into provider_message.

Security

Report vulnerabilities privately — please do not open a public issue. Preferred: a private security advisory on this repository. Alternatively, email security@thinwrap.dev. Include the affected versions and a minimal reproduction if you have one.

A vulnerability in a provider's own API or service belongs to that vendor rather than to this wrapper — please report those upstream.

MIT © Dmitry Polyanovsky

Metadata

Release files for thinwrap-location 1.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for thinwrap-location 1.3.0
File Size Uploaded Provenance
thinwrap_location-1.3.0.tar.gz 165.4 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for thinwrap-location 1.3.0
File Interpreter ABI Platform
thinwrap_location-1.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 284.0 kB

Release files / thinwrap_location-1.3.0.tar.gz

Download URL thinwrap_location-1.3.0.tar.gz
Size 165.4 kB
Tags Source
SHA-256 checksum
How to use checksums
730dd41d01f6edc48a081d90b966758976876f983be781ef3cb98d441bbf1485
BLAKE2b-256 checksum
How to use checksums
3a59fcb35899b7fc040b6a4b0674da16cc3e2ac8a9ab33a677440da2afde2e5c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release files / thinwrap_location-1.3.0-py3-none-any.whl

Download URL thinwrap_location-1.3.0-py3-none-any.whl
Size 118.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
488501a40f4f1e84f98f43d832cd7b50d1884b7b4a913323e9c5a030146c3240
BLAKE2b-256 checksum
How to use checksums
32b7950939e43ced62bbfcf0a7956ac041cbf51210005fc8fed299c905f268ef
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Security for thinwrap-location 1.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for thinwrap_location-1.3.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.3.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page