python-mope 0.3.0__tar.gz → 0.3.1__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.
- {python_mope-0.3.0 → python_mope-0.3.1}/PKG-INFO +23 -1
- {python_mope-0.3.0 → python_mope-0.3.1}/README.md +22 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/__version__.py +1 -1
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/client.py +35 -5
- python_mope-0.3.1/mope/errors.py +52 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/resources/shop.py +3 -2
- {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/PKG-INFO +23 -1
- {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/SOURCES.txt +1 -1
- python_mope-0.3.1/tests/test_mope.py +204 -0
- python_mope-0.3.0/tests/test_models.py +0 -167
- python_mope-0.3.0/tests/test_mope.py +0 -126
- {python_mope-0.3.0 → python_mope-0.3.1}/LICENSE +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/__init__.py +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/json.py +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/models/__init__.py +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/models/payments.py +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/py.typed +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/resources/__init__.py +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/mope/transport.py +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/pyproject.toml +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/dependency_links.txt +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/requires.txt +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/top_level.txt +0 -0
- {python_mope-0.3.0 → python_mope-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mope
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A client library for interacting with the Mopé Web Shop API
|
|
5
5
|
Author-email: Lemuel Boyce <lemuel@vokality.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -52,6 +52,28 @@ mope = Mope(token="test_123")
|
|
|
52
52
|
mope.shop.get_payment_request(payment_id="123")
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### Timeouts and errors
|
|
56
|
+
|
|
57
|
+
Requests use a 5-second connect timeout and a 30-second read timeout by default.
|
|
58
|
+
Configure both with `Mope(token="test_123", timeout=(3.0, 15.0))`, or pass a
|
|
59
|
+
single number of seconds to apply to both. `mope.client.Client` accepts the same
|
|
60
|
+
keyword argument. Values must be finite and greater than zero. The read timeout
|
|
61
|
+
limits inactivity between received bytes, not the total duration of a request.
|
|
62
|
+
|
|
63
|
+
Shop methods raise `mope.errors.MopeAPIError` for HTTP errors. It is a subclass
|
|
64
|
+
of `requests.HTTPError`, so existing handlers keep working. Its `status_code`,
|
|
65
|
+
`code`, `message`, and `data` fields expose the HTTP status and validated Mopé
|
|
66
|
+
error details, such as code `1033` for account limits exceeded. If the error body
|
|
67
|
+
is missing or malformed, `code` and `data` are `None` and `message` retains the
|
|
68
|
+
HTTP error description. The original response and exception cause are preserved.
|
|
69
|
+
|
|
70
|
+
Connection failures and timeouts remain `requests.RequestException` subclasses.
|
|
71
|
+
Malformed successful responses still raise `TypeError`, `ValueError`, or
|
|
72
|
+
`KeyError`; invalid JSON raises `requests.exceptions.JSONDecodeError`, which is
|
|
73
|
+
also a `ValueError`. Applications should translate these errors at the API call
|
|
74
|
+
boundary and decide which failures are retryable. Requests are not automatically
|
|
75
|
+
retried, including payment creation after a timeout.
|
|
76
|
+
|
|
55
77
|
### Development
|
|
56
78
|
|
|
57
79
|
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run:
|
|
@@ -26,6 +26,28 @@ mope = Mope(token="test_123")
|
|
|
26
26
|
mope.shop.get_payment_request(payment_id="123")
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
### Timeouts and errors
|
|
30
|
+
|
|
31
|
+
Requests use a 5-second connect timeout and a 30-second read timeout by default.
|
|
32
|
+
Configure both with `Mope(token="test_123", timeout=(3.0, 15.0))`, or pass a
|
|
33
|
+
single number of seconds to apply to both. `mope.client.Client` accepts the same
|
|
34
|
+
keyword argument. Values must be finite and greater than zero. The read timeout
|
|
35
|
+
limits inactivity between received bytes, not the total duration of a request.
|
|
36
|
+
|
|
37
|
+
Shop methods raise `mope.errors.MopeAPIError` for HTTP errors. It is a subclass
|
|
38
|
+
of `requests.HTTPError`, so existing handlers keep working. Its `status_code`,
|
|
39
|
+
`code`, `message`, and `data` fields expose the HTTP status and validated Mopé
|
|
40
|
+
error details, such as code `1033` for account limits exceeded. If the error body
|
|
41
|
+
is missing or malformed, `code` and `data` are `None` and `message` retains the
|
|
42
|
+
HTTP error description. The original response and exception cause are preserved.
|
|
43
|
+
|
|
44
|
+
Connection failures and timeouts remain `requests.RequestException` subclasses.
|
|
45
|
+
Malformed successful responses still raise `TypeError`, `ValueError`, or
|
|
46
|
+
`KeyError`; invalid JSON raises `requests.exceptions.JSONDecodeError`, which is
|
|
47
|
+
also a `ValueError`. Applications should translate these errors at the API call
|
|
48
|
+
boundary and decide which failures are retryable. Requests are not automatically
|
|
49
|
+
retried, including payment creation after a timeout.
|
|
50
|
+
|
|
29
51
|
### Development
|
|
30
52
|
|
|
31
53
|
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run:
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__author__ = "lemuel@vokality.com"
|
|
2
|
-
__version__ = "0.3.
|
|
2
|
+
__version__ = "0.3.1"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from collections.abc import Iterable, Mapping, Set
|
|
2
|
+
from math import isfinite
|
|
2
3
|
from typing import IO, Protocol, TypeAlias
|
|
3
4
|
from urllib.parse import urljoin
|
|
4
5
|
|
|
@@ -29,19 +30,45 @@ RequestData: TypeAlias = (
|
|
|
29
30
|
| str
|
|
30
31
|
| bytes
|
|
31
32
|
)
|
|
33
|
+
RequestTimeout: TypeAlias = float | tuple[float, float]
|
|
32
34
|
|
|
33
35
|
_user_agent = f"Python Mope {__version__}"
|
|
36
|
+
_BASE_URL = "https://api.mope.sr/api/"
|
|
37
|
+
_DEFAULT_TIMEOUT = (5.0, 30.0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _timeout_seconds(value: object) -> float:
|
|
41
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
42
|
+
raise TypeError("timeout must contain numbers of seconds")
|
|
43
|
+
try:
|
|
44
|
+
seconds = float(value)
|
|
45
|
+
except OverflowError as error:
|
|
46
|
+
raise ValueError("timeout must be finite and greater than zero") from error
|
|
47
|
+
if not isfinite(seconds) or seconds <= 0:
|
|
48
|
+
raise ValueError("timeout must be finite and greater than zero")
|
|
49
|
+
return seconds
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _request_timeout(value: object) -> RequestTimeout:
|
|
53
|
+
if isinstance(value, tuple):
|
|
54
|
+
if len(value) != 2:
|
|
55
|
+
raise ValueError("timeout must be a (connect, read) pair")
|
|
56
|
+
return (_timeout_seconds(value[0]), _timeout_seconds(value[1]))
|
|
57
|
+
return _timeout_seconds(value)
|
|
34
58
|
|
|
35
59
|
|
|
36
60
|
class Client:
|
|
37
|
-
def __init__(
|
|
61
|
+
def __init__(
|
|
62
|
+
self, token: str, *, timeout: RequestTimeout = _DEFAULT_TIMEOUT
|
|
63
|
+
) -> None:
|
|
64
|
+
self._timeout = _request_timeout(timeout)
|
|
38
65
|
self._token = token
|
|
39
66
|
self._headers = {
|
|
40
67
|
"User-Agent": _user_agent,
|
|
41
68
|
"Content-Type": "application/json",
|
|
42
69
|
"Authorization": f"Bearer {token}",
|
|
43
70
|
}
|
|
44
|
-
self._base_url =
|
|
71
|
+
self._base_url = _BASE_URL
|
|
45
72
|
|
|
46
73
|
def call_api(
|
|
47
74
|
self,
|
|
@@ -59,20 +86,23 @@ class Client:
|
|
|
59
86
|
data=data,
|
|
60
87
|
json=json_value(json, path="request"),
|
|
61
88
|
headers=self._headers,
|
|
89
|
+
timeout=self._timeout,
|
|
62
90
|
)
|
|
63
91
|
|
|
64
92
|
|
|
65
93
|
class Mope:
|
|
66
|
-
def __init__(
|
|
94
|
+
def __init__(
|
|
95
|
+
self, token: str, *, timeout: RequestTimeout = _DEFAULT_TIMEOUT
|
|
96
|
+
) -> None:
|
|
67
97
|
self._token = token
|
|
68
98
|
self._headers = {
|
|
69
99
|
"User-Agent": _user_agent,
|
|
70
100
|
"Content-Type": "application/json",
|
|
71
101
|
"Authorization": f"Bearer {token}",
|
|
72
102
|
}
|
|
73
|
-
self.base_url =
|
|
103
|
+
self.base_url = _BASE_URL
|
|
74
104
|
|
|
75
|
-
self._client = Client(token)
|
|
105
|
+
self._client = Client(token, timeout=timeout)
|
|
76
106
|
|
|
77
107
|
self.shop = ShopResource(self._client)
|
|
78
108
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Typed details for HTTP errors returned by the Mopé API."""
|
|
2
|
+
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
from requests import HTTPError, Response
|
|
6
|
+
|
|
7
|
+
from mope.json import JsonValue, json_integer, json_object, json_string
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MopeAPIError(HTTPError):
|
|
11
|
+
"""An HTTP rejection, with validated Mopé error details when available."""
|
|
12
|
+
|
|
13
|
+
response: Response | None
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
message: str,
|
|
18
|
+
*,
|
|
19
|
+
response: Response | None = None,
|
|
20
|
+
code: int | None = None,
|
|
21
|
+
data: JsonValue = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message, response=response)
|
|
24
|
+
self.message = message
|
|
25
|
+
self.code = code
|
|
26
|
+
self.data = data
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def status_code(self) -> int | None:
|
|
30
|
+
return self.response.status_code if self.response is not None else None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_response(cls, response: Response, *, fallback_message: str) -> Self:
|
|
34
|
+
try:
|
|
35
|
+
payload = json_object(response.json())
|
|
36
|
+
# The schema wraps details in "error"; documented examples are unwrapped.
|
|
37
|
+
details = json_object(payload.get("error", payload), path="error")
|
|
38
|
+
code = json_integer(details["code"], path="error.code")
|
|
39
|
+
message = json_string(details["message"], path="error.message")
|
|
40
|
+
except (TypeError, ValueError, KeyError):
|
|
41
|
+
return cls(fallback_message, response=response)
|
|
42
|
+
return cls(message, response=response, code=code, data=details.get("data"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def raise_for_status(response: Response) -> None:
|
|
46
|
+
"""Preserve HTTPError compatibility without letting a bad error body mask it."""
|
|
47
|
+
try:
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
except HTTPError as error:
|
|
50
|
+
raise MopeAPIError.from_response(
|
|
51
|
+
response, fallback_message=str(error)
|
|
52
|
+
) from error
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from mope.errors import raise_for_status
|
|
1
2
|
from mope.models.payments import CreatePaymentResponse, CurrencyCode, PaymentRequest
|
|
2
3
|
from mope.transport import ApiTransport
|
|
3
4
|
|
|
@@ -30,7 +31,7 @@ class ShopResource:
|
|
|
30
31
|
"redirect_url": redirect_url,
|
|
31
32
|
},
|
|
32
33
|
)
|
|
33
|
-
|
|
34
|
+
raise_for_status(response)
|
|
34
35
|
return CreatePaymentResponse.from_json(response.json())
|
|
35
36
|
|
|
36
37
|
def get_payment_request(self, payment_id: str) -> PaymentRequest:
|
|
@@ -40,5 +41,5 @@ class ShopResource:
|
|
|
40
41
|
|
|
41
42
|
endpoint = f"shop/payment_request/{payment_id}"
|
|
42
43
|
response = self._client.call_api("GET", endpoint=endpoint)
|
|
43
|
-
|
|
44
|
+
raise_for_status(response)
|
|
44
45
|
return PaymentRequest.from_json(response.json())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mope
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A client library for interacting with the Mopé Web Shop API
|
|
5
5
|
Author-email: Lemuel Boyce <lemuel@vokality.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -52,6 +52,28 @@ mope = Mope(token="test_123")
|
|
|
52
52
|
mope.shop.get_payment_request(payment_id="123")
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### Timeouts and errors
|
|
56
|
+
|
|
57
|
+
Requests use a 5-second connect timeout and a 30-second read timeout by default.
|
|
58
|
+
Configure both with `Mope(token="test_123", timeout=(3.0, 15.0))`, or pass a
|
|
59
|
+
single number of seconds to apply to both. `mope.client.Client` accepts the same
|
|
60
|
+
keyword argument. Values must be finite and greater than zero. The read timeout
|
|
61
|
+
limits inactivity between received bytes, not the total duration of a request.
|
|
62
|
+
|
|
63
|
+
Shop methods raise `mope.errors.MopeAPIError` for HTTP errors. It is a subclass
|
|
64
|
+
of `requests.HTTPError`, so existing handlers keep working. Its `status_code`,
|
|
65
|
+
`code`, `message`, and `data` fields expose the HTTP status and validated Mopé
|
|
66
|
+
error details, such as code `1033` for account limits exceeded. If the error body
|
|
67
|
+
is missing or malformed, `code` and `data` are `None` and `message` retains the
|
|
68
|
+
HTTP error description. The original response and exception cause are preserved.
|
|
69
|
+
|
|
70
|
+
Connection failures and timeouts remain `requests.RequestException` subclasses.
|
|
71
|
+
Malformed successful responses still raise `TypeError`, `ValueError`, or
|
|
72
|
+
`KeyError`; invalid JSON raises `requests.exceptions.JSONDecodeError`, which is
|
|
73
|
+
also a `ValueError`. Applications should translate these errors at the API call
|
|
74
|
+
boundary and decide which failures are retryable. Requests are not automatically
|
|
75
|
+
retried, including payment creation after a timeout.
|
|
76
|
+
|
|
55
77
|
### Development
|
|
56
78
|
|
|
57
79
|
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run:
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
mope/__init__.py
|
|
5
5
|
mope/__version__.py
|
|
6
6
|
mope/client.py
|
|
7
|
+
mope/errors.py
|
|
7
8
|
mope/json.py
|
|
8
9
|
mope/py.typed
|
|
9
10
|
mope/transport.py
|
|
@@ -16,5 +17,4 @@ python_mope.egg-info/SOURCES.txt
|
|
|
16
17
|
python_mope.egg-info/dependency_links.txt
|
|
17
18
|
python_mope.egg-info/requires.txt
|
|
18
19
|
python_mope.egg-info/top_level.txt
|
|
19
|
-
tests/test_models.py
|
|
20
20
|
tests/test_mope.py
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
4
|
+
from threading import Event, Thread
|
|
5
|
+
from unittest.mock import Mock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from requests import HTTPError, ReadTimeout, Response, Timeout
|
|
9
|
+
|
|
10
|
+
from mope.client import Client, Mope, RequestTimeout
|
|
11
|
+
from mope.errors import MopeAPIError
|
|
12
|
+
from mope.json import JsonObject
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def payment() -> JsonObject:
|
|
17
|
+
return {
|
|
18
|
+
"id": "payment-123",
|
|
19
|
+
"amount": 35638,
|
|
20
|
+
"created_at": "2019-12-02T14:28:14+00:00",
|
|
21
|
+
"description": "Order",
|
|
22
|
+
"expires_at": "2019-12-03T14:28:14+00:00",
|
|
23
|
+
"currency": {"name": "Surinamese dollar", "code": "SRD", "symbol": "$"},
|
|
24
|
+
"status": "paid",
|
|
25
|
+
"meta_data": [{"key": "order_id", "value": "order-1"}],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def http_request() -> Iterator[Mock]:
|
|
31
|
+
with patch("requests.request") as request:
|
|
32
|
+
yield request
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def response_for(payload: object, status: int = 200) -> Response:
|
|
36
|
+
response = Response()
|
|
37
|
+
response.status_code = status
|
|
38
|
+
response._content = json.dumps(payload).encode()
|
|
39
|
+
return response
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_create_payment_sends_correct_request(http_request: Mock) -> None:
|
|
43
|
+
created = {"id": "payment-123", "url": "https://mope.sr/p/payment-123"}
|
|
44
|
+
http_request.return_value = response_for(created)
|
|
45
|
+
result = Mope("token").shop.create_payment_request(
|
|
46
|
+
100, "Order", "order-1", "SRD", "https://example.com/return"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
assert result.as_dict() == created
|
|
50
|
+
http_request.assert_called_once()
|
|
51
|
+
sent = http_request.call_args.kwargs
|
|
52
|
+
assert sent["method"] == "POST"
|
|
53
|
+
assert sent["url"] == "https://api.mope.sr/api/shop/payment_request"
|
|
54
|
+
assert sent["headers"]["Authorization"] == "Bearer token"
|
|
55
|
+
assert sent["timeout"] == (5.0, 30.0)
|
|
56
|
+
assert sent["json"] == {
|
|
57
|
+
"amount": 100,
|
|
58
|
+
"description": "Order",
|
|
59
|
+
"order_id": "order-1",
|
|
60
|
+
"currency": "SRD",
|
|
61
|
+
"redirect_url": "https://example.com/return",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize("timeout", [2.5, (1.0, 3.0)])
|
|
66
|
+
def test_fetch_payment_preserves_details_and_configured_timeout(
|
|
67
|
+
http_request: Mock, payment: JsonObject, timeout: RequestTimeout
|
|
68
|
+
) -> None:
|
|
69
|
+
http_request.return_value = response_for(payment)
|
|
70
|
+
result = Mope("token", timeout=timeout).shop.get_payment_request("payment-123")
|
|
71
|
+
|
|
72
|
+
assert result.as_dict() == payment
|
|
73
|
+
assert result.currency.code == "SRD"
|
|
74
|
+
http_request.assert_called_once()
|
|
75
|
+
sent = http_request.call_args.kwargs
|
|
76
|
+
assert sent["method"] == "GET"
|
|
77
|
+
assert sent["url"] == "https://api.mope.sr/api/shop/payment_request/payment-123"
|
|
78
|
+
assert sent["timeout"] == timeout
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize("create", [True, False])
|
|
82
|
+
def test_shop_errors_preserve_api_details_and_http_error_compatibility(
|
|
83
|
+
http_request: Mock, create: bool
|
|
84
|
+
) -> None:
|
|
85
|
+
code = 1033 if create else 1002
|
|
86
|
+
details: JsonObject = {
|
|
87
|
+
"code": code,
|
|
88
|
+
"message": "API rejected request",
|
|
89
|
+
"data": {"reason": "test"},
|
|
90
|
+
}
|
|
91
|
+
# Exercise both shapes shown in Mopé's schema and examples.
|
|
92
|
+
response = response_for({"error": details} if create else details, status=400)
|
|
93
|
+
http_request.return_value = response
|
|
94
|
+
client = Mope("token")
|
|
95
|
+
|
|
96
|
+
with pytest.raises(HTTPError) as raised:
|
|
97
|
+
if create:
|
|
98
|
+
client.shop.create_payment_request(
|
|
99
|
+
100, "Order", "order-1", "SRD", "https://example.com"
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
client.shop.get_payment_request("payment-123")
|
|
103
|
+
|
|
104
|
+
error = raised.value
|
|
105
|
+
assert isinstance(error, MopeAPIError)
|
|
106
|
+
assert error.status_code == 400
|
|
107
|
+
assert error.code == code
|
|
108
|
+
assert error.message == details["message"]
|
|
109
|
+
assert error.data == details["data"]
|
|
110
|
+
assert error.response is response
|
|
111
|
+
assert isinstance(error.__cause__, HTTPError)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
"body",
|
|
116
|
+
[
|
|
117
|
+
b"<html>Service unavailable</html>",
|
|
118
|
+
b'{"error": {"code": "invalid", "message": "Error"}}',
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
def test_malformed_error_body_does_not_mask_http_failure(
|
|
122
|
+
http_request: Mock, body: bytes
|
|
123
|
+
) -> None:
|
|
124
|
+
response = response_for(None, status=503)
|
|
125
|
+
response._content = body
|
|
126
|
+
http_request.return_value = response
|
|
127
|
+
|
|
128
|
+
with pytest.raises(MopeAPIError) as raised:
|
|
129
|
+
Mope("token").shop.get_payment_request("payment-123")
|
|
130
|
+
|
|
131
|
+
assert raised.value.status_code == 503
|
|
132
|
+
assert raised.value.code is None
|
|
133
|
+
assert "503" in raised.value.message
|
|
134
|
+
assert raised.value.response is response
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.parametrize(
|
|
138
|
+
"invalid, expected",
|
|
139
|
+
[
|
|
140
|
+
("json", ValueError),
|
|
141
|
+
("missing", KeyError),
|
|
142
|
+
("amount", TypeError),
|
|
143
|
+
("status", ValueError),
|
|
144
|
+
("meta_data", TypeError),
|
|
145
|
+
],
|
|
146
|
+
)
|
|
147
|
+
def test_invalid_payment_response_is_rejected(
|
|
148
|
+
http_request: Mock, payment: JsonObject, invalid: str, expected: type[Exception]
|
|
149
|
+
) -> None:
|
|
150
|
+
changes: JsonObject = {
|
|
151
|
+
"amount": True,
|
|
152
|
+
"status": "unknown",
|
|
153
|
+
"meta_data": [{"key": "order_id", "value": 1}],
|
|
154
|
+
}
|
|
155
|
+
if invalid in changes:
|
|
156
|
+
payment[invalid] = changes[invalid]
|
|
157
|
+
response = response_for({} if invalid == "missing" else payment)
|
|
158
|
+
if invalid == "json":
|
|
159
|
+
response._content = b"not JSON"
|
|
160
|
+
http_request.return_value = response
|
|
161
|
+
|
|
162
|
+
with pytest.raises(expected):
|
|
163
|
+
Mope("token").shop.get_payment_request("payment-123")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.parametrize("timeout", [0.0, (1.0, float("inf"))])
|
|
167
|
+
def test_timeout_must_be_positive_and_finite(timeout: RequestTimeout) -> None:
|
|
168
|
+
with pytest.raises(ValueError, match="finite and greater than zero"):
|
|
169
|
+
Mope("token", timeout=timeout)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_payment_creation_timeout_is_not_retried(http_request: Mock) -> None:
|
|
173
|
+
failure = Timeout("Server stopped responding")
|
|
174
|
+
http_request.side_effect = failure
|
|
175
|
+
with pytest.raises(Timeout) as raised:
|
|
176
|
+
Mope("token").shop.create_payment_request(
|
|
177
|
+
100, "Order", "order-1", "SRD", "https://example.com"
|
|
178
|
+
)
|
|
179
|
+
assert raised.value is failure
|
|
180
|
+
http_request.assert_called_once()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_read_timeout_interrupts_a_stalled_http_response() -> None:
|
|
184
|
+
received = Event()
|
|
185
|
+
release = Event()
|
|
186
|
+
|
|
187
|
+
class StalledHandler(BaseHTTPRequestHandler):
|
|
188
|
+
def do_GET(self) -> None:
|
|
189
|
+
received.set()
|
|
190
|
+
release.wait(timeout=5)
|
|
191
|
+
|
|
192
|
+
with ThreadingHTTPServer(("127.0.0.1", 0), StalledHandler) as server:
|
|
193
|
+
worker = Thread(target=server.serve_forever)
|
|
194
|
+
worker.start()
|
|
195
|
+
try:
|
|
196
|
+
with pytest.raises(ReadTimeout):
|
|
197
|
+
Client("token", timeout=(1.0, 0.1)).call_api(
|
|
198
|
+
"GET", f"http://127.0.0.1:{server.server_port}/stalled"
|
|
199
|
+
)
|
|
200
|
+
assert received.is_set()
|
|
201
|
+
finally:
|
|
202
|
+
release.set()
|
|
203
|
+
server.shutdown()
|
|
204
|
+
worker.join(timeout=5)
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from mope.json import JsonObject, json_object
|
|
4
|
-
from mope.models.payments import (
|
|
5
|
-
BaseModel,
|
|
6
|
-
CreatePaymentResponse,
|
|
7
|
-
Currency,
|
|
8
|
-
PaymentRequest,
|
|
9
|
-
PaymentStatus,
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@pytest.fixture
|
|
14
|
-
def payment_payload() -> JsonObject:
|
|
15
|
-
return {
|
|
16
|
-
"id": "payment-123",
|
|
17
|
-
"amount": 35638,
|
|
18
|
-
"created_at": "2019-12-02T14:28:14+00:00",
|
|
19
|
-
"description": "Order for a lot of beer",
|
|
20
|
-
"expires_at": "2019-12-03T14:28:14+00:00",
|
|
21
|
-
"currency": {"name": "Surinamese dollar", "code": "SRD", "symbol": "$"},
|
|
22
|
-
"status": "paid",
|
|
23
|
-
"meta_data": [{"key": "order_id", "value": "beer-order-123"}],
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@pytest.mark.parametrize("status", ["open", "scanned", "unconfirmed", "paid"])
|
|
28
|
-
def test_payment_round_trip(payment_payload: JsonObject, status: PaymentStatus) -> None:
|
|
29
|
-
payment_payload["status"] = status
|
|
30
|
-
payment = PaymentRequest.from_json(payment_payload)
|
|
31
|
-
|
|
32
|
-
assert payment.amount == 35638
|
|
33
|
-
assert isinstance(payment.currency, Currency)
|
|
34
|
-
assert payment.currency.code == "SRD"
|
|
35
|
-
assert payment.status == status
|
|
36
|
-
assert payment.meta_data == [{"key": "order_id", "value": "beer-order-123"}]
|
|
37
|
-
assert payment.as_dict() == payment_payload
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@pytest.mark.parametrize("amount", [True, False, "100", 100.5, None])
|
|
41
|
-
def test_amount_is_not_coerced(payment_payload: JsonObject, amount: object) -> None:
|
|
42
|
-
payload: dict[str, object] = dict(payment_payload)
|
|
43
|
-
payload["amount"] = amount
|
|
44
|
-
|
|
45
|
-
with pytest.raises(TypeError, match="amount must be an integer"):
|
|
46
|
-
PaymentRequest.from_json(payload)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@pytest.mark.parametrize("field", ["id", "created_at", "description", "expires_at"])
|
|
50
|
-
def test_payment_string_fields_are_validated(
|
|
51
|
-
payment_payload: JsonObject, field: str
|
|
52
|
-
) -> None:
|
|
53
|
-
payment_payload[field] = 123
|
|
54
|
-
|
|
55
|
-
with pytest.raises(TypeError, match=f"{field} must be a string"):
|
|
56
|
-
PaymentRequest.from_json(payment_payload)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@pytest.mark.parametrize("status", ["expired", None, 1, True])
|
|
60
|
-
def test_invalid_status_is_rejected(
|
|
61
|
-
payment_payload: JsonObject, status: object
|
|
62
|
-
) -> None:
|
|
63
|
-
payload: dict[str, object] = dict(payment_payload)
|
|
64
|
-
payload["status"] = status
|
|
65
|
-
|
|
66
|
-
with pytest.raises(ValueError, match="status must be"):
|
|
67
|
-
PaymentRequest.from_json(payload)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@pytest.mark.parametrize(
|
|
71
|
-
"metadata",
|
|
72
|
-
[None, {}, "metadata", [1], [{"key": 1, "value": "x"}], [{"key": "x", "value": 1}]],
|
|
73
|
-
)
|
|
74
|
-
def test_metadata_is_validated(payment_payload: JsonObject, metadata: object) -> None:
|
|
75
|
-
payload: dict[str, object] = dict(payment_payload)
|
|
76
|
-
payload["meta_data"] = metadata
|
|
77
|
-
|
|
78
|
-
with pytest.raises(TypeError, match="meta_data"):
|
|
79
|
-
PaymentRequest.from_json(payload)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
@pytest.mark.parametrize("field", ["key", "value"])
|
|
83
|
-
def test_metadata_requires_both_fields(payment_payload: JsonObject, field: str) -> None:
|
|
84
|
-
entry: JsonObject = {"key": "order_id", "value": "order-123"}
|
|
85
|
-
del entry[field]
|
|
86
|
-
payment_payload["meta_data"] = [entry]
|
|
87
|
-
|
|
88
|
-
with pytest.raises(KeyError, match=field):
|
|
89
|
-
PaymentRequest.from_json(payment_payload)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
@pytest.mark.parametrize(
|
|
93
|
-
"field",
|
|
94
|
-
[
|
|
95
|
-
"id",
|
|
96
|
-
"amount",
|
|
97
|
-
"created_at",
|
|
98
|
-
"description",
|
|
99
|
-
"expires_at",
|
|
100
|
-
"currency",
|
|
101
|
-
"status",
|
|
102
|
-
"meta_data",
|
|
103
|
-
],
|
|
104
|
-
)
|
|
105
|
-
def test_payment_requires_all_fields(payment_payload: JsonObject, field: str) -> None:
|
|
106
|
-
del payment_payload[field]
|
|
107
|
-
|
|
108
|
-
with pytest.raises(KeyError, match=field):
|
|
109
|
-
PaymentRequest.from_json(payment_payload)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
@pytest.mark.parametrize("field", ["name", "code", "symbol"])
|
|
113
|
-
def test_currency_fields_are_validated(field: str) -> None:
|
|
114
|
-
payload: JsonObject = {"name": "Surinamese dollar", "code": "SRD", "symbol": "$"}
|
|
115
|
-
payload[field] = None
|
|
116
|
-
|
|
117
|
-
with pytest.raises(TypeError, match=f"currency.{field} must be a string"):
|
|
118
|
-
Currency.from_json(payload)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
@pytest.mark.parametrize("field", ["id", "url"])
|
|
122
|
-
def test_creation_fields_are_validated(field: str) -> None:
|
|
123
|
-
payload: JsonObject = {"id": "payment-123", "url": "https://mope.sr/p/payment-123"}
|
|
124
|
-
payload[field] = 123
|
|
125
|
-
|
|
126
|
-
with pytest.raises(TypeError, match=f"{field} must be a string"):
|
|
127
|
-
CreatePaymentResponse.from_json(payload)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
@pytest.mark.parametrize("model", [CreatePaymentResponse, Currency, PaymentRequest])
|
|
131
|
-
@pytest.mark.parametrize("payload", [None, [], "response", 1])
|
|
132
|
-
def test_models_require_objects(model: type[BaseModel], payload: object) -> None:
|
|
133
|
-
with pytest.raises(TypeError, match="must be a JSON object"):
|
|
134
|
-
model.from_json(payload)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
@pytest.mark.parametrize("payload", [{1: "value"}, {"nested": {1: "value"}}])
|
|
138
|
-
def test_json_object_keys_must_be_strings(payload: object) -> None:
|
|
139
|
-
with pytest.raises(TypeError, match="must have string keys"):
|
|
140
|
-
json_object(payload)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
@pytest.mark.parametrize("value", [object(), {1, 2}, (1, 2)])
|
|
144
|
-
def test_non_json_values_are_rejected(value: object) -> None:
|
|
145
|
-
with pytest.raises(TypeError, match="must be a JSON value"):
|
|
146
|
-
json_object({"nested": [value]})
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
|
|
150
|
-
def test_non_finite_json_numbers_are_rejected(value: float) -> None:
|
|
151
|
-
with pytest.raises(ValueError, match="finite JSON number"):
|
|
152
|
-
json_object({"nested": [value]})
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def test_json_preserves_valid_nested_values() -> None:
|
|
156
|
-
payload: JsonObject = {"nested": [None, True, 1, 1.5, "text", {"key": "value"}]}
|
|
157
|
-
assert json_object(payload) == payload
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def test_serialization_does_not_share_mutable_metadata(
|
|
161
|
-
payment_payload: JsonObject,
|
|
162
|
-
) -> None:
|
|
163
|
-
payment = PaymentRequest.from_json(payment_payload)
|
|
164
|
-
serialized = payment.as_dict()
|
|
165
|
-
payment.meta_data[0]["value"] = "changed"
|
|
166
|
-
|
|
167
|
-
assert serialized == payment_payload
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
from unittest import mock
|
|
3
|
-
from unittest.mock import Mock
|
|
4
|
-
|
|
5
|
-
from requests import HTTPError, Response
|
|
6
|
-
|
|
7
|
-
from mope import Mope
|
|
8
|
-
from mope.__version__ import __version__
|
|
9
|
-
from mope.json import JsonObject
|
|
10
|
-
from mope.models.payments import CreatePaymentResponse, Currency, PaymentRequest
|
|
11
|
-
|
|
12
|
-
mock_payment_request: JsonObject = {
|
|
13
|
-
"id": "c43f24a3-df4f-43b7-9045-b454529369dc",
|
|
14
|
-
"amount": 35638,
|
|
15
|
-
"created_at": "2019-12-02T14:28:14+00:00",
|
|
16
|
-
"description": "Order for a lot of beer",
|
|
17
|
-
"expires_at": "2019-12-03T14:28:14+00:00",
|
|
18
|
-
"currency": {"name": "Surinamese dollar", "code": "SRD", "symbol": "$"},
|
|
19
|
-
"status": "paid",
|
|
20
|
-
"meta_data": [],
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class MopeTestCase(unittest.TestCase):
|
|
25
|
-
def setUp(self) -> None:
|
|
26
|
-
self.mope = Mope("fake-token")
|
|
27
|
-
self.client = self.mope._client
|
|
28
|
-
|
|
29
|
-
@mock.patch("requests.request")
|
|
30
|
-
def test_call_api(self, mock_request: Mock) -> None:
|
|
31
|
-
expected_response = Response()
|
|
32
|
-
mock_request.return_value = expected_response
|
|
33
|
-
response = self.client.call_api(
|
|
34
|
-
"POST", "https://api.mope.sr/api", json={"amount": 1000}
|
|
35
|
-
)
|
|
36
|
-
self.assertIs(response, expected_response)
|
|
37
|
-
|
|
38
|
-
mock_request.assert_called_once_with(
|
|
39
|
-
url="https://api.mope.sr/api",
|
|
40
|
-
json={"amount": 1000},
|
|
41
|
-
data=None,
|
|
42
|
-
method="POST",
|
|
43
|
-
params=None,
|
|
44
|
-
headers={
|
|
45
|
-
"User-Agent": f"Python Mope {__version__}",
|
|
46
|
-
"Content-Type": "application/json",
|
|
47
|
-
"Authorization": "Bearer fake-token",
|
|
48
|
-
},
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
@mock.patch("requests.request")
|
|
52
|
-
def test_shop_create_payment_request(self, mock_request: Mock) -> None:
|
|
53
|
-
payload = {"id": "payment-123", "url": "https://api.mope.sr/pay/payment-123"}
|
|
54
|
-
mock_request.return_value.json.return_value = payload
|
|
55
|
-
|
|
56
|
-
response = self.mope.shop.create_payment_request(
|
|
57
|
-
amount=1000,
|
|
58
|
-
description="test",
|
|
59
|
-
order_id="test-1",
|
|
60
|
-
currency="SRD",
|
|
61
|
-
redirect_url="https://www.sup.com/products/1",
|
|
62
|
-
)
|
|
63
|
-
self.assertIsInstance(response, CreatePaymentResponse)
|
|
64
|
-
self.assertEqual(response.as_dict(), payload)
|
|
65
|
-
mock_request.assert_called_once_with(
|
|
66
|
-
method="POST",
|
|
67
|
-
url="https://api.mope.sr/shop/payment_request",
|
|
68
|
-
params=None,
|
|
69
|
-
data=None,
|
|
70
|
-
json={
|
|
71
|
-
"amount": 1000,
|
|
72
|
-
"order_id": "test-1",
|
|
73
|
-
"currency": "SRD",
|
|
74
|
-
"description": "test",
|
|
75
|
-
"redirect_url": "https://www.sup.com/products/1",
|
|
76
|
-
},
|
|
77
|
-
headers={
|
|
78
|
-
"User-Agent": f"Python Mope {__version__}",
|
|
79
|
-
"Content-Type": "application/json",
|
|
80
|
-
"Authorization": "Bearer fake-token",
|
|
81
|
-
},
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
@mock.patch("requests.request")
|
|
85
|
-
def test_shop_get_payment_request(self, mock_request: Mock) -> None:
|
|
86
|
-
mock_request.return_value = Mock(ok=True)
|
|
87
|
-
mock_request.return_value.json.return_value = mock_payment_request
|
|
88
|
-
|
|
89
|
-
mock_response = self.mope.shop.get_payment_request(
|
|
90
|
-
payment_id="c43f24a3-df4f-43b7-9045-b454529369dc"
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
self.assertTrue(isinstance(mock_response, PaymentRequest))
|
|
94
|
-
self.assertTrue(isinstance(mock_response.currency, Currency))
|
|
95
|
-
|
|
96
|
-
self.assertEqual(mock_response.as_dict(), mock_payment_request)
|
|
97
|
-
|
|
98
|
-
@mock.patch("requests.request")
|
|
99
|
-
def test_invalid_response_is_rejected(self, mock_request: Mock) -> None:
|
|
100
|
-
mock_request.return_value.json.return_value = {
|
|
101
|
-
**mock_payment_request,
|
|
102
|
-
"amount": True,
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
with self.assertRaisesRegex(TypeError, "amount must be an integer"):
|
|
106
|
-
self.mope.shop.get_payment_request("payment-123")
|
|
107
|
-
|
|
108
|
-
@mock.patch("requests.request")
|
|
109
|
-
def test_invalid_request_json_is_rejected(self, mock_request: Mock) -> None:
|
|
110
|
-
with self.assertRaisesRegex(TypeError, "request.amount must be a JSON value"):
|
|
111
|
-
self.client.call_api("POST", "endpoint", json={"amount": object()})
|
|
112
|
-
|
|
113
|
-
mock_request.assert_not_called()
|
|
114
|
-
|
|
115
|
-
@mock.patch("requests.request")
|
|
116
|
-
def test_http_errors_are_raised_before_parsing(self, mock_request: Mock) -> None:
|
|
117
|
-
mock_request.return_value.raise_for_status.side_effect = HTTPError("Not found")
|
|
118
|
-
|
|
119
|
-
with self.assertRaises(HTTPError):
|
|
120
|
-
self.mope.shop.get_payment_request("payment-123")
|
|
121
|
-
with self.assertRaises(HTTPError):
|
|
122
|
-
self.mope.shop.create_payment_request(
|
|
123
|
-
100, "order", "order-1", "SRD", "https://example.com"
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
mock_request.return_value.json.assert_not_called()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|