alpr-sdk 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.
alpr/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ # Code generated by sdk/generator. DO NOT EDIT.
2
+ """Alpr.sh's SDK for Python, generated from Alpr.sh's API contract."""
3
+
4
+ from ._client import Alpr, Transport, urllib_transport
5
+ from ._errors import AlprError
6
+ from ._pagination import Page
7
+ from ._services import CamerasService, PhotosService, RecognitionsService
8
+ from ._version import API_VERSION, DEFAULT_BASE_URL, VERSION
9
+ from .models import Camera, DetectedPlate, DetectionImageLink, PhotoDetection, PhotoRecognition, PhotosDetectParams, PhotosOCRParams, PhotosRecognizeParams, PlateReading, ReadingArea, ReadingAreaBox, Recognition, RecognizedPlate, WebhookAlertTriggered, WebhookRecognitionCreated, WebhookTest
10
+ from .webhooks import (
11
+ WEBHOOK_TOLERANCE_SECONDS,
12
+ AlertTriggeredWebhookEvent,
13
+ RecognitionCreatedWebhookEvent,
14
+ TestWebhookEvent,
15
+ HeadersLike,
16
+ UnknownWebhookEvent,
17
+ WebhookEvent,
18
+ WebhookVerificationError,
19
+ verify_webhook,
20
+ verify_webhook_signature,
21
+ )
22
+
23
+ __all__ = [
24
+ "API_VERSION",
25
+ "DEFAULT_BASE_URL",
26
+ "VERSION",
27
+ "WEBHOOK_TOLERANCE_SECONDS",
28
+ "Alpr",
29
+ "AlprError",
30
+ "HeadersLike",
31
+ "Page",
32
+ "Transport",
33
+ "UnknownWebhookEvent",
34
+ "WebhookEvent",
35
+ "WebhookVerificationError",
36
+ "urllib_transport",
37
+ "verify_webhook",
38
+ "verify_webhook_signature",
39
+ "AlertTriggeredWebhookEvent",
40
+ "RecognitionCreatedWebhookEvent",
41
+ "TestWebhookEvent",
42
+ "CamerasService",
43
+ "PhotosService",
44
+ "RecognitionsService",
45
+ "Camera",
46
+ "DetectedPlate",
47
+ "DetectionImageLink",
48
+ "PhotoDetection",
49
+ "PhotoRecognition",
50
+ "PhotosDetectParams",
51
+ "PhotosOCRParams",
52
+ "PhotosRecognizeParams",
53
+ "PlateReading",
54
+ "ReadingArea",
55
+ "ReadingAreaBox",
56
+ "Recognition",
57
+ "RecognizedPlate",
58
+ "WebhookAlertTriggered",
59
+ "WebhookRecognitionCreated",
60
+ "WebhookTest",
61
+ ]
alpr/_client.py ADDED
@@ -0,0 +1,107 @@
1
+ # Code generated by sdk/generator. DO NOT EDIT.
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import urllib.error
8
+ import urllib.request
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+ from urllib.parse import urlencode, urlsplit
12
+
13
+ from ._errors import AlprError
14
+ from ._services import CamerasService, PhotosService, RecognitionsService
15
+ from ._upload import Form
16
+ from ._version import DEFAULT_BASE_URL, VERSION
17
+
18
+ Transport = Callable[[str, str, dict[str, str], "bytes | None", float], "tuple[int, dict[str, str], bytes]"]
19
+ """Sends one request: (method, url, headers, body, timeout) -> (status, headers in lower case, body)."""
20
+
21
+
22
+ class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
23
+ """The API never redirects, and a redirect would carry the key to wherever it points."""
24
+
25
+ def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> Any:
26
+ raise AlprError(code, "redirect_refused", "the server answered a redirect; the SDK does not follow redirects, so the API key is never sent elsewhere")
27
+
28
+
29
+ _OPENER = urllib.request.build_opener(_RefuseRedirects)
30
+
31
+
32
+ def urllib_transport(method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: float) -> tuple[int, dict[str, str], bytes]:
33
+ """The standard library's HTTP, so the SDK depends on nothing. It refuses redirects."""
34
+ request = urllib.request.Request(url, data=body, headers=headers, method=method)
35
+ try:
36
+ with _OPENER.open(request, timeout=timeout) as response:
37
+ return response.status, {key.lower(): value for key, value in response.headers.items()}, response.read()
38
+ except urllib.error.HTTPError as error:
39
+ with error:
40
+ return error.code, {key.lower(): value for key, value in error.headers.items()}, error.read()
41
+
42
+
43
+ def _check_base_url(base_url: str) -> None:
44
+ """Raises ValueError unless base_url is https, or http to localhost, 127.0.0.0/8 or ::1."""
45
+ try:
46
+ parts = urlsplit(base_url)
47
+ host = (parts.hostname or "").lower()
48
+ except ValueError:
49
+ raise ValueError("alpr: the base URL must be https (http is accepted only for localhost, 127.0.0.0/8 and ::1), so the API key is never sent in clear text") from None
50
+ try:
51
+ loopback = host == "localhost" or ipaddress.ip_address(host).is_loopback
52
+ except ValueError:
53
+ loopback = False
54
+ secure = parts.scheme.lower() == "https" or (parts.scheme.lower() == "http" and loopback)
55
+ if not host or not secure:
56
+ raise ValueError("alpr: the base URL must be https (http is accepted only for localhost, 127.0.0.0/8 and ::1), so the API key is never sent in clear text")
57
+
58
+
59
+ class Alpr:
60
+ """Alpr.sh's API with one workspace's API key.
61
+
62
+ The key (``alpr_live_…``) says the workspace and the scopes the calls may use.
63
+
64
+ alpr = Alpr(os.environ["ALPR_API_KEY"])
65
+ result = alpr.photos.recognize({"image": photo})
66
+ for reading in alpr.recognitions.list_iter(plate="BRA"):
67
+ ...
68
+ """
69
+
70
+ def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0, transport: Transport | None = None) -> None:
71
+ self._api_key = api_key
72
+ self._base_url = base_url.rstrip("/")
73
+ _check_base_url(self._base_url)
74
+ self._timeout = timeout
75
+ self._transport = transport or urllib_transport
76
+ self.cameras = CamerasService(self)
77
+ self.photos = PhotosService(self)
78
+ self.recognitions = RecognitionsService(self)
79
+
80
+ def request(
81
+ self,
82
+ method: str,
83
+ path: str,
84
+ *,
85
+ query: dict[str, Any] | None = None,
86
+ form: Form | None = None,
87
+ api_key: str | None = None,
88
+ ) -> Any:
89
+ """Calls the API and answers its decoded JSON body (None for a 204); a refusal raises AlprError."""
90
+ url = self._base_url + path
91
+ values = {name: str(value) for name, value in (query or {}).items() if value is not None and value != ""}
92
+ if values:
93
+ url += "?" + urlencode(values)
94
+ headers = {
95
+ "Authorization": f"Bearer {api_key or self._api_key}",
96
+ "Accept": "application/json",
97
+ "User-Agent": f"alpr-python/{VERSION}",
98
+ }
99
+ data = None
100
+ if form is not None:
101
+ data, headers["Content-Type"] = form.encode()
102
+ status, response_headers, raw = self._transport(method, url, headers, data, self._timeout)
103
+ if status >= 300:
104
+ raise AlprError.from_response(status, response_headers, raw)
105
+ if status == 204 or not raw:
106
+ return None
107
+ return json.loads(raw)
alpr/_core.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol
4
+ from urllib.parse import quote
5
+
6
+ from ._upload import Form
7
+
8
+
9
+ class Requester(Protocol):
10
+ """What a service calls the API through."""
11
+
12
+ def request(
13
+ self,
14
+ method: str,
15
+ path: str,
16
+ *,
17
+ query: dict[str, Any] | None = None,
18
+ form: Form | None = None,
19
+ api_key: str | None = None,
20
+ ) -> Any: ...
21
+
22
+
23
+ def segment(value: str) -> str:
24
+ """A path parameter, escaped."""
25
+ return quote(value, safe="")
alpr/_errors.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+
8
+ class AlprError(Exception):
9
+ """A request the API refused.
10
+
11
+ ``code`` is the API's: invalid_api_key, insufficient_scope,
12
+ insufficient_credits, missing image, invalid_image, invalid_option,
13
+ image_too_large, no_plate, not_found, image_unavailable, rate_limited,
14
+ engine_unavailable or storage_unavailable. ``fields`` says, when the API
15
+ names them, what is wrong with each field. ``retry_after`` is how many
16
+ seconds to wait on a 429.
17
+ """
18
+
19
+ def __init__(self, status: int, code: str, message: str = "", fields: dict[str, list[str]] | None = None, retry_after: int | None = None) -> None:
20
+ super().__init__(f"alpr: {status} {code}: {message}" if message else f"alpr: {status} {code}")
21
+ self.status = status
22
+ self.code = code
23
+ self.message = message
24
+ self.fields = fields or {}
25
+ self.retry_after = retry_after
26
+
27
+ @classmethod
28
+ def from_response(cls, status: int, headers: Mapping[str, str], body: bytes) -> AlprError:
29
+ data: dict[str, Any] = {}
30
+ try:
31
+ loaded = json.loads(body or b"{}")
32
+ if isinstance(loaded, dict):
33
+ data = loaded
34
+ except ValueError:
35
+ pass # A refusal without a JSON body is still a refusal.
36
+ code, message, fields = data.get("error"), data.get("message"), data.get("fields")
37
+ retry_after = headers.get("retry-after", "").strip()
38
+ return cls(
39
+ status,
40
+ code if isinstance(code, str) else "",
41
+ message if isinstance(message, str) else "",
42
+ fields if isinstance(fields, dict) else None,
43
+ int(retry_after) if retry_after.isascii() and retry_after.isdigit() and len(retry_after) < 10 else None,
44
+ )
alpr/_pagination.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterator
4
+ from dataclasses import dataclass
5
+ from typing import Any, Generic, TypeVar
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Page(Generic[T]):
12
+ """One page of a list: its items, and the cursor of the next page."""
13
+
14
+ items: list[T]
15
+ next: str | None
16
+ """The cursor to ask the next page with; None on the last page."""
17
+
18
+ @classmethod
19
+ def from_dict(cls, data: dict[str, Any], item: Callable[[dict[str, Any]], T], next_field: str) -> Page[T]:
20
+ cursor = data.get(next_field)
21
+ return cls(items=[item(each) for each in data.get("data") or []], next=cursor if isinstance(cursor, str) and cursor else None)
22
+
23
+
24
+ def iterate(fetch: Callable[[str | None], Page[T]], first: str | None = None) -> Iterator[T]:
25
+ """Walks pages from the cursor ``first`` (the newest page when None) until the last one,
26
+ yielding every item. It ends at the first error, which it raises, and when the next cursor
27
+ is the one just used: a cursor that does not move would ask for the same page forever."""
28
+ cursor = first
29
+ while True:
30
+ result = fetch(cursor)
31
+ yield from result.items
32
+ if not result.next or result.next == cursor:
33
+ return
34
+ cursor = result.next
alpr/_services.py ADDED
@@ -0,0 +1,88 @@
1
+ # Code generated by sdk/generator. DO NOT EDIT.
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ from collections.abc import Iterator
7
+
8
+ from . import models
9
+ from ._core import Requester, segment
10
+ from ._pagination import Page, iterate
11
+ from ._upload import Form
12
+
13
+
14
+ class CamerasService:
15
+ """The API's cameras."""
16
+
17
+ def __init__(self, client: Requester) -> None:
18
+ self._client = client
19
+
20
+ def list(self, *, api_key: str | None = None) -> builtins.list[models.Camera]:
21
+ """Listar as câmeras."""
22
+ data = self._client.request("GET", "/api/v1/cameras", api_key=api_key)
23
+ return [models.Camera.from_dict(item) for item in data.get("data") or []]
24
+
25
+
26
+ class PhotosService:
27
+ """The API's photos."""
28
+
29
+ def __init__(self, client: Requester) -> None:
30
+ self._client = client
31
+
32
+ def detect(self, params: models.PhotosDetectParams, *, api_key: str | None = None) -> models.PhotoDetection:
33
+ """Localizar as placas de uma foto."""
34
+ form = Form()
35
+ form.file("image", params["image"])
36
+ if params.get("image_mode") is not None:
37
+ form.text("image_mode", params["image_mode"])
38
+ if params.get("lpr_confidence") is not None:
39
+ form.text("lpr_confidence", params["lpr_confidence"])
40
+ if params.get("min_ocr_confidence") is not None:
41
+ form.text("min_ocr_confidence", params["min_ocr_confidence"])
42
+ data = self._client.request("POST", "/api/v1/detect", form=form, api_key=api_key)
43
+ return models.PhotoDetection.from_dict(data)
44
+
45
+ def ocr(self, params: models.PhotosOCRParams, *, api_key: str | None = None) -> models.PlateReading:
46
+ """Ler uma placa já recortada."""
47
+ form = Form()
48
+ form.file("image", params["image"])
49
+ data = self._client.request("POST", "/api/v1/ocr", form=form, api_key=api_key)
50
+ return models.PlateReading.from_dict(data)
51
+
52
+ def recognize(self, params: models.PhotosRecognizeParams, *, api_key: str | None = None) -> models.PhotoRecognition:
53
+ """Ler as placas de uma foto."""
54
+ form = Form()
55
+ form.file("image", params["image"])
56
+ if params.get("image_mode") is not None:
57
+ form.text("image_mode", params["image_mode"])
58
+ if params.get("lpr_confidence") is not None:
59
+ form.text("lpr_confidence", params["lpr_confidence"])
60
+ if params.get("min_ocr_confidence") is not None:
61
+ form.text("min_ocr_confidence", params["min_ocr_confidence"])
62
+ data = self._client.request("POST", "/api/v1/recognize", form=form, api_key=api_key)
63
+ return models.PhotoRecognition.from_dict(data)
64
+
65
+
66
+ class RecognitionsService:
67
+ """The API's recognitions."""
68
+
69
+ def __init__(self, client: Requester) -> None:
70
+ self._client = client
71
+
72
+ def image(self, id: str, *, api_key: str | None = None) -> models.DetectionImageLink:
73
+ """Imagem de uma leitura."""
74
+ data = self._client.request("GET", "/api/v1/detections/" + segment(id) + "/image", api_key=api_key)
75
+ return models.DetectionImageLink.from_dict(data)
76
+
77
+ def list(self, *, before: str | None = None, plate: str | None = None, camera: str | None = None, source: str | None = None, api_key: str | None = None) -> Page[models.Recognition]:
78
+ """Listar as leituras. One page; ``list_iter`` walks every page.
79
+
80
+ ``before`` is the cursor the previous page answered as ``next``; None asks for the newest page.
81
+ """
82
+ data = self._client.request("GET", "/api/v1/recognitions", query={"before": before, "plate": plate, "camera": camera, "source": source}, api_key=api_key)
83
+ return Page.from_dict(data, models.Recognition.from_dict, "next")
84
+
85
+ def list_iter(self, *, before: str | None = None, plate: str | None = None, camera: str | None = None, source: str | None = None, api_key: str | None = None) -> Iterator[models.Recognition]:
86
+ """Walks every page of recognitions:list, item by item, from ``before`` (the newest when None)."""
87
+ return iterate(lambda cursor: self.list(before=cursor, plate=plate, camera=camera, source=source, api_key=api_key), before)
88
+
alpr/_upload.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+
5
+
6
+ def image_type(data: bytes) -> tuple[str, str]:
7
+ """The content type and file name of a photo: JPEG and PNG are what the API reads,
8
+ and anything else goes as bytes for the API to refuse."""
9
+ if data.startswith(b"\xff\xd8\xff"):
10
+ return "image/jpeg", "photo.jpg"
11
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
12
+ return "image/png", "photo.png"
13
+ return "application/octet-stream", "photo"
14
+
15
+
16
+ def field_text(value: str | int | float | bool) -> str:
17
+ """A value as the text of a multipart field: floats in the shortest decimal form (0.5, 1)."""
18
+ if isinstance(value, bool):
19
+ return "true" if value else "false"
20
+ if isinstance(value, float):
21
+ text = repr(value)
22
+ return text[:-2] if text.endswith(".0") else text
23
+ return str(value)
24
+
25
+
26
+ def _escape(name: str) -> str:
27
+ return name.replace("\\", "\\\\").replace('"', '\\"').replace("\r", "%0D").replace("\n", "%0A")
28
+
29
+
30
+ class Form:
31
+ """A multipart/form-data body: one file and the text fields beside it."""
32
+
33
+ def __init__(self) -> None:
34
+ self._parts: list[tuple[str, bytes | None, str]] = []
35
+
36
+ def text(self, name: str, value: str | int | float | bool) -> None:
37
+ """Adds a text field."""
38
+ self._parts.append((name, None, field_text(value)))
39
+
40
+ def file(self, name: str, data: bytes) -> None:
41
+ """Adds the photo, named and typed by what its first bytes say it is."""
42
+ self._parts.append((name, bytes(data), ""))
43
+
44
+ def encode(self) -> tuple[bytes, str]:
45
+ """The body and its Content-Type, boundary included."""
46
+ boundary = secrets.token_hex(24)
47
+ out = bytearray()
48
+ for name, data, value in self._parts:
49
+ out += f"--{boundary}\r\n".encode()
50
+ if data is None:
51
+ out += f'Content-Disposition: form-data; name="{_escape(name)}"\r\n\r\n'.encode() + value.encode() + b"\r\n"
52
+ continue
53
+ kind, filename = image_type(data)
54
+ out += f'Content-Disposition: form-data; name="{_escape(name)}"; filename="{filename}"\r\nContent-Type: {kind}\r\n\r\n'.encode()
55
+ out += data + b"\r\n"
56
+ out += f"--{boundary}--\r\n".encode()
57
+ return bytes(out), f"multipart/form-data; boundary={boundary}"
alpr/_version.py ADDED
@@ -0,0 +1,10 @@
1
+ # Code generated by sdk/generator. DO NOT EDIT.
2
+
3
+ VERSION = "0.1.0"
4
+ """This SDK's version."""
5
+
6
+ API_VERSION = "1.0.0"
7
+ """The contract this SDK was generated from."""
8
+
9
+ DEFAULT_BASE_URL = "https://alpr.sh"
10
+ """Alpr.sh's API. The workspace is the API key's: alpr_live_…."""
alpr/models.py ADDED
@@ -0,0 +1,388 @@
1
+ # Code generated by sdk/generator. DO NOT EDIT.
2
+ """The resources Alpr.sh's API answers and the bodies its writes take."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal, NotRequired, Self, TypedDict
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Camera:
12
+ """Camera is a resource of Alpr.sh's API."""
13
+
14
+ created_at: str
15
+ enabled: bool
16
+ id: str
17
+ location: str
18
+ """O endereço da câmera como texto; vazio se não foi informado."""
19
+ name: str
20
+ protocol: Literal["rtsp", "rtmp", "hls", "http", "hikconnect", "hikcloud", "rtmppush"]
21
+ """Como a câmera é lida. Nunca traz o endereço do stream, usuário nem senha."""
22
+ reading_area: ReadingArea | None
23
+ """Nulo quando a câmera lê a imagem inteira."""
24
+
25
+ @classmethod
26
+ def from_dict(cls, data: dict[str, Any]) -> Self:
27
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
28
+ return cls(
29
+ created_at=data["createdAt"],
30
+ enabled=data["enabled"],
31
+ id=data["id"],
32
+ location=data["location"],
33
+ name=data["name"],
34
+ protocol=data["protocol"],
35
+ reading_area=None if data.get("readingArea") is None else ReadingArea.from_dict(data["readingArea"]),
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class DetectedPlate:
41
+ """DetectedPlate is a resource of Alpr.sh's API."""
42
+
43
+ conf: float
44
+ """Confiança do detector, de 0 a 100, com uma casa."""
45
+ coordinate_bottom: int
46
+ coordinate_left: int
47
+ coordinate_right: int
48
+ coordinate_top: int
49
+ created_at: str
50
+ plate_type: Literal["car"]
51
+ time: str
52
+
53
+ @classmethod
54
+ def from_dict(cls, data: dict[str, Any]) -> Self:
55
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
56
+ return cls(
57
+ conf=float(data["conf"]),
58
+ coordinate_bottom=data["coordinateBottom"],
59
+ coordinate_left=data["coordinateLeft"],
60
+ coordinate_right=data["coordinateRight"],
61
+ coordinate_top=data["coordinateTop"],
62
+ created_at=data["createdAt"],
63
+ plate_type=data["plateType"],
64
+ time=data["time"],
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class DetectionImageLink:
70
+ """DetectionImageLink is a resource of Alpr.sh's API."""
71
+
72
+ detection_id: str
73
+ expires_in: int
74
+ """Segundos de validade do link (300)."""
75
+ image_url: str
76
+ """Endereço absoluto de GET /api/v1/detections/{id}/image/file, assinado; abra-o como veio."""
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: dict[str, Any]) -> Self:
80
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
81
+ return cls(
82
+ detection_id=data["detectionId"],
83
+ expires_in=data["expiresIn"],
84
+ image_url=data["imageUrl"],
85
+ )
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class PhotoDetection:
90
+ """PhotoDetection is a resource of Alpr.sh's API."""
91
+
92
+ image_height: int
93
+ image_width: int
94
+ plates: list[DetectedPlate]
95
+ """Onde há placa, sem o texto; o detector não filtra pelo formato brasileiro. Sem placa, []."""
96
+ processing_time_ms: int
97
+
98
+ @classmethod
99
+ def from_dict(cls, data: dict[str, Any]) -> Self:
100
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
101
+ return cls(
102
+ image_height=data["imageHeight"],
103
+ image_width=data["imageWidth"],
104
+ plates=[DetectedPlate.from_dict(item0) for item0 in data["plates"]],
105
+ processing_time_ms=data["processingTimeMs"],
106
+ )
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class PhotoRecognition:
111
+ """PhotoRecognition is a resource of Alpr.sh's API."""
112
+
113
+ credits_remaining: int
114
+ """Créditos que sobraram depois desta leitura."""
115
+ image_height: int
116
+ image_width: int
117
+ plates: list[RecognizedPlate]
118
+ """Só as placas válidas no Brasil (ABC1234 ou ABC1D23); sem placa, []."""
119
+ processing_time_ms: int
120
+
121
+ @classmethod
122
+ def from_dict(cls, data: dict[str, Any]) -> Self:
123
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
124
+ return cls(
125
+ credits_remaining=data["creditsRemaining"],
126
+ image_height=data["imageHeight"],
127
+ image_width=data["imageWidth"],
128
+ plates=[RecognizedPlate.from_dict(item0) for item0 in data["plates"]],
129
+ processing_time_ms=data["processingTimeMs"],
130
+ )
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class PlateReading:
135
+ """PlateReading is a resource of Alpr.sh's API."""
136
+
137
+ confidence: float
138
+ """Confiança da leitura, de 0 a 1 com quatro casas (nas outras rotas conf vai de 0 a 100)."""
139
+ credits_remaining: int
140
+ """Créditos que sobraram depois desta leitura."""
141
+ detection_id: str | None
142
+ """Hoje sempre nulo: a leitura de uma placa recortada não é guardada."""
143
+ latency_ms: int
144
+ plate: str
145
+ """O texto lido, sem hífen, maiúsculo; não é validado pelo formato brasileiro."""
146
+
147
+ @classmethod
148
+ def from_dict(cls, data: dict[str, Any]) -> Self:
149
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
150
+ return cls(
151
+ confidence=float(data["confidence"]),
152
+ credits_remaining=data["creditsRemaining"],
153
+ detection_id=data.get("detectionId"),
154
+ latency_ms=data["latencyMs"],
155
+ plate=data["plate"],
156
+ )
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class ReadingArea:
161
+ """ReadingArea is a resource of Alpr.sh's API."""
162
+
163
+ box: ReadingAreaBox
164
+ """A caixa envolvente, em frações da imagem (0 a 1); é o que o motor lê."""
165
+ points: list[list[float]]
166
+ """Os cantos do polígono, de 3 a 16 pares [x, y] em frações da imagem; um retângulo traz os quatro cantos. Placas fora dele são ignoradas."""
167
+
168
+ @classmethod
169
+ def from_dict(cls, data: dict[str, Any]) -> Self:
170
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
171
+ return cls(
172
+ box=ReadingAreaBox.from_dict(data["box"]),
173
+ points=[[float(item1) for item1 in item0] for item0 in data["points"]],
174
+ )
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class ReadingAreaBox:
179
+ """A caixa envolvente, em frações da imagem (0 a 1); é o que o motor lê."""
180
+
181
+ height: float
182
+ width: float
183
+ x: float
184
+ y: float
185
+
186
+ @classmethod
187
+ def from_dict(cls, data: dict[str, Any]) -> Self:
188
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
189
+ return cls(
190
+ height=float(data["height"]),
191
+ width=float(data["width"]),
192
+ x=float(data["x"]),
193
+ y=float(data["y"]),
194
+ )
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class Recognition:
199
+ """Recognition is a resource of Alpr.sh's API."""
200
+
201
+ camera_id: str | None
202
+ """Nulo numa leitura feita pela API por foto."""
203
+ captured_at: str
204
+ """Com o deslocamento do fuso do Alpr.sh."""
205
+ conf: float
206
+ """De 0 a 100, com uma casa."""
207
+ id: str
208
+ plate: str
209
+ source: Literal["camera", "api"]
210
+ """camera: lida por uma câmera; api: enviada por foto."""
211
+
212
+ @classmethod
213
+ def from_dict(cls, data: dict[str, Any]) -> Self:
214
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
215
+ return cls(
216
+ camera_id=data.get("cameraId"),
217
+ captured_at=data["capturedAt"],
218
+ conf=float(data["conf"]),
219
+ id=data["id"],
220
+ plate=data["plate"],
221
+ source=data["source"],
222
+ )
223
+
224
+
225
+ @dataclass(frozen=True)
226
+ class RecognizedPlate:
227
+ """RecognizedPlate is a resource of Alpr.sh's API."""
228
+
229
+ char_confs: str
230
+ """Confiança de cada caractere, de 0 a 1 com duas casas, separadas por ponto e vírgula (\"0.99;0.98\")."""
231
+ conf: float
232
+ """Confiança da placa, de 0 a 100, com uma casa."""
233
+ coordinate_bottom: int
234
+ """Pixels da foto enviada."""
235
+ coordinate_left: int
236
+ coordinate_right: int
237
+ coordinate_top: int
238
+ created_at: str
239
+ """Igual a time."""
240
+ plate: str
241
+ """Sete caracteres, maiúsculos: ABC1234 ou ABC1D23 (Mercosul)."""
242
+ plate_type: Literal["car"]
243
+ """Sempre car; a API não informa país nem formato."""
244
+ time: str
245
+ """Instante da leitura, com o deslocamento do fuso do Alpr.sh."""
246
+
247
+ @classmethod
248
+ def from_dict(cls, data: dict[str, Any]) -> Self:
249
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
250
+ return cls(
251
+ char_confs=data["charConfs"],
252
+ conf=float(data["conf"]),
253
+ coordinate_bottom=data["coordinateBottom"],
254
+ coordinate_left=data["coordinateLeft"],
255
+ coordinate_right=data["coordinateRight"],
256
+ coordinate_top=data["coordinateTop"],
257
+ created_at=data["createdAt"],
258
+ plate=data["plate"],
259
+ plate_type=data["plateType"],
260
+ time=data["time"],
261
+ )
262
+
263
+
264
+ @dataclass(frozen=True)
265
+ class WebhookAlertTriggered:
266
+ """WebhookAlertTriggered is a resource of Alpr.sh's API."""
267
+
268
+ camera_id: str | None
269
+ """Ausente se a leitura não veio de uma câmera."""
270
+ captured_at: str
271
+ confidence: float
272
+ """De 0 a 1."""
273
+ event: Literal["alert.triggered"]
274
+ id: str
275
+ """O id do disparo."""
276
+ plate: str
277
+ recognition_id: str
278
+ """A leitura que disparou o alerta."""
279
+ rule_id: str
280
+ rule_name: str
281
+ skip: list[str] | None
282
+ """Os destinos que as listas de permitidos e de bloqueados excluíram deste disparo (internal, channel:<id>, partner:<id>). Presente só quando algum foi excluído; é informação, não há o que fazer com ela."""
283
+ tenant_id: str
284
+
285
+ @classmethod
286
+ def from_dict(cls, data: dict[str, Any]) -> Self:
287
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
288
+ return cls(
289
+ camera_id=data.get("camera_id"),
290
+ captured_at=data["captured_at"],
291
+ confidence=float(data["confidence"]),
292
+ event=data["event"],
293
+ id=data["id"],
294
+ plate=data["plate"],
295
+ recognition_id=data["recognition_id"],
296
+ rule_id=data["rule_id"],
297
+ rule_name=data["rule_name"],
298
+ skip=data.get("skip"),
299
+ tenant_id=data["tenant_id"],
300
+ )
301
+
302
+
303
+ @dataclass(frozen=True)
304
+ class WebhookRecognitionCreated:
305
+ """WebhookRecognitionCreated is a resource of Alpr.sh's API."""
306
+
307
+ camera_id: str | None
308
+ """Ausente numa leitura enviada por foto."""
309
+ captured_at: str
310
+ """UTC, com fração de segundo."""
311
+ confidence: float
312
+ """De 0 a 1 (na API de chave, conf vai de 0 a 100)."""
313
+ event: Literal["recognition.created"]
314
+ id: str
315
+ """O id da leitura; é o mesmo id de GET /api/v1/recognitions e de GET /api/v1/detections/{id}/image."""
316
+ plate: str
317
+ source: Literal["camera", "api"]
318
+ """camera: lida por uma câmera; api: enviada por foto."""
319
+ tenant_id: str
320
+ """O espaço de trabalho dono da leitura."""
321
+
322
+ @classmethod
323
+ def from_dict(cls, data: dict[str, Any]) -> Self:
324
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
325
+ return cls(
326
+ camera_id=data.get("camera_id"),
327
+ captured_at=data["captured_at"],
328
+ confidence=float(data["confidence"]),
329
+ event=data["event"],
330
+ id=data["id"],
331
+ plate=data["plate"],
332
+ source=data["source"],
333
+ tenant_id=data["tenant_id"],
334
+ )
335
+
336
+
337
+ @dataclass(frozen=True)
338
+ class WebhookTest:
339
+ """WebhookTest is a resource of Alpr.sh's API."""
340
+
341
+ event: Literal["webhook.test"]
342
+ id: str
343
+ sent_at: str
344
+ tenant_id: str
345
+
346
+ @classmethod
347
+ def from_dict(cls, data: dict[str, Any]) -> Self:
348
+ """Reads the object the API answered; the JSON names are the API's, the attributes snake_case."""
349
+ return cls(
350
+ event=data["event"],
351
+ id=data["id"],
352
+ sent_at=data["sent_at"],
353
+ tenant_id=data["tenant_id"],
354
+ )
355
+
356
+
357
+ class PhotosDetectParams(TypedDict):
358
+ """The body of photos:detect."""
359
+
360
+ image: bytes
361
+ """A foto, JPEG ou PNG."""
362
+ image_mode: NotRequired[Literal["NONE"]]
363
+ """Só NONE é aceito."""
364
+ lpr_confidence: NotRequired[float]
365
+ """Piso de confiança do detector de placas."""
366
+ min_ocr_confidence: NotRequired[float]
367
+ """Piso de confiança da leitura dos caracteres."""
368
+
369
+
370
+ class PhotosOCRParams(TypedDict):
371
+ """The body of photos:ocr."""
372
+
373
+ image: bytes
374
+ """A imagem da placa já recortada, JPEG ou PNG."""
375
+
376
+
377
+ class PhotosRecognizeParams(TypedDict):
378
+ """The body of photos:recognize."""
379
+
380
+ image: bytes
381
+ """A foto, JPEG ou PNG."""
382
+ image_mode: NotRequired[Literal["NONE"]]
383
+ """Só NONE é aceito."""
384
+ lpr_confidence: NotRequired[float]
385
+ """Piso de confiança do detector de placas."""
386
+ min_ocr_confidence: NotRequired[float]
387
+ """Piso de confiança da leitura dos caracteres."""
388
+
alpr/py.typed ADDED
File without changes
alpr/webhooks.py ADDED
@@ -0,0 +1,162 @@
1
+ # Code generated by sdk/generator. DO NOT EDIT.
2
+ """Verifying the webhooks Alpr.sh sends."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import re
10
+ import time
11
+ from collections.abc import Iterable, Mapping
12
+ from dataclasses import dataclass
13
+ from typing import Any, TypeAlias
14
+
15
+ from .models import WebhookAlertTriggered, WebhookRecognitionCreated, WebhookTest
16
+
17
+ WEBHOOK_TOLERANCE_SECONDS = 300
18
+ """How far from now a webhook's timestamp may be."""
19
+
20
+ _MAX_SIGNATURES = 6
21
+ """How many signatures a request may list: one for the secret in use and one for each secret still signing during a rotation."""
22
+
23
+ _SIGNATURE = re.compile(r"sha256=[0-9a-fA-F]{64}")
24
+
25
+ HeadersLike: TypeAlias = Mapping[str, str] | Iterable[tuple[str, str]]
26
+ """The request's headers: a mapping, or the (name, value) pairs of a web stack that repeats a header."""
27
+
28
+
29
+ class WebhookVerificationError(Exception):
30
+ """A request Alpr.sh did not sign, or signed too long ago."""
31
+
32
+ def __init__(self, message: str = "alpr: webhook signature does not verify") -> None:
33
+ super().__init__(message)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AlertTriggeredWebhookEvent(WebhookAlertTriggered):
38
+ """The payload of
39
+ - ``alert.triggered``
40
+
41
+ The fields are the payload's, flat: there is no ``data`` wrapper.
42
+ """
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class RecognitionCreatedWebhookEvent(WebhookRecognitionCreated):
47
+ """The payload of
48
+ - ``recognition.created``
49
+
50
+ The fields are the payload's, flat: there is no ``data`` wrapper.
51
+ """
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class TestWebhookEvent(WebhookTest):
56
+ """The payload of
57
+ - ``webhook.test``
58
+
59
+ The fields are the payload's, flat: there is no ``data`` wrapper.
60
+ """
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class UnknownWebhookEvent:
65
+ """An event of a name this SDK does not know yet; its body is kept as it was sent."""
66
+
67
+ event: str
68
+ """The event's name."""
69
+ id: str
70
+ """The id the body carries, or empty."""
71
+ body: dict[str, Any]
72
+ """The whole body, decoded."""
73
+
74
+
75
+ WebhookEvent: TypeAlias = (
76
+ AlertTriggeredWebhookEvent
77
+ | RecognitionCreatedWebhookEvent
78
+ | TestWebhookEvent
79
+ | UnknownWebhookEvent
80
+ )
81
+ """A webhook Alpr.sh sent, verified: one class a payload, told apart with ``match``
82
+ or ``isinstance``, or ``UnknownWebhookEvent`` for an event this SDK does not know yet."""
83
+
84
+
85
+ def _event(raw: bytes) -> WebhookEvent:
86
+ try:
87
+ body = json.loads(raw)
88
+ except ValueError as error:
89
+ raise ValueError(f"alpr: the webhook body is not an event: {error}") from None
90
+ if not isinstance(body, dict) or not isinstance(body.get("event"), str) or not body["event"]:
91
+ raise ValueError("alpr: the webhook body is not an event")
92
+ name: str = body["event"]
93
+ try:
94
+ if name in ("alert.triggered",):
95
+ return AlertTriggeredWebhookEvent.from_dict(body)
96
+ if name in ("recognition.created",):
97
+ return RecognitionCreatedWebhookEvent.from_dict(body)
98
+ if name in ("webhook.test",):
99
+ return TestWebhookEvent.from_dict(body)
100
+ except (KeyError, TypeError, ValueError, AttributeError) as error:
101
+ raise ValueError(f"alpr: the webhook body is not a {name} event: {error!r}") from None
102
+ sent = body.get("id")
103
+ return UnknownWebhookEvent(event=name, id=sent if isinstance(sent, str) else "", body=body)
104
+
105
+
106
+ def _header(headers: HeadersLike, name: str) -> list[str]:
107
+ pairs = headers.items() if isinstance(headers, Mapping) else headers
108
+ return [value for key, value in pairs if key.lower() == name]
109
+
110
+
111
+ def verify_webhook(secret: str, headers: HeadersLike, body: bytes | str) -> WebhookEvent:
112
+ """Checks a webhook Alpr.sh sent and answers its event.
113
+
114
+ Alpr.sh signs the headers ``X-Alprsh-Timestamp`` and ``X-Alprsh-Delivery-Id``
115
+ and the body: ``X-Alprsh-Signature`` carries ``sha256=`` and the hex
116
+ HMAC-SHA256 of ``{timestamp}.{delivery-id}.{body}`` under the endpoint's
117
+ secret (the whole ``whsec_…`` text, as it is), and more than one signature
118
+ while a secret is rotated: one matching is enough. Pass the body exactly as
119
+ it arrived, before parsing it, and deduplicate by the ``X-Alprsh-Delivery-Id``
120
+ header. A signature that does not verify raises ``WebhookVerificationError``;
121
+ an empty secret raises ``ValueError``; so does a body that is not an event.
122
+ """
123
+ return _verify_webhook_at(secret, headers, body, time.time())
124
+
125
+
126
+ def verify_webhook_signature(secret: str, headers: HeadersLike, body: bytes | str) -> None:
127
+ """Checks a webhook's signature as :func:`verify_webhook` does, without reading its body."""
128
+ _verify_webhook_signature_at(secret, headers, body, time.time())
129
+
130
+
131
+ def _verify_webhook_at(secret: str, headers: HeadersLike, body: bytes | str, now: float) -> WebhookEvent:
132
+ raw = body.encode() if isinstance(body, str) else body
133
+ _verify_webhook_signature_at(secret, headers, raw, now)
134
+ return _event(raw)
135
+
136
+
137
+ def _verify_webhook_signature_at(secret: str, headers: HeadersLike, body: bytes | str, now: float) -> None:
138
+ if not secret:
139
+ raise ValueError("alpr: the webhook secret is empty")
140
+ raw = body.encode() if isinstance(body, str) else body
141
+ ids, stamps = _header(headers, "x-alprsh-delivery-id"), _header(headers, "x-alprsh-timestamp")
142
+ delivery_id, stamp = (ids[0] if ids else ""), (stamps[0] if stamps else "")
143
+ # Only ASCII digits: int() would also take a sign, spaces, underscores and other scripts' digits.
144
+ if not delivery_id or not (stamp.isascii() and stamp.isdigit()) or len(stamp) > 15:
145
+ raise WebhookVerificationError()
146
+ if abs(now - int(stamp)) > WEBHOOK_TOLERANCE_SECONDS:
147
+ raise WebhookVerificationError()
148
+ expected = hmac.new(secret.encode(), f"{stamp}.{delivery_id}.".encode() + raw, hashlib.sha256).digest()
149
+ listed = ",".join(_header(headers, "x-alprsh-signature")).split(",")
150
+ if len(listed) > _MAX_SIGNATURES:
151
+ raise WebhookVerificationError()
152
+ matched = False
153
+ for entry in listed:
154
+ entry = entry.strip()
155
+ # One malformed signature refuses the request. Every listed signature is
156
+ # compared, in constant time.
157
+ if not _SIGNATURE.fullmatch(entry):
158
+ raise WebhookVerificationError()
159
+ if hmac.compare_digest(bytes.fromhex(entry[len("sha256=") :]), expected):
160
+ matched = True
161
+ if not matched:
162
+ raise WebhookVerificationError()
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.5
2
+ Name: alpr-sdk
3
+ Version: 0.1.0
4
+ Summary: Alpr.sh's SDK for Python: license-plate recognition on photos, readings, cameras and webhooks.
5
+ Project-URL: Homepage, https://alpr.sh
6
+ Project-URL: Repository, https://github.com/alpr-sh/sdk
7
+ License-Expression: MIT
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Typing :: Typed
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Alpr.sh SDK para Python
14
+
15
+ SDK oficial da API da Alpr.sh para Python 3.11+, sem dependências. É gerado a partir do contrato da API (`openapi.json`, na raiz de [alpr-sh/sdk](https://github.com/alpr-sh/sdk)) por `generator/`. Não edite os arquivos desta pasta: regenere.
16
+
17
+ ```sh
18
+ pip install alpr-sdk
19
+ ```
20
+
21
+ ## Começando
22
+
23
+ A chave de API (`alpr_live_…`) é a do espaço de trabalho: ela diz de quem são as leituras e quais escopos a chamada pode usar. Não há ambiente de teste.
24
+
25
+ ```python
26
+ import os
27
+ from alpr import Alpr
28
+
29
+ alpr = Alpr(os.environ["ALPR_API_KEY"])
30
+
31
+ # Lê as placas de uma foto.
32
+ with open("portaria.jpg", "rb") as file:
33
+ result = alpr.photos.recognize({"image": file.read(), "lpr_confidence": 0.5})
34
+ for plate in result.plates:
35
+ print(plate.plate, plate.conf, plate.plate_type)
36
+
37
+ # Percorre as leituras, da mais nova para a mais antiga, com filtros.
38
+ for reading in alpr.recognitions.list_iter(plate="BRA", source="camera"):
39
+ print(reading.id, reading.plate, reading.captured_at)
40
+
41
+ # Lista as câmeras.
42
+ for camera in alpr.cameras.list():
43
+ print(camera.id, camera.name, camera.enabled)
44
+
45
+ # O link temporário da imagem de uma leitura.
46
+ image = alpr.recognitions.image("det_123")
47
+ print(image.image_url, image.expires_in)
48
+ ```
49
+
50
+ `Alpr(api_key, base_url=…, timeout=30, transport=…)`: o endereço-base é `https://alpr.sh`; `http` só é aceito para `localhost`, `127.0.0.0/8` e `::1`, e qualquer outro endereço levanta `ValueError` antes de qualquer envio, para a chave nunca cruzar a rede em texto claro. A chamada que precisa de outra chave aceita `api_key=`. Redirecionamentos nunca são seguidos (a chave não viaja): um 3xx é um `AlprError`. O SDK não repete chamadas sozinho.
51
+
52
+ ## A foto
53
+
54
+ Passe os **bytes** da foto (`bytes`). O SDK envia `multipart/form-data` no campo `image`, com o nome e o tipo definidos pelos primeiros bytes: JPEG (`FF D8 FF`) vira `photo.jpg` (`image/jpeg`), PNG vira `photo.png` (`image/png`) e qualquer outra coisa vai como `photo` (`application/octet-stream`), para a API recusar. As opções `lpr_confidence` e `min_ocr_confidence` (de 0 a 1) são opcionais e só são enviadas quando você as passa; `ocr` aceita apenas `image`.
55
+
56
+ ## Páginas
57
+
58
+ `alpr.recognitions.list(before=…, plate=…, camera=…, source=…)` busca uma página e devolve `Page` com `items` e `next`, o cursor da página seguinte (`None` na última). `list_iter(...)` é um gerador que percorre todas as páginas, começando do cursor `before` que você passar (sem ele, da mais nova), e termina no primeiro erro ou quando o cursor deixa de andar.
59
+
60
+ ## Recursos
61
+
62
+ | Serviço | Métodos |
63
+ |---|---|
64
+ | `alpr.cameras` | `list` |
65
+ | `alpr.photos` | `detect`, `ocr`, `recognize` |
66
+ | `alpr.recognitions` | `image`, `list`, `list_iter` |
67
+
68
+ ## Erros
69
+
70
+ Uma recusa da API levanta `AlprError`, com `status`, `code`, `message`, `fields` (vazio, a menos que a resposta traga o problema de cada campo) e, num 429, `retry_after` em segundos (do cabeçalho `Retry-After`). Uma resposta de erro que não é JSON continua sendo um erro, só com o `status`. Os códigos:
71
+
72
+ | Status | `code` |
73
+ |---|---|
74
+ | 400 | `missing image`, `invalid_image`, `invalid_option` |
75
+ | 401 | `invalid_api_key` |
76
+ | 402 | `insufficient_credits` |
77
+ | 403 | `insufficient_scope` |
78
+ | 404 | `not_found`, `image_unavailable` |
79
+ | 413 | `image_too_large` |
80
+ | 422 | `no_plate` |
81
+ | 429 | `rate_limited` |
82
+ | 503 | `engine_unavailable`, `storage_unavailable` |
83
+
84
+ ```python
85
+ from alpr import AlprError
86
+
87
+ try:
88
+ alpr.photos.recognize({"image": photo})
89
+ except AlprError as error:
90
+ if error.code == "rate_limited":
91
+ time.sleep(error.retry_after or 1)
92
+ ```
93
+
94
+ ## Webhooks
95
+
96
+ A Alpr.sh envia os eventos para o endereço que você cadastrou, assinados. Os cabeçalhos:
97
+
98
+ - `X-Alprsh-Delivery-Id`: o id da entrega. **Deduplique as entregas por ele**: o `id` do corpo é o da leitura ou do alerta, não o da entrega.
99
+ - `X-Alprsh-Timestamp`: segundos desde 1970 (só dígitos ASCII).
100
+ - `X-Alprsh-Signature`: `sha256=` e os 64 caracteres hexadecimais do HMAC-SHA256 de `timestamp + "." + delivery-id + "." + corpo`, com o segredo do endpoint (`whsec_…`) usado como está, em UTF-8, sem decodificar.
101
+
102
+ ```python
103
+ from alpr import (
104
+ AlertTriggeredWebhookEvent,
105
+ RecognitionCreatedWebhookEvent,
106
+ UnknownWebhookEvent,
107
+ WebhookVerificationError,
108
+ verify_webhook,
109
+ )
110
+
111
+ try:
112
+ event = verify_webhook(os.environ["ALPR_WEBHOOK_SECRET"], request.headers, request.get_data())
113
+ except WebhookVerificationError:
114
+ return "assinatura inválida", 400
115
+
116
+ match event:
117
+ case RecognitionCreatedWebhookEvent(plate=plate, camera_id=camera_id):
118
+ print(event.event, plate, camera_id, event.captured_at)
119
+ case AlertTriggeredWebhookEvent(rule_name=rule_name):
120
+ print(rule_name, event.recognition_id)
121
+ case UnknownWebhookEvent():
122
+ pass # um evento que esta versão do SDK ainda não conhece: event.body é o JSON
123
+ ```
124
+
125
+ Passe o corpo cru, exatamente como chegou, e verifique antes de interpretar. Os cabeçalhos podem ser um dicionário (a busca ignora maiúsculas) ou uma lista de pares `(nome, valor)`, para os frameworks que repetem um cabeçalho. A tolerância do relógio é de `WEBHOOK_TOLERANCE_SECONDS` (300 segundos, para os dois lados; exatamente 300 passa, 301 não).
126
+
127
+ Durante a rotação do segredo o cabeçalho de assinatura traz mais de uma, numa linha separada por vírgulas ou em linhas repetidas (até 6); basta uma conferir, mas uma assinatura malformada recusa a requisição inteira. `verify_webhook_signature` só confere a assinatura. Uma assinatura que não confere levanta `WebhookVerificationError`; um segredo vazio é um erro de configuração (`ValueError`), não de assinatura.
128
+
129
+ O evento é tipado: um tipo por carga (`RecognitionCreatedWebhookEvent`, `AlertTriggeredWebhookEvent`, `TestWebhookEvent`), com os campos da carga na raiz (`event`, `id`, `tenant_id`, `plate`…), sem `data` nem `type`. Um nome de evento que esta versão do SDK não conhece chega como `UnknownWebhookEvent` (`event`, `id` e o `body` inteiro), e não é um erro. Um corpo que não é um objeto com `event` levanta `ValueError`.
130
+
131
+ Versão do SDK: 0.1.0 · contrato da API: 1.0.0
@@ -0,0 +1,14 @@
1
+ alpr/__init__.py,sha256=3QL7b1frGZ8RxPMR6NibGNnfT5o_ExTPy52vGD0oBy4,1825
2
+ alpr/_client.py,sha256=girLmeh_3b6ei6-oozNzGB_PDoQNGqXuK5b_HqOM3Pk,4629
3
+ alpr/_core.py,sha256=1H12QXc5yChvxryMokPTyuEZnNTAsq1uZzz1h3dBfBA,524
4
+ alpr/_errors.py,sha256=jftl3gVBIOYVw97tPUNeo5L41Jp31EaNaaSX58DD2UA,1847
5
+ alpr/_pagination.py,sha256=PoCA8vXAvHHtWCevjBuTLstJgMe-sZW5sJp3idiU0N8,1291
6
+ alpr/_services.py,sha256=XsbF1YZfaDcokycafRAS6SDXKosPEId7jWRB-GuAj_A,4111
7
+ alpr/_upload.py,sha256=TNso4Q17KQn3vdoFBLHPcWnb3S6nX8uvOLyIZkZTfWM,2295
8
+ alpr/_version.py,sha256=34t9pqk6kZ2sQcidaGGP4oBYT-niOFl_PfDvIQt-m4s,270
9
+ alpr/models.py,sha256=nNtdoAzq7hTkSsDubO3ML3Rw4YJ2vJXruxySci1e3pg,13064
10
+ alpr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ alpr/webhooks.py,sha256=0V7BBxB7_4z3b_k0CK0aEJTRsrw4qvUnp9_PkXYOPlk,6378
12
+ alpr_sdk-0.1.0.dist-info/METADATA,sha256=P4JojXdWhyfeyiw90iwcB6bnP5iF98iJaBm62VlcZEI,6580
13
+ alpr_sdk-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
14
+ alpr_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any