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 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
+ )
@@ -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
+ ]