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/tcp.py ADDED
@@ -0,0 +1,575 @@
1
+ """Torque TCP binary ingest client and TQBF writer.
2
+
3
+ Uses only Python stdlib: socket, struct, zlib, io. Zero external dependencies.
4
+
5
+ Wire format matches the .NET SDK's TorqueIngestClient and the Rust server's
6
+ ingest protocol (src/ingest/protocol.rs). All multi-byte integers are big-endian.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import socket
12
+ import struct
13
+ import zlib
14
+ from io import BytesIO
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+
19
+ # ── Protocol Constants ──────────────────────────────────────────────────
20
+
21
+ # Client -> Server message types
22
+ MSG_START_INGEST = 0x01
23
+ MSG_DOC_BATCH = 0x02
24
+ MSG_COMMIT = 0x03
25
+ MSG_ABORT = 0x04
26
+
27
+ # Server -> Client message types
28
+ RESP_ACK = 0x10
29
+ RESP_COMMIT_RESULT = 0x11
30
+ RESP_ERROR = 0x12
31
+ RESP_BACKPRESSURE = 0x13
32
+
33
+
34
+ # ── Response Models ─────────────────────────────────────────────────────
35
+
36
+ @dataclass
37
+ class BatchAck:
38
+ batch_seq: int
39
+ docs_received: int
40
+
41
+
42
+ @dataclass
43
+ class CommitResult:
44
+ success: bool
45
+ docs_indexed: int
46
+ build_ms: int
47
+
48
+
49
+ # ── Exceptions ──────────────────────────────────────────────────────────
50
+
51
+ class TorqueProtocolError(Exception):
52
+ def __init__(self, code: int, message: str) -> None:
53
+ super().__init__(f"[code={code}] {message}")
54
+ self.code = code
55
+
56
+
57
+ class TorqueConnectionError(Exception):
58
+ pass
59
+
60
+
61
+ # ── Frame Codec ─────────────────────────────────────────────────────────
62
+
63
+ def _write_frame(sock: socket.socket, payload: bytes) -> None:
64
+ header = struct.pack("!I", len(payload))
65
+ sock.sendall(header + payload)
66
+
67
+
68
+ def _read_exact(sock: socket.socket, n: int) -> bytes:
69
+ buf = bytearray()
70
+ while len(buf) < n:
71
+ chunk = sock.recv(n - len(buf))
72
+ if not chunk:
73
+ raise TorqueConnectionError("Connection closed while reading frame")
74
+ buf.extend(chunk)
75
+ return bytes(buf)
76
+
77
+
78
+ def _read_frame(sock: socket.socket) -> bytes:
79
+ header = _read_exact(sock, 4)
80
+ length = struct.unpack("!I", header)[0]
81
+ if length == 0:
82
+ return b""
83
+ return _read_exact(sock, length)
84
+
85
+
86
+ # ── Response Decoder ────────────────────────────────────────────────────
87
+
88
+ def _decode_response(data: bytes) -> BatchAck | CommitResult:
89
+ if not data:
90
+ raise TorqueProtocolError(0, "Empty server message")
91
+
92
+ msg_type = data[0]
93
+ payload = data[1:]
94
+
95
+ if msg_type == RESP_ACK:
96
+ if len(payload) < 16:
97
+ raise TorqueProtocolError(0, "Ack message too short")
98
+ batch_seq, docs_received = struct.unpack_from("!QQ", payload)
99
+ return BatchAck(batch_seq=batch_seq, docs_received=docs_received)
100
+
101
+ elif msg_type == RESP_COMMIT_RESULT:
102
+ if len(payload) < 17:
103
+ raise TorqueProtocolError(0, "CommitResult message too short")
104
+ success = payload[0] != 0
105
+ docs_indexed, build_ms = struct.unpack_from("!QQ", payload, 1)
106
+ return CommitResult(success=success, docs_indexed=docs_indexed, build_ms=build_ms)
107
+
108
+ elif msg_type == RESP_ERROR:
109
+ if len(payload) < 6:
110
+ raise TorqueProtocolError(0, "Error message too short")
111
+ code = struct.unpack_from("!H", payload, 0)[0]
112
+ msg_len = struct.unpack_from("!I", payload, 2)[0]
113
+ message = "Unknown error"
114
+ if msg_len > 0 and msg_len <= len(payload) - 6:
115
+ message = payload[6:6 + msg_len].decode("utf-8", errors="replace")
116
+ raise TorqueProtocolError(code, message)
117
+
118
+ elif msg_type == RESP_BACKPRESSURE:
119
+ if len(payload) < 4:
120
+ raise TorqueProtocolError(0, "Backpressure message too short")
121
+ retry_after_ms = struct.unpack_from("!I", payload, 0)[0]
122
+ raise TorqueProtocolError(
123
+ 8, f"Server backpressure, retry after {retry_after_ms}ms"
124
+ )
125
+
126
+ else:
127
+ raise TorqueProtocolError(0, f"Unknown server message type: 0x{msg_type:02X}")
128
+
129
+
130
+ # ── Document Flattener ─────────────────────────────────────────────────
131
+
132
+ def flatten_document(doc: dict[str, Any]) -> dict[str, Any]:
133
+ """Flatten nested objects/arrays in a document to dotted keys.
134
+
135
+ Matches the Rust server's ``flatten_json_object`` logic:
136
+ - Nested objects become dotted keys: ``{"a": {"b": 1}}`` -> ``{"a.b": 1}``
137
+ - Arrays of objects are pivoted: ``{"t": [{"n": "x"}, {"n": "y"}]}``
138
+ -> ``{"t.n": ["x", "y"]}`` with null-padding for missing fields.
139
+ - Scalars and flat arrays pass through unchanged.
140
+ """
141
+ return _flatten_inner(doc, 0)
142
+
143
+
144
+ def _flatten_inner(obj: dict[str, Any], depth: int) -> dict[str, Any]:
145
+
146
+ result: dict[str, Any] = {}
147
+ for key, value in obj.items():
148
+ if isinstance(value, dict):
149
+ flat_inner = _flatten_inner(value, depth + 1)
150
+ for inner_key, inner_val in flat_inner.items():
151
+ result[f"{key}.{inner_key}"] = inner_val
152
+ elif (
153
+ isinstance(value, list)
154
+ and len(value) > 0
155
+ and isinstance(value[0], dict)
156
+ ):
157
+ # Collect all field names across all elements (preserving order)
158
+ seen: set[str] = set()
159
+ all_field_names: list[str] = []
160
+ for item in value:
161
+ if isinstance(item, dict):
162
+ for field_name in item:
163
+ if field_name not in seen:
164
+ seen.add(field_name)
165
+ all_field_names.append(field_name)
166
+ # Build arrays with null-padding for missing fields
167
+ for field_name in all_field_names:
168
+ arr_key = f"{key}.{field_name}"
169
+ values = []
170
+ for item in value:
171
+ if isinstance(item, dict):
172
+ values.append(item.get(field_name))
173
+ else:
174
+ values.append(None)
175
+ result[arr_key] = values
176
+ else:
177
+ result[key] = value
178
+
179
+ return result
180
+
181
+
182
+ # ── Document Encoder ────────────────────────────────────────────────────
183
+
184
+ # Type name -> encoder key mapping
185
+ _TYPE_MAP = {
186
+ "string": "string",
187
+ "int32": "int32",
188
+ "int64": "int64",
189
+ "float": "float",
190
+ "bool": "bool",
191
+ "geopoint": "geopoint",
192
+ "string[]": "string[]",
193
+ "int32[]": "int32[]",
194
+ "int64[]": "int64[]",
195
+ "float[]": "float[]",
196
+ }
197
+
198
+
199
+ class DocumentEncoder:
200
+ """Encodes Python dicts into Torque binary wire format.
201
+
202
+ Wire format per document:
203
+ [u16 BE] id_byte_length
204
+ [bytes] id (UTF-8)
205
+ [u8] language (0 = English)
206
+ [u16 BE] field_count
207
+ For each field in schema order:
208
+ [u8] presence (0 = null, 1 = present)
209
+ If present: encoded value (type-specific)
210
+
211
+ Field encoding:
212
+ string: [u32 BE length][UTF-8 bytes]
213
+ int32: [i32 BE]
214
+ int64: [i64 BE]
215
+ float: [f64 BE] (8 bytes double)
216
+ bool: [u8] (0 or 1)
217
+ geopoint: [f64 BE lat][f64 BE lng]
218
+ string[]: [u16 BE count] then per element: [u8 null_flag] if present: [u32 BE len][UTF-8]
219
+ int32[]: [u16 BE count] then per element: [u8 null_flag] if present: [i32 BE]
220
+ int64[]: [u16 BE count] then per element: [u8 null_flag] if present: [i64 BE]
221
+ float[]: [u16 BE count] then per element: [f32 BE] (4 bytes, no null flag)
222
+ """
223
+
224
+ # Language codes matching Rust enum
225
+ LANG_ENGLISH = 0
226
+ LANG_SWEDISH = 1
227
+ LANG_NORWEGIAN = 2
228
+ LANG_DANISH = 3
229
+ LANG_FINNISH = 4
230
+ LANG_GERMAN = 5
231
+ LANG_FRENCH = 6
232
+ LANG_SPANISH = 7
233
+ LANG_PORTUGUESE = 8
234
+ LANG_ITALIAN = 9
235
+ LANG_DUTCH = 10
236
+ LANG_RUSSIAN = 11
237
+ LANG_ARABIC = 12
238
+ LANG_GREEK = 13
239
+ LANG_HUNGARIAN = 14
240
+ LANG_ROMANIAN = 15
241
+ LANG_TURKISH = 16
242
+
243
+ LANG_MAP = {
244
+ "en": LANG_ENGLISH,
245
+ "sv": LANG_SWEDISH,
246
+ "no": LANG_NORWEGIAN,
247
+ "da": LANG_DANISH,
248
+ "fi": LANG_FINNISH,
249
+ "de": LANG_GERMAN,
250
+ "fr": LANG_FRENCH,
251
+ "es": LANG_SPANISH,
252
+ "pt": LANG_PORTUGUESE,
253
+ "it": LANG_ITALIAN,
254
+ "nl": LANG_DUTCH,
255
+ "ru": LANG_RUSSIAN,
256
+ "ar": LANG_ARABIC,
257
+ "el": LANG_GREEK,
258
+ "hu": LANG_HUNGARIAN,
259
+ "ro": LANG_ROMANIAN,
260
+ "tr": LANG_TURKISH,
261
+ }
262
+
263
+ def __init__(self, schema: dict[str, Any]) -> None:
264
+ self._fields: list[tuple[str, str]] = []
265
+ for f in schema.get("fields", []):
266
+ name = f["name"]
267
+ ftype = f["type"]
268
+ if ftype not in _TYPE_MAP:
269
+ raise ValueError(f"Unsupported field type: {ftype!r} for field {name!r}")
270
+ self._fields.append((name, _TYPE_MAP[ftype]))
271
+
272
+ def encode(self, doc: dict[str, Any], language: str = "en") -> bytes:
273
+ doc = flatten_document(doc)
274
+ buf = BytesIO()
275
+
276
+ # External ID
277
+ doc_id = str(doc.get("id", ""))
278
+ id_bytes = doc_id.encode("utf-8")
279
+ buf.write(struct.pack("!H", len(id_bytes)))
280
+ buf.write(id_bytes)
281
+
282
+ # Language
283
+ lang_code = self.LANG_MAP.get(language, self.LANG_ENGLISH)
284
+ buf.write(struct.pack("B", lang_code))
285
+
286
+ # Field count
287
+ buf.write(struct.pack("!H", len(self._fields)))
288
+
289
+ # Fields in schema order
290
+ for field_name, field_type in self._fields:
291
+ value = doc.get(field_name)
292
+ if value is None:
293
+ buf.write(b"\x00") # null
294
+ else:
295
+ buf.write(b"\x01") # present
296
+ self._encode_value(buf, value, field_type)
297
+
298
+ return buf.getvalue()
299
+
300
+ def encode_batch(self, docs: list[dict[str, Any]], language: str = "en") -> bytes:
301
+ buf = BytesIO()
302
+ for doc in docs:
303
+ buf.write(self.encode(doc, language))
304
+ return buf.getvalue()
305
+
306
+ @staticmethod
307
+ def _encode_value(buf: BytesIO, value: Any, field_type: str) -> None:
308
+ if field_type == "string":
309
+ s = str(value).encode("utf-8")
310
+ buf.write(struct.pack("!I", len(s)))
311
+ buf.write(s)
312
+
313
+ elif field_type == "int32":
314
+ buf.write(struct.pack("!i", int(value)))
315
+
316
+ elif field_type == "int64":
317
+ buf.write(struct.pack("!q", int(value)))
318
+
319
+ elif field_type == "float":
320
+ buf.write(struct.pack("!d", float(value)))
321
+
322
+ elif field_type == "bool":
323
+ buf.write(b"\x01" if value else b"\x00")
324
+
325
+ elif field_type == "geopoint":
326
+ # Accepts [lat, lng] or (lat, lng)
327
+ lat, lng = float(value[0]), float(value[1])
328
+ buf.write(struct.pack("!dd", lat, lng))
329
+
330
+ elif field_type == "string[]":
331
+ arr = list(value)
332
+ buf.write(struct.pack("!H", len(arr)))
333
+ for item in arr:
334
+ if item is None:
335
+ buf.write(b"\x00")
336
+ else:
337
+ buf.write(b"\x01")
338
+ s = str(item).encode("utf-8")
339
+ buf.write(struct.pack("!I", len(s)))
340
+ buf.write(s)
341
+
342
+ elif field_type == "int32[]":
343
+ arr = list(value)
344
+ buf.write(struct.pack("!H", len(arr)))
345
+ for item in arr:
346
+ if item is None:
347
+ buf.write(b"\x00")
348
+ else:
349
+ buf.write(b"\x01")
350
+ buf.write(struct.pack("!i", int(item)))
351
+
352
+ elif field_type == "int64[]":
353
+ arr = list(value)
354
+ buf.write(struct.pack("!H", len(arr)))
355
+ for item in arr:
356
+ if item is None:
357
+ buf.write(b"\x00")
358
+ else:
359
+ buf.write(b"\x01")
360
+ buf.write(struct.pack("!q", int(item)))
361
+
362
+ elif field_type == "float[]":
363
+ arr = list(value)
364
+ buf.write(struct.pack("!H", len(arr)))
365
+ for item in arr:
366
+ buf.write(struct.pack("!f", float(item)))
367
+
368
+
369
+ # ── TCP Ingest Client ───────────────────────────────────────────────────
370
+
371
+ class TorqueIngestClient:
372
+ """TCP ingest client for streaming documents to Torque via the binary protocol.
373
+
374
+ Usage:
375
+ encoder = DocumentEncoder(schema)
376
+ with TorqueIngestClient() as client:
377
+ client.connect("localhost", 8119)
378
+ client.start_ingest("my_collection")
379
+ client.send_batch(documents, encoder)
380
+ result = client.commit()
381
+ print(f"Indexed {result.docs_indexed} docs in {result.build_ms}ms")
382
+ """
383
+
384
+ def __init__(self, *, recv_buf_size: int = 4096) -> None:
385
+ self._sock: socket.socket | None = None
386
+ self._recv_buf_size = recv_buf_size
387
+
388
+ def __enter__(self) -> TorqueIngestClient:
389
+ return self
390
+
391
+ def __exit__(self, *args: Any) -> None:
392
+ self.close()
393
+
394
+ def connect(self, host: str, port: int, *, timeout: float = 30.0) -> None:
395
+ if self._sock is not None:
396
+ raise RuntimeError("Already connected")
397
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
398
+ self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
399
+ self._sock.settimeout(timeout)
400
+ try:
401
+ self._sock.connect((host, port))
402
+ except OSError as e:
403
+ self._sock.close()
404
+ self._sock = None
405
+ raise TorqueConnectionError(
406
+ f"Failed to connect to {host}:{port}: {e}"
407
+ ) from e
408
+
409
+ def start_ingest(self, collection: str, *, mode: int = 0) -> BatchAck:
410
+ self._ensure_connected()
411
+ # Encode: [0x01][u32 BE coll_len][coll UTF-8][u8 mode]
412
+ coll_bytes = collection.encode("utf-8")
413
+ payload = (
414
+ struct.pack("B", MSG_START_INGEST)
415
+ + struct.pack("!I", len(coll_bytes))
416
+ + coll_bytes
417
+ + struct.pack("B", mode)
418
+ )
419
+ _write_frame(self._sock, payload)
420
+ data = _read_frame(self._sock)
421
+ result = _decode_response(data)
422
+ if not isinstance(result, BatchAck):
423
+ raise TorqueProtocolError(0, f"Expected Ack, got {type(result).__name__}")
424
+ return result
425
+
426
+ def send_batch(
427
+ self,
428
+ documents: list[dict[str, Any]],
429
+ encoder: DocumentEncoder,
430
+ *,
431
+ language: str = "en",
432
+ ) -> BatchAck:
433
+ self._ensure_connected()
434
+ # Encode: [0x02][u32 BE doc_count][encoded docs...]
435
+ doc_payload = encoder.encode_batch(documents, language)
436
+ header = (
437
+ struct.pack("B", MSG_DOC_BATCH)
438
+ + struct.pack("!I", len(documents))
439
+ )
440
+ payload = header + doc_payload
441
+ _write_frame(self._sock, payload)
442
+ data = _read_frame(self._sock)
443
+ result = _decode_response(data)
444
+ if not isinstance(result, BatchAck):
445
+ raise TorqueProtocolError(0, f"Expected Ack, got {type(result).__name__}")
446
+ return result
447
+
448
+ def commit(self) -> BatchAck:
449
+ """Send Commit. Server responds with Ack (index builds async)."""
450
+ self._ensure_connected()
451
+ payload = struct.pack("B", MSG_COMMIT)
452
+ _write_frame(self._sock, payload)
453
+ data = _read_frame(self._sock)
454
+ result = _decode_response(data)
455
+ if isinstance(result, (BatchAck, CommitResult)):
456
+ return BatchAck(batch_seq=getattr(result, 'batch_seq', 0),
457
+ docs_received=getattr(result, 'docs_received', getattr(result, 'docs_indexed', 0)))
458
+ raise TorqueProtocolError(0, f"Expected Ack, got {type(result).__name__}")
459
+
460
+ def abort(self) -> BatchAck:
461
+ self._ensure_connected()
462
+ payload = struct.pack("B", MSG_ABORT)
463
+ _write_frame(self._sock, payload)
464
+ data = _read_frame(self._sock)
465
+ result = _decode_response(data)
466
+ if not isinstance(result, BatchAck):
467
+ raise TorqueProtocolError(0, f"Expected Ack, got {type(result).__name__}")
468
+ return result
469
+
470
+ def send_documents(
471
+ self,
472
+ documents: list[dict[str, Any]],
473
+ encoder: DocumentEncoder,
474
+ *,
475
+ batch_size: int = 50_000,
476
+ language: str = "en",
477
+ ) -> BatchAck:
478
+ last_ack = None
479
+ for offset in range(0, len(documents), batch_size):
480
+ chunk = documents[offset:offset + batch_size]
481
+ last_ack = self.send_batch(chunk, encoder, language=language)
482
+ if last_ack is None:
483
+ raise ValueError("No documents to send")
484
+ return last_ack
485
+
486
+ def close(self) -> None:
487
+ if self._sock is not None:
488
+ try:
489
+ self._sock.close()
490
+ except OSError:
491
+ pass
492
+ self._sock = None
493
+
494
+ def _ensure_connected(self) -> None:
495
+ if self._sock is None:
496
+ raise RuntimeError("Not connected. Call connect() first.")
497
+
498
+
499
+ # ── TQBF Binary File Writer ────────────────────────────────────────────
500
+
501
+ class TorqueBinaryFileWriter:
502
+ """Streaming binary file writer for TQBF format.
503
+
504
+ Encodes documents using DocumentEncoder for proper binary encoding,
505
+ then ZLib-compresses and writes a TQBF v2 file.
506
+
507
+ File format (TQBF v2):
508
+ [4 bytes] magic: "TQBF"
509
+ [4 bytes] version: 2 (LE)
510
+ [4 bytes] doc_count: u32 (LE)
511
+ [4 bytes] flags: 2 = ZLib compressed binary
512
+ [8 bytes] uncompressed_size: u64 (LE) ← v2 only
513
+ [N bytes] payload: ZLib-compressed concatenated binary documents
514
+
515
+ The uncompressed_size field lets the server pre-size its decompression
516
+ Vec exactly, avoiding doubling-realloc fragmentation that breaks the
517
+ silicon unikernel's contiguous bitmap allocator on large imports.
518
+
519
+ The torque server still accepts v1 files (16-byte header, no size field)
520
+ via the read_to_end fallback, so older clients keep working.
521
+
522
+ Usage:
523
+ encoder = DocumentEncoder(schema)
524
+ with TorqueBinaryFileWriter("data.tqbf", encoder) as writer:
525
+ for doc in data_source:
526
+ writer.write(doc)
527
+ # Upload via client.import_binary("collection", "data.tqbf")
528
+ """
529
+
530
+ def __init__(self, file_path: str, encoder: DocumentEncoder) -> None:
531
+ self._file_path = file_path
532
+ self._encoder = encoder
533
+ self._buffer = BytesIO()
534
+ self._doc_count = 0
535
+ self._finalized = False
536
+
537
+ def __enter__(self) -> TorqueBinaryFileWriter:
538
+ return self
539
+
540
+ def __exit__(self, *args: Any) -> None:
541
+ self.finalize()
542
+
543
+ @property
544
+ def document_count(self) -> int:
545
+ return self._doc_count
546
+
547
+ @property
548
+ def raw_size(self) -> int:
549
+ return self._buffer.tell()
550
+
551
+ def write(self, document: dict[str, Any], language: str = "en") -> None:
552
+ if self._finalized:
553
+ raise RuntimeError("Writer has been finalized.")
554
+ encoded = self._encoder.encode(document, language)
555
+ self._buffer.write(encoded)
556
+ self._doc_count += 1
557
+
558
+ def finalize(self) -> str:
559
+ if self._finalized:
560
+ return self._file_path
561
+ self._finalized = True
562
+
563
+ raw = self._buffer.getvalue()
564
+ uncompressed_size = len(raw)
565
+ compressed = zlib.compress(raw, level=6)
566
+
567
+ with open(self._file_path, "wb") as f:
568
+ f.write(b"TQBF")
569
+ f.write(struct.pack("<I", 2)) # version: 2
570
+ f.write(struct.pack("<I", self._doc_count)) # doc_count
571
+ f.write(struct.pack("<I", 2)) # flags: 2 = ZLib binary
572
+ f.write(struct.pack("<Q", uncompressed_size)) # NEW: uncompressed size (u64)
573
+ f.write(compressed)
574
+
575
+ return self._file_path
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: torque-http
3
+ Version: 0.5.0
4
+ Summary: Official Python client for Torque — Truespar's in-memory search engine with a Typesense v30.1 compatible API. Zero external dependencies.
5
+ Project-URL: Homepage, https://truespar.com/torque/docs
6
+ Project-URL: Documentation, https://truespar.com/torque/docs
7
+ Author: Truespar
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: search,torque,truespar,typesense,vector-search
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Database :: Front-Ends
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # torque-http
24
+
25
+ Official Python client for [Torque](https://truespar.com/torque/docs) — Truespar's in-memory search engine with a Typesense v30.1 compatible API.
26
+
27
+ - **Zero external dependencies** — uses only the Python standard library
28
+ - **HTTP API** for search, collection management, and document CRUD
29
+ - **TCP binary ingest** for high-throughput streaming from databases
30
+ - **TQBF file upload** for bulk import
31
+ - Multi-node failover with automatic retry
32
+ - Typed search parameters and response models
33
+ - Python 3.10+, MIT licensed
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install torque-http
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```python
44
+ from torque_http import TorqueHttpClient, SearchParameters
45
+
46
+ client = TorqueHttpClient("http://localhost:8108", api_key="your-key")
47
+
48
+ # Search
49
+ results = client.search("products", q="laptop", query_by="title")
50
+ for hit in results.hits:
51
+ print(hit.document["title"])
52
+
53
+ # Typed parameters
54
+ params = SearchParameters(
55
+ q="laptop",
56
+ query_by="title,description",
57
+ filter_by="price:>100",
58
+ per_page=20,
59
+ )
60
+ results = client.search("products", params)
61
+ ```
62
+
63
+ ## Multi-node with failover
64
+
65
+ ```python
66
+ client = TorqueHttpClient(
67
+ ["http://node1:8108", "http://node2:8108"],
68
+ api_key="your-key",
69
+ num_retries=5,
70
+ )
71
+ ```
72
+
73
+ ## Collection management
74
+
75
+ ```python
76
+ schema = {
77
+ "name": "products",
78
+ "fields": [
79
+ {"name": "title", "type": "string"},
80
+ {"name": "price", "type": "float", "sort": True},
81
+ {"name": "brand", "type": "string", "facet": True},
82
+ ],
83
+ }
84
+ client.create_collection(schema)
85
+
86
+ client.create_document("products", {
87
+ "id": "1", "title": "Laptop Pro", "price": 1299.99, "brand": "Acme",
88
+ })
89
+ ```
90
+
91
+ ## High-throughput TCP ingest
92
+
93
+ For streaming 100K+ documents, use the binary TCP protocol:
94
+
95
+ ```python
96
+ from torque_http import TorqueIngestClient, DocumentEncoder
97
+
98
+ schema = {"fields": [
99
+ {"name": "title", "type": "string"},
100
+ {"name": "price", "type": "float"},
101
+ ]}
102
+ encoder = DocumentEncoder(schema)
103
+
104
+ with TorqueIngestClient() as tcp:
105
+ tcp.connect("localhost", 8109)
106
+ tcp.start_ingest("products")
107
+ tcp.send_batch(documents, encoder)
108
+ tcp.commit()
109
+ ```
110
+
111
+ ## TQBF bulk file import
112
+
113
+ Pre-encode a compressed binary file for maximum throughput:
114
+
115
+ ```python
116
+ from torque_http import TorqueBinaryFileWriter, DocumentEncoder
117
+
118
+ encoder = DocumentEncoder(schema)
119
+ with TorqueBinaryFileWriter("data.tqbf", encoder) as writer:
120
+ for doc in source:
121
+ writer.write(doc)
122
+
123
+ client.import_binary("products", "data.tqbf")
124
+ ```
125
+
126
+ ## Documentation
127
+
128
+ - [Torque documentation](https://truespar.com/torque/docs)
129
+
130
+ ## License
131
+
132
+ MIT — see [LICENSE](LICENSE).