lorebase-client 0.2.3__tar.gz → 0.3.0__tar.gz

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.
Files changed (20) hide show
  1. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/PKG-INFO +1 -1
  2. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/pyproject.toml +1 -1
  3. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/src/lorebase_client/__init__.py +1 -1
  4. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/src/lorebase_client/aclient.py +206 -9
  5. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/src/lorebase_client/client.py +201 -10
  6. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/src/lorebase_client/endpoints.py +154 -1
  7. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/src/lorebase_client/models.py +355 -8
  8. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/tests/test_contract.py +42 -2
  9. lorebase_client-0.3.0/tests/test_parity.py +62 -0
  10. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/tests/test_requests.py +326 -1
  11. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/.gitignore +0 -0
  12. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/src/lorebase_client/config.py +0 -0
  13. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/src/lorebase_client/core.py +0 -0
  14. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/src/lorebase_client/errors.py +0 -0
  15. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/src/lorebase_client/py.typed +0 -0
  16. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/tests/integration/conftest.py +0 -0
  17. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/tests/integration/test_flow.py +0 -0
  18. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/tests/test_errors_and_config.py +0 -0
  19. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/tests/test_path_segments.py +0 -0
  20. {lorebase_client-0.2.3 → lorebase_client-0.3.0}/tests/test_trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lorebase-client
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Lorebase client: typed sync and async HTTP client for the Keeper API
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: httpx>=0.27
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lorebase-client"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  description = "Lorebase client: typed sync and async HTTP client for the Keeper API"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -45,7 +45,7 @@ from lorebase_client.errors import (
45
45
  error_from_response,
46
46
  )
47
47
 
48
- __version__ = "0.2.3"
48
+ __version__ = "0.3.0"
49
49
 
50
50
  __all__ = [
51
51
  "ENV_API_KEY",
@@ -106,6 +106,7 @@ class AsyncLorebaseClient:
106
106
  self.proposals = Proposals(self)
107
107
  self.agents = Agents(self)
108
108
  self.webhooks = Webhooks(self)
109
+ self.reconciliation = Reconciliation(self)
109
110
  self.sources = Sources(self)
110
111
  self.documents = Documents(self)
111
112
 
@@ -181,6 +182,11 @@ class AsyncLorebaseClient:
181
182
  """The authenticated caller."""
182
183
  return await self.send(ep.whoami())
183
184
 
185
+ async def session_info(self) -> m.SessionInfo:
186
+ """The browser session the caller's session cookie belongs to; 401 for API keys and
187
+ bearer tokens."""
188
+ return await self.send(ep.session_info())
189
+
184
190
  def idempotency_key(self, prefix: str | None = None) -> str:
185
191
  """A fresh key to pass as `idempotency_key=` to a write."""
186
192
  return new_idempotency_key(prefix)
@@ -228,9 +234,14 @@ class Domains(_Resource):
228
234
  return await self._c.send(ep.get_domain(id))
229
235
 
230
236
  async def update(
231
- self, id: str, *, display_name: str | None = None, description: str | None = None
237
+ self,
238
+ id: str,
239
+ *,
240
+ display_name: str | None = None,
241
+ description: str | None = None,
242
+ settings: JsonObject | None = None,
232
243
  ) -> m.Domain:
233
- body = m.DomainUpdate(display_name=display_name, description=description)
244
+ body = m.DomainUpdate(display_name=display_name, description=description, settings=settings)
234
245
  return await self._c.send(ep.update_domain(id, body))
235
246
 
236
247
  async def delete(self, id: str, *, confirm: str) -> None:
@@ -854,6 +865,10 @@ class Records(_Resource):
854
865
  body = request if isinstance(request, m.SearchRequest) else m.SearchRequest(**request)
855
866
  return await self._c.send(ep.search(domain, body))
856
867
 
868
+ async def resolve(self, domain: str, request: m.ResolveRequest | JsonObject) -> m.ResolveResult:
869
+ body = request if isinstance(request, m.ResolveRequest) else m.ResolveRequest(**request)
870
+ return await self._c.send(ep.resolve(domain, body))
871
+
857
872
 
858
873
  def _query_request(query: m.QueryRequest | JsonObject) -> m.QueryRequest:
859
874
  return query if isinstance(query, m.QueryRequest) else m.QueryRequest(**query)
@@ -1189,6 +1204,7 @@ class Proposals(_Resource):
1189
1204
  source: str | None = None,
1190
1205
  target: str | None = None,
1191
1206
  author: str | None = None,
1207
+ assigned: m.AssignedFilter | None = None,
1192
1208
  limit: int | None = None,
1193
1209
  cursor: str | None = None,
1194
1210
  ) -> m.Page[m.Proposal]:
