specstream-client 1.0.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,1451 @@
1
+ """OpenAPI-aligned SPECSTREAM client: one product, explicit quotes and shared dollar balance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import urllib.error
8
+ import urllib.request
9
+ from collections.abc import Callable
10
+ from typing import Any, cast
11
+ from urllib.parse import quote as quote_path
12
+ from urllib.parse import urlencode, urljoin, urlsplit
13
+
14
+ from specstream_client.authorization import require_work_authorization
15
+ from specstream_client.families import FAMILIES
16
+
17
+ DEFAULT_BASE_URL = "https://api.specstream.ai"
18
+ USER_AGENT = "specstream-python/1.0.0"
19
+
20
+ OpenFn = Callable[[urllib.request.Request, float], Any]
21
+
22
+
23
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
24
+ def redirect_request(
25
+ self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
26
+ ) -> None:
27
+ return None
28
+
29
+
30
+ class SpecstreamError(RuntimeError):
31
+ def __init__(self, message: str, status: int | None = None, body: Any = None) -> None:
32
+ super().__init__(message)
33
+ self.status = status
34
+ self.body = body
35
+ self.code: str | None = None
36
+ self.details: dict[str, Any] = {}
37
+ parsed = body
38
+ if isinstance(parsed, (str, bytes)):
39
+ try:
40
+ parsed = json.loads(parsed)
41
+ except (ValueError, UnicodeError):
42
+ parsed = None
43
+ if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
44
+ error = parsed["error"]
45
+ self.code = error.get("code") if isinstance(error.get("code"), str) else None
46
+ if isinstance(error.get("details"), dict):
47
+ self.details = dict(error["details"])
48
+
49
+
50
+ class Client:
51
+ """Typed HTTP client for finder.cross-source.v1 public operations."""
52
+
53
+ def __init__(
54
+ self,
55
+ api_key: str,
56
+ base_url: str = DEFAULT_BASE_URL,
57
+ opener: OpenFn | None = None,
58
+ timeout: float = 60.0,
59
+ *,
60
+ export_origin: str | None = None,
61
+ ) -> None:
62
+ key = (api_key or "").strip()
63
+ if not key:
64
+ raise SpecstreamError("SPECSTREAM_API_KEY is required")
65
+ self.api_key = key
66
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/") + "/"
67
+ self.timeout = timeout
68
+ self.export_origin = export_origin
69
+ self._opener: OpenFn
70
+ if opener is None:
71
+ transport = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
72
+ self._opener = lambda request, timeout: transport.open(request, timeout=timeout)
73
+ else:
74
+ self._opener = opener
75
+
76
+ def compile(self, q: str, locale: str = "en-US") -> dict[str, Any]:
77
+ """POST /v1/parse (operationId parseQuery) — compile language into query_ast.v1."""
78
+ data = self._request("POST", "/v1/parse", {"q": q, "locale": locale})
79
+ return data
80
+
81
+ def edit_spec(
82
+ self, ast: dict[str, Any], operations: list[dict[str, Any]] | None = None
83
+ ) -> dict[str, Any]:
84
+ """Validate an edited Spec, retaining required facts and exact target scope."""
85
+ return self._request(
86
+ "POST",
87
+ "/v1/parse/from-chips",
88
+ {"ast": ast, "operations": operations if operations is not None else []},
89
+ )
90
+
91
+ def spec_options(self, target: str) -> dict[str, Any]:
92
+ """Read supported conditions and return facts for a target."""
93
+ return self._request("GET", f"/v1/spec-options/{quote_path(target, safe='')}", None)
94
+
95
+ def revisit(self, find_id: str, *, after: str = "", limit: int = 100) -> dict[str, Any]:
96
+ """Read exact-target outcomes, including unknown and not-reached records."""
97
+ query = urlencode({"after": after, "limit": limit})
98
+ return self._request(
99
+ "GET", f"/v1/finds/{quote_path(find_id, safe='')}/revisit?{query}", None
100
+ )
101
+
102
+ def rows(self, find_id: str, *, cursor: str | None = None, limit: int = 100) -> dict[str, Any]:
103
+ """Read accepted rows and separate unbilled possible candidates.
104
+
105
+ Follow next_cursor for accepted rows. Possible candidates have open
106
+ conditions, identity-only cells and billable=False; do not count them as Matches.
107
+ """
108
+ if type(limit) is not int or not 1 <= limit <= 100:
109
+ raise ValueError("Row page limit must be an integer from 1 to 100")
110
+ if cursor is not None and (not isinstance(cursor, str) or not cursor or len(cursor) > 2048):
111
+ raise ValueError("Use the opaque next_cursor returned by this Delivery")
112
+ query = {"limit": str(limit)}
113
+ if cursor is not None:
114
+ query["cursor"] = cursor
115
+ path = f"/v1/finds/{quote_path(find_id, safe='')}/rows?" + urlencode(query)
116
+ return self._request("GET", path, None)
117
+
118
+ def export(self, find_id: str, format: str = "csv") -> dict[str, Any]:
119
+ """POST /v1/finds/{id}/export (operationId exportFind)."""
120
+ return self._request(
121
+ "POST", f"/v1/finds/{quote_path(find_id, safe='')}/export", {"format": format}
122
+ )
123
+
124
+ def download_export(
125
+ self, find_id: str, format: str = "xlsx", *, max_bytes: int = 64 * 1024 * 1024
126
+ ) -> bytes:
127
+ """Fetch a fresh one-use export without forwarding the account bearer.
128
+
129
+ Custom deployments with a separate website origin must set export_origin.
130
+ A failed download is not automatically retried: request a fresh capability.
131
+ """
132
+ if type(max_bytes) is not int or max_bytes <= 0:
133
+ raise ValueError("max_bytes must be a positive integer")
134
+ data = self.export(find_id, format)
135
+ url = data.get("download_url")
136
+ if not isinstance(url, str) or any(ord(c) <= 32 or c == "\\" for c in url):
137
+ raise SpecstreamError("Export returned an invalid download location")
138
+ try:
139
+ parsed = urlsplit(url)
140
+ base = urlsplit(self.base_url)
141
+
142
+ def origin_of(value: Any) -> tuple[str, str | None, int | None]:
143
+ default = {"https": 443, "http": 80}.get(value.scheme)
144
+ port = value.port if value.port is not None else default
145
+ return value.scheme, value.hostname, port
146
+
147
+ origin = origin_of(parsed)
148
+ allowed = {origin_of(base)}
149
+ if self.export_origin is not None:
150
+ declared = urlsplit(self.export_origin)
151
+ if (
152
+ declared.scheme not in {"http", "https"}
153
+ or not declared.hostname
154
+ or declared.username is not None
155
+ or declared.password is not None
156
+ or declared.query
157
+ or declared.fragment
158
+ or declared.path not in {"", "/"}
159
+ ):
160
+ raise ValueError("invalid export origin")
161
+ allowed.add(origin_of(declared))
162
+ elif (
163
+ base.scheme == "https"
164
+ and base.hostname == "api.specstream.ai"
165
+ and base.port in {None, 443}
166
+ ):
167
+ allowed.add(("https", "specstream.ai", 443))
168
+ if (
169
+ origin not in allowed
170
+ or parsed.scheme not in {"http", "https"}
171
+ or parsed.username is not None
172
+ or parsed.password is not None
173
+ or parsed.query
174
+ or parsed.fragment
175
+ or not re.fullmatch(r"/export/[A-Za-z0-9_-]+", parsed.path)
176
+ ):
177
+ raise ValueError("untrusted download location")
178
+ except ValueError as exc:
179
+ raise SpecstreamError("Export returned an untrusted download location") from exc
180
+ req = urllib.request.Request(
181
+ url,
182
+ method="GET",
183
+ headers={"Accept": "*/*", "Accept-Encoding": "identity", "User-Agent": USER_AGENT},
184
+ )
185
+ try:
186
+ with self._opener(req, self.timeout) as response:
187
+ status = getattr(response, "status", 200)
188
+ if status != 200:
189
+ raise SpecstreamError("Export download failed", status=status)
190
+ payload = response.read(max_bytes + 1)
191
+ if not payload:
192
+ raise SpecstreamError("Export download returned no body", status=status)
193
+ if len(payload) > max_bytes:
194
+ raise SpecstreamError("Export exceeds max_bytes", status=status)
195
+ return bytes(payload)
196
+ except urllib.error.HTTPError as exc:
197
+ # The capability URL and any response body are deliberately omitted.
198
+ raise SpecstreamError("Export download failed", status=exc.code) from None
199
+ except (urllib.error.URLError, OSError):
200
+ raise SpecstreamError("Export download transport failed") from None
201
+
202
+ def get_find(self, find_id: str) -> dict[str, Any]:
203
+ """GET /v1/finds/{id} (operationId getFind)."""
204
+ return self._request("GET", f"/v1/finds/{quote_path(find_id, safe='')}", None)
205
+
206
+ # BEGIN GENERATED LIFECYCLE
207
+ def get_vocabulary(self) -> dict[str, Any]:
208
+ """Read the public bar vocabulary and ready-made details."""
209
+ path = "/v1/vocabulary"
210
+ return self._request("GET", path, None)
211
+
212
+ def workspace_list_audit_events(
213
+ self,
214
+ *,
215
+ project_id: str | None = None,
216
+ actor_account_id: str | None = None,
217
+ cursor: str | None = None,
218
+ kind: str | None = None,
219
+ limit: int | None = None,
220
+ ) -> dict[str, Any]:
221
+ """Read immutable twelve-month activity with current owner permissions."""
222
+ path = "/v1/workspace/audit"
223
+ query: dict[str, Any] = {
224
+ "project_id": project_id,
225
+ "actor_account_id": actor_account_id,
226
+ "cursor": cursor,
227
+ "kind": kind,
228
+ "limit": limit,
229
+ }
230
+ encoded = urlencode(
231
+ {
232
+ key: str(value).lower() if isinstance(value, bool) else value
233
+ for key, value in query.items()
234
+ if value is not None
235
+ }
236
+ )
237
+ if encoded:
238
+ path += "?" + encoded
239
+ return self._request("GET", path, None)
240
+
241
+ def understand_live(self, request: dict[str, Any]) -> dict[str, Any]:
242
+ """Read an exact editor snapshot without saving or authorizing work."""
243
+ path = "/v1/understand"
244
+ return self._request("POST", path, request)
245
+
246
+ def workspace_search(
247
+ self,
248
+ *,
249
+ q: str,
250
+ limit: int | None = None,
251
+ cursor: str | None = None,
252
+ ) -> dict[str, Any]:
253
+ """Search the requests, lists and sources you can open."""
254
+ path = "/v1/search"
255
+ query: dict[str, Any] = {
256
+ "q": q,
257
+ "limit": limit,
258
+ "cursor": cursor,
259
+ }
260
+ encoded = urlencode(
261
+ {
262
+ key: str(value).lower() if isinstance(value, bool) else value
263
+ for key, value in query.items()
264
+ if value is not None
265
+ }
266
+ )
267
+ if encoded:
268
+ path += "?" + encoded
269
+ return self._request("GET", path, None)
270
+
271
+ def workspace_list_notifications(
272
+ self,
273
+ *,
274
+ tab: str | None = None,
275
+ limit: int | None = None,
276
+ cursor: str | None = None,
277
+ ) -> dict[str, Any]:
278
+ """Read your inbox with current resource authorization."""
279
+ path = "/v1/workspace/notifications"
280
+ query: dict[str, Any] = {
281
+ "tab": tab,
282
+ "limit": limit,
283
+ "cursor": cursor,
284
+ }
285
+ encoded = urlencode(
286
+ {
287
+ key: str(value).lower() if isinstance(value, bool) else value
288
+ for key, value in query.items()
289
+ if value is not None
290
+ }
291
+ )
292
+ if encoded:
293
+ path += "?" + encoded
294
+ return self._request("GET", path, None)
295
+
296
+ def workspace_update_notification(
297
+ self,
298
+ identifier: str,
299
+ request: dict[str, Any],
300
+ ) -> dict[str, Any]:
301
+ """Persist read or archive state from your signed-in browser."""
302
+ path = f"/v1/workspace/notifications/{quote_path(identifier, safe='')}"
303
+ return self._request("PATCH", path, request)
304
+
305
+ def workspace_read_notifications(self, request: dict[str, Any]) -> dict[str, Any]:
306
+ """Mark the supplied authorized notifications read atomically."""
307
+ path = "/v1/workspace/notifications/read"
308
+ return self._request("POST", path, request)
309
+
310
+ def get_me(self) -> dict[str, Any]:
311
+ """Spend ceiling and remaining_spend authorization state."""
312
+ path = "/v1/me"
313
+ return self._request("GET", path, None)
314
+
315
+ def get_account(self) -> dict[str, Any]:
316
+ """Paid history."""
317
+ path = "/v1/account"
318
+ return self._request("GET", path, None)
319
+
320
+ def list_account_keys(self) -> dict[str, Any]:
321
+ """List API keys."""
322
+ path = "/v1/account/keys"
323
+ return self._request("GET", path, None)
324
+
325
+ def create_account_key(self, request: dict[str, Any]) -> dict[str, Any]:
326
+ """Create ss_live_ or ss_test_ key."""
327
+ path = "/v1/account/keys"
328
+ return self._request("POST", path, request)
329
+
330
+ def update_account_spend(self, request: dict[str, Any]) -> dict[str, Any]:
331
+ """Set an explicit owner authorization ceiling without funding or spending."""
332
+ path = "/v1/account/spend"
333
+ return self._request("PATCH", path, request)
334
+
335
+ def revoke_account_key(self, key_id: str) -> dict[str, Any]:
336
+ """Revoke an API key."""
337
+ path = f"/v1/account/keys/{quote_path(key_id, safe='')}"
338
+ return self._request("DELETE", path, None)
339
+
340
+ def get_find_commerce(self, find_id: str) -> dict[str, Any]:
341
+ """Read payment, fulfillment and access truth."""
342
+ path = f"/v1/finds/{quote_path(find_id, safe='')}/commerce"
343
+ return self._request("GET", path, None)
344
+
345
+ def reconcile_find_commerce(self, find_id: str) -> dict[str, Any]:
346
+ """Recover the existing purchase without rerunning work."""
347
+ path = f"/v1/finds/{quote_path(find_id, safe='')}/reconcile"
348
+ return self._request("POST", path, None)
349
+
350
+ def cancel_find(self, find_id: str) -> dict[str, Any]:
351
+ """Request cancel."""
352
+ path = f"/v1/finds/{quote_path(find_id, safe='')}/cancel"
353
+ return self._request("POST", path, None)
354
+
355
+ def share_find(self, find_id: str, request: dict[str, Any]) -> dict[str, Any]:
356
+ """Snapshot share token."""
357
+ path = f"/v1/finds/{quote_path(find_id, safe='')}/share"
358
+ return self._request("POST", path, request)
359
+
360
+ def revoke_share(self, share_id: str, request: dict[str, Any] | None = None) -> dict[str, Any]:
361
+ """Revoke snapshot."""
362
+ path = f"/v1/share/{quote_path(share_id, safe='')}/revoke"
363
+ return self._request("POST", path, request)
364
+
365
+ def create_spec(self, request: dict[str, Any]) -> dict[str, Any]:
366
+ """Save a request as its first revision."""
367
+ path = "/v1/specs"
368
+ return self._request("POST", path, request)
369
+
370
+ def list_specs(
371
+ self,
372
+ *,
373
+ limit: int | None = None,
374
+ cursor: str | None = None,
375
+ project_id: str | None = None,
376
+ archived: bool | None = None,
377
+ ) -> dict[str, Any]:
378
+ """List the saved requests you can open."""
379
+ path = "/v1/specs"
380
+ query: dict[str, Any] = {
381
+ "limit": limit,
382
+ "cursor": cursor,
383
+ "project_id": project_id,
384
+ "archived": archived,
385
+ }
386
+ encoded = urlencode(
387
+ {
388
+ key: str(value).lower() if isinstance(value, bool) else value
389
+ for key, value in query.items()
390
+ if value is not None
391
+ }
392
+ )
393
+ if encoded:
394
+ path += "?" + encoded
395
+ return self._request("GET", path, None)
396
+
397
+ def get_spec(self, spec_id: str) -> dict[str, Any]:
398
+ """Read a saved request and its current revision."""
399
+ path = f"/v1/specs/{quote_path(spec_id, safe='')}"
400
+ return self._request("GET", path, None)
401
+
402
+ def revise_spec(self, spec_id: str, request: dict[str, Any]) -> dict[str, Any]:
403
+ """Create an immutable revision only if the expected head still matches."""
404
+ path = f"/v1/specs/{quote_path(spec_id, safe='')}/revisions"
405
+ return self._request("POST", path, request)
406
+
407
+ def list_spec_revisions(
408
+ self,
409
+ spec_id: str,
410
+ *,
411
+ limit: int | None = None,
412
+ cursor: str | None = None,
413
+ ) -> dict[str, Any]:
414
+ """Read a saved request's revision history."""
415
+ path = f"/v1/specs/{quote_path(spec_id, safe='')}/revisions"
416
+ query: dict[str, Any] = {
417
+ "limit": limit,
418
+ "cursor": cursor,
419
+ }
420
+ encoded = urlencode(
421
+ {
422
+ key: str(value).lower() if isinstance(value, bool) else value
423
+ for key, value in query.items()
424
+ if value is not None
425
+ }
426
+ )
427
+ if encoded:
428
+ path += "?" + encoded
429
+ return self._request("GET", path, None)
430
+
431
+ def get_spec_revision(self, spec_id: str, revision_id: str) -> dict[str, Any]:
432
+ """Read one revision of a saved request."""
433
+ path = (
434
+ "/v1/specs/"
435
+ f"{quote_path(spec_id, safe='')}"
436
+ "/revisions/"
437
+ f"{quote_path(revision_id, safe='')}"
438
+ )
439
+ return self._request("GET", path, None)
440
+
441
+ def archive_spec(self, spec_id: str, request: dict[str, Any]) -> dict[str, Any]:
442
+ """Archive or restore a saved request."""
443
+ path = f"/v1/specs/{quote_path(spec_id, safe='')}"
444
+ return self._request("PATCH", path, request)
445
+
446
+ def list_spec_finds(
447
+ self,
448
+ spec_id: str,
449
+ *,
450
+ limit: int | None = None,
451
+ cursor: str | None = None,
452
+ ) -> dict[str, Any]:
453
+ """Read the finds a saved request has run."""
454
+ path = f"/v1/specs/{quote_path(spec_id, safe='')}/finds"
455
+ query: dict[str, Any] = {
456
+ "limit": limit,
457
+ "cursor": cursor,
458
+ }
459
+ encoded = urlencode(
460
+ {
461
+ key: str(value).lower() if isinstance(value, bool) else value
462
+ for key, value in query.items()
463
+ if value is not None
464
+ }
465
+ )
466
+ if encoded:
467
+ path += "?" + encoded
468
+ return self._request("GET", path, None)
469
+
470
+ def get_find_lineage(self, find_id: str) -> dict[str, Any]:
471
+ """Read which request revision a find ran, and the find it continued."""
472
+ path = f"/v1/finds/{quote_path(find_id, safe='')}/lineage"
473
+ return self._request("GET", path, None)
474
+
475
+ def get_delivery(self, find_id: str) -> dict[str, Any]:
476
+ """Read your finished results with the request revision and sources behind them."""
477
+ path = f"/v1/finds/{quote_path(find_id, safe='')}/delivery"
478
+ return self._request("GET", path, None)
479
+
480
+ def get_find_decisions(
481
+ self,
482
+ find_id: str,
483
+ *,
484
+ limit: int | None = None,
485
+ cursor: str | None = None,
486
+ ) -> dict[str, Any]:
487
+ """Read currently authorized Match decisions."""
488
+ path = f"/v1/finds/{quote_path(find_id, safe='')}/decisions"
489
+ query: dict[str, Any] = {
490
+ "limit": limit,
491
+ "cursor": cursor,
492
+ }
493
+ encoded = urlencode(
494
+ {
495
+ key: str(value).lower() if isinstance(value, bool) else value
496
+ for key, value in query.items()
497
+ if value is not None
498
+ }
499
+ )
500
+ if encoded:
501
+ path += "?" + encoded
502
+ return self._request("GET", path, None)
503
+
504
+ def create_project(self, request: dict[str, Any]) -> dict[str, Any]:
505
+ """Create a project with an explicit bounded authorization ceiling."""
506
+ path = "/v1/projects"
507
+ return self._request("POST", path, request)
508
+
509
+ def list_projects(
510
+ self,
511
+ *,
512
+ limit: int | None = None,
513
+ cursor: str | None = None,
514
+ ) -> dict[str, Any]:
515
+ """List currently authorized projects."""
516
+ path = "/v1/projects"
517
+ query: dict[str, Any] = {
518
+ "limit": limit,
519
+ "cursor": cursor,
520
+ }
521
+ encoded = urlencode(
522
+ {
523
+ key: str(value).lower() if isinstance(value, bool) else value
524
+ for key, value in query.items()
525
+ if value is not None
526
+ }
527
+ )
528
+ if encoded:
529
+ path += "?" + encoded
530
+ return self._request("GET", path, None)
531
+
532
+ def get_project(self, project_id: str) -> dict[str, Any]:
533
+ """Read current project membership and authorization."""
534
+ path = f"/v1/projects/{quote_path(project_id, safe='')}"
535
+ return self._request("GET", path, None)
536
+
537
+ def update_project(self, project_id: str, request: dict[str, Any]) -> dict[str, Any]:
538
+ """Update a project only if the expected version matches."""
539
+ path = f"/v1/projects/{quote_path(project_id, safe='')}"
540
+ return self._request("PATCH", path, request)
541
+
542
+ def put_project_member(self, project_id: str, request: dict[str, Any]) -> dict[str, Any]:
543
+ """Set a current project member role and bounded spending ceiling."""
544
+ path = f"/v1/projects/{quote_path(project_id, safe='')}/members"
545
+ return self._request("POST", path, request)
546
+
547
+ def list_project_members(
548
+ self,
549
+ project_id: str,
550
+ *,
551
+ limit: int | None = None,
552
+ cursor: str | None = None,
553
+ ) -> dict[str, Any]:
554
+ """List current project membership."""
555
+ path = f"/v1/projects/{quote_path(project_id, safe='')}/members"
556
+ query: dict[str, Any] = {
557
+ "limit": limit,
558
+ "cursor": cursor,
559
+ }
560
+ encoded = urlencode(
561
+ {
562
+ key: str(value).lower() if isinstance(value, bool) else value
563
+ for key, value in query.items()
564
+ if value is not None
565
+ }
566
+ )
567
+ if encoded:
568
+ path += "?" + encoded
569
+ return self._request("GET", path, None)
570
+
571
+ def revoke_project_member(self, project_id: str, account_id: str) -> dict[str, Any]:
572
+ """Revoke project membership without deleting owned work."""
573
+ path = (
574
+ "/v1/projects/"
575
+ f"{quote_path(project_id, safe='')}"
576
+ "/members/"
577
+ f"{quote_path(account_id, safe='')}"
578
+ )
579
+ return self._request("DELETE", path, None)
580
+
581
+ def list_customer_webhooks(self) -> dict[str, Any]:
582
+ """List owned callback destinations without secrets."""
583
+ path = "/v1/account/webhooks"
584
+ return self._request("GET", path, None)
585
+
586
+ def create_customer_webhook(self, request: dict[str, Any]) -> dict[str, Any]:
587
+ """Register find.completed webhook."""
588
+ path = "/v1/account/webhooks"
589
+ return self._request("POST", path, request)
590
+
591
+ def revoke_customer_webhook(self, hook_id: str) -> dict[str, Any]:
592
+ """Revoke an owned callback and cancel queued deliveries."""
593
+ path = f"/v1/account/webhooks/{quote_path(hook_id, safe='')}"
594
+ return self._request("DELETE", path, None)
595
+
596
+ def get_capacity_catalog(self) -> dict[str, Any]:
597
+ """Read the current dollar balance and membership offer."""
598
+ path = "/v1/capacity/catalog"
599
+ return self._request("GET", path, None)
600
+
601
+ def get_capacity_balance(self, *, project_id: str | None = None) -> dict[str, Any]:
602
+ """Read the available balance without approving new work."""
603
+ path = "/v1/capacity/balance"
604
+ query: dict[str, Any] = {
605
+ "project_id": project_id,
606
+ }
607
+ encoded = urlencode(
608
+ {
609
+ key: str(value).lower() if isinstance(value, bool) else value
610
+ for key, value in query.items()
611
+ if value is not None
612
+ }
613
+ )
614
+ if encoded:
615
+ path += "?" + encoded
616
+ return self._request("GET", path, None)
617
+
618
+ def get_capacity_entitlements(self, *, project_id: str | None = None) -> dict[str, Any]:
619
+ """Read what the account's plan includes and how many members it uses."""
620
+ path = "/v1/capacity/entitlements"
621
+ query: dict[str, Any] = {
622
+ "project_id": project_id,
623
+ }
624
+ encoded = urlencode(
625
+ {
626
+ key: str(value).lower() if isinstance(value, bool) else value
627
+ for key, value in query.items()
628
+ if value is not None
629
+ }
630
+ )
631
+ if encoded:
632
+ path += "?" + encoded
633
+ return self._request("GET", path, None)
634
+
635
+ def get_capacity_referral(self) -> dict[str, Any]:
636
+ """Read the verified account's stable referral link and rewards."""
637
+ path = "/v1/capacity/referral"
638
+ return self._request("GET", path, None)
639
+
640
+ def capture_capacity_referral(self, request: dict[str, Any]) -> dict[str, Any]:
641
+ """Remember the first referral visit for sign-up within thirty days."""
642
+ path = "/v1/capacity/referral/visit"
643
+ return self._request("POST", path, request)
644
+
645
+ def get_capacity_history(self) -> dict[str, Any]:
646
+ """Read account balance events including the reason for each bonus grant."""
647
+ path = "/v1/capacity/history"
648
+ return self._request("GET", path, None)
649
+
650
+ def claim_free_capacity(self) -> dict[str, Any]:
651
+ """Claim the one-time free balance for a verified eligible account."""
652
+ path = "/v1/capacity/free"
653
+ return self._request("POST", path, None)
654
+
655
+ def set_capacity_budget(self, request: dict[str, Any]) -> dict[str, Any]:
656
+ """Set an owner-approved monthly account spending maximum."""
657
+ path = "/v1/capacity/budget"
658
+ return self._request("PATCH", path, request)
659
+
660
+ def quote_capacity_work(self, request: dict[str, Any]) -> dict[str, Any]:
661
+ """Get a quote for a request before anything runs."""
662
+ path = "/v1/capacity/quotes"
663
+ return self._request("POST", path, request)
664
+
665
+ def get_capacity_quote(self, quote_id: str) -> dict[str, Any]:
666
+ """Recover your signed quote and any find already approved from it."""
667
+ path = f"/v1/capacity/quotes/{quote_path(quote_id, safe='')}"
668
+ return self._request("GET", path, None)
669
+
670
+ def authorize_capacity_work(self, request: dict[str, Any]) -> dict[str, Any]:
671
+ """
672
+ Approve the exact quote to start one find and reserve its maximum from your balance.
673
+ Needs the specs:write, finds:write and finds:spend scopes.
674
+ """
675
+ path = "/v1/capacity/executions"
676
+ require_work_authorization(request)
677
+ return self._request("POST", path, request)
678
+
679
+ def get_capacity_request(
680
+ self,
681
+ request_key: str,
682
+ *,
683
+ project_id: str | None = None,
684
+ ) -> dict[str, Any]:
685
+ """Recover the find already started with this request key."""
686
+ path = f"/v1/capacity/requests/{quote_path(request_key, safe='')}"
687
+ query: dict[str, Any] = {
688
+ "project_id": project_id,
689
+ }
690
+ encoded = urlencode(
691
+ {
692
+ key: str(value).lower() if isinstance(value, bool) else value
693
+ for key, value in query.items()
694
+ if value is not None
695
+ }
696
+ )
697
+ if encoded:
698
+ path += "?" + encoded
699
+ return self._request("GET", path, None)
700
+
701
+ def get_capacity_execution(self, find_id: str) -> dict[str, Any]:
702
+ """Read a find's approved work, final charge and your access to its results."""
703
+ path = f"/v1/capacity/executions/{quote_path(find_id, safe='')}"
704
+ return self._request("GET", path, None)
705
+
706
+ def create_capacity_funding(self, request: dict[str, Any]) -> dict[str, Any]:
707
+ """Prepare owner-authorized balance or membership checkout without starting work."""
708
+ path = "/v1/capacity/funding"
709
+ return self._request("POST", path, request)
710
+
711
+ def get_capacity_funding(self, funding_id: str) -> dict[str, Any]:
712
+ """Recover the original funding request from verified provider facts."""
713
+ path = f"/v1/capacity/funding/{quote_path(funding_id, safe='')}"
714
+ return self._request("GET", path, None)
715
+
716
+ def cancel_capacity_funding(self, funding_id: str) -> dict[str, Any]:
717
+ """Close an unpaid funding request without duplicating payment."""
718
+ path = f"/v1/capacity/funding/{quote_path(funding_id, safe='')}"
719
+ return self._request("DELETE", path, None)
720
+
721
+ def get_capacity_membership(self) -> dict[str, Any]:
722
+ """Read the current paid period and next-renewal choice."""
723
+ path = "/v1/capacity/membership"
724
+ return self._request("GET", path, None)
725
+
726
+ def change_capacity_membership(self, request: dict[str, Any]) -> dict[str, Any]:
727
+ """Change, cancel, or resume the membership for its next renewal."""
728
+ path = "/v1/capacity/membership"
729
+ return self._request("POST", path, request)
730
+
731
+ def create_capacity_authority(self, request: dict[str, Any]) -> dict[str, Any]:
732
+ """Approve a spending limit for one agent or member in a project."""
733
+ path = "/v1/capacity/authorities"
734
+ return self._request("POST", path, request)
735
+
736
+ def revoke_capacity_authority(self, authority_id: str) -> dict[str, Any]:
737
+ """Revoke a delegated spending limit without removing saved results."""
738
+ path = f"/v1/capacity/authorities/{quote_path(authority_id, safe='')}"
739
+ return self._request("DELETE", path, None)
740
+
741
+ def create_capacity_schedule(self, request: dict[str, Any]) -> dict[str, Any]:
742
+ """Approve future runs of an exact request within set spending limits."""
743
+ path = "/v1/capacity/schedules"
744
+ return self._request("POST", path, request)
745
+
746
+ def list_capacity_schedules(
747
+ self,
748
+ *,
749
+ project_id: str | None = None,
750
+ after: str | None = None,
751
+ limit: int | None = None,
752
+ ) -> dict[str, Any]:
753
+ """Read authorized recurring work and its current state."""
754
+ path = "/v1/capacity/schedules"
755
+ query: dict[str, Any] = {
756
+ "project_id": project_id,
757
+ "after": after,
758
+ "limit": limit,
759
+ }
760
+ encoded = urlencode(
761
+ {
762
+ key: str(value).lower() if isinstance(value, bool) else value
763
+ for key, value in query.items()
764
+ if value is not None
765
+ }
766
+ )
767
+ if encoded:
768
+ path += "?" + encoded
769
+ return self._request("GET", path, None)
770
+
771
+ def update_capacity_schedule(self, schedule_id: str, request: dict[str, Any]) -> dict[str, Any]:
772
+ """Pause, resume, or delete recurring work without buying missed occurrences."""
773
+ path = f"/v1/capacity/schedules/{quote_path(schedule_id, safe='')}"
774
+ return self._request("PATCH", path, request)
775
+
776
+ def list_capacity_occurrences(
777
+ self,
778
+ schedule_id: str,
779
+ *,
780
+ before: str | None = None,
781
+ limit: int | None = None,
782
+ ) -> dict[str, Any]:
783
+ """Read completed, blocked, and missed occurrences."""
784
+ path = f"/v1/capacity/schedules/{quote_path(schedule_id, safe='')}/occurrences"
785
+ query: dict[str, Any] = {
786
+ "before": before,
787
+ "limit": limit,
788
+ }
789
+ encoded = urlencode(
790
+ {
791
+ key: str(value).lower() if isinstance(value, bool) else value
792
+ for key, value in query.items()
793
+ if value is not None
794
+ }
795
+ )
796
+ if encoded:
797
+ path += "?" + encoded
798
+ return self._request("GET", path, None)
799
+
800
+ def run_capacity_schedule_now(self, schedule_id: str) -> dict[str, Any]:
801
+ """
802
+ Run active recurring work once now, inside its own limits, without moving its schedule.
803
+ """
804
+ path = f"/v1/capacity/schedules/{quote_path(schedule_id, safe='')}/run"
805
+ return self._request("POST", path, None)
806
+
807
+ def create_capacity_refund_claim(self, request: dict[str, Any]) -> dict[str, Any]:
808
+ """Report a wrong result for review against its sources."""
809
+ path = "/v1/capacity/refund-claims"
810
+ return self._request("POST", path, request)
811
+
812
+ def list_capacity_refund_claims(self) -> dict[str, Any]:
813
+ """Read the account's correction claims, newest first."""
814
+ path = "/v1/capacity/refund-claims"
815
+ return self._request("GET", path, None)
816
+
817
+ def get_capacity_refund_claim(self, claim_id: str) -> dict[str, Any]:
818
+ """Read the status of an owned correction claim."""
819
+ path = f"/v1/capacity/refund-claims/{quote_path(claim_id, safe='')}"
820
+ return self._request("GET", path, None)
821
+
822
+ def request_capacity_custom_plan(self, request: dict[str, Any]) -> dict[str, Any]:
823
+ """Request an Enterprise conversation without granting an entitlement."""
824
+ path = "/v1/capacity/enterprise"
825
+ return self._request("POST", path, request)
826
+
827
+ def get_capacity_store_config(self) -> dict[str, Any]:
828
+ """Read the app store products and this account's store purchases."""
829
+ path = "/v1/capacity/stores/config"
830
+ return self._request("GET", path, None)
831
+
832
+ def verify_capacity_store_purchase(self, request: dict[str, Any]) -> dict[str, Any]:
833
+ """Confirm an app store purchase before adding its balance."""
834
+ path = "/v1/capacity/stores/purchases"
835
+ return self._request("POST", path, request)
836
+
837
+ def restore_capacity_store_purchases(self, request: dict[str, Any]) -> dict[str, Any]:
838
+ """Recover verified purchases without duplicate balance grants."""
839
+ path = "/v1/capacity/stores/restore"
840
+ return self._request("POST", path, request)
841
+
842
+ def transfer_capacity_store_to_card(self, request: dict[str, Any]) -> dict[str, Any]:
843
+ """Authorize a recoverable Google Play to card billing transfer."""
844
+ path = "/v1/capacity/stores/card-transfer"
845
+ return self._request("POST", path, request)
846
+
847
+ def create_spec_link(self, spec_id: str, request: dict[str, Any]) -> dict[str, Any]:
848
+ """Share a saved request revision as a link, without its results or permission to spend."""
849
+ path = f"/v1/specs/{quote_path(spec_id, safe='')}/links"
850
+ return self._request("POST", path, request)
851
+
852
+ def get_shared_spec(self, token: str) -> dict[str, Any]:
853
+ """Read a public request snapshot while its issuer, access and link remain valid."""
854
+ path = f"/v1/shared-specs/{quote_path(token, safe='')}"
855
+ return self._request("GET", path, None)
856
+
857
+ def revoke_spec_link(self, spec_id: str, link_id: str) -> dict[str, Any]:
858
+ """Turn off a saved-request link without changing the request or its results."""
859
+ path = (
860
+ "/v1/specs/"
861
+ f"{quote_path(spec_id, safe='')}"
862
+ "/links/"
863
+ f"{quote_path(link_id, safe='')}"
864
+ "/revoke"
865
+ )
866
+ return self._request("POST", path, None)
867
+
868
+ def workspace_set_schedule_delivery(
869
+ self,
870
+ schedule_id: str,
871
+ request: dict[str, Any],
872
+ ) -> dict[str, Any]:
873
+ """Set versioned monitor delivery settings without starting work."""
874
+ path = f"/v1/workspace/schedules/{quote_path(schedule_id, safe='')}/delivery"
875
+ return self._request("PATCH", path, request)
876
+
877
+ def workspace_list_collections(
878
+ self,
879
+ *,
880
+ project_id: str | None = None,
881
+ after: str | None = None,
882
+ limit: int | None = None,
883
+ sort: str | None = None,
884
+ ) -> dict[str, Any]:
885
+ """Read the lists you can open, from your finds and imports."""
886
+ path = "/v1/workspace/collections"
887
+ query: dict[str, Any] = {
888
+ "project_id": project_id,
889
+ "after": after,
890
+ "limit": limit,
891
+ "sort": sort,
892
+ }
893
+ encoded = urlencode(
894
+ {
895
+ key: str(value).lower() if isinstance(value, bool) else value
896
+ for key, value in query.items()
897
+ if value is not None
898
+ }
899
+ )
900
+ if encoded:
901
+ path += "?" + encoded
902
+ return self._request("GET", path, None)
903
+
904
+ def workspace_read_collection(self, collection_id: str) -> dict[str, Any]:
905
+ """Read one list, without rows removed by an opt-out."""
906
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}"
907
+ return self._request("GET", path, None)
908
+
909
+ def workspace_combine_collections(self, request: dict[str, Any]) -> dict[str, Any]:
910
+ """Combine your lists into one and keep each row's sources."""
911
+ path = "/v1/workspace/collections/combine"
912
+ return self._request("POST", path, request)
913
+
914
+ def workspace_copy_collection(
915
+ self,
916
+ collection_id: str,
917
+ request: dict[str, Any],
918
+ ) -> dict[str, Any]:
919
+ """Copy rows from a list you can open into a separate list you own."""
920
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}/copy"
921
+ return self._request("POST", path, request)
922
+
923
+ def workspace_filter_collection(
924
+ self,
925
+ collection_id: str,
926
+ request: dict[str, Any],
927
+ ) -> dict[str, Any]:
928
+ """Filter a list's rows by IDs, sources or a map area."""
929
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}/filter"
930
+ return self._request("POST", path, request)
931
+
932
+ def workspace_list_views(self, collection_id: str) -> dict[str, Any]:
933
+ """List your saved views of a list."""
934
+ path = f"/v1/workspace/views/{quote_path(collection_id, safe='')}"
935
+ return self._request("GET", path, None)
936
+
937
+ def workspace_save_view(self, collection_id: str, request: dict[str, Any]) -> dict[str, Any]:
938
+ """Save a versioned personal view without changing source records."""
939
+ path = f"/v1/workspace/views/{quote_path(collection_id, safe='')}"
940
+ return self._request("PUT", path, request)
941
+
942
+ def workspace_list_stars(self) -> dict[str, Any]:
943
+ """List starred work that remains currently authorized."""
944
+ path = "/v1/workspace/stars"
945
+ return self._request("GET", path, None)
946
+
947
+ def workspace_star_item(self, request: dict[str, Any]) -> dict[str, Any]:
948
+ """Star or unstar currently authorized work with an expected version."""
949
+ path = "/v1/workspace/stars"
950
+ return self._request("PUT", path, request)
951
+
952
+ def workspace_list_notes(
953
+ self,
954
+ *,
955
+ project_id: str | None = None,
956
+ collection_id: str | None = None,
957
+ record_id: str | None = None,
958
+ ) -> dict[str, Any]:
959
+ """Read authorized project or collection-record notes."""
960
+ path = "/v1/workspace/notes"
961
+ query: dict[str, Any] = {
962
+ "project_id": project_id,
963
+ "collection_id": collection_id,
964
+ "record_id": record_id,
965
+ }
966
+ encoded = urlencode(
967
+ {
968
+ key: str(value).lower() if isinstance(value, bool) else value
969
+ for key, value in query.items()
970
+ if value is not None
971
+ }
972
+ )
973
+ if encoded:
974
+ path += "?" + encoded
975
+ return self._request("GET", path, None)
976
+
977
+ def workspace_add_note(self, request: dict[str, Any]) -> dict[str, Any]:
978
+ """Add a versioned note to an authorized project or collection record."""
979
+ path = "/v1/workspace/notes"
980
+ return self._request("POST", path, request)
981
+
982
+ def workspace_resolve_note(self, note_id: str, request: dict[str, Any]) -> dict[str, Any]:
983
+ """Resolve or reopen an authorized note with an expected version."""
984
+ path = f"/v1/workspace/notes/{quote_path(note_id, safe='')}"
985
+ return self._request("PATCH", path, request)
986
+
987
+ def workspace_edit_note(self, note_id: str, request: dict[str, Any]) -> dict[str, Any]:
988
+ """Edit a note you wrote with an expected version."""
989
+ path = f"/v1/workspace/notes/{quote_path(note_id, safe='')}"
990
+ return self._request("PUT", path, request)
991
+
992
+ def workspace_delete_note(self, note_id: str, request: dict[str, Any]) -> dict[str, Any]:
993
+ """Remove an authorized note with an expected version."""
994
+ path = f"/v1/workspace/notes/{quote_path(note_id, safe='')}"
995
+ return self._request("DELETE", path, request)
996
+
997
+ def workspace_register_web_push_subscription(self, request: dict[str, Any]) -> dict[str, Any]:
998
+ """Register an encrypted browser push subscription for this signed-in account."""
999
+ path = "/v1/workspace/push/subscriptions"
1000
+ return self._request("POST", path, request)
1001
+
1002
+ def workspace_push_capabilities(self) -> dict[str, Any]:
1003
+ """Read configured notification transports, never credentials."""
1004
+ path = "/v1/workspace/push/capabilities"
1005
+ return self._request("GET", path, None)
1006
+
1007
+ def workspace_list_push_devices(self) -> dict[str, Any]:
1008
+ """List your registered notification devices."""
1009
+ path = "/v1/workspace/push/devices"
1010
+ return self._request("GET", path, None)
1011
+
1012
+ def workspace_register_push_device(self, request: dict[str, Any]) -> dict[str, Any]:
1013
+ """Register the current signed-in device for native notifications."""
1014
+ path = "/v1/workspace/push/devices"
1015
+ return self._request("POST", path, request)
1016
+
1017
+ def workspace_revoke_push_device(self, device_id: str) -> dict[str, Any]:
1018
+ """Stop notifications to a registered device."""
1019
+ path = f"/v1/workspace/push/devices/{quote_path(device_id, safe='')}"
1020
+ return self._request("DELETE", path, None)
1021
+
1022
+ def workspace_list_your_fields(self, collection_id: str) -> dict[str, Any]:
1023
+ """Read the fields you added to a collection and their values."""
1024
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}/fields"
1025
+ return self._request("GET", path, None)
1026
+
1027
+ def workspace_save_your_fields(
1028
+ self,
1029
+ collection_id: str,
1030
+ request: dict[str, Any],
1031
+ ) -> dict[str, Any]:
1032
+ """Add, rename or remove your own fields on a collection."""
1033
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}/fields"
1034
+ return self._request("PUT", path, request)
1035
+
1036
+ def workspace_set_your_field_value(
1037
+ self,
1038
+ collection_id: str,
1039
+ request: dict[str, Any],
1040
+ ) -> dict[str, Any]:
1041
+ """Set or clear one of your fields on selected records."""
1042
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}/fields/values"
1043
+ return self._request("PUT", path, request)
1044
+
1045
+ def begin_connection_authorization(self, provider: str) -> dict[str, Any]:
1046
+ """Begin account-bound provider authorization."""
1047
+ path = f"/v1/connections/{quote_path(provider, safe='')}/authorize"
1048
+ return self._request("POST", path, None)
1049
+
1050
+ def disconnect_connection(self, connection_id: str) -> dict[str, Any]:
1051
+ """Revoke a connected provider."""
1052
+ path = f"/v1/connections/{quote_path(connection_id, safe='')}"
1053
+ return self._request("DELETE", path, None)
1054
+
1055
+ def list_cloud_imports(self) -> dict[str, Any]:
1056
+ """List daily source checks and import status."""
1057
+ path = "/v1/imports/cloud"
1058
+ return self._request("GET", path, None)
1059
+
1060
+ def list_connected_tools(self) -> dict[str, Any]:
1061
+ """List supported tables, CRM, automation and chat tools."""
1062
+ path = "/v1/connections/catalog"
1063
+ return self._request("GET", path, None)
1064
+
1065
+ def list_connections(self) -> dict[str, Any]:
1066
+ """List this account's connected providers."""
1067
+ path = "/v1/connections"
1068
+ return self._request("GET", path, None)
1069
+
1070
+ def list_connection_destinations(
1071
+ self,
1072
+ connection_id: str,
1073
+ *,
1074
+ base_id: str | None = None,
1075
+ cursor: str | None = None,
1076
+ ) -> dict[str, Any]:
1077
+ """List provider-authorized destinations."""
1078
+ path = f"/v1/connections/{quote_path(connection_id, safe='')}/destinations"
1079
+ query: dict[str, Any] = {
1080
+ "base_id": base_id,
1081
+ "cursor": cursor,
1082
+ }
1083
+ encoded = urlencode(
1084
+ {
1085
+ key: str(value).lower() if isinstance(value, bool) else value
1086
+ for key, value in query.items()
1087
+ if value is not None
1088
+ }
1089
+ )
1090
+ if encoded:
1091
+ path += "?" + encoded
1092
+ return self._request("GET", path, None)
1093
+
1094
+ def get_connection_columns(self, connection_id: str, request: dict[str, Any]) -> dict[str, Any]:
1095
+ """Read destination columns before mapping fields."""
1096
+ path = f"/v1/connections/{quote_path(connection_id, safe='')}/columns"
1097
+ return self._request("POST", path, request)
1098
+
1099
+ def save_connection_destination(
1100
+ self,
1101
+ connection_id: str,
1102
+ request: dict[str, Any],
1103
+ ) -> dict[str, Any]:
1104
+ """Save a provider-verified destination and field mapping."""
1105
+ path = f"/v1/connections/{quote_path(connection_id, safe='')}/destination"
1106
+ return self._request("POST", path, request)
1107
+
1108
+ def send_to_destination(self, connection_id: str, request: dict[str, Any]) -> dict[str, Any]:
1109
+ """Send selected owned results with durable delivery identities."""
1110
+ path = f"/v1/connections/{quote_path(connection_id, safe='')}/send"
1111
+ return self._request("POST", path, request)
1112
+
1113
+ def reconcile_destination_export(self, connection_id: str, export_id: str) -> dict[str, Any]:
1114
+ """Check an uncertain delivery without sending it again."""
1115
+ path = (
1116
+ "/v1/connections/"
1117
+ f"{quote_path(connection_id, safe='')}"
1118
+ "/exports/"
1119
+ f"{quote_path(export_id, safe='')}"
1120
+ "/reconcile"
1121
+ )
1122
+ return self._request("POST", path, None)
1123
+
1124
+ def list_destination_exports(self) -> dict[str, Any]:
1125
+ """List delivery receipts for connected tools."""
1126
+ path = "/v1/connections/exports"
1127
+ return self._request("GET", path, None)
1128
+
1129
+ def create_destination_sync(self, request: dict[str, Any]) -> dict[str, Any]:
1130
+ """Keep sending new and changed records to this destination."""
1131
+ path = "/v1/destination-syncs"
1132
+ return self._request("POST", path, request)
1133
+
1134
+ def update_destination_sync(self, sync_id: str, request: dict[str, Any]) -> dict[str, Any]:
1135
+ """Start or stop continuous destination delivery."""
1136
+ path = f"/v1/destination-syncs/{quote_path(sync_id, safe='')}"
1137
+ return self._request("PATCH", path, request)
1138
+
1139
+ def list_destination_syncs(self) -> dict[str, Any]:
1140
+ """List continuous destination deliveries."""
1141
+ path = "/v1/destination-syncs"
1142
+ return self._request("GET", path, None)
1143
+
1144
+ def preview_cloud_list(self, request: dict[str, Any]) -> dict[str, Any]:
1145
+ """Read a provider-authorized list before importing."""
1146
+ path = "/v1/imports/cloud/preview"
1147
+ return self._request("POST", path, request)
1148
+
1149
+ def import_cloud_list(self, request: dict[str, Any]) -> dict[str, Any]:
1150
+ """Import supplied records from a connected table or workbook."""
1151
+ path = "/v1/imports/cloud"
1152
+ return self._request("POST", path, request)
1153
+
1154
+ def update_cloud_import(self, import_id: str, request: dict[str, Any]) -> dict[str, Any]:
1155
+ """Start or stop daily source checks."""
1156
+ path = f"/v1/imports/cloud/{quote_path(import_id, safe='')}"
1157
+ return self._request("PATCH", path, request)
1158
+
1159
+ def workspace_invite_member(self, request: dict[str, Any]) -> dict[str, Any]:
1160
+ """Invite a project member under current project management authority."""
1161
+ path = "/v1/workspace/invitations"
1162
+ return self._request("POST", path, request)
1163
+
1164
+ def import_file_command(self, request: dict[str, Any]) -> dict[str, Any]:
1165
+ """Import supplied CSV or XLSX values without starting research."""
1166
+ path = "/v1/imports/file"
1167
+ return self._request("POST", path, request)
1168
+
1169
+ def workspace_activity(
1170
+ self,
1171
+ *,
1172
+ project_id: str | None = None,
1173
+ cursor: str | None = None,
1174
+ limit: int | None = None,
1175
+ ) -> dict[str, Any]:
1176
+ """Read activity through current account and project permissions."""
1177
+ path = "/v1/activity"
1178
+ query: dict[str, Any] = {
1179
+ "project_id": project_id,
1180
+ "cursor": cursor,
1181
+ "limit": limit,
1182
+ }
1183
+ encoded = urlencode(
1184
+ {
1185
+ key: str(value).lower() if isinstance(value, bool) else value
1186
+ for key, value in query.items()
1187
+ if value is not None
1188
+ }
1189
+ )
1190
+ if encoded:
1191
+ path += "?" + encoded
1192
+ return self._request("GET", path, None)
1193
+
1194
+ def workspace_relationships(
1195
+ self,
1196
+ collection_id: str,
1197
+ *,
1198
+ field_id: str | None = None,
1199
+ ) -> dict[str, Any]:
1200
+ """Group accessible records by actual shared column values."""
1201
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}/relationships"
1202
+ query: dict[str, Any] = {
1203
+ "field_id": field_id,
1204
+ }
1205
+ encoded = urlencode(
1206
+ {
1207
+ key: str(value).lower() if isinstance(value, bool) else value
1208
+ for key, value in query.items()
1209
+ if value is not None
1210
+ }
1211
+ )
1212
+ if encoded:
1213
+ path += "?" + encoded
1214
+ return self._request("GET", path, None)
1215
+
1216
+ def workspace_save_relationships(
1217
+ self,
1218
+ collection_id: str,
1219
+ request: dict[str, Any],
1220
+ ) -> dict[str, Any]:
1221
+ """Save a personal relationship column with an expected version."""
1222
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}/relationships"
1223
+ return self._request("PUT", path, request)
1224
+
1225
+ def workspace_report_result(
1226
+ self,
1227
+ collection_id: str,
1228
+ request: dict[str, Any],
1229
+ ) -> dict[str, Any]:
1230
+ """Report a billed result for review within its guarantee period."""
1231
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}/reports"
1232
+ return self._request("POST", path, request)
1233
+
1234
+ def list_request_templates(self, *, record_type: str | None = None) -> dict[str, Any]:
1235
+ """List request examples; compile and quote before work."""
1236
+ path = "/v1/request-templates"
1237
+ query: dict[str, Any] = {
1238
+ "record_type": record_type,
1239
+ }
1240
+ encoded = urlencode(
1241
+ {
1242
+ key: str(value).lower() if isinstance(value, bool) else value
1243
+ for key, value in query.items()
1244
+ if value is not None
1245
+ }
1246
+ )
1247
+ if encoded:
1248
+ path += "?" + encoded
1249
+ return self._request("GET", path, None)
1250
+
1251
+ def workspace_collection_changes(self, collection_id: str) -> dict[str, Any]:
1252
+ """Read source-backed changes against immutable prior results."""
1253
+ path = f"/v1/workspace/collections/{quote_path(collection_id, safe='')}/changes"
1254
+ return self._request("GET", path, None)
1255
+
1256
+ def workspace_list_passkeys(self) -> dict[str, Any]:
1257
+ """List the current account's registered passkeys."""
1258
+ path = "/v1/workspace/security/passkeys"
1259
+ return self._request("GET", path, None)
1260
+
1261
+ def workspace_register_passkey_options(self) -> dict[str, Any]:
1262
+ """Start a browser-bound passkey registration ceremony."""
1263
+ path = "/v1/workspace/security/passkeys/options"
1264
+ return self._request("POST", path, None)
1265
+
1266
+ def workspace_register_passkey(self, request: dict[str, Any]) -> dict[str, Any]:
1267
+ """Verify and register a passkey for the current account."""
1268
+ path = "/v1/workspace/security/passkeys"
1269
+ return self._request("POST", path, request)
1270
+
1271
+ def workspace_name_passkey(self, credential_id: str, request: dict[str, Any]) -> dict[str, Any]:
1272
+ """Name an account-owned passkey."""
1273
+ path = f"/v1/workspace/security/passkeys/{quote_path(credential_id, safe='')}"
1274
+ return self._request("PATCH", path, request)
1275
+
1276
+ def workspace_delete_passkey(self, credential_id: str) -> dict[str, Any]:
1277
+ """Revoke an account-owned passkey."""
1278
+ path = f"/v1/workspace/security/passkeys/{quote_path(credential_id, safe='')}"
1279
+ return self._request("DELETE", path, None)
1280
+
1281
+ def workspace_passkey_sign_in_options(self) -> dict[str, Any]:
1282
+ """Start a browser-bound passkey sign-in ceremony."""
1283
+ path = "/v1/workspace/auth/passkey/options"
1284
+ return self._request("POST", path, None)
1285
+
1286
+ def workspace_passkey_sign_in(self, request: dict[str, Any]) -> dict[str, Any]:
1287
+ """Verify a passkey assertion and establish a session."""
1288
+ path = "/v1/workspace/auth/passkey/verify"
1289
+ return self._request("POST", path, request)
1290
+
1291
+ def workspace_list_s_s_o_domains(self) -> dict[str, Any]:
1292
+ """List account-owned verified SSO domains."""
1293
+ path = "/v1/workspace/security/sso"
1294
+ return self._request("GET", path, None)
1295
+
1296
+ def workspace_add_s_s_o_domain(self, request: dict[str, Any]) -> dict[str, Any]:
1297
+ """Create an account-owned domain verification challenge."""
1298
+ path = "/v1/workspace/security/sso"
1299
+ return self._request("POST", path, request)
1300
+
1301
+ def workspace_verify_s_s_o_domain(self, domain_id: str) -> dict[str, Any]:
1302
+ """Verify the account's domain through its DNS TXT record."""
1303
+ path = f"/v1/workspace/security/sso/{quote_path(domain_id, safe='')}/verify"
1304
+ return self._request("POST", path, None)
1305
+
1306
+ def workspace_configure_s_s_o(self, domain_id: str, request: dict[str, Any]) -> dict[str, Any]:
1307
+ """Configure verified SAML metadata for an owned domain."""
1308
+ path = f"/v1/workspace/security/sso/{quote_path(domain_id, safe='')}/provider"
1309
+ return self._request("PUT", path, request)
1310
+
1311
+ def workspace_set_s_s_o_policy(self, domain_id: str, request: dict[str, Any]) -> dict[str, Any]:
1312
+ """Set SSO enforcement after a successful provider test."""
1313
+ path = f"/v1/workspace/security/sso/{quote_path(domain_id, safe='')}"
1314
+ return self._request("PATCH", path, request)
1315
+
1316
+ def workspace_start_s_s_o(self, request: dict[str, Any]) -> dict[str, Any]:
1317
+ """Start a single-use browser-bound SAML sign-in."""
1318
+ path = "/v1/workspace/auth/sso/start"
1319
+ return self._request("POST", path, request)
1320
+
1321
+ def workspace_rotate_s_c_i_m_token(self, domain_id: str) -> dict[str, Any]:
1322
+ """Rotate a verified domain's provisioning credential."""
1323
+ path = f"/v1/workspace/security/sso/{quote_path(domain_id, safe='')}/scim/token"
1324
+ return self._request("POST", path, None)
1325
+
1326
+ def workspace_revoke_s_c_i_m_token(self, domain_id: str) -> dict[str, Any]:
1327
+ """Revoke a domain's provisioning credential."""
1328
+ path = f"/v1/workspace/security/sso/{quote_path(domain_id, safe='')}/scim/token"
1329
+ return self._request("DELETE", path, None)
1330
+
1331
+ def workspace_read_s_c_i_m_mappings(self, domain_id: str) -> dict[str, Any]:
1332
+ """Read provisioning mappings, provider groups and token state."""
1333
+ path = f"/v1/workspace/security/sso/{quote_path(domain_id, safe='')}/scim/mappings"
1334
+ return self._request("GET", path, None)
1335
+
1336
+ def workspace_set_s_c_i_m_mappings(
1337
+ self,
1338
+ domain_id: str,
1339
+ request: dict[str, Any],
1340
+ ) -> dict[str, Any]:
1341
+ """Set domain-to-project mappings under current owner authority."""
1342
+ path = f"/v1/workspace/security/sso/{quote_path(domain_id, safe='')}/scim/mappings"
1343
+ return self._request("PUT", path, request)
1344
+
1345
+ def workspace_read_photo(self) -> dict[str, Any]:
1346
+ """Read the account-owned photo version and private URL."""
1347
+ path = "/v1/workspace/profile/photo"
1348
+ return self._request("GET", path, None)
1349
+
1350
+ def workspace_delete_photo(self) -> dict[str, Any]:
1351
+ """Remove the current account's private profile photo."""
1352
+ path = "/v1/workspace/profile/photo"
1353
+ return self._request("DELETE", path, None)
1354
+
1355
+ def workspace_start_native_provider(self, request: dict[str, Any]) -> dict[str, Any]:
1356
+ """Begin native provider authorization bound to an app-held PKCE secret."""
1357
+ path = "/v1/workspace/auth/native/start"
1358
+ return self._request("POST", path, request)
1359
+
1360
+ def workspace_redeem_native_provider(self, request: dict[str, Any]) -> dict[str, Any]:
1361
+ """Redeem verified provider proof for an app session or connection."""
1362
+ path = "/v1/workspace/auth/native/redeem"
1363
+ return self._request("POST", path, request)
1364
+
1365
+ def workspace_placement_allowance(self) -> dict[str, Any]:
1366
+ """Read this billing account's UTC monthly free placement allowance."""
1367
+ path = "/v1/workspace/placement/allowance"
1368
+ return self._request("GET", path, None)
1369
+
1370
+ def workspace_quote_placement(self, import_id: str) -> dict[str, Any]:
1371
+ """Quote the maximum cost of placing imported addresses on the map."""
1372
+ path = f"/v1/workspace/imports/{quote_path(import_id, safe='')}/placement/quote"
1373
+ return self._request("POST", path, None)
1374
+
1375
+ def workspace_start_placement(self, import_id: str, request: dict[str, Any]) -> dict[str, Any]:
1376
+ """Approve the placement quote and enqueue durable background placement."""
1377
+ path = f"/v1/workspace/imports/{quote_path(import_id, safe='')}/placement"
1378
+ return self._request("POST", path, request)
1379
+
1380
+ def workspace_placement_status(self, import_id: str, placement_id: str) -> dict[str, Any]:
1381
+ """Read placement progress and per-row geocoding outcomes."""
1382
+ path = (
1383
+ "/v1/workspace/imports/"
1384
+ f"{quote_path(import_id, safe='')}"
1385
+ "/placement/"
1386
+ f"{quote_path(placement_id, safe='')}"
1387
+ )
1388
+ return self._request("GET", path, None)
1389
+ # END GENERATED LIFECYCLE
1390
+
1391
+ def families(self) -> tuple[str, ...]:
1392
+ return FAMILIES
1393
+
1394
+ def _headers(self) -> dict[str, str]:
1395
+ token = self.api_key
1396
+ auth = token if token.lower().startswith("bearer ") else f"Bearer {token}"
1397
+ return {
1398
+ "Authorization": auth,
1399
+ "Accept": "application/json",
1400
+ "Content-Type": "application/json",
1401
+ "User-Agent": USER_AGENT,
1402
+ }
1403
+
1404
+ def _request(self, method: str, path: str, body: dict[str, Any] | None) -> dict[str, Any]:
1405
+ url = urljoin(self.base_url, path.lstrip("/"))
1406
+ payload = None if body is None else json.dumps(body, separators=(",", ":")).encode("utf-8")
1407
+ req = urllib.request.Request(url, data=payload, method=method, headers=self._headers())
1408
+ try:
1409
+ with self._opener(req, self.timeout) as resp:
1410
+ status = getattr(resp, "status", 200)
1411
+ text = resp.read().decode("utf-8", "replace")
1412
+ except urllib.error.HTTPError as exc:
1413
+ text = exc.read().decode("utf-8", "replace") if exc.fp else ""
1414
+ try:
1415
+ parsed: Any = json.loads(text)
1416
+ except ValueError:
1417
+ parsed = text
1418
+ raise SpecstreamError(
1419
+ f"{method} {path} failed with HTTP {exc.code}", status=exc.code, body=parsed
1420
+ ) from exc
1421
+ except (urllib.error.URLError, OSError) as exc:
1422
+ raise SpecstreamError(f"{method} {path} transport failed") from exc
1423
+ try:
1424
+ loaded = json.loads(text)
1425
+ except ValueError as exc:
1426
+ raise SpecstreamError(
1427
+ f"{method} {path} returned invalid JSON", status=status, body=text
1428
+ ) from exc
1429
+ if not 200 <= status < 300:
1430
+ raise SpecstreamError(
1431
+ f"{method} {path} failed with HTTP {status}", status=status, body=loaded
1432
+ )
1433
+ if not isinstance(loaded, dict):
1434
+ raise SpecstreamError(
1435
+ f"{method} {path} returned non-object JSON", status=status, body=loaded
1436
+ )
1437
+ if loaded.get("error") is not None or not isinstance(loaded.get("data"), dict):
1438
+ raise SpecstreamError(
1439
+ f"{method} {path} returned an invalid success envelope",
1440
+ status=status,
1441
+ body=loaded,
1442
+ )
1443
+ return cast(dict[str, Any], loaded["data"])
1444
+
1445
+
1446
+ __all__ = [
1447
+ "Client",
1448
+ "DEFAULT_BASE_URL",
1449
+ "FAMILIES",
1450
+ "SpecstreamError",
1451
+ ]