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/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