@@ -1200,6 +1216,7 @@ class Proposals(_Resource):
1200
1216
  source=source,
1201
1217
  target=target,
1202
1218
  author=author,
1219
+ assigned=assigned,
1203
1220
  limit=limit,
1204
1221
  cursor=cursor,
1205
1222
  )
@@ -1214,6 +1231,7 @@ class Proposals(_Resource):
1214
1231
  source: str | None = None,
1215
1232
  target: str | None = None,
1216
1233
  author: str | None = None,
1234
+ assigned: m.AssignedFilter | None = None,
1217
1235
  page_size: int | None = None,
1218
1236
  ) -> AsyncIterator[m.Proposal]:
1219
1237
  call = ep.list_proposals(
@@ -1223,6 +1241,7 @@ class Proposals(_Resource):
1223
1241
  source=source,
1224
1242
  target=target,
1225
1243
  author=author,
1244
+ assigned=assigned,
1226
1245
  limit=page_size,
1227
1246
  )
1228
1247
  return self._c.iterate(call)
@@ -1515,6 +1534,141 @@ class Webhooks(_Resource):
1515
1534
  await self._c.send(ep.delete_webhook(domain, webhook_id))
1516
1535
 
1517
1536
 
1537
+ class Reconciliation(_Resource):
1538
+ async def list(
1539
+ self,
1540
+ domain: str,
1541
+ *,
1542
+ branch: str | None = None,
1543
+ type: m.CandidateType | None = None,
1544
+ status: m.CandidateStatus | None = None,
1545
+ kind: str | None = None,
1546
+ attribute: str | None = None,
1547
+ source: str | None = None,
1548
+ assigned: m.AssignedFilter | None = None,
1549
+ limit: int | None = None,
1550
+ cursor: str | None = None,
1551
+ ) -> m.Page[m.ReconciliationCandidate]:
1552
+ """One page of the candidates of a branch (default: the default branch), newest first.
1553
+
1554
+ `kind` and `attribute` are api names (`attribute` needs `kind`); `source` is a Source
1555
+ identity such as `source:src_...`."""
1556
+ return await self._c.send(
1557
+ ep.list_reconciliation(
1558
+ domain,
1559
+ branch=branch,
1560
+ type=type,
1561
+ status=status,
1562
+ kind=kind,
1563
+ attribute=attribute,
1564
+ source=source,
1565
+ assigned=assigned,
1566
+ limit=limit,
1567
+ cursor=cursor,
1568
+ )
1569
+ )
1570
+
1571
+ def iterate(
1572
+ self,
1573
+ domain: str,
1574
+ *,
1575
+ branch: str | None = None,
1576
+ type: m.CandidateType | None = None,
1577
+ status: m.CandidateStatus | None = None,
1578
+ kind: str | None = None,
1579
+ attribute: str | None = None,
1580
+ source: str | None = None,
1581
+ assigned: m.AssignedFilter | None = None,
1582
+ page_size: int | None = None,
1583
+ ) -> AsyncIterator[m.ReconciliationCandidate]:
1584
+ call = ep.list_reconciliation(
1585
+ domain,
1586
+ branch=branch,
1587
+ type=type,
1588
+ status=status,
1589
+ kind=kind,
1590
+ attribute=attribute,
1591
+ source=source,
1592
+ assigned=assigned,
1593
+ limit=page_size,
1594
+ )
1595
+ return self._c.iterate(call)
1596
+
1597
+ async def get(self, domain: str, candidate_id: str) -> m.ReconciliationCandidate:
1598
+ return await self._c.send(ep.get_reconciliation_candidate(domain, candidate_id))
1599
+
1600
+ async def resolve(
1601
+ self,
1602
+ domain: str,
1603
+ candidate_id: str,
1604
+ choice: m.ReconcileChoice,
1605
+ *,
1606
+ reason: str,
1607
+ claim_id: str | None = None,
1608
+ value: JsonValue = None,
1609
+ policy: m.ReconcilePolicy | None = None,
1610
+ survivor: str | None = None,
1611
+ values: JsonObject | None = None,
1612
+ dry_run: bool = False,
1613
+ ) -> m.ReconciliationDecision:
1614
+ """Decide a candidate; `dry_run` reports the outcome without writing."""
1615
+ body = m.ReconcileIn(
1616
+ choice=choice,
1617
+ claim_id=claim_id,
1618
+ value=value,
1619
+ policy=policy,
1620
+ survivor=survivor,
1621
+ values=None if values is None else dict(values),
1622
+ reason=reason,
1623
+ dry_run=dry_run,
1624
+ )
1625
+ return await self._c.send(ep.resolve_reconciliation(domain, candidate_id, body))
1626
+
1627
+ async def dismiss(
1628
+ self, domain: str, candidate_id: str, *, reason: str
1629
+ ) -> m.ReconciliationDecision:
1630
+ return await self._c.send(
1631
+ ep.dismiss_reconciliation(domain, candidate_id, m.ReasonIn(reason=reason))
1632
+ )
1633
+
1634
+ async def reopen(
1635
+ self, domain: str, candidate_id: str, *, reason: str
1636
+ ) -> m.ReconciliationDecision:
1637
+ """Reopen a dismissed candidate."""
1638
+ return await self._c.send(
1639
+ ep.reopen_reconciliation(domain, candidate_id, m.ReasonIn(reason=reason))
1640
+ )
1641
+
1642
+ async def resolve_many(
1643
+ self,
1644
+ domain: str,
1645
+ choice: m.BulkChoice,
1646
+ *,
1647
+ reason: str,
1648
+ ids: Sequence[str] | None = None,
1649
+ where: m.ReconcileFilter | None = None,
1650
+ policy: m.ReconcilePolicy | None = None,
1651
+ dry_run: bool = False,
1652
+ ) -> m.BulkDecision:
1653
+ """Apply a policy or a dismissal to the `ids` given, or to the open candidates
1654
+ matching `where`."""
1655
+ body = m.BulkReconcileIn(
1656
+ ids=None if ids is None else list(ids),
1657
+ filter=where,
1658
+ choice=choice,
1659
+ policy=policy,
1660
+ reason=reason,
1661
+ dry_run=dry_run,
1662
+ )
1663
+ return await self._c.send(ep.resolve_reconciliation_bulk(domain, body))
1664
+
1665
+ async def queue_sweep(self, domain: str, *, branch: str | None = None) -> m.SweepQueued:
1666
+ """Queue a reconciliation sweep of a branch for Scribe."""
1667
+ return await self._c.send(
1668
+ ep.queue_reconciliation_sweep(domain, m.SweepRequest(branch=branch))
1669
+ )
1670
+
1671
+
1518
1672
  class Sources(_Resource):
