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.
- chargealong/__init__.py +66 -0
- chargealong/_client.py +502 -0
- chargealong/_errors.py +71 -0
- chargealong/_models.py +750 -0
- chargealong/py.typed +0 -0
- chargealong-0.1.0.dist-info/METADATA +215 -0
- chargealong-0.1.0.dist-info/RECORD +9 -0
- chargealong-0.1.0.dist-info/WHEEL +4 -0
- chargealong-0.1.0.dist-info/licenses/LICENSE +21 -0
chargealong/__init__.py
ADDED
|
@@ -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)
|