prava-sdk 0.1.0__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.
- prava_sdk/__init__.py +94 -0
- prava_sdk/_config.py +91 -0
- prava_sdk/_transport.py +176 -0
- prava_sdk/_version.py +1 -0
- prava_sdk/client.py +116 -0
- prava_sdk/errors.py +134 -0
- prava_sdk/models/__init__.py +71 -0
- prava_sdk/models/_base.py +66 -0
- prava_sdk/models/cards.py +72 -0
- prava_sdk/models/enums.py +58 -0
- prava_sdk/models/sessions.py +162 -0
- prava_sdk/py.typed +1 -0
- prava_sdk/resources/__init__.py +11 -0
- prava_sdk/resources/_base.py +101 -0
- prava_sdk/resources/cards.py +124 -0
- prava_sdk/resources/sessions.py +314 -0
- prava_sdk-0.1.0.dist-info/METADATA +236 -0
- prava_sdk-0.1.0.dist-info/RECORD +20 -0
- prava_sdk-0.1.0.dist-info/WHEEL +4 -0
- prava_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
prava_sdk/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Official Python server-side SDK for Prava Payments."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .client import AsyncPravaClient, PravaClient
|
|
5
|
+
from .errors import (
|
|
6
|
+
PravaAPIError,
|
|
7
|
+
PravaAuthenticationError,
|
|
8
|
+
PravaError,
|
|
9
|
+
PravaInvalidStateError,
|
|
10
|
+
PravaNotFoundError,
|
|
11
|
+
PravaPollingTimeoutError,
|
|
12
|
+
PravaRateLimitError,
|
|
13
|
+
PravaRequestTimeoutError,
|
|
14
|
+
PravaTransportError,
|
|
15
|
+
PravaValidationError,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
Card,
|
|
19
|
+
CardMetadata,
|
|
20
|
+
CardSelection,
|
|
21
|
+
CardStatus,
|
|
22
|
+
CardStatusFilter,
|
|
23
|
+
CreatedSession,
|
|
24
|
+
CreateSessionRequest,
|
|
25
|
+
DeleteCardRequest,
|
|
26
|
+
DeleteCardResponse,
|
|
27
|
+
DeleteReason,
|
|
28
|
+
Environment,
|
|
29
|
+
IntegrationType,
|
|
30
|
+
ListCardsRequest,
|
|
31
|
+
ListCardsResponse,
|
|
32
|
+
MerchantDetails,
|
|
33
|
+
PaymentLineItem,
|
|
34
|
+
PaymentProduct,
|
|
35
|
+
PaymentResult,
|
|
36
|
+
PaymentTransaction,
|
|
37
|
+
ProductDetails,
|
|
38
|
+
ProductStatus,
|
|
39
|
+
ProductStatusReport,
|
|
40
|
+
PurchaseContext,
|
|
41
|
+
ReportStatusRequest,
|
|
42
|
+
ReportStatusResponse,
|
|
43
|
+
RevokeSessionResponse,
|
|
44
|
+
SessionStatus,
|
|
45
|
+
TransactionError,
|
|
46
|
+
TransactionStatus,
|
|
47
|
+
VisaConfirmation,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"AsyncPravaClient",
|
|
52
|
+
"Card",
|
|
53
|
+
"CardMetadata",
|
|
54
|
+
"CardSelection",
|
|
55
|
+
"CardStatus",
|
|
56
|
+
"CardStatusFilter",
|
|
57
|
+
"CreateSessionRequest",
|
|
58
|
+
"CreatedSession",
|
|
59
|
+
"DeleteCardRequest",
|
|
60
|
+
"DeleteCardResponse",
|
|
61
|
+
"DeleteReason",
|
|
62
|
+
"Environment",
|
|
63
|
+
"IntegrationType",
|
|
64
|
+
"ListCardsRequest",
|
|
65
|
+
"ListCardsResponse",
|
|
66
|
+
"MerchantDetails",
|
|
67
|
+
"PaymentLineItem",
|
|
68
|
+
"PaymentProduct",
|
|
69
|
+
"PaymentResult",
|
|
70
|
+
"PaymentTransaction",
|
|
71
|
+
"PravaAPIError",
|
|
72
|
+
"PravaAuthenticationError",
|
|
73
|
+
"PravaClient",
|
|
74
|
+
"PravaError",
|
|
75
|
+
"PravaInvalidStateError",
|
|
76
|
+
"PravaNotFoundError",
|
|
77
|
+
"PravaPollingTimeoutError",
|
|
78
|
+
"PravaRateLimitError",
|
|
79
|
+
"PravaRequestTimeoutError",
|
|
80
|
+
"PravaTransportError",
|
|
81
|
+
"PravaValidationError",
|
|
82
|
+
"ProductDetails",
|
|
83
|
+
"ProductStatus",
|
|
84
|
+
"ProductStatusReport",
|
|
85
|
+
"PurchaseContext",
|
|
86
|
+
"ReportStatusRequest",
|
|
87
|
+
"ReportStatusResponse",
|
|
88
|
+
"RevokeSessionResponse",
|
|
89
|
+
"SessionStatus",
|
|
90
|
+
"TransactionError",
|
|
91
|
+
"TransactionStatus",
|
|
92
|
+
"VisaConfirmation",
|
|
93
|
+
"__version__",
|
|
94
|
+
]
|
prava_sdk/_config.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Client configuration resolution and environment validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from .errors import PravaValidationError
|
|
10
|
+
from .models import Environment
|
|
11
|
+
|
|
12
|
+
SANDBOX_BASE_URL = "https://sandbox.api.prava.space"
|
|
13
|
+
PRODUCTION_BASE_URL = "https://api.prava.space"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class ClientConfig:
|
|
18
|
+
secret_key: str = field(repr=False)
|
|
19
|
+
environment: Environment
|
|
20
|
+
base_url: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _configuration_error(message: str) -> PravaValidationError:
|
|
24
|
+
return PravaValidationError(message, code="INVALID_CONFIG")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_configuration(
|
|
28
|
+
secret_key: str | None,
|
|
29
|
+
environment: Environment | str | None,
|
|
30
|
+
base_url: str | None,
|
|
31
|
+
) -> ClientConfig:
|
|
32
|
+
resolved_key = secret_key if secret_key is not None else os.getenv("PRAVA_SECRET_KEY")
|
|
33
|
+
if not isinstance(resolved_key, str) or not resolved_key:
|
|
34
|
+
raise _configuration_error(
|
|
35
|
+
"secret_key is required; pass it explicitly or set PRAVA_SECRET_KEY"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if resolved_key.startswith("sk_test_"):
|
|
39
|
+
inferred_environment = Environment.SANDBOX
|
|
40
|
+
elif resolved_key.startswith("sk_live_"):
|
|
41
|
+
inferred_environment = Environment.PRODUCTION
|
|
42
|
+
else:
|
|
43
|
+
raise _configuration_error("secret_key must start with sk_test_ or sk_live_")
|
|
44
|
+
|
|
45
|
+
if environment is None:
|
|
46
|
+
resolved_environment = inferred_environment
|
|
47
|
+
else:
|
|
48
|
+
try:
|
|
49
|
+
resolved_environment = Environment(environment)
|
|
50
|
+
except (TypeError, ValueError) as exc:
|
|
51
|
+
raise _configuration_error("environment must be sandbox or production") from exc
|
|
52
|
+
if resolved_environment is not inferred_environment:
|
|
53
|
+
key_type = "sk_test_*" if inferred_environment is Environment.SANDBOX else "sk_live_*"
|
|
54
|
+
raise _configuration_error(
|
|
55
|
+
f"{key_type} keys cannot be used in {resolved_environment.value}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
resolved_base_url = (
|
|
59
|
+
base_url
|
|
60
|
+
if base_url is not None
|
|
61
|
+
else (
|
|
62
|
+
SANDBOX_BASE_URL if resolved_environment is Environment.SANDBOX else PRODUCTION_BASE_URL
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
parsed_url = urlparse(resolved_base_url)
|
|
67
|
+
invalid_base_url = (
|
|
68
|
+
resolved_base_url != resolved_base_url.strip()
|
|
69
|
+
or parsed_url.scheme not in {"http", "https"}
|
|
70
|
+
or not parsed_url.netloc
|
|
71
|
+
or parsed_url.hostname is None
|
|
72
|
+
or parsed_url.username is not None
|
|
73
|
+
or parsed_url.password is not None
|
|
74
|
+
or parsed_url.path not in {"", "/"}
|
|
75
|
+
or bool(parsed_url.params)
|
|
76
|
+
or bool(parsed_url.query)
|
|
77
|
+
or bool(parsed_url.fragment)
|
|
78
|
+
)
|
|
79
|
+
except (AttributeError, TypeError, ValueError):
|
|
80
|
+
invalid_base_url = True
|
|
81
|
+
|
|
82
|
+
if invalid_base_url:
|
|
83
|
+
raise _configuration_error(
|
|
84
|
+
"base_url must be an HTTP(S) origin without credentials or a path"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return ClientConfig(
|
|
88
|
+
secret_key=resolved_key,
|
|
89
|
+
environment=resolved_environment,
|
|
90
|
+
base_url=resolved_base_url.rstrip("/"),
|
|
91
|
+
)
|
prava_sdk/_transport.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Internal synchronous and asynchronous HTTP transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
10
|
+
|
|
11
|
+
from ._config import ClientConfig
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .errors import (
|
|
14
|
+
PravaAPIError,
|
|
15
|
+
PravaRequestTimeoutError,
|
|
16
|
+
PravaTransportError,
|
|
17
|
+
error_from_response,
|
|
18
|
+
)
|
|
19
|
+
from .models._base import ResponseModel
|
|
20
|
+
|
|
21
|
+
ResponseT = TypeVar("ResponseT", bound=ResponseModel)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _response_id(response: httpx.Response) -> str | None:
|
|
25
|
+
value = response.headers.get("X-Response-ID")
|
|
26
|
+
return str(value) if value is not None else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_success(response: httpx.Response, response_model: type[ResponseT]) -> ResponseT:
|
|
30
|
+
try:
|
|
31
|
+
payload = response.json()
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
raise PravaAPIError(
|
|
34
|
+
"Prava returned a non-JSON success response",
|
|
35
|
+
code="INVALID_RESPONSE",
|
|
36
|
+
status_code=response.status_code,
|
|
37
|
+
response_id=_response_id(response),
|
|
38
|
+
) from exc
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
return response_model.model_validate(payload)
|
|
42
|
+
except PydanticValidationError as exc:
|
|
43
|
+
raise PravaAPIError(
|
|
44
|
+
"Prava returned a response that does not match the documented schema",
|
|
45
|
+
code="INVALID_RESPONSE",
|
|
46
|
+
status_code=response.status_code,
|
|
47
|
+
details={
|
|
48
|
+
"validation_errors": exc.errors(include_input=False, include_url=False),
|
|
49
|
+
},
|
|
50
|
+
response_id=_response_id(response),
|
|
51
|
+
) from exc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _headers(secret_key: str) -> dict[str, str]:
|
|
55
|
+
return {
|
|
56
|
+
"Authorization": f"Bearer {secret_key}",
|
|
57
|
+
"Accept": "application/json",
|
|
58
|
+
"User-Agent": f"prava-sdk-python/{__version__}",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _closed_client_error() -> PravaTransportError:
|
|
63
|
+
return PravaTransportError(
|
|
64
|
+
"The Prava client is closed",
|
|
65
|
+
code="CLIENT_CLOSED",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SyncTransport:
|
|
70
|
+
"""Own the synchronous HTTP connection pool and response decoding."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
config: ClientConfig,
|
|
75
|
+
*,
|
|
76
|
+
timeout: float | httpx.Timeout,
|
|
77
|
+
transport: httpx.BaseTransport | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.http = httpx.Client(
|
|
80
|
+
base_url=config.base_url,
|
|
81
|
+
headers=_headers(config.secret_key),
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
transport=transport,
|
|
84
|
+
follow_redirects=False,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def request(
|
|
88
|
+
self,
|
|
89
|
+
method: str,
|
|
90
|
+
path: str,
|
|
91
|
+
*,
|
|
92
|
+
response_model: type[ResponseT],
|
|
93
|
+
expected_status: int = 200,
|
|
94
|
+
json: dict[str, Any] | None = None,
|
|
95
|
+
params: Mapping[str, Any] | None = None,
|
|
96
|
+
) -> ResponseT:
|
|
97
|
+
if self.is_closed:
|
|
98
|
+
raise _closed_client_error()
|
|
99
|
+
try:
|
|
100
|
+
response = self.http.request(method, path, json=json, params=params)
|
|
101
|
+
except httpx.TimeoutException as exc:
|
|
102
|
+
raise PravaRequestTimeoutError(
|
|
103
|
+
"Prava API request timed out",
|
|
104
|
+
code="REQUEST_TIMEOUT",
|
|
105
|
+
) from exc
|
|
106
|
+
except httpx.RequestError as exc:
|
|
107
|
+
raise PravaTransportError(
|
|
108
|
+
"Could not reach the Prava API",
|
|
109
|
+
code="TRANSPORT_ERROR",
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
if response.status_code != expected_status:
|
|
113
|
+
raise error_from_response(response)
|
|
114
|
+
return _parse_success(response, response_model)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def is_closed(self) -> bool:
|
|
118
|
+
return self.http.is_closed
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
self.http.close()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AsyncTransport:
|
|
125
|
+
"""Own the asynchronous HTTP connection pool and response decoding."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
config: ClientConfig,
|
|
130
|
+
*,
|
|
131
|
+
timeout: float | httpx.Timeout,
|
|
132
|
+
transport: httpx.AsyncBaseTransport | None,
|
|
133
|
+
) -> None:
|
|
134
|
+
self.http = httpx.AsyncClient(
|
|
135
|
+
base_url=config.base_url,
|
|
136
|
+
headers=_headers(config.secret_key),
|
|
137
|
+
timeout=timeout,
|
|
138
|
+
transport=transport,
|
|
139
|
+
follow_redirects=False,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def request(
|
|
143
|
+
self,
|
|
144
|
+
method: str,
|
|
145
|
+
path: str,
|
|
146
|
+
*,
|
|
147
|
+
response_model: type[ResponseT],
|
|
148
|
+
expected_status: int = 200,
|
|
149
|
+
json: dict[str, Any] | None = None,
|
|
150
|
+
params: Mapping[str, Any] | None = None,
|
|
151
|
+
) -> ResponseT:
|
|
152
|
+
if self.is_closed:
|
|
153
|
+
raise _closed_client_error()
|
|
154
|
+
try:
|
|
155
|
+
response = await self.http.request(method, path, json=json, params=params)
|
|
156
|
+
except httpx.TimeoutException as exc:
|
|
157
|
+
raise PravaRequestTimeoutError(
|
|
158
|
+
"Prava API request timed out",
|
|
159
|
+
code="REQUEST_TIMEOUT",
|
|
160
|
+
) from exc
|
|
161
|
+
except httpx.RequestError as exc:
|
|
162
|
+
raise PravaTransportError(
|
|
163
|
+
"Could not reach the Prava API",
|
|
164
|
+
code="TRANSPORT_ERROR",
|
|
165
|
+
) from exc
|
|
166
|
+
|
|
167
|
+
if response.status_code != expected_status:
|
|
168
|
+
raise error_from_response(response)
|
|
169
|
+
return _parse_success(response, response_model)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def is_closed(self) -> bool:
|
|
173
|
+
return self.http.is_closed
|
|
174
|
+
|
|
175
|
+
async def close(self) -> None:
|
|
176
|
+
await self.http.aclose()
|
prava_sdk/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
prava_sdk/client.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Public synchronous and asynchronous Prava clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._config import resolve_configuration
|
|
8
|
+
from ._transport import AsyncTransport, SyncTransport
|
|
9
|
+
from .models import Environment
|
|
10
|
+
from .resources import (
|
|
11
|
+
AsyncCardsResource,
|
|
12
|
+
AsyncSessionsResource,
|
|
13
|
+
CardsResource,
|
|
14
|
+
SessionsResource,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PravaClient:
|
|
19
|
+
"""Synchronous server-side Prava Payments client."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
secret_key: str | None = None,
|
|
24
|
+
*,
|
|
25
|
+
environment: Environment | str | None = None,
|
|
26
|
+
base_url: str | None = None,
|
|
27
|
+
timeout: float | httpx.Timeout = 10.0,
|
|
28
|
+
transport: httpx.BaseTransport | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
config = resolve_configuration(secret_key, environment, base_url)
|
|
31
|
+
self._environment = config.environment
|
|
32
|
+
self._base_url = config.base_url
|
|
33
|
+
self._transport = SyncTransport(config, timeout=timeout, transport=transport)
|
|
34
|
+
self.sessions = SessionsResource(self._transport)
|
|
35
|
+
self.cards = CardsResource(self._transport)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def environment(self) -> Environment:
|
|
39
|
+
"""Resolved API environment selected from the secret-key prefix."""
|
|
40
|
+
|
|
41
|
+
return self._environment
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def base_url(self) -> str:
|
|
45
|
+
"""Normalized API origin used by this client."""
|
|
46
|
+
|
|
47
|
+
return self._base_url
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_closed(self) -> bool:
|
|
51
|
+
"""Whether the client's underlying connection pool has been closed."""
|
|
52
|
+
|
|
53
|
+
return self._transport.is_closed
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Close the client's connection pool."""
|
|
57
|
+
|
|
58
|
+
self._transport.close()
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> PravaClient:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *_: object) -> None:
|
|
64
|
+
self.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AsyncPravaClient:
|
|
68
|
+
"""Asynchronous server-side Prava Payments client."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
secret_key: str | None = None,
|
|
73
|
+
*,
|
|
74
|
+
environment: Environment | str | None = None,
|
|
75
|
+
base_url: str | None = None,
|
|
76
|
+
timeout: float | httpx.Timeout = 10.0,
|
|
77
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
config = resolve_configuration(secret_key, environment, base_url)
|
|
80
|
+
self._environment = config.environment
|
|
81
|
+
self._base_url = config.base_url
|
|
82
|
+
self._transport = AsyncTransport(config, timeout=timeout, transport=transport)
|
|
83
|
+
self.sessions = AsyncSessionsResource(self._transport)
|
|
84
|
+
self.cards = AsyncCardsResource(self._transport)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def environment(self) -> Environment:
|
|
88
|
+
"""Resolved API environment selected from the secret-key prefix."""
|
|
89
|
+
|
|
90
|
+
return self._environment
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def base_url(self) -> str:
|
|
94
|
+
"""Normalized API origin used by this client."""
|
|
95
|
+
|
|
96
|
+
return self._base_url
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_closed(self) -> bool:
|
|
100
|
+
"""Whether the client's underlying connection pool has been closed."""
|
|
101
|
+
|
|
102
|
+
return self._transport.is_closed
|
|
103
|
+
|
|
104
|
+
async def aclose(self) -> None:
|
|
105
|
+
"""Close the client's connection pool."""
|
|
106
|
+
|
|
107
|
+
await self._transport.close()
|
|
108
|
+
|
|
109
|
+
async def __aenter__(self) -> AsyncPravaClient:
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
async def __aexit__(self, *_: object) -> None:
|
|
113
|
+
await self.aclose()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["AsyncPravaClient", "PravaClient"]
|
prava_sdk/errors.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Exception types and HTTP error parsing for the Prava SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PravaError(Exception):
|
|
11
|
+
"""Base exception for all SDK and API errors."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
code: str,
|
|
18
|
+
status_code: int | None = None,
|
|
19
|
+
details: dict[str, Any] | None = None,
|
|
20
|
+
response_id: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.code = code
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.details = details
|
|
27
|
+
self.response_id = response_id
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
rendered = f"[{self.code}] {self.message}"
|
|
31
|
+
if self.response_id:
|
|
32
|
+
rendered += f" (response_id={self.response_id})"
|
|
33
|
+
return rendered
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PravaAuthenticationError(PravaError):
|
|
37
|
+
"""The API rejected the supplied credentials."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PravaValidationError(PravaError):
|
|
41
|
+
"""The API rejected request fields or local configuration."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PravaNotFoundError(PravaError):
|
|
45
|
+
"""The requested Prava resource does not exist."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PravaInvalidStateError(PravaError):
|
|
49
|
+
"""The operation is invalid for the resource's current state."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PravaRateLimitError(PravaError):
|
|
53
|
+
"""The account or endpoint allowance has been exhausted."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PravaAPIError(PravaError):
|
|
57
|
+
"""The API returned an unexpected or server-side failure."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PravaTransportError(PravaError):
|
|
61
|
+
"""The API could not be reached."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PravaRequestTimeoutError(PravaTransportError):
|
|
65
|
+
"""An individual HTTP request timed out."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PravaPollingTimeoutError(PravaError):
|
|
69
|
+
"""Payment-result polling did not finish within the requested timeout."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_VALIDATION_CODES = {"VAL_2001", "INVALID_REQUEST"}
|
|
73
|
+
_INVALID_STATE_CODES = {"INVALID_STATE", "MANDATE_EXPIRED", "CARD_INACTIVE"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def error_from_response(response: httpx.Response) -> PravaError:
|
|
77
|
+
"""Convert Prava's common error envelope into a typed exception."""
|
|
78
|
+
|
|
79
|
+
code = f"HTTP_{response.status_code}"
|
|
80
|
+
message = response.reason_phrase or "Prava API request failed"
|
|
81
|
+
details: dict[str, Any] | None = None
|
|
82
|
+
response_id = response.headers.get("X-Response-ID")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
payload = response.json()
|
|
86
|
+
except ValueError:
|
|
87
|
+
payload = None
|
|
88
|
+
|
|
89
|
+
if isinstance(payload, dict):
|
|
90
|
+
nested_error = payload.get("error")
|
|
91
|
+
error_payload = nested_error if isinstance(nested_error, dict) else payload
|
|
92
|
+
if isinstance(error_payload.get("code"), str):
|
|
93
|
+
code = error_payload["code"]
|
|
94
|
+
if isinstance(error_payload.get("message"), str):
|
|
95
|
+
message = error_payload["message"]
|
|
96
|
+
raw_details = error_payload.get("details")
|
|
97
|
+
if isinstance(raw_details, dict):
|
|
98
|
+
details = raw_details
|
|
99
|
+
|
|
100
|
+
error_type: type[PravaError]
|
|
101
|
+
if response.status_code in {401, 403} or code.startswith("AUTH_"):
|
|
102
|
+
error_type = PravaAuthenticationError
|
|
103
|
+
elif response.status_code == 404:
|
|
104
|
+
error_type = PravaNotFoundError
|
|
105
|
+
elif response.status_code == 409 or code in _INVALID_STATE_CODES:
|
|
106
|
+
error_type = PravaInvalidStateError
|
|
107
|
+
elif response.status_code in {400, 422} or code in _VALIDATION_CODES:
|
|
108
|
+
error_type = PravaValidationError
|
|
109
|
+
elif response.status_code == 429:
|
|
110
|
+
error_type = PravaRateLimitError
|
|
111
|
+
else:
|
|
112
|
+
error_type = PravaAPIError
|
|
113
|
+
|
|
114
|
+
return error_type(
|
|
115
|
+
message,
|
|
116
|
+
code=code,
|
|
117
|
+
status_code=response.status_code,
|
|
118
|
+
details=details,
|
|
119
|
+
response_id=response_id,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = [
|
|
124
|
+
"PravaAPIError",
|
|
125
|
+
"PravaAuthenticationError",
|
|
126
|
+
"PravaError",
|
|
127
|
+
"PravaInvalidStateError",
|
|
128
|
+
"PravaNotFoundError",
|
|
129
|
+
"PravaPollingTimeoutError",
|
|
130
|
+
"PravaRateLimitError",
|
|
131
|
+
"PravaRequestTimeoutError",
|
|
132
|
+
"PravaTransportError",
|
|
133
|
+
"PravaValidationError",
|
|
134
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Public models for the Prava Payments API."""
|
|
2
|
+
|
|
3
|
+
from .cards import (
|
|
4
|
+
Card,
|
|
5
|
+
CardMetadata,
|
|
6
|
+
DeleteCardRequest,
|
|
7
|
+
DeleteCardResponse,
|
|
8
|
+
ListCardsRequest,
|
|
9
|
+
ListCardsResponse,
|
|
10
|
+
)
|
|
11
|
+
from .enums import (
|
|
12
|
+
CardStatus,
|
|
13
|
+
CardStatusFilter,
|
|
14
|
+
DeleteReason,
|
|
15
|
+
Environment,
|
|
16
|
+
IntegrationType,
|
|
17
|
+
ProductStatus,
|
|
18
|
+
SessionStatus,
|
|
19
|
+
TransactionStatus,
|
|
20
|
+
VisaConfirmation,
|
|
21
|
+
)
|
|
22
|
+
from .sessions import (
|
|
23
|
+
CardSelection,
|
|
24
|
+
CreatedSession,
|
|
25
|
+
CreateSessionRequest,
|
|
26
|
+
MerchantDetails,
|
|
27
|
+
PaymentLineItem,
|
|
28
|
+
PaymentProduct,
|
|
29
|
+
PaymentResult,
|
|
30
|
+
PaymentTransaction,
|
|
31
|
+
ProductDetails,
|
|
32
|
+
ProductStatusReport,
|
|
33
|
+
PurchaseContext,
|
|
34
|
+
ReportStatusRequest,
|
|
35
|
+
ReportStatusResponse,
|
|
36
|
+
RevokeSessionResponse,
|
|
37
|
+
TransactionError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Card",
|
|
42
|
+
"CardMetadata",
|
|
43
|
+
"CardSelection",
|
|
44
|
+
"CardStatus",
|
|
45
|
+
"CardStatusFilter",
|
|
46
|
+
"CreateSessionRequest",
|
|
47
|
+
"CreatedSession",
|
|
48
|
+
"DeleteCardRequest",
|
|
49
|
+
"DeleteCardResponse",
|
|
50
|
+
"DeleteReason",
|
|
51
|
+
"Environment",
|
|
52
|
+
"IntegrationType",
|
|
53
|
+
"ListCardsRequest",
|
|
54
|
+
"ListCardsResponse",
|
|
55
|
+
"MerchantDetails",
|
|
56
|
+
"PaymentLineItem",
|
|
57
|
+
"PaymentProduct",
|
|
58
|
+
"PaymentResult",
|
|
59
|
+
"PaymentTransaction",
|
|
60
|
+
"ProductDetails",
|
|
61
|
+
"ProductStatus",
|
|
62
|
+
"ProductStatusReport",
|
|
63
|
+
"PurchaseContext",
|
|
64
|
+
"ReportStatusRequest",
|
|
65
|
+
"ReportStatusResponse",
|
|
66
|
+
"RevokeSessionResponse",
|
|
67
|
+
"SessionStatus",
|
|
68
|
+
"TransactionError",
|
|
69
|
+
"TransactionStatus",
|
|
70
|
+
"VisaConfirmation",
|
|
71
|
+
]
|