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/transport.py ADDED
@@ -0,0 +1,299 @@
1
+ """HTTP transport, retry policy, and raw response handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import random
8
+ import time
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from email.utils import parsedate_to_datetime
12
+ from typing import Any, Generic, TypeVar, cast
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, ValidationError
16
+
17
+ from .errors import ReconifyError, ReconifyValidationError, error_for_response
18
+ from .models import model_dump
19
+
20
+ T = TypeVar("T", bound=BaseModel)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class RetryConfig:
25
+ """Bounded retry settings. Retries apply to GET by default."""
26
+
27
+ max_retries: int = 2
28
+ base_delay: float = 0.25
29
+ max_delay: float = 8.0
30
+ jitter: float = 0.25
31
+ retry_unsafe_methods: bool = False
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class RawResponse(Generic[T]):
36
+ """Unparsed response data returned by an operation with ``raw=True``."""
37
+
38
+ status_code: int
39
+ headers: httpx.Headers
40
+ body: bytes
41
+ request_id: str | None
42
+
43
+ def json(self) -> Any:
44
+ return json.loads(self.body) if self.body else None
45
+
46
+
47
+ def _retry_after(value: str | None) -> float | None:
48
+ if not value:
49
+ return None
50
+ try:
51
+ return max(0.0, float(value))
52
+ except ValueError:
53
+ try:
54
+ parsed = parsedate_to_datetime(value)
55
+ if parsed.tzinfo is None:
56
+ parsed = parsed.replace(tzinfo=timezone.utc)
57
+ return max(0.0, (parsed - datetime.now(timezone.utc)).total_seconds())
58
+ except (TypeError, ValueError, OverflowError):
59
+ return None
60
+
61
+
62
+ def _sleep_for(attempt: int, config: RetryConfig, retry_after: str | None) -> float:
63
+ server_delay = _retry_after(retry_after)
64
+ if server_delay is not None:
65
+ return min(config.max_delay, server_delay)
66
+ exponential = min(config.max_delay, config.base_delay * (2**attempt))
67
+ return float(min(config.max_delay, exponential + random.uniform(0.0, config.jitter)))
68
+
69
+
70
+ def _can_retry_exception(method: str, attempts: int, config: RetryConfig) -> bool:
71
+ if attempts >= config.max_retries:
72
+ return False
73
+ return method.upper() in {"GET", "HEAD", "OPTIONS"} or config.retry_unsafe_methods
74
+
75
+
76
+ def _payload(response: httpx.Response) -> Any:
77
+ if not response.content:
78
+ return None
79
+ try:
80
+ return response.json()
81
+ except (ValueError, json.JSONDecodeError):
82
+ return {"detail": response.text}
83
+
84
+
85
+ def _validate_request_body(body: Any) -> Any:
86
+ if isinstance(body, BaseModel):
87
+ try:
88
+ return model_dump(body)
89
+ except ValidationError as exc:
90
+ raise ReconifyValidationError(str(exc)) from exc
91
+ return body
92
+
93
+
94
+ def _validate_integrity_payload(path: str, body: Any) -> None:
95
+ if path not in {"/integrity/events", "/integrity/test-events"} or body is None:
96
+ return
97
+ payload_size = len(json.dumps(body, separators=(",", ":"), default=str).encode("utf-8"))
98
+ if payload_size > 5 * 1024 * 1024:
99
+ raise ReconifyValidationError("Integrity event requests must not exceed 5 MiB")
100
+
101
+
102
+ class SyncTransport:
103
+ def __init__(
104
+ self,
105
+ *,
106
+ base_url: str,
107
+ api_key: str,
108
+ timeout: float | httpx.Timeout = 30.0,
109
+ request_id: str | None = None,
110
+ retry: RetryConfig | None = None,
111
+ client: httpx.Client | None = None,
112
+ ) -> None:
113
+ self.base_url = base_url.rstrip("/")
114
+ self.api_key = api_key
115
+ self.request_id = request_id
116
+ self.retry = retry or RetryConfig()
117
+ self._client = client or httpx.Client(timeout=timeout)
118
+ self._owns_client = client is None
119
+
120
+ def request(
121
+ self,
122
+ method: str,
123
+ path: str,
124
+ *,
125
+ query: dict[str, Any] | None = None,
126
+ body: Any = None,
127
+ headers: dict[str, str] | None = None,
128
+ model: type[T] | None = None,
129
+ raw: bool = False,
130
+ timeout: float | httpx.Timeout | None = None,
131
+ ) -> T | RawResponse[T] | None:
132
+ request_headers = {"Authorization": f"Bearer {self.api_key}"}
133
+ if body is not None:
134
+ request_headers["Content-Type"] = "application/json"
135
+ if self.request_id:
136
+ request_headers["X-Request-ID"] = self.request_id
137
+ request_headers.update(headers or {})
138
+ data = _validate_request_body(body)
139
+ _validate_integrity_payload(path, data)
140
+ params = {key: value for key, value in (query or {}).items() if value is not None}
141
+ attempts = 0
142
+ while True:
143
+ try:
144
+ response = self._client.request(
145
+ method,
146
+ f"{self.base_url}{path}",
147
+ params=params,
148
+ json=data if data is not None else None,
149
+ headers=request_headers,
150
+ timeout=timeout,
151
+ )
152
+ except httpx.TransportError:
153
+ if not _can_retry_exception(method, attempts, self.retry):
154
+ raise
155
+ time.sleep(_sleep_for(attempts, self.retry, None))
156
+ attempts += 1
157
+ continue
158
+ if response.status_code not in {429, 503} or not self._can_retry(method, attempts):
159
+ return self._finish(response, model=model, raw=raw)
160
+ delay = _sleep_for(attempts, self.retry, response.headers.get("Retry-After"))
161
+ time.sleep(delay)
162
+ attempts += 1
163
+
164
+ def _can_retry(self, method: str, attempts: int) -> bool:
165
+ if attempts >= self.retry.max_retries:
166
+ return False
167
+ return method.upper() in {"GET", "HEAD", "OPTIONS"} or self.retry.retry_unsafe_methods
168
+
169
+ def _finish(
170
+ self, response: httpx.Response, *, model: type[T] | None, raw: bool
171
+ ) -> T | RawResponse[T] | None:
172
+ request_id = response.headers.get("X-Request-ID")
173
+ if response.is_error:
174
+ raise error_for_response(
175
+ response.status_code,
176
+ dict(response.headers),
177
+ _payload(response),
178
+ request_id=request_id,
179
+ )
180
+ if response.status_code == 204:
181
+ return None
182
+ if raw:
183
+ return RawResponse(response.status_code, response.headers, response.content, request_id)
184
+ payload = _payload(response)
185
+ if model is None or payload is None:
186
+ return cast(T | RawResponse[T] | None, payload)
187
+ try:
188
+ return model.model_validate(payload)
189
+ except ValidationError as exc:
190
+ raise ReconifyError(
191
+ "The Reconify response did not match the expected schema",
192
+ status_code=response.status_code,
193
+ request_id=request_id,
194
+ response_headers=dict(response.headers),
195
+ ) from exc
196
+
197
+ def close(self) -> None:
198
+ if self._owns_client:
199
+ self._client.close()
200
+
201
+
202
+ class AsyncTransport:
203
+ def __init__(
204
+ self,
205
+ *,
206
+ base_url: str,
207
+ api_key: str,
208
+ timeout: float | httpx.Timeout = 30.0,
209
+ request_id: str | None = None,
210
+ retry: RetryConfig | None = None,
211
+ client: httpx.AsyncClient | None = None,
212
+ ) -> None:
213
+ self.base_url = base_url.rstrip("/")
214
+ self.api_key = api_key
215
+ self.request_id = request_id
216
+ self.retry = retry or RetryConfig()
217
+ self._client = client or httpx.AsyncClient(timeout=timeout)
218
+ self._owns_client = client is None
219
+
220
+ async def request(
221
+ self,
222
+ method: str,
223
+ path: str,
224
+ *,
225
+ query: dict[str, Any] | None = None,
226
+ body: Any = None,
227
+ headers: dict[str, str] | None = None,
228
+ model: type[T] | None = None,
229
+ raw: bool = False,
230
+ timeout: float | httpx.Timeout | None = None,
231
+ ) -> T | RawResponse[T] | None:
232
+ request_headers = {"Authorization": f"Bearer {self.api_key}"}
233
+ if body is not None:
234
+ request_headers["Content-Type"] = "application/json"
235
+ if self.request_id:
236
+ request_headers["X-Request-ID"] = self.request_id
237
+ request_headers.update(headers or {})
238
+ data = _validate_request_body(body)
239
+ _validate_integrity_payload(path, data)
240
+ params = {key: value for key, value in (query or {}).items() if value is not None}
241
+ attempts = 0
242
+ while True:
243
+ try:
244
+ response = await self._client.request(
245
+ method,
246
+ f"{self.base_url}{path}",
247
+ params=params,
248
+ json=data if data is not None else None,
249
+ headers=request_headers,
250
+ timeout=timeout,
251
+ )
252
+ except httpx.TransportError:
253
+ if not _can_retry_exception(method, attempts, self.retry):
254
+ raise
255
+ await asyncio.sleep(_sleep_for(attempts, self.retry, None))
256
+ attempts += 1
257
+ continue
258
+ if response.status_code not in {429, 503} or not self._can_retry(method, attempts):
259
+ return self._finish(response, model=model, raw=raw)
260
+ delay = _sleep_for(attempts, self.retry, response.headers.get("Retry-After"))
261
+ await asyncio.sleep(delay)
262
+ attempts += 1
263
+
264
+ def _can_retry(self, method: str, attempts: int) -> bool:
265
+ if attempts >= self.retry.max_retries:
266
+ return False
267
+ return method.upper() in {"GET", "HEAD", "OPTIONS"} or self.retry.retry_unsafe_methods
268
+
269
+ def _finish(
270
+ self, response: httpx.Response, *, model: type[T] | None, raw: bool
271
+ ) -> T | RawResponse[T] | None:
272
+ request_id = response.headers.get("X-Request-ID")
273
+ if response.is_error:
274
+ raise error_for_response(
275
+ response.status_code,
276
+ dict(response.headers),
277
+ _payload(response),
278
+ request_id=request_id,
279
+ )
280
+ if response.status_code == 204:
281
+ return None
282
+ if raw:
283
+ return RawResponse(response.status_code, response.headers, response.content, request_id)
284
+ payload = _payload(response)
285
+ if model is None or payload is None:
286
+ return cast(T | RawResponse[T] | None, payload)
287
+ try:
288
+ return model.model_validate(payload)
289
+ except ValidationError as exc:
290
+ raise ReconifyError(
291
+ "The Reconify response did not match the expected schema",
292
+ status_code=response.status_code,
293
+ request_id=request_id,
294
+ response_headers=dict(response.headers),
295
+ ) from exc
296
+
297
+ async def aclose(self) -> None:
298
+ if self._owns_client:
299
+ await self._client.aclose()
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: reconify-python
3
+ Version: 0.1.0
4
+ Summary: Typed Python client for the Reconify Public API
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx<1,>=0.27
9
+ Requires-Dist: pydantic<3,>=2.7
10
+ Provides-Extra: dev
11
+ Requires-Dist: build>=1.2; extra == 'dev'
12
+ Requires-Dist: bump2version>=1.0; extra == 'dev'
13
+ Requires-Dist: mypy>=1.10; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: ruff>=0.5; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Reconify Python SDK
20
+
21
+ Typed synchronous and asynchronous clients for the Reconify Public API.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install reconify-python
27
+ ```
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ from reconify import Reconify
33
+
34
+ with Reconify(api_key="rk_...") as client:
35
+ sources = client.ledger.list_ledger_sources(limit=25)
36
+ for source in sources.sources or []:
37
+ print(source.id, source.name)
38
+ ```
39
+
40
+ The key may also be supplied through `RECONIFY_API_KEY`. The default endpoint is
41
+ `https://api.reconifyhq.com/v1`; pass `base_url="https://staging.example/v1"`
42
+ for staging or self-hosted deployments. `/v1` is added when it is absent.
43
+
44
+ ## Async usage and pagination
45
+
46
+ ```python
47
+ from reconify import AsyncReconify
48
+
49
+ async with AsyncReconify() as client:
50
+ async for event in client.iter_events(limit=100):
51
+ print(event.id)
52
+ ```
53
+
54
+ Cursor and offset iterators preserve opaque cursors and the server's page size.
55
+ When an endpoint accepts both cursor and offset pagination, `after` takes
56
+ precedence.
57
+
58
+ Every list operation also has a natural iterator on the client, for example
59
+ `client.iter_reconciliations(limit=100)` or
60
+ `client.iter_wallet_transactions(after="cursor")`. The async equivalent is
61
+ an async iterator. Iterators forward query parameters using keyword arguments,
62
+ so they never expose transport details.
63
+
64
+ ## Errors and retries
65
+
66
+ HTTP failures raise typed `ReconifyError` subclasses. Every HTTP error exposes
67
+ `status_code`, `detail`, `code`, validation details, response headers, and the
68
+ response `request_id` without including credentials or request bodies.
69
+
70
+ 429, 503, and transient HTTP transport failures such as timeouts are retried
71
+ for safe methods with bounded exponential backoff and jitter. Mutating methods
72
+ are not retried unless `RetryConfig(retry_unsafe_methods=True)` is supplied.
73
+ Transaction ingestion retries must reuse each row's `idempotencyKey`.
74
+
75
+ The client default timeout is 30 seconds. Individual operations can override
76
+ it with `timeout=...`, including an `httpx.Timeout` object. Async operations
77
+ also support normal `asyncio` cancellation, which is the Python equivalent of
78
+ context cancellation in other SDKs.
79
+
80
+ Use `raw=True` on any operation to receive status, headers, request ID, and raw
81
+ body through `RawResponse`.
82
+
83
+ ## Test-session and ingestion headers
84
+
85
+ Integrity ingestion and test-session submission accept
86
+ `integrity_test_session=...`, which is sent as `X-Integrity-Test-Session`.
87
+ Integrity batches support 1–500 events and ledger transaction batches support
88
+ 1–5000 transactions; the SDK does not truncate caller input.
89
+
90
+ The SDK intentionally excludes reconciliation adjustment, evidence, lifecycle,
91
+ report-item, and signoff operations. The retained reconciliation surface is
92
+ integrity sources, reconciliation list/create/get, and all schedule operations.
93
+
94
+ ## API reference
95
+
96
+ The public operation methods are grouped by API module. Request bodies use the
97
+ typed Pydantic models exported from `reconify.models`; list query parameters use
98
+ the OpenAPI names in snake_case. Every operation accepts `raw=True` and a
99
+ per-request `timeout` override.
100
+
101
+ | Module | Methods |
102
+ | --- | --- |
103
+ | Alerts | `list_alert_rules`, `put_alert_rule` |
104
+ | Events | `list_events`, `get_event`, `reveal_event_field` |
105
+ | Ingestion | `ingest_integrity_events`, `ingest_integrity_test_events` |
106
+ | Issues | `list_issues`, `get_issue_summary`, `get_issue`, `update_issue`, `list_issue_deliveries`, `retry_issue_delivery`, `add_issue_note`, `resolve_issue` |
107
+ | Ledger | `list_ledger_sources`, `create_ledger_source`, `delete_ledger_source`, `get_ledger_source`, `update_ledger_source`, `list_source_periods`, `list_transactions`, `ingest_transactions` |
108
+ | Reconciliations | `list_integrity_sources_for_reconciliation`, `list_reconciliation_schedules`, `create_reconciliation_schedule`, `delete_reconciliation_schedule`, `get_reconciliation_schedule`, `update_reconciliation_schedule`, `list_reconciliations`, `create_reconciliation`, `get_reconciliation` |
109
+ | Search | `search_integrity_resources` |
110
+ | Setup | `list_setup_integrations`, `get_setup_integration`, `list_setup_sources`, `create_setup_source`, `get_setup_source`, `update_setup_source`, `disable_setup_source`, `create_test_session`, `get_test_session`, `get_test_session_result`, `retry_test_session`, `submit_test_session_events` |
111
+ | Transactions | `list_wallet_transactions`, `get_wallet_transaction` |
112
+ | Wallets | `list_wallets`, `get_wallet`, `get_wallet_balance` |
113
+
114
+ ## Build and deploy
115
+
116
+ Build the distributable artifacts locally or in CI:
117
+
118
+ ```bash
119
+ python -m pip install build
120
+ python -m build
121
+ ```
122
+
123
+ The resulting wheel and source archive in `dist/` are ready for publication to
124
+ an internal or public Python package registry. CI builds both artifacts after
125
+ running lint, type checking, and tests.
@@ -0,0 +1,23 @@
1
+ reconify/__init__.py,sha256=zd4a21ozt8VkPdgPQO2PXRv_Wz2IepC-x4Q9g9FIufY,878
2
+ reconify/client.py,sha256=2smGF9y6v7sVO1xXp5SYbXXaGFrD3o7Q30Zkc3z1iPg,8349
3
+ reconify/errors.py,sha256=Dt9UVBSt34jL09AnYWRDKk0UEu70UpmDhQI_MyOzrkM,4185
4
+ reconify/models.py,sha256=SBzHBV8Chd5BT0eFa-IbtCk8eYZI7-5eJLCB7yiuTCM,45483
5
+ reconify/pagination.py,sha256=R0kJgeFYmuTujl9VEHCMEarTjp_J3aLuxEM_tRiruss,3486
6
+ reconify/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ reconify/transport.py,sha256=WFJ3zTjoaUd_PkV9JU3KaA-ahRc7fi0pY-iz9SezhIc,10709
8
+ reconify/resources/__init__.py,sha256=XN-fcvMI5TsG8G1EnVq47NkUgAOWBvOd_k3u4JkmnRo,5325
9
+ reconify/resources/alerts.py,sha256=hZ15beElYoGaPefFSlsU-NyswUwj92w-zjzC4QbTvvs,1605
10
+ reconify/resources/base.py,sha256=qHZm6LuIPJmLTKAjj221QaWuUi0aorIa8GWeWB4qtDs,3279
11
+ reconify/resources/events.py,sha256=tc-5Fkb9G59W9ee1r0Yd7Nv_Kn6FuMeNCoCiQ0T3qHk,2213
12
+ reconify/resources/ingestion.py,sha256=7uzumDHPbiY-dPe4HPIZc250LqxMFA3Izm78s5iLC8E,2652
13
+ reconify/resources/issues.py,sha256=ulTlQVcOtgPCthg5MLlxWpAe3u3MWvaJ2xP8CVPaznM,6113
14
+ reconify/resources/ledger.py,sha256=I1ReJqEo-Qin5vvT126Y36YPaaXwk-DmuOGix2PkXm0,6391
15
+ reconify/resources/reconciliations.py,sha256=uCZqG8tMiBz_pzgD0htdEx5aELL-F2XoTv8AyQHv98g,7458
16
+ reconify/resources/search.py,sha256=CtRzVyD3fsYLWUtzNd7HA0S2JkNakg905-97JlLRHe8,885
17
+ reconify/resources/setup.py,sha256=SxBXerZzQVB9Xkw17PLngQg8z5Z8S_zLYKNfyCFKn_k,9756
18
+ reconify/resources/transactions.py,sha256=4pRd7MW9DFp9U_J-mBj3x_cYcMor8FlhdVia0OaeAC4,1678
19
+ reconify/resources/wallets.py,sha256=R0dbzK5DpqvT54FmCkXXw0rcH3LepI22Pl94voC5e_Q,2261
20
+ reconify_python-0.1.0.dist-info/METADATA,sha256=nyF8H5D8zRyeXJyuXDHCMKHAaJNj0u1b8WbcDAlvS0M,5328
21
+ reconify_python-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
22
+ reconify_python-0.1.0.dist-info/licenses/LICENSE,sha256=q4Eqlx9sTPEY6DaOMuTjXAtVA7uEjxZkcvaJzcuYaIw,1065
23
+ reconify_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Reconify
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.