reconify-python 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.
reconify/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Typed clients for the Reconify Public API."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import AsyncReconify, Reconify
6
+ from .errors import (
7
+ ReconifyAuthenticationError,
8
+ ReconifyConflictError,
9
+ ReconifyError,
10
+ ReconifyNotFoundError,
11
+ ReconifyPermissionError,
12
+ ReconifyRateLimitError,
13
+ ReconifyRequestError,
14
+ ReconifyServerError,
15
+ ReconifyServiceUnavailableError,
16
+ ReconifyValidationError,
17
+ )
18
+ from .transport import RawResponse, RetryConfig
19
+
20
+ __all__ = [
21
+ "AsyncReconify",
22
+ "Reconify",
23
+ "__version__",
24
+ "RawResponse",
25
+ "RetryConfig",
26
+ "ReconifyError",
27
+ "ReconifyValidationError",
28
+ "ReconifyAuthenticationError",
29
+ "ReconifyPermissionError",
30
+ "ReconifyNotFoundError",
31
+ "ReconifyConflictError",
32
+ "ReconifyRequestError",
33
+ "ReconifyRateLimitError",
34
+ "ReconifyServiceUnavailableError",
35
+ "ReconifyServerError",
36
+ ]
reconify/client.py ADDED
@@ -0,0 +1,264 @@
1
+ """Top-level synchronous and asynchronous Reconify clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import AsyncIterator, Iterator
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .errors import ReconifyValidationError
12
+ from .pagination import (
13
+ aiter_cursor_pages,
14
+ aiter_offset_pages,
15
+ iter_cursor_pages,
16
+ iter_offset_pages,
17
+ )
18
+ from .resources import (
19
+ ASYNC_RESOURCE_CLASSES,
20
+ SYNC_RESOURCE_CLASSES,
21
+ Alerts,
22
+ AsyncAlerts,
23
+ AsyncEvents,
24
+ AsyncIngestion,
25
+ AsyncIssues,
26
+ AsyncLedger,
27
+ AsyncReconciliations,
28
+ AsyncSearch,
29
+ AsyncSetup,
30
+ AsyncTransactions,
31
+ AsyncWallets,
32
+ Events,
33
+ Ingestion,
34
+ Issues,
35
+ Ledger,
36
+ Reconciliations,
37
+ Search,
38
+ Setup,
39
+ Transactions,
40
+ Wallets,
41
+ )
42
+ from .transport import AsyncTransport, RetryConfig, SyncTransport
43
+
44
+ DEFAULT_BASE_URL = "https://api.reconifyhq.com/v1"
45
+
46
+
47
+ def _normalize_base_url(base_url: str | None) -> str:
48
+ value = (base_url or os.getenv("RECONIFY_API_URL") or DEFAULT_BASE_URL).rstrip("/")
49
+ return value if value.endswith("/v1") else f"{value}/v1"
50
+
51
+
52
+ def _api_key(api_key: str | None) -> str:
53
+ value = api_key or os.getenv("RECONIFY_API_KEY")
54
+ if not value:
55
+ raise ReconifyValidationError("An API key is required")
56
+ if value.startswith("sk_live_"):
57
+ raise ReconifyValidationError("Legacy sk_live_ keys are not supported; use an rk_ key")
58
+ if not value.startswith("rk_"):
59
+ raise ReconifyValidationError("Reconify public API keys must start with rk_")
60
+ return value
61
+
62
+
63
+ class Reconify:
64
+ """Synchronous typed Reconify API client."""
65
+
66
+ alerts: Alerts
67
+ events: Events
68
+ ingestion: Ingestion
69
+ issues: Issues
70
+ ledger: Ledger
71
+ reconciliations: Reconciliations
72
+ search: Search
73
+ setup: Setup
74
+ transactions: Transactions
75
+ wallets: Wallets
76
+
77
+ def __init__(
78
+ self,
79
+ api_key: str | None = None,
80
+ *,
81
+ base_url: str | None = None,
82
+ timeout: float | httpx.Timeout = 30.0,
83
+ request_id: str | None = None,
84
+ retry: RetryConfig | None = None,
85
+ http_client: httpx.Client | None = None,
86
+ ) -> None:
87
+ self._transport = SyncTransport(
88
+ base_url=_normalize_base_url(base_url),
89
+ api_key=_api_key(api_key),
90
+ timeout=timeout,
91
+ request_id=request_id,
92
+ retry=retry,
93
+ client=http_client,
94
+ )
95
+ for name, resource_class in SYNC_RESOURCE_CLASSES.items():
96
+ setattr(self, name, resource_class(self._transport))
97
+
98
+ def close(self) -> None:
99
+ self._transport.close()
100
+
101
+ def __enter__(self) -> Reconify:
102
+ return self
103
+
104
+ def __exit__(self, *_: Any) -> None:
105
+ self.close()
106
+
107
+ def iter_events(self, **query: Any) -> Iterator[Any]:
108
+ return iter_cursor_pages(
109
+ lambda params: self.events.list_events(**params), item_field="events", query=query
110
+ )
111
+
112
+ def iter_issues(self, **query: Any) -> Iterator[Any]:
113
+ return iter_cursor_pages(
114
+ lambda params: self.issues.list_issues(**params), item_field="issues", query=query
115
+ )
116
+
117
+ def iter_ledger_sources(self, **query: Any) -> Iterator[Any]:
118
+ return iter_offset_pages(
119
+ lambda params: self.ledger.list_ledger_sources(**params),
120
+ item_field="sources",
121
+ query=query,
122
+ )
123
+
124
+ def iter_ledger_transactions(self, source_id: str, **query: Any) -> Iterator[Any]:
125
+ return iter_offset_pages(
126
+ lambda params: self.ledger.list_transactions(source_id, **params),
127
+ item_field="transactions",
128
+ query=query,
129
+ )
130
+
131
+ def iter_reconciliation_schedules(self, **query: Any) -> Iterator[Any]:
132
+ return iter_offset_pages(
133
+ lambda params: self.reconciliations.list_reconciliation_schedules(**params),
134
+ item_field="schedules",
135
+ query=query,
136
+ )
137
+
138
+ def iter_reconciliations(self, **query: Any) -> Iterator[Any]:
139
+ return iter_offset_pages(
140
+ lambda params: self.reconciliations.list_reconciliations(**params),
141
+ item_field="reconciliations",
142
+ query=query,
143
+ )
144
+
145
+ def iter_setup_sources(self, **query: Any) -> Iterator[Any]:
146
+ return iter_offset_pages(
147
+ lambda params: self.setup.list_setup_sources(**params),
148
+ item_field="sources",
149
+ query=query,
150
+ )
151
+
152
+ def iter_wallet_transactions(self, **query: Any) -> Iterator[Any]:
153
+ return iter_cursor_pages(
154
+ lambda params: self.transactions.list_wallet_transactions(**params),
155
+ item_field="transactions",
156
+ query=query,
157
+ )
158
+
159
+ def iter_wallets(self, **query: Any) -> Iterator[Any]:
160
+ return iter_offset_pages(
161
+ lambda params: self.wallets.list_wallets(**params), item_field="wallets", query=query
162
+ )
163
+
164
+
165
+ class AsyncReconify:
166
+ """Asynchronous typed Reconify API client."""
167
+
168
+ alerts: AsyncAlerts
169
+ events: AsyncEvents
170
+ ingestion: AsyncIngestion
171
+ issues: AsyncIssues
172
+ ledger: AsyncLedger
173
+ reconciliations: AsyncReconciliations
174
+ search: AsyncSearch
175
+ setup: AsyncSetup
176
+ transactions: AsyncTransactions
177
+ wallets: AsyncWallets
178
+
179
+ def __init__(
180
+ self,
181
+ api_key: str | None = None,
182
+ *,
183
+ base_url: str | None = None,
184
+ timeout: float | httpx.Timeout = 30.0,
185
+ request_id: str | None = None,
186
+ retry: RetryConfig | None = None,
187
+ http_client: httpx.AsyncClient | None = None,
188
+ ) -> None:
189
+ self._transport = AsyncTransport(
190
+ base_url=_normalize_base_url(base_url),
191
+ api_key=_api_key(api_key),
192
+ timeout=timeout,
193
+ request_id=request_id,
194
+ retry=retry,
195
+ client=http_client,
196
+ )
197
+ for name, resource_class in ASYNC_RESOURCE_CLASSES.items():
198
+ setattr(self, name, resource_class(self._transport))
199
+
200
+ async def aclose(self) -> None:
201
+ await self._transport.aclose()
202
+
203
+ async def __aenter__(self) -> AsyncReconify:
204
+ return self
205
+
206
+ async def __aexit__(self, *_: Any) -> None:
207
+ await self.aclose()
208
+
209
+ def iter_events(self, **query: Any) -> AsyncIterator[Any]:
210
+ return aiter_cursor_pages(
211
+ lambda params: self.events.list_events(**params), item_field="events", query=query
212
+ )
213
+
214
+ def iter_issues(self, **query: Any) -> AsyncIterator[Any]:
215
+ return aiter_cursor_pages(
216
+ lambda params: self.issues.list_issues(**params), item_field="issues", query=query
217
+ )
218
+
219
+ def iter_ledger_sources(self, **query: Any) -> AsyncIterator[Any]:
220
+ return aiter_offset_pages(
221
+ lambda params: self.ledger.list_ledger_sources(**params),
222
+ item_field="sources",
223
+ query=query,
224
+ )
225
+
226
+ def iter_ledger_transactions(self, source_id: str, **query: Any) -> AsyncIterator[Any]:
227
+ return aiter_offset_pages(
228
+ lambda params: self.ledger.list_transactions(source_id, **params),
229
+ item_field="transactions",
230
+ query=query,
231
+ )
232
+
233
+ def iter_reconciliation_schedules(self, **query: Any) -> AsyncIterator[Any]:
234
+ return aiter_offset_pages(
235
+ lambda params: self.reconciliations.list_reconciliation_schedules(**params),
236
+ item_field="schedules",
237
+ query=query,
238
+ )
239
+
240
+ def iter_reconciliations(self, **query: Any) -> AsyncIterator[Any]:
241
+ return aiter_offset_pages(
242
+ lambda params: self.reconciliations.list_reconciliations(**params),
243
+ item_field="reconciliations",
244
+ query=query,
245
+ )
246
+
247
+ def iter_setup_sources(self, **query: Any) -> AsyncIterator[Any]:
248
+ return aiter_offset_pages(
249
+ lambda params: self.setup.list_setup_sources(**params),
250
+ item_field="sources",
251
+ query=query,
252
+ )
253
+
254
+ def iter_wallet_transactions(self, **query: Any) -> AsyncIterator[Any]:
255
+ return aiter_cursor_pages(
256
+ lambda params: self.transactions.list_wallet_transactions(**params),
257
+ item_field="transactions",
258
+ query=query,
259
+ )
260
+
261
+ def iter_wallets(self, **query: Any) -> AsyncIterator[Any]:
262
+ return aiter_offset_pages(
263
+ lambda params: self.wallets.list_wallets(**params), item_field="wallets", query=query
264
+ )
reconify/errors.py ADDED
@@ -0,0 +1,132 @@
1
+ """Exception types and safe error parsing for Reconify responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+
8
+ class ReconifyError(Exception):
9
+ """Base class for client, validation, transport, and API errors."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status_code: int | None = None,
16
+ title: str | None = None,
17
+ detail: str | None = None,
18
+ code: str | None = None,
19
+ validation_errors: list[dict[str, Any]] | None = None,
20
+ request_id: str | None = None,
21
+ response_headers: dict[str, str] | None = None,
22
+ response_metadata: dict[str, Any] | None = None,
23
+ ) -> None:
24
+ super().__init__(message)
25
+ self.status_code = status_code
26
+ self.title = title
27
+ self.detail = detail
28
+ self.code = code
29
+ self.validation_errors = validation_errors or []
30
+ self.request_id = request_id
31
+ self.response_headers = response_headers or {}
32
+ self.response_metadata = response_metadata or {}
33
+
34
+
35
+ class ReconifyValidationError(ReconifyError):
36
+ """The client rejected invalid arguments before making a request."""
37
+
38
+
39
+ class ReconifyAuthenticationError(ReconifyError):
40
+ """The API key is missing or not accepted (HTTP 401)."""
41
+
42
+
43
+ class ReconifyPermissionError(ReconifyError):
44
+ """The API key cannot access the requested resource (HTTP 403)."""
45
+
46
+
47
+ class ReconifyNotFoundError(ReconifyError):
48
+ """The requested resource does not exist (HTTP 404)."""
49
+
50
+
51
+ class ReconifyConflictError(ReconifyError):
52
+ """The request conflicts with current server state (HTTP 409)."""
53
+
54
+
55
+ class ReconifyRequestError(ReconifyError):
56
+ """The request is malformed or fails validation (HTTP 400/422)."""
57
+
58
+
59
+ class ReconifyRateLimitError(ReconifyError):
60
+ """The API rate limit was exceeded (HTTP 429)."""
61
+
62
+
63
+ class ReconifyServiceUnavailableError(ReconifyError):
64
+ """The API is temporarily unavailable (HTTP 503)."""
65
+
66
+
67
+ class ReconifyServerError(ReconifyError):
68
+ """The API returned an unexpected 5xx response."""
69
+
70
+
71
+ _STATUS_ERRORS: dict[int, type[ReconifyError]] = {
72
+ 400: ReconifyRequestError,
73
+ 401: ReconifyAuthenticationError,
74
+ 403: ReconifyPermissionError,
75
+ 404: ReconifyNotFoundError,
76
+ 409: ReconifyConflictError,
77
+ 422: ReconifyRequestError,
78
+ 429: ReconifyRateLimitError,
79
+ 500: ReconifyServerError,
80
+ 503: ReconifyServiceUnavailableError,
81
+ }
82
+
83
+
84
+ def error_for_response(
85
+ status_code: int,
86
+ headers: dict[str, str],
87
+ payload: Any,
88
+ *,
89
+ request_id: str | None,
90
+ ) -> ReconifyError:
91
+ """Build a typed exception without including credentials or request bodies."""
92
+
93
+ data = payload if isinstance(payload, dict) else {}
94
+ title = data.get("title") if isinstance(data.get("title"), str) else None
95
+ detail = data.get("detail") if isinstance(data.get("detail"), str) else None
96
+ if detail is None and isinstance(data.get("error"), str):
97
+ detail = data["error"]
98
+ if detail is None and isinstance(data.get("message"), str):
99
+ detail = data["message"]
100
+
101
+ validation = data.get("errors") if isinstance(data.get("errors"), list) else []
102
+ validation = cast(list[Any], validation)
103
+ safe_validation = [item for item in validation if isinstance(item, dict)]
104
+ code = None
105
+ for item in safe_validation:
106
+ if item.get("location") == "$code" and isinstance(item.get("message"), str):
107
+ code = item["message"]
108
+ break
109
+ if code is None and isinstance(data.get("code"), str):
110
+ code = data["code"]
111
+
112
+ message = detail or title or f"Reconify API request failed with status {status_code}"
113
+ exception_type = _STATUS_ERRORS.get(
114
+ status_code,
115
+ ReconifyServerError if status_code >= 500 else ReconifyError,
116
+ )
117
+ metadata = {
118
+ key: value
119
+ for key, value in data.items()
120
+ if key not in {"detail", "message", "error", "errors"}
121
+ }
122
+ return exception_type(
123
+ message,
124
+ status_code=status_code,
125
+ title=title,
126
+ detail=detail,
127
+ code=code,
128
+ validation_errors=safe_validation,
129
+ request_id=request_id,
130
+ response_headers=headers,
131
+ response_metadata=metadata,
132
+ )