sightradar 1.0.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.
- sightradar-1.0.0/.github/workflows/ci.yml +20 -0
- sightradar-1.0.0/.github/workflows/publish.yml +25 -0
- sightradar-1.0.0/.gitignore +16 -0
- sightradar-1.0.0/LICENSE +21 -0
- sightradar-1.0.0/PKG-INFO +112 -0
- sightradar-1.0.0/README.md +93 -0
- sightradar-1.0.0/pyproject.toml +30 -0
- sightradar-1.0.0/src/sightradar/__init__.py +53 -0
- sightradar-1.0.0/src/sightradar/client.py +375 -0
- sightradar-1.0.0/src/sightradar/errors.py +53 -0
- sightradar-1.0.0/src/sightradar/models.py +148 -0
- sightradar-1.0.0/src/sightradar/py.typed +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
python:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.11"
|
|
16
|
+
- name: Import + build smoke test
|
|
17
|
+
run: |
|
|
18
|
+
python -m pip install --upgrade build
|
|
19
|
+
python -m build
|
|
20
|
+
python -c "import sys; sys.path.insert(0, 'src'); import sightradar; print('sightradar', sightradar.__version__)"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publish to PyPI when a tag like `v1.0.0` is pushed.
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags:
|
|
7
|
+
- "v*"
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
publish:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
# Trusted publishing to PyPI (OIDC) — no API token stored in the repo.
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.11"
|
|
20
|
+
- name: Build
|
|
21
|
+
run: |
|
|
22
|
+
python -m pip install --upgrade build
|
|
23
|
+
python -m build
|
|
24
|
+
- name: Publish to PyPI
|
|
25
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
sightradar-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SightRadar (THIRDACT LABS PRIVATE LIMITED)
|
|
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.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sightradar
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for the SightRadar face recognition API
|
|
5
|
+
Project-URL: Homepage, https://sightradar.com
|
|
6
|
+
Project-URL: Documentation, https://sightradar.com/docs
|
|
7
|
+
Project-URL: Source, https://github.com/sightradar-hq/sightradar-python
|
|
8
|
+
Author: SightRadar
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,face recognition,facial recognition,rekognition,sightradar
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# SightRadar — Python client
|
|
21
|
+
|
|
22
|
+
Official Python client for the [SightRadar](https://sightradar.com) face
|
|
23
|
+
recognition API. Zero runtime dependencies (built on the standard library).
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install sightradar
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Authenticate
|
|
30
|
+
|
|
31
|
+
Create an API key in the [console](https://sightradar.com/login), then pass it
|
|
32
|
+
directly or via the `SIGHTRADAR_API_KEY` environment variable.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from sightradar import SightRadar
|
|
36
|
+
|
|
37
|
+
sr = SightRadar(api_key="frs_...") # or: SightRadar() with SIGHTRADAR_API_KEY set
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Core workflow
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
# 1. Create a collection to hold faces.
|
|
44
|
+
sr.create_collection("event-2026")
|
|
45
|
+
|
|
46
|
+
# 2. Index faces from photos (URL, GCS key, or a local file).
|
|
47
|
+
sr.index("event-2026", url="https://example.com/group.jpg")
|
|
48
|
+
sr.index("event-2026", file="/path/to/photo.jpg", photo_id="img-42")
|
|
49
|
+
|
|
50
|
+
# 3. Search the collection with one selfie.
|
|
51
|
+
result = sr.search("event-2026", url="https://example.com/selfie.jpg")
|
|
52
|
+
if result.found:
|
|
53
|
+
for m in result.matches:
|
|
54
|
+
print(m.photo_id, round(m.similarity, 3))
|
|
55
|
+
else:
|
|
56
|
+
print("no match:", result.reason)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Stateless operations (nothing stored)
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
# Detect + quality-gate faces in an image.
|
|
63
|
+
det = sr.detect(url="https://example.com/photo.jpg")
|
|
64
|
+
print(det.detected_face_count, det.gated_face_count)
|
|
65
|
+
|
|
66
|
+
# 1:1 verification between two faces.
|
|
67
|
+
cmp = sr.compare(
|
|
68
|
+
source_url="https://example.com/a.jpg",
|
|
69
|
+
target_url="https://example.com/b.jpg",
|
|
70
|
+
)
|
|
71
|
+
print(cmp.match, cmp.similarity)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Account
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
print(sr.wallet().balance_credits)
|
|
78
|
+
print(sr.usage(days=30))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Errors
|
|
82
|
+
|
|
83
|
+
Every non-2xx response raises a typed exception:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from sightradar import (
|
|
87
|
+
SightRadarError, # base
|
|
88
|
+
AuthenticationError, # 401
|
|
89
|
+
InsufficientCreditsError, # 402
|
|
90
|
+
NotFoundError, # 404
|
|
91
|
+
RateLimitError, # 429
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
sr.describe_collection("missing")
|
|
96
|
+
except NotFoundError as e:
|
|
97
|
+
print(e.status_code, e.message)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Image inputs
|
|
101
|
+
|
|
102
|
+
Index / search / detect / register-selfie accept exactly one image source:
|
|
103
|
+
|
|
104
|
+
- `url=` — a public image URL
|
|
105
|
+
- `gcs_key=` — a Google Cloud Storage object key
|
|
106
|
+
- `file=` — a local path, `bytes`, or a file-like object (uploaded as multipart)
|
|
107
|
+
|
|
108
|
+
`search` additionally accepts `embedding=` (a 512-d vector).
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SightRadar — Python client
|
|
2
|
+
|
|
3
|
+
Official Python client for the [SightRadar](https://sightradar.com) face
|
|
4
|
+
recognition API. Zero runtime dependencies (built on the standard library).
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install sightradar
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Authenticate
|
|
11
|
+
|
|
12
|
+
Create an API key in the [console](https://sightradar.com/login), then pass it
|
|
13
|
+
directly or via the `SIGHTRADAR_API_KEY` environment variable.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from sightradar import SightRadar
|
|
17
|
+
|
|
18
|
+
sr = SightRadar(api_key="frs_...") # or: SightRadar() with SIGHTRADAR_API_KEY set
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Core workflow
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
# 1. Create a collection to hold faces.
|
|
25
|
+
sr.create_collection("event-2026")
|
|
26
|
+
|
|
27
|
+
# 2. Index faces from photos (URL, GCS key, or a local file).
|
|
28
|
+
sr.index("event-2026", url="https://example.com/group.jpg")
|
|
29
|
+
sr.index("event-2026", file="/path/to/photo.jpg", photo_id="img-42")
|
|
30
|
+
|
|
31
|
+
# 3. Search the collection with one selfie.
|
|
32
|
+
result = sr.search("event-2026", url="https://example.com/selfie.jpg")
|
|
33
|
+
if result.found:
|
|
34
|
+
for m in result.matches:
|
|
35
|
+
print(m.photo_id, round(m.similarity, 3))
|
|
36
|
+
else:
|
|
37
|
+
print("no match:", result.reason)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Stateless operations (nothing stored)
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
# Detect + quality-gate faces in an image.
|
|
44
|
+
det = sr.detect(url="https://example.com/photo.jpg")
|
|
45
|
+
print(det.detected_face_count, det.gated_face_count)
|
|
46
|
+
|
|
47
|
+
# 1:1 verification between two faces.
|
|
48
|
+
cmp = sr.compare(
|
|
49
|
+
source_url="https://example.com/a.jpg",
|
|
50
|
+
target_url="https://example.com/b.jpg",
|
|
51
|
+
)
|
|
52
|
+
print(cmp.match, cmp.similarity)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Account
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
print(sr.wallet().balance_credits)
|
|
59
|
+
print(sr.usage(days=30))
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Errors
|
|
63
|
+
|
|
64
|
+
Every non-2xx response raises a typed exception:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from sightradar import (
|
|
68
|
+
SightRadarError, # base
|
|
69
|
+
AuthenticationError, # 401
|
|
70
|
+
InsufficientCreditsError, # 402
|
|
71
|
+
NotFoundError, # 404
|
|
72
|
+
RateLimitError, # 429
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
sr.describe_collection("missing")
|
|
77
|
+
except NotFoundError as e:
|
|
78
|
+
print(e.status_code, e.message)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Image inputs
|
|
82
|
+
|
|
83
|
+
Index / search / detect / register-selfie accept exactly one image source:
|
|
84
|
+
|
|
85
|
+
- `url=` — a public image URL
|
|
86
|
+
- `gcs_key=` — a Google Cloud Storage object key
|
|
87
|
+
- `file=` — a local path, `bytes`, or a file-like object (uploaded as multipart)
|
|
88
|
+
|
|
89
|
+
`search` additionally accepts `embedding=` (a 512-d vector).
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sightradar"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python client for the SightRadar face recognition API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "SightRadar" }]
|
|
13
|
+
keywords = ["face recognition", "facial recognition", "api", "sightradar", "rekognition"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Image Recognition",
|
|
20
|
+
]
|
|
21
|
+
# Zero runtime dependencies — built on the standard library.
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://sightradar.com"
|
|
26
|
+
Documentation = "https://sightradar.com/docs"
|
|
27
|
+
Source = "https://github.com/sightradar-hq/sightradar-python"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/sightradar"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""SightRadar — official Python client for the face recognition API.
|
|
2
|
+
|
|
3
|
+
Quickstart
|
|
4
|
+
----------
|
|
5
|
+
from sightradar import SightRadar
|
|
6
|
+
|
|
7
|
+
sr = SightRadar(api_key="frs_...") # or set SIGHTRADAR_API_KEY
|
|
8
|
+
sr.create_collection("event-2026")
|
|
9
|
+
sr.index("event-2026", url="https://example.com/group.jpg")
|
|
10
|
+
result = sr.search("event-2026", url="https://example.com/selfie.jpg")
|
|
11
|
+
for m in result.matches:
|
|
12
|
+
print(m.photo_id, m.similarity)
|
|
13
|
+
|
|
14
|
+
The API is Rekognition-compatible in shape but exposed through a small, explicit
|
|
15
|
+
surface here. Every call raises :class:`SightRadarError` on a non-2xx response.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .client import SightRadar
|
|
19
|
+
from .errors import (
|
|
20
|
+
SightRadarError,
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
InsufficientCreditsError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
)
|
|
26
|
+
from .models import (
|
|
27
|
+
Collection,
|
|
28
|
+
SearchResult,
|
|
29
|
+
Match,
|
|
30
|
+
IndexResult,
|
|
31
|
+
CompareResult,
|
|
32
|
+
DetectResult,
|
|
33
|
+
Wallet,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "1.0.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"SightRadar",
|
|
40
|
+
"SightRadarError",
|
|
41
|
+
"AuthenticationError",
|
|
42
|
+
"InsufficientCreditsError",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"Collection",
|
|
46
|
+
"SearchResult",
|
|
47
|
+
"Match",
|
|
48
|
+
"IndexResult",
|
|
49
|
+
"CompareResult",
|
|
50
|
+
"DetectResult",
|
|
51
|
+
"Wallet",
|
|
52
|
+
"__version__",
|
|
53
|
+
]
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""The synchronous SightRadar API client.
|
|
2
|
+
|
|
3
|
+
Zero hard dependencies — built on the Python standard library (``urllib``) so it
|
|
4
|
+
installs clean anywhere. File uploads use multipart/form-data; URL/GCS-key inputs
|
|
5
|
+
use JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import mimetypes
|
|
13
|
+
import os
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any, BinaryIO, Dict, List, Optional, Union
|
|
16
|
+
from urllib import error as urlerror
|
|
17
|
+
from urllib import request as urlrequest
|
|
18
|
+
|
|
19
|
+
from .errors import SightRadarError, error_for_status
|
|
20
|
+
from .models import (
|
|
21
|
+
Collection,
|
|
22
|
+
CompareResult,
|
|
23
|
+
DetectResult,
|
|
24
|
+
IndexResult,
|
|
25
|
+
SearchResult,
|
|
26
|
+
Wallet,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__version__ = "1.0.0"
|
|
30
|
+
|
|
31
|
+
DEFAULT_BASE_URL = "https://api.sightradar.com"
|
|
32
|
+
|
|
33
|
+
# A path-or-file the SDK can read bytes from for an upload.
|
|
34
|
+
FileLike = Union[str, bytes, BinaryIO, io.IOBase]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SightRadar:
|
|
38
|
+
"""Client for the SightRadar face recognition API.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
api_key: Your ``frs_<prefix>_<secret>`` key. Falls back to the
|
|
42
|
+
``SIGHTRADAR_API_KEY`` environment variable.
|
|
43
|
+
base_url: Override the API base (defaults to the production gateway).
|
|
44
|
+
timeout: Per-request timeout in seconds.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
api_key: Optional[str] = None,
|
|
50
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
51
|
+
timeout: float = 30.0,
|
|
52
|
+
):
|
|
53
|
+
key = api_key or os.environ.get("SIGHTRADAR_API_KEY")
|
|
54
|
+
if not key:
|
|
55
|
+
raise SightRadarError(
|
|
56
|
+
"No API key. Pass api_key=... or set SIGHTRADAR_API_KEY."
|
|
57
|
+
)
|
|
58
|
+
self.api_key = key
|
|
59
|
+
self.base_url = base_url.rstrip("/")
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
|
|
62
|
+
# -- transport ----------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def _headers(self) -> Dict[str, str]:
|
|
65
|
+
return {
|
|
66
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
67
|
+
"User-Agent": f"sightradar-python/{__version__}",
|
|
68
|
+
"Accept": "application/json",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def _request(
|
|
72
|
+
self,
|
|
73
|
+
method: str,
|
|
74
|
+
path: str,
|
|
75
|
+
*,
|
|
76
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
77
|
+
raw_body: Optional[bytes] = None,
|
|
78
|
+
content_type: Optional[str] = None,
|
|
79
|
+
query: Optional[Dict[str, Any]] = None,
|
|
80
|
+
) -> Any:
|
|
81
|
+
url = f"{self.base_url}{path}"
|
|
82
|
+
if query:
|
|
83
|
+
from urllib.parse import urlencode
|
|
84
|
+
|
|
85
|
+
params = {k: v for k, v in query.items() if v is not None}
|
|
86
|
+
if params:
|
|
87
|
+
url = f"{url}?{urlencode(params)}"
|
|
88
|
+
|
|
89
|
+
headers = self._headers()
|
|
90
|
+
data: Optional[bytes] = None
|
|
91
|
+
if json_body is not None:
|
|
92
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
93
|
+
headers["Content-Type"] = "application/json"
|
|
94
|
+
elif raw_body is not None:
|
|
95
|
+
data = raw_body
|
|
96
|
+
if content_type:
|
|
97
|
+
headers["Content-Type"] = content_type
|
|
98
|
+
|
|
99
|
+
req = urlrequest.Request(url, data=data, method=method, headers=headers)
|
|
100
|
+
try:
|
|
101
|
+
with urlrequest.urlopen(req, timeout=self.timeout) as resp:
|
|
102
|
+
body = resp.read()
|
|
103
|
+
return self._parse(resp.status, body)
|
|
104
|
+
except urlerror.HTTPError as e:
|
|
105
|
+
body = e.read()
|
|
106
|
+
self._raise(e.code, body)
|
|
107
|
+
except urlerror.URLError as e: # network / DNS / TLS
|
|
108
|
+
raise SightRadarError(f"request failed: {e.reason}") from e
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _parse(status: int, body: bytes) -> Any:
|
|
112
|
+
if not body:
|
|
113
|
+
return {}
|
|
114
|
+
try:
|
|
115
|
+
return json.loads(body)
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
raise SightRadarError(
|
|
118
|
+
f"non-JSON response (status {status})", status
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _raise(status: int, body: bytes) -> None:
|
|
123
|
+
message = f"request failed ({status})"
|
|
124
|
+
try:
|
|
125
|
+
parsed = json.loads(body)
|
|
126
|
+
if isinstance(parsed, dict) and parsed.get("error"):
|
|
127
|
+
message = str(parsed["error"])
|
|
128
|
+
except (json.JSONDecodeError, ValueError):
|
|
129
|
+
if body:
|
|
130
|
+
message = body.decode("utf-8", "replace")[:300]
|
|
131
|
+
raise error_for_status(status, message)
|
|
132
|
+
|
|
133
|
+
# -- multipart ----------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _multipart(
|
|
136
|
+
self, file: FileLike, fields: Optional[Dict[str, Any]] = None
|
|
137
|
+
) -> tuple[bytes, str]:
|
|
138
|
+
"""Build a multipart/form-data body from a file + optional fields."""
|
|
139
|
+
filename, content = _read_file(file)
|
|
140
|
+
boundary = f"----sightradar{uuid.uuid4().hex}"
|
|
141
|
+
ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
142
|
+
buf = io.BytesIO()
|
|
143
|
+
|
|
144
|
+
def w(s: str) -> None:
|
|
145
|
+
buf.write(s.encode("utf-8"))
|
|
146
|
+
|
|
147
|
+
for k, v in (fields or {}).items():
|
|
148
|
+
if v is None:
|
|
149
|
+
continue
|
|
150
|
+
w(f"--{boundary}\r\n")
|
|
151
|
+
w(f'Content-Disposition: form-data; name="{k}"\r\n\r\n')
|
|
152
|
+
w(f"{v}\r\n")
|
|
153
|
+
|
|
154
|
+
w(f"--{boundary}\r\n")
|
|
155
|
+
w(f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n')
|
|
156
|
+
w(f"Content-Type: {ctype}\r\n\r\n")
|
|
157
|
+
buf.write(content)
|
|
158
|
+
w(f"\r\n--{boundary}--\r\n")
|
|
159
|
+
return buf.getvalue(), f"multipart/form-data; boundary={boundary}"
|
|
160
|
+
|
|
161
|
+
# -- collections --------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def create_collection(self, collection_id: str) -> Collection:
|
|
164
|
+
"""Create a collection. Idempotent-ish: a duplicate raises (409)."""
|
|
165
|
+
d = self._request(
|
|
166
|
+
"POST", "/v1/collections", json_body={"collection_id": collection_id}
|
|
167
|
+
)
|
|
168
|
+
return Collection.from_dict(d)
|
|
169
|
+
|
|
170
|
+
def list_collections(
|
|
171
|
+
self, *, q: Optional[str] = None, limit: int = 50, offset: int = 0
|
|
172
|
+
) -> List[Collection]:
|
|
173
|
+
"""List your collections (server-side searchable via ``q``)."""
|
|
174
|
+
d = self._request(
|
|
175
|
+
"GET",
|
|
176
|
+
"/v1/collections",
|
|
177
|
+
query={"q": q, "limit": limit, "offset": offset},
|
|
178
|
+
)
|
|
179
|
+
items = d.get("collections", d) if isinstance(d, dict) else d
|
|
180
|
+
return [Collection.from_dict(c) for c in (items or [])]
|
|
181
|
+
|
|
182
|
+
def describe_collection(self, collection_id: str) -> Collection:
|
|
183
|
+
d = self._request("GET", f"/v1/collections/{collection_id}")
|
|
184
|
+
return Collection.from_dict(d)
|
|
185
|
+
|
|
186
|
+
def delete_collection(self, collection_id: str) -> Dict[str, Any]:
|
|
187
|
+
"""Delete a collection and CASCADE every stored face/selfie. Irreversible."""
|
|
188
|
+
return self._request("DELETE", f"/v1/collections/{collection_id}")
|
|
189
|
+
|
|
190
|
+
# -- index / search -----------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def index(
|
|
193
|
+
self,
|
|
194
|
+
collection_id: str,
|
|
195
|
+
*,
|
|
196
|
+
url: Optional[str] = None,
|
|
197
|
+
gcs_key: Optional[str] = None,
|
|
198
|
+
photo_id: Optional[str] = None,
|
|
199
|
+
file: Optional[FileLike] = None,
|
|
200
|
+
) -> IndexResult:
|
|
201
|
+
"""Detect, embed, and store every face in a photo.
|
|
202
|
+
|
|
203
|
+
Provide exactly one image source: ``url``, ``gcs_key``, or ``file``.
|
|
204
|
+
"""
|
|
205
|
+
path = f"/v1/collections/{collection_id}/index"
|
|
206
|
+
if file is not None:
|
|
207
|
+
body, ctype = self._multipart(file, {"photoId": photo_id})
|
|
208
|
+
d = self._request("POST", path, raw_body=body, content_type=ctype)
|
|
209
|
+
else:
|
|
210
|
+
d = self._request(
|
|
211
|
+
"POST", path, json_body=_image_body(url, gcs_key, photo_id)
|
|
212
|
+
)
|
|
213
|
+
return IndexResult.from_dict(d)
|
|
214
|
+
|
|
215
|
+
def search(
|
|
216
|
+
self,
|
|
217
|
+
collection_id: str,
|
|
218
|
+
*,
|
|
219
|
+
url: Optional[str] = None,
|
|
220
|
+
gcs_key: Optional[str] = None,
|
|
221
|
+
embedding: Optional[List[float]] = None,
|
|
222
|
+
file: Optional[FileLike] = None,
|
|
223
|
+
threshold: Optional[float] = None,
|
|
224
|
+
limit: Optional[int] = None,
|
|
225
|
+
) -> SearchResult:
|
|
226
|
+
"""Find every stored photo a person appears in, from one selfie.
|
|
227
|
+
|
|
228
|
+
Provide one of ``url``, ``gcs_key``, ``embedding``, or ``file``.
|
|
229
|
+
"""
|
|
230
|
+
path = f"/v1/collections/{collection_id}/search"
|
|
231
|
+
if file is not None:
|
|
232
|
+
body, ctype = self._multipart(
|
|
233
|
+
file, {"threshold": threshold, "limit": limit}
|
|
234
|
+
)
|
|
235
|
+
d = self._request("POST", path, raw_body=body, content_type=ctype)
|
|
236
|
+
else:
|
|
237
|
+
payload: Dict[str, Any] = {}
|
|
238
|
+
if embedding is not None:
|
|
239
|
+
payload["embedding"] = embedding
|
|
240
|
+
elif url is not None:
|
|
241
|
+
payload["url"] = url
|
|
242
|
+
elif gcs_key is not None:
|
|
243
|
+
payload["gcsKey"] = gcs_key
|
|
244
|
+
else:
|
|
245
|
+
raise SightRadarError(
|
|
246
|
+
"search needs one of: url, gcs_key, embedding, or file"
|
|
247
|
+
)
|
|
248
|
+
if threshold is not None:
|
|
249
|
+
payload["threshold"] = threshold
|
|
250
|
+
if limit is not None:
|
|
251
|
+
payload["limit"] = limit
|
|
252
|
+
d = self._request("POST", path, json_body=payload)
|
|
253
|
+
return SearchResult.from_dict(d)
|
|
254
|
+
|
|
255
|
+
def search_by_id(
|
|
256
|
+
self,
|
|
257
|
+
collection_id: str,
|
|
258
|
+
point_id: str,
|
|
259
|
+
*,
|
|
260
|
+
threshold: Optional[float] = None,
|
|
261
|
+
limit: Optional[int] = None,
|
|
262
|
+
) -> SearchResult:
|
|
263
|
+
"""Search using a previously-stored selfie point id."""
|
|
264
|
+
payload: Dict[str, Any] = {"id": point_id}
|
|
265
|
+
if threshold is not None:
|
|
266
|
+
payload["threshold"] = threshold
|
|
267
|
+
if limit is not None:
|
|
268
|
+
payload["limit"] = limit
|
|
269
|
+
d = self._request(
|
|
270
|
+
"POST", f"/v1/collections/{collection_id}/search-by-id", json_body=payload
|
|
271
|
+
)
|
|
272
|
+
return SearchResult.from_dict(d)
|
|
273
|
+
|
|
274
|
+
def register_selfie(
|
|
275
|
+
self,
|
|
276
|
+
collection_id: str,
|
|
277
|
+
*,
|
|
278
|
+
url: Optional[str] = None,
|
|
279
|
+
gcs_key: Optional[str] = None,
|
|
280
|
+
file: Optional[FileLike] = None,
|
|
281
|
+
photo_id: Optional[str] = None,
|
|
282
|
+
) -> Dict[str, Any]:
|
|
283
|
+
"""Register a selfie point you can later search by id."""
|
|
284
|
+
path = f"/v1/collections/{collection_id}/selfies"
|
|
285
|
+
if file is not None:
|
|
286
|
+
body, ctype = self._multipart(file, {"photoId": photo_id})
|
|
287
|
+
return self._request("POST", path, raw_body=body, content_type=ctype)
|
|
288
|
+
return self._request("POST", path, json_body=_image_body(url, gcs_key, photo_id))
|
|
289
|
+
|
|
290
|
+
# -- stateless ops ------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def detect(
|
|
293
|
+
self,
|
|
294
|
+
*,
|
|
295
|
+
url: Optional[str] = None,
|
|
296
|
+
gcs_key: Optional[str] = None,
|
|
297
|
+
file: Optional[FileLike] = None,
|
|
298
|
+
) -> DetectResult:
|
|
299
|
+
"""Locate and quality-gate faces in an image. Nothing is stored."""
|
|
300
|
+
if file is not None:
|
|
301
|
+
body, ctype = self._multipart(file)
|
|
302
|
+
d = self._request("POST", "/v1/detect", raw_body=body, content_type=ctype)
|
|
303
|
+
else:
|
|
304
|
+
d = self._request("POST", "/v1/detect", json_body=_image_body(url, gcs_key))
|
|
305
|
+
return DetectResult.from_dict(d)
|
|
306
|
+
|
|
307
|
+
def compare(
|
|
308
|
+
self,
|
|
309
|
+
*,
|
|
310
|
+
source_url: Optional[str] = None,
|
|
311
|
+
target_url: Optional[str] = None,
|
|
312
|
+
source_gcs_key: Optional[str] = None,
|
|
313
|
+
target_gcs_key: Optional[str] = None,
|
|
314
|
+
source_embedding: Optional[List[float]] = None,
|
|
315
|
+
target_embedding: Optional[List[float]] = None,
|
|
316
|
+
) -> CompareResult:
|
|
317
|
+
"""1:1 similarity / verification between two faces. Nothing is stored."""
|
|
318
|
+
payload: Dict[str, Any] = {}
|
|
319
|
+
if source_url:
|
|
320
|
+
payload["sourceUrl"] = source_url
|
|
321
|
+
if target_url:
|
|
322
|
+
payload["targetUrl"] = target_url
|
|
323
|
+
if source_gcs_key:
|
|
324
|
+
payload["sourceGcsKey"] = source_gcs_key
|
|
325
|
+
if target_gcs_key:
|
|
326
|
+
payload["targetGcsKey"] = target_gcs_key
|
|
327
|
+
if source_embedding is not None:
|
|
328
|
+
payload["source_embedding"] = source_embedding
|
|
329
|
+
if target_embedding is not None:
|
|
330
|
+
payload["target_embedding"] = target_embedding
|
|
331
|
+
d = self._request("POST", "/v1/compare", json_body=payload)
|
|
332
|
+
return CompareResult.from_dict(d)
|
|
333
|
+
|
|
334
|
+
# -- account ------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def wallet(self) -> Wallet:
|
|
337
|
+
"""Get the current credit balance."""
|
|
338
|
+
return Wallet.from_dict(self._request("GET", "/v1/wallet"))
|
|
339
|
+
|
|
340
|
+
def usage(self, days: int = 30) -> Dict[str, Any]:
|
|
341
|
+
"""Usage report aggregated from the ledger."""
|
|
342
|
+
return self._request("GET", "/v1/usage", query={"days": days})
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# -- helpers ----------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _image_body(
|
|
349
|
+
url: Optional[str], gcs_key: Optional[str], photo_id: Optional[str] = None
|
|
350
|
+
) -> Dict[str, Any]:
|
|
351
|
+
body: Dict[str, Any] = {}
|
|
352
|
+
if url:
|
|
353
|
+
body["url"] = url
|
|
354
|
+
elif gcs_key:
|
|
355
|
+
body["gcsKey"] = gcs_key
|
|
356
|
+
else:
|
|
357
|
+
raise SightRadarError("provide one of: url, gcs_key, or file")
|
|
358
|
+
if photo_id:
|
|
359
|
+
body["photoId"] = photo_id
|
|
360
|
+
return body
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _read_file(file: FileLike) -> tuple[str, bytes]:
|
|
364
|
+
"""Resolve a path / bytes / file-object into (filename, content_bytes)."""
|
|
365
|
+
if isinstance(file, str):
|
|
366
|
+
with open(file, "rb") as fh:
|
|
367
|
+
return os.path.basename(file), fh.read()
|
|
368
|
+
if isinstance(file, (bytes, bytearray)):
|
|
369
|
+
return "upload.jpg", bytes(file)
|
|
370
|
+
# file-like object
|
|
371
|
+
name = getattr(file, "name", "upload.jpg")
|
|
372
|
+
content = file.read()
|
|
373
|
+
if isinstance(content, str):
|
|
374
|
+
content = content.encode("utf-8")
|
|
375
|
+
return os.path.basename(str(name)), content
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Exception types raised by the SightRadar client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SightRadarError(Exception):
|
|
9
|
+
"""Base error for all non-2xx API responses and transport failures.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
message: Human-readable error message (from the API ``error`` field when present).
|
|
13
|
+
status_code: HTTP status code, or ``None`` for transport-level failures.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
22
|
+
if self.status_code is not None:
|
|
23
|
+
return f"[{self.status_code}] {self.message}"
|
|
24
|
+
return self.message
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticationError(SightRadarError):
|
|
28
|
+
"""Raised on 401 — the API key is missing, malformed, or revoked."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InsufficientCreditsError(SightRadarError):
|
|
32
|
+
"""Raised on 402 — the wallet does not have enough credits for the operation."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotFoundError(SightRadarError):
|
|
36
|
+
"""Raised on 404 — the collection, key, or resource does not exist."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RateLimitError(SightRadarError):
|
|
40
|
+
"""Raised on 429 — too many requests; back off and retry."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def error_for_status(status_code: int, message: str) -> SightRadarError:
|
|
44
|
+
"""Map an HTTP status to the most specific error subclass."""
|
|
45
|
+
if status_code == 401:
|
|
46
|
+
return AuthenticationError(message, status_code)
|
|
47
|
+
if status_code == 402:
|
|
48
|
+
return InsufficientCreditsError(message, status_code)
|
|
49
|
+
if status_code == 404:
|
|
50
|
+
return NotFoundError(message, status_code)
|
|
51
|
+
if status_code == 429:
|
|
52
|
+
return RateLimitError(message, status_code)
|
|
53
|
+
return SightRadarError(message, status_code)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Typed response models.
|
|
2
|
+
|
|
3
|
+
These are lightweight dataclasses built from the JSON the API returns. Each has
|
|
4
|
+
a ``from_dict`` constructor that is tolerant of unknown/extra fields (forward
|
|
5
|
+
compatible) and exposes the raw payload via ``.raw`` for anything not modelled.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Collection:
|
|
16
|
+
collection_id: str
|
|
17
|
+
status: str
|
|
18
|
+
photo_count: int = 0
|
|
19
|
+
face_count: int = 0
|
|
20
|
+
selfie_count: int = 0
|
|
21
|
+
created_at: Optional[str] = None
|
|
22
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Collection":
|
|
26
|
+
return cls(
|
|
27
|
+
collection_id=d.get("collection_id", ""),
|
|
28
|
+
status=d.get("status", ""),
|
|
29
|
+
photo_count=d.get("photo_count", 0) or 0,
|
|
30
|
+
face_count=d.get("face_count", 0) or 0,
|
|
31
|
+
selfie_count=d.get("selfie_count", 0) or 0,
|
|
32
|
+
created_at=d.get("created_at"),
|
|
33
|
+
raw=d,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Match:
|
|
39
|
+
photo_id: Optional[str] = None
|
|
40
|
+
similarity: Optional[float] = None
|
|
41
|
+
point_id: Optional[str] = None
|
|
42
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Match":
|
|
46
|
+
return cls(
|
|
47
|
+
photo_id=d.get("photo_id") or d.get("photoId"),
|
|
48
|
+
similarity=d.get("similarity"),
|
|
49
|
+
point_id=d.get("point_id") or d.get("id"),
|
|
50
|
+
raw=d,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SearchResult:
|
|
56
|
+
collection_id: str = ""
|
|
57
|
+
matches: List[Match] = field(default_factory=list)
|
|
58
|
+
photo_ids: List[str] = field(default_factory=list)
|
|
59
|
+
reason: Optional[str] = None
|
|
60
|
+
model_version: Optional[str] = None
|
|
61
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def found(self) -> bool:
|
|
65
|
+
"""True when at least one match was returned."""
|
|
66
|
+
return len(self.matches) > 0
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, d: Dict[str, Any]) -> "SearchResult":
|
|
70
|
+
return cls(
|
|
71
|
+
collection_id=d.get("collection_id", ""),
|
|
72
|
+
matches=[Match.from_dict(m) for m in d.get("matches", []) or []],
|
|
73
|
+
photo_ids=list(d.get("photo_ids", []) or []),
|
|
74
|
+
reason=d.get("reason"),
|
|
75
|
+
model_version=d.get("model_version"),
|
|
76
|
+
raw=d,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class IndexResult:
|
|
82
|
+
collection_id: str = ""
|
|
83
|
+
photo_id: Optional[str] = None
|
|
84
|
+
indexed: int = 0
|
|
85
|
+
detected_face_count: int = 0
|
|
86
|
+
rejected_face_count: int = 0
|
|
87
|
+
faces: List[Dict[str, Any]] = field(default_factory=list)
|
|
88
|
+
model_version: Optional[str] = None
|
|
89
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, d: Dict[str, Any]) -> "IndexResult":
|
|
93
|
+
return cls(
|
|
94
|
+
collection_id=d.get("collection_id", ""),
|
|
95
|
+
photo_id=d.get("photo_id") or d.get("photoId"),
|
|
96
|
+
indexed=d.get("indexed", 0) or 0,
|
|
97
|
+
detected_face_count=d.get("detected_face_count", 0) or 0,
|
|
98
|
+
rejected_face_count=d.get("rejected_face_count", 0) or 0,
|
|
99
|
+
faces=list(d.get("faces", []) or []),
|
|
100
|
+
model_version=d.get("model_version"),
|
|
101
|
+
raw=d,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class CompareResult:
|
|
107
|
+
face_found: bool = False
|
|
108
|
+
similarity: Optional[float] = None
|
|
109
|
+
match: bool = False
|
|
110
|
+
threshold: Optional[float] = None
|
|
111
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_dict(cls, d: Dict[str, Any]) -> "CompareResult":
|
|
115
|
+
return cls(
|
|
116
|
+
face_found=bool(d.get("face_found", False)),
|
|
117
|
+
similarity=d.get("similarity"),
|
|
118
|
+
match=bool(d.get("match", False)),
|
|
119
|
+
threshold=d.get("threshold"),
|
|
120
|
+
raw=d,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class DetectResult:
|
|
126
|
+
detected_face_count: int = 0
|
|
127
|
+
gated_face_count: int = 0
|
|
128
|
+
faces: List[Dict[str, Any]] = field(default_factory=list)
|
|
129
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_dict(cls, d: Dict[str, Any]) -> "DetectResult":
|
|
133
|
+
return cls(
|
|
134
|
+
detected_face_count=d.get("detected_face_count", 0) or 0,
|
|
135
|
+
gated_face_count=d.get("gated_face_count", 0) or 0,
|
|
136
|
+
faces=list(d.get("faces", []) or []),
|
|
137
|
+
raw=d,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class Wallet:
|
|
143
|
+
balance_credits: int = 0
|
|
144
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Wallet":
|
|
148
|
+
return cls(balance_credits=d.get("balance_credits", 0) or 0, raw=d)
|
|
File without changes
|