opensettle 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.
opensettle/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """Official Python SDK for the OpenSettle API.
2
+
3
+ Public surface is intentionally narrow: the high-level clients
4
+ (:class:`OpenSettle`, :class:`AsyncOpenSettle`), the typed error
5
+ hierarchy, and the webhook verifier. Everything else is private and may
6
+ change without a major-version bump.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ._errors import (
12
+ APIError,
13
+ AuthenticationError,
14
+ ConflictError,
15
+ ErrorCode,
16
+ ForbiddenError,
17
+ InvalidRequestError,
18
+ InvalidStateTransitionError,
19
+ NetworkError,
20
+ NotFoundError,
21
+ OpenSettleError,
22
+ RateLimitError,
23
+ SettlementError,
24
+ StepUpRequiredError,
25
+ )
26
+ from ._version import SDK_VERSION
27
+ from ._webhooks import (
28
+ VerifiedWebhook,
29
+ WebhookVerificationError,
30
+ verify_webhook,
31
+ )
32
+ from .client import AsyncOpenSettle, OpenSettle
33
+
34
+ __all__ = [
35
+ "SDK_VERSION",
36
+ "APIError",
37
+ "AsyncOpenSettle",
38
+ "AuthenticationError",
39
+ "ConflictError",
40
+ "ErrorCode",
41
+ "ForbiddenError",
42
+ "InvalidRequestError",
43
+ "InvalidStateTransitionError",
44
+ "NetworkError",
45
+ "NotFoundError",
46
+ "OpenSettle",
47
+ "OpenSettleError",
48
+ "RateLimitError",
49
+ "SettlementError",
50
+ "StepUpRequiredError",
51
+ "VerifiedWebhook",
52
+ "WebhookVerificationError",
53
+ "verify_webhook",
54
+ ]
opensettle/_errors.py ADDED
@@ -0,0 +1,255 @@
1
+ """Typed error hierarchy for the OpenSettle SDK.
2
+
3
+ Mirrors the API's stable ``error.code`` taxonomy from
4
+ ``@opensettle/shared/errors`` and the Node SDK's ``src/errors.ts``
5
+ character-for-character — same 12 codes, same subclasses, same fields.
6
+
7
+ Catchers can either:
8
+ - check ``isinstance(err, OpenSettleError)`` for the broad case, then
9
+ branch on ``err.code`` to handle specifics, or
10
+ - catch the specific subclass (``InvalidRequestError``, ...) when the
11
+ handler only cares about that family.
12
+
13
+ The base class always carries ``request_id`` so users can quote it in
14
+ support — this is the same ``request_id`` the API echoes back in every
15
+ error envelope.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Optional
21
+
22
+ try: # pragma: no cover - exercised on Python 3.9/3.10
23
+ from typing import Literal
24
+ except ImportError: # pragma: no cover
25
+ from typing_extensions import Literal
26
+
27
+ ErrorCode = Literal[
28
+ "invalid_request",
29
+ "invalid_state_transition",
30
+ "unauthorized",
31
+ "forbidden",
32
+ "not_found",
33
+ "conflict",
34
+ "rate_limited",
35
+ "internal_error",
36
+ "chain_reverted",
37
+ "insufficient_confirmations",
38
+ "signing_required",
39
+ "aal_required",
40
+ "network_error",
41
+ ]
42
+
43
+
44
+ class OpenSettleError(Exception):
45
+ """Base class for every typed error raised by the SDK."""
46
+
47
+ code: ErrorCode
48
+ status: int
49
+ request_id: Optional[str]
50
+ param: Optional[str]
51
+
52
+ def __init__(
53
+ self,
54
+ message: str,
55
+ *,
56
+ code: ErrorCode,
57
+ status: int,
58
+ request_id: Optional[str] = None,
59
+ param: Optional[str] = None,
60
+ ) -> None:
61
+ super().__init__(message)
62
+ self.code = code
63
+ self.status = status
64
+ self.request_id = request_id
65
+ self.param = param
66
+
67
+ def __repr__(self) -> str:
68
+ return (
69
+ f"{type(self).__name__}(code={self.code!r}, status={self.status!r}, "
70
+ f"request_id={self.request_id!r}, param={self.param!r}, "
71
+ f"message={self.args[0]!r})"
72
+ )
73
+
74
+
75
+ class InvalidRequestError(OpenSettleError):
76
+ """The request payload failed validation."""
77
+
78
+
79
+ class InvalidStateTransitionError(OpenSettleError):
80
+ """The resource is in a state that does not allow this operation."""
81
+
82
+
83
+ class AuthenticationError(OpenSettleError):
84
+ """The API key was missing, malformed, revoked, or unrecognised."""
85
+
86
+
87
+ class ForbiddenError(OpenSettleError):
88
+ """The API key authenticated but lacks permission for this route."""
89
+
90
+
91
+ class NotFoundError(OpenSettleError):
92
+ """The addressed resource does not exist (or is not visible to this key)."""
93
+
94
+
95
+ class ConflictError(OpenSettleError):
96
+ """The request collides with the current resource state (dup, race, FK)."""
97
+
98
+
99
+ class RateLimitError(OpenSettleError):
100
+ """The caller exceeded a rate-limit bucket.
101
+
102
+ ``retry_after`` is the seconds-to-wait the API advertised in its
103
+ ``Retry-After`` header (numeric or HTTP-date), or ``None`` if not
104
+ advertised.
105
+ """
106
+
107
+ retry_after: Optional[float]
108
+
109
+ def __init__(
110
+ self,
111
+ message: str,
112
+ *,
113
+ status: int,
114
+ request_id: Optional[str] = None,
115
+ param: Optional[str] = None,
116
+ retry_after: Optional[float] = None,
117
+ ) -> None:
118
+ super().__init__(
119
+ message,
120
+ code="rate_limited",
121
+ status=status,
122
+ request_id=request_id,
123
+ param=param,
124
+ )
125
+ self.retry_after = retry_after
126
+
127
+
128
+ class SettlementError(OpenSettleError):
129
+ """A chain-side or signing failure: revert, insufficient confs, missing sig."""
130
+
131
+
132
+ class StepUpRequiredError(OpenSettleError):
133
+ """The route requires step-up auth (AAL=2). Re-authenticate and retry."""
134
+
135
+
136
+ class APIError(OpenSettleError):
137
+ """Catch-all for unknown error codes and ``internal_error`` 5xx responses."""
138
+
139
+
140
+ class NetworkError(OpenSettleError):
141
+ """Transport-level failure: timeout, DNS, connection refused, etc."""
142
+
143
+ def __init__(self, message: str) -> None:
144
+ super().__init__(
145
+ message,
146
+ code="network_error",
147
+ status=0,
148
+ request_id=None,
149
+ param=None,
150
+ )
151
+
152
+
153
+ def from_envelope(
154
+ envelope: Any,
155
+ status: int,
156
+ retry_after: Optional[float],
157
+ ) -> OpenSettleError:
158
+ """Map a parsed API error envelope + HTTP status into the right subclass.
159
+
160
+ Falls back to :class:`APIError` on unknown codes so a new server-side
161
+ code never crashes older clients.
162
+ """
163
+
164
+ error = envelope.get("error") if isinstance(envelope, dict) else None
165
+ code = error.get("code") if isinstance(error, dict) else None
166
+ message = error.get("message") if isinstance(error, dict) else None
167
+ request_id_raw = error.get("request_id") if isinstance(error, dict) else None
168
+ param_raw = error.get("param") if isinstance(error, dict) else None
169
+
170
+ if not isinstance(message, str) or not message:
171
+ message = f"Request failed with status {status}"
172
+
173
+ req_id: Optional[str] = request_id_raw if isinstance(request_id_raw, str) else None
174
+ param: Optional[str] = param_raw if isinstance(param_raw, str) else None
175
+
176
+ if code == "invalid_request":
177
+ return InvalidRequestError(
178
+ message, code="invalid_request", status=status, request_id=req_id, param=param
179
+ )
180
+ if code == "invalid_state_transition":
181
+ return InvalidStateTransitionError(
182
+ message,
183
+ code="invalid_state_transition",
184
+ status=status,
185
+ request_id=req_id,
186
+ param=param,
187
+ )
188
+ if code == "unauthorized":
189
+ return AuthenticationError(
190
+ message, code="unauthorized", status=status, request_id=req_id, param=param
191
+ )
192
+ if code == "forbidden":
193
+ return ForbiddenError(
194
+ message, code="forbidden", status=status, request_id=req_id, param=param
195
+ )
196
+ if code == "not_found":
197
+ return NotFoundError(
198
+ message, code="not_found", status=status, request_id=req_id, param=param
199
+ )
200
+ if code == "conflict":
201
+ return ConflictError(
202
+ message, code="conflict", status=status, request_id=req_id, param=param
203
+ )
204
+ if code == "rate_limited":
205
+ return RateLimitError(
206
+ message,
207
+ status=status,
208
+ request_id=req_id,
209
+ param=param,
210
+ retry_after=retry_after,
211
+ )
212
+ if code == "chain_reverted":
213
+ return SettlementError(
214
+ message, code="chain_reverted", status=status, request_id=req_id, param=param
215
+ )
216
+ if code == "insufficient_confirmations":
217
+ return SettlementError(
218
+ message,
219
+ code="insufficient_confirmations",
220
+ status=status,
221
+ request_id=req_id,
222
+ param=param,
223
+ )
224
+ if code == "signing_required":
225
+ return SettlementError(
226
+ message,
227
+ code="signing_required",
228
+ status=status,
229
+ request_id=req_id,
230
+ param=param,
231
+ )
232
+ if code == "aal_required":
233
+ return StepUpRequiredError(
234
+ message, code="aal_required", status=status, request_id=req_id, param=param
235
+ )
236
+ # internal_error or anything we don't recognise → forward-compat APIError.
237
+ return APIError(message, code="internal_error", status=status, request_id=req_id, param=param)
238
+
239
+
240
+ __all__ = [
241
+ "APIError",
242
+ "AuthenticationError",
243
+ "ConflictError",
244
+ "ErrorCode",
245
+ "ForbiddenError",
246
+ "InvalidRequestError",
247
+ "InvalidStateTransitionError",
248
+ "NetworkError",
249
+ "NotFoundError",
250
+ "OpenSettleError",
251
+ "RateLimitError",
252
+ "SettlementError",
253
+ "StepUpRequiredError",
254
+ "from_envelope",
255
+ ]
opensettle/_http.py ADDED
@@ -0,0 +1,204 @@
1
+ """Synchronous HTTP client built on ``httpx.Client``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Mapping
7
+ from typing import Any, Optional, Union
8
+
9
+ import httpx
10
+ from typing_extensions import Self
11
+
12
+ from ._errors import NetworkError, OpenSettleError
13
+ from ._transport import (
14
+ DEFAULT_MAX_NETWORK_RETRIES,
15
+ DEFAULT_TIMEOUT_SECONDS,
16
+ assert_api_key_environment,
17
+ build_headers,
18
+ build_raw_url,
19
+ build_url,
20
+ encode_body,
21
+ is_retriable_error,
22
+ map_error,
23
+ normalize_base_url,
24
+ parse_response_body,
25
+ wait_seconds_for_error,
26
+ )
27
+
28
+
29
+ class HttpClient:
30
+ """Thin sync wrapper around ``httpx.Client``.
31
+
32
+ Owns the auth header, workspace URL prefix, idempotency-key
33
+ injection, retries with exponential backoff, and error mapping.
34
+ Resources call :meth:`request` (workspace-scoped) or
35
+ :meth:`request_raw` (everything else, rare).
36
+ """
37
+
38
+ api_key: str
39
+ workspace_id: str
40
+ base_url: str
41
+ timeout: float
42
+ max_network_retries: int
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ api_key: str,
48
+ workspace_id: str,
49
+ base_url: Optional[str] = None,
50
+ test_mode: Optional[bool] = None,
51
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
52
+ max_network_retries: int = DEFAULT_MAX_NETWORK_RETRIES,
53
+ transport: Optional[httpx.BaseTransport] = None,
54
+ _client: Optional[httpx.Client] = None,
55
+ _sleep: Any = None,
56
+ ) -> None:
57
+ if not api_key:
58
+ raise ValueError("OpenSettle: api_key is required")
59
+ if not workspace_id:
60
+ raise ValueError("OpenSettle: workspace_id is required")
61
+ assert_api_key_environment(api_key, test_mode)
62
+
63
+ self.api_key = api_key
64
+ self.workspace_id = workspace_id
65
+ self.base_url = normalize_base_url(base_url)
66
+ self.timeout = timeout
67
+ self.max_network_retries = max_network_retries
68
+ if _client is not None:
69
+ self._client = _client
70
+ self._owns_client = False
71
+ else:
72
+ self._client = httpx.Client(timeout=timeout, transport=transport)
73
+ self._owns_client = True
74
+ self._sleep = _sleep if _sleep is not None else time.sleep
75
+
76
+ # Lifecycle ----------------------------------------------------------
77
+
78
+ def close(self) -> None:
79
+ if self._owns_client:
80
+ self._client.close()
81
+
82
+ def __enter__(self) -> Self:
83
+ return self
84
+
85
+ def __exit__(self, *_exc: object) -> None:
86
+ self.close()
87
+
88
+ # URL helpers --------------------------------------------------------
89
+
90
+ def url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
91
+ return build_url(self.base_url, self.workspace_id, path, query)
92
+
93
+ def raw_url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
94
+ return build_raw_url(self.base_url, path, query)
95
+
96
+ # Request entry points ----------------------------------------------
97
+
98
+ def request(
99
+ self,
100
+ path: str,
101
+ *,
102
+ method: str = "GET",
103
+ body: Any = None,
104
+ query: Optional[Mapping[str, Any]] = None,
105
+ idempotency_key: Union[None, str, bool] = None,
106
+ headers: Optional[Mapping[str, str]] = None,
107
+ timeout: Optional[float] = None,
108
+ max_network_retries: Optional[int] = None,
109
+ ) -> Any:
110
+ return self._request_at(
111
+ self.url(path, query),
112
+ method=method,
113
+ body=body,
114
+ idempotency_key=idempotency_key,
115
+ headers=headers,
116
+ timeout=timeout,
117
+ max_network_retries=max_network_retries,
118
+ )
119
+
120
+ def request_raw(
121
+ self,
122
+ path: str,
123
+ *,
124
+ method: str = "GET",
125
+ body: Any = None,
126
+ query: Optional[Mapping[str, Any]] = None,
127
+ idempotency_key: Union[None, str, bool] = None,
128
+ headers: Optional[Mapping[str, str]] = None,
129
+ timeout: Optional[float] = None,
130
+ max_network_retries: Optional[int] = None,
131
+ ) -> Any:
132
+ return self._request_at(
133
+ self.raw_url(path, query),
134
+ method=method,
135
+ body=body,
136
+ idempotency_key=idempotency_key,
137
+ headers=headers,
138
+ timeout=timeout,
139
+ max_network_retries=max_network_retries,
140
+ )
141
+
142
+ # Internal -----------------------------------------------------------
143
+
144
+ def _request_at(
145
+ self,
146
+ url: str,
147
+ *,
148
+ method: str,
149
+ body: Any,
150
+ idempotency_key: Union[None, str, bool],
151
+ headers: Optional[Mapping[str, str]],
152
+ timeout: Optional[float],
153
+ max_network_retries: Optional[int],
154
+ ) -> Any:
155
+ body_text = encode_body(body)
156
+ final_headers = build_headers(
157
+ api_key=self.api_key,
158
+ extra=headers,
159
+ has_body=body_text is not None,
160
+ idempotency_key=idempotency_key,
161
+ )
162
+ request_timeout = timeout if timeout is not None else self.timeout
163
+ budget = (
164
+ max_network_retries if max_network_retries is not None else self.max_network_retries
165
+ )
166
+
167
+ last_error: Optional[OpenSettleError] = None
168
+ for attempt in range(budget + 1):
169
+ try:
170
+ resp = self._client.request(
171
+ method,
172
+ url,
173
+ content=body_text,
174
+ headers=final_headers,
175
+ timeout=request_timeout,
176
+ )
177
+ except httpx.HTTPError as exc:
178
+ err = NetworkError(f"Network error: {exc}")
179
+ if attempt < budget:
180
+ self._sleep(wait_seconds_for_error(err, attempt))
181
+ last_error = err
182
+ continue
183
+ raise err from exc
184
+
185
+ if resp.status_code < 400:
186
+ if resp.status_code == 204 or not resp.content:
187
+ return None
188
+ data, parse_err = parse_response_body(resp.text, resp.status_code)
189
+ if parse_err is not None:
190
+ raise parse_err
191
+ return data
192
+
193
+ api_err = map_error(resp.text, resp.status_code, resp.headers.get("retry-after"))
194
+ if is_retriable_error(api_err) and attempt < budget:
195
+ self._sleep(wait_seconds_for_error(api_err, attempt))
196
+ last_error = api_err
197
+ continue
198
+ raise api_err
199
+
200
+ # Unreachable in practice — the loop either returns or raises.
201
+ raise last_error or RuntimeError("OpenSettle: request loop exited unexpectedly")
202
+
203
+
204
+ __all__ = ["HttpClient"]
@@ -0,0 +1,194 @@
1
+ """Asynchronous HTTP client built on ``httpx.AsyncClient``.
2
+
3
+ Mirror of :mod:`opensettle._http`. The two clients share the
4
+ transport core in :mod:`opensettle._transport` — they only differ in
5
+ ``await`` and ``asyncio.sleep`` vs ``time.sleep``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import Mapping
12
+ from typing import Any, Optional, Union
13
+
14
+ import httpx
15
+ from typing_extensions import Self
16
+
17
+ from ._errors import NetworkError, OpenSettleError
18
+ from ._transport import (
19
+ DEFAULT_MAX_NETWORK_RETRIES,
20
+ DEFAULT_TIMEOUT_SECONDS,
21
+ assert_api_key_environment,
22
+ build_headers,
23
+ build_raw_url,
24
+ build_url,
25
+ encode_body,
26
+ is_retriable_error,
27
+ map_error,
28
+ normalize_base_url,
29
+ parse_response_body,
30
+ wait_seconds_for_error,
31
+ )
32
+
33
+
34
+ class AsyncHttpClient:
35
+ """Async sibling of :class:`opensettle._http.HttpClient`."""
36
+
37
+ api_key: str
38
+ workspace_id: str
39
+ base_url: str
40
+ timeout: float
41
+ max_network_retries: int
42
+
43
+ def __init__(
44
+ self,
45
+ *,
46
+ api_key: str,
47
+ workspace_id: str,
48
+ base_url: Optional[str] = None,
49
+ test_mode: Optional[bool] = None,
50
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
51
+ max_network_retries: int = DEFAULT_MAX_NETWORK_RETRIES,
52
+ transport: Optional[httpx.AsyncBaseTransport] = None,
53
+ _client: Optional[httpx.AsyncClient] = None,
54
+ _sleep: Any = None,
55
+ ) -> None:
56
+ if not api_key:
57
+ raise ValueError("OpenSettle: api_key is required")
58
+ if not workspace_id:
59
+ raise ValueError("OpenSettle: workspace_id is required")
60
+ assert_api_key_environment(api_key, test_mode)
61
+
62
+ self.api_key = api_key
63
+ self.workspace_id = workspace_id
64
+ self.base_url = normalize_base_url(base_url)
65
+ self.timeout = timeout
66
+ self.max_network_retries = max_network_retries
67
+ if _client is not None:
68
+ self._client = _client
69
+ self._owns_client = False
70
+ else:
71
+ self._client = httpx.AsyncClient(timeout=timeout, transport=transport)
72
+ self._owns_client = True
73
+ self._sleep = _sleep if _sleep is not None else asyncio.sleep
74
+
75
+ async def aclose(self) -> None:
76
+ if self._owns_client:
77
+ await self._client.aclose()
78
+
79
+ async def __aenter__(self) -> Self:
80
+ return self
81
+
82
+ async def __aexit__(self, *_exc: object) -> None:
83
+ await self.aclose()
84
+
85
+ def url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
86
+ return build_url(self.base_url, self.workspace_id, path, query)
87
+
88
+ def raw_url(self, path: str, query: Optional[Mapping[str, Any]] = None) -> str:
89
+ return build_raw_url(self.base_url, path, query)
90
+
91
+ async def request(
92
+ self,
93
+ path: str,
94
+ *,
95
+ method: str = "GET",
96
+ body: Any = None,
97
+ query: Optional[Mapping[str, Any]] = None,
98
+ idempotency_key: Union[None, str, bool] = None,
99
+ headers: Optional[Mapping[str, str]] = None,
100
+ timeout: Optional[float] = None,
101
+ max_network_retries: Optional[int] = None,
102
+ ) -> Any:
103
+ return await self._request_at(
104
+ self.url(path, query),
105
+ method=method,
106
+ body=body,
107
+ idempotency_key=idempotency_key,
108
+ headers=headers,
109
+ timeout=timeout,
110
+ max_network_retries=max_network_retries,
111
+ )
112
+
113
+ async def request_raw(
114
+ self,
115
+ path: str,
116
+ *,
117
+ method: str = "GET",
118
+ body: Any = None,
119
+ query: Optional[Mapping[str, Any]] = None,
120
+ idempotency_key: Union[None, str, bool] = None,
121
+ headers: Optional[Mapping[str, str]] = None,
122
+ timeout: Optional[float] = None,
123
+ max_network_retries: Optional[int] = None,
124
+ ) -> Any:
125
+ return await self._request_at(
126
+ self.raw_url(path, query),
127
+ method=method,
128
+ body=body,
129
+ idempotency_key=idempotency_key,
130
+ headers=headers,
131
+ timeout=timeout,
132
+ max_network_retries=max_network_retries,
133
+ )
134
+
135
+ async def _request_at(
136
+ self,
137
+ url: str,
138
+ *,
139
+ method: str,
140
+ body: Any,
141
+ idempotency_key: Union[None, str, bool],
142
+ headers: Optional[Mapping[str, str]],
143
+ timeout: Optional[float],
144
+ max_network_retries: Optional[int],
145
+ ) -> Any:
146
+ body_text = encode_body(body)
147
+ final_headers = build_headers(
148
+ api_key=self.api_key,
149
+ extra=headers,
150
+ has_body=body_text is not None,
151
+ idempotency_key=idempotency_key,
152
+ )
153
+ request_timeout = timeout if timeout is not None else self.timeout
154
+ budget = (
155
+ max_network_retries if max_network_retries is not None else self.max_network_retries
156
+ )
157
+
158
+ last_error: Optional[OpenSettleError] = None
159
+ for attempt in range(budget + 1):
160
+ try:
161
+ resp = await self._client.request(
162
+ method,
163
+ url,
164
+ content=body_text,
165
+ headers=final_headers,
166
+ timeout=request_timeout,
167
+ )
168
+ except httpx.HTTPError as exc:
169
+ err = NetworkError(f"Network error: {exc}")
170
+ if attempt < budget:
171
+ await self._sleep(wait_seconds_for_error(err, attempt))
172
+ last_error = err
173
+ continue
174
+ raise err from exc
175
+
176
+ if resp.status_code < 400:
177
+ if resp.status_code == 204 or not resp.content:
178
+ return None
179
+ data, parse_err = parse_response_body(resp.text, resp.status_code)
180
+ if parse_err is not None:
181
+ raise parse_err
182
+ return data
183
+
184
+ api_err = map_error(resp.text, resp.status_code, resp.headers.get("retry-after"))
185
+ if is_retriable_error(api_err) and attempt < budget:
186
+ await self._sleep(wait_seconds_for_error(api_err, attempt))
187
+ last_error = api_err
188
+ continue
189
+ raise api_err
190
+
191
+ raise last_error or RuntimeError("OpenSettle: request loop exited unexpectedly")
192
+
193
+
194
+ __all__ = ["AsyncHttpClient"]