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.
async_sonic/__init__.py
ADDED
|
@@ -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
|
+
[](https://github.com/cr0hn/async-sonic/actions/workflows/ci.yml)
|
|
29
|
+

|
|
30
|
+
[](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,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.
|