1519
1673
  async def sync(self, domain: str, source: str, *, dry_run: bool = False) -> m.SyncQueued:
1520
1674
  """Queue a synchronization of the Source (id or api name) for Scribe."""
@@ -1541,12 +1695,16 @@ class Documents(_Resource):
1541
1695
  filename: str | None = None,
1542
1696
  extract: bool | None = None,
1543
1697
  tiers: Sequence[int] | None = None,
1698
+ source_key: str | None = None,
1544
1699
  ) -> m.DocumentUploaded:
1545
1700
  """Upload a document: a path (named after the file) or bytes with a `filename`.
1546
1701
 
1547
- `extract` (server default: true) queues an extraction job on the given `tiers`.
1702
+ `extract` (server default: true) queues an extraction job on the given `tiers`. With a
1703
+ `source_key`, new bytes become a new version of the key's document.
1548
1704
  """
1549
- call = ep.upload_document(domain, file, filename=filename, extract=extract, tiers=tiers)
1705
+ call = ep.upload_document(
1706
+ domain, file, filename=filename, extract=extract, tiers=tiers, source_key=source_key
1707
+ )
1550
1708
  return await self._c.send(call)
1551
1709
 
1552
1710
  async def upload_url(
@@ -1557,6 +1715,7 @@ class Documents(_Resource):
1557
1715
  filename: str | None = None,
1558
1716
  extract: bool | None = None,
1559
1717
  tiers: Sequence[int] | None = None,
1718
+ source_key: str | None = None,
1560
1719
  ) -> m.DocumentUploaded:
