alpr-sdk 0.1.0__tar.gz
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_sdk-0.1.0/.gitignore +5 -0
- alpr_sdk-0.1.0/PKG-INFO +131 -0
- alpr_sdk-0.1.0/README.md +119 -0
- alpr_sdk-0.1.0/pyproject.toml +23 -0
- alpr_sdk-0.1.0/src/alpr/__init__.py +61 -0
- alpr_sdk-0.1.0/src/alpr/_client.py +107 -0
- alpr_sdk-0.1.0/src/alpr/_core.py +25 -0
- alpr_sdk-0.1.0/src/alpr/_errors.py +44 -0
- alpr_sdk-0.1.0/src/alpr/_pagination.py +34 -0
- alpr_sdk-0.1.0/src/alpr/_services.py +88 -0
- alpr_sdk-0.1.0/src/alpr/_upload.py +57 -0
- alpr_sdk-0.1.0/src/alpr/_version.py +10 -0
- alpr_sdk-0.1.0/src/alpr/models.py +388 -0
- alpr_sdk-0.1.0/src/alpr/py.typed +0 -0
- alpr_sdk-0.1.0/src/alpr/webhooks.py +162 -0
- alpr_sdk-0.1.0/tests/test_alpr.py +454 -0
alpr_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
alpr_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Alpr.sh SDK para Python
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install alpr-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Começando
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from alpr import Alpr
|
|
16
|
+
|
|
17
|
+
alpr = Alpr(os.environ["ALPR_API_KEY"])
|
|
18
|
+
|
|
19
|
+
# Lê as placas de uma foto.
|
|
20
|
+
with open("portaria.jpg", "rb") as file:
|
|
21
|
+
result = alpr.photos.recognize({"image": file.read(), "lpr_confidence": 0.5})
|
|
22
|
+
for plate in result.plates:
|
|
23
|
+
print(plate.plate, plate.conf, plate.plate_type)
|
|
24
|
+
|
|
25
|
+
# Percorre as leituras, da mais nova para a mais antiga, com filtros.
|
|
26
|
+
for reading in alpr.recognitions.list_iter(plate="BRA", source="camera"):
|
|
27
|
+
print(reading.id, reading.plate, reading.captured_at)
|
|
28
|
+
|
|
29
|
+
# Lista as câmeras.
|
|
30
|
+
for camera in alpr.cameras.list():
|
|
31
|
+
print(camera.id, camera.name, camera.enabled)
|
|
32
|
+
|
|
33
|
+
# O link temporário da imagem de uma leitura.
|
|
34
|
+
image = alpr.recognitions.image("det_123")
|
|
35
|
+
print(image.image_url, image.expires_in)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`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.
|
|
39
|
+
|
|
40
|
+
## A foto
|
|
41
|
+
|
|
42
|
+
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`.
|
|
43
|
+
|
|
44
|
+
## Páginas
|
|
45
|
+
|
|
46
|
+
`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.
|
|
47
|
+
|
|
48
|
+
## Recursos
|
|
49
|
+
|
|
50
|
+
| Serviço | Métodos |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `alpr.cameras` | `list` |
|
|
53
|
+
| `alpr.photos` | `detect`, `ocr`, `recognize` |
|
|
54
|
+
| `alpr.recognitions` | `image`, `list`, `list_iter` |
|
|
55
|
+
|
|
56
|
+
## Erros
|
|
57
|
+
|
|
58
|
+
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:
|
|
59
|
+
|
|
60
|
+
| Status | `code` |
|
|
61
|
+
|---|---|
|
|
62
|
+
| 400 | `missing image`, `invalid_image`, `invalid_option` |
|
|
63
|
+
| 401 | `invalid_api_key` |
|
|
64
|
+
| 402 | `insufficient_credits` |
|
|
65
|
+
| 403 | `insufficient_scope` |
|
|
66
|
+
| 404 | `not_found`, `image_unavailable` |
|
|
67
|
+
| 413 | `image_too_large` |
|
|
68
|
+
| 422 | `no_plate` |
|
|
69
|
+
| 429 | `rate_limited` |
|
|
70
|
+
| 503 | `engine_unavailable`, `storage_unavailable` |
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from alpr import AlprError
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
alpr.photos.recognize({"image": photo})
|
|
77
|
+
except AlprError as error:
|
|
78
|
+
if error.code == "rate_limited":
|
|
79
|
+
time.sleep(error.retry_after or 1)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Webhooks
|
|
83
|
+
|
|
84
|
+
A Alpr.sh envia os eventos para o endereço que você cadastrou, assinados. Os cabeçalhos:
|
|
85
|
+
|
|
86
|
+
- `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.
|
|
87
|
+
- `X-Alprsh-Timestamp`: segundos desde 1970 (só dígitos ASCII).
|
|
88
|
+
- `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.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from alpr import (
|
|
92
|
+
AlertTriggeredWebhookEvent,
|
|
93
|
+
RecognitionCreatedWebhookEvent,
|
|
94
|
+
UnknownWebhookEvent,
|
|
95
|
+
WebhookVerificationError,
|
|
96
|
+
verify_webhook,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
event = verify_webhook(os.environ["ALPR_WEBHOOK_SECRET"], request.headers, request.get_data())
|
|
101
|
+
except WebhookVerificationError:
|
|
102
|
+
return "assinatura inválida", 400
|
|
103
|
+
|
|
104
|
+
match event:
|
|
105
|
+
case RecognitionCreatedWebhookEvent(plate=plate, camera_id=camera_id):
|
|
106
|
+
print(event.event, plate, camera_id, event.captured_at)
|
|
107
|
+
case AlertTriggeredWebhookEvent(rule_name=rule_name):
|
|
108
|
+
print(rule_name, event.recognition_id)
|
|
109
|
+
case UnknownWebhookEvent():
|
|
110
|
+
pass # um evento que esta versão do SDK ainda não conhece: event.body é o JSON
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
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).
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
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`.
|
|
118
|
+
|
|
119
|
+
Versão do SDK: 0.1.0 · contrato da API: 1.0.0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.33"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "alpr-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Alpr.sh's SDK for Python: license-plate recognition on photos, readings, cameras and webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = []
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://alpr.sh"
|
|
20
|
+
Repository = "https://github.com/alpr-sh/sdk"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/alpr"]
|
|
@@ -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
|
+
]
|
|
@@ -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)
|
|
@@ -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="")
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
@@ -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
|
+
|