Skip to content

Commit 011b38b

Browse files
✨ add webhook support (#236)
1 parent f74bb46 commit 011b38b

File tree

6 files changed

+214
-9
lines changed

6 files changed

+214
-9
lines changed

mindee/client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from mindee.error.mindee_error import MindeeClientError, MindeeError
66
from mindee.error.mindee_http_error import handle_error
7+
from mindee.input import LocalResponse
78
from mindee.input.page_options import PageOptions
89
from mindee.input.sources import (
910
Base64Input,
@@ -178,6 +179,23 @@ def enqueue(
178179
cropper,
179180
)
180181

182+
def load_prediction(
183+
self, product_class: Type[Inference], local_response: LocalResponse
184+
) -> Union[AsyncPredictResponse, PredictResponse]:
185+
"""
186+
Load a prediction.
187+
188+
:param product_class: Class of the product to use.
189+
:param local_response: Local response to load.
190+
:return: A valid prediction.
191+
"""
192+
try:
193+
if local_response.as_dict.get("job"):
194+
return AsyncPredictResponse(product_class, local_response.as_dict)
195+
return PredictResponse(product_class, local_response.as_dict)
196+
except KeyError as exc:
197+
raise MindeeError("No prediction found in local response.") from exc
198+
181199
def parse_queued(
182200
self,
183201
product_class: Type[Inference],

mindee/input/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from mindee.input.local_response import LocalResponse
12
from mindee.input.page_options import PageOptions
23
from mindee.input.sources import (
34
Base64Input,

mindee/input/local_response.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import hashlib
2+
import hmac
3+
import io
4+
import json
5+
from pathlib import Path
6+
from typing import Any, BinaryIO, Dict, Union
7+
8+
from mindee.error import MindeeError
9+
10+
11+
class LocalResponse:
12+
"""Local response loaded from a file."""
13+
14+
_file: BinaryIO
15+
"""File object of the local response."""
16+
17+
def __init__(self, input_file: Union[BinaryIO, str, Path, bytes]):
18+
if isinstance(input_file, BinaryIO):
19+
self._file = input_file
20+
self._file.seek(0)
21+
elif isinstance(input_file, (str, Path)):
22+
with open(input_file, "r", encoding="utf-8") as file:
23+
self._file = io.BytesIO(
24+
file.read().replace("\r", "").replace("\n", "").encode()
25+
)
26+
elif isinstance(input_file, bytes):
27+
self._file = io.BytesIO(input_file)
28+
else:
29+
raise MindeeError("Incompatible type for input.")
30+
31+
@property
32+
def as_dict(self) -> Dict[str, Any]:
33+
"""
34+
Returns the dictionary representation of the file.
35+
36+
:return: A json-like dictionary.
37+
"""
38+
try:
39+
self._file.seek(0)
40+
out_json = json.loads(self._file.read())
41+
except json.decoder.JSONDecodeError as exc:
42+
raise MindeeError("File is not a valid dictionary.") from exc
43+
return out_json
44+
45+
@staticmethod
46+
def _process_secret_key(
47+
secret_key: Union[str, bytes, bytearray]
48+
) -> Union[bytes, bytearray]:
49+
"""
50+
Processes the secret key as a byte array.
51+
52+
:param secret_key: Secret key, either a string or a byte/byte array.
53+
:return: a byte/byte array secret key.
54+
"""
55+
if isinstance(secret_key, (bytes, bytearray)):
56+
return secret_key
57+
return secret_key.encode("utf-8")
58+
59+
def get_hmac_signature(self, secret_key: Union[str, bytes, bytearray]):
60+
"""
61+
Returns the hmac signature of the local response, from the secret key provided.
62+
63+
:param secret_key: Secret key, either a string or a byte/byte array.
64+
:return: The hmac signature of the local response.
65+
"""
66+
algorithm = hashlib.sha256
67+
68+
try:
69+
self._file.seek(0)
70+
mac = hmac.new(
71+
LocalResponse._process_secret_key(secret_key),
72+
self._file.read(),
73+
algorithm,
74+
)
75+
except (TypeError, ValueError) as exc:
76+
raise MindeeError("Could not get HMAC signature from payload.") from exc
77+
78+
return mac.hexdigest()
79+
80+
def is_valid_hmac_signature(
81+
self, secret_key: Union[str, bytes, bytearray], signature: str
82+
):
83+
"""
84+
Checks if the hmac signature of the local response is valid.
85+
86+
:param secret_key: Secret key, given as a string.
87+
:param signature:
88+
:return: True if the HMAC signature is valid.
89+
"""
90+
return signature == self.get_hmac_signature(secret_key)

tests/Input/test_local_response.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from mindee.input import LocalResponse
6+
from tests.api.test_async_response import ASYNC_DIR
7+
8+
9+
@pytest.fixture
10+
def dummy_secret_key():
11+
return "ogNjY44MhvKPGTtVsI8zG82JqWQa68woYQH"
12+
13+
14+
@pytest.fixture
15+
def signature():
16+
return "5ed1673e34421217a5dbfcad905ee62261a3dd66c442f3edd19302072bbf70d0"
17+
18+
19+
@pytest.fixture
20+
def file_path():
21+
return Path(ASYNC_DIR / "get_completed_empty.json")
22+
23+
24+
def test_valid_file_local_response(dummy_secret_key, signature, file_path):
25+
local_response = LocalResponse(file_path)
26+
assert local_response._file is not None
27+
assert not local_response.is_valid_hmac_signature(
28+
dummy_secret_key, "invalid signature"
29+
)
30+
assert signature == local_response.get_hmac_signature(dummy_secret_key)
31+
assert local_response.is_valid_hmac_signature(dummy_secret_key, signature)
32+
33+
34+
def test_valid_path_local_response(dummy_secret_key, signature, file_path):
35+
local_response = LocalResponse(file_path)
36+
assert local_response._file is not None
37+
assert not local_response.is_valid_hmac_signature(
38+
dummy_secret_key, "invalid signature"
39+
)
40+
assert signature == local_response.get_hmac_signature(dummy_secret_key)
41+
assert local_response.is_valid_hmac_signature(dummy_secret_key, signature)
42+
43+
44+
def test_valid_bytes_local_response(dummy_secret_key, signature, file_path):
45+
with open(file_path, "r") as f:
46+
str_response = f.read().replace("\r", "").replace("\n", "")
47+
file_bytes = str_response.encode("utf-8")
48+
local_response = LocalResponse(file_bytes)
49+
assert local_response._file is not None
50+
assert not local_response.is_valid_hmac_signature(
51+
dummy_secret_key, "invalid signature"
52+
)
53+
assert signature == local_response.get_hmac_signature(dummy_secret_key)
54+
assert local_response.is_valid_hmac_signature(dummy_secret_key, signature)

tests/api/test_async_response.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from pathlib import Path
23

34
import pytest
45
import requests
@@ -10,13 +11,13 @@
1011
from mindee.parsing.common.async_predict_response import AsyncPredictResponse
1112
from mindee.product.invoice_splitter import InvoiceSplitterV1
1213

13-
ASYNC_DIR = "./tests/data/async"
14+
ASYNC_DIR = Path("./tests/data/async")
1415

15-
FILE_PATH_POST_SUCCESS = f"{ASYNC_DIR}/post_success.json"
16-
FILE_PATH_POST_FAIL = f"{ASYNC_DIR}/post_fail_forbidden.json"
17-
FILE_PATH_GET_PROCESSING = f"{ASYNC_DIR}/get_processing.json"
18-
FILE_PATH_GET_COMPLETED = f"{ASYNC_DIR}/get_completed.json"
19-
FILE_PATH_GET_FAILED_JOB = f"{ASYNC_DIR}/get_failed_job_error.json"
16+
FILE_PATH_POST_SUCCESS = ASYNC_DIR / "post_success.json"
17+
FILE_PATH_POST_FAIL = ASYNC_DIR / "post_fail_forbidden.json"
18+
FILE_PATH_GET_PROCESSING = ASYNC_DIR / "get_processing.json"
19+
FILE_PATH_GET_COMPLETED = ASYNC_DIR / "get_completed.json"
20+
FILE_PATH_GET_FAILED_JOB = ASYNC_DIR / "get_failed_job_error.json"
2021

2122

2223
class FakeResponse(requests.Response):

tests/test_client.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
import pytest
44

5-
from mindee import Client, PageOptions, product
6-
from mindee.error.mindee_error import MindeeClientError
5+
from mindee import AsyncPredictResponse, Client, PageOptions, PredictResponse, product
6+
from mindee.error.mindee_error import MindeeClientError, MindeeError
77
from mindee.error.mindee_http_error import MindeeHTTPError
8+
from mindee.input import LocalResponse
89
from mindee.input.sources import LocalInputSource
10+
from mindee.product import InternationalIdV2, InvoiceV4
911
from mindee.product.invoice_splitter.invoice_splitter_v1 import InvoiceSplitterV1
1012
from mindee.product.receipt.receipt_v4 import ReceiptV4
11-
from tests.test_inputs import FILE_TYPES_DIR
13+
from tests.mindee_http.test_error import ERROR_DATA_DIR
14+
from tests.test_inputs import FILE_TYPES_DIR, PRODUCT_DATA_DIR
1215
from tests.utils import clear_envvars, dummy_envvars
1316

1417

@@ -113,3 +116,41 @@ def test_async_wrong_polling_delay(dummy_client: Client):
113116
input_doc = dummy_client.source_from_path(FILE_TYPES_DIR / "pdf" / "blank.pdf")
114117
with pytest.raises(MindeeClientError):
115118
dummy_client.enqueue_and_parse(InvoiceSplitterV1, input_doc, delay_sec=0)
119+
120+
121+
def test_local_response_from_sync_json(dummy_client: Client):
122+
input_file = LocalResponse(
123+
PRODUCT_DATA_DIR / "invoices" / "response_v4" / "complete.json"
124+
)
125+
with open(PRODUCT_DATA_DIR / "invoices" / "response_v4" / "summary_full.rst") as f:
126+
reference_doc = f.read()
127+
result = dummy_client.load_prediction(InvoiceV4, input_file)
128+
assert isinstance(result, PredictResponse)
129+
assert str(result.document) == reference_doc
130+
131+
132+
def test_local_response_from_async_json(dummy_client: Client):
133+
input_file = LocalResponse(
134+
PRODUCT_DATA_DIR / "international_id" / "response_v2" / "complete.json"
135+
)
136+
with open(
137+
PRODUCT_DATA_DIR / "international_id" / "response_v2" / "summary_full.rst"
138+
) as f:
139+
reference_doc = f.read()
140+
result = dummy_client.load_prediction(InternationalIdV2, input_file)
141+
assert isinstance(result, AsyncPredictResponse)
142+
assert str(result.document) == reference_doc
143+
144+
145+
def test_local_response_from_invalid_file(dummy_client: Client):
146+
local_response = LocalResponse(
147+
PRODUCT_DATA_DIR / "invoices" / "response_v4" / "summary_full.rst"
148+
)
149+
with pytest.raises(MindeeError):
150+
print(local_response.as_dict)
151+
152+
153+
def test_local_response_from_invalid_dict(dummy_client: Client):
154+
input_file = LocalResponse(ERROR_DATA_DIR / "error_400_no_details.json")
155+
with pytest.raises(MindeeError):
156+
dummy_client.load_prediction(InvoiceV4, input_file)

0 commit comments

Comments
 (0)