1561
1720
  """Have the server fetch the document from an allowlisted URL."""
1562
1721
  body = m.DocumentFromUrl(
@@ -1564,17 +1723,55 @@ class Documents(_Resource):
1564
1723
  filename=filename,
1565
1724
  extract=extract,
1566
1725
  tiers=None if tiers is None else list(tiers),
1726
+ source_key=source_key,
1567
1727
  )
1568
1728
  return await self._c.send(ep.upload_document_url(domain, body))
1569
1729
 
1570
1730
  async def list(
1571
- self, domain: str, *, limit: int | None = None, cursor: str | None = None
1731
+ self,
1732
+ domain: str,
1733
+ *,
1734
+ source_key: str | None = None,
1735
+ limit: int | None = None,
1736
+ cursor: str | None = None,
1572
1737
  ) -> m.Page[m.Document]:
1573
- """Documents of the Domain, newest first."""
1574
- return await self._c.send(ep.list_documents(domain, limit=limit, cursor=cursor))
1738
+ """Documents of the Domain, newest first; `source_key` keeps one key's versions."""
1739
+ call = ep.list_documents(domain, source_key=source_key, limit=limit, cursor=cursor)
1740
+ return await self._c.send(call)
1741
+
1742
+ def iterate(
1743
+ self, domain: str, *, source_key: str | None = None, page_size: int | None = None
1744
+ ) -> AsyncIterator[m.Document]:
1745
+ return self._c.iterate(ep.list_documents(domain, source_key=source_key, limit=page_size))
1746
+
1747
+ async def versions(
1748
+ self,
1749
+ domain: str,
1750
+ document_id: str,
1751
+ *,
1752
+ limit: int | None = None,
1753
+ cursor: str | None = None,
1754
+ ) -> m.Page[m.Document]:
1755
+ """The versions of the document's source key, newest first."""
1756
+ call = ep.document_versions(domain, document_id, limit=limit, cursor=cursor)
1757
+ return await self._c.send(call)
1575
1758
 
