synapseone-sdk 0.7.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.
- synapsedx_sdk/__init__.py +11 -0
- synapseone_sdk/__init__.py +291 -0
- synapseone_sdk/_http.py +105 -0
- synapseone_sdk/ai.py +44 -0
- synapseone_sdk/base.py +744 -0
- synapseone_sdk/example.py +36 -0
- synapseone_sdk/faststore.py +358 -0
- synapseone_sdk/iam.py +314 -0
- synapseone_sdk/inference.py +74 -0
- synapseone_sdk/lds.py +188 -0
- synapseone_sdk/queue.py +233 -0
- synapseone_sdk/stream.py +167 -0
- synapseone_sdk/test_client.py +497 -0
- synapseone_sdk/test_services.py +806 -0
- synapseone_sdk/workflow.py +260 -0
- synapseone_sdk-0.7.0.dist-info/METADATA +141 -0
- synapseone_sdk-0.7.0.dist-info/RECORD +20 -0
- synapseone_sdk-0.7.0.dist-info/WHEEL +5 -0
- synapseone_sdk-0.7.0.dist-info/licenses/LICENSE +199 -0
- synapseone_sdk-0.7.0.dist-info/top_level.txt +2 -0
synapseone_sdk/base.py
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
# Copyright 2026 SynapseOne Contributors
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
"""SynapseBase client: collections, documents, queries, batch writes, atomic
|
|
4
|
+
counters, a change feed and typed object <-> data mapping.
|
|
5
|
+
|
|
6
|
+
The service stores raw JSON ``data``; a collection may declare a *mapping*
|
|
7
|
+
from logical field names to dot paths inside that data. Writes can send
|
|
8
|
+
``object`` (logical keys) and reads can ask ``as_view="object"`` — the service
|
|
9
|
+
converts in both directions. :class:`BaseCollection` is a small typed wrapper
|
|
10
|
+
that binds one collection to a dataclass/model so application code works with
|
|
11
|
+
objects instead of JSON.
|
|
12
|
+
|
|
13
|
+
Access the service through ``SynapseOneClient(...).base`` or build it directly
|
|
14
|
+
with a :class:`~synapseone_sdk._http.HttpClient`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import quote, urlencode
|
|
23
|
+
|
|
24
|
+
from ._http import UNSET, HttpClient
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# mapping helpers (client-side mirror of the server's object<->data mapping)
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalize_mapping(mapping: Any | None) -> dict[str, dict]:
|
|
32
|
+
"""Normalize a base mapping to ``{logical: {"path", "type", "required"}}``.
|
|
33
|
+
|
|
34
|
+
Accepts the canonical ``{"fields": {...}}`` form as well as the shorthand
|
|
35
|
+
``{logical: "dot.path"}`` / ``{logical: {"path": ...}}`` maps.
|
|
36
|
+
"""
|
|
37
|
+
if not mapping:
|
|
38
|
+
return {}
|
|
39
|
+
fields = (
|
|
40
|
+
mapping.get("fields")
|
|
41
|
+
if isinstance(mapping, dict) and isinstance(mapping.get("fields"), dict)
|
|
42
|
+
else mapping
|
|
43
|
+
)
|
|
44
|
+
if not isinstance(fields, dict):
|
|
45
|
+
return {}
|
|
46
|
+
out: dict[str, dict] = {}
|
|
47
|
+
for name, spec in fields.items():
|
|
48
|
+
if isinstance(spec, str):
|
|
49
|
+
out[str(name)] = {"path": spec}
|
|
50
|
+
elif isinstance(spec, dict):
|
|
51
|
+
out[str(name)] = dict(spec)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get_path(data: Any, path: str) -> Any:
|
|
56
|
+
cur = data
|
|
57
|
+
for key in str(path).split("."):
|
|
58
|
+
if not isinstance(cur, dict) or key not in cur:
|
|
59
|
+
return UNSET
|
|
60
|
+
cur = cur[key]
|
|
61
|
+
return cur
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _set_path(target: dict, path: str, value: Any) -> None:
|
|
65
|
+
segments = str(path).split(".")
|
|
66
|
+
cur = target
|
|
67
|
+
for key in segments[:-1]:
|
|
68
|
+
if not isinstance(cur.get(key), dict):
|
|
69
|
+
cur[key] = {}
|
|
70
|
+
cur = cur[key]
|
|
71
|
+
cur[segments[-1]] = value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def data_to_object(data: dict | None, mapping: Any | None) -> dict:
|
|
75
|
+
"""Project stored ``data`` onto logical field names (empty mapping = copy)."""
|
|
76
|
+
fields = normalize_mapping(mapping)
|
|
77
|
+
if not fields:
|
|
78
|
+
return dict(data or {})
|
|
79
|
+
out: dict = {}
|
|
80
|
+
for name, spec in fields.items():
|
|
81
|
+
value = _get_path(data or {}, spec.get("path", ""))
|
|
82
|
+
if value is not UNSET:
|
|
83
|
+
out[name] = value
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def object_to_data(obj: dict | None, mapping: Any | None) -> dict:
|
|
88
|
+
"""Project a logical object onto stored ``data`` (empty mapping = copy)."""
|
|
89
|
+
fields = normalize_mapping(mapping)
|
|
90
|
+
out: dict = {}
|
|
91
|
+
for key, value in dict(obj or {}).items():
|
|
92
|
+
spec = fields.get(key)
|
|
93
|
+
_set_path(out, spec["path"] if spec else key, value)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def model_to_object(instance: Any) -> dict:
|
|
98
|
+
"""Convert a model instance (dataclass/class/dict) to a logical object."""
|
|
99
|
+
if instance is None:
|
|
100
|
+
raise ValueError("instance is required")
|
|
101
|
+
if isinstance(instance, dict):
|
|
102
|
+
return dict(instance)
|
|
103
|
+
if dataclasses.is_dataclass(instance) and not isinstance(instance, type):
|
|
104
|
+
return {
|
|
105
|
+
f.name: getattr(instance, f.name)
|
|
106
|
+
for f in dataclasses.fields(instance)
|
|
107
|
+
if getattr(instance, f.name) is not None
|
|
108
|
+
}
|
|
109
|
+
if hasattr(instance, "__dict__"):
|
|
110
|
+
return {
|
|
111
|
+
k: v
|
|
112
|
+
for k, v in vars(instance).items()
|
|
113
|
+
if not k.startswith("_") and v is not None
|
|
114
|
+
}
|
|
115
|
+
raise TypeError("model instances must be dataclasses, plain classes or dicts")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def object_to_model(obj: dict | None, model: type | None) -> Any:
|
|
119
|
+
"""Build a model instance from a logical object (None model = pass through)."""
|
|
120
|
+
data = dict(obj or {})
|
|
121
|
+
if model is None:
|
|
122
|
+
return data
|
|
123
|
+
if dataclasses.is_dataclass(model):
|
|
124
|
+
kwargs: dict = {}
|
|
125
|
+
for f in dataclasses.fields(model):
|
|
126
|
+
if f.name in data:
|
|
127
|
+
kwargs[f.name] = data[f.name]
|
|
128
|
+
return model(**kwargs)
|
|
129
|
+
return model(**data)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# service
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class QueryResult:
|
|
139
|
+
"""Result of :meth:`BaseService.query_documents` / :meth:`BaseCollection.query`."""
|
|
140
|
+
|
|
141
|
+
items: list
|
|
142
|
+
total: int
|
|
143
|
+
limit: int
|
|
144
|
+
offset: int
|
|
145
|
+
next_cursor: str | None = None
|
|
146
|
+
|
|
147
|
+
def __len__(self) -> int:
|
|
148
|
+
return len(self.items)
|
|
149
|
+
|
|
150
|
+
def __iter__(self):
|
|
151
|
+
return iter(self.items)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class VectorSearchResult:
|
|
156
|
+
"""Result of :meth:`BaseService.vector_search` / :meth:`BaseCollection.vector_search`."""
|
|
157
|
+
|
|
158
|
+
items: list
|
|
159
|
+
total: int
|
|
160
|
+
limit: int
|
|
161
|
+
metric: str
|
|
162
|
+
|
|
163
|
+
def __len__(self) -> int:
|
|
164
|
+
return len(self.items)
|
|
165
|
+
|
|
166
|
+
def __iter__(self):
|
|
167
|
+
return iter(self.items)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class BaseService:
|
|
171
|
+
"""Typed methods for every ``/v1/base`` endpoint exposed by the gateway.
|
|
172
|
+
|
|
173
|
+
The API accepts raw ``data`` (the stored JSON document) or logical
|
|
174
|
+
``object`` payloads; pass exactly one of the two on writes.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, http: HttpClient) -> None:
|
|
178
|
+
self._http = http
|
|
179
|
+
|
|
180
|
+
def _req(self, method: str, path: str, body: Any | None = None) -> Any:
|
|
181
|
+
return self._http.request(method, path, body)
|
|
182
|
+
|
|
183
|
+
def collection(
|
|
184
|
+
self, name: str, *, model: type | None = None, mapping: Any | None = None
|
|
185
|
+
) -> BaseCollection:
|
|
186
|
+
"""Bind a collection name (plus optional model/mapping) for typed use."""
|
|
187
|
+
return BaseCollection(self, name, model=model, mapping=mapping)
|
|
188
|
+
|
|
189
|
+
# ---- collections ----
|
|
190
|
+
def list_collections(
|
|
191
|
+
self, org_id: str | None = None, account_id: str | None = None
|
|
192
|
+
) -> list:
|
|
193
|
+
"""List collections, optionally filtered by org and/or account."""
|
|
194
|
+
params: dict[str, Any] = {}
|
|
195
|
+
if org_id:
|
|
196
|
+
params["orgId"] = org_id
|
|
197
|
+
if account_id:
|
|
198
|
+
params["accountId"] = account_id
|
|
199
|
+
qs = f"?{urlencode(params)}" if params else ""
|
|
200
|
+
return self._req("GET", f"/v1/base/collections{qs}")
|
|
201
|
+
|
|
202
|
+
def create_collection(
|
|
203
|
+
self,
|
|
204
|
+
name: str,
|
|
205
|
+
description: str | None = None,
|
|
206
|
+
mapping: Any | None = None,
|
|
207
|
+
org_id: str | None = None,
|
|
208
|
+
account_id: str | None = None,
|
|
209
|
+
) -> dict:
|
|
210
|
+
"""Create a collection, optionally with an object<->data mapping."""
|
|
211
|
+
body: dict = {"name": name}
|
|
212
|
+
if description is not None:
|
|
213
|
+
body["description"] = description
|
|
214
|
+
if mapping is not None:
|
|
215
|
+
body["mapping"] = mapping
|
|
216
|
+
if org_id:
|
|
217
|
+
body["orgId"] = org_id
|
|
218
|
+
if account_id:
|
|
219
|
+
body["accountId"] = account_id
|
|
220
|
+
return self._req("POST", "/v1/base/collections", body)
|
|
221
|
+
|
|
222
|
+
def get_collection(self, collection: str) -> dict:
|
|
223
|
+
"""Fetch a collection plus document stats."""
|
|
224
|
+
return self._req("GET", f"/v1/base/collections/{quote(collection, safe='')}")
|
|
225
|
+
|
|
226
|
+
def update_collection(
|
|
227
|
+
self,
|
|
228
|
+
collection: str,
|
|
229
|
+
name: str | None = None,
|
|
230
|
+
description: Any = UNSET,
|
|
231
|
+
mapping: Any = UNSET,
|
|
232
|
+
) -> dict:
|
|
233
|
+
"""Patch a collection's name, description and/or mapping.
|
|
234
|
+
|
|
235
|
+
``description=None`` clears the description; omit it to keep the value.
|
|
236
|
+
"""
|
|
237
|
+
patch: dict = {}
|
|
238
|
+
if name is not None:
|
|
239
|
+
patch["name"] = name
|
|
240
|
+
if description is not UNSET:
|
|
241
|
+
patch["description"] = description
|
|
242
|
+
if mapping is not UNSET:
|
|
243
|
+
patch["mapping"] = mapping
|
|
244
|
+
return self._req(
|
|
245
|
+
"PATCH", f"/v1/base/collections/{quote(collection, safe='')}", patch
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def delete_collection(self, collection: str) -> dict:
|
|
249
|
+
"""Delete a collection and all its documents."""
|
|
250
|
+
return self._req("DELETE", f"/v1/base/collections/{quote(collection, safe='')}")
|
|
251
|
+
|
|
252
|
+
def collection_stats(self, collection: str) -> dict:
|
|
253
|
+
"""Fetch a collection's document stats."""
|
|
254
|
+
return self._req(
|
|
255
|
+
"GET", f"/v1/base/collections/{quote(collection, safe='')}/stats"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# ---- documents ----
|
|
259
|
+
def create_document(
|
|
260
|
+
self,
|
|
261
|
+
collection: str,
|
|
262
|
+
data: Any = UNSET,
|
|
263
|
+
*,
|
|
264
|
+
object: Any = UNSET,
|
|
265
|
+
id: str | None = None,
|
|
266
|
+
) -> dict:
|
|
267
|
+
"""Create a document from raw ``data`` or a logical ``object``.
|
|
268
|
+
|
|
269
|
+
Pass exactly one of ``data`` (stored JSON) and ``object`` (mapped
|
|
270
|
+
logical keys); the server validates the payload against the mapping.
|
|
271
|
+
"""
|
|
272
|
+
body = _write_body(data, object)
|
|
273
|
+
if id is not None:
|
|
274
|
+
body["id"] = id
|
|
275
|
+
return self._req(
|
|
276
|
+
"POST", f"/v1/base/collections/{quote(collection, safe='')}/documents", body
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def get_document(self, collection: str, doc_id: str, as_view: str = "data") -> dict:
|
|
280
|
+
"""Fetch one document; ``as_view`` is 'data' or 'object'."""
|
|
281
|
+
qs = f"?as={quote(as_view, safe='')}" if as_view != "data" else ""
|
|
282
|
+
return self._req(
|
|
283
|
+
"GET",
|
|
284
|
+
f"/v1/base/collections/{quote(collection, safe='')}/documents/"
|
|
285
|
+
f"{quote(doc_id, safe='')}{qs}",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def set_document(
|
|
289
|
+
self,
|
|
290
|
+
collection: str,
|
|
291
|
+
doc_id: str,
|
|
292
|
+
data: Any = UNSET,
|
|
293
|
+
*,
|
|
294
|
+
object: Any = UNSET,
|
|
295
|
+
if_version: int | None = None,
|
|
296
|
+
merge: bool = False,
|
|
297
|
+
) -> dict:
|
|
298
|
+
"""Create-or-replace a document (PUT).
|
|
299
|
+
|
|
300
|
+
``merge=True`` deep-merges ``data`` into the existing document instead
|
|
301
|
+
of replacing it; ``if_version`` enables optimistic concurrency.
|
|
302
|
+
"""
|
|
303
|
+
body = _write_body(data, object)
|
|
304
|
+
if if_version is not None:
|
|
305
|
+
body["ifVersion"] = if_version
|
|
306
|
+
if merge:
|
|
307
|
+
body["merge"] = True
|
|
308
|
+
return self._req(
|
|
309
|
+
"PUT",
|
|
310
|
+
f"/v1/base/collections/{quote(collection, safe='')}/documents/"
|
|
311
|
+
f"{quote(doc_id, safe='')}",
|
|
312
|
+
body,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def update_document(
|
|
316
|
+
self,
|
|
317
|
+
collection: str,
|
|
318
|
+
doc_id: str,
|
|
319
|
+
data: Any = UNSET,
|
|
320
|
+
*,
|
|
321
|
+
object: Any = UNSET,
|
|
322
|
+
unset: list | None = None,
|
|
323
|
+
if_version: int | None = None,
|
|
324
|
+
) -> dict:
|
|
325
|
+
"""Patch a document (PATCH): deep-merge ``data``/``object`` and/or
|
|
326
|
+
remove dot paths listed in ``unset``."""
|
|
327
|
+
if data is UNSET and object is UNSET and not unset:
|
|
328
|
+
raise ValueError("provide data/object and/or unset")
|
|
329
|
+
body: dict = {}
|
|
330
|
+
if data is not UNSET or object is not UNSET:
|
|
331
|
+
body.update(_write_body(data, object))
|
|
332
|
+
if unset:
|
|
333
|
+
body["unset"] = list(unset)
|
|
334
|
+
if if_version is not None:
|
|
335
|
+
body["ifVersion"] = if_version
|
|
336
|
+
return self._req(
|
|
337
|
+
"PATCH",
|
|
338
|
+
f"/v1/base/collections/{quote(collection, safe='')}/documents/"
|
|
339
|
+
f"{quote(doc_id, safe='')}",
|
|
340
|
+
body,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def delete_document(
|
|
344
|
+
self, collection: str, doc_id: str, if_version: int | None = None
|
|
345
|
+
) -> dict:
|
|
346
|
+
"""Delete a document, optionally guarded by ``if_version``."""
|
|
347
|
+
qs = f"?ifVersion={int(if_version)}" if if_version is not None else ""
|
|
348
|
+
return self._req(
|
|
349
|
+
"DELETE",
|
|
350
|
+
f"/v1/base/collections/{quote(collection, safe='')}/documents/"
|
|
351
|
+
f"{quote(doc_id, safe='')}{qs}",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# ---- query ----
|
|
355
|
+
def query_documents(
|
|
356
|
+
self,
|
|
357
|
+
collection: str,
|
|
358
|
+
where: Any = None,
|
|
359
|
+
*,
|
|
360
|
+
order_by: Any = None,
|
|
361
|
+
limit: int | None = None,
|
|
362
|
+
offset: int | None = None,
|
|
363
|
+
cursor: str | None = None,
|
|
364
|
+
select: list | None = None,
|
|
365
|
+
as_view: str = "data",
|
|
366
|
+
) -> QueryResult:
|
|
367
|
+
"""Run a query: filter tree, ordering, cursor/offset pagination and
|
|
368
|
+
projection.
|
|
369
|
+
|
|
370
|
+
``where`` accepts leaf ``{field, op, value}`` nodes, ``and``/``or``/
|
|
371
|
+
``not`` groups, arrays (implicit ``and``) and the shorthand
|
|
372
|
+
``{"status": "active", "age": {"gte": 18}}``. Metadata fields
|
|
373
|
+
``$id/$version/$createdAt/$updatedAt`` and mapping logical names are
|
|
374
|
+
resolved by the service.
|
|
375
|
+
"""
|
|
376
|
+
body: dict = {"as": as_view}
|
|
377
|
+
if where is not None:
|
|
378
|
+
body["where"] = where
|
|
379
|
+
if order_by is not None:
|
|
380
|
+
body["orderBy"] = order_by
|
|
381
|
+
if limit is not None:
|
|
382
|
+
body["limit"] = limit
|
|
383
|
+
if offset is not None:
|
|
384
|
+
body["offset"] = offset
|
|
385
|
+
if cursor is not None:
|
|
386
|
+
body["cursor"] = cursor
|
|
387
|
+
if select is not None:
|
|
388
|
+
body["select"] = select
|
|
389
|
+
raw = self._req(
|
|
390
|
+
"POST", f"/v1/base/collections/{quote(collection, safe='')}/query", body
|
|
391
|
+
)
|
|
392
|
+
return QueryResult(
|
|
393
|
+
items=list(raw.get("items", [])),
|
|
394
|
+
total=int(raw.get("total", 0)),
|
|
395
|
+
limit=int(raw.get("limit", 0)),
|
|
396
|
+
offset=int(raw.get("offset", 0)),
|
|
397
|
+
next_cursor=raw.get("nextCursor"),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def vector_search(
|
|
401
|
+
self,
|
|
402
|
+
collection: str,
|
|
403
|
+
vector: list[float],
|
|
404
|
+
*,
|
|
405
|
+
limit: int | None = None,
|
|
406
|
+
k: int | None = None,
|
|
407
|
+
field: str | None = None,
|
|
408
|
+
metric: str | None = None,
|
|
409
|
+
where: Any = None,
|
|
410
|
+
filter: Any = None,
|
|
411
|
+
select: list | None = None,
|
|
412
|
+
as_view: str = "data",
|
|
413
|
+
) -> VectorSearchResult:
|
|
414
|
+
"""Run vector similarity search with optional metadata filtering."""
|
|
415
|
+
body: dict = {"vector": vector, "as": as_view}
|
|
416
|
+
effective_limit = limit if limit is not None else k
|
|
417
|
+
if effective_limit is not None:
|
|
418
|
+
body["limit"] = effective_limit
|
|
419
|
+
if field is not None:
|
|
420
|
+
body["field"] = field
|
|
421
|
+
if metric is not None:
|
|
422
|
+
body["metric"] = metric
|
|
423
|
+
effective_where = where if where is not None else filter
|
|
424
|
+
if effective_where is not None:
|
|
425
|
+
body["where"] = effective_where
|
|
426
|
+
if select is not None:
|
|
427
|
+
body["select"] = select
|
|
428
|
+
raw = self._req(
|
|
429
|
+
"POST",
|
|
430
|
+
f"/v1/base/collections/{quote(collection, safe='')}/vector-search",
|
|
431
|
+
body,
|
|
432
|
+
)
|
|
433
|
+
return VectorSearchResult(
|
|
434
|
+
items=list(raw.get("items", [])),
|
|
435
|
+
total=int(raw.get("total", 0)),
|
|
436
|
+
limit=int(raw.get("limit", effective_limit or 10)),
|
|
437
|
+
metric=str(raw.get("metric", "cosine")),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def list_documents(
|
|
441
|
+
self, collection: str, limit: int | None = None, offset: int | None = None
|
|
442
|
+
) -> list:
|
|
443
|
+
"""Page through a collection's documents in id order."""
|
|
444
|
+
params: dict[str, Any] = {}
|
|
445
|
+
if limit is not None:
|
|
446
|
+
params["limit"] = limit
|
|
447
|
+
if offset is not None:
|
|
448
|
+
params["offset"] = offset
|
|
449
|
+
qs = f"?{urlencode(params)}" if params else ""
|
|
450
|
+
return self._req(
|
|
451
|
+
"GET",
|
|
452
|
+
f"/v1/base/collections/{quote(collection, safe='')}/documents{qs}",
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
def increment(
|
|
456
|
+
self,
|
|
457
|
+
collection: str,
|
|
458
|
+
doc_id: str,
|
|
459
|
+
field: str,
|
|
460
|
+
by: float = 1,
|
|
461
|
+
if_version: int | None = None,
|
|
462
|
+
) -> dict:
|
|
463
|
+
"""Atomically add ``by`` to a numeric dot-path field."""
|
|
464
|
+
body: dict = {"field": field, "by": by}
|
|
465
|
+
if if_version is not None:
|
|
466
|
+
body["ifVersion"] = if_version
|
|
467
|
+
return self._req(
|
|
468
|
+
"POST",
|
|
469
|
+
f"/v1/base/collections/{quote(collection, safe='')}/documents/"
|
|
470
|
+
f"{quote(doc_id, safe='')}/increment",
|
|
471
|
+
body,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def batch(self, collection: str, operations: list) -> dict:
|
|
475
|
+
"""Run 1..100 create/set/update/delete/increment ops in one call.
|
|
476
|
+
|
|
477
|
+
Each operation is ``{"op": ..., "id"?, "data"|"object"?, ...}``; the
|
|
478
|
+
response reports per-op success and never fails the whole batch on a
|
|
479
|
+
single bad op.
|
|
480
|
+
"""
|
|
481
|
+
return self._req(
|
|
482
|
+
"POST",
|
|
483
|
+
f"/v1/base/collections/{quote(collection, safe='')}/documents:batch",
|
|
484
|
+
{"operations": operations},
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def changes(
|
|
488
|
+
self,
|
|
489
|
+
collection: str,
|
|
490
|
+
since: str,
|
|
491
|
+
limit: int | None = None,
|
|
492
|
+
as_view: str = "data",
|
|
493
|
+
) -> dict:
|
|
494
|
+
"""Poll the change feed for documents updated since an ISO timestamp.
|
|
495
|
+
|
|
496
|
+
Returns ``{changes, nextSince, hasMore}``; pass ``nextSince`` back in
|
|
497
|
+
on the next call. Deletes are not emitted.
|
|
498
|
+
"""
|
|
499
|
+
params: dict[str, Any] = {"since": since}
|
|
500
|
+
if limit is not None:
|
|
501
|
+
params["limit"] = limit
|
|
502
|
+
if as_view != "data":
|
|
503
|
+
params["as"] = as_view
|
|
504
|
+
return self._req(
|
|
505
|
+
"GET",
|
|
506
|
+
f"/v1/base/collections/{quote(collection, safe='')}/changes?"
|
|
507
|
+
f"{urlencode(params)}",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _write_body(data: Any, object: Any) -> dict:
|
|
512
|
+
has_data = data is not UNSET
|
|
513
|
+
has_object = object is not UNSET
|
|
514
|
+
if has_data and has_object:
|
|
515
|
+
raise ValueError("provide exactly one of data or object")
|
|
516
|
+
if not has_data and not has_object:
|
|
517
|
+
raise ValueError("provide exactly one of data or object")
|
|
518
|
+
return {"data": data} if has_data else {"object": object}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
# typed collection
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
class BaseCollection:
|
|
527
|
+
"""A collection bound to a service, optionally with a model + mapping.
|
|
528
|
+
|
|
529
|
+
``model`` is any dataclass (or plain class) whose attribute names match the
|
|
530
|
+
mapping's logical field names. ``mapping`` mirrors the server-side mapping
|
|
531
|
+
(``{logical: "dot.path"}`` or ``{logical: {path, type, required}}``) and is
|
|
532
|
+
used for endpoints that return raw ``data`` (list/increment/changes).
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
def __init__(
|
|
536
|
+
self,
|
|
537
|
+
service: BaseService,
|
|
538
|
+
name: str,
|
|
539
|
+
*,
|
|
540
|
+
model: type | None = None,
|
|
541
|
+
mapping: Any | None = None,
|
|
542
|
+
) -> None:
|
|
543
|
+
self.service = service
|
|
544
|
+
self.name = name
|
|
545
|
+
self.model = model
|
|
546
|
+
self.mapping = normalize_mapping(mapping)
|
|
547
|
+
|
|
548
|
+
# -- model conversion --
|
|
549
|
+
def to_object(self, instance: Any) -> dict:
|
|
550
|
+
"""Convert a model instance (or dict) to a logical object payload."""
|
|
551
|
+
return model_to_object(instance)
|
|
552
|
+
|
|
553
|
+
def from_object(self, obj: dict | None) -> Any:
|
|
554
|
+
"""Convert a logical object response into a model instance."""
|
|
555
|
+
return object_to_model(obj, self.model)
|
|
556
|
+
|
|
557
|
+
def from_data(self, data: dict | None) -> Any:
|
|
558
|
+
"""Convert raw stored ``data`` into a model instance."""
|
|
559
|
+
return object_to_model(data_to_object(data, self.mapping), self.model)
|
|
560
|
+
|
|
561
|
+
def _doc_result(self, doc: dict) -> Any:
|
|
562
|
+
"""Convert a write response envelope into a model (falls back to raw)."""
|
|
563
|
+
if "object" in doc:
|
|
564
|
+
return self.from_object(doc.get("object"))
|
|
565
|
+
if "data" in doc:
|
|
566
|
+
return self.from_data(doc.get("data"))
|
|
567
|
+
return doc
|
|
568
|
+
|
|
569
|
+
# -- documents --
|
|
570
|
+
def create(
|
|
571
|
+
self,
|
|
572
|
+
instance: Any = UNSET,
|
|
573
|
+
*,
|
|
574
|
+
id: str | None = None,
|
|
575
|
+
data: Any = UNSET,
|
|
576
|
+
object: Any = UNSET,
|
|
577
|
+
) -> Any:
|
|
578
|
+
"""Create a document from a model instance, logical object or raw data."""
|
|
579
|
+
if instance is not UNSET:
|
|
580
|
+
object = self.to_object(instance)
|
|
581
|
+
doc = self.service.create_document(self.name, data, object=object, id=id)
|
|
582
|
+
return self._doc_result(doc)
|
|
583
|
+
|
|
584
|
+
def get(self, doc_id: str) -> Any:
|
|
585
|
+
"""Fetch one document as a model instance."""
|
|
586
|
+
doc = self.service.get_document(self.name, doc_id, as_view="object")
|
|
587
|
+
return self.from_object(doc.get("object"))
|
|
588
|
+
|
|
589
|
+
def get_document(self, doc_id: str, as_view: str = "data") -> dict:
|
|
590
|
+
"""Fetch the raw document envelope (id/version/timestamps + data|object)."""
|
|
591
|
+
return self.service.get_document(self.name, doc_id, as_view=as_view)
|
|
592
|
+
|
|
593
|
+
def list(self, limit: int | None = None, offset: int | None = None) -> list:
|
|
594
|
+
"""Page through documents, converted to models via the mapping."""
|
|
595
|
+
return [
|
|
596
|
+
self.from_data(doc.get("data"))
|
|
597
|
+
for doc in self.service.list_documents(
|
|
598
|
+
self.name, limit=limit, offset=offset
|
|
599
|
+
)
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
def set(
|
|
603
|
+
self,
|
|
604
|
+
doc_id: str,
|
|
605
|
+
instance: Any = UNSET,
|
|
606
|
+
*,
|
|
607
|
+
data: Any = UNSET,
|
|
608
|
+
object: Any = UNSET,
|
|
609
|
+
if_version: int | None = None,
|
|
610
|
+
merge: bool = False,
|
|
611
|
+
) -> Any:
|
|
612
|
+
"""Create-or-replace a document from a model instance/object/data."""
|
|
613
|
+
if instance is not UNSET:
|
|
614
|
+
object = self.to_object(instance)
|
|
615
|
+
doc = self.service.set_document(
|
|
616
|
+
self.name, doc_id, data, object=object, if_version=if_version, merge=merge
|
|
617
|
+
)
|
|
618
|
+
return self._doc_result(doc)
|
|
619
|
+
|
|
620
|
+
def update(
|
|
621
|
+
self,
|
|
622
|
+
doc_id: str,
|
|
623
|
+
instance: Any = UNSET,
|
|
624
|
+
*,
|
|
625
|
+
data: Any = UNSET,
|
|
626
|
+
object: Any = UNSET,
|
|
627
|
+
unset: list | None = None,
|
|
628
|
+
if_version: int | None = None,
|
|
629
|
+
) -> Any:
|
|
630
|
+
"""Patch a document (deep-merge instance/object/data and/or unset paths)."""
|
|
631
|
+
if instance is not UNSET:
|
|
632
|
+
object = self.to_object(instance)
|
|
633
|
+
doc = self.service.update_document(
|
|
634
|
+
self.name, doc_id, data, object=object, unset=unset, if_version=if_version
|
|
635
|
+
)
|
|
636
|
+
return self._doc_result(doc)
|
|
637
|
+
|
|
638
|
+
def delete(self, doc_id: str, if_version: int | None = None) -> dict:
|
|
639
|
+
"""Delete a document, optionally guarded by ``if_version``."""
|
|
640
|
+
return self.service.delete_document(self.name, doc_id, if_version=if_version)
|
|
641
|
+
|
|
642
|
+
def increment(
|
|
643
|
+
self,
|
|
644
|
+
doc_id: str,
|
|
645
|
+
field: str,
|
|
646
|
+
by: float = 1,
|
|
647
|
+
if_version: int | None = None,
|
|
648
|
+
) -> Any:
|
|
649
|
+
"""Atomically increment a numeric field, returning the updated model."""
|
|
650
|
+
doc = self.service.increment(
|
|
651
|
+
self.name, doc_id, field, by, if_version=if_version
|
|
652
|
+
)
|
|
653
|
+
return self.from_data(doc.get("data"))
|
|
654
|
+
|
|
655
|
+
def query(
|
|
656
|
+
self,
|
|
657
|
+
where: Any = None,
|
|
658
|
+
*,
|
|
659
|
+
order_by: Any = None,
|
|
660
|
+
limit: int | None = None,
|
|
661
|
+
offset: int | None = None,
|
|
662
|
+
cursor: str | None = None,
|
|
663
|
+
select: list | None = None,
|
|
664
|
+
) -> QueryResult:
|
|
665
|
+
"""Query the collection and convert the page to model instances."""
|
|
666
|
+
result = self.service.query_documents(
|
|
667
|
+
self.name,
|
|
668
|
+
where,
|
|
669
|
+
order_by=order_by,
|
|
670
|
+
limit=limit,
|
|
671
|
+
offset=offset,
|
|
672
|
+
cursor=cursor,
|
|
673
|
+
select=select,
|
|
674
|
+
as_view="object",
|
|
675
|
+
)
|
|
676
|
+
result.items = [self.from_object(item.get("object")) for item in result.items]
|
|
677
|
+
return result
|
|
678
|
+
|
|
679
|
+
def vector_search(
|
|
680
|
+
self,
|
|
681
|
+
vector: list[float],
|
|
682
|
+
*,
|
|
683
|
+
limit: int | None = None,
|
|
684
|
+
k: int | None = None,
|
|
685
|
+
field: str | None = None,
|
|
686
|
+
metric: str | None = None,
|
|
687
|
+
where: Any = None,
|
|
688
|
+
filter: Any = None,
|
|
689
|
+
select: list | None = None,
|
|
690
|
+
) -> VectorSearchResult:
|
|
691
|
+
"""Run vector similarity search; converts result object payloads to models."""
|
|
692
|
+
raw = self.service.vector_search(
|
|
693
|
+
self.name,
|
|
694
|
+
vector,
|
|
695
|
+
limit=limit,
|
|
696
|
+
k=k,
|
|
697
|
+
field=field,
|
|
698
|
+
metric=metric,
|
|
699
|
+
where=where,
|
|
700
|
+
filter=filter,
|
|
701
|
+
select=select,
|
|
702
|
+
as_view="object",
|
|
703
|
+
)
|
|
704
|
+
items = []
|
|
705
|
+
for item in raw.items:
|
|
706
|
+
it = dict(item)
|
|
707
|
+
if "object" in it:
|
|
708
|
+
it["object"] = self.from_object(it["object"])
|
|
709
|
+
items.append(it)
|
|
710
|
+
return VectorSearchResult(
|
|
711
|
+
items=items,
|
|
712
|
+
total=raw.total,
|
|
713
|
+
limit=raw.limit,
|
|
714
|
+
metric=raw.metric,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def changes(self, since: str, limit: int | None = None) -> dict:
|
|
718
|
+
"""Poll the change feed and convert entries to model instances."""
|
|
719
|
+
raw = self.service.changes(self.name, since, limit=limit, as_view="object")
|
|
720
|
+
raw["changes"] = [
|
|
721
|
+
{**ch, "document": self.from_object(ch.get("object"))}
|
|
722
|
+
for ch in raw.get("changes", [])
|
|
723
|
+
]
|
|
724
|
+
return raw
|
|
725
|
+
|
|
726
|
+
def batch(self, operations: list) -> dict:
|
|
727
|
+
"""Run a batch of create/set/update/delete/increment operations."""
|
|
728
|
+
return self.service.batch(self.name, operations)
|
|
729
|
+
|
|
730
|
+
def stats(self) -> dict:
|
|
731
|
+
"""Fetch this collection's document stats."""
|
|
732
|
+
return self.service.collection_stats(self.name)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
__all__ = [
|
|
736
|
+
"BaseCollection",
|
|
737
|
+
"BaseService",
|
|
738
|
+
"QueryResult",
|
|
739
|
+
"data_to_object",
|
|
740
|
+
"model_to_object",
|
|
741
|
+
"normalize_mapping",
|
|
742
|
+
"object_to_data",
|
|
743
|
+
"object_to_model",
|
|
744
|
+
]
|