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 |
|---|---|---|
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 |
|---|---|
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, anincludetoken, isochronetype), raised when the options are constructed. Valid raw strings such as"walking"are accepted.TypeError—Noneor a non-config argument, atransportwithoutsend(), a wrong argument type (e.g. astrrouting/matrixdeparture_timeon 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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| thinwrap_location-1.3.0.tar.gz | 165.4 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.