1576
- def iterate(self, domain: str, *, page_size: int | None = None) -> AsyncIterator[m.Document]:
1577
- return self._c.iterate(ep.list_documents(domain, limit=page_size))
1759
+ def iterate_versions(
1760
+ self, domain: str, document_id: str, *, page_size: int | None = None
1761
+ ) -> AsyncIterator[m.Document]:
1762
+ return self._c.iterate(ep.document_versions(domain, document_id, limit=page_size))
1763
+
1764
+ async def retire(self, domain: str, document_id: str, *, reason: str) -> m.DocumentRetired:
1765
+ """Retire a document: it is never extracted again and its evidence is re-checked."""
1766
+ body = m.RetirementIn(reason=reason)
1767
+ return await self._c.send(ep.retire_document(domain, document_id, body))
1768
+
1769
+ async def purge(
1770
+ self, domain: str, document_id: str, *, reason: str, confirm: str
1771
+ ) -> m.DocumentPurged:
1772
+ """Irreversibly delete the content of a document; `confirm` is the document id."""
1773
+ body = m.PurgeIn(reason=reason, confirm=confirm)
1774
+ return await self._c.send(ep.purge_document(domain, document_id, body))
1578
1775
 
1579
1776
  async def get(self, domain: str, document_id: str) -> m.DocumentDetail:
1580
1777
  """A document with its extraction jobs."""
@@ -7,7 +7,7 @@
7
7
 
8
8
  Resources group the endpoints (`domains`, `branches`, `tags`, `schema`, `operations`, `records`,
9
9
  `revisions`, `api_keys`, `grants`, `principals`, `audit`, `proposals`, `agents`, `webhooks`,
10
- `sources`, `documents`).
10
+ `reconciliation`, `sources`, `documents`).
11
11
  Every list endpoint has a `list` method (one page) and an `iterate` method (all items; pages
12
12
  are fetched on demand). Errors
13
13
  raise subclasses of `LorebaseError`. The request layer is shared with `AsyncLorebaseClient`
@@ -126,6 +126,7 @@ class LorebaseClient:
126
126
  self.proposals = Proposals(self)
127
127
  self.agents = Agents(self)
128
128
  self.webhooks = Webhooks(self)
129
+ self.reconciliation = Reconciliation(self)
129
130
  self.sources = Sources(self)
130
131
  self.documents = Documents(self)
131
132
 
@@ -200,6 +201,11 @@ class LorebaseClient:
200
201
  """The authenticated caller."""
201
202
  return self.send(ep.whoami())
202
203
 
204
+ def session_info(self) -> m.SessionInfo:
205
+ """The browser session the caller's session cookie belongs to; 401 for API keys and
206
+ bearer tokens."""
207
+ return self.send(ep.session_info())
208
+
203
209
  def idempotency_key(self, prefix: str | None = None) -> str:
204
210
  """A fresh key to pass as `idempotency_key=` to a write."""
205
211
  return new_idempotency_key(prefix)
@@ -245,9 +251,14 @@ class Domains(_Resource):
245
251
  return self._c.send(ep.get_domain(id))
246
252
 
247
253
  def update(
248
- self, id: str, *, display_name: str | None = None, description: str | None = None
254
+ self,
255
+ id: str,
256
+ *,
257
+ display_name: str | None = None,
258
+ description: str | None = None,
259
+ settings: JsonObject | None = None,
249
260
  ) -> m.Domain:
250
- body = m.DomainUpdate(display_name=display_name, description=description)
261
+ body = m.DomainUpdate(display_name=display_name, description=description, settings=settings)
251
262
  return self._c.send(ep.update_domain(id, body))
252
263
 
253
264
  def delete(self, id: str, *, confirm: str) -> None:
@@ -862,6 +873,10 @@ class Records(_Resource):
862
873
  body = request if isinstance(request, m.SearchRequest) else m.SearchRequest(**request)
863
874
  return self._c.send(ep.search(domain, body))
864
875
 
876
+ def resolve(self, domain: str, request: m.ResolveRequest | JsonObject) -> m.ResolveResult:
877
+ body = request if isinstance(request, m.ResolveRequest) else m.ResolveRequest(**request)
878
+ return self._c.send(ep.resolve(domain, body))
879
+
865
880
 
866
881
  def _query_request(query: m.QueryRequest | JsonObject) -> m.QueryRequest:
867
882
  return query if isinstance(query, m.QueryRequest) else m.QueryRequest(**query)
