lorebase-client 0.2.3__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,1587 @@
1
+ """Synchronous client: `LorebaseClient`.
2
+
3
+ with LorebaseClient("http://localhost:8080", api_key="lbk_...") as client:
4
+ client.domains.create("supply", display_name="Supply chain")
5
+ for record in client.records.iterate("supply", "Supplier", branch="work"):
6
+ print(record.key, record.title)
7
+
8
+ Resources group the endpoints (`domains`, `branches`, `tags`, `schema`, `operations`, `records`,
9
+ `revisions`, `api_keys`, `grants`, `principals`, `audit`, `proposals`, `agents`, `webhooks`,
10
+ `sources`, `documents`).
11
+ Every list endpoint has a `list` method (one page) and an `iterate` method (all items; pages
12
+ are fetched on demand). Errors
13
+ raise subclasses of `LorebaseError`. The request layer is shared with `AsyncLorebaseClient`
14
+ (`lorebase_client.core`).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import copy
20
+ import os
21
+ import time
22
+ from collections.abc import Callable, Iterator, Mapping, Sequence
23
+ from dataclasses import replace
24
+ from types import TracebackType
25
+ from typing import Any, Self
26
+
27
+ import httpx
28
+ from pydantic import JsonValue
29
+
30
+ from lorebase_client import endpoints as ep
31
+ from lorebase_client import models as m
32
+ from lorebase_client.config import (
33
+ ClientConfig,
34
+ RetryPolicy,
35
+ TokenSource,
36
+ TraceMode,
37
+ check_session_id,
38
+ resolve_config,
39
+ )
40
+ from lorebase_client.core import (
41
+ Call,
42
+ Done,
43
+ Exchange,
44
+ RequestCore,
45
+ Retry,
46
+ new_idempotency_key,
47
+ )
48
+
49
+ JsonObject = Mapping[str, Any]
50
+
51
+
52
+ class LorebaseClient:
53
+ """Blocking client over `httpx.Client`.
54
+
55
+ `base_url`, `api_key`, `token` and `timeout` fall back to `LOREBASE_URL`,
56
+ `LOREBASE_API_KEY`, `LOREBASE_TOKEN` and `LOREBASE_TIMEOUT`. `cache=True` keeps an ETag
57
+ cache for GET reads (an integer sets its size). `auto_idempotency_keys=True` gives every
58
+ create and Operation execution a key, which also makes them safe to retry after a lost
59
+ answer. `transport` replaces the network (tests, in-process servers); `sleep` replaces the
60
+ wait between retries. `trust_env=False` makes `httpx` ignore proxy variables
61
+ (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`) and other environment settings. `event_hooks` maps
62
+ `request` / `response` to callables `httpx` runs on every HTTP request sent (each retry
63
+ included), e.g. to set a header per request.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ base_url: str | None = None,
69
+ *,
70
+ api_key: str | None = None,
71
+ token: TokenSource | None = None,
72
+ timeout: float | None = None,
73
+ api_prefix: str | None = None,
74
+ retry: RetryPolicy | None = None,
75
+ auto_idempotency_keys: bool = False,
76
+ cache: bool | int = False,
77
+ headers: Mapping[str, str] | None = None,
78
+ session_id: str | None = None,
79
+ tool: str | None = None,
80
+ trace: TraceMode | None = None,
81
+ transport: httpx.BaseTransport | None = None,
82
+ trust_env: bool = True,
83
+ event_hooks: Mapping[str, Sequence[Callable[..., Any]]] | None = None,
84
+ env: Mapping[str, str] | None = None,
85
+ sleep: Callable[[float], None] = time.sleep,
86
+ ) -> None:
87
+ self.config: ClientConfig = resolve_config(
88
+ base_url,
89
+ api_key=api_key,
90
+ token=token,
91
+ timeout=timeout,
92
+ api_prefix=api_prefix,
93
+ retry=retry,
94
+ auto_idempotency_keys=auto_idempotency_keys,
95
+ cache=cache,
96
+ headers=headers,
97
+ session_id=session_id,
98
+ tool=tool,
99
+ trace=trace,
100
+ env=env,
101
+ )
102
+ self._core = RequestCore(self.config)
103
+ self._sleep = sleep
104
+ self._http = httpx.Client(
105
+ timeout=self.config.timeout_s,
106
+ transport=transport,
107
+ trust_env=trust_env,
108
+ event_hooks={k: list(v) for k, v in (event_hooks or {}).items()},
109
+ )
110
+ self._mount()
111
+
112
+ def _mount(self) -> None:
113
+ self.domains = Domains(self)
114
+ self.branches = Branches(self)
115
+ self.tags = Tags(self)
116
+ self.schema = SchemaApi(self)
117
+ self.operations = Operations(self)
118
+ self.records = Records(self)
119
+ self.revisions = Revisions(self)
120
+ self.api_keys = ApiKeys(self)
121
+ self.vault = Vault(self)
122
+ self.grants = Grants(self)
123
+ self.principals = Principals(self)
124
+ self.audit = Audit(self)
125
+ self.trace = Trace(self)
126
+ self.proposals = Proposals(self)
127
+ self.agents = Agents(self)
128
+ self.webhooks = Webhooks(self)
129
+ self.sources = Sources(self)
130
+ self.documents = Documents(self)
131
+
132
+ def with_session(
133
+ self, session_id: str, *, tool: str | None = None, trace: TraceMode | None = None
134
+ ) -> Self:
135
+ """A client for another agent trace session over the same connection and cache.
136
+
137
+ It sends `session_id` (and `tool`, `trace` when given; otherwise this client's) with
138
+ every request. Closing either client closes the shared connection.
139
+ """
140
+ derived = copy.copy(self)
141
+ derived.config = replace(
142
+ self.config,
143
+ session_id=check_session_id(session_id),
144
+ tool=tool or self.config.tool,
145
+ trace=trace or self.config.trace,
146
+ )
147
+ derived._core = RequestCore(derived.config)
148
+ derived._core.cache = self._core.cache
149
+ derived._mount()
150
+ return derived
151
+
152
+ def close(self) -> None:
153
+ self._http.close()
154
+
155
+ def __enter__(self) -> Self:
156
+ return self
157
+
158
+ def __exit__(
159
+ self,
160
+ exc_type: type[BaseException] | None,
161
+ exc: BaseException | None,
162
+ tb: TracebackType | None,
163
+ ) -> None:
164
+ self.close()
165
+
166
+ # Request layer ---------------------------------------------------------------------
167
+
168
+ def send[T](self, call: Call[T]) -> T:
169
+ """Run a call, retrying as the retry policy says; returns the parsed answer."""
170
+ exchange = Exchange(self._core, call)
171
+ step: Done[T] | Retry
172
+ while True:
173
+ request = self._http.build_request(**exchange.request_args())
174
+ try:
175
+ response = self._http.send(request)
176
+ except httpx.TransportError as error:
177
+ step = exchange.on_error(error)
178
+ else:
179
+ step = exchange.on_response(response)
180
+ if isinstance(step, Done):
181
+ return step.value
182
+ self._sleep(step.delay)
183
+
184
+ def pages[P: m.Paged[Any]](self, call: Call[P]) -> Iterator[P]:
185
+ """Every page of a list call, following `next_cursor`."""
186
+ cursor = call.params.get("cursor")
187
+ while True:
188
+ page = self.send(call.with_params(cursor=cursor))
189
+ yield page
190
+ cursor = page.next_cursor
191
+ if cursor is None:
192
+ return
193
+
194
+ def iterate[T](self, call: Call[m.Paged[T]]) -> Iterator[T]:
195
+ """Every item of a list call."""
196
+ for page in self.pages(call):
197
+ yield from page.items
198
+
199
+ def whoami(self) -> m.WhoAmI:
200
+ """The authenticated caller."""
201
+ return self.send(ep.whoami())
202
+
203
+ def idempotency_key(self, prefix: str | None = None) -> str:
204
+ """A fresh key to pass as `idempotency_key=` to a write."""
205
+ return new_idempotency_key(prefix)
206
+
207
+ def cache_clear(self) -> None:
208
+ """Forget cached answers (no effect when the cache is off)."""
209
+ if self._core.cache is not None:
210
+ self._core.cache.clear()
211
+
212
+
213
+ class _Resource:
214
+ def __init__(self, client: LorebaseClient) -> None:
215
+ self._c = client
216
+
217
+
218
+ class Domains(_Resource):
219
+ def create(
220
+ self,
221
+ id: str,
222
+ *,
223
+ display_name: str = "",
224
+ description: str = "",
225
+ default_branch: str | None = None,
226
+ settings: JsonObject | None = None,
227
+ idempotency_key: str | None = None,
228
+ ) -> m.Domain:
229
+ body = m.DomainCreate(
230
+ id=id,
231
+ display_name=display_name,
232
+ description=description,
233
+ default_branch=default_branch,
234
+ settings=dict(settings or {}),
235
+ )
236
+ return self._c.send(ep.create_domain(body, idempotency_key=idempotency_key))
237
+
238
+ def list(self, *, limit: int | None = None, cursor: str | None = None) -> m.Page[m.Domain]:
239
+ return self._c.send(ep.list_domains(limit=limit, cursor=cursor))
240
+
241
+ def iterate(self, *, page_size: int | None = None) -> Iterator[m.Domain]:
242
+ return self._c.iterate(ep.list_domains(limit=page_size))
243
+
244
+ def get(self, id: str) -> m.Domain:
245
+ return self._c.send(ep.get_domain(id))
246
+
247
+ def update(
248
+ self, id: str, *, display_name: str | None = None, description: str | None = None
249
+ ) -> m.Domain:
250
+ body = m.DomainUpdate(display_name=display_name, description=description)
251
+ return self._c.send(ep.update_domain(id, body))
252
+
253
+ def delete(self, id: str, *, confirm: str) -> None:
254
+ """Delete the Domain with everything in it; `confirm` repeats its id."""
255
+ self._c.send(ep.delete_domain(id, confirm=confirm))
256
+
257
+
258
+ class Branches(_Resource):
259
+ def list(
260
+ self, domain: str, *, limit: int | None = None, cursor: str | None = None
261
+ ) -> m.Page[m.Branch]:
262
+ return self._c.send(ep.list_branches(domain, limit=limit, cursor=cursor))
263
+
264
+ def iterate(self, domain: str, *, page_size: int | None = None) -> Iterator[m.Branch]:
265
+ return self._c.iterate(ep.list_branches(domain, limit=page_size))
266
+
267
+ def create(
268
+ self,
269
+ domain: str,
270
+ name: str,
271
+ *,
272
+ source: str | None = None,
273
+ revision: str | None = None,
274
+ mode: m.BranchMode | None = None,
275
+ idempotency_key: str | None = None,
276
+ ) -> m.Branch:
277
+ body = m.BranchCreate(name=name, source=source, revision=revision, mode=mode)
278
+ return self._c.send(ep.create_branch(domain, body, idempotency_key=idempotency_key))
279
+
280
+ def get(self, domain: str, name: str) -> m.BranchDetail:
281
+ return self._c.send(ep.get_branch(domain, name))
282
+
283
+ def set_protected(self, domain: str, name: str, protected: bool) -> m.Branch:
284
+ return self._c.send(ep.protect_branch(domain, name, protected=protected))
285
+
286
+ def delete(self, domain: str, name: str) -> None:
287
+ self._c.send(ep.delete_branch(domain, name))
288
+
289
+ def rebase_tags(
290
+ self,
291
+ domain: str,
292
+ name: str,
293
+ *,
294
+ message: str | None = None,
295
+ expected_head: str | None = None,
296
+ dry_run: bool = False,
297
+ rows_only: bool = False,
298
+ ) -> m.RebaseTagsResult:
299
+ """Commit the default branch's sensitivity tags to the branch's schema (or, with
300
+ `rows_only`, queue the rewrite of its stale titles and search vectors)."""
301
+ body = m.RebaseTagsRequest(
302
+ message=message, expected_head=expected_head, dry_run=dry_run, rows_only=rows_only
303
+ )
304
+ return self._c.send(ep.rebase_branch_tags(domain, name, body))
305
+
306
+
307
+ class Tags(_Resource):
308
+ def list(
309
+ self, domain: str, *, limit: int | None = None, cursor: str | None = None
310
+ ) -> m.Page[m.Tag]:
311
+ return self._c.send(ep.list_tags(domain, limit=limit, cursor=cursor))
312
+
313
+ def iterate(self, domain: str, *, page_size: int | None = None) -> Iterator[m.Tag]:
314
+ return self._c.iterate(ep.list_tags(domain, limit=page_size))
315
+
316
+ def create(
317
+ self,
318
+ domain: str,
319
+ name: str,
320
+ *,
321
+ source: str | None = None,
322
+ revision: str | None = None,
323
+ idempotency_key: str | None = None,
324
+ ) -> m.Tag:
325
+ body = m.TagCreate(name=name, source=source, revision=revision)
326
+ return self._c.send(ep.create_tag(domain, body, idempotency_key=idempotency_key))
327
+
328
+
329
+ class SchemaApi(_Resource):
330
+ def get(
331
+ self, domain: str, *, branch: str | None = None, at: str | None = None
332
+ ) -> m.SchemaDocument:
333
+ return self._c.send(ep.get_schema(domain, branch=branch, at=at))
334
+
335
+ def get_yaml(self, domain: str, *, branch: str | None = None, at: str | None = None) -> str:
336
+ return self._c.send(ep.get_schema_yaml(domain, branch=branch, at=at))
337
+
338
+ def element(
339
+ self,
340
+ domain: str,
341
+ section: m.SchemaSection,
342
+ name: str,
343
+ *,
344
+ branch: str | None = None,
345
+ at: str | None = None,
346
+ ) -> dict[str, Any]:
347
+ return self._c.send(ep.get_schema_element(domain, section, name, branch=branch, at=at))
348
+
349
+ def validate(
350
+ self,
351
+ domain: str,
352
+ *,
353
+ yaml: str | None = None,
354
+ patch: JsonObject | None = None,
355
+ remove: Sequence[m.ElementRemoval] | None = None,
356
+ branch: str | None = None,
357
+ at: str | None = None,
358
+ ) -> m.SchemaValidation:
359
+ body = m.SchemaValidateRequest(
360
+ yaml=yaml,
361
+ patch=None if patch is None else dict(patch),
362
+ remove=list(remove or []),
363
+ branch=branch,
364
+ at=at,
365
+ )
366
+ return self._c.send(ep.validate_schema(domain, body))
367
+
368
+ def apply(
369
+ self,
370
+ domain: str,
371
+ branch: str,
372
+ *,
373
+ yaml: str | None = None,
374
+ patch: JsonObject | None = None,
375
+ remove: Sequence[m.ElementRemoval] | None = None,
376
+ message: str | None = None,
377
+ expected_head: str | None = None,
378
+ dry_run: bool = False,
379
+ cascade: bool = False,
380
+ ) -> m.SchemaChangeResult:
381
+ body = m.SchemaChangeRequest(
382
+ yaml=yaml,
383
+ patch=None if patch is None else dict(patch),
384
+ remove=list(remove or []),
385
+ message=message,
386
+ expected_head=expected_head,
387
+ dry_run=dry_run,
388
+ cascade=cascade,
389
+ )
390
+ return self._c.send(ep.apply_schema(domain, branch, body))
391
+
392
+ def infer_postgres(
393
+ self,
394
+ domain: str,
395
+ dsn: str,
396
+ *,
397
+ schema: str | None = None,
398
+ tables: Sequence[str] | None = None,
399
+ options: JsonObject | None = None,
400
+ ) -> dict[str, Any]:
401
+ """Propose a schema from Postgres tables (`dsn` may be a `secret://` reference)."""
402
+ return self._c.send(
403
+ ep.infer_schema_postgres(domain, _infer_body(dsn, schema, tables, options))
404
+ )
405
+
406
+ def infer_files(
407
+ self,
408
+ domain: str,
409
+ files: Mapping[str, bytes],
410
+ *,
411
+ options: JsonObject | None = None,
412
+ records_path: str | None = None,
413
+ ) -> dict[str, Any]:
414
+ """Propose a schema from data files (file name to content); `records_path` (dotted keys)
415
+ locates the records array of JSON documents that hold it under a key."""
416
+ return self._c.send(
417
+ ep.infer_schema_files(domain, files, options=options, records_path=records_path)
418
+ )
419
+
420
+ def preview_files(
421
+ self,
422
+ domain: str,
423
+ files: Mapping[str, bytes],
424
+ *,
425
+ rows: int | None = None,
426
+ records_path: str | None = None,
427
+ ) -> dict[str, Any]:
428
+ """The first rows of each data file as Keeper reads them; nothing is stored."""
429
+ return self._c.send(ep.preview_files(domain, files, rows=rows, records_path=records_path))
430
+
431
+ def preview_derivation(self, domain: str, body: JsonObject) -> dict[str, Any]:
432
+ """Evaluate a candidate Derivation on sample Records without saving it."""
433
+ return self._c.send(ep.preview_derivation(domain, body))
434
+
435
+ def preview_source(self, domain: str, body: JsonObject) -> dict[str, Any]:
436
+ """Evaluate the expression mappings of a candidate Source on sample rows."""
437
+ return self._c.send(ep.preview_source(domain, body))
438
+
439
+ def suggestions(
440
+ self,
441
+ domain: str,
442
+ *,
443
+ status: Sequence[m.ItemDecisionValue] | None = None,
444
+ type: Sequence[m.SuggestionType] | None = None,
445
+ all: bool | None = None,
446
+ limit: int | None = None,
447
+ cursor: str | None = None,
448
+ ) -> m.Page[m.SchemaSuggestion]:
449
+ """One page of schema suggestions from extraction, the most supported first."""
450
+ return self._c.send(
451
+ ep.list_schema_suggestions(
452
+ domain, status=status, type=type, all=all, limit=limit, cursor=cursor
453
+ )
454
+ )
455
+
456
+ def iterate_suggestions(
457
+ self,
458
+ domain: str,
459
+ *,
460
+ status: Sequence[m.ItemDecisionValue] | None = None,
461
+ type: Sequence[m.SuggestionType] | None = None,
462
+ all: bool | None = None,
463
+ page_size: int | None = None,
464
+ ) -> Iterator[m.SchemaSuggestion]:
465
+ call = ep.list_schema_suggestions(
466
+ domain, status=status, type=type, all=all, limit=page_size
467
+ )
468
+ return self._c.iterate(call)
469
+
470
+ def decide_suggestions(
471
+ self, domain: str, decisions: Sequence[m.SuggestionDecision | JsonObject]
472
+ ) -> m.SuggestionsDecided:
473
+ """Reject, reset or accept suggestions; the accepted ones become one schema change
474
+ set on a new branch, proposed into the default branch (`proposal`)."""
475
+ body = m.DecideSuggestions(
476
+ decisions=[
477
+ d if isinstance(d, m.SuggestionDecision) else m.SuggestionDecision(**d)
478
+ for d in decisions
479
+ ]
480
+ )
481
+ return self._c.send(ep.decide_schema_suggestions(domain, body))
482
+
483
+ def merge_suggestions(self, domain: str) -> m.SuggestionsMerged:
484
+ """Fold the stored Attribute suggestions that are variants of one another."""
485
+ return self._c.send(ep.merge_schema_suggestions(domain))
486
+
487
+
488
+ class Operations(_Resource):
489
+ def execute(
490
+ self,
491
+ domain: str,
492
+ operation: str,
493
+ parameters: JsonObject | None = None,
494
+ *,
495
+ branch: str | None = None,
496
+ dry_run: bool = False,
497
+ expected_head: str | None = None,
498
+ message: str | None = None,
499
+ idempotency_key: str | None = None,
500
+ ) -> m.Execution:
501
+ body = m.ExecuteRequest(
502
+ parameters=dict(parameters or {}),
503
+ branch=branch,
504
+ dry_run=dry_run,
505
+ expected_head=expected_head,
506
+ message=message,
507
+ )
508
+ return self._c.send(
509
+ ep.execute_operation(domain, operation, body, idempotency_key=idempotency_key)
510
+ )
511
+
512
+ def execute_as_agent(
513
+ self,
514
+ domain: str,
515
+ operation: str,
516
+ parameters: JsonObject | None = None,
517
+ *,
518
+ task: str | None = None,
519
+ intent: str | None = None,
520
+ auto_propose: bool | None = None,
521
+ auto_merge: bool | None = None,
522
+ branch: str | None = None,
523
+ dry_run: bool = False,
524
+ expected_head: str | None = None,
525
+ message: str | None = None,
526
+ idempotency_key: str | None = None,
527
+ ) -> m.Execution | m.AgentWrite:
528
+ """Execute an Operation that may need review. When it does (protected branch, review
529
+ policy) it runs on the branch `agent/{principal}/{task}` and the answer is an
530
+ `AgentWrite` naming the Proposal; otherwise it is the usual `Execution`. Without a `task`
531
+ Keeper refuses `auto_propose` and answers 409 `review-required` when review is needed."""
532
+ body = m.ExecuteRequest(
533
+ parameters=dict(parameters or {}),
534
+ branch=branch,
535
+ dry_run=dry_run,
536
+ expected_head=expected_head,
537
+ message=message,
538
+ task=task,
539
+ intent=intent,
540
+ auto_propose=auto_propose,
541
+ auto_merge=auto_merge,
542
+ )
543
+ return self._c.send(
544
+ ep.execute_operation_as_agent(domain, operation, body, idempotency_key=idempotency_key)
545
+ )
546
+
547
+ def import_records(
548
+ self,
549
+ domain: str,
550
+ kind: str,
551
+ rows: Sequence[JsonObject],
552
+ *,
553
+ branch: str | None = None,
554
+ dry_run: bool = False,
555
+ expected_head: str | None = None,
556
+ message: str | None = None,
557
+ idempotency_key: str | None = None,
558
+ ) -> m.Execution:
559
+ """Upsert Records of `kind`; rows are objects keyed by Attribute api name."""
560
+ return self.execute(
561
+ domain,
562
+ "import_records",
563
+ {"kind": kind, "rows": list(rows)},
564
+ branch=branch,
565
+ dry_run=dry_run,
566
+ expected_head=expected_head,
567
+ message=message,
568
+ idempotency_key=idempotency_key,
569
+ )
570
+
571
+ def import_links(
572
+ self,
573
+ domain: str,
574
+ relation: str,
575
+ rows: Sequence[JsonObject],
576
+ *,
577
+ branch: str | None = None,
578
+ dry_run: bool = False,
579
+ expected_head: str | None = None,
580
+ message: str | None = None,
581
+ idempotency_key: str | None = None,
582
+ ) -> m.Execution:
583
+ """Upsert Links of a forward Relation; rows have `from`, `to` and Link Attributes."""
584
+ return self.execute(
585
+ domain,
586
+ "import_links",
587
+ {"relation": relation, "rows": list(rows)},
588
+ branch=branch,
589
+ dry_run=dry_run,
590
+ expected_head=expected_head,
591
+ message=message,
592
+ idempotency_key=idempotency_key,
593
+ )
594
+
595
+ def import_batch(
596
+ self,
597
+ domain: str,
598
+ records: Sequence[JsonObject] = (),
599
+ links: Sequence[JsonObject] = (),
600
+ *,
601
+ branch: str | None = None,
602
+ dry_run: bool = False,
603
+ expected_head: str | None = None,
604
+ message: str | None = None,
605
+ idempotency_key: str | None = None,
606
+ ) -> m.Execution:
607
+ """Upsert Records (rows with `kind`) and Links (rows with `relation`, `from`, `to`) in
608
+ one call; a Link goes in the same Revision as the Record at one of its ends."""
609
+ return self.execute(
610
+ domain,
611
+ "import_batch",
612
+ {"records": list(records), "links": list(links)},
613
+ branch=branch,
614
+ dry_run=dry_run,
615
+ expected_head=expected_head,
616
+ message=message,
617
+ idempotency_key=idempotency_key,
618
+ )
619
+
620
+ def delete_records(
621
+ self,
622
+ domain: str,
623
+ kind: str,
624
+ keys: Sequence[str],
625
+ *,
626
+ skip_missing: bool = False,
627
+ branch: str | None = None,
628
+ dry_run: bool = False,
629
+ expected_head: str | None = None,
630
+ message: str | None = None,
631
+ idempotency_key: str | None = None,
632
+ ) -> m.Execution:
633
+ """Delete Records of `kind` by key in one Revision; Links follow each Relation's
634
+ `on_delete`. Unknown keys fail the call unless `skip_missing` (then warnings)."""
635
+ parameters: dict[str, Any] = {"kind": kind, "keys": list(keys)}
636
+ if skip_missing:
637
+ parameters["skip_missing"] = True
638
+ return self.execute(
639
+ domain,
640
+ "delete_records",
641
+ parameters,
642
+ branch=branch,
643
+ dry_run=dry_run,
644
+ expected_head=expected_head,
645
+ message=message,
646
+ idempotency_key=idempotency_key,
647
+ )
648
+
649
+
650
+ class Records(_Resource):
651
+ def list(
652
+ self,
653
+ domain: str,
654
+ kind: str,
655
+ *,
656
+ branch: str | None = None,
657
+ at: str | None = None,
658
+ where: JsonObject | None = None,
659
+ order_by: Sequence[str] | None = None,
660
+ select: Sequence[str] | None = None,
661
+ limit: int | None = None,
662
+ cursor: str | None = None,
663
+ ) -> m.RecordPage:
664
+ """One page of Records; `order_by` terms are `name` or `name:desc`."""
665
+ return self._c.send(
666
+ ep.list_records(
667
+ domain,
668
+ kind,
669
+ branch=branch,
670
+ at=at,
671
+ where=where,
672
+ order_by=order_by,
673
+ select=select,
674
+ limit=limit,
675
+ cursor=cursor,
676
+ )
677
+ )
678
+
679
+ def iterate(
680
+ self,
681
+ domain: str,
682
+ kind: str,
683
+ *,
684
+ branch: str | None = None,
685
+ at: str | None = None,
686
+ where: JsonObject | None = None,
687
+ order_by: Sequence[str] | None = None,
688
+ select: Sequence[str] | None = None,
689
+ page_size: int | None = None,
690
+ ) -> Iterator[m.RecordItem]:
691
+ call = ep.list_records(
692
+ domain,
693
+ kind,
694
+ branch=branch,
695
+ at=at,
696
+ where=where,
697
+ order_by=order_by,
698
+ select=select,
699
+ limit=page_size,
700
+ )
701
+ return self._c.iterate(call)
702
+
703
+ def get(
704
+ self,
705
+ domain: str,
706
+ kind: str,
707
+ key: str,
708
+ *,
709
+ branch: str | None = None,
710
+ at: str | None = None,
711
+ links: bool = False,
712
+ lineage: bool = False,
713
+ ) -> m.RecordResponse:
714
+ return self._c.send(
715
+ ep.get_record(domain, kind, key, branch=branch, at=at, links=links, lineage=lineage)
716
+ )
717
+
718
+ def history(
719
+ self,
720
+ domain: str,
721
+ kind: str,
722
+ key: str,
723
+ *,
724
+ branch: str | None = None,
725
+ at: str | None = None,
726
+ values: bool = False,
727
+ limit: int | None = None,
728
+ cursor: str | None = None,
729
+ ) -> m.RecordHistory:
730
+ """One page of the changes of a Record, newest first."""
731
+ return self._c.send(
732
+ ep.record_history(
733
+ domain, kind, key, branch=branch, at=at, values=values, limit=limit, cursor=cursor
734
+ )
735
+ )
736
+
737
+ def iterate_history(
738
+ self,
739
+ domain: str,
740
+ kind: str,
741
+ key: str,
742
+ *,
743
+ branch: str | None = None,
744
+ at: str | None = None,
745
+ values: bool = False,
746
+ page_size: int | None = None,
747
+ ) -> Iterator[m.RecordChange]:
748
+ call = ep.record_history(
749
+ domain, kind, key, branch=branch, at=at, values=values, limit=page_size
750
+ )
751
+ return self._c.iterate(call)
752
+
753
+ def lineage(
754
+ self,
755
+ domain: str,
756
+ kind: str,
757
+ key: str,
758
+ *,
759
+ branch: str | None = None,
760
+ at: str | None = None,
761
+ ) -> m.RecordLineage:
762
+ """Where the values of a Record came from, at the branch head or at `at`."""
763
+ return self._c.send(ep.record_lineage(domain, kind, key, branch=branch, at=at))
764
+
765
+ def links(
766
+ self,
767
+ domain: str,
768
+ kind: str,
769
+ key: str,
770
+ relation: str,
771
+ *,
772
+ branch: str | None = None,
773
+ at: str | None = None,
774
+ limit: int | None = None,
775
+ cursor: str | None = None,
776
+ ) -> m.LinkPage:
777
+ """Links of one Record along a Relation (forward or inverse api name)."""
778
+ return self._c.send(
779
+ ep.list_record_links(
780
+ domain, kind, key, relation, branch=branch, at=at, limit=limit, cursor=cursor
781
+ )
782
+ )
783
+
784
+ def relation_links(
785
+ self,
786
+ domain: str,
787
+ relation: str,
788
+ *,
789
+ branch: str | None = None,
790
+ at: str | None = None,
791
+ from_key: str | None = None,
792
+ to_key: str | None = None,
793
+ limit: int | None = None,
794
+ cursor: str | None = None,
795
+ ) -> m.LinkPage:
796
+ return self._c.send(
797
+ ep.list_relation_links(
798
+ domain,
799
+ relation,
800
+ branch=branch,
801
+ at=at,
802
+ from_key=from_key,
803
+ to_key=to_key,
804
+ limit=limit,
805
+ cursor=cursor,
806
+ )
807
+ )
808
+
809
+ def iterate_relation_links(
810
+ self,
811
+ domain: str,
812
+ relation: str,
813
+ *,
814
+ branch: str | None = None,
815
+ at: str | None = None,
816
+ from_key: str | None = None,
817
+ to_key: str | None = None,
818
+ page_size: int | None = None,
819
+ ) -> Iterator[m.LinkItem]:
820
+ call = ep.list_relation_links(
821
+ domain,
822
+ relation,
823
+ branch=branch,
824
+ at=at,
825
+ from_key=from_key,
826
+ to_key=to_key,
827
+ limit=page_size,
828
+ )
829
+ return self._c.iterate(call)
830
+
831
+ def query(self, domain: str, query: m.QueryRequest | JsonObject) -> m.QueryResult:
832
+ """Run a DSL query (a `QueryRequest` or the JSON object of the DSL)."""
833
+ return self._c.send(ep.query(domain, _query_request(query)))
834
+
835
+ def iterate_query(
836
+ self, domain: str, query: m.QueryRequest | JsonObject
837
+ ) -> Iterator[m.RecordItem]:
838
+ """All Records a query matches, page after page."""
839
+ request = _query_request(query)
840
+ while True:
841
+ page = self._c.send(ep.query(domain, request))
842
+ yield from page.items
843
+ if page.next_cursor is None:
844
+ return
845
+ request = request.model_copy(update={"cursor": page.next_cursor})
846
+
847
+ def traverse(self, domain: str, request: m.TraverseRequest | JsonObject) -> m.TraverseResult:
848
+ body = request if isinstance(request, m.TraverseRequest) else m.TraverseRequest(**request)
849
+ return self._c.send(ep.traverse(domain, body))
850
+
851
+ def neighborhood(
852
+ self, domain: str, request: m.NeighborhoodRequest | JsonObject
853
+ ) -> m.NeighborhoodResult:
854
+ body = (
855
+ request
856
+ if isinstance(request, m.NeighborhoodRequest)
857
+ else m.NeighborhoodRequest(**request)
858
+ )
859
+ return self._c.send(ep.neighborhood(domain, body))
860
+
861
+ def search(self, domain: str, request: m.SearchRequest | JsonObject) -> m.SearchResult:
862
+ body = request if isinstance(request, m.SearchRequest) else m.SearchRequest(**request)
863
+ return self._c.send(ep.search(domain, body))
864
+
865
+
866
+ def _query_request(query: m.QueryRequest | JsonObject) -> m.QueryRequest:
867
+ return query if isinstance(query, m.QueryRequest) else m.QueryRequest(**query)
868
+
869
+
870
+ def _context_request(request: m.ContextRequest | JsonObject) -> m.ContextRequest:
871
+ return request if isinstance(request, m.ContextRequest) else m.ContextRequest(**request)
872
+
873
+
874
+ def _infer_body(
875
+ dsn: str, schema: str | None, tables: Sequence[str] | None, options: JsonObject | None
876
+ ) -> dict[str, Any]:
877
+ postgres: dict[str, Any] = {"dsn": dsn}
878
+ if schema is not None:
879
+ postgres["schema"] = schema
880
+ if tables is not None:
881
+ postgres["tables"] = list(tables)
882
+ body: dict[str, Any] = {"postgres": postgres}
883
+ if options:
884
+ body["options"] = dict(options)
885
+ return body
886
+
887
+
888
+ class Revisions(_Resource):
889
+ def log(
890
+ self,
891
+ domain: str,
892
+ branch: str,
893
+ *,
894
+ all_parents: bool = False,
895
+ origin_type: str | None = None,
896
+ author: str | None = None,
897
+ limit: int | None = None,
898
+ cursor: str | None = None,
899
+ ) -> m.LogPage:
900
+ return self._c.send(
901
+ ep.branch_log(
902
+ domain,
903
+ branch,
904
+ all_parents=all_parents,
905
+ origin_type=origin_type,
906
+ author=author,
907
+ limit=limit,
908
+ cursor=cursor,
909
+ )
910
+ )
911
+
912
+ def iterate_log(
913
+ self,
914
+ domain: str,
915
+ branch: str,
916
+ *,
917
+ all_parents: bool = False,
918
+ origin_type: str | None = None,
919
+ author: str | None = None,
920
+ page_size: int | None = None,
921
+ ) -> Iterator[m.Revision]:
922
+ call = ep.branch_log(
923
+ domain,
924
+ branch,
925
+ all_parents=all_parents,
926
+ origin_type=origin_type,
927
+ author=author,
928
+ limit=page_size,
929
+ )
930
+ return self._c.iterate(call)
931
+
932
+ def get(self, domain: str, revision: str) -> m.Revision:
933
+ """A Revision by id, tag or branch name."""
934
+ return self._c.send(ep.get_revision(domain, revision))
935
+
936
+ def counts(self, domain: str, revision: str) -> m.RevisionCounts:
937
+ return self._c.send(ep.revision_counts(domain, revision))
938
+
939
+ def changeset(
940
+ self,
941
+ domain: str,
942
+ revision: str,
943
+ *,
944
+ limit: int | None = None,
945
+ cursor: str | None = None,
946
+ ) -> m.ChangesPage:
947
+ return self._c.send(ep.revision_changeset(domain, revision, limit=limit, cursor=cursor))
948
+
949
+ def iterate_changeset(
950
+ self, domain: str, revision: str, *, page_size: int | None = None
951
+ ) -> Iterator[m.Change]:
952
+ return self._c.iterate(ep.revision_changeset(domain, revision, limit=page_size))
953
+
954
+ def diff(
955
+ self,
956
+ domain: str,
957
+ from_: str,
958
+ to: str,
959
+ *,
960
+ kinds: Sequence[str] | None = None,
961
+ relations: Sequence[str] | None = None,
962
+ schema: bool | None = None,
963
+ limit: int | None = None,
964
+ cursor: str | None = None,
965
+ ) -> m.ChangesPage:
966
+ return self._c.send(
967
+ ep.diff(
968
+ domain,
969
+ from_,
970
+ to,
971
+ kinds=kinds,
972
+ relations=relations,
973
+ schema=schema,
974
+ limit=limit,
975
+ cursor=cursor,
976
+ )
977
+ )
978
+
979
+ def iterate_diff(
980
+ self,
981
+ domain: str,
982
+ from_: str,
983
+ to: str,
984
+ *,
985
+ kinds: Sequence[str] | None = None,
986
+ relations: Sequence[str] | None = None,
987
+ schema: bool | None = None,
988
+ page_size: int | None = None,
989
+ ) -> Iterator[m.Change]:
990
+ call = ep.diff(
991
+ domain, from_, to, kinds=kinds, relations=relations, schema=schema, limit=page_size
992
+ )
993
+ return self._c.iterate(call)
994
+
995
+
996
+ class Vault(_Resource):
997
+ """Erasure of personal data held in the vault (`pii.erase`) and key rotation
998
+ (`vault.manage`, installation administrators)."""
999
+
1000
+ def keys(self) -> m.VaultKeys:
1001
+ """Rows by encryption key and fingerprint key, and the earlier keys no row uses."""
1002
+ return self._c.send(ep.vault_keys())
1003
+
1004
+ def rotate(self) -> m.VaultRotation:
1005
+ """Queue the job that makes rows stored under earlier keys again."""
1006
+ return self._c.send(ep.rotate_vault())
1007
+
1008
+ def erase_record(
1009
+ self,
1010
+ domain: str,
1011
+ kind: str,
1012
+ key: str,
1013
+ *,
1014
+ attributes: Sequence[str] | None = None,
1015
+ branch: str | None = None,
1016
+ ) -> m.EraseResult:
1017
+ """Erase every value the Record ever held in its vault Attributes (or `attributes`)."""
1018
+ body = m.EraseRequest(
1019
+ kind=kind,
1020
+ key=key,
1021
+ attributes=list(attributes) if attributes else None,
1022
+ branch=branch,
1023
+ )
1024
+ return self._c.send(ep.erase_vault(domain, body))
1025
+
1026
+ def erase_value(
1027
+ self,
1028
+ domain: str,
1029
+ kind: str,
1030
+ attribute: str,
1031
+ value: JsonValue,
1032
+ *,
1033
+ branch: str | None = None,
1034
+ ) -> m.EraseResult:
1035
+ """Erase `value` of a vault Attribute in every Record of the Kind."""
1036
+ body = m.EraseRequest(kind=kind, attribute=attribute, value=value, branch=branch)
1037
+ return self._c.send(ep.erase_vault(domain, body))
1038
+
1039
+
1040
+ class ApiKeys(_Resource):
1041
+ def create(
1042
+ self,
1043
+ domain: str,
1044
+ name: str,
1045
+ *,
1046
+ role: str | None = None,
1047
+ ttl_days: int | None = None,
1048
+ principal_id: str | None = None,
1049
+ allowed_networks: Sequence[str] | None = None,
1050
+ ) -> m.ApiKeyCreated:
1051
+ """Create a key; the secret is in `key` of the answer and is not shown again.
1052
+
1053
+ `allowed_networks` (CIDR) limits the client addresses the key works from.
1054
+ """
1055
+ body = m.CreateApiKey(
1056
+ name=name,
1057
+ role=role,
1058
+ ttl_days=ttl_days,
1059
+ principal_id=principal_id,
1060
+ allowed_networks=list(allowed_networks) if allowed_networks else None,
1061
+ )
1062
+ return self._c.send(ep.create_api_key(domain, body))
1063
+
1064
+ def list(
1065
+ self, domain: str, *, limit: int | None = None, cursor: str | None = None
1066
+ ) -> m.Page[m.ApiKey]:
1067
+ return self._c.send(ep.list_api_keys(domain, limit=limit, cursor=cursor))
1068
+
1069
+ def iterate(self, domain: str, *, page_size: int | None = None) -> Iterator[m.ApiKey]:
1070
+ return self._c.iterate(ep.list_api_keys(domain, limit=page_size))
1071
+
1072
+ def revoke(self, domain: str, key_id: str) -> None:
1073
+ self._c.send(ep.revoke_api_key(domain, key_id))
1074
+
1075
+
1076
+ class Grants(_Resource):
1077
+ def create(
1078
+ self,
1079
+ domain: str,
1080
+ subject: str,
1081
+ role: str,
1082
+ *,
1083
+ scope_type: str = "domain",
1084
+ scope_ref: str | None = None,
1085
+ ) -> m.Grant:
1086
+ body = m.CreateGrant.model_validate(
1087
+ {"subject": subject, "role": role, "scope_type": scope_type, "scope_ref": scope_ref}
1088
+ )
1089
+ return self._c.send(ep.create_grant(domain, body))
1090
+
1091
+ def list(
1092
+ self, domain: str, *, limit: int | None = None, cursor: str | None = None
1093
+ ) -> m.Page[m.Grant]:
1094
+ return self._c.send(ep.list_grants(domain, limit=limit, cursor=cursor))
1095
+
1096
+ def iterate(self, domain: str, *, page_size: int | None = None) -> Iterator[m.Grant]:
1097
+ return self._c.iterate(ep.list_grants(domain, limit=page_size))
1098
+
1099
+ def revoke(self, domain: str, grant_id: str) -> None:
1100
+ self._c.send(ep.revoke_grant(domain, grant_id))
1101
+
1102
+
1103
+ class Principals(_Resource):
1104
+ def list(
1105
+ self, *, type_: str | None = None, limit: int | None = None, cursor: str | None = None
1106
+ ) -> m.Page[m.Principal]:
1107
+ return self._c.send(ep.list_principals(type_=type_, limit=limit, cursor=cursor))
1108
+
1109
+ def iterate(
1110
+ self, *, type_: str | None = None, page_size: int | None = None
1111
+ ) -> Iterator[m.Principal]:
1112
+ return self._c.iterate(ep.list_principals(type_=type_, limit=page_size))
1113
+
1114
+
1115
+ class Audit(_Resource):
1116
+ def list(
1117
+ self, domain: str, *, limit: int | None = None, cursor: str | None = None
1118
+ ) -> m.Page[m.AuditEntry]:
1119
+ return self._c.send(ep.list_audit(domain, limit=limit, cursor=cursor))
1120
+
1121
+ def iterate(self, domain: str, *, page_size: int | None = None) -> Iterator[m.AuditEntry]:
1122
+ return self._c.iterate(ep.list_audit(domain, limit=page_size))
1123
+
1124
+ def verify(self, domain: str, *, after_seq: int | None = None) -> m.AuditVerification:
1125
+ """Recompute the Domain's audit hash chain; `intact` and `first_broken` tell the result."""
1126
+ return self._c.send(ep.verify_audit(domain, after_seq=after_seq))
1127
+
1128
+
1129
+ class Trace(_Resource):
1130
+ """Agent trace: sessions the caller may see, their timelines and notes."""
1131
+
1132
+ def sessions(
1133
+ self,
1134
+ *,
1135
+ actor: str | None = None,
1136
+ domain_id: str | None = None,
1137
+ limit: int | None = None,
1138
+ cursor: str | None = None,
1139
+ ) -> m.TraceSessionPage:
1140
+ """One page of sessions, most recent first; `enabled` tells whether Keeper traces."""
1141
+ call = ep.list_trace_sessions(actor=actor, domain_id=domain_id, limit=limit, cursor=cursor)
1142
+ return self._c.send(call)
1143
+
1144
+ def iterate(
1145
+ self,
1146
+ *,
1147
+ actor: str | None = None,
1148
+ domain_id: str | None = None,
1149
+ page_size: int | None = None,
1150
+ ) -> Iterator[m.TraceSession]:
1151
+ return self._c.iterate(
1152
+ ep.list_trace_sessions(actor=actor, domain_id=domain_id, limit=page_size)
1153
+ )
1154
+
1155
+ def session(self, session_id: str, *, actor: str | None = None) -> m.TraceTimeline:
1156
+ """The calls and notes of a session in order."""
1157
+ return self._c.send(ep.get_trace_session(session_id, actor=actor))
1158
+
1159
+ def add_note(
1160
+ self,
1161
+ session_id: str,
1162
+ text: str,
1163
+ *,
1164
+ domain_id: str | None = None,
1165
+ step: int | None = None,
1166
+ ) -> m.NoteAccepted:
1167
+ """Attach a note to the caller's session; it is written off the request path."""
1168
+ body = m.NoteRequest(text=text, domain_id=domain_id, step=step)
1169
+ return self._c.send(ep.add_trace_note(session_id, body))
1170
+
1171
+
1172
+ class Proposals(_Resource):
1173
+ def create(
1174
+ self,
1175
+ domain: str,
1176
+ source_ref: str,
1177
+ target_ref: str,
1178
+ *,
1179
+ title: str,
1180
+ description: str | None = None,
1181
+ idempotency_key: str | None = None,
1182
+ ) -> m.ProposalDetail:
1183
+ """Propose merging `source_ref` into `target_ref`."""
1184
+ body = m.ProposalCreate(
1185
+ source_ref=source_ref, target_ref=target_ref, title=title, description=description
1186
+ )
1187
+ return self._c.send(ep.create_proposal(domain, body, idempotency_key=idempotency_key))
1188
+
1189
+ def list(
1190
+ self,
1191
+ domain: str,
1192
+ *,
1193
+ status: m.ProposalStatus | None = None,
1194
+ origin: m.ProposalOrigin | None = None,
1195
+ source: str | None = None,
1196
+ target: str | None = None,
1197
+ author: str | None = None,
1198
+ limit: int | None = None,
1199
+ cursor: str | None = None,
1200
+ ) -> m.Page[m.Proposal]:
1201
+ return self._c.send(
1202
+ ep.list_proposals(
1203
+ domain,
1204
+ status=status,
1205
+ origin=origin,
1206
+ source=source,
1207
+ target=target,
1208
+ author=author,
1209
+ limit=limit,
1210
+ cursor=cursor,
1211
+ )
1212
+ )
1213
+
1214
+ def iterate(
1215
+ self,
1216
+ domain: str,
1217
+ *,
1218
+ status: m.ProposalStatus | None = None,
1219
+ origin: m.ProposalOrigin | None = None,
1220
+ source: str | None = None,
1221
+ target: str | None = None,
1222
+ author: str | None = None,
1223
+ page_size: int | None = None,
1224
+ ) -> Iterator[m.Proposal]:
1225
+ call = ep.list_proposals(
1226
+ domain,
1227
+ status=status,
1228
+ origin=origin,
1229
+ source=source,
1230
+ target=target,
1231
+ author=author,
1232
+ limit=page_size,
1233
+ )
1234
+ return self._c.iterate(call)
1235
+
1236
+ def get(self, domain: str, proposal_id: str) -> m.ProposalDetail:
1237
+ return self._c.send(ep.get_proposal(domain, proposal_id))
1238
+
1239
+ def update(
1240
+ self,
1241
+ domain: str,
1242
+ proposal_id: str,
1243
+ *,
1244
+ title: str | None = None,
1245
+ description: str | None = None,
1246
+ ) -> m.ProposalDetail:
1247
+ body = m.ProposalUpdate(title=title, description=description)
1248
+ return self._c.send(ep.update_proposal(domain, proposal_id, body))
1249
+
1250
+ def close(self, domain: str, proposal_id: str) -> m.ProposalDetail:
1251
+ return self._c.send(ep.close_proposal(domain, proposal_id))
1252
+
1253
+ def review(
1254
+ self,
1255
+ domain: str,
1256
+ proposal_id: str,
1257
+ verdict: m.ReviewVerdict,
1258
+ *,
1259
+ comment: str | None = None,
1260
+ ) -> m.ReviewResult:
1261
+ body = m.ReviewIn(verdict=verdict, comment=comment)
1262
+ return self._c.send(ep.review_proposal(domain, proposal_id, body))
1263
+
1264
+ def merge(
1265
+ self,
1266
+ domain: str,
1267
+ proposal_id: str,
1268
+ *,
1269
+ message: str | None = None,
1270
+ idempotency_key: str | None = None,
1271
+ ) -> m.MergeResult:
1272
+ body = m.MergeIn(message=message)
1273
+ return self._c.send(
1274
+ ep.merge_proposal(domain, proposal_id, body, idempotency_key=idempotency_key)
1275
+ )
1276
+
1277
+ def conflicts(
1278
+ self,
1279
+ domain: str,
1280
+ proposal_id: str,
1281
+ *,
1282
+ kind: str | None = None,
1283
+ status: m.ConflictStatus | None = None,
1284
+ limit: int | None = None,
1285
+ cursor: str | None = None,
1286
+ ) -> m.Conflicts:
1287
+ """One page of the Conflicts of the latest merge attempt; `kind` is a code list."""
1288
+ return self._c.send(
1289
+ ep.list_conflicts(
1290
+ domain, proposal_id, kind=kind, status=status, limit=limit, cursor=cursor
1291
+ )
1292
+ )
1293
+
1294
+ def iterate_conflicts(
1295
+ self,
1296
+ domain: str,
1297
+ proposal_id: str,
1298
+ *,
1299
+ kind: str | None = None,
1300
+ status: m.ConflictStatus | None = None,
1301
+ page_size: int | None = None,
1302
+ ) -> Iterator[m.ConflictItem]:
1303
+ call = ep.list_conflicts(domain, proposal_id, kind=kind, status=status, limit=page_size)
1304
+ return self._c.iterate(call)
1305
+
1306
+ def resolve(
1307
+ self,
1308
+ domain: str,
1309
+ proposal_id: str,
1310
+ conflict_id: str,
1311
+ choice: str,
1312
+ *,
1313
+ value: JsonValue = None,
1314
+ values: JsonObject | None = None,
1315
+ ) -> m.Resolution:
1316
+ """Decide one Conflict; `value` or `values` go with the `custom` choice."""
1317
+ body = m.ResolutionIn(
1318
+ choice=choice, value=value, values=None if values is None else dict(values)
1319
+ )
1320
+ return self._c.send(ep.resolve_conflict(domain, proposal_id, conflict_id, body))
1321
+
1322
+ def resolve_many(
1323
+ self,
1324
+ domain: str,
1325
+ proposal_id: str,
1326
+ resolutions: Sequence[m.FingerprintedResolutionIn | JsonObject],
1327
+ ) -> m.Conflicts:
1328
+ """Decide several Conflicts, each named by its fingerprint."""
1329
+ body = m.ResolveManyIn(
1330
+ resolutions=[
1331
+ r
1332
+ if isinstance(r, m.FingerprintedResolutionIn)
1333
+ else m.FingerprintedResolutionIn(**r)
1334
+ for r in resolutions
1335
+ ]
1336
+ )
1337
+ return self._c.send(ep.resolve_conflicts(domain, proposal_id, body))
1338
+
1339
+ def items(
1340
+ self,
1341
+ domain: str,
1342
+ proposal_id: str,
1343
+ *,
1344
+ status: Sequence[m.ItemDecisionValue] | None = None,
1345
+ kind: Sequence[str] | None = None,
1346
+ type: Sequence[m.ItemType] | None = None,
1347
+ min_confidence: float | None = None,
1348
+ max_confidence: float | None = None,
1349
+ limit: int | None = None,
1350
+ cursor: str | None = None,
1351
+ ) -> m.Page[m.ProposalItem]:
1352
+ """One page of the items of an extraction Proposal; a filter given several values
1353
+ matches any of them."""
1354
+ return self._c.send(
1355
+ ep.list_proposal_items(
1356
+ domain,
1357
+ proposal_id,
1358
+ status=status,
1359
+ kind=kind,
1360
+ type=type,
1361
+ min_confidence=min_confidence,
1362
+ max_confidence=max_confidence,
1363
+ limit=limit,
1364
+ cursor=cursor,
1365
+ )
1366
+ )
1367
+
1368
+ def iterate_items(
1369
+ self,
1370
+ domain: str,
1371
+ proposal_id: str,
1372
+ *,
1373
+ status: Sequence[m.ItemDecisionValue] | None = None,
1374
+ kind: Sequence[str] | None = None,
1375
+ type: Sequence[m.ItemType] | None = None,
1376
+ min_confidence: float | None = None,
1377
+ max_confidence: float | None = None,
1378
+ page_size: int | None = None,
1379
+ ) -> Iterator[m.ProposalItem]:
1380
+ call = ep.list_proposal_items(
1381
+ domain,
1382
+ proposal_id,
1383
+ status=status,
1384
+ kind=kind,
1385
+ type=type,
1386
+ min_confidence=min_confidence,
1387
+ max_confidence=max_confidence,
1388
+ limit=page_size,
1389
+ )
1390
+ return self._c.iterate(call)
1391
+
1392
+ def decide_items(
1393
+ self,
1394
+ domain: str,
1395
+ proposal_id: str,
1396
+ decisions: Sequence[m.ItemDecision | JsonObject] = (),
1397
+ *,
1398
+ where: m.ItemSelection | JsonObject | None = None,
1399
+ decision: m.ItemDecisionValue | None = None,
1400
+ ) -> m.ItemsDecided:
1401
+ """Record decisions by item, and or one `decision` for every item matching `where`;
1402
+ the extraction branch and the Proposal are refreshed."""
1403
+ body = m.DecideItems(
1404
+ decisions=[
1405
+ d if isinstance(d, m.ItemDecision) else m.ItemDecision(**d) for d in decisions
1406
+ ],
1407
+ where=where
1408
+ if where is None or isinstance(where, m.ItemSelection)
1409
+ else m.ItemSelection(**where),
1410
+ decision=decision,
1411
+ )
1412
+ return self._c.send(ep.decide_proposal_items(domain, proposal_id, body))
1413
+
1414
+
1415
+ class Agents(_Resource):
1416
+ def context(self, domain: str, request: m.ContextRequest | JsonObject) -> m.ContextPack:
1417
+ """A context pack as JSON (a `ContextRequest` or its JSON object)."""
1418
+ return self._c.send(ep.context_pack(domain, _context_request(request)))
1419
+
1420
+ def context_markdown(self, domain: str, request: m.ContextRequest | JsonObject) -> str:
1421
+ """The same context pack rendered as Markdown."""
1422
+ return self._c.send(ep.context_pack_markdown(domain, _context_request(request)))
1423
+
1424
+ def explain(
1425
+ self, domain: str, kind: str, key: str, *, branch: str | None = None, at: str | None = None
1426
+ ) -> m.Explain:
1427
+ """Where the values and Links of a Record came from."""
1428
+ return self._c.send(ep.explain_record(domain, kind, key, branch=branch, at=at))
1429
+
1430
+ def explain_markdown(
1431
+ self, domain: str, kind: str, key: str, *, branch: str | None = None, at: str | None = None
1432
+ ) -> str:
1433
+ return self._c.send(ep.explain_record_markdown(domain, kind, key, branch=branch, at=at))
1434
+
1435
+ def health(
1436
+ self, domain: str, *, branch: str | None = None, at: str | None = None
1437
+ ) -> m.DomainHealth:
1438
+ """Violations, orphan Links, gaps and duplicates per Kind and Relation of a Domain."""
1439
+ return self._c.send(ep.domain_health(domain, branch=branch, at=at))
1440
+
1441
+ def health_markdown(
1442
+ self, domain: str, *, branch: str | None = None, at: str | None = None
1443
+ ) -> str:
1444
+ """The Domain health report rendered as Markdown."""
1445
+ return self._c.send(ep.domain_health_markdown(domain, branch=branch, at=at))
1446
+
1447
+ def kind_health(
1448
+ self, domain: str, kind: str, *, branch: str | None = None, at: str | None = None
1449
+ ) -> m.KindHealthReport:
1450
+ """The health of one Kind with the Relations it takes part in."""
1451
+ return self._c.send(ep.kind_health(domain, kind, branch=branch, at=at))
1452
+
1453
+ def kind_health_markdown(
1454
+ self, domain: str, kind: str, *, branch: str | None = None, at: str | None = None
1455
+ ) -> str:
1456
+ return self._c.send(ep.kind_health_markdown(domain, kind, branch=branch, at=at))
1457
+
1458
+ def changes(
1459
+ self,
1460
+ domain: str,
1461
+ branch: str,
1462
+ *,
1463
+ since: str | None = None,
1464
+ kinds: Sequence[str] | None = None,
1465
+ limit: int | None = None,
1466
+ ) -> m.ChangesFeed:
1467
+ """Revision events of a branch after `since` (a Revision id or tag)."""
1468
+ return self._c.send(
1469
+ ep.branch_changes(domain, branch, since=since, kinds=kinds, limit=limit)
1470
+ )
1471
+
1472
+ def events(
1473
+ self,
1474
+ domain: str,
1475
+ branch: str,
1476
+ *,
1477
+ kinds: Sequence[str] | None = None,
1478
+ last_event_id: str | None = None,
1479
+ ) -> str:
1480
+ """The event stream of a branch as text, read until the server closes it."""
1481
+ return self._c.send(
1482
+ ep.branch_events(domain, branch, kinds=kinds, last_event_id=last_event_id)
1483
+ )
1484
+
1485
+
1486
+ class Webhooks(_Resource):
1487
+ def create(
1488
+ self,
1489
+ domain: str,
1490
+ url: str,
1491
+ *,
1492
+ events: Sequence[str] | None = None,
1493
+ branches: Sequence[str] | None = None,
1494
+ description: str | None = None,
1495
+ clearances: Sequence[str] | None = None,
1496
+ ) -> m.WebhookCreated:
1497
+ """Register a webhook; the signing secret is in `secret` and is not shown again."""
1498
+ body = m.CreateWebhook(
1499
+ url=url,
1500
+ events=None if events is None else list(events),
1501
+ branches=None if branches is None else list(branches),
1502
+ description=description,
1503
+ clearances=None if clearances is None else list(clearances),
1504
+ )
1505
+ return self._c.send(ep.create_webhook(domain, body))
1506
+
1507
+ def list(self, domain: str) -> m.WebhookList:
1508
+ return self._c.send(ep.list_webhooks(domain))
1509
+
1510
+ def set_clearances(self, domain: str, webhook_id: str, clearances: Sequence[str]) -> m.Webhook:
1511
+ """Replace the clearances of a webhook; adding one needs its clearance permission on
1512
+ the Domain."""
1513
+ body = m.UpdateWebhook(clearances=list(clearances))
1514
+ return self._c.send(ep.update_webhook(domain, webhook_id, body))
1515
+
1516
+ def delete(self, domain: str, webhook_id: str) -> None:
1517
+ self._c.send(ep.delete_webhook(domain, webhook_id))
1518
+
1519
+
1520
+ class Sources(_Resource):
1521
+ def sync(self, domain: str, source: str, *, dry_run: bool = False) -> m.SyncQueued:
1522
+ """Queue a synchronization of the Source (id or api name) for Scribe."""
1523
+ return self._c.send(ep.sync_source(domain, source, m.SyncRequest(dry_run=dry_run)))
1524
+
1525
+ def runs(
1526
+ self, domain: str, source: str, *, limit: int | None = None, cursor: str | None = None
1527
+ ) -> m.Page[m.SourceRun]:
1528
+ """Runs of the Source, newest first (needs a Domain-wide `data.read` grant)."""
1529
+ return self._c.send(ep.list_source_runs(domain, source, limit=limit, cursor=cursor))
1530
+
1531
+ def iterate_runs(
1532
+ self, domain: str, source: str, *, page_size: int | None = None
1533
+ ) -> Iterator[m.SourceRun]:
1534
+ return self._c.iterate(ep.list_source_runs(domain, source, limit=page_size))
1535
+
1536
+
1537
+ class Documents(_Resource):
1538
+ def upload(
1539
+ self,
1540
+ domain: str,
1541
+ file: str | os.PathLike[str] | bytes,
1542
+ *,
1543
+ filename: str | None = None,
1544
+ extract: bool | None = None,
1545
+ tiers: Sequence[int] | None = None,
1546
+ ) -> m.DocumentUploaded:
1547
+ """Upload a document: a path (named after the file) or bytes with a `filename`.
1548
+
1549
+ `extract` (server default: true) queues an extraction job on the given `tiers`.
1550
+ """
1551
+ call = ep.upload_document(domain, file, filename=filename, extract=extract, tiers=tiers)
1552
+ return self._c.send(call)
1553
+
1554
+ def upload_url(
1555
+ self,
1556
+ domain: str,
1557
+ url: str,
1558
+ *,
1559
+ filename: str | None = None,
1560
+ extract: bool | None = None,
1561
+ tiers: Sequence[int] | None = None,
1562
+ ) -> m.DocumentUploaded:
1563
+ """Have the server fetch the document from an allowlisted URL."""
1564
+ body = m.DocumentFromUrl(
1565
+ url=url,
1566
+ filename=filename,
1567
+ extract=extract,
1568
+ tiers=None if tiers is None else list(tiers),
1569
+ )
1570
+ return self._c.send(ep.upload_document_url(domain, body))
1571
+
1572
+ def list(
1573
+ self, domain: str, *, limit: int | None = None, cursor: str | None = None
1574
+ ) -> m.Page[m.Document]:
1575
+ """Documents of the Domain, newest first."""
1576
+ return self._c.send(ep.list_documents(domain, limit=limit, cursor=cursor))
1577
+
1578
+ def iterate(self, domain: str, *, page_size: int | None = None) -> Iterator[m.Document]:
1579
+ return self._c.iterate(ep.list_documents(domain, limit=page_size))
1580
+
1581
+ def get(self, domain: str, document_id: str) -> m.DocumentDetail:
1582
+ """A document with its extraction jobs."""
1583
+ return self._c.send(ep.get_document(domain, document_id))
1584
+
1585
+ def text(self, domain: str, document_id: str) -> m.DocumentText:
1586
+ """The parsed text of a document (404 until it is parsed)."""
1587
+ return self._c.send(ep.get_document_text(domain, document_id))