async-sonic 0.1.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.
@@ -0,0 +1,548 @@
1
+ """Asyncio client for Sonic (https://github.com/valeriansaliou/sonic), zero dependencies.
2
+
3
+ Usage: ``async with Sonic("localhost", 1491, "password") as sonic: await sonic.query(...)``.
4
+ Full documentation in README.md and llms.txt.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import re
11
+ from collections import deque
12
+ from typing import Self
13
+
14
+ __all__ = [
15
+ "Sonic",
16
+ "SonicConnectionError",
17
+ "SonicError",
18
+ "SonicProtocolError",
19
+ "SonicServerError",
20
+ "SonicTimeout",
21
+ "quote",
22
+ ]
23
+
24
+
25
+ class SonicError(Exception):
26
+ """Base class of every error raised by this library."""
27
+
28
+
29
+ class SonicConnectionError(SonicError):
30
+ """Could not connect, or the connection dropped or is closed.
31
+
32
+ In-flight commands on that connection fail with this; the next command opens a new one.
33
+ """
34
+
35
+
36
+ class SonicTimeout(SonicConnectionError):
37
+ """`connect_timeout` (while connecting) or `timeout` (per command) expired."""
38
+
39
+
40
+ class SonicServerError(SonicError):
41
+ """Sonic answered `ERR <code>(<detail>)`. `.code` is the code, `.line` the whole line."""
42
+
43
+ def __init__(self, line: str) -> None:
44
+ match = re.match(r"ERR (\w+)", line)
45
+ self.line = line
46
+ self.code = match.group(1) if match else ""
47
+ hint = _HINTS.get(self.code, "")
48
+ super().__init__(f"Sonic rejected the command: {line}" + (f". {hint}" if hint else ""))
49
+
50
+
51
+ class SonicProtocolError(SonicError):
52
+ """Sonic said something PROTOCOL.md does not cover (incompatible version?). The connection is closed."""
53
+
54
+
55
+ _HINTS = {
56
+ "authentication_failed": "Wrong password: use `channel.auth_password` from sonic.cfg.",
57
+ "invalid_format": "Invalid command format: if you only use the public API this is an "
58
+ "async-sonic bug; please open an issue with the text you sent.",
59
+ "policy_reject": "Value outside the server limits (limit/offset/text): check "
60
+ "`[search]` and `[store]` in sonic.cfg.",
61
+ "unknown_command": "This Sonic version does not know that command.",
62
+ "not_found": "Sonic does not know that resource or action (e.g. trigger: consolidate, backup, restore).",
63
+ }
64
+ _BUFFER = re.compile(r"buffer\((\d+)\)")
65
+ _KV = re.compile(r"(\w+)\((-?\d+)\)")
66
+
67
+
68
+ def quote(text: str) -> str:
69
+ """Quote text for PUSH/POP/QUERY/SUGGEST. E.g. ``quote('say "hi"')``.
70
+
71
+ PROTOCOL.md only requires `\\"` for quotes; the backslash is doubled so that a trailing
72
+ backslash cannot swallow the closing quote. Newlines become spaces: a raw newline would
73
+ end the command halfway (Sonic tokenizes on spaces, so no word is lost).
74
+ """
75
+ flat = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
76
+ return '"' + flat.replace("\\", "\\\\").replace('"', '\\"') + '"'
77
+
78
+
79
+ def _token(value: str, what: str) -> str:
80
+ if not value or any(c.isspace() or c == '"' or not c.isprintable() for c in value):
81
+ raise ValueError(
82
+ f"Invalid {what} {value!r}: it cannot be empty or contain whitespace, quotes or "
83
+ "control characters. Use a short name such as 'videos' or 'video:42'."
84
+ )
85
+ return value
86
+
87
+
88
+ def _opts(*, limit: int | None = None, offset: int | None = None, lang: str | None = None) -> str:
89
+ out = ""
90
+ if limit is not None:
91
+ out += f" LIMIT({int(limit)})"
92
+ if offset is not None:
93
+ out += f" OFFSET({int(offset)})"
94
+ if lang is not None:
95
+ if not lang.isalpha():
96
+ raise ValueError(f"Invalid lang {lang!r}: use an ISO 639-3 code ('eng') or 'none'.")
97
+ out += f" LANG({lang})"
98
+ return out
99
+
100
+
101
+ def _settle(fut: asyncio.Future[str], value: str | Exception) -> None:
102
+ if not fut.done():
103
+ if isinstance(value, Exception):
104
+ fut.set_exception(value)
105
+ else:
106
+ fut.set_result(value)
107
+
108
+
109
+ class _Conn:
110
+ """One TCP connection in one mode, with a background reader.
111
+
112
+ Pipelining: every command is written without waiting. Immediate replies (OK, RESULT,
113
+ PONG, PENDING <id>, ERR) arrive in order, so a FIFO of futures matches them. The `EVENT`
114
+ lines of QUERY/SUGGEST/LIST (PROTOCOL.md: they may arrive out of order) are matched by id.
115
+ A command whose timeout expires leaves its slot in the queue/table: the late reply is
116
+ discarded on arrival, so the connection never gets out of sync.
117
+ """
118
+
119
+ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
120
+ self._reader, self._writer = reader, writer
121
+ self.buffer = 0
122
+ self.load = 0 # active calls, used to pick a connection
123
+ self.timeout = 10.0
124
+ self.gate: asyncio.Semaphore | None = None
125
+ self._fifo: deque[tuple[asyncio.Future[str], str | None]] = deque()
126
+ self._events: dict[str, tuple[asyncio.Future[str], str]] = {}
127
+ self._task: asyncio.Task[None] | None = None
128
+ self.closed = False
129
+
130
+ @classmethod
131
+ async def open(
132
+ cls,
133
+ host: str,
134
+ port: int,
135
+ password: str,
136
+ mode: str,
137
+ *,
138
+ connect_timeout: float,
139
+ timeout: float,
140
+ max_in_flight: int | None,
141
+ ) -> _Conn:
142
+ where = f"{host}:{port}"
143
+ try:
144
+ async with asyncio.timeout(connect_timeout):
145
+ reader, writer = await asyncio.open_connection(host, port, limit=1 << 20)
146
+ conn = cls(reader, writer)
147
+ try:
148
+ greeting = await conn._readline()
149
+ if not greeting.startswith("CONNECTED "):
150
+ raise SonicProtocolError(
151
+ f"{where} does not speak the Sonic protocol (greeting {greeting!r}): "
152
+ "check that the port is the Sonic Channel one (1491 by default)."
153
+ )
154
+ writer.write(f"START {mode} {password}\n".encode())
155
+ started = await conn._readline()
156
+ match = _BUFFER.search(started)
157
+ if not started.startswith(f"STARTED {mode} ") or match is None:
158
+ raise SonicProtocolError(f"Unexpected reply to START: {started!r}")
159
+ conn.buffer = int(match.group(1))
160
+ except BaseException:
161
+ writer.close()
162
+ raise
163
+ except TimeoutError:
164
+ raise SonicTimeout(
165
+ f"Timeout ({connect_timeout}s) connecting to Sonic at {where}: check "
166
+ "host/port or raise `connect_timeout`."
167
+ ) from None
168
+ except OSError as exc:
169
+ raise SonicConnectionError(
170
+ f"Could not connect to Sonic at {where} ({exc}): check that Sonic is "
171
+ "running and that host and port are correct."
172
+ ) from exc
173
+ conn.timeout = timeout
174
+ conn.gate = asyncio.Semaphore(max_in_flight) if max_in_flight else None
175
+ conn._task = asyncio.create_task(conn._run())
176
+ return conn
177
+
178
+ async def _readline(self) -> str:
179
+ try:
180
+ raw = await self._reader.readline()
181
+ except ValueError as exc: # longer than `limit`
182
+ raise SonicProtocolError("Sonic sent a line longer than 1 MiB") from exc
183
+ except OSError as exc:
184
+ raise SonicConnectionError(f"Lost the connection to Sonic ({exc}).") from exc
185
+ if not raw.endswith(b"\n"):
186
+ raise SonicConnectionError(
187
+ "Sonic closed the connection (restart, `tcp_timeout` or crash): the next "
188
+ "command will open a new connection."
189
+ )
190
+ line = raw.decode(errors="replace").rstrip("\r\n")
191
+ if line.startswith("ENDED authentication_failed") and self._task is None:
192
+ raise SonicServerError("ERR authentication_failed") # this is how Sonic answers START
193
+ if line.startswith("ENDED "):
194
+ raise SonicConnectionError(f"Sonic ended the session ({line}).")
195
+ if line.startswith("ERR ") and self._task is None: # during the handshake
196
+ raise SonicServerError(line)
197
+ return line
198
+
199
+ async def _run(self) -> None:
200
+ exc: Exception
201
+ try:
202
+ while True:
203
+ self._dispatch(await self._readline())
204
+ except SonicError as e:
205
+ exc = e
206
+ except Exception as e: # ponytail: anything unexpected also closes the connection
207
+ exc = SonicProtocolError(f"Internal error while reading from Sonic: {e!r}")
208
+ self._shutdown(exc)
209
+
210
+ def _dispatch(self, line: str) -> None:
211
+ if line.startswith("EVENT "):
212
+ parts = line.split(" ", 3)
213
+ entry = self._events.pop(parts[2], None) if len(parts) > 2 else None
214
+ if entry is None:
215
+ raise SonicProtocolError(f"EVENT without a previous PENDING: {line!r}")
216
+ fut, name = entry
217
+ if parts[1] != name:
218
+ _settle(fut, SonicProtocolError(f"Expected EVENT {name}, got {line!r}"))
219
+ else:
220
+ _settle(fut, parts[3] if len(parts) > 3 else "")
221
+ return
222
+ if not self._fifo:
223
+ raise SonicProtocolError(f"Reply with no pending command: {line!r}")
224
+ fut, event = self._fifo.popleft()
225
+ if line.startswith("ERR "):
226
+ _settle(fut, SonicServerError(line))
227
+ elif event is None and not line.startswith("PENDING "):
228
+ _settle(fut, line)
229
+ elif event is not None and line.startswith("PENDING "):
230
+ self._events[line.removeprefix("PENDING ").strip()] = (fut, event)
231
+ else:
232
+ _settle(fut, SonicProtocolError(f"Unexpected reply: {line!r}"))
233
+
234
+ def _shutdown(self, exc: Exception) -> None:
235
+ self.closed = True
236
+ self._writer.close()
237
+ pending = [f for f, _ in self._fifo] + [f for f, _ in self._events.values()]
238
+ self._fifo.clear()
239
+ self._events.clear()
240
+ for fut in pending:
241
+ _settle(fut, exc)
242
+
243
+ async def call(self, line: str, event: str | None = None) -> str:
244
+ """One command. With `event`, returns what follows `EVENT <event> <id>`; without it, the
245
+ first reply line."""
246
+ if self.closed:
247
+ raise SonicConnectionError(
248
+ "Connection closed: open a new `async with Sonic(...)`, or call again "
249
+ "(the pool opens a new connection)."
250
+ )
251
+ data = line.encode() + b"\n"
252
+ if len(data) > self.buffer:
253
+ raise ValueError(
254
+ f"Command of {len(data)} bytes; the buffer Sonic announces is {self.buffer}. "
255
+ "Shorten the text (PUSH splits it on its own; QUERY/SUGGEST/POP do not)."
256
+ )
257
+ self.load += 1
258
+ try:
259
+ if self.gate is not None:
260
+ await self.gate.acquire()
261
+ try:
262
+ fut: asyncio.Future[str] = asyncio.get_running_loop().create_future()
263
+ self._fifo.append(
264
+ (fut, event)
265
+ ) # queue and write with no await in between: same order
266
+ self._writer.write(data)
267
+ try:
268
+ async with asyncio.timeout(self.timeout):
269
+ await self._writer.drain()
270
+ return await fut
271
+ except TimeoutError:
272
+ raise SonicTimeout(
273
+ f"Sonic did not answer {line[:40]!r} within {self.timeout}s: raise `timeout=` "
274
+ "or check the server load. The connection is still usable."
275
+ ) from None
276
+ except OSError as exc:
277
+ raise SonicConnectionError(f"Lost the connection to Sonic ({exc}).") from exc
278
+ finally:
279
+ fut.cancel()
280
+ finally:
281
+ if self.gate is not None:
282
+ self.gate.release()
283
+ finally:
284
+ self.load -= 1
285
+
286
+ async def close(self, timeout: float) -> None:
287
+ if self.closed or self._task is None:
288
+ return
289
+ self._writer.write(b"QUIT\n")
290
+ try:
291
+ async with asyncio.timeout(timeout):
292
+ await self._task # the server answers ENDED and closes
293
+ except TimeoutError, OSError:
294
+ pass
295
+ finally:
296
+ self._task.cancel()
297
+ self._shutdown(SonicConnectionError("Connection closed by the client."))
298
+
299
+
300
+ class _Pool:
301
+ """Up to `pool_size` connections of one mode, opened on demand. Picks the least loaded."""
302
+
303
+ def __init__(self, mode: str, sonic: Sonic) -> None:
304
+ self.mode, self.sonic = mode, sonic
305
+ self.conns: list[_Conn] = []
306
+ self._lock = asyncio.Lock()
307
+
308
+ def _best(self) -> _Conn | None:
309
+ self.conns = [c for c in self.conns if not c.closed]
310
+ best = min(self.conns, key=lambda c: c.load, default=None)
311
+ if best is not None and (best.load == 0 or len(self.conns) >= self.sonic.pool_size):
312
+ return best
313
+ return None
314
+
315
+ async def get(self) -> _Conn:
316
+ if (conn := self._best()) is not None:
317
+ return conn
318
+ async with self._lock:
319
+ if (conn := self._best()) is not None:
320
+ return conn
321
+ s = self.sonic
322
+ conn = await _Conn.open(
323
+ s.host,
324
+ s.port,
325
+ s.password,
326
+ self.mode,
327
+ connect_timeout=s.connect_timeout,
328
+ timeout=s.timeout,
329
+ max_in_flight=s.max_in_flight,
330
+ )
331
+ self.conns.append(conn)
332
+ return conn
333
+
334
+ async def call(self, line: str, event: str | None = None) -> str:
335
+ return await (await self.get()).call(line, event)
336
+
337
+ async def close(self) -> None:
338
+ await asyncio.gather(*(c.close(self.sonic.connect_timeout) for c in self.conns))
339
+ self.conns.clear()
340
+
341
+
342
+ def _ok(reply: str) -> None:
343
+ if reply != "OK":
344
+ raise SonicProtocolError(f"Expected OK, got {reply!r}")
345
+
346
+
347
+ def _result(reply: str) -> str:
348
+ if not reply.startswith("RESULT "):
349
+ raise SonicProtocolError(f"Expected RESULT, got {reply!r}")
350
+ return reply.removeprefix("RESULT ")
351
+
352
+
353
+ def _int(reply: str) -> int:
354
+ text = _result(reply)
355
+ if not text.isdecimal():
356
+ raise SonicProtocolError(f"Expected an integer, got {reply!r}")
357
+ return int(text)
358
+
359
+
360
+ def _split(escaped: str, room: int) -> list[str]:
361
+ """Split on spaces into chunks of <= room UTF-8 bytes (escaping never creates spaces)."""
362
+ if room < 1:
363
+ raise ValueError("The buffer Sonic announces leaves no room for the text")
364
+ chunks: list[str] = []
365
+ cur = ""
366
+ for word in escaped.split(" "):
367
+ size = len(word.encode())
368
+ if size > room:
369
+ raise ValueError(
370
+ f"A {size}-byte word does not fit in the Sonic buffer ({room} usable): "
371
+ "shorten or remove it before indexing."
372
+ )
373
+ joined = f"{cur} {word}" if cur else word
374
+ if len(joined.encode()) <= room:
375
+ cur = joined
376
+ else:
377
+ chunks.append(cur)
378
+ cur = word
379
+ chunks.append(cur)
380
+ return chunks
381
+
382
+
383
+ class Sonic:
384
+ """Sonic client. Everything is a flat method; the mode (search/ingest/control), the
385
+ handshake and the connection pool are handled by the class.
386
+
387
+ >>> async with Sonic("localhost", 1491, "SecretPassword") as sonic: # doctest: +SKIP
388
+ ... await sonic.push("videos", "catalog", "video:1", "cats and dogs", lang="eng")
389
+
390
+ Connections: opened on first use, up to `pool_size` per channel, with pipelining (each
391
+ connection allows `max_in_flight` simultaneous commands; `None` = unlimited). No retries:
392
+ if a connection drops, its in-flight commands fail with `SonicConnectionError` and the
393
+ next command opens a new connection. `timeout` is per command; when it expires
394
+ `SonicTimeout` is raised but the connection stays alive.
395
+ """
396
+
397
+ def __init__(
398
+ self,
399
+ host: str = "localhost",
400
+ port: int = 1491,
401
+ password: str = "",
402
+ *,
403
+ pool_size: int = 4,
404
+ max_in_flight: int | None = None,
405
+ timeout: float = 10.0,
406
+ connect_timeout: float = 5.0,
407
+ ) -> None:
408
+ self.host, self.port = host, port
409
+ self.password = _token(password, "password") if password else ""
410
+ self.pool_size, self.max_in_flight = max(1, pool_size), max_in_flight
411
+ self.timeout, self.connect_timeout = timeout, connect_timeout
412
+ self._search = _Pool("search", self)
413
+ self._ingest = _Pool("ingest", self)
414
+ self._control = _Pool("control", self)
415
+
416
+ async def __aenter__(self) -> Self:
417
+ return self
418
+
419
+ async def __aexit__(self, *exc: object) -> None:
420
+ await self.close()
421
+
422
+ async def close(self) -> None:
423
+ """Close every connection (QUIT). E.g. ``await sonic.close()``. Never raises."""
424
+ await asyncio.gather(self._search.close(), self._ingest.close(), self._control.close())
425
+
426
+ # -- search ---------------------------------------------------------------------
427
+
428
+ async def query(
429
+ self,
430
+ collection: str,
431
+ bucket: str,
432
+ terms: str,
433
+ *,
434
+ limit: int | None = None,
435
+ offset: int | None = None,
436
+ lang: str | None = None,
437
+ ) -> list[str]:
438
+ """Object ids matching `terms`, best first. E.g. ``await sonic.query("videos", "catalog", "cats", limit=10)``."""
439
+ line = (
440
+ f"QUERY {_token(collection, 'collection')} {_token(bucket, 'bucket')} {quote(terms)}"
441
+ + _opts(limit=limit, offset=offset, lang=lang)
442
+ )
443
+ return (await self._search.call(line, "QUERY")).split()
444
+
445
+ async def suggest(
446
+ self, collection: str, bucket: str, word: str, *, limit: int | None = None
447
+ ) -> list[str]:
448
+ """Words that complete `word`. E.g. ``await sonic.suggest("videos", "catalog", "ca")``."""
449
+ line = (
450
+ f"SUGGEST {_token(collection, 'collection')} {_token(bucket, 'bucket')} {quote(word)}"
451
+ + _opts(limit=limit)
452
+ )
453
+ return (await self._search.call(line, "SUGGEST")).split()
454
+
455
+ async def list_words(
456
+ self, collection: str, bucket: str, *, limit: int | None = None, offset: int | None = None
457
+ ) -> list[str]:
458
+ """Indexed words of the bucket (LIST enumerates words, not objects). E.g. ``await sonic.list_words("videos", "catalog", limit=50)``."""
459
+ line = f"LIST {_token(collection, 'collection')} {_token(bucket, 'bucket')}" + _opts(
460
+ limit=limit, offset=offset
461
+ )
462
+ return (await self._search.call(line, "LIST")).split()
463
+
464
+ # -- ingest ----------------------------------------------------------------------
465
+
466
+ async def push(
467
+ self, collection: str, bucket: str, object: str, text: str, *, lang: str | None = None
468
+ ) -> None:
469
+ """Index `text` for `object`. If it does not fit in the Sonic buffer it is split on words. E.g. ``await sonic.push("videos", "catalog", "video:1", "cats and dogs", lang="eng")``."""
470
+ head = f"PUSH {_token(collection, 'collection')} {_token(bucket, 'bucket')} "
471
+ head += f"{_token(object, 'object')} "
472
+ tail = _opts(lang=lang)
473
+ conn = await self._ingest.get()
474
+ room = conn.buffer - len((head + tail).encode()) - 3 # 2 quotes + newline
475
+ for chunk in _split(quote(text)[1:-1], room):
476
+ _ok(await conn.call(f'{head}"{chunk}"{tail}'))
477
+
478
+ async def pop(self, collection: str, bucket: str, object: str, text: str) -> int:
479
+ """Remove the words of `text` from the object; returns how many. E.g. ``await sonic.pop("videos", "catalog", "video:1", "cats")``."""
480
+ line = (
481
+ f"POP {_token(collection, 'collection')} {_token(bucket, 'bucket')} "
482
+ f"{_token(object, 'object')} {quote(text)}"
483
+ )
484
+ return _int(await self._ingest.call(line))
485
+
486
+ async def count(
487
+ self, collection: str, bucket: str | None = None, object: str | None = None
488
+ ) -> int:
489
+ """Buckets of the collection, objects of the bucket or terms of the object. E.g. ``await sonic.count("videos", "catalog")``.
490
+
491
+ Uses `COUNT`, not `COUNTC/B/O`: PROTOCOL.md lists them but Sonic v1.9.1 answers
492
+ `ERR unknown_command`. Note: on v1.9.1 `count(collection, bucket)` returns distinct
493
+ words, not objects.
494
+ """
495
+ if bucket is None and object is not None:
496
+ raise ValueError("count: `object` requires `bucket`.")
497
+ parts = [_token(collection, "collection")]
498
+ if bucket is not None:
499
+ parts.append(_token(bucket, "bucket"))
500
+ if object is not None:
501
+ parts.append(_token(object, "object"))
502
+ return _int(await self._ingest.call("COUNT " + " ".join(parts)))
503
+
504
+ async def flush_collection(self, collection: str) -> int:
505
+ """Delete the whole collection; returns how many items. E.g. ``await sonic.flush_collection("videos")``."""
506
+ return _int(await self._ingest.call(f"FLUSHC {_token(collection, 'collection')}"))
507
+
508
+ async def flush_bucket(self, collection: str, bucket: str) -> int:
509
+ """Delete a bucket. E.g. ``await sonic.flush_bucket("videos", "catalog")``."""
510
+ line = f"FLUSHB {_token(collection, 'collection')} {_token(bucket, 'bucket')}"
511
+ return _int(await self._ingest.call(line))
512
+
513
+ async def flush_object(self, collection: str, bucket: str, object: str) -> int:
514
+ """Delete an object (does not clean SUGGEST). E.g. ``await sonic.flush_object("videos", "catalog", "video:1")``."""
515
+ line = (
516
+ f"FLUSHO {_token(collection, 'collection')} {_token(bucket, 'bucket')} "
517
+ f"{_token(object, 'object')}"
518
+ )
519
+ return _int(await self._ingest.call(line))
520
+
521
+ # -- control ----------------------------------------------------------------------
522
+
523
+ async def trigger(self, action: str | None = None, data: str | None = None) -> str:
524
+ """`TRIGGER [action] [data]`; actions: consolidate, backup, restore. Returns the result ("" if Sonic answers OK; with no action, the list of actions). E.g. ``await sonic.trigger("consolidate")``."""
525
+ if action is None and data is not None:
526
+ raise ValueError("trigger: `data` requires `action`.")
527
+ line = "TRIGGER"
528
+ if action is not None:
529
+ line += " " + _token(action, "action")
530
+ if data is not None:
531
+ line += " " + _token(data, "data")
532
+ reply = await self._control.call(line)
533
+ return "" if reply == "OK" else _result(reply)
534
+
535
+ async def info(self) -> dict[str, int]:
536
+ """Server metrics (`uptime`, `clients_connected`...). E.g. ``(await sonic.info())["uptime"]``."""
537
+ return {k: int(v) for k, v in _KV.findall(_result(await self._control.call("INFO")))}
538
+
539
+ async def ping(self) -> None:
540
+ """Check that Sonic answers (raises otherwise). E.g. ``await sonic.ping()``."""
541
+ reply = await self._control.call("PING")
542
+ if reply != "PONG":
543
+ raise SonicProtocolError(f"Expected PONG, got {reply!r}")
544
+
545
+ async def help(self, manual: str | None = None) -> str:
546
+ """`HELP [manual]` as is. E.g. ``await sonic.help("commands")``."""
547
+ line = "HELP" if manual is None else f"HELP {_token(manual, 'manual')}"
548
+ return _result(await self._control.call(line))
async_sonic/py.typed ADDED
File without changes
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.5
2
+ Name: async-sonic
3
+ Version: 0.1.0
4
+ Summary: A zero-dependency, fully typed asyncio client for the Sonic search index, with connection pooling and pipelining.
5
+ Project-URL: Homepage, https://github.com/cr0hn/async-sonic
6
+ Project-URL: Repository, https://github.com/cr0hn/async-sonic
7
+ Project-URL: Issues, https://github.com/cr0hn/async-sonic/issues
8
+ Author-email: Daniel Alfocea <cr0hn@cr0hn.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: async,asyncio,client,connection-pool,pipelining,search,sonic,sonic-search,typed
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Database :: Front-Ends
19
+ Classifier: Topic :: Internet
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.14
22
+ Description-Content-Type: text/markdown
23
+
24
+ # async-sonic
25
+
26
+ A zero-dependency, fully typed asyncio client for the [Sonic](https://github.com/valeriansaliou/sonic) search index, with connection pooling and pipelining.
27
+
28
+ [![CI](https://github.com/cr0hn/async-sonic/actions/workflows/ci.yml/badge.svg)](https://github.com/cr0hn/async-sonic/actions/workflows/ci.yml)
29
+ ![Python 3.14+](https://img.shields.io/badge/python-3.14%2B-blue)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
31
+
32
+ LLM-friendly reference: [`llms.txt`](llms.txt).
33
+
34
+ ## Table of contents
35
+
36
+ - [Why async-sonic](#why-async-sonic)
37
+ - [Installation](#installation)
38
+ - [Quickstart](#quickstart)
39
+ - [API reference](#api-reference)
40
+ - [Concurrency and performance](#concurrency-and-performance)
41
+ - [Error handling](#error-handling)
42
+ - [Escaping and limits](#escaping-and-limits)
43
+ - [Compatibility and limitations](#compatibility-and-limitations)
44
+ - [Development](#development)
45
+ - [Contributing](#contributing)
46
+ - [License](#license)
47
+
48
+ ## Why async-sonic
49
+
50
+ Sonic is a fast, lightweight search index that speaks a plain-text protocol
51
+ ([`PROTOCOL.md`](https://github.com/valeriansaliou/sonic/blob/master/PROTOCOL.md)).
52
+ The asyncio client that existed on PyPI (`asonic`, last released in 2020) does not work, and
53
+ the other clients are synchronous. `async-sonic` implements the protocol from its specification
54
+ on top of `asyncio.open_connection` and nothing else.
55
+
56
+ - **One class**: `Sonic(host, port, password)` used as `async with`, with flat, obviously named
57
+ methods (`push`, `query`, `suggest`, ...). Channels, handshake and pool are hidden.
58
+ - **Fast**: a lazy per-channel connection pool plus pipelining (one background reader per
59
+ connection). See [the numbers](#concurrency-and-performance).
60
+ - **Zero runtime dependencies**, Python 3.14+, strict typing, `py.typed`.
61
+ - **Honest errors**: no hidden retries; every exception message says what happened and what to do.
62
+ - **LLM-friendly**: short docstrings with a one-line example on every public method, plus
63
+ [`llms.txt`](llms.txt).
64
+
65
+ ## Installation
66
+
67
+ ```bash
68
+ uv add async-sonic # or: pip install async-sonic
69
+ ```
70
+
71
+ The package is not on PyPI yet; until then install from Git:
72
+
73
+ ```bash
74
+ uv add git+https://github.com/cr0hn/async-sonic
75
+ # or: pip install git+https://github.com/cr0hn/async-sonic
76
+ ```
77
+
78
+ ## Quickstart
79
+
80
+ <!-- quickstart -->
81
+ ```python
82
+ import asyncio
83
+ from async_sonic import Sonic
84
+
85
+
86
+ async def main() -> None:
87
+ async with Sonic("localhost", 1491, "SecretPassword") as sonic:
88
+ await sonic.push("videos", "catalog", "video:1", "cats and dogs are funny", lang="eng")
89
+ await sonic.push("videos", "catalog", "video:2", "stray dogs", lang="eng")
90
+ print(await sonic.query("videos", "catalog", "cats", lang="eng"))
91
+ await sonic.trigger(
92
+ "consolidate"
93
+ ) # SUGGEST reads a graph that is only updated on consolidate
94
+ print(await sonic.suggest("videos", "catalog", "fun"))
95
+ await sonic.flush_collection("videos")
96
+
97
+
98
+ asyncio.run(main())
99
+ ```
100
+ Output:
101
+ ```text
102
+ ['video:1']
103
+ ['funny']
104
+ ```
105
+ <!-- /quickstart -->
106
+
107
+ This block is executed as a test against a real Sonic
108
+ (`tests/test_integration.py::test_readme_quickstart_runs`), so it cannot drift from the code.
109
+ To try it, start Sonic (see [Development](#development)) and use its host, port and password.
110
+
111
+ ## API reference
112
+
113
+ `Sonic` opens nothing on construction or on `async with` entry: connections are opened lazily on
114
+ the first command, one pool per channel.
115
+
116
+ ```python
117
+ Sonic(host="localhost", port=1491, password="", *,
118
+ pool_size=4, max_in_flight=None, timeout=10.0, connect_timeout=5.0)
119
+ ```
120
+
121
+ | Method | Sonic command | Channel | Returns |
122
+ |---|---|---|---|
123
+ | `query(collection, bucket, terms, *, limit, offset, lang)` | `QUERY` | search | `list[str]` object ids, best first |
124
+ | `suggest(collection, bucket, word, *, limit)` | `SUGGEST` | search | `list[str]` words |
125
+ | `list_words(collection, bucket, *, limit, offset)` | `LIST` | search | `list[str]` words |
126
+ | `push(collection, bucket, object, text, *, lang)` | `PUSH` | ingest | `None` |
127
+ | `pop(collection, bucket, object, text)` | `POP` | ingest | `int` |
128
+ | `count(collection, bucket=None, object=None)` | `COUNT` | ingest | `int` |
129
+ | `flush_collection(collection)` | `FLUSHC` | ingest | `int` |
130
+ | `flush_bucket(collection, bucket)` | `FLUSHB` | ingest | `int` |
131
+ | `flush_object(collection, bucket, object)` | `FLUSHO` | ingest | `int` |
132
+ | `trigger(action=None, data=None)` | `TRIGGER` | control | `str` |
133
+ | `info()` | `INFO` | control | `dict[str, int]` |
134
+ | `ping()` | `PING` | control | `None` |
135
+ | `help(manual=None)` | `HELP` | control | `str` |
136
+
137
+ `close()` (or leaving the `async with`) sends `QUIT` on every connection. `quote(text)` is public.
138
+
139
+ `lang` is an ISO 639-3 code (`"eng"`, `"spa"`) or `"none"`. If omitted, Sonic guesses the
140
+ language of the text, and may guess differently at index and query time: pass it on both sides.
141
+
142
+ Deliberate differences from the protocol:
143
+
144
+ - `list_words`: Sonic's `LIST` enumerates **words** of the index, not objects.
145
+ - `count` uses `COUNT` rather than `COUNTC/COUNTB/COUNTO` (see [Compatibility](#compatibility-and-limitations)).
146
+
147
+ ## Concurrency and performance
148
+
149
+ A Sonic channel is a TCP connection in one mode (search, ingest or control).
150
+
151
+ - **Pool**: up to `pool_size` connections per channel, opened when needed (an idle connection is
152
+ reused before a new one is opened). A command goes to the least loaded connection.
153
+ - **Pipelining**: each connection writes commands without waiting for replies, and a background
154
+ reader task hands the replies to futures. `PROTOCOL.md` allows this: immediate replies (`OK`,
155
+ `RESULT`, `PONG`, `PENDING <id>`, `ERR`) arrive **in order** (a FIFO of futures), while the
156
+ `EVENT` lines of `QUERY`/`SUGGEST`/`LIST` may arrive **out of order** and are matched by the
157
+ id of their `PENDING`. `max_in_flight` caps simultaneous commands per connection
158
+ (`None` = unlimited, `1` = no pipelining).
159
+ - **No retries, no magic reconnection.** If a connection drops, its in-flight commands fail with
160
+ `SonicConnectionError`; the *next* command opens a fresh connection. What to retry, and when,
161
+ is your decision (a `PUSH` is idempotent if you `flush_object` first; a `POP` is not).
162
+ - **Per-command timeout** (`timeout`) raises `SonicTimeout`, but the connection stays usable: the
163
+ late reply is discarded when it arrives and nothing gets out of sync.
164
+
165
+ ### Measured numbers
166
+
167
+ `benchmarks/bench.py`: 2000 operations per row, Sonic v1.9.1 in Docker Desktop (4 CPUs) on an
168
+ Apple M2 Max, over localhost. **It is a single run and numbers vary from run to run**: trust the
169
+ orders of magnitude, not the decimals.
170
+
171
+ | Scenario | QUERY ops/s | PUSH ops/s |
172
+ |---|---:|---:|
173
+ | 1 connection, sequential | 2205 | 3061 |
174
+ | pool of 8, concurrent, no pipelining | 10549 | 7559 |
175
+ | 1 connection, pipelining | 13464 | 12795 |
176
+ | pool of 8 + pipelining | 14843 | 11104 |
177
+
178
+ Pipelining is the big lever: 4x to 6x over sequential. On top of it the pool did not help in
179
+ this run (an earlier run showed +40% for queries and nothing for writes), so with pipelining one
180
+ connection is often enough; the pool mostly matters when `max_in_flight` is capped. Over a real
181
+ network with latency the gap to the sequential case is larger, because a round trip is paid once
182
+ per burst instead of once per command; that was not measured.
183
+
184
+ ## Error handling
185
+
186
+ Every exception inherits from `SonicError`; messages say what happened and what to do.
187
+
188
+ | Exception | When |
189
+ |---|---|
190
+ | `SonicConnectionError` | could not connect, or the connection dropped, was closed or received `ENDED` |
191
+ | `SonicTimeout` (subclass of the previous one) | `connect_timeout` or `timeout` expired |
192
+ | `SonicServerError` | Sonic answered `ERR ...` (`.code`, `.line`); also a wrong password (`authentication_failed`) |
193
+ | `SonicProtocolError` | Sonic said something outside `PROTOCOL.md`; the connection is closed |
194
+ | `ValueError` (builtin) | invalid argument, or a command that does not fit in the buffer; nothing is sent |
195
+
196
+ ```python
197
+ from async_sonic import Sonic, SonicServerError, SonicTimeout
198
+
199
+ async with Sonic(password="SecretPassword") as sonic:
200
+ try:
201
+ await sonic.query("videos", "catalog", "cats", limit=0)
202
+ except SonicServerError as exc:
203
+ print(exc.code) # policy_reject
204
+ except SonicTimeout:
205
+ ... # Sonic did not answer in time; the connection is still usable
206
+ ```
207
+
208
+ ## Escaping and limits
209
+
210
+ `quote(text)` wraps text in quotes: `"` becomes `\"`, `\` becomes `\\` (so a trailing backslash
211
+ cannot swallow the closing quote) and newlines (`\n`, `\r`) become a space, because a raw newline
212
+ would cut the command in two; Sonic tokenizes on whitespace, so no word is lost. Unicode goes
213
+ through as UTF-8. `collection`, `bucket`, `object`, `action`... cannot be empty or contain
214
+ whitespace, quotes or control characters (`ValueError`).
215
+
216
+ **Buffer.** `STARTED ... buffer(N)` (20000 by default) is the limit of a whole command line,
217
+ newline included. `PROTOCOL.md` asks clients to split: `push` does it for you, emitting several
218
+ `PUSH` commands for the same object, always cutting **between words**. `query`, `suggest` and
219
+ `pop` are not split (splitting would change their meaning) and raise `ValueError`, as does a
220
+ single word longer than the buffer.
221
+
222
+ ## Compatibility and limitations
223
+
224
+ - **Verified**: Sonic **v1.9.1** (official image `valeriansaliou/sonic:v1.9.1`) on Docker
225
+ Desktop, macOS arm64, Python 3.14. Every command in the table above; text with quotes,
226
+ backslashes, newlines, accents, CJK and emoji; a 48 KB text (split by the buffer); real `ERR`
227
+ replies; a wrong password; and 200 concurrent queries over the pool with pipelining.
228
+ - **Deviations from `PROTOCOL.md` found on v1.9.1**:
229
+ - `COUNTC`, `COUNTB` and `COUNTO` answer `ERR unknown_command`; `COUNT` is used instead, and
230
+ `count(collection, bucket)` returns the number of **distinct words**, not of objects
231
+ (measured: 2 objects, 4 words, `count` returned 4).
232
+ - `SUGGEST` and `LIST` only see new words after `trigger("consolidate")`.
233
+ - A wrong password is answered with `ENDED authentication_failed` (surfaced as `SonicServerError`).
234
+ - Replies end in `\r\n`.
235
+ - **Not verified**: other Sonic versions (the `COUNTC/B/O` commands may exist in later ones);
236
+ `TRIGGER backup` and `restore` (they are sent, never tested against real data); Linux and
237
+ Windows (CI covers Linux); sustained load or a server with many clients; network latency.
238
+ - No TLS: Sonic Channel is plain TCP. Do not expose it to an untrusted network.
239
+ - Searching without accents (`cancion` for `canción`) depends on the server configuration
240
+ (`diacritic_folding_enabled`), not on this client; the test `sonic.cfg` does not enable it.
241
+
242
+ ## Development
243
+
244
+ ```bash
245
+ uv sync
246
+ uv run ruff check . && uv run ruff format --check .
247
+ uv run pyright
248
+ uv run pytest # everything
249
+ uv run pytest tests/test_unit.py # unit tests only, no docker needed
250
+ uv run python benchmarks/bench.py # benchmark against a real Sonic
251
+ ```
252
+
253
+ - `tests/test_unit.py` runs against a fake asyncio server (`tests/fake_sonic.py`) that speaks the
254
+ real protocol: handshake, out-of-order `PENDING`/`EVENT`, `ERR`, abrupt close, `ENDED`, slow
255
+ replies (timeouts and late replies), buffer splitting, pool and pipelining with latency.
256
+ - `tests/test_integration.py` runs against a **real Sonic**. If `docker` is available, the
257
+ fixture starts `valeriansaliou/sonic:v1.9.1` with `tests/sonic.cfg` (password `SecretPassword`)
258
+ on a free port. **Without docker these tests are skipped with an explicit reason**
259
+ (`SKIPPED: docker is not available...`): always check the `skipped` count, because a green run
260
+ with every integration test skipped does not prove the real wiring. CI fails if any test is skipped.
261
+
262
+ ## Contributing
263
+
264
+ Issues and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). Please follow the
265
+ [Code of Conduct](CODE_OF_CONDUCT.md). Security reports: [SECURITY.md](SECURITY.md).
266
+
267
+ ## License
268
+
269
+ [MIT](LICENSE) (c) 2026 Daniel Alfocea.
@@ -0,0 +1,6 @@
1
+ async_sonic/__init__.py,sha256=OZyebH9FsTWS5nMD_AlLLFUNg0lX5GLaAE1bDsCDAwc,23088
2
+ async_sonic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ async_sonic-0.1.0.dist-info/METADATA,sha256=CAFhVF-rhf_uJYZdMsJAN8s1ZtZoctg99U3anfwN9w0,12948
4
+ async_sonic-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ async_sonic-0.1.0.dist-info/licenses/LICENSE,sha256=OGBqg3l9A_6jtIsMPWPhYxqneTDWvByYlB6B_lGY_zc,1071
6
+ async_sonic-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Alfocea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.