@@ -1195,6 +1210,7 @@ class Proposals(_Resource):
1195
1210
  source: str | None = None,
1196
1211
  target: str | None = None,
1197
1212
  author: str | None = None,
1213
+ assigned: m.AssignedFilter | None = None,
1198
1214
  limit: int | None = None,
1199
1215
  cursor: str | None = None,
1200
1216
  ) -> m.Page[m.Proposal]:
@@ -1206,6 +1222,7 @@ class Proposals(_Resource):
1206
1222
  source=source,
1207
1223
  target=target,
1208
1224
  author=author,
1225
+ assigned=assigned,
1209
1226
  limit=limit,
1210
1227
  cursor=cursor,
1211
1228
  )
@@ -1220,6 +1237,7 @@ class Proposals(_Resource):
1220
1237
  source: str | None = None,
1221
1238
  target: str | None = None,
1222
1239
  author: str | None = None,
1240
+ assigned: m.AssignedFilter | None = None,
1223
1241
  page_size: int | None = None,
1224
1242
  ) -> Iterator[m.Proposal]:
1225
1243
  call = ep.list_proposals(
@@ -1229,6 +1247,7 @@ class Proposals(_Resource):
1229
1247
  source=source,
1230
1248
  target=target,
1231
1249
  author=author,
1250
+ assigned=assigned,
1232
1251
  limit=page_size,
1233
1252
  )
1234
1253
  return self._c.iterate(call)
@@ -1517,6 +1536,135 @@ class Webhooks(_Resource):
1517
1536
  self._c.send(ep.delete_webhook(domain, webhook_id))
1518
1537
 
1519
1538
 
