vatly-api-python 0.1.0a2__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.
vatly/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Vatly Python SDK."""
2
+
3
+ from . import models, webhooks
4
+ from ._transport import VATLY_BASE_URL, RequestOptions
5
+ from ._version import __version__
6
+ from .client import AsyncVatlyClient, VatlyClient
7
+ from .errors import (
8
+ VatlyApiError,
9
+ VatlyAuthError,
10
+ VatlyConflictError,
11
+ VatlyConnectionError,
12
+ VatlyError,
13
+ VatlyForbiddenError,
14
+ VatlyInvalidSignatureError,
15
+ VatlyNotFoundError,
16
+ VatlyRateLimitError,
17
+ VatlyTimeoutError,
18
+ VatlyValidationError,
19
+ )
20
+ from .pagination import Page, VatlyPaginationError
21
+
22
+ __all__ = [
23
+ "VATLY_BASE_URL",
24
+ "AsyncVatlyClient",
25
+ "Page",
26
+ "RequestOptions",
27
+ "VatlyApiError",
28
+ "VatlyAuthError",
29
+ "VatlyClient",
30
+ "VatlyConflictError",
31
+ "VatlyConnectionError",
32
+ "VatlyError",
33
+ "VatlyForbiddenError",
34
+ "VatlyInvalidSignatureError",
35
+ "VatlyNotFoundError",
36
+ "VatlyPaginationError",
37
+ "VatlyRateLimitError",
38
+ "VatlyTimeoutError",
39
+ "VatlyValidationError",
40
+ "__version__",
41
+ "models",
42
+ "webhooks",
43
+ ]
vatly/_model.py ADDED
@@ -0,0 +1,13 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, model_validator
4
+
5
+
6
+ class VatlyModel(BaseModel):
7
+ @model_validator(mode="before")
8
+ @classmethod
9
+ def normalize_empty_metadata(cls, value: Any) -> Any:
10
+ # PHP's empty associative arrays can serialize as [] instead of {}.
11
+ if isinstance(value, dict) and value.get("metadata") == []:
12
+ return {**value, "metadata": {}}
13
+ return value
vatly/_transport.py ADDED
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ import os
6
+ import platform
7
+ import random
8
+ import re
9
+ import time
10
+ from collections.abc import Callable, Mapping
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from email.utils import parsedate_to_datetime
14
+ from typing import Any, TypeVar
15
+ from uuid import uuid4
16
+
17
+ import httpx
18
+ from pydantic import BaseModel, TypeAdapter, ValidationError
19
+ from typing_extensions import Self
20
+
21
+ from ._version import __version__
22
+ from .errors import (
23
+ VatlyApiError,
24
+ VatlyConnectionError,
25
+ VatlyError,
26
+ VatlyTimeoutError,
27
+ error_from_response,
28
+ )
29
+
30
+ T = TypeVar("T")
31
+ VATLY_BASE_URL = "https://api.vatly.com/v1"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class RequestOptions:
36
+ """Per-request overrides. Timeouts are seconds; zero disables the timeout."""
37
+
38
+ idempotency_key: str | None = None
39
+ timeout: float | None = None
40
+ headers: Mapping[str, str] | None = None
41
+
42
+
43
+ def _timeout(value: float) -> float | None:
44
+ if not math.isfinite(value) or value < 0:
45
+ raise ValueError("timeout must be finite and non-negative")
46
+ return value or None
47
+
48
+
49
+ def _retry_after(value: str | None) -> float | None:
50
+ if not value:
51
+ return None
52
+ if value.strip().isdigit():
53
+ return float(value)
54
+ try:
55
+ date = parsedate_to_datetime(value)
56
+ return max(0, (date - datetime.now(timezone.utc)).total_seconds())
57
+ except (ValueError, TypeError, OverflowError):
58
+ return None
59
+
60
+
61
+ class _Transport:
62
+ def __init__(
63
+ self,
64
+ api_key: str | None = None,
65
+ *,
66
+ base_url: str = VATLY_BASE_URL,
67
+ timeout: float = 30,
68
+ max_retries: int = 2,
69
+ default_headers: Mapping[str, str] | None = None,
70
+ idempotency_key_generator: Callable[[], str] | None = lambda: str(uuid4()),
71
+ client_info: Mapping[str, str] | bool = True,
72
+ transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None,
73
+ ) -> None:
74
+ key = api_key if api_key is not None else os.environ.get("VATLY_API_KEY", "")
75
+ if not re.fullmatch(r"(live|test)_[\w|]{18,}", key, flags=re.ASCII):
76
+ raise ValueError("Pass a valid live_ or test_ API key or set VATLY_API_KEY")
77
+ _timeout(timeout)
78
+ if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
79
+ raise ValueError("max_retries must be a non-negative integer")
80
+ self.mode = "test" if key.startswith("test_") else "live"
81
+ self.base_url = base_url.rstrip("/")
82
+ url = httpx.URL(self.base_url)
83
+ if url.scheme not in {"http", "https"} or not url.host or url.query or url.fragment:
84
+ raise ValueError("base_url must be an HTTP(S) URL without query or fragment")
85
+ self.timeout = timeout
86
+ self.max_retries = max_retries
87
+ self._key_generator = idempotency_key_generator
88
+ self._headers = httpx.Headers(
89
+ {
90
+ "Authorization": f"Bearer {key}",
91
+ "Accept": "application/json",
92
+ "User-Agent": f"vatly-api-python/{__version__}",
93
+ }
94
+ )
95
+ self._headers.update(default_headers or {})
96
+ if client_info is not False:
97
+ info = f"sdk=vatly-api-python/{__version__}; runtime=python/{platform.python_version()}; os={platform.system()}; arch={platform.machine()}"
98
+ if isinstance(client_info, Mapping):
99
+ for name in ("app", "framework"):
100
+ value = re.sub(r"[\r\n;,]|\s+", " ", client_info.get(name, "")).strip()[:64]
101
+ if value:
102
+ info += f"; {name}={value}"
103
+ self._headers["Vatly-Client-Info"] = info
104
+ self._custom_transport = transport
105
+ self._init_http()
106
+
107
+ def _init_http(self) -> None:
108
+ raise NotImplementedError
109
+
110
+ @staticmethod
111
+ def serialize(value: BaseModel | dict[str, Any] | None) -> dict[str, Any] | None:
112
+ if isinstance(value, BaseModel):
113
+ return value.model_dump(mode="json", by_alias=True, exclude_unset=True)
114
+ return dict(value) if value is not None else None
115
+
116
+ def _prepare(
117
+ self,
118
+ method: str,
119
+ path: str,
120
+ body: BaseModel | dict[str, Any] | None,
121
+ params: BaseModel | dict[str, Any] | None,
122
+ options: RequestOptions | None,
123
+ ) -> tuple[dict[str, Any], bool]:
124
+ opts = options or RequestOptions()
125
+ headers = self._headers.copy()
126
+ headers.update(opts.headers or {})
127
+ key = opts.idempotency_key
128
+ if key is None:
129
+ key = headers.get("Idempotency-Key")
130
+ if key is None and method in {"POST", "PATCH", "PUT", "DELETE"} and self._key_generator:
131
+ key = self._key_generator()
132
+ if key:
133
+ headers["Idempotency-Key"] = key
134
+ elif "Idempotency-Key" in headers:
135
+ del headers["Idempotency-Key"]
136
+ query = self.serialize(params) or {}
137
+ if query.get("startingAfter") is not None and query.get("endingBefore") is not None:
138
+ raise ValueError("startingAfter and endingBefore are mutually exclusive")
139
+ query = {k: int(v) if isinstance(v, bool) else v for k, v in query.items() if v is not None}
140
+ return {
141
+ "method": method,
142
+ "url": self.base_url + path,
143
+ "headers": headers,
144
+ "params": query,
145
+ "json": self.serialize(body),
146
+ "timeout": _timeout(self.timeout if opts.timeout is None else opts.timeout),
147
+ }, method in {"GET", "HEAD", "OPTIONS"} or bool(key)
148
+
149
+ @staticmethod
150
+ def _parse(response: httpx.Response, response_type: type[T]) -> T:
151
+ if not response.is_success:
152
+ try:
153
+ body = response.json()
154
+ except ValueError:
155
+ body = response.text
156
+ raise error_from_response(
157
+ response.status_code,
158
+ body,
159
+ str(response.request.url),
160
+ response.request.method,
161
+ _retry_after(response.headers.get("Retry-After")),
162
+ )
163
+ try:
164
+ if response_type is type(None) and not response.content:
165
+ return None
166
+ return TypeAdapter(response_type).validate_json(response.content)
167
+ except (ValueError, ValidationError) as exc:
168
+ raise VatlyApiError(
169
+ "Invalid Vatly response",
170
+ status=response.status_code,
171
+ body=response.text,
172
+ url=str(response.request.url),
173
+ method=response.request.method,
174
+ ) from exc
175
+
176
+ @staticmethod
177
+ def _network_error(exc: httpx.TransportError, kwargs: dict[str, Any]) -> VatlyConnectionError:
178
+ try:
179
+ request = exc.request
180
+ except RuntimeError:
181
+ request = httpx.Request(kwargs["method"], kwargs["url"], params=kwargs["params"])
182
+ cls = VatlyTimeoutError if isinstance(exc, httpx.TimeoutException) else VatlyConnectionError
183
+ error = cls(
184
+ "Vatly request timed out" if cls is VatlyTimeoutError else "Vatly connection failed",
185
+ method=request.method,
186
+ url=str(request.url),
187
+ )
188
+ error.__cause__ = exc
189
+ return error
190
+
191
+ @staticmethod
192
+ def _delay(error: VatlyError, attempt: int, retryable: bool, max_retries: int) -> float:
193
+ can_retry = (
194
+ isinstance(error, VatlyConnectionError)
195
+ or error.status == 429
196
+ or error.status >= 500
197
+ or (error.status == 409 and error.details.get("idempotency_outcome") == "in_progress")
198
+ )
199
+ if not retryable or attempt >= max_retries or not can_retry:
200
+ raise error
201
+ if error.retry_after is not None:
202
+ return error.retry_after
203
+ base = min(0.25 * 2.0 ** min(attempt, 6), 8.0)
204
+ return base + random.random() * base * 0.25
205
+
206
+
207
+ class SyncTransport(_Transport):
208
+ def _init_http(self) -> None:
209
+ if self._custom_transport is not None and not isinstance(
210
+ self._custom_transport, httpx.BaseTransport
211
+ ):
212
+ raise TypeError("Sync client requires an httpx.BaseTransport")
213
+ self._http = httpx.Client(transport=self._custom_transport, follow_redirects=False)
214
+
215
+ def request(
216
+ self,
217
+ method: str,
218
+ path: str,
219
+ response_type: type[T],
220
+ *,
221
+ body: BaseModel | dict[str, Any] | None = None,
222
+ params: BaseModel | dict[str, Any] | None = None,
223
+ options: RequestOptions | None = None,
224
+ ) -> T:
225
+ kwargs, retryable = self._prepare(method, path, body, params, options)
226
+ for attempt in range(self.max_retries + 1):
227
+ try:
228
+ response = self._http.request(**kwargs)
229
+ return self._parse(response, response_type)
230
+ except httpx.TransportError as exc:
231
+ error: VatlyError = self._network_error(exc, kwargs)
232
+ except VatlyError as exc:
233
+ error = exc
234
+ time.sleep(self._delay(error, attempt, retryable, self.max_retries))
235
+ raise AssertionError("unreachable")
236
+
237
+ def close(self) -> None:
238
+ self._http.close()
239
+
240
+ def __enter__(self) -> Self:
241
+ return self
242
+
243
+ def __exit__(self, *args: object) -> None:
244
+ self.close()
245
+
246
+
247
+ class AsyncTransport(_Transport):
248
+ def _init_http(self) -> None:
249
+ if self._custom_transport is not None and not isinstance(
250
+ self._custom_transport, httpx.AsyncBaseTransport
251
+ ):
252
+ raise TypeError("Async client requires an httpx.AsyncBaseTransport")
253
+ self._http = httpx.AsyncClient(transport=self._custom_transport, follow_redirects=False)
254
+
255
+ async def request(
256
+ self,
257
+ method: str,
258
+ path: str,
259
+ response_type: type[T],
260
+ *,
261
+ body: BaseModel | dict[str, Any] | None = None,
262
+ params: BaseModel | dict[str, Any] | None = None,
263
+ options: RequestOptions | None = None,
264
+ ) -> T:
265
+ kwargs, retryable = self._prepare(method, path, body, params, options)
266
+ for attempt in range(self.max_retries + 1):
267
+ try:
268
+ response = await self._http.request(**kwargs)
269
+ return self._parse(response, response_type)
270
+ except httpx.TransportError as exc:
271
+ error: VatlyError = self._network_error(exc, kwargs)
272
+ except VatlyError as exc:
273
+ error = exc
274
+ await asyncio.sleep(self._delay(error, attempt, retryable, self.max_retries))
275
+ raise AssertionError("unreachable")
276
+
277
+ async def aclose(self) -> None:
278
+ await self._http.aclose()
279
+
280
+ async def __aenter__(self) -> Self:
281
+ return self
282
+
283
+ async def __aexit__(self, *args: object) -> None:
284
+ await self.aclose()
vatly/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0a2"
vatly/client.py ADDED
@@ -0,0 +1,100 @@
1
+ """Vatly synchronous and asynchronous clients."""
2
+
3
+ from . import resources
4
+ from ._transport import AsyncTransport, SyncTransport
5
+
6
+
7
+ class VatlyClient(SyncTransport):
8
+ """Resources are initialized lazily and share this client's connection pool."""
9
+
10
+ @property
11
+ def checkouts(self) -> resources.Checkouts:
12
+ return resources.Checkouts(self)
13
+
14
+ @property
15
+ def customers(self) -> resources.Customers:
16
+ return resources.Customers(self)
17
+
18
+ @property
19
+ def one_off_products(self) -> resources.OneOffProducts:
20
+ return resources.OneOffProducts(self)
21
+
22
+ @property
23
+ def subscription_plans(self) -> resources.SubscriptionPlans:
24
+ return resources.SubscriptionPlans(self)
25
+
26
+ @property
27
+ def orders(self) -> resources.Orders:
28
+ return resources.Orders(self)
29
+
30
+ @property
31
+ def refunds(self) -> resources.Refunds:
32
+ return resources.Refunds(self)
33
+
34
+ @property
35
+ def chargebacks(self) -> resources.Chargebacks:
36
+ return resources.Chargebacks(self)
37
+
38
+ @property
39
+ def webhook_events(self) -> resources.WebhookEvents:
40
+ return resources.WebhookEvents(self)
41
+
42
+ @property
43
+ def webhook_endpoints(self) -> resources.WebhookEndpoints:
44
+ return resources.WebhookEndpoints(self)
45
+
46
+ @property
47
+ def subscriptions(self) -> resources.Subscriptions:
48
+ return resources.Subscriptions(self)
49
+
50
+ @property
51
+ def test_helpers(self) -> resources.TestHelpers:
52
+ return resources.TestHelpers(self)
53
+
54
+
55
+ class AsyncVatlyClient(AsyncTransport):
56
+ """Resources are initialized lazily and share this client's connection pool."""
57
+
58
+ @property
59
+ def checkouts(self) -> resources.AsyncCheckouts:
60
+ return resources.AsyncCheckouts(self)
61
+
62
+ @property
63
+ def customers(self) -> resources.AsyncCustomers:
64
+ return resources.AsyncCustomers(self)
65
+
66
+ @property
67
+ def one_off_products(self) -> resources.AsyncOneOffProducts:
68
+ return resources.AsyncOneOffProducts(self)
69
+
70
+ @property
71
+ def subscription_plans(self) -> resources.AsyncSubscriptionPlans:
72
+ return resources.AsyncSubscriptionPlans(self)
73
+
74
+ @property
75
+ def orders(self) -> resources.AsyncOrders:
76
+ return resources.AsyncOrders(self)
77
+
78
+ @property
79
+ def refunds(self) -> resources.AsyncRefunds:
80
+ return resources.AsyncRefunds(self)
81
+
82
+ @property
83
+ def chargebacks(self) -> resources.AsyncChargebacks:
84
+ return resources.AsyncChargebacks(self)
85
+
86
+ @property
87
+ def webhook_events(self) -> resources.AsyncWebhookEvents:
88
+ return resources.AsyncWebhookEvents(self)
89
+
90
+ @property
91
+ def webhook_endpoints(self) -> resources.AsyncWebhookEndpoints:
92
+ return resources.AsyncWebhookEndpoints(self)
93
+
94
+ @property
95
+ def subscriptions(self) -> resources.AsyncSubscriptions:
96
+ return resources.AsyncSubscriptions(self)
97
+
98
+ @property
99
+ def test_helpers(self) -> resources.AsyncTestHelpers:
100
+ return resources.AsyncTestHelpers(self)
vatly/errors.py ADDED
@@ -0,0 +1,86 @@
1
+ """Typed Vatly API, transport, and webhook errors."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class VatlyError(Exception):
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ *,
11
+ status: int = 0,
12
+ body: Any = None,
13
+ url: str | None = None,
14
+ method: str | None = None,
15
+ retry_after: float | None = None,
16
+ ) -> None:
17
+ super().__init__(message)
18
+ self.status = status
19
+ self.body = body
20
+ self.url = url
21
+ self.method = method
22
+ self.retry_after = retry_after
23
+ self.details: dict[str, Any] = body.get("details", {}) if isinstance(body, dict) else {}
24
+ self.errors: dict[str, list[str]] = body.get("errors", {}) if isinstance(body, dict) else {}
25
+
26
+
27
+ class VatlyAuthError(VatlyError):
28
+ pass
29
+
30
+
31
+ class VatlyForbiddenError(VatlyError):
32
+ pass
33
+
34
+
35
+ class VatlyNotFoundError(VatlyError):
36
+ pass
37
+
38
+
39
+ class VatlyConflictError(VatlyError):
40
+ pass
41
+
42
+
43
+ class VatlyValidationError(VatlyError):
44
+ pass
45
+
46
+
47
+ class VatlyRateLimitError(VatlyError):
48
+ pass
49
+
50
+
51
+ class VatlyApiError(VatlyError):
52
+ pass
53
+
54
+
55
+ class VatlyConnectionError(VatlyError):
56
+ pass
57
+
58
+
59
+ class VatlyTimeoutError(VatlyConnectionError):
60
+ pass
61
+
62
+
63
+ class VatlyInvalidSignatureError(Exception):
64
+ pass
65
+
66
+
67
+ def error_from_response(
68
+ status: int, body: Any, url: str, method: str, retry_after: float | None
69
+ ) -> VatlyError:
70
+ cls = {
71
+ 401: VatlyAuthError,
72
+ 403: VatlyForbiddenError,
73
+ 404: VatlyNotFoundError,
74
+ 409: VatlyConflictError,
75
+ 422: VatlyValidationError,
76
+ 429: VatlyRateLimitError,
77
+ }.get(status, VatlyApiError)
78
+ message = body.get("message") if isinstance(body, dict) else None
79
+ return cls(
80
+ message if isinstance(message, str) else f"Vatly API error ({status}).",
81
+ status=status,
82
+ body=body,
83
+ url=url,
84
+ method=method,
85
+ retry_after=retry_after,
86
+ )