sonara-client 0.21.7__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,35 @@
1
+ """sonara-client: talk to the Sonara runtime (protocol v1). Standard library only.
2
+
3
+ import sonara_client
4
+
5
+ with sonara_client.connect("my-app", runtime_path=r"C:\\path\\to\\sonarad.exe") as sonara:
6
+ item = sonara.speak("Hello.")
7
+ for event in sonara.subscribe(["items"]):
8
+ if event["item_id"] == item and event["phase"] != "started":
9
+ break
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from .client import Client, Subscription
14
+ from .connect import connect
15
+ from .discovery import read_runtime, resolve_home
16
+ from .engines import SEND_MODES, Engines
17
+ from .errors import SonaraError
18
+ from .extensions import Agent, Channels, System
19
+ from .version import PROTOCOL, __version__
20
+
21
+ __all__ = [
22
+ "Agent",
23
+ "Channels",
24
+ "Client",
25
+ "Engines",
26
+ "PROTOCOL",
27
+ "SEND_MODES",
28
+ "SonaraError",
29
+ "Subscription",
30
+ "System",
31
+ "__version__",
32
+ "connect",
33
+ "read_runtime",
34
+ "resolve_home",
35
+ ]
@@ -0,0 +1,136 @@
1
+ """The connected client and its event subscriptions."""
2
+ from __future__ import annotations
3
+
4
+ import socket
5
+ from typing import Any, Callable, Iterator, List, Optional, Sequence
6
+
7
+ from .connection import Connection
8
+ from .errors import E_CLOSED, SonaraError
9
+ from .engines import Engines
10
+ from .extensions import Agent, Channels, System
11
+
12
+ ALL_EVENTS = ("state", "items", "log")
13
+
14
+
15
+ class Subscription:
16
+ """Events on a connection of their own: iterate it, or ``read()`` with a
17
+ timeout. Each event is a dict whose ``event`` is ``state``, ``item`` or
18
+ ``log``. Iteration ends when the runtime closes the connection."""
19
+
20
+ def __init__(self, conn: Connection):
21
+ self._conn = conn
22
+
23
+ def __iter__(self) -> Iterator[dict]:
24
+ return self
25
+
26
+ def __next__(self) -> dict:
27
+ event = self._conn.next_event(None)
28
+ if event is None:
29
+ raise StopIteration
30
+ return event
31
+
32
+ def read(self, timeout: Optional[float] = None) -> Optional[dict]:
33
+ """The next event, or None when none arrived within ``timeout``
34
+ seconds. SonaraError ``E_CLOSED`` once the stream ended."""
35
+ try:
36
+ event = self._conn.next_event(timeout)
37
+ except socket.timeout:
38
+ return None
39
+ if event is None:
40
+ raise SonaraError(E_CLOSED, "the event stream ended")
41
+ return event
42
+
43
+ def close(self) -> None:
44
+ self._conn.close()
45
+
46
+ def __enter__(self) -> "Subscription":
47
+ return self
48
+
49
+ def __exit__(self, *exc: Any) -> None:
50
+ self.close()
51
+
52
+
53
+ class Client:
54
+ """A connected Sonara client (see ``connect``). Thread-safe: requests on
55
+ the shared connection run one at a time."""
56
+
57
+ def __init__(self, conn: Connection, runtime: dict, info: dict, dial: Callable[[], Connection]):
58
+ self._conn = conn
59
+ self._dial = dial
60
+ self._subs: List[Subscription] = []
61
+ #: ``runtime.json`` of the instance this client talks to.
62
+ self.runtime = runtime
63
+ #: The ``hello`` reply: version, protocol, capabilities, extensions, unavailable.
64
+ self.info = info
65
+ self.channels = Channels(self.request)
66
+ self.agent = Agent(self.request)
67
+ self.system = System(self.request)
68
+ self.engines = Engines(self.request)
69
+
70
+ @property
71
+ def closed(self) -> bool:
72
+ return self._conn.closed
73
+
74
+ def request(self, type_: str, fields: Optional[dict] = None) -> dict:
75
+ """Send any protocol message; the ``ok: true`` reply."""
76
+ return self._conn.request(type_, fields)
77
+
78
+ def speak(self, text: str, mode: Optional[str] = None, interrupt: Optional[bool] = None,
79
+ label: Optional[str] = None) -> int:
80
+ """Add ``text`` as one item; its item id. ``mode``: ``append``
81
+ (default) or ``replace``; ``interrupt`` also cuts the current item."""
82
+ fields: dict = {"text": text}
83
+ if mode is not None:
84
+ fields["mode"] = mode
85
+ if interrupt is not None:
86
+ fields["interrupt"] = interrupt
87
+ if label is not None:
88
+ fields["label"] = label
89
+ return self.request("speak", fields)["item_id"]
90
+
91
+ def control(self, action: str) -> None:
92
+ """play, pause, toggle, stop, skip, previous, next, restart, mute, unmute."""
93
+ self.request("control", {"action": action})
94
+
95
+ def set(self, key: str, value: Any) -> Any:
96
+ """Change ``volume``, ``rate``, ``voice`` or ``engine``; the value now in force."""
97
+ return self.request("set", {"key": key, "value": value})["value"]
98
+
99
+ def get(self, key: str) -> Any:
100
+ """Read a setting."""
101
+ return self.request("get", {"key": key})["value"]
102
+
103
+ def voices(self, engine: Optional[str] = None, refresh: bool = False) -> list:
104
+ """Voices of one engine, or of all. ``refresh`` asks an external
105
+ engine's provider again (protocol 1.2)."""
106
+ fields: dict = {} if engine is None else {"engine": engine}
107
+ if refresh:
108
+ fields["refresh"] = True
109
+ return self.request("voices", fields)["voices"]
110
+
111
+ def subscribe(self, events: Sequence[str] = ALL_EVENTS) -> Subscription:
112
+ """Open an event stream (``state``, ``items``, ``log``) on a new
113
+ connection. When ``state`` is included the first event is the
114
+ current state."""
115
+ conn = self._dial()
116
+ try:
117
+ conn.request("subscribe", {"events": list(events)})
118
+ except SonaraError:
119
+ conn.close()
120
+ raise
121
+ sub = Subscription(conn)
122
+ self._subs.append(sub)
123
+ return sub
124
+
125
+ def close(self) -> None:
126
+ """Close this client's connections. The runtime keeps running for
127
+ other clients and exits on its own when idle."""
128
+ for sub in self._subs:
129
+ sub.close()
130
+ self._conn.close()
131
+
132
+ def __enter__(self) -> "Client":
133
+ return self
134
+
135
+ def __exit__(self, *exc: Any) -> None:
136
+ self.close()
@@ -0,0 +1,164 @@
1
+ """``connect()``: find or start the shared runtime (spec section 3)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import time
6
+ from typing import Optional, Sequence
7
+
8
+ from .client import Client
9
+ from .connection import Connection
10
+ from .discovery import live_runtime, resolve_home, start_runtime, wait_exit
11
+ from .errors import E_CLOSED, E_NOT_RUNNING, SonaraError
12
+ from .version import PROTOCOL, __version__
13
+
14
+ CONNECT_TIMEOUT = 5.0
15
+ EXIT_WAIT = 5.0
16
+
17
+
18
+ class _Incompatible(Exception):
19
+ """The instance answers but cannot serve this client."""
20
+
21
+
22
+ def connect(
23
+ client_name: str,
24
+ *,
25
+ runtime_path: Optional[str] = None,
26
+ home: Optional[str] = None,
27
+ autostart: bool = True,
28
+ require: Sequence[str] = (),
29
+ extensions: Sequence[str] = (),
30
+ client_version: Optional[str] = None,
31
+ keep_alive: bool = False,
32
+ runtime_args: Sequence[str] = (),
33
+ start_timeout: float = 5.0,
34
+ takeover_timeout: float = 30.0,
35
+ takeover_retry: float = 0.25,
36
+ timeout: float = 10.0,
37
+ ) -> Client:
38
+ """Connect to the shared Sonara runtime.
39
+
40
+ 1. Read ``runtime.json`` in the home (``home``, else ``SONARA_HOME``,
41
+ else ``%LOCALAPPDATA%\\Sonara``); when its pid is alive, connect and
42
+ send ``hello``.
43
+ 2. Use it when it speaks protocol 1 and offers everything in ``require``.
44
+ 3. Otherwise (with ``autostart``), start ``runtime_path --home <home>``
45
+ (default: ``SONARA_RUNTIME``), wait up to ``start_timeout`` seconds
46
+ for its ``runtime.json``, then ``hello``.
47
+ 4. An incompatible running instance is first asked to step down
48
+ (``hello`` with ``takeover: true``); while it is busy the takeover is
49
+ retried for up to ``takeover_timeout`` seconds, then
50
+ ``E_INCOMPATIBLE``.
51
+
52
+ ``runtime_args`` are extra arguments for a started runtime (tests:
53
+ ``["--engine", "fake"]``); ``timeout`` bounds each request.
54
+ """
55
+ if not isinstance(client_name, str) or not client_name:
56
+ raise SonaraError("E_BAD_REQUEST", "connect() needs a client_name")
57
+ home = resolve_home(home)
58
+ runtime_path = runtime_path or os.environ.get("SONARA_RUNTIME")
59
+ can_start = bool(autostart and runtime_path)
60
+ hello = {
61
+ "client": {"name": client_name, "version": client_version or __version__},
62
+ "protocol": dict(PROTOCOL),
63
+ "require": list(require),
64
+ "extensions": list(extensions),
65
+ }
66
+ if keep_alive:
67
+ hello["keep_alive"] = True
68
+
69
+ running = live_runtime(home)
70
+ if running:
71
+ try:
72
+ client = _greet(running, hello, list(require), timeout)
73
+ if client:
74
+ return client
75
+ except _Incompatible as e:
76
+ if not can_start:
77
+ raise SonaraError(
78
+ "E_INCOMPATIBLE",
79
+ f"the running Sonara {running.get('version')} cannot serve this client ({e}) "
80
+ "and there is no runtime to start in its place",
81
+ ) from None
82
+ _take_over(home, client_name, client_version, str(e), takeover_timeout, takeover_retry)
83
+
84
+ if not can_start:
85
+ why = "autostart is off" if runtime_path else "no runtime_path to start one"
86
+ raise SonaraError(E_NOT_RUNNING, f"no Sonara runtime is running and {why}")
87
+ started = start_runtime(runtime_path, home, list(runtime_args), start_timeout)
88
+ try:
89
+ client = _greet(started, hello, list(require), timeout)
90
+ except _Incompatible as e:
91
+ raise SonaraError(
92
+ "E_INCOMPATIBLE", f"the bundled Sonara {started.get('version')} cannot serve this client: {e}"
93
+ ) from None
94
+ if client is None:
95
+ raise SonaraError(E_CLOSED, "the started runtime closed the connection")
96
+ return client
97
+
98
+
99
+ def _greet(info: dict, hello: dict, require: list, timeout: float) -> Optional[Client]:
100
+ """Connect and ``hello``. None when the instance cannot be reached;
101
+ _Incompatible when it answers but cannot serve this client."""
102
+
103
+ def dial() -> Connection:
104
+ conn = Connection(info["port"], timeout)
105
+ try:
106
+ conn.request("hello", {**hello, "token": info["token"]})
107
+ except SonaraError:
108
+ conn.close()
109
+ raise
110
+ return conn
111
+
112
+ try:
113
+ conn = Connection(info["port"], min(timeout, CONNECT_TIMEOUT))
114
+ except SonaraError:
115
+ return None
116
+ try:
117
+ reply = conn.request("hello", {**hello, "token": info["token"]})
118
+ except SonaraError as e:
119
+ conn.close()
120
+ if e.code in ("E_INCOMPATIBLE", "E_UNSUPPORTED"):
121
+ raise _Incompatible(e.message) from None
122
+ if e.code == E_CLOSED:
123
+ return None
124
+ raise
125
+ offered = set(reply.get("capabilities") or []) | set(reply.get("extensions") or [])
126
+ missing = [r for r in require if r not in offered]
127
+ major = (reply.get("protocol") or {}).get("major")
128
+ if major != PROTOCOL["major"] or missing:
129
+ conn.close()
130
+ raise _Incompatible(f"it does not offer {', '.join(missing)}" if missing else f"it speaks protocol {major}")
131
+ return Client(conn, info, reply, dial)
132
+
133
+
134
+ def _take_over(home: str, name: str, version: Optional[str], why: str, limit: float, retry: float) -> None:
135
+ """Ask the running instance to exit so the bundled runtime can start. It
136
+ accepts only when idle; while busy, retry until ``limit`` seconds. Each
137
+ attempt uses a fresh connection: the runtime closes one that has no
138
+ successful ``hello`` within 5 s."""
139
+ deadline = time.monotonic() + limit
140
+ client = {"name": name, "version": version or __version__}
141
+ while True:
142
+ cur = live_runtime(home)
143
+ if not cur:
144
+ return
145
+ try:
146
+ conn = Connection(cur["port"], CONNECT_TIMEOUT)
147
+ try:
148
+ conn.request("hello", {"token": cur["token"], "client": client, "takeover": True})
149
+ finally:
150
+ conn.close()
151
+ if wait_exit(cur["pid"], EXIT_WAIT):
152
+ return
153
+ except SonaraError as e:
154
+ if e.code not in ("E_BUSY", E_CLOSED):
155
+ raise SonaraError(
156
+ "E_INCOMPATIBLE",
157
+ f"the running Sonara cannot serve this client ({why}) and refused a takeover: {e.message}",
158
+ ) from None
159
+ if time.monotonic() >= deadline:
160
+ raise SonaraError(
161
+ "E_INCOMPATIBLE",
162
+ f"the running Sonara cannot serve this client ({why}) and stayed busy; gave up the takeover",
163
+ )
164
+ time.sleep(retry)
@@ -0,0 +1,118 @@
1
+ """One TCP JSON-lines connection to the runtime."""
2
+ from __future__ import annotations
3
+
4
+ import itertools
5
+ import json
6
+ import socket
7
+ import threading
8
+ from typing import Optional
9
+
10
+ from .errors import E_CLOSED, SonaraError
11
+
12
+ MAX_LINE = 1 << 20
13
+
14
+
15
+ class Connection:
16
+ """Requests and their replies (blocking, one at a time), plus events
17
+ for a subscribed connection."""
18
+
19
+ def __init__(self, port: int, timeout: float):
20
+ try:
21
+ self._sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
22
+ except OSError as e:
23
+ raise SonaraError(E_CLOSED, f"cannot connect to 127.0.0.1:{port}: {e}") from None
24
+ self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
25
+ self._timeout = timeout
26
+ self._buf = b""
27
+ self._ids = itertools.count(1)
28
+ self._lock = threading.Lock()
29
+ self._events: list = []
30
+ self.closed = False
31
+
32
+ def request(self, type_: str, fields: Optional[dict] = None) -> dict:
33
+ """Send ``{type, **fields}``; the ``ok: true`` reply, or SonaraError."""
34
+ with self._lock:
35
+ if self.closed:
36
+ raise SonaraError(E_CLOSED, "the connection is closed")
37
+ msg = dict(fields or {})
38
+ msg["type"] = type_
39
+ msg["id"] = next(self._ids)
40
+ try:
41
+ self._sock.sendall(json.dumps(msg).encode("utf-8") + b"\n")
42
+ except OSError as e:
43
+ self._mark_closed()
44
+ raise SonaraError(E_CLOSED, f"send failed: {e}") from None
45
+ while True:
46
+ try:
47
+ reply = self._read(self._timeout)
48
+ except socket.timeout:
49
+ # A late reply would answer the next request: give up
50
+ # on this connection instead.
51
+ self._mark_closed()
52
+ raise SonaraError(E_CLOSED, f"no reply within {self._timeout} s") from None
53
+ if reply is None:
54
+ raise SonaraError(E_CLOSED, "the connection closed before the reply")
55
+ if "event" in reply:
56
+ self._events.append(reply)
57
+ continue
58
+ if "ok" not in reply:
59
+ continue
60
+ if reply.get("ok") is True:
61
+ return reply
62
+ err = reply.get("error") or {}
63
+ raise SonaraError(err.get("code", "E_BAD_REQUEST"), err.get("message", "request failed"),
64
+ err.get("reason"))
65
+
66
+ def next_event(self, timeout: Optional[float]) -> Optional[dict]:
67
+ """The next event; None at the end of the stream. ``socket.timeout``
68
+ (``TimeoutError``) when none arrived within ``timeout`` seconds."""
69
+ if self._events:
70
+ return self._events.pop(0)
71
+ while True:
72
+ msg = self._read(timeout)
73
+ if msg is None or "event" in msg:
74
+ return msg
75
+
76
+ def close(self) -> None:
77
+ self._mark_closed()
78
+
79
+ def _mark_closed(self) -> None:
80
+ if not self.closed:
81
+ self.closed = True
82
+ try:
83
+ self._sock.close()
84
+ except OSError:
85
+ pass
86
+
87
+ def _read(self, timeout: Optional[float]) -> Optional[dict]:
88
+ """The next JSON object line, or None once the stream ended."""
89
+ while True:
90
+ nl = self._buf.find(b"\n")
91
+ if nl >= 0:
92
+ line, self._buf = self._buf[:nl], self._buf[nl + 1:]
93
+ line = line.strip()
94
+ if not line:
95
+ continue
96
+ try:
97
+ msg = json.loads(line)
98
+ except ValueError:
99
+ continue
100
+ if isinstance(msg, dict):
101
+ return msg
102
+ continue
103
+ if self.closed:
104
+ return None
105
+ self._sock.settimeout(timeout)
106
+ try:
107
+ chunk = self._sock.recv(65536)
108
+ except socket.timeout:
109
+ raise
110
+ except OSError:
111
+ chunk = b""
112
+ if not chunk:
113
+ self._mark_closed()
114
+ return None
115
+ self._buf += chunk
116
+ if len(self._buf) > MAX_LINE * 2:
117
+ self._mark_closed()
118
+ return None
@@ -0,0 +1,147 @@
1
+ """Find, check and start the runtime (protocol v1, Discovery)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import time
8
+ from typing import Optional, Sequence
9
+
10
+ from .errors import E_NOT_RUNNING, E_START_FAILED, SonaraError
11
+
12
+ # Exit code of a second sonarad for the same user and home.
13
+ EXIT_ALREADY_RUNNING = 3
14
+ POLL = 0.05
15
+ CREATE_NO_WINDOW = 0x08000000
16
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
17
+
18
+ # Started runtimes outlive this process (they are shared and exit on their
19
+ # own when idle). Holding the Popen objects keeps Python from warning that a
20
+ # child is still running when they are garbage collected.
21
+ _started: list = []
22
+
23
+
24
+ def resolve_home(home: Optional[str] = None, env=None) -> str:
25
+ """``home``, else ``SONARA_HOME``, else ``%LOCALAPPDATA%\\Sonara``."""
26
+ env = os.environ if env is None else env
27
+ if home:
28
+ return str(home)
29
+ if env.get("SONARA_HOME"):
30
+ return env["SONARA_HOME"]
31
+ if env.get("LOCALAPPDATA"):
32
+ return os.path.join(env["LOCALAPPDATA"], "Sonara")
33
+ raise SonaraError(E_NOT_RUNNING, "no home folder: pass home, or set SONARA_HOME or LOCALAPPDATA")
34
+
35
+
36
+ def read_runtime(home: str) -> Optional[dict]:
37
+ """``runtime.json`` of ``home``, or None when missing or unreadable."""
38
+ try:
39
+ with open(os.path.join(home, "runtime.json"), encoding="utf-8") as f:
40
+ info = json.load(f)
41
+ except (OSError, ValueError):
42
+ return None
43
+ if not isinstance(info, dict):
44
+ return None
45
+ if not isinstance(info.get("pid"), int) or not isinstance(info.get("port"), int):
46
+ return None
47
+ if not isinstance(info.get("token"), str):
48
+ return None
49
+ return info
50
+
51
+
52
+ def pid_alive(pid: int) -> bool:
53
+ """True while process ``pid`` exists."""
54
+ if not isinstance(pid, int) or pid <= 0:
55
+ return False
56
+ if os.name == "nt":
57
+ return _pid_alive_windows(pid)
58
+ try:
59
+ os.kill(pid, 0)
60
+ except ProcessLookupError:
61
+ return False
62
+ except PermissionError:
63
+ return True
64
+ return True
65
+
66
+
67
+ def _pid_alive_windows(pid: int) -> bool:
68
+ # Not os.kill(pid, 0): on Windows that terminates the process.
69
+ import ctypes
70
+ from ctypes import wintypes
71
+
72
+ k32 = ctypes.WinDLL("kernel32", use_last_error=True)
73
+ k32.OpenProcess.restype = wintypes.HANDLE
74
+ k32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
75
+ k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
76
+ k32.CloseHandle.argtypes = [wintypes.HANDLE]
77
+ process_query_limited_information = 0x1000
78
+ still_active = 259
79
+ handle = k32.OpenProcess(process_query_limited_information, False, pid)
80
+ if not handle:
81
+ # Access denied: it exists but belongs to someone else.
82
+ return ctypes.get_last_error() == 5
83
+ try:
84
+ code = wintypes.DWORD()
85
+ if not k32.GetExitCodeProcess(handle, ctypes.byref(code)):
86
+ return True
87
+ return code.value == still_active
88
+ finally:
89
+ k32.CloseHandle(handle)
90
+
91
+
92
+ def live_runtime(home: str) -> Optional[dict]:
93
+ """``runtime.json`` of ``home`` when its pid is alive."""
94
+ info = read_runtime(home)
95
+ return info if info and pid_alive(info["pid"]) else None
96
+
97
+
98
+ def start_runtime(runtime_path: str, home: str, args: Sequence[str], timeout: float) -> dict:
99
+ """Start ``runtime_path --home <home> [args]`` without a console window
100
+ and wait up to ``timeout`` seconds for its ``runtime.json``. When another
101
+ client started one at the same moment (the new process exits with code
102
+ 3), the other's ``runtime.json`` is used."""
103
+ kwargs: dict = {
104
+ "stdin": subprocess.DEVNULL,
105
+ "stdout": subprocess.DEVNULL,
106
+ "stderr": subprocess.DEVNULL,
107
+ "close_fds": True,
108
+ }
109
+ if os.name == "nt":
110
+ # Its own process group, so a Ctrl+C in this console does not end
111
+ # a runtime other apps share.
112
+ kwargs["creationflags"] = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP
113
+ else:
114
+ kwargs["start_new_session"] = True
115
+ # An absolute path: Windows does not find a relative one written with
116
+ # forward slashes ("sonara/sonarad.exe"), which Node's spawn accepts (#274).
117
+ exe = os.path.abspath(str(runtime_path))
118
+ try:
119
+ proc = subprocess.Popen([exe, "--home", str(home), *args], **kwargs)
120
+ except OSError as e:
121
+ raise SonaraError(E_START_FAILED, f"cannot start {runtime_path}: {e}") from None
122
+ _started.append(proc)
123
+
124
+ deadline = time.monotonic() + timeout
125
+ while time.monotonic() < deadline:
126
+ info = read_runtime(home)
127
+ if info and info["pid"] == proc.pid:
128
+ return info
129
+ code = proc.poll()
130
+ if code is not None:
131
+ if code != EXIT_ALREADY_RUNNING:
132
+ raise SonaraError(E_START_FAILED, f"{runtime_path} exited with code {code}")
133
+ other = live_runtime(home)
134
+ if other:
135
+ return other
136
+ time.sleep(POLL)
137
+ raise SonaraError(E_START_FAILED, f"{runtime_path} wrote no runtime.json within {timeout} s")
138
+
139
+
140
+ def wait_exit(pid: int, timeout: float) -> bool:
141
+ """Wait until process ``pid`` has ended; False if it still runs."""
142
+ deadline = time.monotonic() + timeout
143
+ while time.monotonic() < deadline:
144
+ if not pid_alive(pid):
145
+ return True
146
+ time.sleep(POLL)
147
+ return not pid_alive(pid)
@@ -0,0 +1,110 @@
1
+ """External engines (protocol 1.2, capability ``engines``): speech engines
2
+ the user adds at run time, such as OpenAI or a local OpenAI-compatible
3
+ server.
4
+
5
+ Each method sends one core message as is. The profile is named by the
6
+ ``engine`` field (a request's ``id`` is its correlation id). A runtime that
7
+ refuses external engines (``sonarad --no-external-engines``) answers
8
+ ``E_UNSUPPORTED``. A key is sent only in ``add`` (``secret``) or
9
+ ``set_key`` (or with a draft profile in ``models``, for that request only);
10
+ the runtime stores it in Windows Credential Manager and never returns it.
11
+
12
+ Sonara names no model or voice of its own (protocol 1.5, runtime 0.19.0):
13
+ ``models`` and ``voices`` list the provider's live, and a profile without
14
+ one it needs says so in ``engine_list`` (``missing``).
15
+
16
+ "Send to the engine" (``send_mode`` of a profile, runtime 0.19.0, #235):
17
+ ``"message"`` sends a whole released message in one request (the default of
18
+ the cloud kinds), ``"sentence"`` each sentence as it comes (the default of a
19
+ program and a server on this PC). The view in ``engine_list`` tells the mode
20
+ in force, and ``explicit["send_mode"]`` whether the profile chose it.
21
+
22
+ A ``command`` engine (a program on the user's PC) is never added or changed
23
+ through the protocol: ``add`` of one, or replacing one, is ``E_FORBIDDEN``
24
+ (protocol 1.3). The user adds it locally (``sonara engines add <id> --kind
25
+ command``, or ``engines.json``); ``reload`` makes a running runtime read
26
+ ``engines.json`` again. Listing, testing, selecting and removing one work.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from typing import Callable, Optional
31
+
32
+ Send = Callable[[str, dict], dict]
33
+
34
+ #: The values of a profile's ``send_mode`` (#235).
35
+ SEND_MODES = ("message", "sentence")
36
+
37
+
38
+ class Engines:
39
+ """``engine_list``, ``engine_add``, ``engine_remove``, ``engine_key``,
40
+ ``engine_test``, ``engine_reload``, ``engine_models`` and ``voices`` with
41
+ ``refresh``."""
42
+
43
+ def __init__(self, send: Send):
44
+ self._send = send
45
+
46
+ def list(self) -> dict:
47
+ """``engine_list``: the profiles, the built-in engines, kinds and presets."""
48
+ return self._send("engine_list", {})
49
+
50
+ def add(self, profile: dict, secret: Optional[str] = None, replace: bool = False) -> dict:
51
+ """``engine_add``. It does not select the engine: ``set engine`` does."""
52
+ fields: dict = {"engine": profile}
53
+ if secret is not None:
54
+ fields["secret"] = secret
55
+ if replace:
56
+ fields["replace"] = True
57
+ return self._send("engine_add", fields)
58
+
59
+ def reload(self) -> dict:
60
+ """``engine_reload`` (protocol 1.3): the runtime reads ``engines.json``
61
+ again. It takes no profile. Replies like ``list``, plus ``problems``."""
62
+ return self._send("engine_reload", {})
63
+
64
+ def remove(self, engine: str, forget_key: bool = True) -> dict:
65
+ """``engine_remove``; the stored key goes too unless ``forget_key`` is false."""
66
+ fields: dict = {"engine": engine}
67
+ if not forget_key:
68
+ fields["forget_key"] = False
69
+ return self._send("engine_remove", fields)
70
+
71
+ def set_key(self, engine: str, secret: Optional[str]) -> dict:
72
+ """``engine_key``: store a key, or delete it with ``None``."""
73
+ return self._send("engine_key", {"engine": engine, "secret": secret})
74
+
75
+ def test(self, engine: str, text: Optional[str] = None, voice: Optional[str] = None,
76
+ play: bool = True) -> dict:
77
+ """``engine_test``: one synthesis with no fallback, at the current rate."""
78
+ fields: dict = {"engine": engine}
79
+ if text is not None:
80
+ fields["text"] = text
81
+ if voice is not None:
82
+ fields["voice"] = voice
83
+ if not play:
84
+ fields["play"] = False
85
+ return self._send("engine_test", fields)
86
+
87
+ def models(self, engine: Optional[str] = None, refresh: bool = False,
88
+ profile: Optional[dict] = None, secret: Optional[str] = None) -> dict:
89
+ """``engine_models`` (protocol 1.5): the provider's models now, of a
90
+ saved ``engine`` (``refresh`` asks the provider again) or of a draft
91
+ ``profile`` (with ``secret`` for this request only). Replies
92
+ ``{models: [{id, name}], list, takes_model, required, error?}``."""
93
+ if profile is not None:
94
+ fields: dict = {"profile": profile}
95
+ if secret is not None:
96
+ fields["secret"] = secret
97
+ return self._send("engine_models", fields)
98
+ if engine is None:
99
+ raise ValueError("models needs an engine or a profile")
100
+ fields = {"engine": engine}
101
+ if refresh:
102
+ fields["refresh"] = True
103
+ return self._send("engine_models", fields)
104
+
105
+ def voices(self, engine: str, refresh: bool = False) -> list:
106
+ """``voices`` of one engine; ``refresh`` asks the provider again."""
107
+ fields: dict = {"engine": engine}
108
+ if refresh:
109
+ fields["refresh"] = True
110
+ return self._send("voices", fields)["voices"]
@@ -0,0 +1,23 @@
1
+ """Errors of the Sonara client."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ # Client-side codes, next to the protocol's E_* codes that come from the runtime.
7
+ E_NOT_RUNNING = "E_NOT_RUNNING" # nothing running, nothing to start
8
+ E_START_FAILED = "E_START_FAILED" # the bundled runtime did not come up in time
9
+ E_CLOSED = "E_CLOSED" # the connection closed before the reply
10
+
11
+
12
+ class SonaraError(Exception):
13
+ """A coded failure: a protocol error (``E_BUSY``, ``E_NOT_FOUND``...) or
14
+ one of the client's own (``E_NOT_RUNNING``, ``E_START_FAILED``,
15
+ ``E_CLOSED``)."""
16
+
17
+ def __init__(self, code: str, message: str, reason: Optional[str] = None):
18
+ super().__init__(f"{code}: {message}")
19
+ self.code = code
20
+ self.message = message
21
+ #: Protocol 1.2: why an external engine failed (``auth``, ``quota``,
22
+ #: ``network``...), on an ``E_ENGINE`` of ``engine_test``.
23
+ self.reason = reason
@@ -0,0 +1,122 @@
1
+ """Extension namespaces (spec sections 4.2 to 4.4).
2
+
3
+ Each method sends one protocol message as is; all behaviour lives in the
4
+ runtime. A runtime that does not offer the extension (or no client enabled
5
+ it in ``hello``) answers ``E_UNSUPPORTED``. ``extra`` keyword fields are
6
+ sent along unchanged, for fields a later protocol minor adds.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable, Optional, Sequence, Union
11
+
12
+ Send = Callable[[str, dict], dict]
13
+ Turn = Union[str, int]
14
+
15
+
16
+ def _defined(fields: dict) -> dict:
17
+ return {k: v for k, v in fields.items() if v is not None}
18
+
19
+
20
+ class Channels:
21
+ """``channels``: named sources, each with its own queue and policy."""
22
+
23
+ def __init__(self, send: Send):
24
+ self._send = send
25
+
26
+ def open(self, channel: str, label: Optional[str] = None, host_tab: Optional[str] = None,
27
+ policy: Optional[str] = None, keep_label: Optional[bool] = None,
28
+ **extra: Any) -> dict:
29
+ """``channel_open`` (policy ``latest`` or ``queue``; ``keep_label``: an
30
+ open channel keeps the label it has, runtime 0.20.3)."""
31
+ return self._send("channel_open", _defined(
32
+ {**extra, "channel": channel, "label": label, "host_tab": host_tab, "policy": policy,
33
+ "keep_label": keep_label}))
34
+
35
+ def close(self, channel: str, **extra: Any) -> dict:
36
+ """``channel_close``."""
37
+ return self._send("channel_close", _defined({**extra, "channel": channel}))
38
+
39
+ def focus(self, channel: str, **extra: Any) -> dict:
40
+ """``focus``: bring a channel to the front."""
41
+ return self._send("focus", _defined({**extra, "channel": channel}))
42
+
43
+ def speak(self, channel: str, text: str, mode: Optional[str] = None, interrupt: Optional[bool] = None,
44
+ label: Optional[str] = None, **extra: Any) -> int:
45
+ """``speak`` on a channel; the item id."""
46
+ r = self._send("speak", _defined({**extra, "channel": channel, "text": text, "mode": mode,
47
+ "interrupt": interrupt, "label": label}))
48
+ return r["item_id"]
49
+
50
+ def control(self, channel: str, action: str, **extra: Any) -> None:
51
+ """``control`` scoped to a channel."""
52
+ self._send("control", _defined({**extra, "channel": channel, "action": action}))
53
+
54
+ def next_channel(self, **extra: Any) -> None:
55
+ """``control`` ``next_channel``."""
56
+ self._send("control", _defined({**extra, "action": "next_channel"}))
57
+
58
+ def flush(self, **extra: Any) -> dict:
59
+ """``control`` ``flush``: stop only the session being read (the flush hotkey, #228)."""
60
+ return self._send("control", _defined({**extra, "action": "flush"}))
61
+
62
+
63
+ class Agent:
64
+ """``agent`` (needs ``channels``): streaming turns, decisions, earcons."""
65
+
66
+ def __init__(self, send: Send):
67
+ self._send = send
68
+
69
+ def stream(self, channel: str, turn: Turn, delta: str, index: int, final: bool,
70
+ t: Optional[float] = None, **extra: Any) -> dict:
71
+ """``stream``: one delta of a turn's text (``t``: sender start time)."""
72
+ return self._send("stream", _defined({**extra, "channel": channel, "turn": turn, "delta": delta,
73
+ "index": index, "final": final, "t": t}))
74
+
75
+ def turn_start(self, channel: str, turn: Turn, **extra: Any) -> dict:
76
+ """``turn_start``."""
77
+ return self._send("turn_start", _defined({**extra, "channel": channel, "turn": turn}))
78
+
79
+ def turn_end(self, channel: str, turn: Turn, **extra: Any) -> dict:
80
+ """``turn_end``."""
81
+ return self._send("turn_end", _defined({**extra, "channel": channel, "turn": turn}))
82
+
83
+ def ask(self, channel: str, kind: str, text: str, options: Optional[Sequence[Any]] = None,
84
+ **extra: Any) -> dict:
85
+ """``ask``: ``question``, ``permission`` or ``plan``, spoken with priority."""
86
+ return self._send("ask", _defined({**extra, "channel": channel, "kind": kind, "text": text,
87
+ "options": list(options) if options is not None else None}))
88
+
89
+ def earcon(self, kind: str, **extra: Any) -> dict:
90
+ """``earcon``."""
91
+ return self._send("earcon", _defined({**extra, "kind": kind}))
92
+
93
+ def set_mute_level(self, level: int) -> dict:
94
+ """``set mute_level`` 0, 1 or 2."""
95
+ return self._send("set", {"key": "mute_level", "value": level})
96
+
97
+ def set_summaries(self, value: dict) -> dict:
98
+ """``set summaries``."""
99
+ return self._send("set", {"key": "summaries", "value": value})
100
+
101
+
102
+ class System:
103
+ """``system`` (Windows): other apps' audio, global hotkeys, the settings page."""
104
+
105
+ def __init__(self, send: Send):
106
+ self._send = send
107
+
108
+ def set_audio_mode(self, mode: str) -> dict:
109
+ """``set audio_mode`` (``duck``, ``pause`` or ``off``)."""
110
+ return self._send("set", {"key": "audio_mode", "value": mode})
111
+
112
+ def set_duck_level(self, level: int) -> dict:
113
+ """``set duck_level``."""
114
+ return self._send("set", {"key": "duck_level", "value": level})
115
+
116
+ def set_hotkeys(self, hotkeys: dict) -> dict:
117
+ """``set hotkeys``."""
118
+ return self._send("set", {"key": "hotkeys", "value": hotkeys})
119
+
120
+ def settings_url(self) -> str:
121
+ """``get settings_url``."""
122
+ return self._send("get", {"key": "settings_url"})["value"]
@@ -0,0 +1,5 @@
1
+ """Version of this package and the protocol it speaks."""
2
+ from __future__ import annotations
3
+
4
+ __version__ = "0.21.7"
5
+ PROTOCOL = {"major": 1, "minor": 0}
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonara-client
3
+ Version: 0.21.7
4
+ Summary: Client for the Sonara text-to-speech runtime (protocol v1). Standard library only.
5
+ Maintainer: Max Aubert
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Maxaubert/Sonara
8
+ Project-URL: Documentation, https://github.com/Maxaubert/Sonara/blob/main/docs/bundling.md
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # sonara-client
19
+
20
+ Talk to the Sonara text-to-speech runtime from Python 3.9+. Standard library only.
21
+
22
+ ```python
23
+ import sonara_client
24
+
25
+ with sonara_client.connect("my-app", runtime_path=r"C:\path\to\sonarad.exe") as sonara:
26
+ item = sonara.speak("Hello.", label="greeting")
27
+ sonara.set("rate", 240)
28
+ with sonara.subscribe(["state", "items"]) as events:
29
+ for e in events:
30
+ if e["event"] == "item" and e["item_id"] == item and e["phase"] != "started":
31
+ break
32
+ ```
33
+
34
+ - `connect(client_name, *, runtime_path=None, home=None, autostart=True, require=(), extensions=(), keep_alive=False)`: finds the shared runtime through `runtime.json`, starts the bundled `sonarad.exe` when none is usable (`runtime_path`, default `SONARA_RUNTIME`), takes over an idle incompatible one.
35
+ - `speak(text, mode=None, interrupt=None, label=None)` returns the item id; `control(action)`; `set(key, value)` / `get(key)`; `voices(engine=None)`.
36
+ - `subscribe(events)` opens an event stream on its own connection: iterate it, or `read(timeout)`.
37
+ - `channels`, `agent`, `system`: extension namespaces that send the protocol's extension messages as they are.
38
+ - Errors are `SonaraError` with a `code`. The client is thread-safe.
39
+
40
+ Guide: [Bundle Sonara in your app](https://github.com/Maxaubert/Sonara/blob/main/docs/bundling.md). Tests: `python -m pytest clients/python/tests -q` (the end-to-end ones need `cargo build -p sonarad`).
41
+
42
+ MIT. Ship `THIRD_PARTY_NOTICES.md` with an app that bundles the runtime.
@@ -0,0 +1,14 @@
1
+ sonara_client/__init__.py,sha256=7rGPlHXBcTpsjI4pMCm3pHeefVoZ0rlrOS6Bjo3t-dI,981
2
+ sonara_client/client.py,sha256=K7_D1Le9IZ81cEeGWdNJX2SUlOFEXR7CPSDSxjpw_nM,5044
3
+ sonara_client/connect.py,sha256=AxdF43OXhQWmhEcEtUGt7Qjkx5X8Xq9yQ2Jz27HknBY,6640
4
+ sonara_client/connection.py,sha256=C7mOYbT-zsrpmN436S9I_3vKd9Gd4CBuqLltVT8BaUs,4431
5
+ sonara_client/discovery.py,sha256=6NwlW3_v5KtPRHBM-vvNB1_Enw76oqsKlEPqiqKp0kg,5496
6
+ sonara_client/engines.py,sha256=i9ZHpsBuISpmOTZvD1hrNkpkD-3feGULabGU6yH-EJg,5133
7
+ sonara_client/errors.py,sha256=_8-bjcNW9ItXhWnWio4sFJUjxZBCM2dbmDam-tQsWf4,974
8
+ sonara_client/extensions.py,sha256=mvESGvKOeuoLFXpBy81BzPNWH0wg2EckpZuD6qGqR94,5489
9
+ sonara_client/version.py,sha256=AmDOI4GTzDNPVD_JAoKqGqBldArZ8PV4Ja9OjtHdyCA,158
10
+ sonara_client-0.21.7.dist-info/licenses/LICENSE,sha256=Jd33EBnpxUDjmvOo4I4R6kpK8CMf_EJccRSlQDLmHF4,1089
11
+ sonara_client-0.21.7.dist-info/METADATA,sha256=n4USDnTdKuD8CTq8pLqdXF6a5HQnGUUTEKuVmTSfl0Y,2205
12
+ sonara_client-0.21.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ sonara_client-0.21.7.dist-info/top_level.txt,sha256=WnnLWWDza1RBQlgqm-ODOPGO1zsQKKzdEn0MOTx2gpg,14
14
+ sonara_client-0.21.7.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nima Hakimi
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.
@@ -0,0 +1 @@
1
+ sonara_client