1539
+ class Reconciliation(_Resource):
1540
+ def list(
1541
+ self,
1542
+ domain: str,
1543
+ *,
1544
+ branch: str | None = None,
1545
+ type: m.CandidateType | None = None,
1546
+ status: m.CandidateStatus | None = None,
1547
+ kind: str | None = None,
1548
+ attribute: str | None = None,
1549
+ source: str | None = None,
1550
+ assigned: m.AssignedFilter | None = None,
1551
+ limit: int | None = None,
1552
+ cursor: str | None = None,
1553
+ ) -> m.Page[m.ReconciliationCandidate]:
1554
+ """One page of the candidates of a branch (default: the default branch), newest first.
1555
+
1556
+ `kind` and `attribute` are api names (`attribute` needs `kind`); `source` is a Source
1557
+ identity such as `source:src_...`."""
1558
+ return self._c.send(
1559
+ ep.list_reconciliation(
1560
+ domain,
1561
+ branch=branch,
1562
+ type=type,
1563
+ status=status,
1564
+ kind=kind,
1565
+ attribute=attribute,
1566
+ source=source,
1567
+ assigned=assigned,
1568
+ limit=limit,
1569
+ cursor=cursor,
1570
+ )
1571
+ )
1572
+
1573
+ def iterate(
1574
+ self,
1575
+ domain: str,
1576
+ *,
1577
+ branch: str | None = None,
1578
+ type: m.CandidateType | None = None,
1579
+ status: m.CandidateStatus | None = None,
1580
+ kind: str | None = None,
1581
+ attribute: str | None = None,
1582
+ source: str | None = None,
1583
+ assigned: m.AssignedFilter | None = None,
1584
+ page_size: int | None = None,
1585
+ ) -> Iterator[m.ReconciliationCandidate]:
1586
+ call = ep.list_reconciliation(
1587
+ domain,
1588
+ branch=branch,
1589
+ type=type,
1590
+ status=status,
1591
+ kind=kind,
1592
+ attribute=attribute,
1593
+ source=source,
1594
+ assigned=assigned,
1595
+ limit=page_size,
1596
+ )
1597
+ return self._c.iterate(call)
1598
+
1599
+ def get(self, domain: str, candidate_id: str) -> m.ReconciliationCandidate:
1600
+ return self._c.send(ep.get_reconciliation_candidate(domain, candidate_id))
1601
+
1602
+ def resolve(
1603
+ self,
1604
+ domain: str,
1605
+ candidate_id: str,
1606
+ choice: m.ReconcileChoice,
1607
+ *,
1608
+ reason: str,
1609
+ claim_id: str | None = None,
1610
+ value: JsonValue = None,
1611
+ policy: m.ReconcilePolicy | None = None,
1612
+ survivor: str | None = None,
1613
+ values: JsonObject | None = None,
1614
+ dry_run: bool = False,
1615
+ ) -> m.ReconciliationDecision:
1616
+ """Decide a candidate; `dry_run` reports the outcome without writing."""
1617
+ body = m.ReconcileIn(
1618
+ choice=choice,
1619
+ claim_id=claim_id,
1620
+ value=value,
1621
+ policy=policy,
1622
+ survivor=survivor,
1623
+ values=None if values is None else dict(values),
1624
+ reason=reason,
1625
+ dry_run=dry_run,
1626
+ )
1627
+ return self._c.send(ep.resolve_reconciliation(domain, candidate_id, body))
1628
+
1629
+ def dismiss(self, domain: str, candidate_id: str, *, reason: str) -> m.ReconciliationDecision:
1630
+ return self._c.send(
1631
+ ep.dismiss_reconciliation(domain, candidate_id, m.ReasonIn(reason=reason))
1632
+ )
1633
+
1634
+ def reopen(self, domain: str, candidate_id: str, *, reason: str) -> m.ReconciliationDecision:
1635
+ """Reopen a dismissed candidate."""
1636
+ return self._c.send(
1637
+ ep.reopen_reconciliation(domain, candidate_id, m.ReasonIn(reason=reason))
1638
+ )
1639
+
1640
+ def resolve_many(
1641
+ self,
1642
+ domain: str,
1643
+ choice: m.BulkChoice,
1644
+ *,
1645
+ reason: str,
1646
+ ids: Sequence[str] | None = None,
1647
+ where: m.ReconcileFilter | None = None,
1648
+ policy: m.ReconcilePolicy | None = None,
1649
+ dry_run: bool = False,
1650
+ ) -> m.BulkDecision:
1651
+ """Apply a policy or a dismissal to the `ids` given, or to the open candidates
1652
+ matching `where`."""
1653
+ body = m.BulkReconcileIn(
1654
+ ids=None if ids is None else list(ids),
1655
+ filter=where,
1656
+ choice=choice,
1657
+ policy=policy,
1658
+ reason=reason,
1659
+ dry_run=dry_run,
1660
+ )
1661
+ return self._c.send(ep.resolve_reconciliation_bulk(domain, body))
1662
+
1663
+ def queue_sweep(self, domain: str, *, branch: str | None = None) -> m.SweepQueued:
1664
+ """Queue a reconciliation sweep of a branch for Scribe."""
1665
+ return self._c.send(ep.queue_reconciliation_sweep(domain, m.SweepRequest(branch=branch)))
1666
+
1667
+
1520
1668
  class Sources(_Resource):
1521
1669
  def sync(self, domain: str, source: str, *, dry_run: bool = False) -> m.SyncQueued:
1522
1670
  """Queue a synchronization of the Source (id or api name) for Scribe."""
@@ -1543,12 +1691,16 @@ class Documents(_Resource):
1543
1691
  filename: str | None = None,
1544
1692
  extract: bool | None = None,
1545
1693
  tiers: Sequence[int] | None = None,
1694
+ source_key: str | None = None,
1546
1695
  ) -> m.DocumentUploaded:
1547
1696
  """Upload a document: a path (named after the file) or bytes with a `filename`.
1548
1697
 
1549
- `extract` (server default: true) queues an extraction job on the given `tiers`.
1698
+ `extract` (server default: true) queues an extraction job on the given `tiers`. With a
1699
+ `source_key`, new bytes become a new version of the key's document.
1550
1700
  """
