sonara-client 0.21.7__tar.gz
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.
- sonara_client-0.21.7/LICENSE +21 -0
- sonara_client-0.21.7/PKG-INFO +42 -0
- sonara_client-0.21.7/README.md +25 -0
- sonara_client-0.21.7/pyproject.toml +32 -0
- sonara_client-0.21.7/setup.cfg +4 -0
- sonara_client-0.21.7/src/sonara_client/__init__.py +35 -0
- sonara_client-0.21.7/src/sonara_client/client.py +136 -0
- sonara_client-0.21.7/src/sonara_client/connect.py +164 -0
- sonara_client-0.21.7/src/sonara_client/connection.py +118 -0
- sonara_client-0.21.7/src/sonara_client/discovery.py +147 -0
- sonara_client-0.21.7/src/sonara_client/engines.py +110 -0
- sonara_client-0.21.7/src/sonara_client/errors.py +23 -0
- sonara_client-0.21.7/src/sonara_client/extensions.py +122 -0
- sonara_client-0.21.7/src/sonara_client/version.py +5 -0
- sonara_client-0.21.7/src/sonara_client.egg-info/PKG-INFO +42 -0
- sonara_client-0.21.7/src/sonara_client.egg-info/SOURCES.txt +19 -0
- sonara_client-0.21.7/src/sonara_client.egg-info/dependency_links.txt +1 -0
- sonara_client-0.21.7/src/sonara_client.egg-info/top_level.txt +1 -0
- sonara_client-0.21.7/tests/test_connect.py +274 -0
- sonara_client-0.21.7/tests/test_engines.py +150 -0
- sonara_client-0.21.7/tests/test_sonarad.py +138 -0
|
@@ -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,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,25 @@
|
|
|
1
|
+
# sonara-client
|
|
2
|
+
|
|
3
|
+
Talk to the Sonara text-to-speech runtime from Python 3.9+. Standard library only.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import sonara_client
|
|
7
|
+
|
|
8
|
+
with sonara_client.connect("my-app", runtime_path=r"C:\path\to\sonarad.exe") as sonara:
|
|
9
|
+
item = sonara.speak("Hello.", label="greeting")
|
|
10
|
+
sonara.set("rate", 240)
|
|
11
|
+
with sonara.subscribe(["state", "items"]) as events:
|
|
12
|
+
for e in events:
|
|
13
|
+
if e["event"] == "item" and e["item_id"] == item and e["phase"] != "started":
|
|
14
|
+
break
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `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.
|
|
18
|
+
- `speak(text, mode=None, interrupt=None, label=None)` returns the item id; `control(action)`; `set(key, value)` / `get(key)`; `voices(engine=None)`.
|
|
19
|
+
- `subscribe(events)` opens an event stream on its own connection: iterate it, or `read(timeout)`.
|
|
20
|
+
- `channels`, `agent`, `system`: extension namespaces that send the protocol's extension messages as they are.
|
|
21
|
+
- Errors are `SonaraError` with a `code`. The client is thread-safe.
|
|
22
|
+
|
|
23
|
+
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`).
|
|
24
|
+
|
|
25
|
+
MIT. Ship `THIRD_PARTY_NOTICES.md` with an app that bundles the runtime.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sonara-client"
|
|
7
|
+
version = "0.21.7"
|
|
8
|
+
description = "Client for the Sonara text-to-speech runtime (protocol v1). Standard library only."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
# setuptools ships LICENSE beside this file (a copy of the root one) in
|
|
13
|
+
# the sdist and the wheel by default.
|
|
14
|
+
maintainers = [{ name = "Max Aubert" }]
|
|
15
|
+
# Zero runtime dependencies (spec R6): bundlers ship nothing else's licence.
|
|
16
|
+
dependencies = []
|
|
17
|
+
classifiers = [
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: Microsoft :: Windows",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/Maxaubert/Sonara"
|
|
26
|
+
Documentation = "https://github.com/Maxaubert/Sonara/blob/main/docs/bundling.md"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -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
|