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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.