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.
@@ -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
+ )
@@ -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()