chargealong 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,66 @@
1
+ """ChargeAlong: public EV charging sites, trip planning and electric cars, as an API.
2
+
3
+ Free, keyless and cached at the edge. The directory behind chargealong.io.
4
+
5
+ >>> from chargealong import ChargeAlong
6
+ >>> ca = ChargeAlong()
7
+ >>> near = ca.nearby(-37.7404, 144.9633, min_kw=50)
8
+ >>> near.sites[0].name, near.sites[0].max_kw
9
+ ('Coburg Civic Centre / Moreland City', 50)
10
+
11
+ Charging site data from Open Charge Map contributors, Transport for NSW and the
12
+ Queensland Government, places from GeoNames, all CC BY 4.0. Publishing what you
13
+ get back means carrying those credits:
14
+ chargealong.io/en/guides/where-the-data-comes-from/.
15
+ """
16
+
17
+ from ._client import DEFAULT_BASE_URL, ChargeAlong, Transport, __version__
18
+ from ._errors import BadRequest, ChargeAlongError, NoRoute, NotFound, RateLimited
19
+ from ._models import (
20
+ CONNECTORS,
21
+ SITE,
22
+ ChargeTime,
23
+ Connector,
24
+ Cost,
25
+ Country,
26
+ CountryDetail,
27
+ CountryOverview,
28
+ CurvePoint,
29
+ Facet,
30
+ Gap,
31
+ Locality,
32
+ LocalityDetail,
33
+ NearPlace,
34
+ Nearby,
35
+ Network,
36
+ NetworkDetail,
37
+ NetworkList,
38
+ Overview,
39
+ Photo,
40
+ PlaceMatch,
41
+ PostcodeMatch,
42
+ Redirect,
43
+ Region,
44
+ RegionDetail,
45
+ Review,
46
+ Reviews,
47
+ ReviewSummary,
48
+ Route,
49
+ SearchResult,
50
+ Site,
51
+ SiteDetail,
52
+ Source,
53
+ TripPlan,
54
+ TripStop,
55
+ Vehicle,
56
+ )
57
+
58
+ __all__ = [
59
+ "BadRequest", "CONNECTORS", "ChargeAlong", "ChargeAlongError", "ChargeTime", "Connector", "Cost",
60
+ "Country", "CountryDetail", "CountryOverview", "CurvePoint", "DEFAULT_BASE_URL", "Facet", "Gap",
61
+ "Locality", "LocalityDetail", "NearPlace", "Nearby", "Network", "NetworkDetail",
62
+ "NetworkList", "NoRoute", "NotFound", "Overview", "Photo", "PlaceMatch", "PostcodeMatch",
63
+ "RateLimited", "Redirect", "Region", "RegionDetail", "Review", "ReviewSummary", "Reviews",
64
+ "Route", "SITE", "SearchResult", "Site", "SiteDetail", "Source", "Transport", "TripPlan",
65
+ "TripStop", "Vehicle", "__version__",
66
+ ]
chargealong/_client.py ADDED
@@ -0,0 +1,502 @@
1
+ """The client itself."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import math
8
+ import re
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from typing import Any, Callable, Iterable, Mapping, Optional, Sequence, Tuple, Union
13
+
14
+ from ._errors import error_for
15
+ from ._models import (
16
+ CONNECTORS,
17
+ Country,
18
+ CountryDetail,
19
+ LocalityDetail,
20
+ Nearby,
21
+ NetworkDetail,
22
+ NetworkList,
23
+ Overview,
24
+ Redirect,
25
+ RegionDetail,
26
+ SearchResult,
27
+ SiteDetail,
28
+ TripPlan,
29
+ Vehicle,
30
+ site_path,
31
+ )
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ DEFAULT_BASE_URL = "https://api.chargealong.io"
36
+
37
+ #: (method, url, headers, timeout) -> (status, body bytes) or
38
+ #: (status, body bytes, response headers). Headers are only read for
39
+ #: Retry-After, so a two item answer is enough for a test double.
40
+ Transport = Callable[[str, str, dict, float], "tuple"]
41
+
42
+ # Bounded reads. A network's page is the largest answer here and runs to a few
43
+ # hundred kilobytes; past a few megabytes, something on the other end of the
44
+ # socket is not this API and a client should not be talked into reading it all.
45
+ _MAX_BODY = 16 << 20
46
+
47
+ # What the service accepts, mirrored so a bad value costs no request.
48
+ _SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
49
+ _COUNTRY_CODE = re.compile(r"^[A-Z]{2}$")
50
+
51
+ Point = Tuple[float, float]
52
+
53
+
54
+ class _NoRedirects(urllib.request.HTTPRedirectHandler):
55
+ """Follow no redirect at all.
56
+
57
+ The API does not redirect, so a 3xx is a proxy or a hijack rather than an
58
+ answer. A query here carries where somebody is; it goes nowhere it was not
59
+ sent. The 3xx comes back as an error with its status.
60
+ """
61
+
62
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
63
+ raise urllib.error.HTTPError(req.full_url, code, "chargealong: refused a redirect", headers, fp)
64
+
65
+
66
+ _opener = urllib.request.build_opener(_NoRedirects)
67
+
68
+
69
+ def _urllib_transport(method: str, url: str, headers: dict, timeout: float):
70
+ req = urllib.request.Request(url, method=method, headers=headers)
71
+ try:
72
+ with _opener.open(req, timeout=timeout) as res:
73
+ return res.status, res.read(_MAX_BODY), dict(res.headers.items())
74
+ except urllib.error.HTTPError as err:
75
+ body = err.read(_MAX_BODY) if err.fp is not None else b""
76
+ return err.code, body, dict(err.headers.items()) if err.headers else {}
77
+
78
+
79
+ def _check_base_url(raw: str) -> str:
80
+ """Refuse a base URL that would put the request somewhere it should not go.
81
+
82
+ https always, and plaintext http only to loopback, which is what a local
83
+ proxy and a test server need.
84
+ """
85
+ if not raw:
86
+ raise ValueError("chargealong: no base URL")
87
+ parsed = urllib.parse.urlparse(raw)
88
+ if not parsed.scheme or not parsed.hostname:
89
+ raise ValueError(f"chargealong: base URL {raw!r} is not absolute")
90
+ # https://api.chargealong.io:pass@evil.example reads as the real host to
91
+ # anyone skimming a config file, and is a different host to urllib.
92
+ if parsed.username or parsed.password:
93
+ raise ValueError(
94
+ f"chargealong: base URL {raw!r} carries credentials, and the host it "
95
+ "would reach is not the one it reads as"
96
+ )
97
+ if "?" in raw or "#" in raw:
98
+ raise ValueError(f"chargealong: base URL {raw!r} has a query or a fragment, which would swallow the path")
99
+ if parsed.scheme == "https":
100
+ return raw.rstrip("/")
101
+ if parsed.scheme == "http" and _is_loopback(parsed.hostname):
102
+ return raw.rstrip("/")
103
+ if parsed.scheme == "http":
104
+ raise ValueError(
105
+ f"chargealong: base URL {raw!r} is plaintext http to a public host, "
106
+ "which would send where people are in the clear"
107
+ )
108
+ raise ValueError(f"chargealong: base URL {raw!r} has scheme {parsed.scheme!r}, want https")
109
+
110
+
111
+ def _is_loopback(host: str) -> bool:
112
+ if host == "localhost":
113
+ return True
114
+ try:
115
+ return ipaddress.ip_address(host).is_loopback
116
+ except ValueError:
117
+ return False
118
+
119
+
120
+ def _segment(value: Any, what: str) -> str:
121
+ """One path segment, encoded.
122
+
123
+ ``safe=""`` so a slug carrying a slash cannot walk up the path, and "." and
124
+ ".." are refused because as a whole segment they are the collection or its
125
+ parent, whatever the encoding.
126
+ """
127
+ text = str(value or "").strip()
128
+ if not text:
129
+ raise ValueError(f"chargealong: no {what}")
130
+ if text in (".", ".."):
131
+ raise ValueError(f"chargealong: {text!r} is not a {what}")
132
+ return urllib.parse.quote(text, safe="")
133
+
134
+
135
+ def _number(name: str, value: Any, lo: float, hi: float) -> float:
136
+ """A finite number within the service's own bounds, or ValueError.
137
+
138
+ Written as a range check that NaN cannot pass. bool is refused because
139
+ True is an int to Python and a mistake to everybody else.
140
+ """
141
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
142
+ raise ValueError(f"chargealong: {name} must be a number, not {value!r}")
143
+ if not (lo <= value <= hi):
144
+ raise ValueError(f"chargealong: {name} must be between {_fmt(lo)} and {_fmt(hi)}, not {value!r}")
145
+ return value
146
+
147
+
148
+ def _count(name: str, value: Any, lo: int, hi: int) -> int:
149
+ if isinstance(value, bool) or not isinstance(value, int):
150
+ raise ValueError(f"chargealong: {name} must be a whole number, not {value!r}")
151
+ if not (lo <= value <= hi):
152
+ raise ValueError(f"chargealong: {name} must be between {lo} and {hi}, not {value!r}")
153
+ return value
154
+
155
+
156
+ def _fmt(value: float) -> str:
157
+ """400 rather than 400.0: what a person would type, and what the docs show."""
158
+ if isinstance(value, float) and value.is_integer():
159
+ return str(int(value))
160
+ return str(value)
161
+
162
+
163
+ def _point(name: str, value: Any) -> Point:
164
+ if isinstance(value, (str, bytes)) or not isinstance(value, Sequence) or len(value) != 2:
165
+ raise ValueError(f"chargealong: {name} must be a (lat, lng) pair")
166
+ lat, lng = value
167
+ try:
168
+ return _number(f"{name} lat", lat, -90, 90), _number(f"{name} lng", lng, -180, 180)
169
+ except ValueError:
170
+ raise ValueError(
171
+ f"chargealong: {name} {value!r} is not a point on the globe; lat -90..90, lng -180..180"
172
+ ) from None
173
+
174
+
175
+ def _connectors(name: str, value: Union[str, Iterable[str]]) -> "list[str]":
176
+ items = [value] if isinstance(value, str) else list(value)
177
+ for c in items:
178
+ if c not in CONNECTORS:
179
+ raise ValueError(f"chargealong: {name} {c!r} is not a connector standard; use " + ", ".join(CONNECTORS))
180
+ return items
181
+
182
+
183
+ def _search_term(term: Any) -> Optional[str]:
184
+ """The term, or None when the service would refuse it as too short.
185
+
186
+ The service keeps letters, digits, apostrophes and hyphens, and wants three
187
+ letters or digits among them.
188
+ """
189
+ text = str(term or "").strip()
190
+ if len(text.encode("utf-8")) > 100:
191
+ raise ValueError("chargealong: a search is at most 100 bytes")
192
+ if sum(1 for c in text if c.isalnum()) < 3:
193
+ return None
194
+ return text
195
+
196
+
197
+ class ChargeAlong:
198
+ """A client for the ChargeAlong EV charging API.
199
+
200
+ Public charging sites, the networks that run them, trip planning through
201
+ charging stops, and how fast electric cars charge.
202
+
203
+ >>> from chargealong import ChargeAlong
204
+ >>> ca = ChargeAlong()
205
+ >>> for site in ca.nearby(-37.7404, 144.9633, min_kw=50).sites:
206
+ ... print(site.name, site.max_kw, "kW", site.distance_m, "m")
207
+
208
+ No key and nothing to buy: the API is free, anonymous and cached at the
209
+ edge. Be gentle with it: keep answers rather than fetching them again, and
210
+ debounce anything a person types.
211
+
212
+ Attribution travels with the data. Charging site data is from Open Charge
213
+ Map contributors, Transport for NSW and the Queensland Government, places
214
+ from GeoNames, all CC BY 4.0; cars from Open EV Data. See
215
+ chargealong.io/en/guides/where-the-data-comes-from/.
216
+
217
+ Thread safe. Make one and keep it.
218
+ """
219
+
220
+ def __init__(
221
+ self,
222
+ *,
223
+ base_url: str = DEFAULT_BASE_URL,
224
+ timeout: float = 15.0,
225
+ transport: Optional[Transport] = None,
226
+ ):
227
+ self._base_url = _check_base_url(base_url)
228
+ self._timeout = timeout
229
+ self._transport = transport or _urllib_transport
230
+ self._agent = f"chargealong-python/{__version__}"
231
+
232
+ # -- finding chargers --------------------------------------------------
233
+
234
+ def nearby(
235
+ self,
236
+ lat: float,
237
+ lng: float,
238
+ *,
239
+ min_kw: Optional[float] = None,
240
+ radius_km: Optional[float] = None,
241
+ limit: Optional[int] = None,
242
+ connector: Union[str, Iterable[str], None] = None,
243
+ network: Optional[str] = None,
244
+ ) -> Nearby:
245
+ """The charging sites nearest a point, nearest first.
246
+
247
+ ``radius_km`` defaults to 50 (1 to 150), ``limit`` to 30 (1 to 50).
248
+ ``min_kw`` keeps sites with a connector of at least that power: 100 is
249
+ roughly the line between a stop and a top up. ``connector`` is one
250
+ standard or several (any of them), from :data:`CONNECTORS`.
251
+ ``network`` is a network's slug: ``evie-networks``.
252
+ """
253
+ lat, lng = self._coordinate(lat, lng)
254
+ params: "list[tuple[str, str]]" = [("lat", _fmt(lat)), ("lng", _fmt(lng))]
255
+ if min_kw is not None:
256
+ params.append(("min_kw", _fmt(_number("min_kw", min_kw, 0, 400))))
257
+ if radius_km is not None:
258
+ params.append(("radius_km", _fmt(_number("radius_km", radius_km, 1, 150))))
259
+ if limit is not None:
260
+ params.append(("limit", str(_count("limit", limit, 1, 50))))
261
+ if connector is not None:
262
+ # Repeated: /v1/nearby reads connector=ccs2&connector=chademo.
263
+ params += [("connector", c) for c in _connectors("connector", connector)]
264
+ if network is not None:
265
+ params.append(("network", self._slug_value("network", network)))
266
+ return Nearby.from_json(self._get("/v1/nearby", params).get("data"))
267
+
268
+ def site(self, public_id: str) -> SiteDetail:
269
+ """One charging site in full: connectors, price, access, sources, what
270
+ drivers said, and nearby alternatives.
271
+
272
+ The id is permanent. Raises :class:`NotFound` for an id that never
273
+ existed. A site removed from its source still answers, with
274
+ ``site.status`` of ``"removed"``, so code holding stored ids can tell
275
+ a typo from a retired charger.
276
+ """
277
+ body = self._get(f"/v1/sites/{_segment(public_id, 'site id')}", None)
278
+ return SiteDetail.from_json(body.get("data"))
279
+
280
+ def search(self, q: str, *, prefer: Optional[str] = None) -> SearchResult:
281
+ """Places, postcodes and sites matching a name.
282
+
283
+ ``prefer`` is a two letter country code whose places rank first, which
284
+ is what a search box wants for its visitor. A term with fewer than
285
+ three letters or digits answers empty without a request, since the
286
+ service would refuse it.
287
+ """
288
+ term = _search_term(q)
289
+ params: "list[tuple[str, str]]" = []
290
+ if prefer is not None:
291
+ code = str(prefer).strip().upper()
292
+ if not _COUNTRY_CODE.match(code):
293
+ raise ValueError(f"chargealong: prefer {prefer!r} is not a two letter country code")
294
+ params.append(("prefer", code))
295
+ if term is None:
296
+ return SearchResult()
297
+ return SearchResult.from_json(self._get("/v1/search", [("q", term)] + params).get("data"))
298
+
299
+ # -- trips -------------------------------------------------------------
300
+
301
+ def plan_trip(
302
+ self,
303
+ from_: Point,
304
+ to: Point,
305
+ *,
306
+ range_km: float,
307
+ reserve: Optional[float] = None,
308
+ start: Optional[float] = None,
309
+ charge_to: Optional[float] = None,
310
+ battery_kwh: Optional[float] = None,
311
+ max_dc_kw: Optional[float] = None,
312
+ max_ac_kw: Optional[float] = None,
313
+ min_kw: Optional[float] = None,
314
+ connectors: Union[str, Iterable[str], None] = None,
315
+ ) -> TripPlan:
316
+ """A road trip through charging stops.
317
+
318
+ ``from_`` and ``to`` are ``(lat, lng)``. ``range_km`` is how far the car
319
+ really goes on a full battery (50 to 1500). The rest are optional:
320
+ ``reserve`` never arrive below this percentage (default 20),
321
+ ``start`` percentage at the start (default 100, above the reserve),
322
+ ``charge_to`` never charge above this (default 80), ``battery_kwh``
323
+ usable battery for charging times, ``max_dc_kw`` and ``max_ac_kw`` what
324
+ the car accepts, ``min_kw`` ignore slower chargers (default 50), and
325
+ ``connectors`` what the car takes (default ccs2 and type-2).
326
+
327
+ A plan that cannot meet the reserve is still an answer, with
328
+ ``feasible`` false and ``gap`` saying where. No road between the points
329
+ (Sydney to Auckland) raises :class:`NoRoute`. Trips are planned in
330
+ Australia and New Zealand; the service rounds both points to about
331
+ 100 m.
332
+ """
333
+ a, b = _point("from", from_), _point("to", to)
334
+ if (round(a[0], 3), round(a[1], 3)) == (round(b[0], 3), round(b[1], 3)):
335
+ raise ValueError("chargealong: to must be somewhere other than from")
336
+
337
+ params: "list[tuple[str, str]]" = [
338
+ ("from", f"{_fmt(a[0])},{_fmt(a[1])}"),
339
+ ("to", f"{_fmt(b[0])},{_fmt(b[1])}"),
340
+ ("range_km", _fmt(_number("range_km", range_km, 50, 1500))),
341
+ ]
342
+ floor = 20.0
343
+ if reserve is not None:
344
+ floor = _number("reserve", reserve, 5, 50)
345
+ params.append(("reserve", _fmt(reserve)))
346
+ if start is not None:
347
+ params.append(("start", _fmt(_number("start", start, floor + 1, 100))))
348
+ if charge_to is not None:
349
+ params.append(("charge_to", _fmt(_number("charge_to", charge_to, max(50.0, floor + 10), 100))))
350
+ if battery_kwh is not None:
351
+ params.append(("battery_kwh", _fmt(_number("battery_kwh", battery_kwh, 10, 250))))
352
+ if max_dc_kw is not None:
353
+ params.append(("max_dc_kw", _fmt(_number("max_dc_kw", max_dc_kw, 20, 400))))
354
+ if max_ac_kw is not None:
355
+ params.append(("max_ac_kw", _fmt(_number("max_ac_kw", max_ac_kw, 3, 22))))
356
+ if min_kw is not None:
357
+ params.append(("min_kw", _fmt(_number("min_kw", min_kw, 0, 350))))
358
+ if connectors is not None:
359
+ # Comma separated: /v1/trips/plan reads connectors=ccs2,type-2.
360
+ params.append(("connectors", ",".join(_connectors("connectors", connectors))))
361
+ return TripPlan.from_json(self._get("/v1/trips/plan", params).get("data"))
362
+
363
+ # -- vehicles ----------------------------------------------------------
364
+
365
+ def vehicles(self, q: Optional[str] = None, *, limit: Optional[int] = None, all: bool = False) -> "list[Vehicle]":
366
+ """Electric cars: battery, range and how they charge.
367
+
368
+ ``q`` finds cars by any part of the brand, model or variant. ``limit``
369
+ is 1 to 100 (default 20); ``all=True`` returns every car, and is
370
+ ignored when ``q`` is given.
371
+ """
372
+ params: "list[tuple[str, str]]" = []
373
+ if q is not None and str(q).strip():
374
+ params.append(("q", str(q).strip()))
375
+ if limit is not None:
376
+ params.append(("limit", str(_count("limit", limit, 1, 100))))
377
+ if all:
378
+ params.append(("all", "true"))
379
+ data = self._get("/v1/vehicles", params).get("data")
380
+ return [Vehicle.from_json(v) for v in (data if isinstance(data, list) else []) if isinstance(v, Mapping)]
381
+
382
+ def vehicle(self, slug: str) -> Vehicle:
383
+ """One car, with how long 10 to 80 percent takes on 50, 150 and 350 kW."""
384
+ return Vehicle.from_json(self._get(f"/v1/vehicles/{_segment(slug, 'vehicle')}", None).get("data"))
385
+
386
+ # -- browsing ----------------------------------------------------------
387
+
388
+ def countries(self) -> "list[Country]":
389
+ """Every country with charging sites, and how many each has."""
390
+ data = self._get("/v1/countries", None).get("data")
391
+ return [Country.from_json(c) for c in (data if isinstance(data, list) else []) if isinstance(c, Mapping)]
392
+
393
+ def overview(self) -> Overview:
394
+ """What the whole directory holds: countries and their busiest places."""
395
+ return Overview.from_json(self._get("/v1/overview", None).get("data"))
396
+
397
+ def country(self, country: str) -> CountryDetail:
398
+ """One country: its regions and their counts, and its busiest places."""
399
+ return CountryDetail.from_json(self._get(f"/v1/countries/{_segment(country, 'country')}", None).get("data"))
400
+
401
+ def region(self, country: str, region: str) -> RegionDetail:
402
+ """One state or region: the places in it that have charging sites."""
403
+ path = f"/v1/countries/{_segment(country, 'country')}/regions/{_segment(region, 'region')}"
404
+ return RegionDetail.from_json(self._get(path, None).get("data"))
405
+
406
+ def networks(self, country: str) -> NetworkList:
407
+ """Every charging network in a country, with its site and region counts."""
408
+ path = f"/v1/countries/{_segment(country, 'country')}/networks"
409
+ return NetworkList.from_json(self._get(path, None).get("data"))
410
+
411
+ def network(self, country: str, network: str) -> NetworkDetail:
412
+ """One network in a country: where it reaches, and a sample of its sites."""
413
+ path = f"/v1/countries/{_segment(country, 'country')}/networks/{_segment(network, 'network')}"
414
+ return NetworkDetail.from_json(self._get(path, None).get("data"))
415
+
416
+ def locality(self, country: str, region: str, locality: str) -> LocalityDetail:
417
+ """One suburb or town: every charging site in it, with facet counts.
418
+
419
+ Raises :class:`NotFound` for a place with no sites: those have no page.
420
+ """
421
+ path = (
422
+ f"/v1/localities/{_segment(country, 'country')}"
423
+ f"/{_segment(region, 'region')}/{_segment(locality, 'locality')}"
424
+ )
425
+ return LocalityDetail.from_json(self._get(path, None).get("data"))
426
+
427
+ def redirect(self, path: str) -> Optional[Redirect]:
428
+ """Where a page on chargealong.io went, if it moved; None if it did not.
429
+
430
+ URLs there are permanent, so this is for the rare page that had to move
431
+ (a site filed under the wrong suburb). Most paths asked about have
432
+ simply not moved, which is None rather than an exception.
433
+ """
434
+ text = str(path or "").strip()
435
+ if not site_path(text):
436
+ raise ValueError(f"chargealong: {path!r} is not a path on chargealong.io, starting with one slash")
437
+ from ._errors import NotFound
438
+
439
+ try:
440
+ body = self._get("/v1/redirect", [("path", text)])
441
+ except NotFound:
442
+ return None
443
+ return Redirect.from_json(body.get("data"))
444
+
445
+ # -- plumbing ----------------------------------------------------------
446
+
447
+ @staticmethod
448
+ def _coordinate(lat: Any, lng: Any) -> Point:
449
+ try:
450
+ return _number("lat", lat, -90, 90), _number("lng", lng, -180, 180)
451
+ except ValueError:
452
+ raise ValueError(
453
+ f"chargealong: ({lat!r}, {lng!r}) is not a point on the globe; lat -90..90, lng -180..180"
454
+ ) from None
455
+
456
+ @staticmethod
457
+ def _slug_value(name: str, value: Any) -> str:
458
+ text = str(value or "").strip()
459
+ if len(text) > 100 or not _SLUG.match(text):
460
+ raise ValueError(f"chargealong: {name} {value!r} is not a slug, like evie-networks")
461
+ return text
462
+
463
+ def _get(self, path: str, params: "Optional[Sequence[tuple[str, str]]]") -> dict:
464
+ url = self._base_url + path
465
+ if params:
466
+ url += "?" + urllib.parse.urlencode(list(params), quote_via=urllib.parse.quote)
467
+
468
+ answer = self._transport(
469
+ "GET",
470
+ url,
471
+ {"Accept": "application/json", "User-Agent": self._agent},
472
+ self._timeout,
473
+ )
474
+ status, raw = answer[0], answer[1]
475
+ headers = answer[2] if len(answer) > 2 and isinstance(answer[2], Mapping) else {}
476
+
477
+ # A problem document is JSON; a proxy having a bad day is not. Either
478
+ # way the status is the part a caller can act on, so parsing never
479
+ # decides whether an error is raised.
480
+ try:
481
+ body = json.loads(raw or b"{}")
482
+ except ValueError:
483
+ body = {}
484
+ if not isinstance(body, dict):
485
+ body = {}
486
+
487
+ if not 200 <= status < 300:
488
+ title = body.get("title") if isinstance(body.get("title"), str) else ""
489
+ detail = body.get("detail") if isinstance(body.get("detail"), str) else ""
490
+ raise error_for(status, title, detail, _retry_after(headers))
491
+ return body
492
+
493
+
494
+ def _retry_after(headers: Mapping[str, Any]) -> Optional[float]:
495
+ for name, value in headers.items():
496
+ if str(name).lower() == "retry-after":
497
+ try:
498
+ seconds = float(value)
499
+ except (TypeError, ValueError):
500
+ return None
501
+ return seconds if math.isfinite(seconds) and seconds > 0 else None
502
+ return None
chargealong/_errors.py ADDED
@@ -0,0 +1,71 @@
1
+ """What the API said when it refused."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ChargeAlongError(Exception):
7
+ """The API refused, and said why.
8
+
9
+ The service answers RFC 7807 problem documents: a title and a detail
10
+ written for a person to read and act on, such as which parameter is out of
11
+ range. Collapsing that into "HTTP 400" throws away the only part of the
12
+ answer that says what to do about it.
13
+ """
14
+
15
+ def __init__(self, status: int, title: str = "", detail: str = ""):
16
+ self.status = status
17
+ self.title = title
18
+ self.detail = detail
19
+ message = title or f"HTTP {status}"
20
+ if detail:
21
+ message = f"{message}: {detail}"
22
+ super().__init__(f"{status} {message}")
23
+
24
+
25
+ class NotFound(ChargeAlongError):
26
+ """Nothing is filed under that id or slug.
27
+
28
+ Its own class because it is an ordinary thing to hit rather than a failure:
29
+ a place with no charging sites has no page, and code walking a list of ids
30
+ will meet this.
31
+ """
32
+
33
+
34
+ class BadRequest(ChargeAlongError):
35
+ """The request could not be served as written. The detail names the
36
+ parameter: "lat: must be between -90 and 90"."""
37
+
38
+
39
+ class NoRoute(BadRequest):
40
+ """No road route between the two points of a trip (422).
41
+
42
+ An answer to show rather than a parameter to fix: Sydney to Auckland has
43
+ no road. (Melbourne to Hobart does: the router takes the ferry, and the
44
+ plan comes back infeasible with the crossing as its gap.)
45
+ """
46
+
47
+
48
+ class RateLimited(ChargeAlongError):
49
+ """Too many requests from here.
50
+
51
+ The API is free and keyless, and asks callers to be gentle: keep answers
52
+ rather than fetching them again, and debounce anything a person types.
53
+
54
+ ``retry_after`` is the seconds the service asked for, when it said.
55
+ """
56
+
57
+ def __init__(self, status: int, title: str = "", detail: str = "", retry_after: float | None = None):
58
+ super().__init__(status, title, detail)
59
+ self.retry_after = retry_after
60
+
61
+
62
+ def error_for(status: int, title: str = "", detail: str = "", retry_after: float | None = None) -> ChargeAlongError:
63
+ if status == 404:
64
+ return NotFound(status, title, detail)
65
+ if status == 422:
66
+ return NoRoute(status, title, detail)
67
+ if status == 429:
68
+ return RateLimited(status, title, detail, retry_after)
69
+ if 400 <= status < 500:
70
+ return BadRequest(status, title, detail)
71
+ return ChargeAlongError(status, title, detail)