1551
- call = ep.upload_document(domain, file, filename=filename, extract=extract, tiers=tiers)
1701
+ call = ep.upload_document(
1702
+ domain, file, filename=filename, extract=extract, tiers=tiers, source_key=source_key
1703
+ )
1552
1704
  return self._c.send(call)
1553
1705
 
1554
1706
  def upload_url(
@@ -1559,6 +1711,7 @@ class Documents(_Resource):
1559
1711
  filename: str | None = None,
1560
1712
  extract: bool | None = None,
1561
1713
  tiers: Sequence[int] | None = None,
1714
+ source_key: str | None = None,
1562
1715
  ) -> m.DocumentUploaded:
1563
1716
  """Have the server fetch the document from an allowlisted URL."""
1564
1717
  body = m.DocumentFromUrl(
@@ -1566,17 +1719,55 @@ class Documents(_Resource):
1566
1719
  filename=filename,
1567
1720
  extract=extract,
1568
1721
  tiers=None if tiers is None else list(tiers),
1722
+ source_key=source_key,
1569
1723
  )
1570
1724
  return self._c.send(ep.upload_document_url(domain, body))
1571
1725
 
1572
1726
  def list(
1573
- self, domain: str, *, limit: int | None = None, cursor: str | None = None
1727
+ self,
1728
+ domain: str,
1729
+ *,
1730
+ source_key: str | None = None,
1731
+ limit: int | None = None,
1732
+ cursor: str | None = None,
1574
1733
  ) -> m.Page[m.Document]:
1575
- """Documents of the Domain, newest first."""
1576
- return self._c.send(ep.list_documents(domain, limit=limit, cursor=cursor))
1734
+ """Documents of the Domain, newest first; `source_key` keeps one key's versions."""
1735
+ call = ep.list_documents(domain, source_key=source_key, limit=limit, cursor=cursor)
1736
+ return self._c.send(call)
1737
+
1738
+ def iterate(
1739
+ self, domain: str, *, source_key: str | None = None, page_size: int | None = None
1740
+ ) -> Iterator[m.Document]:
1741
+ return self._c.iterate(ep.list_documents(domain, source_key=source_key, limit=page_size))
1742
+
1743
+ def versions(
1744
+ self,
1745
+ domain: str,
1746
+ document_id: str,
1747
+ *,
1748
+ limit: int | None = None,
1749
+ cursor: str | None = None,
1750
+ ) -> m.Page[m.Document]:
1751
+ """The versions of the document's source key, newest first."""
1752
+ call = ep.document_versions(domain, document_id, limit=limit, cursor=cursor)
1753
+ return self._c.send(call)
1577
1754
 
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))
1755
+ def iterate_versions(
1756
+ self, domain: str, document_id: str, *, page_size: int | None = None
1757
+ ) -> Iterator[m.Document]:
1758
+ return self._c.iterate(ep.document_versions(domain, document_id, limit=page_size))
1759
+
1760
+ def retire(self, domain: str, document_id: str, *, reason: str) -> m.DocumentRetired:
1761
+ """Retire a document: it is never extracted again and its evidence is re-checked."""
1762
+ body = m.RetirementIn(reason=reason)
1763
+ return self._c.send(ep.retire_document(domain, document_id, body))
1764
+
1765
+ def purge(
1766
+ self, domain: str, document_id: str, *, reason: str, confirm: str
1767
+ ) -> m.DocumentPurged:
1768
+ """Irreversibly delete the content of a document; `confirm` is the document id."""
1769
+ body = m.PurgeIn(reason=reason, confirm=confirm)
1770
+ return self._c.send(ep.purge_document(domain, document_id, body))
1580
1771
 
1581
1772
  def get(self, domain: str, document_id: str) -> m.DocumentDetail:
1582
1773
  """A document with its extraction jobs."""