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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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