justrouting 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ """JustRouting Python client.
2
+
3
+ Official Python client for the JustRouting API — routing, distance
4
+ matrices, and vehicle routing optimization across Southeast Asia.
5
+
6
+ client = justrouting.Client("YOUR_API_KEY")
7
+
8
+ route = client.routes.get(justrouting.RouteRequest(
9
+ origin=[103.8198, 1.3521],
10
+ destination=[103.9915, 1.3644],
11
+ ))
12
+ print(f"Distance: {route.distance / 1000:.1f} km")
13
+
14
+ Coordinates are always [longitude, latitude], the order used by GeoJSON,
15
+ OSRM and VROOM. Failed calls raise a subclass of [justrouting.Error] —
16
+ see [justrouting.NoRouteError] and friends.
17
+
18
+ The package has no dependencies outside the standard library.
19
+ """
20
+
21
+ from .client import Client
22
+ from .consts import DEFAULT_BASE_URL, DEFAULT_PROFILE, VERSION
23
+ from .error import (
24
+ CrossCountryError,
25
+ DecodeError,
26
+ Error,
27
+ InvalidCoordinatesError,
28
+ InvalidRequestError,
29
+ JustRoutingError,
30
+ NoRouteError,
31
+ PlanLimitExceededError,
32
+ QuotaExceededError,
33
+ RateLimitedError,
34
+ TransportError,
35
+ UnauthorizedError,
36
+ UpstreamUnavailableError,
37
+ )
38
+ from .geo import Geometry, LineString, Point, Waypoint
39
+ from .health import Health, HealthService
40
+ from .matrix import MatrixRequest, MatrixResponse, MatrixService
41
+ from .optimization import (
42
+ Job,
43
+ OptimizationOptions,
44
+ OptimizationRequest,
45
+ OptimizationService,
46
+ RouteStep,
47
+ Shipment,
48
+ ShipmentStep,
49
+ Solution,
50
+ Summary,
51
+ TimeWindow,
52
+ Unassigned,
53
+ Vehicle,
54
+ VehicleRoute,
55
+ )
56
+ from .routes import (
57
+ Annotation,
58
+ Intersection,
59
+ Lane,
60
+ Leg,
61
+ Maneuver,
62
+ Route,
63
+ RouteRequest,
64
+ RouteResponse,
65
+ RoutesService,
66
+ Step,
67
+ )
68
+
69
+ __version__ = VERSION
70
+
71
+ __all__ = [
72
+ # Client
73
+ "Client",
74
+ "VERSION",
75
+ "DEFAULT_BASE_URL",
76
+ "DEFAULT_PROFILE",
77
+ # Errors
78
+ "JustRoutingError",
79
+ "Error",
80
+ "UnauthorizedError",
81
+ "RateLimitedError",
82
+ "QuotaExceededError",
83
+ "PlanLimitExceededError",
84
+ "CrossCountryError",
85
+ "InvalidCoordinatesError",
86
+ "NoRouteError",
87
+ "UpstreamUnavailableError",
88
+ "InvalidRequestError",
89
+ "TransportError",
90
+ "DecodeError",
91
+ # Geo
92
+ "Point",
93
+ "Geometry",
94
+ "LineString",
95
+ "Waypoint",
96
+ # Services
97
+ "RoutesService",
98
+ "MatrixService",
99
+ "OptimizationService",
100
+ "HealthService",
101
+ # Routes
102
+ "RouteRequest",
103
+ "RouteResponse",
104
+ "Route",
105
+ "Leg",
106
+ "Step",
107
+ "Maneuver",
108
+ "Intersection",
109
+ "Lane",
110
+ "Annotation",
111
+ # Matrix
112
+ "MatrixRequest",
113
+ "MatrixResponse",
114
+ # Optimization
115
+ "OptimizationRequest",
116
+ "OptimizationOptions",
117
+ "TimeWindow",
118
+ "Vehicle",
119
+ "Job",
120
+ "ShipmentStep",
121
+ "Shipment",
122
+ "Solution",
123
+ "Summary",
124
+ "VehicleRoute",
125
+ "RouteStep",
126
+ "Unassigned",
127
+ # Health
128
+ "Health",
129
+ ]
justrouting/client.py ADDED
@@ -0,0 +1,139 @@
1
+ """The JustRouting client.
2
+
3
+ A client is created with an API key and, optionally, keyword options::
4
+
5
+ client = justrouting.Client("YOUR_API_KEY")
6
+
7
+ route = client.routes.get(justrouting.RouteRequest(
8
+ origin=[103.8198, 1.3521],
9
+ destination=[103.9915, 1.3644],
10
+ ))
11
+ print(f"Distance: {route.distance / 1000:.1f} km")
12
+
13
+ Coordinates are always [longitude, latitude], the order used by GeoJSON,
14
+ OSRM and VROOM. Every call accepts an optional ``timeout`` argument in
15
+ seconds that bounds the whole retry sequence, like a context deadline in
16
+ the Go client.
17
+
18
+ Failed calls raise a subclass of [justrouting.Error]. Use ``except`` clauses
19
+ for control flow and [justrouting.Error] when the status code or raw body is
20
+ needed::
21
+
22
+ try:
23
+ route = client.routes.get(req)
24
+ except justrouting.RateLimitedError:
25
+ # back off and retry later
26
+
27
+ Unlike the Go client, invalid constructor options raise ``ValueError``
28
+ immediately — Python constructors can fail, so there is no need to defer
29
+ the error to the first request.
30
+
31
+ The package has no dependencies outside the standard library.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import urllib.parse
37
+ from typing import Callable, Optional
38
+
39
+ from .consts import DEFAULT_BASE_URL, DEFAULT_PROFILE, VERSION
40
+ from .health import HealthService
41
+ from .matrix import MatrixService
42
+ from .optimization import OptimizationService
43
+ from .routes import RoutesService
44
+ from .transport import Transport, default_backoff
45
+
46
+ __all__ = [
47
+ "Client",
48
+ "VERSION",
49
+ "DEFAULT_BASE_URL",
50
+ "DEFAULT_PROFILE",
51
+ ]
52
+
53
+ _DEFAULT_MAX_RETRIES = 2
54
+ _DEFAULT_TIMEOUT = 30.0
55
+
56
+
57
+ def _validate_base_url(raw: str) -> str:
58
+ raw = raw.strip()
59
+ if not raw:
60
+ raise ValueError("justrouting: base_url must not be empty")
61
+ parts = urllib.parse.urlsplit(raw)
62
+ if not parts.scheme or not parts.netloc:
63
+ raise ValueError(f"justrouting: base_url {raw!r} is not an absolute URL")
64
+ return raw
65
+
66
+
67
+ class Client:
68
+ """A JustRouting API client.
69
+
70
+ It is safe for concurrent use by multiple threads, and should be created
71
+ once and reused.
72
+
73
+ Args:
74
+ api_key: The API key, sent as an ``Authorization: Bearer`` header on
75
+ every request that needs it. An empty key is allowed but only
76
+ [HealthService.get] will work; every other call fails with
77
+ [UnauthorizedError] before any request is sent.
78
+ base_url: The API endpoint, default [DEFAULT_BASE_URL]. Target a
79
+ local or staging server with ``base_url="http://localhost:8080"``.
80
+ Any path is kept as a prefix for every request.
81
+ user_agent: Override the User-Agent header. Identifying your
82
+ application helps when diagnosing traffic against the API.
83
+ max_retries: How many times a failed request is retried, on top of
84
+ the initial attempt. The default is 2. Pass 0 to disable
85
+ retries. Only rate-limit (429), server (5xx) and transport
86
+ errors are retried; other 4xx responses are returned immediately.
87
+ backoff: Callable(attempt: int) -> float returning the delay in
88
+ seconds before retry ``attempt`` (1 for the first retry, 2 for
89
+ the second, and so on). The default is exponential backoff with
90
+ jitter, starting at 500ms and capped at 8s. A Retry-After
91
+ response header, when present, takes precedence.
92
+ timeout: Seconds allowed for a single HTTP attempt, like
93
+ ``http.Client.Timeout`` in Go. The default is 30. Use the
94
+ per-call ``timeout`` argument instead to bound the whole retry
95
+ sequence.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ api_key: str,
101
+ *,
102
+ base_url: str = DEFAULT_BASE_URL,
103
+ user_agent: Optional[str] = None,
104
+ max_retries: int = _DEFAULT_MAX_RETRIES,
105
+ backoff: Optional[Callable[[int], float]] = None,
106
+ timeout: Optional[float] = _DEFAULT_TIMEOUT,
107
+ ) -> None:
108
+ if max_retries < 0:
109
+ raise ValueError(
110
+ f"justrouting: max_retries must not be negative, got {max_retries}"
111
+ )
112
+ if user_agent is not None and not user_agent.strip():
113
+ raise ValueError("justrouting: user_agent must not be empty")
114
+ if backoff is not None and not callable(backoff):
115
+ raise ValueError("justrouting: backoff must be a callable")
116
+ if timeout is not None and timeout <= 0:
117
+ raise ValueError("justrouting: timeout must be positive or None")
118
+
119
+ self._transport = Transport(
120
+ api_key=api_key.strip(),
121
+ base_url=_validate_base_url(base_url),
122
+ user_agent=user_agent or f"justrouting-py/{VERSION}",
123
+ max_retries=max_retries,
124
+ backoff=backoff or default_backoff,
125
+ timeout=timeout,
126
+ )
127
+
128
+ # Routes computes routes between two or more coordinates.
129
+ self.routes = RoutesService(self._transport)
130
+ # Matrix computes duration and distance matrices.
131
+ self.matrix = MatrixService(self._transport)
132
+ # Optimization solves vehicle routing problems.
133
+ self.optimization = OptimizationService(self._transport)
134
+ # Health reports API and upstream availability.
135
+ self.health = HealthService(self._transport)
136
+
137
+ def base_url(self) -> str:
138
+ """The API endpoint the client sends requests to."""
139
+ return self._transport.base_url
justrouting/consts.py ADDED
@@ -0,0 +1,30 @@
1
+ """Package constants, in a leaf module so services and the client can both
2
+ import them without a cycle."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import urllib.parse
7
+
8
+ __all__ = [
9
+ "VERSION",
10
+ "DEFAULT_BASE_URL",
11
+ "DEFAULT_PROFILE",
12
+ "profile_or_default",
13
+ ]
14
+
15
+ # VERSION is the client version, reported in the User-Agent header.
16
+ VERSION = "0.1.0"
17
+
18
+ # DEFAULT_BASE_URL is the hosted JustRouting API endpoint.
19
+ DEFAULT_BASE_URL = "https://api.justrouting.tech"
20
+
21
+ # DEFAULT_PROFILE is the routing profile used when a request leaves
22
+ # Profile empty.
23
+ DEFAULT_PROFILE = "driving"
24
+
25
+
26
+ def profile_or_default(profile: str) -> str:
27
+ """The profile to use in a request path, with URL path escaping."""
28
+ if not profile:
29
+ return DEFAULT_PROFILE
30
+ return urllib.parse.quote(profile, safe="")
justrouting/error.py ADDED
@@ -0,0 +1,269 @@
1
+ """Errors raised by the JustRouting client.
2
+
3
+ The Go client classifies failures with sentinel errors and ``errors.Is``.
4
+ Python expresses the same classification as an exception hierarchy, so
5
+ callers use ``except`` clauses instead of string matching::
6
+
7
+ try:
8
+ route = client.routes.get(req)
9
+ except justrouting.QuotaExceededError:
10
+ # daily allowance used up — retrying will not help
11
+ except justrouting.RateLimitedError:
12
+ # throttled; the client already retried
13
+ except justrouting.CrossCountryError:
14
+ # coordinates span more than one country
15
+ except justrouting.NoRouteError:
16
+ # no road connects these points
17
+
18
+ ``QuotaExceededError`` is a subclass of ``RateLimitedError``, mirroring how
19
+ the Go client reports a daily-quota response as both
20
+ ``ErrQuotaExceeded`` and ``ErrRateLimited``.
21
+
22
+ Every API failure — non-2xx responses and in-body engine failures alike —
23
+ raises an ``Error`` (or one of its subclasses). Catch it when the status
24
+ code, engine code, or raw body is needed::
25
+
26
+ except justrouting.Error as e:
27
+ log("HTTP %d: %s\n%s", e.status_code, e.message, e.body)
28
+
29
+ ``Error`` carries the same fields as the Go ``*Error`` struct. Failures
30
+ that never produced an API response (local validation, transport, decoding)
31
+ raise ``InvalidRequestError``, ``TransportError`` or ``DecodeError``
32
+ instead, which are *not* subclasses of ``Error``.
33
+ """
34
+
35
+ import http.client
36
+ import json
37
+
38
+ __all__ = [
39
+ "JustRoutingError",
40
+ "Error",
41
+ "UnauthorizedError",
42
+ "RateLimitedError",
43
+ "QuotaExceededError",
44
+ "PlanLimitExceededError",
45
+ "CrossCountryError",
46
+ "InvalidCoordinatesError",
47
+ "NoRouteError",
48
+ "UpstreamUnavailableError",
49
+ "InvalidRequestError",
50
+ "TransportError",
51
+ "DecodeError",
52
+ ]
53
+
54
+ # maxErrorBodyBytes bounds how much of a response body is retained on an
55
+ # Error, so that a large or misbehaving upstream cannot balloon a log line.
56
+ _MAX_ERROR_BODY_BYTES = 8 << 10
57
+
58
+
59
+ class JustRoutingError(Exception):
60
+ """Base class of every error raised by this package."""
61
+
62
+
63
+ class Error(JustRoutingError):
64
+ """A failed API call.
65
+
66
+ It is raised whenever the API responds with a non-2xx status, and also
67
+ when a 2xx response carries an in-body failure code — the routing and
68
+ optimization engines can both report errors alongside HTTP 200.
69
+
70
+ Attributes:
71
+ status_code: The HTTP status of the response. Zero for errors that
72
+ were produced without an HTTP response, such as local validation.
73
+ message: The human-readable reason reported by the API.
74
+ osrm_code: The routing engine's status code, such as ``"NoRoute"``
75
+ or ``"InvalidValue"``. Empty when the error did not come from
76
+ the routing engine.
77
+ vroom_code: The optimization engine's non-zero status code. Zero
78
+ when the error did not come from the optimization engine.
79
+ body: The raw response body, truncated to a sane limit. Useful for
80
+ logging responses this package does not model.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ message: str = "",
86
+ *,
87
+ status_code: int = 0,
88
+ osrm_code: str = "",
89
+ vroom_code: int = 0,
90
+ body: bytes = b"",
91
+ ) -> None:
92
+ super().__init__(message)
93
+ self.message = message
94
+ self.status_code = status_code
95
+ self.osrm_code = osrm_code
96
+ self.vroom_code = vroom_code
97
+ self.body = body
98
+
99
+ def __str__(self) -> str:
100
+ # A zero status means the error was produced without an HTTP
101
+ # response; the message then already carries the full text.
102
+ if self.status_code == 0:
103
+ return self.message
104
+ parts = ["justrouting: "]
105
+ parts.append(f"HTTP {self.status_code}")
106
+ if self.osrm_code:
107
+ parts.append(f" ({self.osrm_code})")
108
+ elif self.vroom_code:
109
+ parts.append(f" (optimization code {self.vroom_code})")
110
+ if self.message:
111
+ parts.append(f": {self.message}")
112
+ return "".join(parts)
113
+
114
+
115
+ class UnauthorizedError(Error):
116
+ """The API key is missing, invalid or revoked."""
117
+
118
+
119
+ class RateLimitedError(Error):
120
+ """The request was throttled (per-second limit or daily quota)."""
121
+
122
+
123
+ class QuotaExceededError(RateLimitedError):
124
+ """The plan's daily request quota is used up.
125
+
126
+ Retrying before the quota resets will not help. Subclasses
127
+ ``RateLimitedError`` because quota exhaustion is a form of throttling.
128
+ """
129
+
130
+
131
+ class PlanLimitExceededError(Error):
132
+ """The request is larger than the plan allows — too many matrix
133
+ coordinates, jobs, or vehicles."""
134
+
135
+
136
+ class CrossCountryError(Error):
137
+ """The coordinates span more than one country.
138
+
139
+ Every coordinate in a request must fall within a single country.
140
+ """
141
+
142
+
143
+ class InvalidCoordinatesError(Error):
144
+ """A coordinate was malformed, out of range, or could not be parsed."""
145
+
146
+
147
+ class NoRouteError(Error):
148
+ """No route exists between the given coordinates."""
149
+
150
+
151
+ class UpstreamUnavailableError(Error):
152
+ """The routing engine behind the API could not be reached.
153
+
154
+ This is usually transient.
155
+ """
156
+
157
+
158
+ class InvalidRequestError(Error):
159
+ """The request was rejected before being sent because required fields
160
+ were missing or inconsistent.
161
+
162
+ These errors carry no HTTP status; the message holds the reason.
163
+ """
164
+
165
+
166
+ class TransportError(JustRoutingError):
167
+ """A network-level failure while contacting the API.
168
+
169
+ The original exception is available as ``__cause__``. Transport errors
170
+ are transient and are retried like 429 and 5xx responses.
171
+ """
172
+
173
+
174
+ class DecodeError(JustRoutingError):
175
+ """A successful response could not be decoded.
176
+
177
+ The original exception is available as ``__cause__``.
178
+ """
179
+
180
+
181
+ def _truncate(body: bytes, max_bytes: int) -> bytes:
182
+ if len(body) <= max_bytes:
183
+ return body
184
+ return body[:max_bytes]
185
+
186
+
187
+ def osrm_status_error(code: str, message: str) -> "Error | None":
188
+ """Convert a routing engine status code into an Error, or None when the
189
+ response was successful."""
190
+ if not code or code.lower() == "ok":
191
+ return None
192
+ if not message:
193
+ message = f"routing engine returned {code}"
194
+ cls = _classify_error(http.client.OK, message, code)
195
+ return cls(status_code=http.client.OK, osrm_code=code, message=message)
196
+
197
+
198
+ def error_from_response(status_code: int, body: bytes) -> "Error":
199
+ """Build the right Error subclass for an HTTP error response.
200
+
201
+ Bodies are classified by shape rather than Content-Type: some API
202
+ handlers emit a JSON body while leaving the header set to text/plain.
203
+ """
204
+ message, osrm_code, vroom_code = "", "", 0
205
+
206
+ payload = None
207
+ try:
208
+ stripped = body.strip()
209
+ if stripped:
210
+ payload = json.loads(stripped)
211
+ except ValueError:
212
+ payload = None
213
+
214
+ if isinstance(payload, dict):
215
+ # {"error": ...} gateway errors
216
+ # {"code": "NoRoute", "message": ...} routing engine (string code)
217
+ # {"code": 3, "error": ...} optimization engine (int code)
218
+ message = payload.get("error") or payload.get("message") or ""
219
+ code = payload.get("code")
220
+ if isinstance(code, str) and code.lower() != "ok":
221
+ osrm_code = code
222
+ elif isinstance(code, int):
223
+ vroom_code = code
224
+
225
+ if not message:
226
+ message = _fallback_message(status_code, body)
227
+
228
+ cls = _classify_error(status_code, message, osrm_code)
229
+ return cls(
230
+ status_code=status_code,
231
+ message=message,
232
+ osrm_code=osrm_code,
233
+ vroom_code=vroom_code,
234
+ body=_truncate(body, _MAX_ERROR_BODY_BYTES),
235
+ )
236
+
237
+
238
+ def _classify_error(status_code: int, message: str, osrm_code: str):
239
+ """Pick the exception class for a failure, in the same priority order
240
+ the Go client's Error.Is uses."""
241
+ msg = message.lower()
242
+ if status_code == http.client.UNAUTHORIZED:
243
+ return UnauthorizedError
244
+ if status_code == http.client.TOO_MANY_REQUESTS:
245
+ if "daily quota" in msg:
246
+ return QuotaExceededError
247
+ return RateLimitedError
248
+ if status_code == http.client.BAD_GATEWAY or "upstream unavailable" in msg:
249
+ return UpstreamUnavailableError
250
+ if "cross-country" in msg:
251
+ return CrossCountryError
252
+ if "exceeds plan limit" in msg or "too many jobs" in msg or "too many vehicles" in msg:
253
+ return PlanLimitExceededError
254
+ if osrm_code.lower() == "noroute":
255
+ return NoRouteError
256
+ if "cannot parse coordinates" in msg or "invalid coordinates" in msg:
257
+ return InvalidCoordinatesError
258
+ return Error
259
+
260
+
261
+ def _fallback_message(status_code: int, body: bytes) -> str:
262
+ """Produce a message for responses with no recognisable error field,
263
+ such as an HTML error page from an intermediary proxy."""
264
+ s = body.decode("utf-8", errors="replace").strip()
265
+ if s and not s.startswith("{") and not s.startswith("<") and len(s) <= 200:
266
+ return s
267
+ if text := http.client.responses.get(status_code, ""):
268
+ return text.lower()
269
+ return "unexpected response"