spreadspace 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.
- spreadspace/__init__.py +75 -0
- spreadspace/_transport.py +272 -0
- spreadspace/_version.py +12 -0
- spreadspace/client.py +308 -0
- spreadspace/errors.py +117 -0
- spreadspace/helpers/__init__.py +5 -0
- spreadspace/helpers/operations.py +196 -0
- spreadspace/helpers/pagination.py +89 -0
- spreadspace/helpers/upload.py +283 -0
- spreadspace/webhooks.py +330 -0
- spreadspace-0.1.0.dist-info/METADATA +171 -0
- spreadspace-0.1.0.dist-info/RECORD +13 -0
- spreadspace-0.1.0.dist-info/WHEEL +4 -0
spreadspace/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""SpreadSpace Python SDK.
|
|
2
|
+
|
|
3
|
+
from spreadspace import SpreadSpace
|
|
4
|
+
client = SpreadSpace(api_key="ss_test_...")
|
|
5
|
+
for borrower in client.borrowers.list():
|
|
6
|
+
...
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._version import DEFAULT_API_VERSION, SDK_VERSION
|
|
10
|
+
from .client import SpreadSpace
|
|
11
|
+
from .errors import (
|
|
12
|
+
APIStatusError,
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
BadRequestError,
|
|
15
|
+
ConflictError,
|
|
16
|
+
InternalServerError,
|
|
17
|
+
NetworkError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
PermissionDeniedError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
SpreadSpaceError,
|
|
22
|
+
)
|
|
23
|
+
from .helpers.operations import (
|
|
24
|
+
AsyncOperation,
|
|
25
|
+
AsyncOperationError,
|
|
26
|
+
AsyncOperationHandle,
|
|
27
|
+
AsyncOperationTimeout,
|
|
28
|
+
)
|
|
29
|
+
from .helpers.pagination import PageIterator
|
|
30
|
+
from .helpers.upload import JobHandle, PresignedUrl, UploadError, UploadTimeout
|
|
31
|
+
from .webhooks import (
|
|
32
|
+
DEFAULT_FRESHNESS_TOLERANCE_SECONDS,
|
|
33
|
+
SIGNATURE_HEADER_NAME,
|
|
34
|
+
WEBHOOK_EVENT_TYPES,
|
|
35
|
+
WebhookEvent,
|
|
36
|
+
WebhookSignatureError,
|
|
37
|
+
verify_and_parse_webhook,
|
|
38
|
+
verify_webhook_signature,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
# client + version
|
|
43
|
+
"SpreadSpace",
|
|
44
|
+
"SDK_VERSION",
|
|
45
|
+
"DEFAULT_API_VERSION",
|
|
46
|
+
# errors
|
|
47
|
+
"SpreadSpaceError",
|
|
48
|
+
"NetworkError",
|
|
49
|
+
"APIStatusError",
|
|
50
|
+
"BadRequestError",
|
|
51
|
+
"AuthenticationError",
|
|
52
|
+
"PermissionDeniedError",
|
|
53
|
+
"NotFoundError",
|
|
54
|
+
"ConflictError",
|
|
55
|
+
"RateLimitError",
|
|
56
|
+
"InternalServerError",
|
|
57
|
+
# helper types
|
|
58
|
+
"PageIterator",
|
|
59
|
+
"AsyncOperation",
|
|
60
|
+
"AsyncOperationHandle",
|
|
61
|
+
"AsyncOperationError",
|
|
62
|
+
"AsyncOperationTimeout",
|
|
63
|
+
"JobHandle",
|
|
64
|
+
"PresignedUrl",
|
|
65
|
+
"UploadError",
|
|
66
|
+
"UploadTimeout",
|
|
67
|
+
# webhooks
|
|
68
|
+
"verify_webhook_signature",
|
|
69
|
+
"verify_and_parse_webhook",
|
|
70
|
+
"WebhookSignatureError",
|
|
71
|
+
"WebhookEvent",
|
|
72
|
+
"WEBHOOK_EVENT_TYPES",
|
|
73
|
+
"SIGNATURE_HEADER_NAME",
|
|
74
|
+
"DEFAULT_FRESHNESS_TOLERANCE_SECONDS",
|
|
75
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""HTTP transport: auth, version header, idempotency, retries, error mapping.
|
|
2
|
+
|
|
3
|
+
This is the configured client every helper and (eventually) the generated core
|
|
4
|
+
sit on top of. It is hand-written, not generated. Responsibilities:
|
|
5
|
+
|
|
6
|
+
* `Authorization: Bearer <key>` + `SpreadSpace-Version` on every API request.
|
|
7
|
+
* Auto `Idempotency-Key` (uuid4) on non-safe methods, suppressible per call.
|
|
8
|
+
* Retry 429 + 5xx + transport errors with exponential backoff + full jitter,
|
|
9
|
+
honoring `Retry-After`.
|
|
10
|
+
* Decode JSON with `parse_float=Decimal` so money survives exactly (no float64
|
|
11
|
+
corruption) regardless of how the wire types it.
|
|
12
|
+
* Map the canonical error envelope to typed exceptions carrying `request_id`,
|
|
13
|
+
preferring the `X-Request-ID` response header.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import random
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from decimal import Decimal
|
|
25
|
+
from typing import Any, Callable, Mapping, Optional
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from . import errors
|
|
30
|
+
from ._version import DEFAULT_API_VERSION, SDK_VERSION
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.spreadspace.ai"
|
|
33
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
34
|
+
|
|
35
|
+
# Backoff tuning, mirrored from the node SDK (base 500ms, cap 30s, full jitter).
|
|
36
|
+
_BACKOFF_BASE_SECONDS = 0.5
|
|
37
|
+
_BACKOFF_MAX_SECONDS = 30.0
|
|
38
|
+
|
|
39
|
+
# Sentinel distinguishing "argument omitted" from an explicit None.
|
|
40
|
+
_UNSET: Any = object()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def loads(text: Optional[str]) -> Any:
|
|
44
|
+
"""Decode a JSON document, materializing every fractional number as Decimal.
|
|
45
|
+
|
|
46
|
+
`parse_float=Decimal` reads the literal digits of each float-looking token,
|
|
47
|
+
so `"1234.56"` becomes `Decimal("1234.56")` exactly. Integer tokens stay
|
|
48
|
+
`int`. This is what makes money exact in Python without any wire change.
|
|
49
|
+
"""
|
|
50
|
+
if not text:
|
|
51
|
+
return None
|
|
52
|
+
return json.loads(text, parse_float=Decimal)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _dumps(body: Any) -> bytes:
|
|
56
|
+
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clean_params(params: Optional[Mapping[str, Any]]) -> Optional[dict]:
|
|
60
|
+
if not params:
|
|
61
|
+
return None
|
|
62
|
+
out: dict[str, Any] = {}
|
|
63
|
+
for key, value in params.items():
|
|
64
|
+
if value is None:
|
|
65
|
+
continue
|
|
66
|
+
if isinstance(value, bool):
|
|
67
|
+
out[key] = "true" if value else "false"
|
|
68
|
+
else:
|
|
69
|
+
out[key] = value
|
|
70
|
+
return out or None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
74
|
+
if not value:
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
return max(0.0, float(value))
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
# HTTP-date form is unusual here; the API emits integer seconds.
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Transport:
|
|
84
|
+
"""Configured HTTP client wrapping httpx with SpreadSpace conventions."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
api_key: Optional[str] = None,
|
|
90
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
91
|
+
api_version: str = DEFAULT_API_VERSION,
|
|
92
|
+
timeout: float = 60.0,
|
|
93
|
+
max_retries: int = 2,
|
|
94
|
+
httpx_transport: Optional[httpx.BaseTransport] = None,
|
|
95
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
96
|
+
) -> None:
|
|
97
|
+
key = api_key if api_key is not None else os.environ.get("SPREADSPACE_API_KEY")
|
|
98
|
+
if not key:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"An API key is required. Pass api_key=... or set SPREADSPACE_API_KEY."
|
|
101
|
+
)
|
|
102
|
+
self._api_key = key
|
|
103
|
+
self._base_url = base_url.rstrip("/")
|
|
104
|
+
self._api_version = api_version
|
|
105
|
+
self._max_retries = max_retries
|
|
106
|
+
self._sleep = sleep
|
|
107
|
+
self._user_agent = (
|
|
108
|
+
f"spreadspace-python/{SDK_VERSION} python/{platform.python_version()}"
|
|
109
|
+
)
|
|
110
|
+
self._client = httpx.Client(timeout=timeout, transport=httpx_transport)
|
|
111
|
+
|
|
112
|
+
# -- public API ---------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def request(
|
|
115
|
+
self,
|
|
116
|
+
method: str,
|
|
117
|
+
path: str,
|
|
118
|
+
*,
|
|
119
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
120
|
+
json: Any = None,
|
|
121
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
122
|
+
idempotency_key: Any = _UNSET,
|
|
123
|
+
api_version: Optional[str] = None,
|
|
124
|
+
max_retries: Optional[int] = None,
|
|
125
|
+
) -> Any:
|
|
126
|
+
"""Issue an API request and return the decoded JSON body (or None)."""
|
|
127
|
+
url = self._base_url + path
|
|
128
|
+
req_headers = self._default_headers(
|
|
129
|
+
method,
|
|
130
|
+
api_version=api_version,
|
|
131
|
+
idempotency_key=idempotency_key,
|
|
132
|
+
has_body=json is not None,
|
|
133
|
+
)
|
|
134
|
+
if headers:
|
|
135
|
+
req_headers.update(headers)
|
|
136
|
+
content = _dumps(json) if json is not None else None
|
|
137
|
+
request = self._client.build_request(
|
|
138
|
+
method, url, params=_clean_params(params), content=content, headers=req_headers
|
|
139
|
+
)
|
|
140
|
+
retries = self._max_retries if max_retries is None else max_retries
|
|
141
|
+
response = self._send(request, max_retries=retries)
|
|
142
|
+
return self._process(response)
|
|
143
|
+
|
|
144
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
145
|
+
return self.request("GET", path, **kwargs)
|
|
146
|
+
|
|
147
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
148
|
+
return self.request("POST", path, **kwargs)
|
|
149
|
+
|
|
150
|
+
def put_presigned(
|
|
151
|
+
self,
|
|
152
|
+
url: str,
|
|
153
|
+
data: Any,
|
|
154
|
+
content_type: str,
|
|
155
|
+
*,
|
|
156
|
+
max_retries: Optional[int] = None,
|
|
157
|
+
) -> httpx.Response:
|
|
158
|
+
"""PUT bytes straight to a presigned S3 URL (out-of-band, no auth).
|
|
159
|
+
|
|
160
|
+
`Content-Type` must match the value sent when minting the URL — it is
|
|
161
|
+
part of the V4 signature. No SSE headers (bucket-default KMS applies).
|
|
162
|
+
"""
|
|
163
|
+
request = self._client.build_request(
|
|
164
|
+
"PUT", url, content=data, headers={"Content-Type": content_type}
|
|
165
|
+
)
|
|
166
|
+
retries = self._max_retries if max_retries is None else max_retries
|
|
167
|
+
response = self._send(request, max_retries=retries)
|
|
168
|
+
if not response.is_success:
|
|
169
|
+
raise errors.APIStatusError(
|
|
170
|
+
f"Presigned upload failed: HTTP {response.status_code}",
|
|
171
|
+
status_code=response.status_code,
|
|
172
|
+
raw_body=response.text,
|
|
173
|
+
)
|
|
174
|
+
return response
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
self._client.close()
|
|
178
|
+
|
|
179
|
+
def __enter__(self) -> "Transport":
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
183
|
+
self.close()
|
|
184
|
+
|
|
185
|
+
# -- internals ----------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _default_headers(
|
|
188
|
+
self,
|
|
189
|
+
method: str,
|
|
190
|
+
*,
|
|
191
|
+
api_version: Optional[str],
|
|
192
|
+
idempotency_key: Any,
|
|
193
|
+
has_body: bool,
|
|
194
|
+
) -> dict[str, str]:
|
|
195
|
+
headers = {
|
|
196
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
197
|
+
"SpreadSpace-Version": api_version or self._api_version,
|
|
198
|
+
"User-Agent": self._user_agent,
|
|
199
|
+
"Accept": "application/json",
|
|
200
|
+
}
|
|
201
|
+
if has_body:
|
|
202
|
+
headers["Content-Type"] = "application/json"
|
|
203
|
+
if method.upper() not in _SAFE_METHODS:
|
|
204
|
+
if idempotency_key is _UNSET:
|
|
205
|
+
headers["Idempotency-Key"] = str(uuid.uuid4())
|
|
206
|
+
elif idempotency_key is not None:
|
|
207
|
+
headers["Idempotency-Key"] = str(idempotency_key)
|
|
208
|
+
# idempotency_key is None -> caller explicitly suppressed the header.
|
|
209
|
+
return headers
|
|
210
|
+
|
|
211
|
+
def _send(self, request: httpx.Request, *, max_retries: int) -> httpx.Response:
|
|
212
|
+
attempt = 0
|
|
213
|
+
while True:
|
|
214
|
+
try:
|
|
215
|
+
response = self._client.send(request)
|
|
216
|
+
except httpx.TransportError as exc:
|
|
217
|
+
if attempt < max_retries:
|
|
218
|
+
self._sleep(self._backoff(attempt, None))
|
|
219
|
+
attempt += 1
|
|
220
|
+
continue
|
|
221
|
+
raise errors.NetworkError(str(exc)) from exc
|
|
222
|
+
if errors.is_retryable_status(response.status_code) and attempt < max_retries:
|
|
223
|
+
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
|
|
224
|
+
response.close()
|
|
225
|
+
self._sleep(self._backoff(attempt, retry_after))
|
|
226
|
+
attempt += 1
|
|
227
|
+
continue
|
|
228
|
+
return response
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _backoff(attempt: int, retry_after: Optional[float]) -> float:
|
|
232
|
+
ceiling = min(_BACKOFF_MAX_SECONDS, _BACKOFF_BASE_SECONDS * (2 ** attempt))
|
|
233
|
+
delay = random.uniform(0, ceiling) # full jitter
|
|
234
|
+
if retry_after is not None:
|
|
235
|
+
delay = max(delay, retry_after)
|
|
236
|
+
return delay
|
|
237
|
+
|
|
238
|
+
def _process(self, response: httpx.Response) -> Any:
|
|
239
|
+
text = response.text
|
|
240
|
+
if response.is_success:
|
|
241
|
+
return loads(text) if text else None
|
|
242
|
+
self._raise(response, text)
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _raise(response: httpx.Response, text: str) -> None:
|
|
246
|
+
request_id = response.headers.get("X-Request-ID")
|
|
247
|
+
err_type: Optional[str] = None
|
|
248
|
+
message: Optional[str] = None
|
|
249
|
+
details = None
|
|
250
|
+
body: Any = None
|
|
251
|
+
try:
|
|
252
|
+
body = loads(text)
|
|
253
|
+
except ValueError:
|
|
254
|
+
body = None
|
|
255
|
+
if isinstance(body, dict):
|
|
256
|
+
err = body.get("error")
|
|
257
|
+
if isinstance(err, dict):
|
|
258
|
+
err_type = err.get("type")
|
|
259
|
+
message = err.get("message")
|
|
260
|
+
details = err.get("details")
|
|
261
|
+
request_id = request_id or err.get("request_id")
|
|
262
|
+
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
|
|
263
|
+
exc_cls = errors.classify(response.status_code, err_type)
|
|
264
|
+
raise exc_cls(
|
|
265
|
+
message or f"HTTP {response.status_code}",
|
|
266
|
+
type=err_type,
|
|
267
|
+
status_code=response.status_code,
|
|
268
|
+
request_id=request_id,
|
|
269
|
+
details=details,
|
|
270
|
+
raw_body=text,
|
|
271
|
+
retry_after=retry_after,
|
|
272
|
+
)
|
spreadspace/_version.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Version constants.
|
|
2
|
+
|
|
3
|
+
`SDK_VERSION` is the published package version; `DEFAULT_API_VERSION` is the
|
|
4
|
+
dated `SpreadSpace-Version` this SDK release pins by default (overridable per
|
|
5
|
+
request). The SDK semver is decoupled from the dated API version on purpose.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
SDK_VERSION = "0.1.0"
|
|
9
|
+
|
|
10
|
+
# Latest supported API version as of this SDK release. Mirrors
|
|
11
|
+
# ApiVersionRegistry.Latest on the server (api/src/Models/ApiVersionRegistry.cs).
|
|
12
|
+
DEFAULT_API_VERSION = "2026-05-03"
|
spreadspace/client.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""The `SpreadSpace` client facade.
|
|
2
|
+
|
|
3
|
+
Thin ergonomic namespaces over the transport + helpers. These wrap the
|
|
4
|
+
cross-cutting flows a generator can't produce (pagination, the operation
|
|
5
|
+
waiter, the upload sequence); the full typed method-per-endpoint surface comes
|
|
6
|
+
from the generated core (`spreadspace._generated`, produced in CI).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from ._transport import _UNSET, DEFAULT_BASE_URL, Transport
|
|
15
|
+
from ._version import DEFAULT_API_VERSION
|
|
16
|
+
from .helpers import operations as _ops
|
|
17
|
+
from .helpers import upload as _upload
|
|
18
|
+
from .helpers.pagination import PageIterator, paginate
|
|
19
|
+
from .webhooks import verify_and_parse_webhook, verify_webhook_signature
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Borrowers:
|
|
23
|
+
def __init__(self, transport: Transport) -> None:
|
|
24
|
+
self._t = transport
|
|
25
|
+
|
|
26
|
+
def list(
|
|
27
|
+
self, *, intake: Optional[bool] = None, limit: Optional[int] = None,
|
|
28
|
+
api_version: Optional[str] = None,
|
|
29
|
+
) -> PageIterator:
|
|
30
|
+
return paginate(
|
|
31
|
+
self._t, "/api/borrowers",
|
|
32
|
+
params={"limit": limit, "intake": intake}, api_version=api_version,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Loans:
|
|
37
|
+
def __init__(self, transport: Transport) -> None:
|
|
38
|
+
self._t = transport
|
|
39
|
+
|
|
40
|
+
def list(
|
|
41
|
+
self, *, borrower_id: Optional[str] = None, limit: Optional[int] = None,
|
|
42
|
+
api_version: Optional[str] = None,
|
|
43
|
+
) -> PageIterator:
|
|
44
|
+
path = f"/api/borrowers/{borrower_id}/loans" if borrower_id else "/api/loans"
|
|
45
|
+
return paginate(self._t, path, params={"limit": limit}, api_version=api_version)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Jobs:
|
|
49
|
+
def __init__(self, transport: Transport) -> None:
|
|
50
|
+
self._t = transport
|
|
51
|
+
|
|
52
|
+
def list(
|
|
53
|
+
self, *, limit: Optional[int] = None, api_version: Optional[str] = None,
|
|
54
|
+
) -> PageIterator:
|
|
55
|
+
return paginate(self._t, "/api/jobs", params={"limit": limit}, api_version=api_version)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _AsyncOperations:
|
|
59
|
+
def __init__(self, transport: Transport) -> None:
|
|
60
|
+
self._t = transport
|
|
61
|
+
|
|
62
|
+
def list(
|
|
63
|
+
self, *, kind: Optional[str] = None, status: Optional[str] = None,
|
|
64
|
+
limit: Optional[int] = None, api_version: Optional[str] = None,
|
|
65
|
+
) -> PageIterator:
|
|
66
|
+
# Divergent envelope: items live under "operations", not "data".
|
|
67
|
+
return paginate(
|
|
68
|
+
self._t, "/api/async-operations",
|
|
69
|
+
params={"limit": limit, "kind": kind, "status": status},
|
|
70
|
+
items_key="operations", api_version=api_version,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def get(self, operation_id: str) -> _ops.AsyncOperation:
|
|
74
|
+
return _ops.get_operation(self._t, operation_id)
|
|
75
|
+
|
|
76
|
+
def cancel(self, operation_id: str) -> _ops.AsyncOperation:
|
|
77
|
+
return _ops.cancel_operation(self._t, operation_id)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _Exports:
|
|
81
|
+
def __init__(self, transport: Transport) -> None:
|
|
82
|
+
self._t = transport
|
|
83
|
+
|
|
84
|
+
def create(
|
|
85
|
+
self, *, borrower_id: Optional[str] = None, loan_id: Optional[str] = None,
|
|
86
|
+
document_ids: Optional[list] = None, format: Optional[str] = None,
|
|
87
|
+
delivery_mode: Optional[str] = None,
|
|
88
|
+
) -> _ops.AsyncOperationHandle:
|
|
89
|
+
return _ops.create_extraction_export(
|
|
90
|
+
self._t, borrower_id=borrower_id, loan_id=loan_id,
|
|
91
|
+
document_ids=document_ids, format=format, delivery_mode=delivery_mode,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def get(self, operation_id: str) -> _ops.AsyncOperation:
|
|
95
|
+
return _ops.get_operation(self._t, operation_id)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _Documents:
|
|
99
|
+
def __init__(self, transport: Transport) -> None:
|
|
100
|
+
self._t = transport
|
|
101
|
+
|
|
102
|
+
def upload(self, file: Any, **kwargs: Any) -> _upload.JobHandle:
|
|
103
|
+
return _upload.upload_document(self._t, file, **kwargs)
|
|
104
|
+
|
|
105
|
+
def status(self, job_id: str) -> dict:
|
|
106
|
+
return _upload.JobHandle(self._t, job_id).status()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Webhooks:
|
|
110
|
+
"""Webhook endpoint registration (`/api/admin/webhooks/*`).
|
|
111
|
+
|
|
112
|
+
The signature verifiers are exposed as static methods for discoverability;
|
|
113
|
+
they're also importable as bare functions from ``spreadspace.webhooks`` for
|
|
114
|
+
receivers that don't want the HTTP transport in scope.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# Static handles to the verifier primitives — `client.webhooks.verify_signature(...)`.
|
|
118
|
+
verify_signature = staticmethod(verify_webhook_signature)
|
|
119
|
+
verify_and_parse = staticmethod(verify_and_parse_webhook)
|
|
120
|
+
|
|
121
|
+
def __init__(self, transport: Transport) -> None:
|
|
122
|
+
self._t = transport
|
|
123
|
+
|
|
124
|
+
def list(
|
|
125
|
+
self, *, limit: Optional[int] = None, api_version: Optional[str] = None,
|
|
126
|
+
) -> PageIterator:
|
|
127
|
+
"""List configured webhook endpoints (`GET /api/admin/webhooks`)."""
|
|
128
|
+
return paginate(
|
|
129
|
+
self._t, "/api/admin/webhooks",
|
|
130
|
+
params={"limit": limit}, api_version=api_version,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def get(self, endpoint_id: str, *, api_version: Optional[str] = None) -> Any:
|
|
134
|
+
"""Retrieve one endpoint (`GET /api/admin/webhooks/{id}`)."""
|
|
135
|
+
return self._t.get(
|
|
136
|
+
f"/api/admin/webhooks/{quote(endpoint_id, safe='')}",
|
|
137
|
+
api_version=api_version,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def create(self, **params: Any) -> Any:
|
|
141
|
+
"""Create an endpoint (`POST /api/admin/webhooks`).
|
|
142
|
+
|
|
143
|
+
The response carries the plaintext signing secret exactly once — store
|
|
144
|
+
it server-side, do not log it.
|
|
145
|
+
"""
|
|
146
|
+
return self._t.post("/api/admin/webhooks", json=params)
|
|
147
|
+
|
|
148
|
+
def update(self, endpoint_id: str, **params: Any) -> Any:
|
|
149
|
+
"""Update endpoint metadata (`PATCH /api/admin/webhooks/{id}`)."""
|
|
150
|
+
return self._t.request(
|
|
151
|
+
"PATCH", f"/api/admin/webhooks/{quote(endpoint_id, safe='')}",
|
|
152
|
+
json=params,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def delete(self, endpoint_id: str) -> Any:
|
|
156
|
+
"""Delete an endpoint (`DELETE /api/admin/webhooks/{id}`)."""
|
|
157
|
+
return self._t.request(
|
|
158
|
+
"DELETE", f"/api/admin/webhooks/{quote(endpoint_id, safe='')}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def rotate_secret(self, endpoint_id: str, **params: Any) -> Any:
|
|
162
|
+
"""Rotate an endpoint's signing secret
|
|
163
|
+
(`POST /api/admin/webhooks/{id}/rotate`). Returns the new plaintext
|
|
164
|
+
secret exactly once; the old one stays valid for a grace window.
|
|
165
|
+
"""
|
|
166
|
+
return self._t.post(
|
|
167
|
+
f"/api/admin/webhooks/{quote(endpoint_id, safe='')}/rotate",
|
|
168
|
+
json=params,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def deliveries(
|
|
172
|
+
self, endpoint_id: str, *, limit: Optional[int] = None,
|
|
173
|
+
api_version: Optional[str] = None,
|
|
174
|
+
) -> PageIterator:
|
|
175
|
+
"""Iterate delivery attempts
|
|
176
|
+
(`GET /api/admin/webhooks/{id}/deliveries`)."""
|
|
177
|
+
return paginate(
|
|
178
|
+
self._t,
|
|
179
|
+
f"/api/admin/webhooks/{quote(endpoint_id, safe='')}/deliveries",
|
|
180
|
+
params={"limit": limit}, api_version=api_version,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def replay_delivery(
|
|
184
|
+
self, endpoint_id: str, delivery_id: str, **params: Any
|
|
185
|
+
) -> Any:
|
|
186
|
+
"""Replay a delivery
|
|
187
|
+
(`POST /api/admin/webhooks/{id}/deliveries/{deliveryId}/replay`)."""
|
|
188
|
+
return self._t.post(
|
|
189
|
+
f"/api/admin/webhooks/{quote(endpoint_id, safe='')}"
|
|
190
|
+
f"/deliveries/{quote(delivery_id, safe='')}/replay",
|
|
191
|
+
json=params,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _EmbedSessions:
|
|
196
|
+
"""Embed-session minting (`/api/embed/*`).
|
|
197
|
+
|
|
198
|
+
Mint short-lived `ss_embed_` tokens server-side with the integrator's API
|
|
199
|
+
key, then hand the token (or signed iframe URL) to the browser. Minted
|
|
200
|
+
tokens are locked to a single `loan_id`, capped to the embed read-only
|
|
201
|
+
scope allowlist, and cannot mint further tokens.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(self, transport: Transport) -> None:
|
|
205
|
+
self._t = transport
|
|
206
|
+
|
|
207
|
+
def create(
|
|
208
|
+
self, *, loan_id: str, scopes: Optional[list] = None,
|
|
209
|
+
expires_in_seconds: Optional[int] = None,
|
|
210
|
+
) -> Any:
|
|
211
|
+
"""Mint an embed-session token (`POST /api/embed/sessions`).
|
|
212
|
+
|
|
213
|
+
Returns ``{embed_token, session_id, expires_at, loan_id, borrower_id,
|
|
214
|
+
scopes}`` — the token is shown once, no retrieval endpoint. ``scopes``
|
|
215
|
+
omitted inherits the key's scopes capped to the embed allowlist;
|
|
216
|
+
``expires_in_seconds`` defaults to 3600, caps at 86400.
|
|
217
|
+
"""
|
|
218
|
+
body: dict[str, Any] = {"loan_id": loan_id}
|
|
219
|
+
if scopes is not None:
|
|
220
|
+
body["scopes"] = scopes
|
|
221
|
+
if expires_in_seconds is not None:
|
|
222
|
+
body["expires_in_seconds"] = expires_in_seconds
|
|
223
|
+
return self._t.post("/api/embed/sessions", json=body)
|
|
224
|
+
|
|
225
|
+
def revoke(self, session_id: str, *, idempotency_key: Any = _UNSET) -> Any:
|
|
226
|
+
"""Revoke an embed session early
|
|
227
|
+
(`DELETE /api/embed/sessions/{sessionId}`). Returns ``None`` (204)."""
|
|
228
|
+
return self._t.request(
|
|
229
|
+
"DELETE", f"/api/embed/sessions/{quote(session_id, safe='')}",
|
|
230
|
+
idempotency_key=idempotency_key,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def create_iframe_url(
|
|
234
|
+
self, *, loan_id: str, surface: str, scopes: Optional[list] = None,
|
|
235
|
+
handle_lifetime_seconds: Optional[int] = None,
|
|
236
|
+
token_lifetime_seconds: Optional[int] = None,
|
|
237
|
+
) -> Any:
|
|
238
|
+
"""Mint an iframe signed-URL handle (`POST /api/embed/iframe-urls`).
|
|
239
|
+
|
|
240
|
+
Returns ``{signed_url, handle_id, expires_at, surface, loan_id,
|
|
241
|
+
borrower_id, scopes}`` — ``expires_at`` is the HANDLE expiry, not the
|
|
242
|
+
token's. ``surface`` is validated server-side (e.g. ``"spreading"``).
|
|
243
|
+
``handle_lifetime_seconds`` defaults to 60 (cap 300);
|
|
244
|
+
``token_lifetime_seconds`` defaults to 3600 (cap 86400).
|
|
245
|
+
"""
|
|
246
|
+
body: dict[str, Any] = {"loan_id": loan_id, "surface": surface}
|
|
247
|
+
if scopes is not None:
|
|
248
|
+
body["scopes"] = scopes
|
|
249
|
+
if handle_lifetime_seconds is not None:
|
|
250
|
+
body["handle_lifetime_seconds"] = handle_lifetime_seconds
|
|
251
|
+
if token_lifetime_seconds is not None:
|
|
252
|
+
body["token_lifetime_seconds"] = token_lifetime_seconds
|
|
253
|
+
return self._t.post("/api/embed/iframe-urls", json=body)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class _Embed:
|
|
257
|
+
"""Top-level `client.embed` namespace; surfaces the `sessions` sub-resource."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, transport: Transport) -> None:
|
|
260
|
+
self.sessions = _EmbedSessions(transport)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class SpreadSpace:
|
|
264
|
+
"""Entry point. `SpreadSpace(api_key=...)` or set `SPREADSPACE_API_KEY`.
|
|
265
|
+
|
|
266
|
+
An `ss_test_` key routes to the isolated sandbox tenant; `ss_live_` operates
|
|
267
|
+
on real workspace data. Both hit the same base URL.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def __init__(
|
|
271
|
+
self,
|
|
272
|
+
api_key: Optional[str] = None,
|
|
273
|
+
*,
|
|
274
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
275
|
+
api_version: str = DEFAULT_API_VERSION,
|
|
276
|
+
timeout: float = 60.0,
|
|
277
|
+
max_retries: int = 2,
|
|
278
|
+
transport: Optional[Transport] = None,
|
|
279
|
+
) -> None:
|
|
280
|
+
self._transport = transport or Transport(
|
|
281
|
+
api_key=api_key, base_url=base_url, api_version=api_version,
|
|
282
|
+
timeout=timeout, max_retries=max_retries,
|
|
283
|
+
)
|
|
284
|
+
self.borrowers = _Borrowers(self._transport)
|
|
285
|
+
self.loans = _Loans(self._transport)
|
|
286
|
+
self.jobs = _Jobs(self._transport)
|
|
287
|
+
self.async_operations = _AsyncOperations(self._transport)
|
|
288
|
+
self.exports = _Exports(self._transport)
|
|
289
|
+
self.documents = _Documents(self._transport)
|
|
290
|
+
self.webhooks = _Webhooks(self._transport)
|
|
291
|
+
self.embed = _Embed(self._transport)
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def transport(self) -> Transport:
|
|
295
|
+
return self._transport
|
|
296
|
+
|
|
297
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
298
|
+
"""Low-level escape hatch to any endpoint the helpers don't wrap."""
|
|
299
|
+
return self._transport.request(method, path, **kwargs)
|
|
300
|
+
|
|
301
|
+
def close(self) -> None:
|
|
302
|
+
self._transport.close()
|
|
303
|
+
|
|
304
|
+
def __enter__(self) -> "SpreadSpace":
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
308
|
+
self.close()
|