torque-http 0.5.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.
- torque_http/__init__.py +121 -0
- torque_http/client.py +1471 -0
- torque_http/exceptions.py +96 -0
- torque_http/models.py +302 -0
- torque_http/py.typed +0 -0
- torque_http/tcp.py +575 -0
- torque_http-0.5.0.dist-info/METADATA +132 -0
- torque_http-0.5.0.dist-info/RECORD +10 -0
- torque_http-0.5.0.dist-info/WHEEL +4 -0
- torque_http-0.5.0.dist-info/licenses/LICENSE +21 -0
torque_http/client.py
ADDED
|
@@ -0,0 +1,1471 @@
|
|
|
1
|
+
"""Torque Python HTTP client — zero external dependencies.
|
|
2
|
+
|
|
3
|
+
Uses only Python 3.13+ stdlib: http.client, json, ssl, threading.
|
|
4
|
+
Features: connection pooling with keep-alive, multi-node round-robin,
|
|
5
|
+
configurable retry with exponential backoff, typed error hierarchy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import http.client
|
|
14
|
+
import json
|
|
15
|
+
import ssl
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Iterator
|
|
20
|
+
from urllib.parse import quote, urlencode, urlparse
|
|
21
|
+
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
RequestTimeout,
|
|
24
|
+
TorqueApiError,
|
|
25
|
+
TorqueApiException,
|
|
26
|
+
TorqueError,
|
|
27
|
+
raise_for_status,
|
|
28
|
+
)
|
|
29
|
+
from .models import (
|
|
30
|
+
AdminStats,
|
|
31
|
+
ApiKey,
|
|
32
|
+
CollectionAlias,
|
|
33
|
+
CollectionResponse,
|
|
34
|
+
CollectionSchema,
|
|
35
|
+
DeleteResult,
|
|
36
|
+
FacetCount,
|
|
37
|
+
FacetResult,
|
|
38
|
+
GroupedHit,
|
|
39
|
+
GroupedSearchResult,
|
|
40
|
+
HealthResponse,
|
|
41
|
+
Hit,
|
|
42
|
+
ImportResult,
|
|
43
|
+
Preset,
|
|
44
|
+
SearchParameters,
|
|
45
|
+
SearchResult,
|
|
46
|
+
StopwordSet,
|
|
47
|
+
SynonymItem,
|
|
48
|
+
SynonymSet,
|
|
49
|
+
UpdateResult,
|
|
50
|
+
)
|
|
51
|
+
from .tcp import DocumentEncoder, TorqueBinaryFileWriter
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Node health tracking ─────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Node:
|
|
58
|
+
"""A single server node with health tracking."""
|
|
59
|
+
host: str
|
|
60
|
+
port: int
|
|
61
|
+
scheme: str = "http"
|
|
62
|
+
path_prefix: str = ""
|
|
63
|
+
_healthy: bool = True
|
|
64
|
+
_last_failure: float = 0.0
|
|
65
|
+
|
|
66
|
+
def mark_unhealthy(self) -> None:
|
|
67
|
+
self._healthy = False
|
|
68
|
+
self._last_failure = time.monotonic()
|
|
69
|
+
|
|
70
|
+
def is_healthy(self, healthcheck_interval: float) -> bool:
|
|
71
|
+
if self._healthy:
|
|
72
|
+
return True
|
|
73
|
+
if time.monotonic() - self._last_failure >= healthcheck_interval:
|
|
74
|
+
self._healthy = True
|
|
75
|
+
return True
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Connection pool ──────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
class _ConnectionPool:
|
|
82
|
+
"""Thread-safe pool of persistent HTTP(S) connections per node.
|
|
83
|
+
|
|
84
|
+
Each node gets a pool of keep-alive connections. Connections are
|
|
85
|
+
reused across requests to avoid TCP handshake overhead.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
max_connections_per_node: int = 8,
|
|
92
|
+
timeout: float = 30.0,
|
|
93
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
self._max = max_connections_per_node
|
|
96
|
+
self._timeout = timeout
|
|
97
|
+
self._ssl_context = ssl_context
|
|
98
|
+
self._lock = threading.Lock()
|
|
99
|
+
# Key: (scheme, host, port) -> list of idle connections
|
|
100
|
+
self._pools: dict[tuple[str, str, int], list[http.client.HTTPConnection]] = {}
|
|
101
|
+
|
|
102
|
+
def acquire(self, node: Node) -> http.client.HTTPConnection:
|
|
103
|
+
"""Get a connection from the pool, or create a new one.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
node: The target server node.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
An HTTPConnection (or HTTPSConnection) ready for use.
|
|
110
|
+
"""
|
|
111
|
+
key = (node.scheme, node.host, node.port)
|
|
112
|
+
with self._lock:
|
|
113
|
+
pool = self._pools.get(key, [])
|
|
114
|
+
if pool:
|
|
115
|
+
conn = pool.pop()
|
|
116
|
+
return conn
|
|
117
|
+
return self._create(node)
|
|
118
|
+
|
|
119
|
+
def release(self, node: Node, conn: http.client.HTTPConnection) -> None:
|
|
120
|
+
"""Return a connection to the pool for reuse.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
node: The node this connection belongs to.
|
|
124
|
+
conn: The connection to return.
|
|
125
|
+
"""
|
|
126
|
+
key = (node.scheme, node.host, node.port)
|
|
127
|
+
with self._lock:
|
|
128
|
+
pool = self._pools.setdefault(key, [])
|
|
129
|
+
if len(pool) < self._max:
|
|
130
|
+
pool.append(conn)
|
|
131
|
+
return
|
|
132
|
+
# Pool is full, close the connection
|
|
133
|
+
try:
|
|
134
|
+
conn.close()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def discard(self, conn: http.client.HTTPConnection) -> None:
|
|
139
|
+
"""Close and discard a broken connection.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
conn: The connection to discard.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
conn.close()
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def close_all(self) -> None:
|
|
150
|
+
"""Close all pooled connections."""
|
|
151
|
+
with self._lock:
|
|
152
|
+
for pool in self._pools.values():
|
|
153
|
+
for conn in pool:
|
|
154
|
+
try:
|
|
155
|
+
conn.close()
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
self._pools.clear()
|
|
159
|
+
|
|
160
|
+
def _create(self, node: Node) -> http.client.HTTPConnection:
|
|
161
|
+
if node.scheme == "https":
|
|
162
|
+
ctx = self._ssl_context or ssl.create_default_context()
|
|
163
|
+
conn = http.client.HTTPSConnection(
|
|
164
|
+
node.host, node.port, timeout=self._timeout, context=ctx,
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
conn = http.client.HTTPConnection(
|
|
168
|
+
node.host, node.port, timeout=self._timeout,
|
|
169
|
+
)
|
|
170
|
+
return conn
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── Client ───────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
class TorqueHttpClient:
|
|
176
|
+
"""Torque search engine HTTP client.
|
|
177
|
+
|
|
178
|
+
Supports the full Typesense v30.1 HTTP API plus Torque-specific
|
|
179
|
+
binary ingest and TQBF file upload. Zero external dependencies.
|
|
180
|
+
|
|
181
|
+
Features:
|
|
182
|
+
- Connection pooling with HTTP keep-alive (persistent connections)
|
|
183
|
+
- Multi-node support with round-robin load balancing
|
|
184
|
+
- Automatic retry with exponential backoff
|
|
185
|
+
- Typed exception hierarchy for precise error handling
|
|
186
|
+
- Scoped API key generation via HMAC
|
|
187
|
+
|
|
188
|
+
Usage::
|
|
189
|
+
|
|
190
|
+
# Single node
|
|
191
|
+
client = TorqueHttpClient("http://localhost:8108", api_key="your-key")
|
|
192
|
+
results = client.search("products", q="laptop", query_by="title")
|
|
193
|
+
|
|
194
|
+
# Multi-node with custom retry
|
|
195
|
+
client = TorqueHttpClient(
|
|
196
|
+
["http://node1:8108", "http://node2:8108", "http://node3:8108"],
|
|
197
|
+
api_key="your-key",
|
|
198
|
+
num_retries=5,
|
|
199
|
+
retry_interval=0.5,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# With typed search parameters
|
|
203
|
+
from torque_http import SearchParameters
|
|
204
|
+
params = SearchParameters(q="laptop", query_by="title", per_page=20)
|
|
205
|
+
results = client.search("products", **params)
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
url: str | list[str],
|
|
211
|
+
*,
|
|
212
|
+
api_key: str,
|
|
213
|
+
timeout: float = 30.0,
|
|
214
|
+
transfer_timeout: float = 300.0,
|
|
215
|
+
num_retries: int = 3,
|
|
216
|
+
retry_interval: float = 0.1,
|
|
217
|
+
healthcheck_interval: float = 60.0,
|
|
218
|
+
max_connections_per_node: int = 8,
|
|
219
|
+
allow_untrusted_certificate: bool = False,
|
|
220
|
+
send_api_key_as_query_param: bool = False,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Initialize the Torque client.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
url: Server URL or list of URLs for multi-node round-robin.
|
|
226
|
+
Accepts ``"http://host:port"`` or a list of such strings.
|
|
227
|
+
api_key: API key for authentication (X-TYPESENSE-API-KEY header).
|
|
228
|
+
timeout: Default request timeout in seconds.
|
|
229
|
+
transfer_timeout: Timeout for bulk transfer operations (import/export).
|
|
230
|
+
num_retries: Number of retry attempts on transient failures (default 3).
|
|
231
|
+
retry_interval: Base interval between retries in seconds. Actual delay
|
|
232
|
+
uses exponential backoff: ``retry_interval * 2^attempt``.
|
|
233
|
+
healthcheck_interval: Seconds before retrying an unhealthy node.
|
|
234
|
+
max_connections_per_node: Maximum pooled connections per node.
|
|
235
|
+
allow_untrusted_certificate: Skip TLS certificate verification.
|
|
236
|
+
send_api_key_as_query_param: When True, append the API key as
|
|
237
|
+
``x-typesense-api-key`` query parameter instead of using the
|
|
238
|
+
``X-TYPESENSE-API-KEY`` header.
|
|
239
|
+
"""
|
|
240
|
+
self._api_key = api_key
|
|
241
|
+
self._send_api_key_as_query_param = send_api_key_as_query_param
|
|
242
|
+
self._timeout = timeout
|
|
243
|
+
self._transfer_timeout = transfer_timeout
|
|
244
|
+
self._num_retries = num_retries
|
|
245
|
+
self._retry_interval = retry_interval
|
|
246
|
+
self._healthcheck_interval = healthcheck_interval
|
|
247
|
+
|
|
248
|
+
# SSL context
|
|
249
|
+
ssl_ctx: ssl.SSLContext | None = None
|
|
250
|
+
if allow_untrusted_certificate:
|
|
251
|
+
ssl_ctx = ssl.create_default_context()
|
|
252
|
+
ssl_ctx.check_hostname = False
|
|
253
|
+
ssl_ctx.verify_mode = ssl.CERT_NONE
|
|
254
|
+
|
|
255
|
+
# Parse node URLs
|
|
256
|
+
urls = [url] if isinstance(url, str) else url
|
|
257
|
+
if not urls:
|
|
258
|
+
raise ValueError("At least one server URL is required")
|
|
259
|
+
self._nodes: list[Node] = []
|
|
260
|
+
for u in urls:
|
|
261
|
+
parsed = urlparse(u)
|
|
262
|
+
scheme = parsed.scheme or "http"
|
|
263
|
+
host = parsed.hostname or "localhost"
|
|
264
|
+
port = parsed.port or (443 if scheme == "https" else 8108)
|
|
265
|
+
path_prefix = (parsed.path or "").rstrip("/")
|
|
266
|
+
self._nodes.append(Node(
|
|
267
|
+
host=host, port=port, scheme=scheme, path_prefix=path_prefix,
|
|
268
|
+
))
|
|
269
|
+
|
|
270
|
+
# Round-robin counter
|
|
271
|
+
self._node_index = 0
|
|
272
|
+
self._node_lock = threading.Lock()
|
|
273
|
+
|
|
274
|
+
# Connection pool
|
|
275
|
+
self._pool = _ConnectionPool(
|
|
276
|
+
max_connections_per_node=max_connections_per_node,
|
|
277
|
+
timeout=timeout,
|
|
278
|
+
ssl_context=ssl_ctx,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Backward compat: expose single-node base_url
|
|
282
|
+
first = self._nodes[0]
|
|
283
|
+
self._base_url = f"{first.scheme}://{first.host}:{first.port}{first.path_prefix}"
|
|
284
|
+
|
|
285
|
+
def __enter__(self) -> TorqueHttpClient:
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
def __exit__(self, *args: Any) -> None:
|
|
289
|
+
self.close()
|
|
290
|
+
|
|
291
|
+
def close(self) -> None:
|
|
292
|
+
"""Close all pooled connections.
|
|
293
|
+
|
|
294
|
+
Called automatically when using the client as a context manager.
|
|
295
|
+
"""
|
|
296
|
+
self._pool.close_all()
|
|
297
|
+
|
|
298
|
+
# ── Health ──────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def health(self) -> HealthResponse:
|
|
301
|
+
"""Get server health status.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
HealthResponse with server version and license info.
|
|
305
|
+
"""
|
|
306
|
+
d = self._get("/health")
|
|
307
|
+
return HealthResponse(
|
|
308
|
+
ok=d.get("ok", False),
|
|
309
|
+
version=d.get("version", ""),
|
|
310
|
+
license=d.get("license", {}),
|
|
311
|
+
update=d.get("update"),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def stats(self) -> AdminStats:
|
|
315
|
+
"""Get server admin statistics.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
AdminStats with system info and per-collection stats.
|
|
319
|
+
"""
|
|
320
|
+
d = self._get("/admin/stats")
|
|
321
|
+
return AdminStats(
|
|
322
|
+
system=d.get("system", {}),
|
|
323
|
+
collections=d.get("collections", []),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def metrics(self) -> str:
|
|
327
|
+
"""Get Prometheus metrics.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Raw Prometheus text metrics.
|
|
331
|
+
"""
|
|
332
|
+
return self._get_text("/metrics")
|
|
333
|
+
|
|
334
|
+
# ── Collections ────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
def create_collection(self, schema: CollectionSchema) -> CollectionResponse:
|
|
337
|
+
"""Create a new collection.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
schema: Collection schema with name, fields, and options.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
CollectionResponse with the created collection metadata.
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
ObjectAlreadyExists: If a collection with that name exists.
|
|
347
|
+
"""
|
|
348
|
+
d = self._post("/collections", schema.to_dict())
|
|
349
|
+
return _parse_collection(d)
|
|
350
|
+
|
|
351
|
+
def list_collections(self) -> list[CollectionResponse]:
|
|
352
|
+
"""List all collections.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
List of CollectionResponse objects.
|
|
356
|
+
"""
|
|
357
|
+
return [_parse_collection(c) for c in self._get("/collections")]
|
|
358
|
+
|
|
359
|
+
def get_collection(self, name: str) -> CollectionResponse:
|
|
360
|
+
"""Get collection details.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
name: Collection name.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
CollectionResponse with metadata including document count.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
ObjectNotFound: If the collection does not exist.
|
|
370
|
+
"""
|
|
371
|
+
return _parse_collection(self._get(f"/collections/{_enc(name)}"))
|
|
372
|
+
|
|
373
|
+
def update_collection(self, name: str, schema: dict[str, Any]) -> CollectionResponse:
|
|
374
|
+
"""Update a collection schema (add/drop fields).
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
name: Collection name to update.
|
|
378
|
+
schema: Partial schema update. Typically contains a ``fields`` list
|
|
379
|
+
with field definitions to add (new fields) or drop
|
|
380
|
+
(``{"name": "field", "drop": True}``).
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
CollectionResponse with the updated collection metadata.
|
|
384
|
+
|
|
385
|
+
Raises:
|
|
386
|
+
ObjectNotFound: If the collection does not exist.
|
|
387
|
+
RequestMalformed: If the schema update is invalid.
|
|
388
|
+
"""
|
|
389
|
+
d = self._patch(f"/collections/{_enc(name)}", schema)
|
|
390
|
+
return _parse_collection(d)
|
|
391
|
+
|
|
392
|
+
def delete_collection(self, name: str) -> None:
|
|
393
|
+
"""Delete a collection.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
name: Collection name to delete.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
ObjectNotFound: If the collection does not exist.
|
|
400
|
+
"""
|
|
401
|
+
self._delete(f"/collections/{_enc(name)}")
|
|
402
|
+
|
|
403
|
+
# ── Documents ──────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
def create_document(
|
|
406
|
+
self, collection: str, document: dict[str, Any], *, dirty_values: str | None = None,
|
|
407
|
+
) -> dict[str, Any]:
|
|
408
|
+
"""Create a single document.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
collection: Collection name.
|
|
412
|
+
document: Document dict with an ``id`` field.
|
|
413
|
+
dirty_values: How to handle dirty values — ``"coerce_or_reject"``,
|
|
414
|
+
``"coerce_or_drop"``, ``"drop"``, or ``"reject"`` (default).
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
The created document as returned by the server.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
ObjectAlreadyExists: If a document with that ID already exists.
|
|
421
|
+
"""
|
|
422
|
+
qs = f"?dirty_values={_enc(dirty_values)}" if dirty_values else ""
|
|
423
|
+
return self._post(f"/collections/{_enc(collection)}/documents{qs}", document)
|
|
424
|
+
|
|
425
|
+
def upsert_document(
|
|
426
|
+
self, collection: str, document: dict[str, Any], *, dirty_values: str | None = None,
|
|
427
|
+
) -> dict[str, Any]:
|
|
428
|
+
"""Create or update a single document.
|
|
429
|
+
|
|
430
|
+
If a document with the same ``id`` exists it is replaced; otherwise
|
|
431
|
+
a new document is created.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
collection: Collection name.
|
|
435
|
+
document: Document dict with an ``id`` field.
|
|
436
|
+
dirty_values: How to handle dirty values — ``"coerce_or_reject"``,
|
|
437
|
+
``"coerce_or_drop"``, ``"drop"``, or ``"reject"`` (default).
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
The upserted document as returned by the server.
|
|
441
|
+
"""
|
|
442
|
+
qs = "action=upsert"
|
|
443
|
+
if dirty_values:
|
|
444
|
+
qs += f"&dirty_values={_enc(dirty_values)}"
|
|
445
|
+
return self._post(
|
|
446
|
+
f"/collections/{_enc(collection)}/documents?{qs}",
|
|
447
|
+
document,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def get_document(self, collection: str, document_id: str) -> dict[str, Any]:
|
|
451
|
+
"""Get a document by ID.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
collection: Collection name.
|
|
455
|
+
document_id: Document ID.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
The document dict.
|
|
459
|
+
|
|
460
|
+
Raises:
|
|
461
|
+
ObjectNotFound: If the document does not exist.
|
|
462
|
+
"""
|
|
463
|
+
return self._get(f"/collections/{_enc(collection)}/documents/{_enc(document_id)}")
|
|
464
|
+
|
|
465
|
+
def update_document(self, collection: str, document_id: str, fields: dict[str, Any]) -> dict[str, Any]:
|
|
466
|
+
"""Partially update a document.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
collection: Collection name.
|
|
470
|
+
document_id: Document ID.
|
|
471
|
+
fields: Dict of fields to update.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
The updated document dict.
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
ObjectNotFound: If the document does not exist.
|
|
478
|
+
"""
|
|
479
|
+
return self._patch(f"/collections/{_enc(collection)}/documents/{_enc(document_id)}", fields)
|
|
480
|
+
|
|
481
|
+
def delete_document(self, collection: str, document_id: str) -> None:
|
|
482
|
+
"""Delete a document by ID.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
collection: Collection name.
|
|
486
|
+
document_id: Document ID.
|
|
487
|
+
|
|
488
|
+
Raises:
|
|
489
|
+
ObjectNotFound: If the document does not exist.
|
|
490
|
+
"""
|
|
491
|
+
self._delete(f"/collections/{_enc(collection)}/documents/{_enc(document_id)}")
|
|
492
|
+
|
|
493
|
+
def delete_by_filter(self, collection: str, filter_by: str) -> DeleteResult:
|
|
494
|
+
"""Delete documents matching a filter.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
collection: Collection name.
|
|
498
|
+
filter_by: Typesense filter expression (e.g. ``"status:=inactive"``).
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
DeleteResult with the count of deleted documents.
|
|
502
|
+
"""
|
|
503
|
+
d = self._delete_json(f"/collections/{_enc(collection)}/documents?filter_by={quote(filter_by)}")
|
|
504
|
+
return DeleteResult(num_deleted=d.get("num_deleted", 0))
|
|
505
|
+
|
|
506
|
+
def delete_documents_by_filter(self, collection: str, filter_by: str) -> DeleteResult:
|
|
507
|
+
"""Delete documents matching a filter.
|
|
508
|
+
|
|
509
|
+
Alias for ``delete_by_filter`` with a more explicit name.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
collection: Collection name.
|
|
513
|
+
filter_by: Typesense filter expression.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
DeleteResult with the count of deleted documents.
|
|
517
|
+
"""
|
|
518
|
+
return self.delete_by_filter(collection, filter_by)
|
|
519
|
+
|
|
520
|
+
def truncate_collection(self, collection: str) -> DeleteResult:
|
|
521
|
+
"""Delete all documents from a collection while keeping its schema.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
collection: Collection name.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
DeleteResult with the count of deleted documents.
|
|
528
|
+
"""
|
|
529
|
+
d = self._delete_json(f"/collections/{_enc(collection)}/documents")
|
|
530
|
+
return DeleteResult(num_deleted=d.get("num_deleted", 0))
|
|
531
|
+
|
|
532
|
+
def update_documents_by_filter(
|
|
533
|
+
self, collection: str, document: dict[str, Any], filter_by: str,
|
|
534
|
+
) -> UpdateResult:
|
|
535
|
+
"""Update documents matching a filter.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
collection: Collection name.
|
|
539
|
+
document: Dict of fields to update on all matching documents.
|
|
540
|
+
filter_by: Typesense filter expression.
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
UpdateResult with the count of updated documents.
|
|
544
|
+
"""
|
|
545
|
+
d = self._patch(
|
|
546
|
+
f"/collections/{_enc(collection)}/documents?filter_by={quote(filter_by)}",
|
|
547
|
+
document,
|
|
548
|
+
)
|
|
549
|
+
return UpdateResult(num_updated=d.get("num_updated", 0))
|
|
550
|
+
|
|
551
|
+
def import_documents(
|
|
552
|
+
self,
|
|
553
|
+
collection: str,
|
|
554
|
+
documents: list[dict[str, Any]],
|
|
555
|
+
*,
|
|
556
|
+
action: str = "upsert",
|
|
557
|
+
dirty_values: str | None = None,
|
|
558
|
+
batch_size: int | None = None,
|
|
559
|
+
return_doc: bool = False,
|
|
560
|
+
return_id: bool = False,
|
|
561
|
+
) -> list[ImportResult]:
|
|
562
|
+
"""Import documents via JSONL.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
collection: Collection name.
|
|
566
|
+
documents: List of document dicts.
|
|
567
|
+
action: Import action — ``"create"``, ``"upsert"``, ``"update"``,
|
|
568
|
+
or ``"emplace"`` (insert if missing, update existing fields).
|
|
569
|
+
dirty_values: How to handle dirty values — ``"coerce_or_reject"``,
|
|
570
|
+
``"coerce_or_drop"``, ``"drop"``, or ``"reject"`` (default).
|
|
571
|
+
batch_size: When set, automatically chunks the list and sends
|
|
572
|
+
multiple requests of this size. Results are concatenated.
|
|
573
|
+
return_doc: Pass ``return_doc=true`` query param so the server
|
|
574
|
+
echoes back the full document in each ImportResult.
|
|
575
|
+
return_id: Pass ``return_id=true`` query param so the server
|
|
576
|
+
includes the document ``id`` in each ImportResult.
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
List of ImportResult, one per document.
|
|
580
|
+
"""
|
|
581
|
+
if batch_size is not None and batch_size > 0 and len(documents) > batch_size:
|
|
582
|
+
results: list[ImportResult] = []
|
|
583
|
+
for i in range(0, len(documents), batch_size):
|
|
584
|
+
chunk = documents[i:i + batch_size]
|
|
585
|
+
results.extend(self.import_documents(
|
|
586
|
+
collection, chunk,
|
|
587
|
+
action=action,
|
|
588
|
+
dirty_values=dirty_values,
|
|
589
|
+
return_doc=return_doc,
|
|
590
|
+
return_id=return_id,
|
|
591
|
+
))
|
|
592
|
+
return results
|
|
593
|
+
|
|
594
|
+
qs = f"action={_enc(action)}"
|
|
595
|
+
if dirty_values:
|
|
596
|
+
qs += f"&dirty_values={_enc(dirty_values)}"
|
|
597
|
+
if return_doc:
|
|
598
|
+
qs += "&return_doc=true"
|
|
599
|
+
if return_id:
|
|
600
|
+
qs += "&return_id=true"
|
|
601
|
+
body = "\n".join(json.dumps(doc, separators=(",", ":")) for doc in documents)
|
|
602
|
+
text = self._post_text(
|
|
603
|
+
f"/collections/{_enc(collection)}/documents/import?{qs}",
|
|
604
|
+
body.encode("utf-8"),
|
|
605
|
+
content_type="text/plain",
|
|
606
|
+
)
|
|
607
|
+
results = []
|
|
608
|
+
for line in text.strip().split("\n"):
|
|
609
|
+
if not line:
|
|
610
|
+
continue
|
|
611
|
+
d = json.loads(line)
|
|
612
|
+
results.append(ImportResult(
|
|
613
|
+
success=d.get("success", False),
|
|
614
|
+
error=d.get("error"),
|
|
615
|
+
id=d.get("id"),
|
|
616
|
+
))
|
|
617
|
+
return results
|
|
618
|
+
|
|
619
|
+
def import_jsonl(
|
|
620
|
+
self, collection: str, jsonl: str, *, action: str = "upsert", dirty_values: str | None = None,
|
|
621
|
+
) -> list[ImportResult]:
|
|
622
|
+
"""Import raw JSONL string.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
collection: Collection name.
|
|
626
|
+
jsonl: Newline-delimited JSON string.
|
|
627
|
+
action: Import action — ``"create"``, ``"upsert"``, ``"update"``,
|
|
628
|
+
or ``"emplace"``.
|
|
629
|
+
dirty_values: How to handle dirty values — ``"coerce_or_reject"``,
|
|
630
|
+
``"coerce_or_drop"``, ``"drop"``, or ``"reject"`` (default).
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
List of ImportResult, one per document.
|
|
634
|
+
"""
|
|
635
|
+
qs = f"action={_enc(action)}"
|
|
636
|
+
if dirty_values:
|
|
637
|
+
qs += f"&dirty_values={_enc(dirty_values)}"
|
|
638
|
+
text = self._post_text(
|
|
639
|
+
f"/collections/{_enc(collection)}/documents/import?{qs}",
|
|
640
|
+
jsonl.encode("utf-8"),
|
|
641
|
+
content_type="text/plain",
|
|
642
|
+
)
|
|
643
|
+
results = []
|
|
644
|
+
for line in text.strip().split("\n"):
|
|
645
|
+
if line:
|
|
646
|
+
d = json.loads(line)
|
|
647
|
+
results.append(ImportResult(success=d.get("success", False), error=d.get("error")))
|
|
648
|
+
return results
|
|
649
|
+
|
|
650
|
+
def export_documents(
|
|
651
|
+
self,
|
|
652
|
+
collection: str,
|
|
653
|
+
*,
|
|
654
|
+
filter_by: str | None = None,
|
|
655
|
+
include_fields: str | None = None,
|
|
656
|
+
exclude_fields: str | None = None,
|
|
657
|
+
) -> Iterator[dict[str, Any]]:
|
|
658
|
+
"""Export documents as an iterator of dicts.
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
collection: Collection name.
|
|
662
|
+
filter_by: Optional Typesense filter expression to export a subset.
|
|
663
|
+
include_fields: Comma-separated list of fields to include.
|
|
664
|
+
exclude_fields: Comma-separated list of fields to exclude.
|
|
665
|
+
|
|
666
|
+
Yields:
|
|
667
|
+
Document dicts, one per exported document.
|
|
668
|
+
"""
|
|
669
|
+
params: dict[str, str] = {}
|
|
670
|
+
if filter_by is not None:
|
|
671
|
+
params["filter_by"] = filter_by
|
|
672
|
+
if include_fields is not None:
|
|
673
|
+
params["include_fields"] = include_fields
|
|
674
|
+
if exclude_fields is not None:
|
|
675
|
+
params["exclude_fields"] = exclude_fields
|
|
676
|
+
qs = f"?{urlencode(params)}" if params else ""
|
|
677
|
+
text = self._get_text(f"/collections/{_enc(collection)}/documents/export{qs}")
|
|
678
|
+
for line in text.strip().split("\n"):
|
|
679
|
+
if line:
|
|
680
|
+
yield json.loads(line)
|
|
681
|
+
|
|
682
|
+
def export_documents_stream(
|
|
683
|
+
self,
|
|
684
|
+
collection: str,
|
|
685
|
+
*,
|
|
686
|
+
filter_by: str | None = None,
|
|
687
|
+
include_fields: str | None = None,
|
|
688
|
+
exclude_fields: str | None = None,
|
|
689
|
+
) -> Iterator[str]:
|
|
690
|
+
"""Export documents as a streaming iterator of JSONL lines.
|
|
691
|
+
|
|
692
|
+
Unlike ``export_documents``, this streams line-by-line from the
|
|
693
|
+
HTTP response without buffering the entire body in memory.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
collection: Collection name.
|
|
697
|
+
filter_by: Optional Typesense filter expression to export a subset.
|
|
698
|
+
include_fields: Comma-separated list of fields to include.
|
|
699
|
+
exclude_fields: Comma-separated list of fields to exclude.
|
|
700
|
+
|
|
701
|
+
Yields:
|
|
702
|
+
Raw JSON strings, one per exported document.
|
|
703
|
+
"""
|
|
704
|
+
params: dict[str, str] = {}
|
|
705
|
+
if filter_by is not None:
|
|
706
|
+
params["filter_by"] = filter_by
|
|
707
|
+
if include_fields is not None:
|
|
708
|
+
params["include_fields"] = include_fields
|
|
709
|
+
if exclude_fields is not None:
|
|
710
|
+
params["exclude_fields"] = exclude_fields
|
|
711
|
+
qs = f"?{urlencode(params)}" if params else ""
|
|
712
|
+
path = f"/collections/{_enc(collection)}/documents/export{qs}"
|
|
713
|
+
yield from self._request_stream_lines("GET", path)
|
|
714
|
+
|
|
715
|
+
def import_documents_stream(
|
|
716
|
+
self,
|
|
717
|
+
collection: str,
|
|
718
|
+
stream: Iterator[str],
|
|
719
|
+
*,
|
|
720
|
+
action: str = "create",
|
|
721
|
+
dirty_values: str | None = None,
|
|
722
|
+
) -> list[ImportResult]:
|
|
723
|
+
"""Import documents from a streaming iterable of JSONL lines.
|
|
724
|
+
|
|
725
|
+
Accepts any iterable/generator that yields one JSON string per
|
|
726
|
+
document. Lines are concatenated and sent as a single request body.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
collection: Collection name.
|
|
730
|
+
stream: Iterable of JSON strings (one per document).
|
|
731
|
+
action: Import action — ``"create"``, ``"upsert"``, ``"update"``,
|
|
732
|
+
or ``"emplace"``.
|
|
733
|
+
dirty_values: How to handle dirty values.
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
List of ImportResult, one per document.
|
|
737
|
+
"""
|
|
738
|
+
qs = f"action={_enc(action)}"
|
|
739
|
+
if dirty_values:
|
|
740
|
+
qs += f"&dirty_values={_enc(dirty_values)}"
|
|
741
|
+
body = "\n".join(stream)
|
|
742
|
+
text = self._post_text(
|
|
743
|
+
f"/collections/{_enc(collection)}/documents/import?{qs}",
|
|
744
|
+
body.encode("utf-8"),
|
|
745
|
+
content_type="text/plain",
|
|
746
|
+
)
|
|
747
|
+
results = []
|
|
748
|
+
for line in text.strip().split("\n"):
|
|
749
|
+
if not line:
|
|
750
|
+
continue
|
|
751
|
+
d = json.loads(line)
|
|
752
|
+
results.append(ImportResult(
|
|
753
|
+
success=d.get("success", False),
|
|
754
|
+
error=d.get("error"),
|
|
755
|
+
id=d.get("id"),
|
|
756
|
+
))
|
|
757
|
+
return results
|
|
758
|
+
|
|
759
|
+
def import_binary(self, collection: str, file_path: str) -> dict[str, Any]:
|
|
760
|
+
"""Upload a TQBF binary file.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
collection: Collection name.
|
|
764
|
+
file_path: Path to a ``.tqbf`` file on disk.
|
|
765
|
+
|
|
766
|
+
Returns:
|
|
767
|
+
Server response dict with import stats.
|
|
768
|
+
"""
|
|
769
|
+
with open(file_path, "rb") as f:
|
|
770
|
+
data = f.read()
|
|
771
|
+
return self._post_bytes(
|
|
772
|
+
f"/collections/{_enc(collection)}/documents/import-binary",
|
|
773
|
+
data,
|
|
774
|
+
content_type="application/octet-stream",
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
# ── Search ─────────────────────────────────────────────────────────
|
|
778
|
+
|
|
779
|
+
def search(
|
|
780
|
+
self,
|
|
781
|
+
collection: str,
|
|
782
|
+
params: SearchParameters | dict[str, Any] | None = None,
|
|
783
|
+
**kwargs: Any,
|
|
784
|
+
) -> SearchResult:
|
|
785
|
+
"""Search documents.
|
|
786
|
+
|
|
787
|
+
Accepts either a ``SearchParameters`` TypedDict, a plain dict, or
|
|
788
|
+
keyword arguments (for backward compatibility). All three forms can
|
|
789
|
+
be combined — keyword arguments override dict values.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
collection: Collection name to search.
|
|
793
|
+
params: Optional typed search parameters dict.
|
|
794
|
+
**kwargs: Additional or overriding search parameters.
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
SearchResult with hits, facets, and timing info.
|
|
798
|
+
|
|
799
|
+
Examples::
|
|
800
|
+
|
|
801
|
+
# Keyword arguments (backward compatible)
|
|
802
|
+
client.search("products", q="laptop", query_by="title")
|
|
803
|
+
|
|
804
|
+
# Typed parameters
|
|
805
|
+
from torque_http import SearchParameters
|
|
806
|
+
p = SearchParameters(q="laptop", query_by="title", per_page=20)
|
|
807
|
+
client.search("products", p)
|
|
808
|
+
|
|
809
|
+
# Mixed
|
|
810
|
+
client.search("products", p, filter_by="price:>100")
|
|
811
|
+
"""
|
|
812
|
+
merged: dict[str, Any] = {}
|
|
813
|
+
if params is not None:
|
|
814
|
+
merged.update(params)
|
|
815
|
+
merged.update(kwargs)
|
|
816
|
+
qs = urlencode({k: v for k, v in merged.items() if v is not None})
|
|
817
|
+
d = self._get(f"/collections/{_enc(collection)}/documents/search?{qs}")
|
|
818
|
+
return _parse_search(d)
|
|
819
|
+
|
|
820
|
+
def search_grouped(
|
|
821
|
+
self,
|
|
822
|
+
collection: str,
|
|
823
|
+
params: SearchParameters | dict[str, Any] | None = None,
|
|
824
|
+
**kwargs: Any,
|
|
825
|
+
) -> GroupedSearchResult:
|
|
826
|
+
"""Search with group_by -- returns grouped results.
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
collection: Collection name.
|
|
830
|
+
params: Optional typed search parameters dict (must include ``group_by``).
|
|
831
|
+
**kwargs: Additional or overriding search parameters.
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
GroupedSearchResult with grouped hits.
|
|
835
|
+
"""
|
|
836
|
+
merged: dict[str, Any] = {}
|
|
837
|
+
if params is not None:
|
|
838
|
+
merged.update(params)
|
|
839
|
+
merged.update(kwargs)
|
|
840
|
+
qs = urlencode({k: v for k, v in merged.items() if v is not None})
|
|
841
|
+
d = self._get(f"/collections/{_enc(collection)}/documents/search?{qs}")
|
|
842
|
+
return _parse_grouped_search(d)
|
|
843
|
+
|
|
844
|
+
def multi_search(
|
|
845
|
+
self,
|
|
846
|
+
searches: list[dict[str, Any]],
|
|
847
|
+
common_params: dict[str, Any] | None = None,
|
|
848
|
+
) -> list[SearchResult]:
|
|
849
|
+
"""Execute multiple searches in one request.
|
|
850
|
+
|
|
851
|
+
Args:
|
|
852
|
+
searches: List of search parameter dicts, each with at least
|
|
853
|
+
``collection`` and ``q`` keys.
|
|
854
|
+
common_params: Optional dict of parameters merged into every
|
|
855
|
+
search. Per-search values override common values.
|
|
856
|
+
|
|
857
|
+
Returns:
|
|
858
|
+
List of SearchResult, one per input search.
|
|
859
|
+
"""
|
|
860
|
+
if common_params:
|
|
861
|
+
merged = []
|
|
862
|
+
for s in searches:
|
|
863
|
+
m = {**common_params, **s}
|
|
864
|
+
merged.append(m)
|
|
865
|
+
searches = merged
|
|
866
|
+
d = self._post("/multi_search", {"searches": searches})
|
|
867
|
+
return [_parse_search(r) for r in d.get("results", [])]
|
|
868
|
+
|
|
869
|
+
# ── Aliases ────────────────────────────────────────────────────────
|
|
870
|
+
|
|
871
|
+
def upsert_alias(self, name: str, collection_name: str) -> CollectionAlias:
|
|
872
|
+
"""Create or update a collection alias.
|
|
873
|
+
|
|
874
|
+
Args:
|
|
875
|
+
name: Alias name.
|
|
876
|
+
collection_name: Target collection name.
|
|
877
|
+
|
|
878
|
+
Returns:
|
|
879
|
+
CollectionAlias with the alias mapping.
|
|
880
|
+
"""
|
|
881
|
+
d = self._put(f"/aliases/{_enc(name)}", {"collection_name": collection_name})
|
|
882
|
+
return CollectionAlias(name=d.get("name", ""), collection_name=d.get("collection_name", ""))
|
|
883
|
+
|
|
884
|
+
def get_alias(self, name: str) -> CollectionAlias:
|
|
885
|
+
"""Get an alias.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
name: Alias name.
|
|
889
|
+
|
|
890
|
+
Returns:
|
|
891
|
+
CollectionAlias with the alias mapping.
|
|
892
|
+
|
|
893
|
+
Raises:
|
|
894
|
+
ObjectNotFound: If the alias does not exist.
|
|
895
|
+
"""
|
|
896
|
+
d = self._get(f"/aliases/{_enc(name)}")
|
|
897
|
+
return CollectionAlias(name=d.get("name", ""), collection_name=d.get("collection_name", ""))
|
|
898
|
+
|
|
899
|
+
def list_aliases(self) -> list[CollectionAlias]:
|
|
900
|
+
"""List all aliases.
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
List of CollectionAlias objects.
|
|
904
|
+
"""
|
|
905
|
+
d = self._get("/aliases")
|
|
906
|
+
return [CollectionAlias(name=a.get("name", ""), collection_name=a.get("collection_name", ""))
|
|
907
|
+
for a in d.get("aliases", [])]
|
|
908
|
+
|
|
909
|
+
def delete_alias(self, name: str) -> None:
|
|
910
|
+
"""Delete an alias.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
name: Alias name.
|
|
914
|
+
|
|
915
|
+
Raises:
|
|
916
|
+
ObjectNotFound: If the alias does not exist.
|
|
917
|
+
"""
|
|
918
|
+
self._delete(f"/aliases/{_enc(name)}")
|
|
919
|
+
|
|
920
|
+
# ── API Keys ───────────────────────────────────────────────────────
|
|
921
|
+
|
|
922
|
+
def create_key(self, description: str, actions: list[str], collections: list[str],
|
|
923
|
+
*, expires_at: int | None = None) -> ApiKey:
|
|
924
|
+
"""Create an API key.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
description: Human-readable description for the key.
|
|
928
|
+
actions: List of allowed actions (e.g. ``["documents:search"]``).
|
|
929
|
+
collections: List of allowed collection names (``["*"]`` for all).
|
|
930
|
+
expires_at: Optional Unix timestamp when the key expires.
|
|
931
|
+
|
|
932
|
+
Returns:
|
|
933
|
+
ApiKey with the generated key value (only visible on creation).
|
|
934
|
+
"""
|
|
935
|
+
body: dict[str, Any] = {"description": description, "actions": actions, "collections": collections}
|
|
936
|
+
if expires_at is not None:
|
|
937
|
+
body["expires_at"] = expires_at
|
|
938
|
+
d = self._post("/keys", body)
|
|
939
|
+
return _parse_key(d)
|
|
940
|
+
|
|
941
|
+
def list_keys(self) -> list[ApiKey]:
|
|
942
|
+
"""List all API keys.
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
List of ApiKey objects (``value`` is redacted).
|
|
946
|
+
"""
|
|
947
|
+
d = self._get("/keys")
|
|
948
|
+
return [_parse_key(k) for k in d.get("keys", [])]
|
|
949
|
+
|
|
950
|
+
def get_key(self, key_id: int) -> ApiKey:
|
|
951
|
+
"""Get an API key by ID.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
key_id: Numeric key ID.
|
|
955
|
+
|
|
956
|
+
Returns:
|
|
957
|
+
ApiKey with metadata (``value`` is redacted).
|
|
958
|
+
|
|
959
|
+
Raises:
|
|
960
|
+
ObjectNotFound: If the key does not exist.
|
|
961
|
+
"""
|
|
962
|
+
return _parse_key(self._get(f"/keys/{key_id}"))
|
|
963
|
+
|
|
964
|
+
def delete_key(self, key_id: int) -> None:
|
|
965
|
+
"""Delete an API key.
|
|
966
|
+
|
|
967
|
+
Args:
|
|
968
|
+
key_id: Numeric key ID.
|
|
969
|
+
|
|
970
|
+
Raises:
|
|
971
|
+
ObjectNotFound: If the key does not exist.
|
|
972
|
+
"""
|
|
973
|
+
self._delete(f"/keys/{key_id}")
|
|
974
|
+
|
|
975
|
+
def generate_scoped_search_key(
|
|
976
|
+
self,
|
|
977
|
+
search_key: str,
|
|
978
|
+
params: dict[str, Any],
|
|
979
|
+
) -> str:
|
|
980
|
+
"""Generate an HMAC-based scoped search key.
|
|
981
|
+
|
|
982
|
+
Scoped keys embed search restrictions (filter_by, expires_at, etc.)
|
|
983
|
+
that cannot be modified by the client. The key is derived from a
|
|
984
|
+
parent search-only API key using HMAC-SHA256.
|
|
985
|
+
|
|
986
|
+
This matches the Typesense v30.1 scoped key format.
|
|
987
|
+
|
|
988
|
+
Args:
|
|
989
|
+
search_key: The parent search-only API key value.
|
|
990
|
+
params: Dict of embedded search parameters. Common fields:
|
|
991
|
+
``filter_by``, ``expires_at``, ``collection``.
|
|
992
|
+
|
|
993
|
+
Returns:
|
|
994
|
+
Base64-encoded scoped key string suitable for use as an API key.
|
|
995
|
+
|
|
996
|
+
Example::
|
|
997
|
+
|
|
998
|
+
scoped = client.generate_scoped_search_key(
|
|
999
|
+
"search-only-key-value",
|
|
1000
|
+
{"filter_by": "company_id:=123", "expires_at": 1700000000},
|
|
1001
|
+
)
|
|
1002
|
+
"""
|
|
1003
|
+
params_json = json.dumps(params, separators=(",", ":"))
|
|
1004
|
+
digest = hmac.new(
|
|
1005
|
+
search_key.encode("utf-8"),
|
|
1006
|
+
params_json.encode("utf-8"),
|
|
1007
|
+
hashlib.sha256,
|
|
1008
|
+
).digest()
|
|
1009
|
+
digest_b64 = base64.b64encode(digest).decode("utf-8")
|
|
1010
|
+
prefix = search_key[:4]
|
|
1011
|
+
raw = f"{digest_b64}{prefix}{params_json}"
|
|
1012
|
+
return base64.b64encode(raw.encode("utf-8")).decode("utf-8")
|
|
1013
|
+
|
|
1014
|
+
# ── Synonyms ───────────────────────────────────────────────────────
|
|
1015
|
+
|
|
1016
|
+
def upsert_synonym_set(self, name: str, items: list[dict[str, Any]] | None = None) -> SynonymSet:
|
|
1017
|
+
"""Create or update a synonym set.
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
name: Synonym set name.
|
|
1021
|
+
items: List of synonym rule dicts.
|
|
1022
|
+
|
|
1023
|
+
Returns:
|
|
1024
|
+
SynonymSet with the set name.
|
|
1025
|
+
"""
|
|
1026
|
+
body: dict[str, Any] = {"name": name, "items": items or []}
|
|
1027
|
+
d = self._put(f"/synonym_sets/{_enc(name)}", body)
|
|
1028
|
+
return SynonymSet(name=d.get("name", ""), items=[])
|
|
1029
|
+
|
|
1030
|
+
def list_synonym_sets(self) -> list[SynonymSet]:
|
|
1031
|
+
"""List all synonym sets.
|
|
1032
|
+
|
|
1033
|
+
Returns:
|
|
1034
|
+
List of SynonymSet objects.
|
|
1035
|
+
"""
|
|
1036
|
+
d = self._get("/synonym_sets")
|
|
1037
|
+
return [SynonymSet(name=s.get("name", "")) for s in d.get("synonym_sets", [])]
|
|
1038
|
+
|
|
1039
|
+
def get_synonym_set(self, name: str) -> SynonymSet:
|
|
1040
|
+
"""Get a synonym set by name.
|
|
1041
|
+
|
|
1042
|
+
Args:
|
|
1043
|
+
name: Synonym set name.
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
SynonymSet with name and items.
|
|
1047
|
+
|
|
1048
|
+
Raises:
|
|
1049
|
+
ObjectNotFound: If the synonym set does not exist.
|
|
1050
|
+
"""
|
|
1051
|
+
d = self._get(f"/synonym_sets/{_enc(name)}")
|
|
1052
|
+
items = [
|
|
1053
|
+
SynonymItem(
|
|
1054
|
+
id=item.get("id", ""),
|
|
1055
|
+
synonyms=item.get("synonyms", []),
|
|
1056
|
+
root=item.get("root"),
|
|
1057
|
+
locale=item.get("locale"),
|
|
1058
|
+
)
|
|
1059
|
+
for item in d.get("items", [])
|
|
1060
|
+
]
|
|
1061
|
+
return SynonymSet(name=d.get("name", ""), items=items)
|
|
1062
|
+
|
|
1063
|
+
def delete_synonym_set(self, name: str) -> None:
|
|
1064
|
+
"""Delete a synonym set.
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
name: Synonym set name.
|
|
1068
|
+
|
|
1069
|
+
Raises:
|
|
1070
|
+
ObjectNotFound: If the synonym set does not exist.
|
|
1071
|
+
"""
|
|
1072
|
+
self._delete(f"/synonym_sets/{_enc(name)}")
|
|
1073
|
+
|
|
1074
|
+
# ── Presets ────────────────────────────────────────────────────────
|
|
1075
|
+
|
|
1076
|
+
def upsert_preset(self, preset_id: str, value: dict[str, Any]) -> Preset:
|
|
1077
|
+
"""Create or update a search preset.
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
preset_id: Preset identifier.
|
|
1081
|
+
value: Dict of search parameters to save.
|
|
1082
|
+
|
|
1083
|
+
Returns:
|
|
1084
|
+
Preset with the ID and saved value.
|
|
1085
|
+
"""
|
|
1086
|
+
d = self._put(f"/presets/{_enc(preset_id)}", {"id": preset_id, "value": value})
|
|
1087
|
+
return Preset(id=d.get("id", ""), value=d.get("value", {}))
|
|
1088
|
+
|
|
1089
|
+
def list_presets(self) -> list[Preset]:
|
|
1090
|
+
"""List all presets.
|
|
1091
|
+
|
|
1092
|
+
Returns:
|
|
1093
|
+
List of Preset objects.
|
|
1094
|
+
"""
|
|
1095
|
+
d = self._get("/presets")
|
|
1096
|
+
return [Preset(id=p.get("id", ""), value=p.get("value", {})) for p in d.get("presets", [])]
|
|
1097
|
+
|
|
1098
|
+
def get_preset(self, preset_id: str) -> Preset:
|
|
1099
|
+
"""Get a preset by ID.
|
|
1100
|
+
|
|
1101
|
+
Args:
|
|
1102
|
+
preset_id: Preset identifier.
|
|
1103
|
+
|
|
1104
|
+
Returns:
|
|
1105
|
+
Preset with the ID and saved value.
|
|
1106
|
+
|
|
1107
|
+
Raises:
|
|
1108
|
+
ObjectNotFound: If the preset does not exist.
|
|
1109
|
+
"""
|
|
1110
|
+
d = self._get(f"/presets/{_enc(preset_id)}")
|
|
1111
|
+
return Preset(id=d.get("id", ""), value=d.get("value", {}))
|
|
1112
|
+
|
|
1113
|
+
def delete_preset(self, preset_id: str) -> None:
|
|
1114
|
+
"""Delete a preset.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
preset_id: Preset identifier.
|
|
1118
|
+
|
|
1119
|
+
Raises:
|
|
1120
|
+
ObjectNotFound: If the preset does not exist.
|
|
1121
|
+
"""
|
|
1122
|
+
self._delete(f"/presets/{_enc(preset_id)}")
|
|
1123
|
+
|
|
1124
|
+
# ── Stopwords ──────────────────────────────────────────────────────
|
|
1125
|
+
|
|
1126
|
+
def upsert_stopwords(self, stopword_id: str, stopwords: list[str]) -> StopwordSet:
|
|
1127
|
+
"""Create or update a stopword set.
|
|
1128
|
+
|
|
1129
|
+
Args:
|
|
1130
|
+
stopword_id: Stopword set identifier.
|
|
1131
|
+
stopwords: List of stopword strings.
|
|
1132
|
+
|
|
1133
|
+
Returns:
|
|
1134
|
+
StopwordSet with the ID and word list.
|
|
1135
|
+
"""
|
|
1136
|
+
d = self._put(f"/stopwords/{_enc(stopword_id)}", {"id": stopword_id, "stopwords": stopwords})
|
|
1137
|
+
return StopwordSet(id=d.get("id", ""), stopwords=d.get("stopwords", []))
|
|
1138
|
+
|
|
1139
|
+
def list_stopwords(self) -> list[StopwordSet]:
|
|
1140
|
+
"""List all stopword sets.
|
|
1141
|
+
|
|
1142
|
+
Returns:
|
|
1143
|
+
List of StopwordSet objects.
|
|
1144
|
+
"""
|
|
1145
|
+
d = self._get("/stopwords")
|
|
1146
|
+
return [StopwordSet(id=s.get("id", ""), stopwords=s.get("stopwords", []))
|
|
1147
|
+
for s in d.get("stopwords", [])]
|
|
1148
|
+
|
|
1149
|
+
def get_stopwords_set(self, stopword_id: str) -> StopwordSet:
|
|
1150
|
+
"""Get a stopword set by ID.
|
|
1151
|
+
|
|
1152
|
+
Args:
|
|
1153
|
+
stopword_id: Stopword set identifier.
|
|
1154
|
+
|
|
1155
|
+
Returns:
|
|
1156
|
+
StopwordSet with the ID and word list.
|
|
1157
|
+
|
|
1158
|
+
Raises:
|
|
1159
|
+
ObjectNotFound: If the stopword set does not exist.
|
|
1160
|
+
"""
|
|
1161
|
+
d = self._get(f"/stopwords/{_enc(stopword_id)}")
|
|
1162
|
+
return StopwordSet(id=d.get("id", ""), stopwords=d.get("stopwords", []))
|
|
1163
|
+
|
|
1164
|
+
def delete_stopwords(self, stopword_id: str) -> None:
|
|
1165
|
+
"""Delete a stopword set.
|
|
1166
|
+
|
|
1167
|
+
Args:
|
|
1168
|
+
stopword_id: Stopword set identifier.
|
|
1169
|
+
|
|
1170
|
+
Raises:
|
|
1171
|
+
ObjectNotFound: If the stopword set does not exist.
|
|
1172
|
+
"""
|
|
1173
|
+
self._delete(f"/stopwords/{_enc(stopword_id)}")
|
|
1174
|
+
|
|
1175
|
+
# ── TQBF Binary File Writer ────────────────────────────────────────
|
|
1176
|
+
|
|
1177
|
+
def create_binary_writer(self, file_path: str, schema: dict[str, Any]) -> TorqueBinaryFileWriter:
|
|
1178
|
+
"""Create a streaming binary file writer for TQBF format.
|
|
1179
|
+
|
|
1180
|
+
Uses proper binary encoding via DocumentEncoder (not JSONL).
|
|
1181
|
+
|
|
1182
|
+
Args:
|
|
1183
|
+
file_path: Output file path for the ``.tqbf`` file.
|
|
1184
|
+
schema: Collection schema dict with a ``fields`` list.
|
|
1185
|
+
|
|
1186
|
+
Returns:
|
|
1187
|
+
TorqueBinaryFileWriter context manager for streaming writes.
|
|
1188
|
+
|
|
1189
|
+
Example::
|
|
1190
|
+
|
|
1191
|
+
schema = {"fields": [{"name": "title", "type": "string"}, ...]}
|
|
1192
|
+
with client.create_binary_writer("data.tqbf", schema) as writer:
|
|
1193
|
+
for doc in data_source:
|
|
1194
|
+
writer.write(doc)
|
|
1195
|
+
client.import_binary("my_collection", "data.tqbf")
|
|
1196
|
+
"""
|
|
1197
|
+
encoder = DocumentEncoder(schema)
|
|
1198
|
+
return TorqueBinaryFileWriter(file_path, encoder)
|
|
1199
|
+
|
|
1200
|
+
# ── Internal HTTP ──────────────────────────────────────────────────
|
|
1201
|
+
|
|
1202
|
+
def _next_node(self) -> Node:
|
|
1203
|
+
"""Select the next healthy node via round-robin."""
|
|
1204
|
+
with self._node_lock:
|
|
1205
|
+
start = self._node_index
|
|
1206
|
+
for _ in range(len(self._nodes)):
|
|
1207
|
+
node = self._nodes[self._node_index % len(self._nodes)]
|
|
1208
|
+
self._node_index = (self._node_index + 1) % len(self._nodes)
|
|
1209
|
+
if node.is_healthy(self._healthcheck_interval):
|
|
1210
|
+
return node
|
|
1211
|
+
# All nodes unhealthy — reset and return the next one anyway
|
|
1212
|
+
for node in self._nodes:
|
|
1213
|
+
node._healthy = True
|
|
1214
|
+
node = self._nodes[start % len(self._nodes)]
|
|
1215
|
+
self._node_index = (start + 1) % len(self._nodes)
|
|
1216
|
+
return node
|
|
1217
|
+
|
|
1218
|
+
def _request(
|
|
1219
|
+
self,
|
|
1220
|
+
method: str,
|
|
1221
|
+
path: str,
|
|
1222
|
+
body: bytes | None = None,
|
|
1223
|
+
content_type: str = "application/json",
|
|
1224
|
+
timeout: float | None = None,
|
|
1225
|
+
) -> bytes:
|
|
1226
|
+
"""Execute an HTTP request with retry and node failover.
|
|
1227
|
+
|
|
1228
|
+
Args:
|
|
1229
|
+
method: HTTP method (GET, POST, PUT, PATCH, DELETE).
|
|
1230
|
+
path: Request path (e.g. ``/collections``).
|
|
1231
|
+
body: Optional request body bytes.
|
|
1232
|
+
content_type: Content-Type header value.
|
|
1233
|
+
timeout: Request timeout override.
|
|
1234
|
+
|
|
1235
|
+
Returns:
|
|
1236
|
+
Response body bytes.
|
|
1237
|
+
|
|
1238
|
+
Raises:
|
|
1239
|
+
TorqueApiError: On non-2xx response (typed by status code).
|
|
1240
|
+
RequestTimeout: On connection or read timeout.
|
|
1241
|
+
"""
|
|
1242
|
+
effective_timeout = timeout or self._timeout
|
|
1243
|
+
last_error: Exception | None = None
|
|
1244
|
+
|
|
1245
|
+
for attempt in range(self._num_retries + 1):
|
|
1246
|
+
node = self._next_node()
|
|
1247
|
+
if self._send_api_key_as_query_param:
|
|
1248
|
+
sep = "&" if "?" in path else "?"
|
|
1249
|
+
req_path = f"{node.path_prefix}{path}{sep}x-typesense-api-key={quote(self._api_key, safe='')}"
|
|
1250
|
+
headers: dict[str, str] = {}
|
|
1251
|
+
else:
|
|
1252
|
+
req_path = f"{node.path_prefix}{path}"
|
|
1253
|
+
headers = {"X-TYPESENSE-API-KEY": self._api_key}
|
|
1254
|
+
conn = self._pool.acquire(node)
|
|
1255
|
+
|
|
1256
|
+
# Set timeout for this request
|
|
1257
|
+
conn.timeout = effective_timeout
|
|
1258
|
+
|
|
1259
|
+
try:
|
|
1260
|
+
if body is not None:
|
|
1261
|
+
headers["Content-Type"] = content_type
|
|
1262
|
+
|
|
1263
|
+
conn.request(method, req_path, body=body, headers=headers)
|
|
1264
|
+
resp = conn.getresponse()
|
|
1265
|
+
resp_body = resp.read()
|
|
1266
|
+
|
|
1267
|
+
if 200 <= resp.status < 300:
|
|
1268
|
+
self._pool.release(node, conn)
|
|
1269
|
+
return resp_body
|
|
1270
|
+
|
|
1271
|
+
# Non-2xx response — parse error and raise typed exception
|
|
1272
|
+
self._pool.release(node, conn)
|
|
1273
|
+
msg = None
|
|
1274
|
+
try:
|
|
1275
|
+
d = json.loads(resp_body)
|
|
1276
|
+
msg = d.get("message") or d.get("error")
|
|
1277
|
+
except Exception:
|
|
1278
|
+
pass
|
|
1279
|
+
if msg is None:
|
|
1280
|
+
msg = f"HTTP {resp.status}: {resp_body[:200].decode(errors='replace')}"
|
|
1281
|
+
|
|
1282
|
+
# 5xx and 503 are retryable
|
|
1283
|
+
if resp.status >= 500 and attempt < self._num_retries:
|
|
1284
|
+
node.mark_unhealthy()
|
|
1285
|
+
last_error = TorqueApiError(msg, status_code=resp.status)
|
|
1286
|
+
time.sleep(self._retry_interval * (2 ** attempt))
|
|
1287
|
+
continue
|
|
1288
|
+
|
|
1289
|
+
raise_for_status(resp.status, msg)
|
|
1290
|
+
|
|
1291
|
+
except TorqueApiError:
|
|
1292
|
+
raise
|
|
1293
|
+
except TorqueError:
|
|
1294
|
+
raise
|
|
1295
|
+
except (OSError, http.client.HTTPException, TimeoutError) as e:
|
|
1296
|
+
# Connection-level failure — discard and retry
|
|
1297
|
+
self._pool.discard(conn)
|
|
1298
|
+
node.mark_unhealthy()
|
|
1299
|
+
last_error = e
|
|
1300
|
+
|
|
1301
|
+
if attempt < self._num_retries:
|
|
1302
|
+
time.sleep(self._retry_interval * (2 ** attempt))
|
|
1303
|
+
continue
|
|
1304
|
+
|
|
1305
|
+
if isinstance(e, (TimeoutError, OSError)) and "timed out" in str(e).lower():
|
|
1306
|
+
raise RequestTimeout(f"Request to {node.host}:{node.port} timed out after {effective_timeout}s") from e
|
|
1307
|
+
raise TorqueError(f"Connection error after {self._num_retries + 1} attempts: {e}") from e
|
|
1308
|
+
|
|
1309
|
+
# Should not reach here, but just in case
|
|
1310
|
+
if last_error is not None:
|
|
1311
|
+
raise TorqueError(f"All {self._num_retries + 1} attempts failed") from last_error
|
|
1312
|
+
raise TorqueError("Request failed with no error details")
|
|
1313
|
+
|
|
1314
|
+
def _get(self, path: str) -> Any:
|
|
1315
|
+
return json.loads(self._request("GET", path))
|
|
1316
|
+
|
|
1317
|
+
def _get_text(self, path: str) -> str:
|
|
1318
|
+
return self._request("GET", path).decode("utf-8")
|
|
1319
|
+
|
|
1320
|
+
def _request_stream_lines(self, method: str, path: str) -> Iterator[str]:
|
|
1321
|
+
"""Execute an HTTP request and yield response lines without buffering.
|
|
1322
|
+
|
|
1323
|
+
Uses the transfer timeout and reads the response line-by-line via
|
|
1324
|
+
the http.client socket, avoiding full-body buffering for large exports.
|
|
1325
|
+
"""
|
|
1326
|
+
node = self._next_node()
|
|
1327
|
+
if self._send_api_key_as_query_param:
|
|
1328
|
+
sep = "&" if "?" in path else "?"
|
|
1329
|
+
full_path = f"{node.path_prefix}{path}{sep}x-typesense-api-key={quote(self._api_key, safe='')}"
|
|
1330
|
+
stream_headers: dict[str, str] = {}
|
|
1331
|
+
else:
|
|
1332
|
+
full_path = f"{node.path_prefix}{path}"
|
|
1333
|
+
stream_headers = {"X-TYPESENSE-API-KEY": self._api_key}
|
|
1334
|
+
conn = self._pool.acquire(node)
|
|
1335
|
+
conn.timeout = self._transfer_timeout
|
|
1336
|
+
|
|
1337
|
+
try:
|
|
1338
|
+
conn.request(method, full_path, headers=stream_headers)
|
|
1339
|
+
resp = conn.getresponse()
|
|
1340
|
+
|
|
1341
|
+
if resp.status < 200 or resp.status >= 300:
|
|
1342
|
+
body = resp.read()
|
|
1343
|
+
self._pool.release(node, conn)
|
|
1344
|
+
msg = None
|
|
1345
|
+
try:
|
|
1346
|
+
d = json.loads(body)
|
|
1347
|
+
msg = d.get("message") or d.get("error")
|
|
1348
|
+
except Exception:
|
|
1349
|
+
pass
|
|
1350
|
+
if msg is None:
|
|
1351
|
+
msg = f"HTTP {resp.status}: {body[:200].decode(errors='replace')}"
|
|
1352
|
+
raise_for_status(resp.status, msg)
|
|
1353
|
+
|
|
1354
|
+
buf = b""
|
|
1355
|
+
while True:
|
|
1356
|
+
chunk = resp.read(8192)
|
|
1357
|
+
if not chunk:
|
|
1358
|
+
break
|
|
1359
|
+
buf += chunk
|
|
1360
|
+
while b"\n" in buf:
|
|
1361
|
+
line_bytes, buf = buf.split(b"\n", 1)
|
|
1362
|
+
line = line_bytes.decode("utf-8").strip()
|
|
1363
|
+
if line:
|
|
1364
|
+
yield line
|
|
1365
|
+
if buf:
|
|
1366
|
+
line = buf.decode("utf-8").strip()
|
|
1367
|
+
if line:
|
|
1368
|
+
yield line
|
|
1369
|
+
|
|
1370
|
+
self._pool.release(node, conn)
|
|
1371
|
+
except TorqueApiError:
|
|
1372
|
+
raise
|
|
1373
|
+
except TorqueError:
|
|
1374
|
+
raise
|
|
1375
|
+
except Exception as e:
|
|
1376
|
+
self._pool.discard(conn)
|
|
1377
|
+
node.mark_unhealthy()
|
|
1378
|
+
raise TorqueError(f"Stream error: {e}") from e
|
|
1379
|
+
|
|
1380
|
+
def _post(self, path: str, body: Any = None) -> Any:
|
|
1381
|
+
data = json.dumps(body, separators=(",", ":")).encode("utf-8") if body is not None else None
|
|
1382
|
+
resp = self._request("POST", path, data)
|
|
1383
|
+
return json.loads(resp) if resp else None
|
|
1384
|
+
|
|
1385
|
+
def _post_text(self, path: str, body: bytes, content_type: str = "text/plain") -> str:
|
|
1386
|
+
return self._request("POST", path, body, content_type, self._transfer_timeout).decode("utf-8")
|
|
1387
|
+
|
|
1388
|
+
def _post_bytes(self, path: str, body: bytes, content_type: str = "application/octet-stream") -> Any:
|
|
1389
|
+
resp = self._request("POST", path, body, content_type, self._transfer_timeout)
|
|
1390
|
+
return json.loads(resp)
|
|
1391
|
+
|
|
1392
|
+
def _put(self, path: str, body: Any) -> Any:
|
|
1393
|
+
data = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
1394
|
+
resp = self._request("PUT", path, data)
|
|
1395
|
+
return json.loads(resp) if resp else {}
|
|
1396
|
+
|
|
1397
|
+
def _patch(self, path: str, body: Any) -> Any:
|
|
1398
|
+
data = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
1399
|
+
resp = self._request("PATCH", path, data)
|
|
1400
|
+
return json.loads(resp) if resp else {}
|
|
1401
|
+
|
|
1402
|
+
def _delete(self, path: str) -> None:
|
|
1403
|
+
self._request("DELETE", path)
|
|
1404
|
+
|
|
1405
|
+
def _delete_json(self, path: str) -> Any:
|
|
1406
|
+
resp = self._request("DELETE", path)
|
|
1407
|
+
return json.loads(resp) if resp else {}
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
1411
|
+
|
|
1412
|
+
def _enc(s: str) -> str:
|
|
1413
|
+
return quote(s, safe="")
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _parse_collection(d: dict) -> CollectionResponse:
|
|
1417
|
+
return CollectionResponse(
|
|
1418
|
+
name=d.get("name", ""),
|
|
1419
|
+
num_documents=d.get("num_documents", 0),
|
|
1420
|
+
fields=d.get("fields", []),
|
|
1421
|
+
default_sorting_field=d.get("default_sorting_field"),
|
|
1422
|
+
enable_nested_fields=d.get("enable_nested_fields", False),
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def _parse_search(d: dict) -> SearchResult:
|
|
1427
|
+
return SearchResult(
|
|
1428
|
+
found=d.get("found", 0),
|
|
1429
|
+
out_of=d.get("out_of", 0),
|
|
1430
|
+
page=d.get("page", 1),
|
|
1431
|
+
per_page=d.get("per_page", 10),
|
|
1432
|
+
hits=[Hit(
|
|
1433
|
+
document=h.get("document", {}),
|
|
1434
|
+
text_match=h.get("text_match", 0),
|
|
1435
|
+
highlights=h.get("highlights", []),
|
|
1436
|
+
) for h in d.get("hits", [])],
|
|
1437
|
+
facet_counts=[FacetResult(
|
|
1438
|
+
field_name=fc.get("field_name", ""),
|
|
1439
|
+
counts=[FacetCount(value=c.get("value", ""), count=c.get("count", 0))
|
|
1440
|
+
for c in fc.get("counts", [])],
|
|
1441
|
+
) for fc in d.get("facet_counts", [])],
|
|
1442
|
+
search_time_ms=d.get("search_time_ms", 0),
|
|
1443
|
+
search_time_us=d.get("search_time_us", 0),
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _parse_grouped_search(d: dict) -> GroupedSearchResult:
|
|
1448
|
+
return GroupedSearchResult(
|
|
1449
|
+
found=d.get("found", 0),
|
|
1450
|
+
out_of=d.get("out_of", 0),
|
|
1451
|
+
grouped_hits=[GroupedHit(
|
|
1452
|
+
group_key=g.get("group_key", []),
|
|
1453
|
+
hits=[Hit(document=h.get("document", {})) for h in g.get("hits", [])],
|
|
1454
|
+
found=g.get("found", 0),
|
|
1455
|
+
) for g in d.get("grouped_hits", [])],
|
|
1456
|
+
search_time_us=d.get("search_time_us", 0),
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _parse_key(d: dict) -> ApiKey:
|
|
1461
|
+
return ApiKey(
|
|
1462
|
+
id=d.get("id", 0),
|
|
1463
|
+
description=d.get("description", ""),
|
|
1464
|
+
actions=d.get("actions", []),
|
|
1465
|
+
collections=d.get("collections", []),
|
|
1466
|
+
value=d.get("value"),
|
|
1467
|
+
expires_at=d.get("expires_at"),
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
# Backward-compatible alias — deprecated, use TorqueHttpClient instead
|