lapviz 1.0.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.
- lapviz/__init__.py +26 -0
- lapviz/client.py +388 -0
- lapviz/errors.py +63 -0
- lapviz/py.typed +0 -0
- lapviz/webhooks.py +53 -0
- lapviz-1.0.0.dist-info/METADATA +108 -0
- lapviz-1.0.0.dist-info/RECORD +9 -0
- lapviz-1.0.0.dist-info/WHEEL +4 -0
- lapviz-1.0.0.dist-info/licenses/LICENSE +21 -0
lapviz/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""LapViz API client for Python: https://lapviz.com/developers/docs"""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from .client import LapViz # noqa: E402
|
|
6
|
+
from .errors import ( # noqa: E402
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
LapVizError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
PermissionDeniedError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
)
|
|
13
|
+
from .webhooks import EVENT_HEADER, SIGNATURE_HEADER, compute_signature, verify_signature # noqa: E402
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"LapViz",
|
|
17
|
+
"LapVizError",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"PermissionDeniedError",
|
|
20
|
+
"NotFoundError",
|
|
21
|
+
"RateLimitError",
|
|
22
|
+
"verify_signature",
|
|
23
|
+
"compute_signature",
|
|
24
|
+
"SIGNATURE_HEADER",
|
|
25
|
+
"EVENT_HEADER",
|
|
26
|
+
]
|
lapviz/client.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Client of the LapViz API (https://lapviz.com/developers/docs), with the Python standard library only.
|
|
2
|
+
|
|
3
|
+
Responses are returned as the JSON of the API (dicts with camelCase keys, as in the reference): fields added to the API
|
|
4
|
+
appear without a new version of this package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import date, datetime
|
|
14
|
+
from typing import Any, BinaryIO, Dict, Iterator, List, Optional, Tuple, Union
|
|
15
|
+
from urllib.error import HTTPError, URLError
|
|
16
|
+
from urllib.parse import quote, urlencode
|
|
17
|
+
from urllib.request import Request, urlopen
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .errors import LapVizError, RateLimitError, error_for
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://lapviz.com"
|
|
23
|
+
JSON = Any
|
|
24
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _segment(value: Any) -> str:
|
|
28
|
+
"""One segment of a path: "users/1-A" becomes "users%2F1-A"."""
|
|
29
|
+
return quote(str(value), safe="")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _camel(name: str) -> str:
|
|
33
|
+
name = name.rstrip("_") # from_ (a keyword in Python) is "from"
|
|
34
|
+
head, *rest = name.split("_")
|
|
35
|
+
return head + "".join(x[:1].upper() + x[1:] for x in rest)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _body(values: Dict[str, Any]) -> Dict[str, Any]:
|
|
39
|
+
"""Keyword arguments as a JSON body: camelCase names, dates in ISO 8601, None left out."""
|
|
40
|
+
result = {}
|
|
41
|
+
for name, value in values.items():
|
|
42
|
+
if value is None:
|
|
43
|
+
continue
|
|
44
|
+
if isinstance(value, (datetime, date)):
|
|
45
|
+
value = value.isoformat()
|
|
46
|
+
result[_camel(name)] = value
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LapViz:
|
|
51
|
+
"""Client of the LapViz API.
|
|
52
|
+
|
|
53
|
+
``LapViz()`` reads the key from the ``LAPVIZ_API_KEY`` environment variable; ``LapViz(api_key="lvz_...")`` takes
|
|
54
|
+
it directly, ``LapViz(access_token="lvzat_...")`` uses an OAuth access token ("Sign in with LapViz") instead.
|
|
55
|
+
Anonymous calls (the circuits, the public sessions) need neither.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_key: Optional[str] = None,
|
|
61
|
+
*,
|
|
62
|
+
access_token: Optional[str] = None,
|
|
63
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
64
|
+
timeout: float = 60,
|
|
65
|
+
max_retries: int = 2,
|
|
66
|
+
user_agent: Optional[str] = None,
|
|
67
|
+
):
|
|
68
|
+
self.api_key = api_key if api_key is not None else (None if access_token else os.environ.get("LAPVIZ_API_KEY") or None)
|
|
69
|
+
self.access_token = access_token
|
|
70
|
+
self.base_url = base_url.rstrip("/")
|
|
71
|
+
if self.base_url.endswith("/api"):
|
|
72
|
+
self.base_url = self.base_url[:-4]
|
|
73
|
+
self.timeout = timeout
|
|
74
|
+
self.max_retries = max_retries
|
|
75
|
+
self.user_agent = user_agent or f"lapviz-python/{__version__}"
|
|
76
|
+
self.rate_limit: Dict[str, str] = {}
|
|
77
|
+
"""The RateLimit-* headers of the last response (Limit, Remaining, Policy)."""
|
|
78
|
+
|
|
79
|
+
self.account = Account(self)
|
|
80
|
+
self.sessions = Sessions(self)
|
|
81
|
+
self.imports = Imports(self)
|
|
82
|
+
self.teams = Teams(self)
|
|
83
|
+
self.webhooks = Webhooks(self)
|
|
84
|
+
self.circuits = Circuits(self)
|
|
85
|
+
|
|
86
|
+
def me(self) -> JSON:
|
|
87
|
+
"""The account of the key or token: the first call to make with a new key."""
|
|
88
|
+
return self.account.me()
|
|
89
|
+
|
|
90
|
+
# Transport
|
|
91
|
+
|
|
92
|
+
def request(
|
|
93
|
+
self,
|
|
94
|
+
method: str,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
json_body: Any = None,
|
|
98
|
+
query: Optional[Dict[str, Any]] = None,
|
|
99
|
+
data: Optional[bytes] = None,
|
|
100
|
+
headers: Optional[Dict[str, str]] = None,
|
|
101
|
+
stream_to: Optional[BinaryIO] = None,
|
|
102
|
+
) -> JSON:
|
|
103
|
+
"""Calls any operation of the API, e.g. ``request("GET", "api/session/vehicles")``; returns the JSON of the
|
|
104
|
+
answer (None when it has no body), or writes it to ``stream_to``. Retries on 429 and 503 after Retry-After."""
|
|
105
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
106
|
+
query = {k: v for k, v in (query or {}).items() if v is not None}
|
|
107
|
+
if query:
|
|
108
|
+
url += "?" + urlencode({k: (str(v).lower() if isinstance(v, bool) else v) for k, v in query.items()})
|
|
109
|
+
|
|
110
|
+
all_headers = {"Accept": "application/json", "User-Agent": self.user_agent}
|
|
111
|
+
if self.api_key:
|
|
112
|
+
all_headers["X-Api-Key"] = self.api_key
|
|
113
|
+
elif self.access_token:
|
|
114
|
+
all_headers["Authorization"] = f"Bearer {self.access_token}"
|
|
115
|
+
if json_body is not None:
|
|
116
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
117
|
+
all_headers["Content-Type"] = "application/json"
|
|
118
|
+
all_headers.update(headers or {})
|
|
119
|
+
|
|
120
|
+
attempt = 0
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
with urlopen(Request(url, data=data, headers=all_headers, method=method), timeout=self.timeout) as response:
|
|
124
|
+
self._remember_rate_limit(response.headers)
|
|
125
|
+
if stream_to is not None:
|
|
126
|
+
while True:
|
|
127
|
+
chunk = response.read(1 << 16)
|
|
128
|
+
if not chunk:
|
|
129
|
+
return None
|
|
130
|
+
stream_to.write(chunk)
|
|
131
|
+
content = response.read()
|
|
132
|
+
if not content:
|
|
133
|
+
return None
|
|
134
|
+
if "json" in (response.headers.get("Content-Type") or ""):
|
|
135
|
+
return json.loads(content)
|
|
136
|
+
return content.decode("utf-8", errors="replace")
|
|
137
|
+
except HTTPError as error:
|
|
138
|
+
self._remember_rate_limit(error.headers)
|
|
139
|
+
retry_after = _retry_after(error.headers.get("Retry-After"))
|
|
140
|
+
content = error.read()
|
|
141
|
+
try:
|
|
142
|
+
body: Any = json.loads(content) if content else None
|
|
143
|
+
except ValueError:
|
|
144
|
+
body = content.decode("utf-8", errors="replace")
|
|
145
|
+
if error.code in (429, 503) and attempt < self.max_retries:
|
|
146
|
+
attempt += 1
|
|
147
|
+
time.sleep(min(retry_after if retry_after is not None else 2 ** attempt, 60))
|
|
148
|
+
continue
|
|
149
|
+
raise error_for(error.code, body, retry_after) from None
|
|
150
|
+
except URLError as error:
|
|
151
|
+
raise LapVizError(0, "Connection failed", str(error.reason)) from None
|
|
152
|
+
|
|
153
|
+
def _remember_rate_limit(self, headers: Any) -> None:
|
|
154
|
+
values = {name[len("RateLimit-"):]: headers.get(name) for name in ("RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Policy")}
|
|
155
|
+
if any(values.values()):
|
|
156
|
+
self.rate_limit = {k: v for k, v in values.items() if v is not None}
|
|
157
|
+
|
|
158
|
+
def _download(self, path: str, output: PathLike, query: Optional[Dict[str, Any]] = None) -> str:
|
|
159
|
+
output = os.fspath(output)
|
|
160
|
+
partial = output + ".part"
|
|
161
|
+
try:
|
|
162
|
+
with open(partial, "wb") as file:
|
|
163
|
+
self.request("GET", path, query=query, headers={"Accept": "*/*"}, stream_to=file)
|
|
164
|
+
os.replace(partial, output)
|
|
165
|
+
finally:
|
|
166
|
+
if os.path.exists(partial):
|
|
167
|
+
os.remove(partial)
|
|
168
|
+
return output
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _retry_after(value: Optional[str]) -> Optional[float]:
|
|
172
|
+
try:
|
|
173
|
+
return max(0.0, float(value)) if value else None
|
|
174
|
+
except ValueError:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _Resource:
|
|
179
|
+
def __init__(self, client: LapViz):
|
|
180
|
+
self._client = client
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Account(_Resource):
|
|
184
|
+
"""The authenticated account and the public driver cards."""
|
|
185
|
+
|
|
186
|
+
def me(self) -> JSON:
|
|
187
|
+
return self._client.request("GET", "api/users/me")
|
|
188
|
+
|
|
189
|
+
def driver_card(self, user_id: str) -> JSON:
|
|
190
|
+
"""Driver card of an account ("users/1-A"): someone else's only when their profile is public."""
|
|
191
|
+
return self._client.request("GET", f"api/users/info/{_segment(user_id)}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Sessions(_Resource):
|
|
195
|
+
"""Lap timing sessions: search, read, change, delete, export, telemetry and the change feed of the library."""
|
|
196
|
+
|
|
197
|
+
def search(self, *, page: int = 1, page_size: int = 20, **filters: Any) -> JSON:
|
|
198
|
+
"""One page of the sessions the caller may see (``{"sessions": [...], "totalRecords": n}``).
|
|
199
|
+
|
|
200
|
+
Filters, in snake_case: ``mine``, ``user_id``, ``user_ids``, ``circuit``, ``circuit_code``,
|
|
201
|
+
``telemetry_data_available``, ``video_available``, ``weather_temperature_min``, ``weather_temperature_max``,
|
|
202
|
+
``weather_conditions``, ``from_`` and ``to`` (datetimes).
|
|
203
|
+
"""
|
|
204
|
+
return self._client.request("POST", "api/session/search", json_body=_body(dict(filters, active_page_index=page, page_size=page_size)))
|
|
205
|
+
|
|
206
|
+
def iterate(self, *, page_size: int = 20, **filters: Any) -> Iterator[JSON]:
|
|
207
|
+
"""Every session of a search, page after page."""
|
|
208
|
+
page, seen = 1, 0
|
|
209
|
+
while True:
|
|
210
|
+
result = self.search(page=page, page_size=page_size, **filters)
|
|
211
|
+
sessions = result.get("sessions") or []
|
|
212
|
+
yield from sessions
|
|
213
|
+
seen += len(sessions)
|
|
214
|
+
if not sessions or seen >= (result.get("totalRecords") or 0):
|
|
215
|
+
return
|
|
216
|
+
page += 1
|
|
217
|
+
|
|
218
|
+
def get(self, session_id: str) -> JSON:
|
|
219
|
+
"""A session with its laps, sectors and telemetry channels."""
|
|
220
|
+
return self._client.request("GET", f"api/session/{_segment(session_id)}")
|
|
221
|
+
|
|
222
|
+
def update(self, session_id: str, **changes: Any) -> JSON:
|
|
223
|
+
"""Changes ``label``, ``description``, ``notes``, ``sharing`` (Public, Laptimes, Restricted, Private),
|
|
224
|
+
``vehicle_id`` or ``setup`` of one of the caller's sessions; the others are left as they are."""
|
|
225
|
+
return self._client.request("PATCH", f"api/session/{_segment(session_id)}", json_body=_body(changes))
|
|
226
|
+
|
|
227
|
+
def delete(self, session_id: str) -> None:
|
|
228
|
+
self._client.request("DELETE", f"api/session/{_segment(session_id)}")
|
|
229
|
+
|
|
230
|
+
def telemetry(self, session_id: str, lap: Optional[int] = None, *, generator: Optional[str] = None) -> JSON:
|
|
231
|
+
"""The laps and the samples of every channel, for the whole session or one lap (from 1)."""
|
|
232
|
+
path = f"api/session/alldata/{_segment(session_id)}" + (f"/{int(lap)}" if lap is not None else "")
|
|
233
|
+
return self._client.request("GET", path, query={"generator": generator})
|
|
234
|
+
|
|
235
|
+
def channel(self, session_id: str, lap: int, channel: str) -> List[JSON]:
|
|
236
|
+
"""The samples of one channel during one lap: ``[{"s": seconds, "d": value}, ...]``."""
|
|
237
|
+
return self._client.request("GET", f"api/session/data/{_segment(session_id)}/{int(lap)}/{_segment(channel)}") or []
|
|
238
|
+
|
|
239
|
+
def export(self, session_id: str, format: str, output: PathLike, *, generator: Optional[str] = None) -> str:
|
|
240
|
+
"""Writes the session in another format (csv, gpx, kml, srt, original...) to ``output``; returns its path."""
|
|
241
|
+
return self._client._download(f"api/session/export/{_segment(session_id)}/{_segment(format)}", output, {"generator": generator})
|
|
242
|
+
|
|
243
|
+
def download(self, session_id: str, output: PathLike) -> str:
|
|
244
|
+
"""Writes the session in the LapViz format (.lvz), every sample and timing source, to ``output``."""
|
|
245
|
+
return self._client._download(f"api/session/{_segment(session_id)}/file", output)
|
|
246
|
+
|
|
247
|
+
def vehicles(self) -> List[JSON]:
|
|
248
|
+
"""The vehicles of the caller, the primary one first: the values of ``vehicle_id``."""
|
|
249
|
+
return self._client.request("GET", "api/session/vehicles") or []
|
|
250
|
+
|
|
251
|
+
def changes(self, cursor: Optional[str] = None, page_size: int = 200) -> JSON:
|
|
252
|
+
"""One page of the change feed since ``cursor`` (None: the whole library). Keep its ``cursor`` for the next
|
|
253
|
+
call; call again while ``hasMore``."""
|
|
254
|
+
return self._client.request("GET", "api/session/changes", query={"cursor": cursor, "pageSize": page_size})
|
|
255
|
+
|
|
256
|
+
def sync(self, cursor: Optional[str] = None, page_size: int = 200) -> Tuple[List[JSON], List[JSON], Optional[str]]:
|
|
257
|
+
"""Every change since ``cursor``: (sessions created or changed, sessions deleted, the cursor to keep)."""
|
|
258
|
+
changed: List[JSON] = []
|
|
259
|
+
deleted: List[JSON] = []
|
|
260
|
+
while True:
|
|
261
|
+
page = self.changes(cursor, page_size)
|
|
262
|
+
changed.extend(page.get("sessions") or [])
|
|
263
|
+
deleted.extend(page.get("deleted") or [])
|
|
264
|
+
cursor = page.get("cursor") or cursor
|
|
265
|
+
if not page.get("hasMore"):
|
|
266
|
+
return changed, deleted, cursor
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class Imports(_Resource):
|
|
270
|
+
"""Data logger and lap timer files, read in the background: upload, then wait for the sessions."""
|
|
271
|
+
|
|
272
|
+
PENDING_STATES = ("Enqueued", "Processing")
|
|
273
|
+
|
|
274
|
+
def upload(
|
|
275
|
+
self,
|
|
276
|
+
file: Union[PathLike, bytes, BinaryIO],
|
|
277
|
+
*,
|
|
278
|
+
filename: Optional[str] = None,
|
|
279
|
+
driver: Optional[str] = None,
|
|
280
|
+
source_device: Optional[str] = None,
|
|
281
|
+
) -> JSON:
|
|
282
|
+
"""Sends a file (a path, bytes or a binary file object); returns ``{"jobId": ...}``. ``driver``: "auto"
|
|
283
|
+
(recognized from the previous imports) or the id of a driver the caller coaches; None for the caller."""
|
|
284
|
+
if isinstance(file, (bytes, bytearray)):
|
|
285
|
+
content, name = bytes(file), filename or "upload.bin"
|
|
286
|
+
elif hasattr(file, "read"):
|
|
287
|
+
content, name = file.read(), filename or os.path.basename(getattr(file, "name", "upload.bin"))
|
|
288
|
+
else:
|
|
289
|
+
with open(file, "rb") as handle:
|
|
290
|
+
content = handle.read()
|
|
291
|
+
name = filename or os.path.basename(os.fspath(file))
|
|
292
|
+
|
|
293
|
+
boundary = "lapviz-" + uuid.uuid4().hex
|
|
294
|
+
safe_name = name.replace("\\", "_").replace('"', "_").replace("\r", "").replace("\n", "")
|
|
295
|
+
data = (
|
|
296
|
+
f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{safe_name}\"\r\n"
|
|
297
|
+
"Content-Type: application/octet-stream\r\n\r\n"
|
|
298
|
+
).encode("utf-8") + content + f"\r\n--{boundary}--\r\n".encode("ascii")
|
|
299
|
+
headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"}
|
|
300
|
+
if driver:
|
|
301
|
+
headers["lv-driver"] = driver
|
|
302
|
+
if source_device:
|
|
303
|
+
headers["lv-source-device"] = source_device
|
|
304
|
+
return self._client.request("POST", "api/session/import", data=data, headers=headers)
|
|
305
|
+
|
|
306
|
+
def status(self, job_id: str) -> JSON:
|
|
307
|
+
"""State of an import: ``Enqueued`` or ``Processing``, then ``Success`` (``shareIds``) or ``Failed``."""
|
|
308
|
+
return self._client.request("GET", f"api/session/import/{_segment(job_id)}")
|
|
309
|
+
|
|
310
|
+
def wait(self, job_id: str, *, timeout: float = 300, interval: float = 2) -> JSON:
|
|
311
|
+
"""Polls the import until it ends; raises TimeoutError after ``timeout`` seconds."""
|
|
312
|
+
deadline = time.monotonic() + timeout
|
|
313
|
+
while True:
|
|
314
|
+
status = self.status(job_id)
|
|
315
|
+
if status.get("state") not in self.PENDING_STATES:
|
|
316
|
+
return status
|
|
317
|
+
if time.monotonic() + interval > deadline:
|
|
318
|
+
raise TimeoutError(f"Import {job_id} still {status.get('state')} after {timeout} s")
|
|
319
|
+
time.sleep(interval)
|
|
320
|
+
|
|
321
|
+
def drivers(self) -> List[JSON]:
|
|
322
|
+
"""The drivers the caller may import sessions for: themselves first, then the drivers they coach."""
|
|
323
|
+
return self._client.request("GET", "api/session/import/drivers") or []
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class Teams(_Resource):
|
|
327
|
+
"""Teams of the caller; the other operations are in the reference (``client.request``)."""
|
|
328
|
+
|
|
329
|
+
def list(self) -> List[JSON]:
|
|
330
|
+
return self._client.request("GET", "api/teams") or []
|
|
331
|
+
|
|
332
|
+
def get(self, team_key: str) -> JSON:
|
|
333
|
+
return self._client.request("GET", f"api/teams/{_segment(team_key)}")
|
|
334
|
+
|
|
335
|
+
def create(self, name: str, description: Optional[str] = None, roles: Optional[List[str]] = None) -> JSON:
|
|
336
|
+
return self._client.request("POST", "api/teams", json_body=_body({"name": name, "description": description, "roles": roles}))
|
|
337
|
+
|
|
338
|
+
def invitations(self) -> List[JSON]:
|
|
339
|
+
"""The teams whose admins invited the caller, waiting for an answer."""
|
|
340
|
+
return self._client.request("GET", "api/teams/invitations") or []
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class Webhooks(_Resource):
|
|
344
|
+
"""Addresses that receive the events of the account, signed (see ``lapviz.verify_signature``)."""
|
|
345
|
+
|
|
346
|
+
EVENTS = ("session.created", "session.updated", "session.deleted", "import.failed")
|
|
347
|
+
|
|
348
|
+
def list(self) -> List[JSON]:
|
|
349
|
+
return self._client.request("GET", "api/webhooks") or []
|
|
350
|
+
|
|
351
|
+
def create(self, url: str, events: Optional[List[str]] = None, description: Optional[str] = None) -> JSON:
|
|
352
|
+
"""Creates a webhook (every event by default); the answer carries its ``secret``, shown only once."""
|
|
353
|
+
return self._client.request("POST", "api/webhooks", json_body=_body({"url": url, "events": list(events or self.EVENTS), "description": description}))
|
|
354
|
+
|
|
355
|
+
def get(self, webhook_id: str) -> JSON:
|
|
356
|
+
return self._client.request("GET", f"api/webhooks/{_segment(webhook_id)}")
|
|
357
|
+
|
|
358
|
+
def update(self, webhook_id: str, **changes: Any) -> JSON:
|
|
359
|
+
"""Changes ``url``, ``description``, ``events``, or ``enabled`` (False pauses, True resumes)."""
|
|
360
|
+
return self._client.request("PATCH", f"api/webhooks/{_segment(webhook_id)}", json_body=_body(changes))
|
|
361
|
+
|
|
362
|
+
def delete(self, webhook_id: str) -> None:
|
|
363
|
+
self._client.request("DELETE", f"api/webhooks/{_segment(webhook_id)}")
|
|
364
|
+
|
|
365
|
+
def deliveries(self, webhook_id: str, take: Optional[int] = None) -> List[JSON]:
|
|
366
|
+
return self._client.request("GET", f"api/webhooks/{_segment(webhook_id)}/deliveries", query={"take": take}) or []
|
|
367
|
+
|
|
368
|
+
def ping(self, webhook_id: str) -> JSON:
|
|
369
|
+
return self._client.request("POST", f"api/webhooks/{_segment(webhook_id)}/ping")
|
|
370
|
+
|
|
371
|
+
def rotate_secret(self, webhook_id: str) -> JSON:
|
|
372
|
+
"""A new signing secret (in ``secret``); the previous one stops signing at once."""
|
|
373
|
+
return self._client.request("POST", f"api/webhooks/{_segment(webhook_id)}/secret")
|
|
374
|
+
|
|
375
|
+
def redeliver(self, delivery_id: str) -> JSON:
|
|
376
|
+
return self._client.request("POST", f"api/webhooks/deliveries/{_segment(delivery_id)}/redeliver")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class Circuits(_Resource):
|
|
380
|
+
"""The circuits of LapViz. Public: no key needed."""
|
|
381
|
+
|
|
382
|
+
def search(self, text: Optional[str] = None, *, page: int = 1, page_size: int = 20) -> JSON:
|
|
383
|
+
"""``{"circuits": [...], "totalRecords": n}``, by name, code, venue or location."""
|
|
384
|
+
return self._client.request("GET", "api/circuit/search", query={"text": text, "p": page, "s": page_size})
|
|
385
|
+
|
|
386
|
+
def nearby(self, lat: float, lon: float, radius: Optional[float] = None) -> JSON:
|
|
387
|
+
"""The 10 circuits closest to a position, nearest first."""
|
|
388
|
+
return self._client.request("GET", "api/circuit/nearby", query={"lat": lat, "lon": lon, "radius": radius})
|
lapviz/errors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Errors of the LapViz API: RFC 9457 problem details turned into exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LapVizError(Exception):
|
|
9
|
+
"""An answer of the API that is not a success (4xx, 5xx), or a server that could not be reached (status 0)."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status: int, title: str, detail: Optional[str] = None, problem: Optional[Dict[str, Any]] = None):
|
|
12
|
+
self.status = status
|
|
13
|
+
self.title = title
|
|
14
|
+
self.detail = detail
|
|
15
|
+
self.problem = problem or {}
|
|
16
|
+
super().__init__(f"{status} {title}" + (f": {detail}" if detail else ""))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthenticationError(LapVizError):
|
|
20
|
+
"""401: missing, invalid, expired or revoked key or token."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PermissionDeniedError(LapVizError):
|
|
24
|
+
"""403: forbidden, e.g. a read-only API key on an operation that changes something."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NotFoundError(LapVizError):
|
|
28
|
+
"""404: unknown, or a session the caller may not see (the API does not tell them apart)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RateLimitError(LapVizError):
|
|
32
|
+
"""429: too many requests; retry_after is the number of seconds to wait."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, status: int, title: str, detail: Optional[str] = None, problem: Optional[Dict[str, Any]] = None, retry_after: Optional[float] = None):
|
|
35
|
+
super().__init__(status, title, detail, problem)
|
|
36
|
+
self.retry_after = retry_after
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_BY_STATUS = {401: AuthenticationError, 403: PermissionDeniedError, 404: NotFoundError}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def error_for(status: int, body: Any, retry_after: Optional[float] = None) -> LapVizError:
|
|
43
|
+
"""The exception of an answer: its problem details when the body has them, else the HTTP status alone."""
|
|
44
|
+
problem = body if isinstance(body, dict) else {}
|
|
45
|
+
title = str(problem.get("title") or _REASONS.get(status, "Error"))
|
|
46
|
+
detail = problem.get("detail") or (body.strip()[:500] if isinstance(body, str) and body.strip() else None)
|
|
47
|
+
if status == 429:
|
|
48
|
+
return RateLimitError(status, title, detail, problem, retry_after)
|
|
49
|
+
return _BY_STATUS.get(status, LapVizError)(status, title, detail, problem)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_REASONS = {
|
|
53
|
+
400: "Bad Request",
|
|
54
|
+
401: "Unauthorized",
|
|
55
|
+
403: "Forbidden",
|
|
56
|
+
404: "Not Found",
|
|
57
|
+
409: "Conflict",
|
|
58
|
+
410: "Gone",
|
|
59
|
+
413: "Payload Too Large",
|
|
60
|
+
429: "Too Many Requests",
|
|
61
|
+
500: "Internal Server Error",
|
|
62
|
+
503: "Service Unavailable",
|
|
63
|
+
}
|
lapviz/py.typed
ADDED
|
File without changes
|
lapviz/webhooks.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Checks the requests LapViz posts to a webhook (https://lapviz.com/developers/docs#webhooks).
|
|
2
|
+
|
|
3
|
+
The ``LapViz-Signature`` header (``t=...,v1=...``) is the hexadecimal HMAC-SHA256 of ``{t}.{body}`` with the secret
|
|
4
|
+
of the webhook.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional, Union
|
|
13
|
+
|
|
14
|
+
SIGNATURE_HEADER = "LapViz-Signature"
|
|
15
|
+
EVENT_HEADER = "LapViz-Event"
|
|
16
|
+
DEFAULT_TOLERANCE = 300 # Seconds: older signatures are refused (replays)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compute_signature(secret: str, timestamp: int, body: Union[str, bytes]) -> str:
|
|
20
|
+
"""The hexadecimal HMAC-SHA256 of ``{timestamp}.{body}``."""
|
|
21
|
+
payload = body if isinstance(body, bytes) else body.encode("utf-8")
|
|
22
|
+
return hmac.new(secret.encode("utf-8"), str(timestamp).encode("ascii") + b"." + payload, hashlib.sha256).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify_signature(
|
|
26
|
+
secret: str,
|
|
27
|
+
signature_header: Optional[str],
|
|
28
|
+
body: Union[str, bytes],
|
|
29
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
30
|
+
now: Optional[float] = None,
|
|
31
|
+
) -> bool:
|
|
32
|
+
"""True when ``signature_header`` signs ``body`` (the raw request body, before any JSON parsing) with ``secret``
|
|
33
|
+
and is not older than ``tolerance`` seconds."""
|
|
34
|
+
if not secret or not signature_header or body is None:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
timestamp = None
|
|
38
|
+
signature = None
|
|
39
|
+
for part in signature_header.split(","):
|
|
40
|
+
name, separator, value = part.partition("=")
|
|
41
|
+
if not separator:
|
|
42
|
+
continue
|
|
43
|
+
name, value = name.strip(), value.strip()
|
|
44
|
+
if name == "t" and value.isdigit():
|
|
45
|
+
timestamp = int(value)
|
|
46
|
+
elif name == "v1":
|
|
47
|
+
signature = value
|
|
48
|
+
|
|
49
|
+
if timestamp is None or signature is None:
|
|
50
|
+
return False
|
|
51
|
+
if abs((time.time() if now is None else now) - timestamp) > tolerance:
|
|
52
|
+
return False
|
|
53
|
+
return hmac.compare_digest(compute_signature(secret, timestamp, body), signature)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lapviz
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client of the LapViz API: lap timing sessions, telemetry, imports, webhooks and circuits.
|
|
5
|
+
Project-URL: Homepage, https://lapviz.com/developers
|
|
6
|
+
Project-URL: Documentation, https://lapviz.com/developers/docs
|
|
7
|
+
Project-URL: Reference, https://lapviz.com/swagger
|
|
8
|
+
Project-URL: Changelog, https://lapviz.com/developers/docs#changelog
|
|
9
|
+
Author-email: Mengal SRL <support@lapviz.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,karting,lap timing,lapviz,motorsport,telemetry
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# LapViz for Python
|
|
23
|
+
|
|
24
|
+
The official Python client of the [LapViz API](https://lapviz.com/developers): lap timing sessions, telemetry, file
|
|
25
|
+
imports, the change feed of a library, webhooks and circuits. Standard library only, Python 3.10+, MIT licensed.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install lapviz
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
Create a personal API key in [Account › API keys](https://lapviz.com/Identity/Account/Manage/ApiKeys) and put it in
|
|
34
|
+
the `LAPVIZ_API_KEY` environment variable (never in your code).
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from lapviz import LapViz
|
|
38
|
+
|
|
39
|
+
lapviz = LapViz() # reads LAPVIZ_API_KEY; or LapViz(api_key="lvz_...")
|
|
40
|
+
print(lapviz.me()["username"])
|
|
41
|
+
|
|
42
|
+
for session in lapviz.sessions.iterate(mine=True):
|
|
43
|
+
print(session["shareId"], session["start"], session["best"])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Responses are the JSON of the API, as dicts with the camelCase names of the
|
|
47
|
+
[reference](https://lapviz.com/swagger): new fields appear without a new version of this package.
|
|
48
|
+
|
|
49
|
+
## Telemetry
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
session = lapviz.sessions.get("7e2b5e")
|
|
53
|
+
speed = lapviz.sessions.channel("7e2b5e", lap=3, channel="GPS Speed") # [{"s": seconds, "d": value}, ...]
|
|
54
|
+
everything = lapviz.sessions.telemetry("7e2b5e", lap=3) # laps and every channel
|
|
55
|
+
lapviz.sessions.export("7e2b5e", "csv", "session.csv")
|
|
56
|
+
lapviz.sessions.download("7e2b5e", "session.lvz") # LapViz format, every sample
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Reading public sessions (like the demo sessions of the guides) works without a key; exports need one.
|
|
60
|
+
|
|
61
|
+
## Import a file
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
job = lapviz.imports.upload("run.xrk") # AiM, Alfano, Unipro, RaceBox, RaceChrono, MyChron...
|
|
65
|
+
status = lapviz.imports.wait(job["jobId"]) # polls until Success or Failed
|
|
66
|
+
print(status["state"], status.get("shareIds"))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Keep a copy of a library
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
changed, deleted, cursor = lapviz.sessions.sync(saved_cursor) # None the first time: the whole library
|
|
73
|
+
save(cursor)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Webhooks
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
hook = lapviz.webhooks.create("https://example.com/lapviz", events=["session.created"])
|
|
80
|
+
secret = hook["secret"] # shown once: store it
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
In your receiver, check the signature on the raw body before parsing it:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from lapviz import verify_signature, SIGNATURE_HEADER
|
|
87
|
+
|
|
88
|
+
if not verify_signature(secret, request.headers.get(SIGNATURE_HEADER), request.get_data()):
|
|
89
|
+
abort(400)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Errors and limits
|
|
93
|
+
|
|
94
|
+
Errors raise `LapVizError` (with `status`, `title`, `detail` and the `problem` details), or one of its subclasses
|
|
95
|
+
`AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404) and `RateLimitError` (429, with
|
|
96
|
+
`retry_after`). The client waits and retries twice on 429 and 503; `lapviz.rate_limit` holds the RateLimit headers of
|
|
97
|
+
the last response.
|
|
98
|
+
|
|
99
|
+
## Anything else
|
|
100
|
+
|
|
101
|
+
Every operation of the reference is one call away:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
lapviz.request("PUT", f"api/teams/{key}/share", json_body={"share": True})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
OAuth applications pass `LapViz(access_token="lvzat_...")` instead of a key. Guides:
|
|
108
|
+
[lapviz.com/developers/docs](https://lapviz.com/developers/docs).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
lapviz/__init__.py,sha256=xYlR_yALtIsUQ8XwbnOYLdMB_dfyxPiJyR7lvRGP3Us,626
|
|
2
|
+
lapviz/client.py,sha256=6WijOr-Kpw_idHkv-_Us7meA_58hsvcCUTwZT8nFKFw,18282
|
|
3
|
+
lapviz/errors.py,sha256=qoBlRnhTq7uDjNP__EOk8OPU-BsKASkkTLY-_zBvKHw,2355
|
|
4
|
+
lapviz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
lapviz/webhooks.py,sha256=3ZkNQGyhnCRa5qlAP7BfdOrHev5Yz-B4OvNobFxjkBY,1899
|
|
6
|
+
lapviz-1.0.0.dist-info/METADATA,sha256=ND_J70fqob4fo3ZxY0qhon3P0lzHUkljNXmbPlUDH6A,3903
|
|
7
|
+
lapviz-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
lapviz-1.0.0.dist-info/licenses/LICENSE,sha256=SQx-xggAndwhldn8Ai7rEj1HsquPE2gWdbVitzLJvJ8,1067
|
|
9
|
+
lapviz-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mengal SRL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|