easyid-python 1.0.2__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.
- easyid/__init__.py +39 -0
- easyid/bank.py +48 -0
- easyid/billing.py +62 -0
- easyid/client.py +56 -0
- easyid/error.py +20 -0
- easyid/face.py +85 -0
- easyid/idcard.py +77 -0
- easyid/phone.py +44 -0
- easyid/py.typed +1 -0
- easyid/risk.py +82 -0
- easyid/signer.py +24 -0
- easyid/transport.py +202 -0
- easyid_python-1.0.2.dist-info/METADATA +89 -0
- easyid_python-1.0.2.dist-info/RECORD +16 -0
- easyid_python-1.0.2.dist-info/WHEEL +4 -0
- easyid_python-1.0.2.dist-info/licenses/LICENSE +21 -0
easyid/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""EasyID Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .bank import BankService, BankVerify4Result
|
|
4
|
+
from .billing import BillingRecord, BillingRecordsResult, BillingService, BillingBalanceResult
|
|
5
|
+
from .client import EasyID
|
|
6
|
+
from .error import APIError, is_api_error
|
|
7
|
+
from .face import CompareResult, FaceService, FaceVerifyResult, LivenessResult
|
|
8
|
+
from .idcard import IDCardOCRResult, IDCardService, IDCardVerifyResult
|
|
9
|
+
from .phone import PhoneService, PhoneStatusResult, PhoneVerify3Result
|
|
10
|
+
from .risk import RiskDetails, RiskScoreResult, RiskService, StoreFingerprintResult
|
|
11
|
+
|
|
12
|
+
__version__ = EasyID.version
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"APIError",
|
|
16
|
+
"BankService",
|
|
17
|
+
"BankVerify4Result",
|
|
18
|
+
"BillingBalanceResult",
|
|
19
|
+
"BillingRecord",
|
|
20
|
+
"BillingRecordsResult",
|
|
21
|
+
"BillingService",
|
|
22
|
+
"CompareResult",
|
|
23
|
+
"EasyID",
|
|
24
|
+
"FaceService",
|
|
25
|
+
"FaceVerifyResult",
|
|
26
|
+
"IDCardOCRResult",
|
|
27
|
+
"IDCardService",
|
|
28
|
+
"IDCardVerifyResult",
|
|
29
|
+
"LivenessResult",
|
|
30
|
+
"PhoneService",
|
|
31
|
+
"PhoneStatusResult",
|
|
32
|
+
"PhoneVerify3Result",
|
|
33
|
+
"RiskDetails",
|
|
34
|
+
"RiskScoreResult",
|
|
35
|
+
"RiskService",
|
|
36
|
+
"StoreFingerprintResult",
|
|
37
|
+
"__version__",
|
|
38
|
+
"is_api_error",
|
|
39
|
+
]
|
easyid/bank.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Bank APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .transport import Transport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class BankVerify4Result:
|
|
13
|
+
result: bool
|
|
14
|
+
match: bool
|
|
15
|
+
bank_name: str
|
|
16
|
+
supplier: str
|
|
17
|
+
score: float
|
|
18
|
+
masked_bank_card: str
|
|
19
|
+
card_type: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BankService:
|
|
23
|
+
def __init__(self, transport: Transport) -> None:
|
|
24
|
+
self._transport = transport
|
|
25
|
+
|
|
26
|
+
def verify4(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
name: str,
|
|
30
|
+
id_number: str,
|
|
31
|
+
bank_card: str,
|
|
32
|
+
mobile: Optional[str] = None,
|
|
33
|
+
trace_id: Optional[str] = None,
|
|
34
|
+
) -> BankVerify4Result:
|
|
35
|
+
body = {
|
|
36
|
+
"name": name,
|
|
37
|
+
"id_number": id_number,
|
|
38
|
+
"bank_card": bank_card,
|
|
39
|
+
"mobile": mobile,
|
|
40
|
+
"trace_id": trace_id,
|
|
41
|
+
}
|
|
42
|
+
data = self._transport.request_json(
|
|
43
|
+
"POST",
|
|
44
|
+
"/v1/bank/verify4",
|
|
45
|
+
body={key: value for key, value in body.items() if value is not None},
|
|
46
|
+
)
|
|
47
|
+
return BankVerify4Result(**data)
|
|
48
|
+
|
easyid/billing.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Billing APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from .transport import Transport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class BillingBalanceResult:
|
|
13
|
+
app_id: str
|
|
14
|
+
available_cents: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class BillingRecord:
|
|
19
|
+
id: int
|
|
20
|
+
app_id: str
|
|
21
|
+
request_id: str
|
|
22
|
+
change_cents: int
|
|
23
|
+
balance_before: int
|
|
24
|
+
balance_after: int
|
|
25
|
+
reason: str
|
|
26
|
+
operator: str
|
|
27
|
+
created_at: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class BillingRecordsResult:
|
|
32
|
+
total: int
|
|
33
|
+
page: int
|
|
34
|
+
records: List[BillingRecord]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BillingService:
|
|
38
|
+
def __init__(self, transport: Transport) -> None:
|
|
39
|
+
self._transport = transport
|
|
40
|
+
|
|
41
|
+
def balance(self, *, app_id: str) -> BillingBalanceResult:
|
|
42
|
+
data = self._transport.request_json(
|
|
43
|
+
"GET",
|
|
44
|
+
"/v1/billing/balance",
|
|
45
|
+
query={"app_id": app_id},
|
|
46
|
+
)
|
|
47
|
+
return BillingBalanceResult(**data)
|
|
48
|
+
|
|
49
|
+
def records(self, *, app_id: str, page: int = 1, page_size: int = 20) -> BillingRecordsResult:
|
|
50
|
+
if page <= 0:
|
|
51
|
+
page = 1
|
|
52
|
+
if page_size <= 0:
|
|
53
|
+
page_size = 20
|
|
54
|
+
else:
|
|
55
|
+
page_size = min(page_size, 100)
|
|
56
|
+
data = self._transport.request_json(
|
|
57
|
+
"GET",
|
|
58
|
+
"/v1/billing/records",
|
|
59
|
+
query={"app_id": app_id, "page": page, "page_size": page_size},
|
|
60
|
+
)
|
|
61
|
+
records = [BillingRecord(**record) for record in data.get("records", [])]
|
|
62
|
+
return BillingRecordsResult(total=data["total"], page=data["page"], records=records)
|
easyid/client.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Public client entrypoint for the EasyID SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .bank import BankService
|
|
11
|
+
from .billing import BillingService
|
|
12
|
+
from .face import FaceService
|
|
13
|
+
from .idcard import IDCardService
|
|
14
|
+
from .phone import PhoneService
|
|
15
|
+
from .risk import RiskService
|
|
16
|
+
from .transport import DEFAULT_BASE_URL, SDK_VERSION, Transport
|
|
17
|
+
|
|
18
|
+
_KEY_ID_RE = re.compile(r"^ak_[0-9a-f]+$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EasyID:
|
|
22
|
+
"""EasyID API client.
|
|
23
|
+
|
|
24
|
+
The client is safe to reuse across requests.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
version = SDK_VERSION
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
key_id: str,
|
|
32
|
+
secret: str,
|
|
33
|
+
*,
|
|
34
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
35
|
+
timeout: float = 30.0,
|
|
36
|
+
session: Optional[requests.Session] = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
if not _KEY_ID_RE.fullmatch(key_id):
|
|
39
|
+
raise ValueError(f"easyid: key_id must match ak_<hex>, got: {key_id}")
|
|
40
|
+
if not secret:
|
|
41
|
+
raise ValueError("easyid: secret must not be empty")
|
|
42
|
+
|
|
43
|
+
self._transport = Transport(
|
|
44
|
+
key_id=key_id,
|
|
45
|
+
secret=secret,
|
|
46
|
+
base_url=base_url,
|
|
47
|
+
timeout=timeout,
|
|
48
|
+
session=session,
|
|
49
|
+
)
|
|
50
|
+
self.idcard = IDCardService(self._transport)
|
|
51
|
+
self.phone = PhoneService(self._transport)
|
|
52
|
+
self.face = FaceService(self._transport)
|
|
53
|
+
self.bank = BankService(self._transport)
|
|
54
|
+
self.risk = RiskService(self._transport)
|
|
55
|
+
self.billing = BillingService(self._transport)
|
|
56
|
+
|
easyid/error.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Error types for the EasyID SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class APIError(Exception):
|
|
7
|
+
"""Business error returned by the EasyID API."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, code: int, message: str, request_id: str) -> None:
|
|
10
|
+
self.code = code
|
|
11
|
+
self.message = message
|
|
12
|
+
self.request_id = request_id
|
|
13
|
+
super().__init__(f"easyid: code={code} message={message} request_id={request_id}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_api_error(exc: BaseException) -> bool:
|
|
17
|
+
"""Return True when *exc* is an :class:`APIError`."""
|
|
18
|
+
|
|
19
|
+
return isinstance(exc, APIError)
|
|
20
|
+
|
easyid/face.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Face APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .transport import FileInput, Transport, make_multipart_part
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class LivenessResult:
|
|
13
|
+
liveness: bool
|
|
14
|
+
score: float
|
|
15
|
+
method: str
|
|
16
|
+
frames_analyzed: int
|
|
17
|
+
attack_type: Optional[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CompareResult:
|
|
22
|
+
match: bool
|
|
23
|
+
score: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class FaceVerifyResult:
|
|
28
|
+
result: bool
|
|
29
|
+
supplier: str
|
|
30
|
+
score: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FaceService:
|
|
34
|
+
def __init__(self, transport: Transport) -> None:
|
|
35
|
+
self._transport = transport
|
|
36
|
+
|
|
37
|
+
def liveness(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
media: FileInput,
|
|
41
|
+
mode: Optional[str] = None,
|
|
42
|
+
filename: Optional[str] = None,
|
|
43
|
+
) -> LivenessResult:
|
|
44
|
+
fields = {}
|
|
45
|
+
if mode is not None:
|
|
46
|
+
fields["mode"] = mode
|
|
47
|
+
data = self._transport.request_multipart(
|
|
48
|
+
"/v1/face/liveness",
|
|
49
|
+
fields=fields,
|
|
50
|
+
files=[make_multipart_part("media", media, filename)],
|
|
51
|
+
)
|
|
52
|
+
return LivenessResult(**data)
|
|
53
|
+
|
|
54
|
+
def compare(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
image1: FileInput,
|
|
58
|
+
image2: FileInput,
|
|
59
|
+
filename1: Optional[str] = None,
|
|
60
|
+
filename2: Optional[str] = None,
|
|
61
|
+
) -> CompareResult:
|
|
62
|
+
data = self._transport.request_multipart(
|
|
63
|
+
"/v1/face/compare",
|
|
64
|
+
files=[
|
|
65
|
+
make_multipart_part("image1", image1, filename1),
|
|
66
|
+
make_multipart_part("image2", image2, filename2),
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
return CompareResult(**data)
|
|
70
|
+
|
|
71
|
+
def verify(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
id_number: str,
|
|
75
|
+
media_key: Optional[str] = None,
|
|
76
|
+
callback_url: Optional[str] = None,
|
|
77
|
+
) -> FaceVerifyResult:
|
|
78
|
+
body = {"id_number": id_number}
|
|
79
|
+
if media_key is not None:
|
|
80
|
+
body["media_key"] = media_key
|
|
81
|
+
if callback_url is not None:
|
|
82
|
+
body["callback_url"] = callback_url
|
|
83
|
+
data = self._transport.request_json("POST", "/v1/face/verify", body=body)
|
|
84
|
+
return FaceVerifyResult(**data)
|
|
85
|
+
|
easyid/idcard.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""ID card APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from .transport import FileInput, Transport, make_multipart_part
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class IDCardVerifyResult:
|
|
13
|
+
result: bool
|
|
14
|
+
match: bool
|
|
15
|
+
supplier: str
|
|
16
|
+
score: float
|
|
17
|
+
raw: Optional[Dict[str, Any]] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class IDCardOCRResult:
|
|
22
|
+
side: str
|
|
23
|
+
name: str = ""
|
|
24
|
+
id_number: str = ""
|
|
25
|
+
gender: str = ""
|
|
26
|
+
nation: str = ""
|
|
27
|
+
birth: str = ""
|
|
28
|
+
address: str = ""
|
|
29
|
+
issue: str = ""
|
|
30
|
+
valid: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IDCardService:
|
|
34
|
+
def __init__(self, transport: Transport) -> None:
|
|
35
|
+
self._transport = transport
|
|
36
|
+
|
|
37
|
+
def verify2(self, *, name: str, id_number: str, trace_id: Optional[str] = None) -> IDCardVerifyResult:
|
|
38
|
+
data = self._transport.request_json(
|
|
39
|
+
"POST",
|
|
40
|
+
"/v1/idcard/verify2",
|
|
41
|
+
body=_compact({"name": name, "id_number": id_number, "trace_id": trace_id}),
|
|
42
|
+
)
|
|
43
|
+
return IDCardVerifyResult(**data)
|
|
44
|
+
|
|
45
|
+
def verify3(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
name: str,
|
|
49
|
+
id_number: str,
|
|
50
|
+
mobile: str,
|
|
51
|
+
trace_id: Optional[str] = None,
|
|
52
|
+
) -> IDCardVerifyResult:
|
|
53
|
+
data = self._transport.request_json(
|
|
54
|
+
"POST",
|
|
55
|
+
"/v1/idcard/verify3",
|
|
56
|
+
body=_compact(
|
|
57
|
+
{
|
|
58
|
+
"name": name,
|
|
59
|
+
"id_number": id_number,
|
|
60
|
+
"mobile": mobile,
|
|
61
|
+
"trace_id": trace_id,
|
|
62
|
+
}
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
return IDCardVerifyResult(**data)
|
|
66
|
+
|
|
67
|
+
def ocr(self, *, side: str, image: FileInput, filename: Optional[str] = None) -> IDCardOCRResult:
|
|
68
|
+
data = self._transport.request_multipart(
|
|
69
|
+
"/v1/ocr/idcard",
|
|
70
|
+
fields={"side": side},
|
|
71
|
+
files=[make_multipart_part("image", image, filename)],
|
|
72
|
+
)
|
|
73
|
+
return IDCardOCRResult(**data)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _compact(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
77
|
+
return {key: value for key, value in payload.items() if value is not None}
|
easyid/phone.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Phone APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .transport import Transport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class PhoneStatusResult:
|
|
12
|
+
status: str
|
|
13
|
+
carrier: str
|
|
14
|
+
province: str
|
|
15
|
+
roaming: bool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class PhoneVerify3Result:
|
|
20
|
+
result: bool
|
|
21
|
+
match: bool
|
|
22
|
+
supplier: str
|
|
23
|
+
score: float
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PhoneService:
|
|
27
|
+
def __init__(self, transport: Transport) -> None:
|
|
28
|
+
self._transport = transport
|
|
29
|
+
|
|
30
|
+
def status(self, *, phone: str) -> PhoneStatusResult:
|
|
31
|
+
data = self._transport.request_json(
|
|
32
|
+
"GET",
|
|
33
|
+
"/v1/phone/status",
|
|
34
|
+
query={"phone": phone},
|
|
35
|
+
)
|
|
36
|
+
return PhoneStatusResult(**data)
|
|
37
|
+
|
|
38
|
+
def verify3(self, *, name: str, id_number: str, mobile: str) -> PhoneVerify3Result:
|
|
39
|
+
data = self._transport.request_json(
|
|
40
|
+
"POST",
|
|
41
|
+
"/v1/phone/verify3",
|
|
42
|
+
body={"name": name, "id_number": id_number, "mobile": mobile},
|
|
43
|
+
)
|
|
44
|
+
return PhoneVerify3Result(**data)
|
easyid/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
easyid/risk.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Risk APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .transport import Transport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class RiskDetails:
|
|
13
|
+
rule_score: Optional[int]
|
|
14
|
+
ml_score: Optional[int]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RiskScoreResult:
|
|
19
|
+
risk_score: int
|
|
20
|
+
reasons: List[str]
|
|
21
|
+
recommendation: str
|
|
22
|
+
details: RiskDetails
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class StoreFingerprintResult:
|
|
27
|
+
device_id: str
|
|
28
|
+
stored: bool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RiskService:
|
|
32
|
+
def __init__(self, transport: Transport) -> None:
|
|
33
|
+
self._transport = transport
|
|
34
|
+
|
|
35
|
+
def score(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
ip: Optional[str] = None,
|
|
39
|
+
device_fingerprint: Optional[str] = None,
|
|
40
|
+
device_id: Optional[str] = None,
|
|
41
|
+
phone: Optional[str] = None,
|
|
42
|
+
email: Optional[str] = None,
|
|
43
|
+
user_agent: Optional[str] = None,
|
|
44
|
+
action: Optional[str] = None,
|
|
45
|
+
amount: Optional[int] = None,
|
|
46
|
+
context: Optional[Dict[str, Any]] = None,
|
|
47
|
+
) -> RiskScoreResult:
|
|
48
|
+
body = {
|
|
49
|
+
"ip": ip,
|
|
50
|
+
"device_fingerprint": device_fingerprint,
|
|
51
|
+
"device_id": device_id,
|
|
52
|
+
"phone": phone,
|
|
53
|
+
"email": email,
|
|
54
|
+
"user_agent": user_agent,
|
|
55
|
+
"action": action,
|
|
56
|
+
"amount": amount,
|
|
57
|
+
"context": context,
|
|
58
|
+
}
|
|
59
|
+
data = self._transport.request_json(
|
|
60
|
+
"POST",
|
|
61
|
+
"/v1/risk/score",
|
|
62
|
+
body={key: value for key, value in body.items() if value is not None},
|
|
63
|
+
)
|
|
64
|
+
details = data.get("details", {})
|
|
65
|
+
return RiskScoreResult(
|
|
66
|
+
risk_score=data["risk_score"],
|
|
67
|
+
reasons=list(data.get("reasons", [])),
|
|
68
|
+
recommendation=data["recommendation"],
|
|
69
|
+
details=RiskDetails(
|
|
70
|
+
rule_score=details.get("rule_score"),
|
|
71
|
+
ml_score=details.get("ml_score"),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def store_fingerprint(self, *, device_id: str, fingerprint: Dict[str, Any]) -> StoreFingerprintResult:
|
|
76
|
+
data = self._transport.request_json(
|
|
77
|
+
"POST",
|
|
78
|
+
"/v1/device/fingerprint",
|
|
79
|
+
body={"device_id": device_id, "fingerprint": fingerprint},
|
|
80
|
+
)
|
|
81
|
+
return StoreFingerprintResult(**data)
|
|
82
|
+
|
easyid/signer.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Signing helpers for the EasyID SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
from typing import Mapping, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def sign(secret: str, timestamp: str, query: Optional[Mapping[str, str]], body: bytes) -> str:
|
|
11
|
+
"""Return the lowercase hex HMAC-SHA256 signature for a request."""
|
|
12
|
+
|
|
13
|
+
parts = []
|
|
14
|
+
if query:
|
|
15
|
+
for key in sorted(query):
|
|
16
|
+
parts.append(f"{key}={query[key]}")
|
|
17
|
+
payload = "&".join(parts)
|
|
18
|
+
if body:
|
|
19
|
+
body_text = body.decode("utf-8")
|
|
20
|
+
payload = f"{payload}&{body_text}" if payload else body_text
|
|
21
|
+
to_sign = f"{timestamp}\n{payload}"
|
|
22
|
+
digest = hmac.new(secret.encode("utf-8"), to_sign.encode("utf-8"), hashlib.sha256)
|
|
23
|
+
return digest.hexdigest()
|
|
24
|
+
|
easyid/transport.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""HTTP transport for the EasyID SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, BinaryIO, Dict, Iterable, Mapping, Optional, Tuple, Union
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from .error import APIError
|
|
15
|
+
from .signer import sign
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.easyid.com"
|
|
18
|
+
MAX_RESPONSE_BYTES = 10 << 20
|
|
19
|
+
SDK_VERSION = "1.0.0"
|
|
20
|
+
|
|
21
|
+
FileInput = Union[bytes, bytearray, BinaryIO]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class MultipartPart:
|
|
26
|
+
field_name: str
|
|
27
|
+
filename: str
|
|
28
|
+
content: bytes
|
|
29
|
+
content_type: str = "application/octet-stream"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Transport:
|
|
33
|
+
"""Low-level HTTP transport shared by all services."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
key_id: str,
|
|
39
|
+
secret: str,
|
|
40
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
41
|
+
timeout: float = 30.0,
|
|
42
|
+
session: Optional[requests.Session] = None,
|
|
43
|
+
user_agent: Optional[str] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.key_id = key_id
|
|
46
|
+
self.secret = secret
|
|
47
|
+
self.base_url = base_url.rstrip("/")
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
self.session = session or requests.Session()
|
|
50
|
+
self.user_agent = user_agent or f"easyid-python/{SDK_VERSION}"
|
|
51
|
+
|
|
52
|
+
def request_json(
|
|
53
|
+
self,
|
|
54
|
+
method: str,
|
|
55
|
+
path: str,
|
|
56
|
+
*,
|
|
57
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
58
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
59
|
+
) -> Any:
|
|
60
|
+
body_bytes = b""
|
|
61
|
+
if body is not None:
|
|
62
|
+
body_bytes = json.dumps(
|
|
63
|
+
body,
|
|
64
|
+
ensure_ascii=False,
|
|
65
|
+
separators=(",", ":"),
|
|
66
|
+
).encode("utf-8")
|
|
67
|
+
return self._send(
|
|
68
|
+
method=method,
|
|
69
|
+
path=path,
|
|
70
|
+
query=self._normalize_query(query),
|
|
71
|
+
body_bytes=body_bytes,
|
|
72
|
+
content_type="application/json",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def request_multipart(
|
|
76
|
+
self,
|
|
77
|
+
path: str,
|
|
78
|
+
*,
|
|
79
|
+
fields: Optional[Mapping[str, str]] = None,
|
|
80
|
+
files: Optional[Iterable[MultipartPart]] = None,
|
|
81
|
+
) -> Any:
|
|
82
|
+
request = requests.Request(
|
|
83
|
+
method="POST",
|
|
84
|
+
url=self.base_url + path,
|
|
85
|
+
data=fields or {},
|
|
86
|
+
files=[
|
|
87
|
+
(
|
|
88
|
+
part.field_name,
|
|
89
|
+
(part.filename, part.content, part.content_type),
|
|
90
|
+
)
|
|
91
|
+
for part in (files or [])
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
prepared = self.session.prepare_request(request)
|
|
95
|
+
body = prepared.body
|
|
96
|
+
if body is None:
|
|
97
|
+
body_bytes = b""
|
|
98
|
+
elif isinstance(body, bytes):
|
|
99
|
+
body_bytes = body
|
|
100
|
+
else:
|
|
101
|
+
body_bytes = body.encode("utf-8")
|
|
102
|
+
return self._send(
|
|
103
|
+
method="POST",
|
|
104
|
+
path=path,
|
|
105
|
+
query=None,
|
|
106
|
+
body_bytes=body_bytes,
|
|
107
|
+
content_type=prepared.headers["Content-Type"],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _send(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
method: str,
|
|
114
|
+
path: str,
|
|
115
|
+
query: Optional[Dict[str, str]],
|
|
116
|
+
body_bytes: bytes,
|
|
117
|
+
content_type: str,
|
|
118
|
+
) -> Any:
|
|
119
|
+
timestamp = str(int(time.time()))
|
|
120
|
+
signature = sign(self.secret, timestamp, query, body_bytes)
|
|
121
|
+
|
|
122
|
+
url = self.base_url + path
|
|
123
|
+
if query:
|
|
124
|
+
url = f"{url}?{urlencode(sorted(query.items()))}"
|
|
125
|
+
|
|
126
|
+
request = requests.Request(method=method, url=url, data=body_bytes)
|
|
127
|
+
prepared = self.session.prepare_request(request)
|
|
128
|
+
prepared.headers["Content-Type"] = content_type
|
|
129
|
+
prepared.headers["X-Key-ID"] = self.key_id
|
|
130
|
+
prepared.headers["X-Timestamp"] = timestamp
|
|
131
|
+
prepared.headers["X-Signature"] = signature
|
|
132
|
+
prepared.headers["User-Agent"] = self.user_agent
|
|
133
|
+
|
|
134
|
+
response = self.session.send(prepared, timeout=self.timeout, stream=True)
|
|
135
|
+
raw = self._read_response(response)
|
|
136
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
137
|
+
api_error = self._maybe_api_error(raw)
|
|
138
|
+
if api_error is not None:
|
|
139
|
+
raise api_error
|
|
140
|
+
raise requests.HTTPError(
|
|
141
|
+
f"easyid: http status {response.status_code}",
|
|
142
|
+
response=response,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
envelope = json.loads(raw.decode("utf-8"))
|
|
147
|
+
except json.JSONDecodeError as exc:
|
|
148
|
+
raise ValueError(f"easyid: decode response (status={response.status_code}): {exc}") from exc
|
|
149
|
+
|
|
150
|
+
code = int(envelope.get("code", 0))
|
|
151
|
+
if code != 0:
|
|
152
|
+
raise APIError(code, str(envelope.get("message", "")), str(envelope.get("request_id", "")))
|
|
153
|
+
return envelope.get("data")
|
|
154
|
+
|
|
155
|
+
def _read_response(self, response: requests.Response) -> bytes:
|
|
156
|
+
chunks = []
|
|
157
|
+
total = 0
|
|
158
|
+
try:
|
|
159
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
160
|
+
if not chunk:
|
|
161
|
+
continue
|
|
162
|
+
total += len(chunk)
|
|
163
|
+
if total > MAX_RESPONSE_BYTES:
|
|
164
|
+
raise ValueError("easyid: response exceeds 10 MB limit")
|
|
165
|
+
chunks.append(chunk)
|
|
166
|
+
finally:
|
|
167
|
+
response.close()
|
|
168
|
+
return b"".join(chunks)
|
|
169
|
+
|
|
170
|
+
def _maybe_api_error(self, raw: bytes) -> Optional[APIError]:
|
|
171
|
+
try:
|
|
172
|
+
envelope = json.loads(raw.decode("utf-8"))
|
|
173
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
174
|
+
return None
|
|
175
|
+
code_value = envelope.get("code", 0)
|
|
176
|
+
if code_value in (None, ""):
|
|
177
|
+
return None
|
|
178
|
+
code = int(code_value)
|
|
179
|
+
if code == 0:
|
|
180
|
+
return None
|
|
181
|
+
return APIError(code, str(envelope.get("message", "")), str(envelope.get("request_id", "")))
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _normalize_query(query: Optional[Mapping[str, Any]]) -> Optional[Dict[str, str]]:
|
|
185
|
+
if not query:
|
|
186
|
+
return None
|
|
187
|
+
return {str(key): str(value) for key, value in query.items()}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def make_multipart_part(field_name: str, file_obj: FileInput, filename: Optional[str]) -> MultipartPart:
|
|
191
|
+
"""Create a :class:`MultipartPart` from bytes or a binary file-like object."""
|
|
192
|
+
|
|
193
|
+
if isinstance(file_obj, (bytes, bytearray)):
|
|
194
|
+
content = bytes(file_obj)
|
|
195
|
+
resolved_name = filename or "upload.bin"
|
|
196
|
+
else:
|
|
197
|
+
content = file_obj.read()
|
|
198
|
+
if not isinstance(content, bytes):
|
|
199
|
+
raise TypeError("easyid: file object must return bytes")
|
|
200
|
+
guessed_name = getattr(file_obj, "name", None)
|
|
201
|
+
resolved_name = filename or (os.path.basename(guessed_name) if guessed_name else "upload.bin")
|
|
202
|
+
return MultipartPart(field_name=field_name, filename=resolved_name, content=content)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easyid-python
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Official Python SDK for the EasyID identity verification API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/easyid-com-cn/easyid-python
|
|
6
|
+
Project-URL: Repository, https://github.com/easyid-com-cn/easyid-python
|
|
7
|
+
Project-URL: Documentation, https://www.easyid.com.cn
|
|
8
|
+
Author: EasyID Team
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: easyid,identity,kyc,risk,verification
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: requests<3,>=2.31.0
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest<9,>=8; extra == 'test'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# EasyID Python SDK
|
|
30
|
+
|
|
31
|
+
Official Python SDK for the EasyID identity verification API.
|
|
32
|
+
|
|
33
|
+
EasyID 易验云 focuses on identity verification and security risk control APIs, including real-name verification, liveness detection, face recognition, phone verification, and fraud-risk related capabilities.
|
|
34
|
+
|
|
35
|
+
中文文档: [README.zh-CN.md](README.zh-CN.md)
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install easyid-python
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from easyid import EasyID
|
|
47
|
+
|
|
48
|
+
client = EasyID("ak_xxx", "sk_xxx")
|
|
49
|
+
result = client.idcard.verify2(name="张三", id_number="110101199001011234")
|
|
50
|
+
|
|
51
|
+
print(result.match)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Supported APIs
|
|
55
|
+
|
|
56
|
+
- IDCard: `verify2`, `verify3`, `ocr`
|
|
57
|
+
- Phone: `status`, `verify3`
|
|
58
|
+
- Face: `liveness`, `compare`, `verify`
|
|
59
|
+
- Bank: `verify4`
|
|
60
|
+
- Risk: `score`, `store_fingerprint`
|
|
61
|
+
- Billing: `balance`, `records`
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
- `base_url`
|
|
66
|
+
- `timeout`
|
|
67
|
+
- `session`
|
|
68
|
+
|
|
69
|
+
## Error Handling
|
|
70
|
+
|
|
71
|
+
Service-side business errors raise `APIError`.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from easyid import APIError
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
client.phone.status(phone="13800138000")
|
|
78
|
+
except APIError as exc:
|
|
79
|
+
print(exc.code, exc.message, exc.request_id)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Security Notice
|
|
83
|
+
|
|
84
|
+
This is a server-side SDK. Never expose `secret` in browsers, mobile apps, or other untrusted clients.
|
|
85
|
+
|
|
86
|
+
## Official Resources
|
|
87
|
+
|
|
88
|
+
- Official website: `https://www.easyid.com.cn/`
|
|
89
|
+
- GitHub organization: `https://github.com/easyid-com-cn/`
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
easyid/__init__.py,sha256=njg6-q6YluVxXZh-E9DQaQxW5Xt2P4NZ2Udwd_O35mo,1119
|
|
2
|
+
easyid/bank.py,sha256=LAPYl-5buGWQjJICTRVekarxmBzkidnvivGkI3as-xM,1081
|
|
3
|
+
easyid/billing.py,sha256=e-QrqSR1AFmKqDUDwfQJkdXHk5RehfsTs2tJPmcbEm8,1562
|
|
4
|
+
easyid/client.py,sha256=28wH-1rQcW3EJ7Fn_3ck7z5zp7WSg6xj1RmWW34d2ec,1511
|
|
5
|
+
easyid/error.py,sha256=i7lYC9VDU6Upa39Sew_PJJSgQiqFh9V7cT12uG9UZmQ,560
|
|
6
|
+
easyid/face.py,sha256=faX-B7rk9_-wN9_i4Z-Y318nY-xeaF7jOgpLHgFCcU8,2111
|
|
7
|
+
easyid/idcard.py,sha256=eMubWiSZUp_Caj8xc8C5MT2c-nwsn_SMZA3I23OGDJk,2083
|
|
8
|
+
easyid/phone.py,sha256=0urP0PQoEmqMrYW_9nTYh_fpYyttZTqlMeYhU3TbTiY,1031
|
|
9
|
+
easyid/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
10
|
+
easyid/risk.py,sha256=X9vdoXrGVbYXhppjYrr6yLCF46tASZ_QjYA3PVgGOWk,2281
|
|
11
|
+
easyid/signer.py,sha256=uto372PqeRJdaN_EWmyREe5OpMvJv5krkaxe8Q3PU2E,735
|
|
12
|
+
easyid/transport.py,sha256=7AE_tM5UBNkhtPm289nSf3JM5BfJJe3PmKkaiCZLx4I,6586
|
|
13
|
+
easyid_python-1.0.2.dist-info/METADATA,sha256=IG1O7BoWnC2iKz8RfDQitay86_e7-5zRMGl9JKTL2RY,2512
|
|
14
|
+
easyid_python-1.0.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
easyid_python-1.0.2.dist-info/licenses/LICENSE,sha256=kCoyPvOpIJB7kNFFsIm0nnd2yEcynll2t0Dg-AvJqVU,1068
|
|
16
|
+
easyid_python-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EasyID Team
|
|
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.
|