ekko-stt 0.1.0__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.
ekko_stt-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emil Schledermann / RyeAI
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,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: ekko-stt
3
+ Version: 0.1.0
4
+ Summary: Local Danish speech recognition and punctuation with Ekko Tiny and PnC
5
+ Author: Emil Schledermann / RyeAI
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Rye-A1/ekko-danish-stt
8
+ Project-URL: Issues, https://github.com/Rye-A1/ekko-danish-stt/issues
9
+ Keywords: asr,danish,speech-to-text,onnx,gguf
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Operating System :: Microsoft :: Windows
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ License-File: THIRD_PARTY_NOTICES.md
21
+ Requires-Dist: filelock<4,>=3.13
22
+ Requires-Dist: huggingface-hub<2,>=0.27
23
+ Requires-Dist: numpy<3,>=1.26
24
+ Requires-Dist: soundfile<1,>=0.12
25
+ Requires-Dist: soxr<2,>=0.5
26
+ Provides-Extra: pnc
27
+ Requires-Dist: onnxruntime==1.29.0; extra == "pnc"
28
+ Requires-Dist: tokenizers==0.22.2; extra == "pnc"
29
+ Provides-Extra: tiny-onnx
30
+ Requires-Dist: sherpa-onnx==1.13.6; extra == "tiny-onnx"
31
+ Requires-Dist: sherpa-onnx-core==1.13.6; extra == "tiny-onnx"
32
+ Provides-Extra: local
33
+ Requires-Dist: onnxruntime==1.29.0; extra == "local"
34
+ Requires-Dist: sherpa-onnx==1.13.6; extra == "local"
35
+ Requires-Dist: sherpa-onnx-core==1.13.6; extra == "local"
36
+ Requires-Dist: tokenizers==0.22.2; extra == "local"
37
+ Dynamic: license-file
38
+
39
+ # Ekko STT
40
+
41
+ ![Ekko STT: Tiny speech recognition and PnC text formatting](https://raw.githubusercontent.com/Rye-A1/ekko-danish-stt/main/assets/ekko-stt-cover.png)
42
+
43
+ Local inference for [Ekko v1 Tiny](https://huggingface.co/RyeAI/ekko-v1-tiny),
44
+ a Danish speech recognizer with word timestamps, and
45
+ [Ekko PnC](https://huggingface.co/RyeAI/ekko-pnc), its optional punctuation and
46
+ capitalization model. This repository contains the Python runtime and the
47
+ [browser demo](https://huggingface.co/spaces/RyeAI/ekko-tiny-browser).
48
+
49
+ | Command | Runtime | Default model |
50
+ |---|---|---|
51
+ | `ekko tiny` | parakeet.cpp | Q5_0 GGUF |
52
+ | `ekko tiny-onnx` | sherpa-onnx | int8 ONNX |
53
+ | `ekko pnc` | ONNX Runtime | PnC int8 |
54
+
55
+ ## Run locally
56
+
57
+ Install [uv](https://docs.astral.sh/uv/), then from this checkout:
58
+
59
+ ```bash
60
+ uv sync --locked --no-editable --extra pnc
61
+ uv run --no-sync ekko tiny audio.wav --pnc --format json
62
+ ```
63
+
64
+ To format an existing transcript without running speech recognition:
65
+
66
+ ```bash
67
+ uv run --no-sync ekko pnc "hej mit navn er emil hvordan går det i dag"
68
+ ```
69
+
70
+ For the ONNX runtime:
71
+
72
+ ```bash
73
+ uv sync --locked --no-editable --extra tiny-onnx --extra pnc
74
+ uv run --no-sync ekko tiny-onnx audio.wav --precision int8 --pnc --format json
75
+ ```
76
+
77
+ The first run downloads model artifacts from Hugging Face and the native
78
+ parakeet.cpp library when needed. Downloads use immutable revisions and
79
+ SHA-256 checks in [the release manifest](https://github.com/Rye-A1/ekko-danish-stt/blob/main/src/ekko/release_manifest.json).
80
+ Set `EKKO_OFFLINE=1` after prefetching to prevent network access.
81
+
82
+ The [browser demo](https://huggingface.co/spaces/RyeAI/ekko-tiny-browser) runs Tiny and PnC locally
83
+ through WebAssembly. Audio and transcripts stay on the device.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ uv sync --locked --extra local
89
+ uv run --no-sync python -m unittest discover -s tests -p 'test_*.py'
90
+ uv build
91
+ ```
92
+
93
+ The source code is MIT licensed. Tiny and PnC weights have their own terms in
94
+ their Hugging Face repositories. See [third-party notices](https://github.com/Rye-A1/ekko-danish-stt/blob/main/THIRD_PARTY_NOTICES.md)
95
+ for runtime dependencies.
@@ -0,0 +1,57 @@
1
+ # Ekko STT
2
+
3
+ ![Ekko STT: Tiny speech recognition and PnC text formatting](https://raw.githubusercontent.com/Rye-A1/ekko-danish-stt/main/assets/ekko-stt-cover.png)
4
+
5
+ Local inference for [Ekko v1 Tiny](https://huggingface.co/RyeAI/ekko-v1-tiny),
6
+ a Danish speech recognizer with word timestamps, and
7
+ [Ekko PnC](https://huggingface.co/RyeAI/ekko-pnc), its optional punctuation and
8
+ capitalization model. This repository contains the Python runtime and the
9
+ [browser demo](https://huggingface.co/spaces/RyeAI/ekko-tiny-browser).
10
+
11
+ | Command | Runtime | Default model |
12
+ |---|---|---|
13
+ | `ekko tiny` | parakeet.cpp | Q5_0 GGUF |
14
+ | `ekko tiny-onnx` | sherpa-onnx | int8 ONNX |
15
+ | `ekko pnc` | ONNX Runtime | PnC int8 |
16
+
17
+ ## Run locally
18
+
19
+ Install [uv](https://docs.astral.sh/uv/), then from this checkout:
20
+
21
+ ```bash
22
+ uv sync --locked --no-editable --extra pnc
23
+ uv run --no-sync ekko tiny audio.wav --pnc --format json
24
+ ```
25
+
26
+ To format an existing transcript without running speech recognition:
27
+
28
+ ```bash
29
+ uv run --no-sync ekko pnc "hej mit navn er emil hvordan går det i dag"
30
+ ```
31
+
32
+ For the ONNX runtime:
33
+
34
+ ```bash
35
+ uv sync --locked --no-editable --extra tiny-onnx --extra pnc
36
+ uv run --no-sync ekko tiny-onnx audio.wav --precision int8 --pnc --format json
37
+ ```
38
+
39
+ The first run downloads model artifacts from Hugging Face and the native
40
+ parakeet.cpp library when needed. Downloads use immutable revisions and
41
+ SHA-256 checks in [the release manifest](https://github.com/Rye-A1/ekko-danish-stt/blob/main/src/ekko/release_manifest.json).
42
+ Set `EKKO_OFFLINE=1` after prefetching to prevent network access.
43
+
44
+ The [browser demo](https://huggingface.co/spaces/RyeAI/ekko-tiny-browser) runs Tiny and PnC locally
45
+ through WebAssembly. Audio and transcripts stay on the device.
46
+
47
+ ## Development
48
+
49
+ ```bash
50
+ uv sync --locked --extra local
51
+ uv run --no-sync python -m unittest discover -s tests -p 'test_*.py'
52
+ uv build
53
+ ```
54
+
55
+ The source code is MIT licensed. Tiny and PnC weights have their own terms in
56
+ their Hugging Face repositories. See [third-party notices](https://github.com/Rye-A1/ekko-danish-stt/blob/main/THIRD_PARTY_NOTICES.md)
57
+ for runtime dependencies.
@@ -0,0 +1,17 @@
1
+ # Third-party notices
2
+
3
+ Ekko source code is MIT licensed. It integrates these separately licensed
4
+ projects and artifacts:
5
+
6
+ - [parakeet.cpp](https://github.com/mudler/parakeet.cpp), MIT. Downloaded native
7
+ bundles include the upstream license.
8
+ - [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx), Apache-2.0.
9
+ - [ONNX Runtime](https://github.com/microsoft/onnxruntime), MIT.
10
+ - [Hugging Face tokenizers](https://github.com/huggingface/tokenizers), Apache-2.0.
11
+ - [libsndfile](https://github.com/libsndfile/libsndfile), LGPL-2.1-or-later.
12
+ - [libsoxr](https://sourceforge.net/projects/soxr/), LGPL-2.1-or-later.
13
+
14
+ Model weights have separate terms. See the
15
+ [Tiny model license](https://huggingface.co/RyeAI/ekko-v1-tiny/blob/main/MODEL_LICENSE.md)
16
+ and [PnC model license](https://huggingface.co/RyeAI/ekko-pnc/blob/main/MODEL_LICENSE.md)
17
+ with their bundled notices on Hugging Face.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["setuptools==84.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ekko-stt"
7
+ version = "0.1.0"
8
+ description = "Local Danish speech recognition and punctuation with Ekko Tiny and PnC"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
13
+ authors = [{name = "Emil Schledermann / RyeAI"}]
14
+ keywords = ["asr", "danish", "speech-to-text", "onnx", "gguf"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Operating System :: MacOS",
18
+ "Operating System :: Microsoft :: Windows",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
23
+ ]
24
+ dependencies = [
25
+ "filelock>=3.13,<4",
26
+ "huggingface-hub>=0.27,<2",
27
+ "numpy>=1.26,<3",
28
+ "soundfile>=0.12,<1",
29
+ "soxr>=0.5,<2",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ pnc = ["onnxruntime==1.29.0", "tokenizers==0.22.2"]
34
+ tiny-onnx = ["sherpa-onnx==1.13.6", "sherpa-onnx-core==1.13.6"]
35
+ local = [
36
+ "onnxruntime==1.29.0",
37
+ "sherpa-onnx==1.13.6",
38
+ "sherpa-onnx-core==1.13.6",
39
+ "tokenizers==0.22.2",
40
+ ]
41
+
42
+ [project.scripts]
43
+ ekko = "ekko.cli:main"
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/Rye-A1/ekko-danish-stt"
47
+ Issues = "https://github.com/Rye-A1/ekko-danish-stt/issues"
48
+
49
+ [tool.setuptools.packages.find]
50
+ where = ["src"]
51
+
52
+ [tool.setuptools.package-data]
53
+ ekko = ["release_manifest.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,10 @@
1
+ """Local inference adapters for Ekko v1 Tiny."""
2
+
3
+ from .artifacts import RELEASE_MANIFEST
4
+ from .pnc import Punctuator
5
+ from .runtime import (EkkoError, TinyBackend, TinyCLIBackend, Transcript, Word, validate_transcript)
6
+ from .tiny_onnx import TinyOnnxBackend
7
+
8
+ __all__ = ["EkkoError", "Punctuator", "RELEASE_MANIFEST", "TinyBackend",
9
+ "TinyCLIBackend", "TinyOnnxBackend", "Transcript", "Word",
10
+ "validate_transcript"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+
4
+ raise SystemExit(main())
@@ -0,0 +1,371 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import importlib
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import stat
10
+ import sys
11
+ import tarfile
12
+ import tempfile
13
+ import urllib.request
14
+ import zipfile
15
+ from dataclasses import dataclass
16
+ from importlib.resources import files
17
+ from pathlib import Path
18
+
19
+ from filelock import FileLock
20
+
21
+ from .runtime import EkkoError
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class NativeArtifact:
26
+ archive: str
27
+ sha256: str
28
+ directory: str
29
+ library: str
30
+ library_sha256: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class HubArtifact:
35
+ repo_id: str
36
+ revision: str
37
+ filename: str
38
+ sha256: str
39
+ dialect: str
40
+
41
+
42
+ RELEASE_MANIFEST = json.loads(
43
+ files("ekko").joinpath("release_manifest.json").read_text(encoding="utf-8")
44
+ )
45
+ HUB_ARTIFACTS = {
46
+ name: HubArtifact(**metadata)
47
+ for name, metadata in RELEASE_MANIFEST["artifacts"].items()
48
+ }
49
+ HUB_LIBRARY_NAME = RELEASE_MANIFEST["distribution"]
50
+ HUB_LIBRARY_VERSION = RELEASE_MANIFEST["release_version"]
51
+ PARAKEET_VERSION = RELEASE_MANIFEST["native_runtime"]["version"]
52
+ PARAKEET_RELEASE_URL = RELEASE_MANIFEST["native_runtime"]["release_url"]
53
+ _PARAKEET_ARTIFACTS = {
54
+ tuple(key.split("/", 1)): NativeArtifact(**metadata)
55
+ for key, metadata in RELEASE_MANIFEST["native_runtime"]["artifacts"].items()
56
+ }
57
+
58
+
59
+ def cache_root() -> Path:
60
+ override = os.environ.get("EKKO_CACHE_DIR")
61
+ if override:
62
+ return Path(override).expanduser()
63
+ current_platform = _platform_name()
64
+ if current_platform == "darwin":
65
+ return Path.home() / "Library" / "Caches" / "ekko"
66
+ if current_platform == "win32":
67
+ return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "ekko"
68
+ return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "ekko"
69
+
70
+
71
+ def _platform_name() -> str:
72
+ return sys.platform
73
+
74
+
75
+ def _machine(value: str | None = None) -> str:
76
+ machine = (value or platform.machine()).lower()
77
+ return {"arm64": "arm64", "aarch64": "aarch64", "amd64": "amd64", "x86_64": "x86_64"}.get(
78
+ machine, machine
79
+ )
80
+
81
+
82
+ def native_artifact(
83
+ *,
84
+ system: str | None = None,
85
+ machine: str | None = None,
86
+ libc: tuple[str, str] | None = None,
87
+ ) -> NativeArtifact:
88
+ current_system = system or _platform_name()
89
+ current_machine = _machine(machine)
90
+ key = (current_system, current_machine)
91
+ if key == ("linux", "arm64"):
92
+ key = ("linux", "aarch64")
93
+ if key == ("win32", "x86_64"):
94
+ key = ("win32", "amd64")
95
+ try:
96
+ artifact = _PARAKEET_ARTIFACTS[key]
97
+ except KeyError as error:
98
+ raise EkkoError(
99
+ f"no prebuilt parakeet.cpp {PARAKEET_VERSION} library for "
100
+ f"{current_system}/{current_machine}"
101
+ ) from error
102
+ if current_system == "linux":
103
+ libc_name, libc_version = libc or platform.libc_ver()
104
+ try:
105
+ version = tuple(int(part) for part in libc_version.split(".")[:2])
106
+ except ValueError:
107
+ version = ()
108
+ if libc_name.lower() != "glibc" or version < (2, 34):
109
+ raise EkkoError(
110
+ "the published parakeet.cpp Linux library requires glibc >= 2.34; "
111
+ f"detected {libc_name or 'unknown'} {libc_version or 'unknown'}"
112
+ )
113
+ return artifact
114
+
115
+
116
+ def sha256_file(path: Path) -> str:
117
+ digest = hashlib.sha256()
118
+ with path.open("rb") as stream:
119
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
120
+ digest.update(block)
121
+ return digest.hexdigest()
122
+
123
+
124
+ def _download_verified_hub_file(
125
+ hub: object,
126
+ *,
127
+ repo_id: str,
128
+ revision: str,
129
+ filename: str,
130
+ expected_sha256: str,
131
+ local_only: bool,
132
+ ) -> Path:
133
+ lock_root = cache_root() / "locks"
134
+ lock_root.mkdir(parents=True, exist_ok=True)
135
+ lock = FileLock(str(lock_root / f"hub-{expected_sha256}.lock"))
136
+
137
+ def download(*, force: bool) -> Path:
138
+ try:
139
+ return Path(
140
+ hub.hf_hub_download(
141
+ repo_id=repo_id,
142
+ filename=filename,
143
+ revision=revision,
144
+ cache_dir=cache_root() / "huggingface",
145
+ local_files_only=local_only,
146
+ force_download=force,
147
+ library_name=HUB_LIBRARY_NAME,
148
+ library_version=HUB_LIBRARY_VERSION,
149
+ )
150
+ )
151
+ except Exception as error:
152
+ raise EkkoError(
153
+ f"could not download {repo_id}/{filename} at {revision}: {error}"
154
+ ) from error
155
+
156
+ def actual_hash(path: Path) -> str:
157
+ try:
158
+ return sha256_file(path)
159
+ except OSError as error:
160
+ raise EkkoError(f"could not hash cached artifact {path}: {error}") from error
161
+
162
+ with lock:
163
+ path = download(force=False)
164
+ actual = actual_hash(path)
165
+ if actual == expected_sha256:
166
+ return path
167
+ if not local_only:
168
+ path = download(force=True)
169
+ actual = actual_hash(path)
170
+ if actual == expected_sha256:
171
+ return path
172
+ raise EkkoError(
173
+ f"checksum mismatch for {repo_id}/{filename}: "
174
+ f"expected {expected_sha256}, got {actual}; remove the cached file "
175
+ "and retry"
176
+ )
177
+
178
+
179
+ def _ensure_model_licenses(
180
+ hub: object, *, repo_id: str, revision: str, local_only: bool
181
+ ) -> None:
182
+ model = next(
183
+ (
184
+ metadata
185
+ for metadata in RELEASE_MANIFEST["models"].values()
186
+ if metadata["repo_id"] == repo_id
187
+ ),
188
+ None,
189
+ )
190
+ if model is None:
191
+ return
192
+ if model["snapshot_revision"] != revision:
193
+ raise EkkoError(f"model and legal files use different revisions for {repo_id}")
194
+ for filename, expected_sha256 in model["license_files"].items():
195
+ _download_verified_hub_file(
196
+ hub,
197
+ repo_id=repo_id,
198
+ revision=revision,
199
+ filename=filename,
200
+ expected_sha256=expected_sha256,
201
+ local_only=local_only,
202
+ )
203
+
204
+
205
+ def ensure_hub_artifact(name: str, *, offline: bool = False) -> Path:
206
+ try:
207
+ artifact = HUB_ARTIFACTS[name]
208
+ except KeyError as error:
209
+ raise EkkoError(f"unknown Ekko artifact: {name}") from error
210
+
211
+ try:
212
+ hub = importlib.import_module("huggingface_hub")
213
+ except ImportError as error:
214
+ raise EkkoError(
215
+ "model download requires huggingface-hub; install ekko-stt"
216
+ ) from error
217
+
218
+ local_only = offline or os.environ.get("EKKO_OFFLINE") == "1"
219
+ path = _download_verified_hub_file(
220
+ hub,
221
+ repo_id=artifact.repo_id,
222
+ revision=artifact.revision,
223
+ filename=artifact.filename,
224
+ expected_sha256=artifact.sha256,
225
+ local_only=local_only,
226
+ )
227
+ _ensure_model_licenses(
228
+ hub,
229
+ repo_id=artifact.repo_id,
230
+ revision=artifact.revision,
231
+ local_only=local_only,
232
+ )
233
+ return path
234
+
235
+
236
+ def ensure_pnc_directory(*, offline: bool = False) -> Path:
237
+ paths = [
238
+ ensure_hub_artifact(name, offline=offline)
239
+ for name in ("pnc-model", "pnc-tokenizer", "pnc-config")
240
+ ]
241
+ parents = {path.parent for path in paths}
242
+ if len(parents) != 1:
243
+ raise EkkoError("PnC files did not resolve to one pinned Hub snapshot")
244
+ return parents.pop()
245
+
246
+
247
+ def ensure_tiny_onnx_directory(
248
+ precision: str, *, offline: bool = False
249
+ ) -> Path:
250
+ if precision not in {"fp32", "int8"}:
251
+ raise EkkoError(f"unsupported Tiny ONNX precision: {precision}")
252
+ paths = [
253
+ ensure_hub_artifact(
254
+ f"tiny-onnx-{precision}-{component}", offline=offline
255
+ )
256
+ for component in ("encoder", "decoder", "joiner")
257
+ ]
258
+ paths.append(ensure_hub_artifact("tiny-onnx-tokens", offline=offline))
259
+ parents = {path.parent for path in paths}
260
+ if len(parents) != 1:
261
+ raise EkkoError("Tiny ONNX files did not resolve to one pinned Hub snapshot")
262
+ return parents.pop()
263
+
264
+
265
+ def _safe_target(root: Path, name: str) -> Path:
266
+ target = (root / name).resolve()
267
+ if target != root.resolve() and root.resolve() not in target.parents:
268
+ raise EkkoError(f"unsafe path in native runtime archive: {name}")
269
+ return target
270
+
271
+
272
+ def _extract_archive(archive: Path, destination: Path) -> None:
273
+ if archive.suffix == ".zip":
274
+ with zipfile.ZipFile(archive) as bundle:
275
+ for member in bundle.infolist():
276
+ _safe_target(destination, member.filename)
277
+ mode = member.external_attr >> 16
278
+ if stat.S_ISLNK(mode):
279
+ raise EkkoError(
280
+ f"links are not allowed in native runtime archive: "
281
+ f"{member.filename}"
282
+ )
283
+ bundle.extractall(destination)
284
+ return
285
+
286
+ with tarfile.open(archive, "r:gz") as bundle:
287
+ for member in bundle.getmembers():
288
+ _safe_target(destination, member.name)
289
+ if member.issym() or member.islnk():
290
+ raise EkkoError(
291
+ f"links are not allowed in native runtime archive: {member.name}"
292
+ )
293
+ bundle.extractall(destination)
294
+
295
+
296
+ def ensure_parakeet_library(*, offline: bool = False) -> Path:
297
+ override = os.environ.get("EKKO_PARAKEET_LIBRARY")
298
+ if override:
299
+ path = Path(override).expanduser()
300
+ if not path.is_file():
301
+ raise EkkoError(f"EKKO_PARAKEET_LIBRARY does not exist: {path}")
302
+ return path
303
+
304
+ artifact = native_artifact()
305
+ install_root = (
306
+ cache_root()
307
+ / "native"
308
+ / f"parakeet-{PARAKEET_VERSION}-{artifact.sha256[:12]}"
309
+ )
310
+ library = install_root / artifact.directory / artifact.library
311
+ install_root.parent.mkdir(parents=True, exist_ok=True)
312
+ lock = FileLock(str(install_root) + ".lock")
313
+ with lock:
314
+ if library.is_file():
315
+ actual = sha256_file(library)
316
+ if actual != artifact.library_sha256:
317
+ raise EkkoError(
318
+ f"cached native library checksum mismatch at {library}; "
319
+ "remove its versioned cache directory and retry"
320
+ )
321
+ return library
322
+ if offline or os.environ.get("EKKO_OFFLINE") == "1":
323
+ raise EkkoError(f"parakeet.cpp runtime is not cached at {library}")
324
+
325
+ with tempfile.TemporaryDirectory(
326
+ prefix="parakeet-download-", dir=install_root.parent
327
+ ) as temporary:
328
+ temporary_path = Path(temporary)
329
+ archive = temporary_path / artifact.archive
330
+ request = urllib.request.Request(
331
+ f"{PARAKEET_RELEASE_URL}/{artifact.archive}",
332
+ headers={"User-Agent": "ekko-runtime"},
333
+ )
334
+ try:
335
+ with urllib.request.urlopen(request, timeout=60) as response:
336
+ with archive.open("wb") as output:
337
+ shutil.copyfileobj(response, output)
338
+ except OSError as error:
339
+ raise EkkoError(
340
+ f"could not download parakeet.cpp runtime: {error}"
341
+ ) from error
342
+ actual = sha256_file(archive)
343
+ if actual != artifact.sha256:
344
+ raise EkkoError(
345
+ f"parakeet.cpp archive checksum mismatch: expected "
346
+ f"{artifact.sha256}, got {actual}"
347
+ )
348
+ extracted = temporary_path / "extracted"
349
+ extracted.mkdir()
350
+ _extract_archive(archive, extracted)
351
+ candidate = extracted / artifact.directory / artifact.library
352
+ if not candidate.is_file():
353
+ raise EkkoError(f"native runtime archive lacks {artifact.library}")
354
+ actual_library = sha256_file(candidate)
355
+ if actual_library != artifact.library_sha256:
356
+ raise EkkoError(
357
+ f"parakeet.cpp library checksum mismatch: expected "
358
+ f"{artifact.library_sha256}, got {actual_library}"
359
+ )
360
+ try:
361
+ os.replace(extracted, install_root)
362
+ except FileExistsError:
363
+ pass
364
+
365
+ if not library.is_file():
366
+ raise EkkoError(
367
+ f"native runtime installation failed: {library}"
368
+ )
369
+ if sha256_file(library) != artifact.library_sha256:
370
+ raise EkkoError(f"installed native library checksum mismatch: {library}")
371
+ return library