edgespeak 0.6.4__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.
- edgespeak/__init__.py +10 -0
- edgespeak/_bootstrap.py +246 -0
- edgespeak/_client.py +568 -0
- edgespeak/_errors.py +65 -0
- edgespeak/_process.py +137 -0
- edgespeak/_reference.py +45 -0
- edgespeak/_streaming.py +115 -0
- edgespeak/_types.py +10 -0
- edgespeak/_version.py +3 -0
- edgespeak/py.typed +0 -0
- edgespeak-0.6.4.dist-info/METADATA +120 -0
- edgespeak-0.6.4.dist-info/RECORD +14 -0
- edgespeak-0.6.4.dist-info/WHEEL +5 -0
- edgespeak-0.6.4.dist-info/top_level.txt +1 -0
edgespeak/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""EdgeSpeak's Python SDK for the local CLI runtime."""
|
|
2
|
+
from ._client import EdgeSpeak, EdgeSpeakError
|
|
3
|
+
from ._process import RuntimeStartError
|
|
4
|
+
from ._bootstrap import MIN_CLI_VERSION
|
|
5
|
+
from ._streaming import EventStream
|
|
6
|
+
from ._types import AudioChannel, ReferenceFormat, SearchEffort, TimestampGranularity, TranscriptionFormat
|
|
7
|
+
|
|
8
|
+
from ._version import __version__ as __version__
|
|
9
|
+
__all__ = ["EdgeSpeak", "EdgeSpeakError", "RuntimeStartError", "MIN_CLI_VERSION", "EventStream",
|
|
10
|
+
"AudioChannel", "ReferenceFormat", "SearchEffort", "TimestampGranularity", "TranscriptionFormat"]
|
edgespeak/_bootstrap.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Discover a compatible CLI, installing the official standalone runtime if needed."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
import logging
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from ._process import RuntimeStartError
|
|
21
|
+
|
|
22
|
+
# Both the released CLI version floor and SDK protocol support are required.
|
|
23
|
+
# Each SDK release ships its own floor. Raise this value
|
|
24
|
+
# when that SDK release relies on newer CLI features. It is a compatibility
|
|
25
|
+
# requirement, not a download pin: installation always uses the latest stable CLI.
|
|
26
|
+
MIN_CLI_VERSION = "0.6.4"
|
|
27
|
+
SDK_PROTOCOL = "edgespeak-sdk-v2"
|
|
28
|
+
_LOG = logging.getLogger("edgespeak")
|
|
29
|
+
_lock_streams = set()
|
|
30
|
+
_stream_guard = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _release_inherited_locks():
|
|
34
|
+
# Raw FileIO has no buffered-I/O mutex inherited from another thread.
|
|
35
|
+
for stream in _lock_streams:
|
|
36
|
+
stream.close()
|
|
37
|
+
_lock_streams.clear()
|
|
38
|
+
_stream_guard.release()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if hasattr(os, "register_at_fork"):
|
|
42
|
+
os.register_at_fork(before=_stream_guard.acquire,
|
|
43
|
+
after_in_parent=_stream_guard.release,
|
|
44
|
+
after_in_child=_release_inherited_locks)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@contextmanager
|
|
48
|
+
def _lock_file(path):
|
|
49
|
+
with _stream_guard:
|
|
50
|
+
stream = path.open("a+b", buffering=0)
|
|
51
|
+
_lock_streams.add(stream)
|
|
52
|
+
try:
|
|
53
|
+
yield stream
|
|
54
|
+
finally:
|
|
55
|
+
with _stream_guard:
|
|
56
|
+
_lock_streams.discard(stream)
|
|
57
|
+
stream.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _environment():
|
|
61
|
+
# Neither discovery nor installation needs account/gateway credentials or
|
|
62
|
+
# inherited runtime/download overrides. In particular, probe the sibling host.
|
|
63
|
+
return {k: v for k, v in os.environ.items() if not k.upper().startswith("EDGESPEAK_")}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _candidates():
|
|
67
|
+
home = Path.home()
|
|
68
|
+
executable = "edgespeak-cli.exe" if os.name == "nt" else "edgespeak-cli"
|
|
69
|
+
found = shutil.which(executable)
|
|
70
|
+
paths = [Path(found)] if found else []
|
|
71
|
+
# Only parse the exact launcher format emitted by the official Windows
|
|
72
|
+
# installer, never execute arbitrary shell text from a .cmd file.
|
|
73
|
+
if os.name == "nt":
|
|
74
|
+
shim = shutil.which("edgespeak-cli.cmd")
|
|
75
|
+
if shim:
|
|
76
|
+
try:
|
|
77
|
+
match = re.fullmatch(r'@echo off\s+"([^"\r\n]+)" %\*\s*', Path(shim).read_text(encoding="ascii"))
|
|
78
|
+
if match and Path(match[1]).is_absolute():
|
|
79
|
+
paths.append(Path(match[1]))
|
|
80
|
+
except (OSError, UnicodeError):
|
|
81
|
+
pass
|
|
82
|
+
try:
|
|
83
|
+
generation = json.loads((home / ".edgespeak/sdk/current.json").read_text())["generation"]
|
|
84
|
+
if isinstance(generation, str) and re.fullmatch(r"runtime-[a-zA-Z0-9_-]+", generation):
|
|
85
|
+
paths.insert(0, home / ".edgespeak/sdk" / generation / "runtime/Resources" / executable)
|
|
86
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
87
|
+
pass
|
|
88
|
+
paths += [home / ".edgespeak/runtime/Resources" / executable,
|
|
89
|
+
home / ".local/bin" / executable]
|
|
90
|
+
if platform.system() == "Darwin":
|
|
91
|
+
paths += [base / "EdgeSpeak.app/Contents/Resources/edgespeak-cli"
|
|
92
|
+
for base in (Path("/Applications"), home / "Applications")]
|
|
93
|
+
if os.name == "nt":
|
|
94
|
+
local = Path(os.environ.get("LOCALAPPDATA", home / "AppData/Local"))
|
|
95
|
+
paths += [local / "EdgeSpeak/bin" / executable]
|
|
96
|
+
return dict.fromkeys(path.resolve() for path in paths if path.is_file())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compatible_version(version: str) -> bool:
|
|
100
|
+
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", version)
|
|
101
|
+
return bool(match and tuple(map(int, match.groups())) >= tuple(map(int, MIN_CLI_VERSION.split("."))))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _compatible(cli: Path) -> bool:
|
|
105
|
+
options = {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
|
|
106
|
+
try:
|
|
107
|
+
version = subprocess.run([str(cli), "--version"], stdin=subprocess.DEVNULL,
|
|
108
|
+
capture_output=True, timeout=10, env=_environment(), **options)
|
|
109
|
+
match = re.fullmatch(rb"edgespeak(?:-cli)?\s+(\d+)\.(\d+)\.(\d+)\s*", version.stdout.strip())
|
|
110
|
+
if version.returncode or not match:
|
|
111
|
+
return False
|
|
112
|
+
if tuple(map(int, match.groups())) < tuple(map(int, MIN_CLI_VERSION.split("."))):
|
|
113
|
+
return False
|
|
114
|
+
help_result = subprocess.run([str(cli), "serve", "--help"], stdin=subprocess.DEVNULL,
|
|
115
|
+
capture_output=True, timeout=10, env=_environment(), **options)
|
|
116
|
+
return help_result.returncode == 0 and re.search(rb"(?<![\w-])" + re.escape(SDK_PROTOCOL.encode()) + rb"(?![\w-])", help_result.stdout) is not None and re.search(rb"(?<!\S)\[?--sdk(?:\]|\s|$)", help_result.stdout) is not None
|
|
117
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _find():
|
|
122
|
+
return next((cli for cli in _candidates() if _compatible(cli)), None)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def _installation_lock():
|
|
127
|
+
# OS locks survive stale lock files and are released even if Python is killed.
|
|
128
|
+
directory = Path.home() / ".edgespeak/sdk"
|
|
129
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
with _lock_file(directory / "install.lock") as stream:
|
|
131
|
+
if stream.seek(0, 2) == 0:
|
|
132
|
+
stream.write(b"\0")
|
|
133
|
+
deadline = time.monotonic() + 1800
|
|
134
|
+
while True:
|
|
135
|
+
try:
|
|
136
|
+
stream.seek(0)
|
|
137
|
+
if os.name == "nt":
|
|
138
|
+
import msvcrt
|
|
139
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
140
|
+
else:
|
|
141
|
+
import fcntl
|
|
142
|
+
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
143
|
+
break
|
|
144
|
+
except OSError:
|
|
145
|
+
if time.monotonic() >= deadline:
|
|
146
|
+
raise RuntimeStartError("Timed out waiting for CLI installation.") from None
|
|
147
|
+
time.sleep(.1)
|
|
148
|
+
try:
|
|
149
|
+
yield
|
|
150
|
+
finally:
|
|
151
|
+
if os.name == "nt":
|
|
152
|
+
stream.seek(0)
|
|
153
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
154
|
+
else:
|
|
155
|
+
fcntl.flock(stream, fcntl.LOCK_UN)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _install():
|
|
159
|
+
"""Install the latest official stable CLI and verify this SDK's requirements.
|
|
160
|
+
|
|
161
|
+
The official stable installer selects the latest platform release. Never pass
|
|
162
|
+
MIN_CLI_VERSION as a target download version; it is only the acceptance floor.
|
|
163
|
+
"""
|
|
164
|
+
system, machine = platform.system(), platform.machine().lower()
|
|
165
|
+
# The official installer distinguishes Intel Macs from Rosetta on Apple Silicon.
|
|
166
|
+
if (system, machine) not in {("Darwin", "arm64"), ("Darwin", "x86_64"), ("Linux", "x86_64"),
|
|
167
|
+
("Windows", "amd64"), ("Windows", "x86_64")}:
|
|
168
|
+
raise RuntimeStartError("Automatic CLI installation is unavailable on this platform.")
|
|
169
|
+
windows = system == "Windows"
|
|
170
|
+
shell = shutil.which("powershell.exe" if windows else "sh")
|
|
171
|
+
if not shell:
|
|
172
|
+
raise RuntimeStartError("The system shell required by the CLI installer is unavailable.")
|
|
173
|
+
suffix = ".ps1" if windows else ".sh"
|
|
174
|
+
_LOG.warning("Installing a compatible EdgeSpeak CLI (requires >= %s and %s).",
|
|
175
|
+
MIN_CLI_VERSION, SDK_PROTOCOL)
|
|
176
|
+
root = Path.home() / ".edgespeak/sdk"
|
|
177
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
# Never replace a running runtime. Each attempt has its own directory/lock;
|
|
179
|
+
# failed or interrupted attempts cannot block a later generation.
|
|
180
|
+
directory = Path(tempfile.mkdtemp(prefix="runtime-", dir=root))
|
|
181
|
+
script = directory / ("install" + suffix)
|
|
182
|
+
try:
|
|
183
|
+
with httpx.Client(timeout=60, follow_redirects=False) as http:
|
|
184
|
+
response = http.get("https://edgespeak.com/install" + suffix)
|
|
185
|
+
response.raise_for_status()
|
|
186
|
+
if (not response.content or len(response.content) > 1024 * 1024
|
|
187
|
+
or b"EDGESPEAK_SDK_INSTALL_V1" not in response.content):
|
|
188
|
+
raise RuntimeStartError("The official installer does not yet support SDK-managed installation. A matching installer release is required.")
|
|
189
|
+
script.write_bytes(response.content)
|
|
190
|
+
except httpx.HTTPError:
|
|
191
|
+
raise RuntimeStartError("Cannot download the official CLI installer. Check your network and retry.") from None
|
|
192
|
+
env = _environment()
|
|
193
|
+
env["EDGESPEAK_DATA_DIR"] = str(directory / "data")
|
|
194
|
+
env["EDGESPEAK_RUNTIME_DIR"] = str(directory / "runtime")
|
|
195
|
+
env["EDGESPEAK_INSTALL_DIR"] = str(directory / "bin")
|
|
196
|
+
env["EDGESPEAK_INSTALL_CONTEXT"] = "sdk"
|
|
197
|
+
env["EDGESPEAK_LEGACY_INSTALL_PATH"] = str(directory / "unused-legacy-entry")
|
|
198
|
+
command = ([shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)]
|
|
199
|
+
if windows else [shell, str(script)])
|
|
200
|
+
options = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW}
|
|
201
|
+
if windows else {"start_new_session": True})
|
|
202
|
+
process = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
203
|
+
stderr=subprocess.DEVNULL, env=env, **options)
|
|
204
|
+
try:
|
|
205
|
+
code = process.wait(timeout=1200)
|
|
206
|
+
except BaseException:
|
|
207
|
+
# Bounded cleanup, with no Popen context manager (its exit waits forever).
|
|
208
|
+
try:
|
|
209
|
+
if windows:
|
|
210
|
+
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
211
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
|
|
212
|
+
else:
|
|
213
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
214
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
215
|
+
pass
|
|
216
|
+
try:
|
|
217
|
+
if process.poll() is None:
|
|
218
|
+
process.kill()
|
|
219
|
+
process.wait(timeout=5)
|
|
220
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
221
|
+
pass
|
|
222
|
+
raise
|
|
223
|
+
if code:
|
|
224
|
+
raise RuntimeStartError("Official CLI installation failed. Check network, disk space and installation permissions, then retry.")
|
|
225
|
+
cli = directory / "runtime/Resources" / ("edgespeak-cli.exe" if windows else "edgespeak-cli")
|
|
226
|
+
if not _compatible(cli):
|
|
227
|
+
raise RuntimeStartError(f"The available CLI does not meet SDK requirements: CLI >= {MIN_CLI_VERSION} with {SDK_PROTOCOL}. A compatible official CLI release is required.")
|
|
228
|
+
# Publish only after validation. Existing generations remain valid for live
|
|
229
|
+
# clients and other SDK versions; never delete them during an update.
|
|
230
|
+
pointer = directory / "current.json"
|
|
231
|
+
pointer.write_text(json.dumps({"generation": directory.name}))
|
|
232
|
+
os.replace(pointer, root / "current.json")
|
|
233
|
+
return cli
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def ensure_cli() -> Path:
|
|
237
|
+
"""Reuse compatible installations without network access; bootstrap once otherwise."""
|
|
238
|
+
cli = _find()
|
|
239
|
+
if cli:
|
|
240
|
+
return cli
|
|
241
|
+
try:
|
|
242
|
+
with _installation_lock():
|
|
243
|
+
cli = _find() # Another Python process may have just finished installing.
|
|
244
|
+
return cli if cli else _install()
|
|
245
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
246
|
+
raise RuntimeStartError("CLI installation could not finish. Check network and local installation permissions, then retry.") from None
|