feat(dictate): support configurable ASR backends
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
{"type":"transcript","final":false,"text":"partial"}
|
||||
{"type":"transcript","final":true,"text":"final fixture transcript"}
|
||||
@@ -0,0 +1 @@
|
||||
{"sessionId":"fixture-session-1"}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"type":"session.created"}
|
||||
{"type":"session.updated"}
|
||||
{"type":"input_audio_buffer.committed","event_id":"event-fixture-1","previous_item_id":null,"item_id":"fixture-item-1"}
|
||||
{"type":"conversation.item.input_audio_transcription.completed","item_id":"fixture-item-1","transcript":"final fixture transcript"}
|
||||
@@ -0,0 +1,507 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from pi_dictate_service.backend import PcmQueue, ProviderError, provider_event
|
||||
from pi_dictate_service.backends.meta import (
|
||||
META_ENDPOINT,
|
||||
META_MAX_BACKLOG_BYTES,
|
||||
META_SOCKET_IO_TIMEOUT,
|
||||
MetaBackend,
|
||||
_default_transport as default_meta_transport,
|
||||
)
|
||||
from pi_dictate_service.backends.openai import (
|
||||
OPENAI_ENDPOINT,
|
||||
OPENAI_SOCKET_IO_TIMEOUT,
|
||||
OpenAIBackend,
|
||||
_default_transport as default_openai_transport,
|
||||
)
|
||||
from pi_dictate_service.config import MetaConfig, OpenAIConfig
|
||||
|
||||
|
||||
FIXTURE_ROOT = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def fixture_events(provider: str) -> list[str]:
|
||||
return (FIXTURE_ROOT / provider / "events.jsonl").read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def _capture_start_error(backend: object, errors: list[BaseException]) -> None:
|
||||
try:
|
||||
backend.start_turn()
|
||||
except BaseException as error:
|
||||
errors.append(error)
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self, events: list[str | bytes | None]) -> None:
|
||||
self.events = list(events)
|
||||
self.sent: list[str | bytes] = []
|
||||
self.closed = False
|
||||
|
||||
def send(self, value: str | bytes) -> None:
|
||||
self.sent.append(value)
|
||||
|
||||
def recv(self, timeout: float | None = None) -> str | bytes | None:
|
||||
if not self.events:
|
||||
return None
|
||||
return self.events.pop(0)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class BlockingCloseTransport(FakeTransport):
|
||||
def __init__(self, events: list[str | bytes | None]) -> None:
|
||||
super().__init__(events)
|
||||
self.close_started = threading.Event()
|
||||
self.allow_close = threading.Event()
|
||||
|
||||
def close(self) -> None:
|
||||
self.close_started.set()
|
||||
self.allow_close.wait(1)
|
||||
super().close()
|
||||
|
||||
|
||||
class HttpFailure(RuntimeError):
|
||||
def __init__(self, status_code: int) -> None:
|
||||
super().__init__(f"HTTP {status_code}")
|
||||
self.response = SimpleNamespace(status_code=status_code)
|
||||
|
||||
|
||||
class BackendTests(unittest.TestCase):
|
||||
def test_production_transports_apply_a_socket_io_deadline(self) -> None:
|
||||
for factory, timeout, arguments in (
|
||||
(default_meta_transport, META_SOCKET_IO_TIMEOUT, (META_ENDPOINT,)),
|
||||
(
|
||||
default_openai_transport,
|
||||
OPENAI_SOCKET_IO_TIMEOUT,
|
||||
(OPENAI_ENDPOINT,),
|
||||
),
|
||||
):
|
||||
with self.subTest(factory=factory.__module__):
|
||||
transport = SimpleNamespace(socket=Mock())
|
||||
with patch("websockets.sync.client.connect", return_value=transport):
|
||||
if factory is default_openai_transport:
|
||||
factory(*arguments, headers={"Authorization": "Bearer test"})
|
||||
else:
|
||||
factory(*arguments)
|
||||
transport.socket.settimeout.assert_called_once_with(timeout)
|
||||
|
||||
def make_files(self) -> tuple[Path, Path, tempfile.TemporaryDirectory[str]]:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
root = Path(temporary.name)
|
||||
key = root / "key"
|
||||
key.write_text("secret", encoding="utf-8")
|
||||
key.chmod(0o600)
|
||||
terms = root / "terms"
|
||||
terms.write_text("JSON\n", encoding="utf-8")
|
||||
return key, terms, temporary
|
||||
|
||||
def test_meta_waits_for_ack_sends_documented_handshake_and_finalizes_after_tail(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
acknowledgement = (FIXTURE_ROOT / "meta" / "session_ack.json").read_text(encoding="utf-8").strip()
|
||||
transport = FakeTransport([acknowledgement, *fixture_events("meta"), None])
|
||||
connections: list[str] = []
|
||||
|
||||
def connect(endpoint: str) -> FakeTransport:
|
||||
connections.append(endpoint)
|
||||
return transport
|
||||
|
||||
backend = MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms, language_bias=("en",)),
|
||||
transport_factory=connect,
|
||||
timeout=1,
|
||||
)
|
||||
turn = backend.start_turn()
|
||||
turn.enqueue_pcm(b"pcm")
|
||||
self.assertEqual(turn.finish(), "final fixture transcript")
|
||||
|
||||
self.assertEqual(connections, [META_ENDPOINT])
|
||||
handshake = json.loads(transport.sent[0])
|
||||
self.assertEqual(handshake["authorization"]["accessToken"], "Bearer secret")
|
||||
self.assertEqual(handshake["audioEncoding"], "PCM_24KHZ")
|
||||
self.assertEqual(handshake["mode"], "PUSH_TO_TALK")
|
||||
self.assertFalse(handshake["emitAudioProgress"])
|
||||
self.assertEqual(handshake["languageBias"], ["en"])
|
||||
self.assertEqual(handshake["keywords"], ["JSON"])
|
||||
self.assertEqual(transport.sent[1], b"pcm")
|
||||
self.assertEqual(json.loads(transport.sent[2]), {"type": "endStream"})
|
||||
|
||||
def test_openai_uses_ga_session_shape_and_correlates_committed_item(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
transport = FakeTransport(fixture_events("openai"))
|
||||
connections: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
def connect(endpoint: str, *, headers: dict[str, str]) -> FakeTransport:
|
||||
connections.append((endpoint, headers))
|
||||
return transport
|
||||
|
||||
backend = OpenAIBackend(
|
||||
OpenAIConfig(
|
||||
"gpt-live-transcribe",
|
||||
key,
|
||||
terms,
|
||||
delay="low",
|
||||
prompt="Coding",
|
||||
languages=("en",),
|
||||
),
|
||||
transport_factory=connect,
|
||||
timeout=1,
|
||||
)
|
||||
turn = backend.start_turn()
|
||||
turn.enqueue_pcm(b"pcm")
|
||||
self.assertEqual(turn.finish(), "final fixture transcript")
|
||||
|
||||
self.assertEqual(
|
||||
connections,
|
||||
[(OPENAI_ENDPOINT, {"Authorization": "Bearer secret"})],
|
||||
)
|
||||
update = json.loads(transport.sent[0])
|
||||
session = update["session"]
|
||||
self.assertEqual(session["type"], "transcription")
|
||||
input_audio = session["audio"]["input"]
|
||||
self.assertEqual(input_audio["format"], {"type": "audio/pcm", "rate": 24000})
|
||||
self.assertEqual(input_audio["transcription"]["languages"], ["en"])
|
||||
self.assertEqual(input_audio["transcription"]["delay"], "low")
|
||||
self.assertEqual(input_audio["transcription"]["keywords"], ["JSON"])
|
||||
self.assertIsNone(input_audio["turn_detection"])
|
||||
self.assertEqual(json.loads(transport.sent[-1]), {"type": "input_audio_buffer.commit"})
|
||||
self.assertFalse(any(isinstance(item, str) and "response.create" in item for item in transport.sent))
|
||||
|
||||
def test_meta_rejects_malformed_ack_and_auth_error(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
malformed = MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=lambda _endpoint: FakeTransport(['{"sessionId":"s","unexpected":1}']),
|
||||
timeout=0.1,
|
||||
)
|
||||
with self.assertRaisesRegex(ProviderError, "acknowledgement"):
|
||||
malformed.start_turn()
|
||||
|
||||
auth = MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=lambda _endpoint: FakeTransport(['{"type":"error","code":"401"}']),
|
||||
timeout=0.1,
|
||||
)
|
||||
with self.assertRaisesRegex(ProviderError, "Meta transcription service reported") as error:
|
||||
auth.start_turn()
|
||||
self.assertEqual(error.exception.code, "meta_auth")
|
||||
with self.assertRaises(ValueError):
|
||||
MetaBackend(MetaConfig("muse-voice-transcribe-1.0", key, terms), max_queue_bytes=META_MAX_BACKLOG_BYTES + 1)
|
||||
|
||||
def test_cloud_turn_rejects_partial_only_close_and_queue_overflow(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
transport = FakeTransport(['{"sessionId":"s"}', '{"type":"transcript","final":false,"text":"partial"}', None])
|
||||
backend = MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=lambda _endpoint: transport,
|
||||
timeout=0.1,
|
||||
)
|
||||
turn = backend.start_turn()
|
||||
with self.assertRaisesRegex(ProviderError, "final transcript"):
|
||||
turn.finish()
|
||||
|
||||
queue = PcmQueue(max_bytes=2, max_chunks=1)
|
||||
queue.put(b"12")
|
||||
with self.assertRaisesRegex(ProviderError, "full"):
|
||||
queue.put(b"3")
|
||||
|
||||
def test_cancel_before_prepare_worker_runs_prevents_connection(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
for backend in (
|
||||
MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=lambda *_args, **_kwargs: self.fail("Meta connected"),
|
||||
),
|
||||
OpenAIBackend(
|
||||
OpenAIConfig("gpt-live-transcribe", key, terms),
|
||||
transport_factory=lambda *_args, **_kwargs: self.fail("OpenAI connected"),
|
||||
),
|
||||
):
|
||||
with self.subTest(backend=backend.descriptor.backend):
|
||||
backend.begin_prepare()
|
||||
backend.cancel_prepare()
|
||||
with self.assertRaises(ProviderError) as error:
|
||||
backend.start_turn()
|
||||
self.assertEqual(error.exception.code, "cancelled")
|
||||
|
||||
def test_cancel_during_connect_prevents_authenticated_handshake(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
for backend_name in ("meta", "openai"):
|
||||
with self.subTest(backend=backend_name):
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
transport = FakeTransport([])
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def connect(*_args: object, **_kwargs: object) -> FakeTransport:
|
||||
entered.set()
|
||||
release.wait(1)
|
||||
return transport
|
||||
|
||||
if backend_name == "meta":
|
||||
backend = MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=connect,
|
||||
timeout=0.1,
|
||||
)
|
||||
else:
|
||||
backend = OpenAIBackend(
|
||||
OpenAIConfig("gpt-live-transcribe", key, terms),
|
||||
transport_factory=connect,
|
||||
timeout=0.1,
|
||||
)
|
||||
worker = threading.Thread(
|
||||
target=lambda: _capture_start_error(backend, errors), daemon=True
|
||||
)
|
||||
worker.start()
|
||||
self.assertTrue(entered.wait(1))
|
||||
backend.cancel_prepare()
|
||||
release.set()
|
||||
worker.join(1)
|
||||
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertEqual(getattr(errors[0], "code", None), "cancelled")
|
||||
self.assertEqual(transport.sent, [])
|
||||
|
||||
def test_meta_discards_high_volume_partials_instead_of_overflowing_event_queue(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
partial = '{"type":"transcript","final":false,"text":"partial"}'
|
||||
transport = FakeTransport([
|
||||
'{"sessionId":"s"}',
|
||||
*([partial] * 200),
|
||||
'{"type":"transcript","final":true,"text":"final"}',
|
||||
None,
|
||||
])
|
||||
turn = MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=lambda _endpoint: transport,
|
||||
timeout=1,
|
||||
).start_turn()
|
||||
|
||||
self.assertEqual(turn.finish(), "final")
|
||||
|
||||
def test_cancel_never_sends_a_provider_end_marker(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
transports_and_turns = []
|
||||
meta_transport = FakeTransport(['{"sessionId":"s"}'])
|
||||
transports_and_turns.append((
|
||||
meta_transport,
|
||||
MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=lambda _endpoint: meta_transport,
|
||||
timeout=1,
|
||||
).start_turn(),
|
||||
))
|
||||
openai_transport = FakeTransport([
|
||||
'{"type":"session.created"}', '{"type":"session.updated"}'
|
||||
])
|
||||
transports_and_turns.append((
|
||||
openai_transport,
|
||||
OpenAIBackend(
|
||||
OpenAIConfig("gpt-live-transcribe", key, terms),
|
||||
transport_factory=lambda _endpoint, **_kwargs: openai_transport,
|
||||
timeout=1,
|
||||
).start_turn(),
|
||||
))
|
||||
|
||||
for transport, turn in transports_and_turns:
|
||||
turn.cancel()
|
||||
deadline = time.monotonic() + 1
|
||||
while not turn.cleanup_complete and time.monotonic() < deadline:
|
||||
turn.close()
|
||||
sent_types = [
|
||||
value.get("type")
|
||||
for raw in transport.sent
|
||||
if isinstance(raw, str) and isinstance((value := json.loads(raw)), dict)
|
||||
]
|
||||
self.assertNotIn("endStream", sent_types)
|
||||
self.assertNotIn("input_audio_buffer.commit", sent_types)
|
||||
|
||||
def test_cancel_does_not_block_on_a_slow_transport_close(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
transport = BlockingCloseTransport(['{"sessionId":"s"}'])
|
||||
turn = MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=lambda _endpoint: transport,
|
||||
timeout=0.1,
|
||||
).start_turn()
|
||||
|
||||
started = time.monotonic()
|
||||
turn.cancel()
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
self.assertLess(elapsed, 0.05)
|
||||
self.assertTrue(transport.close_started.wait(1))
|
||||
self.assertFalse(turn.cleanup_complete)
|
||||
transport.allow_close.set()
|
||||
deadline = time.monotonic() + 1
|
||||
while not turn.cleanup_complete and time.monotonic() < deadline:
|
||||
turn.close()
|
||||
self.assertTrue(turn.cleanup_complete)
|
||||
|
||||
def test_http_failures_use_websockets_response_status(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
meta = MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=lambda _endpoint: (_ for _ in ()).throw(HttpFailure(401)),
|
||||
)
|
||||
with self.assertRaises(ProviderError) as meta_error:
|
||||
meta.start_turn()
|
||||
self.assertEqual(meta_error.exception.code, "meta_auth")
|
||||
|
||||
openai = OpenAIBackend(
|
||||
OpenAIConfig("gpt-live-transcribe", key, terms),
|
||||
transport_factory=lambda _endpoint, **_kwargs: (_ for _ in ()).throw(HttpFailure(429)),
|
||||
)
|
||||
with self.assertRaises(ProviderError) as openai_error:
|
||||
openai.start_turn()
|
||||
self.assertEqual(openai_error.exception.code, "openai_rate_limit")
|
||||
|
||||
def test_cloud_turn_reports_provider_close_during_recording(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
backend = MetaBackend(
|
||||
MetaConfig("muse-voice-transcribe-1.0", key, terms),
|
||||
transport_factory=lambda _endpoint: FakeTransport([
|
||||
'{"sessionId":"s"}',
|
||||
None,
|
||||
]),
|
||||
timeout=0.1,
|
||||
)
|
||||
turn = backend.start_turn()
|
||||
deadline = time.monotonic() + 1
|
||||
failure = turn.poll_error()
|
||||
while failure is None and time.monotonic() < deadline:
|
||||
time.sleep(0.001)
|
||||
failure = turn.poll_error()
|
||||
|
||||
self.assertIsNotNone(failure)
|
||||
self.assertEqual(failure.code, "meta_premature_close")
|
||||
|
||||
def test_provider_event_rejects_recursive_values(self) -> None:
|
||||
recursive: dict[str, object] = {}
|
||||
recursive["self"] = recursive
|
||||
with self.assertRaises(ProviderError):
|
||||
provider_event(recursive)
|
||||
|
||||
def test_openai_rejects_malformed_error_and_unmatched_completion(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
malformed = OpenAIBackend(
|
||||
OpenAIConfig("gpt-live-transcribe", key, terms),
|
||||
transport_factory=lambda _endpoint, **_kwargs: FakeTransport([
|
||||
'{"type":"session.created"}',
|
||||
'{"type":"error","error":{"message":"bad"}}',
|
||||
]),
|
||||
timeout=0.1,
|
||||
)
|
||||
with self.assertRaisesRegex(ProviderError, "error event"):
|
||||
malformed.start_turn()
|
||||
|
||||
transport = FakeTransport([
|
||||
'{"type":"session.created"}',
|
||||
'{"type":"session.updated"}',
|
||||
'{"type":"input_audio_buffer.committed","item_id":"new"}',
|
||||
'{"type":"conversation.item.input_audio_transcription.completed","item_id":"old","transcript":"stale"}',
|
||||
None,
|
||||
])
|
||||
backend = OpenAIBackend(
|
||||
OpenAIConfig("gpt-live-transcribe", key, terms),
|
||||
transport_factory=lambda _endpoint, **_kwargs: transport,
|
||||
timeout=0.1,
|
||||
)
|
||||
turn = backend.start_turn()
|
||||
with self.assertRaises(ProviderError) as error:
|
||||
turn.finish()
|
||||
self.assertIn(error.exception.code, {"openai_premature_close", "openai_timeout"})
|
||||
|
||||
def test_openai_commit_oriented_alternative_uses_explicit_commit(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
transport = FakeTransport(fixture_events("openai"))
|
||||
turn = OpenAIBackend(
|
||||
OpenAIConfig("gpt-transcribe", key, terms),
|
||||
transport_factory=lambda _endpoint, **_kwargs: transport,
|
||||
timeout=1,
|
||||
).start_turn()
|
||||
turn.enqueue_pcm(b"pcm")
|
||||
|
||||
self.assertEqual(turn.finish(), "final fixture transcript")
|
||||
update = json.loads(transport.sent[0])
|
||||
self.assertEqual(
|
||||
update["session"]["audio"]["input"]["transcription"]["model"],
|
||||
"gpt-transcribe",
|
||||
)
|
||||
self.assertEqual(json.loads(transport.sent[-1]), {"type": "input_audio_buffer.commit"})
|
||||
|
||||
def test_openai_rejects_unknown_delay_from_direct_callers(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
with self.assertRaisesRegex(ValueError, "delay"):
|
||||
OpenAIBackend(OpenAIConfig("gpt-live-transcribe", key, terms, delay="fast"))
|
||||
|
||||
def test_openai_receiver_failure_overrides_an_already_queued_completion(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
transport = FakeTransport([
|
||||
'{"type":"session.created"}',
|
||||
'{"type":"session.updated"}',
|
||||
'{"type":"input_audio_buffer.committed","item_id":"i"}',
|
||||
'{"type":"conversation.item.input_audio_transcription.completed","item_id":"i","transcript":"unsafe"}',
|
||||
'{}',
|
||||
])
|
||||
turn = OpenAIBackend(
|
||||
OpenAIConfig("gpt-live-transcribe", key, terms),
|
||||
transport_factory=lambda _endpoint, **_kwargs: transport,
|
||||
timeout=1,
|
||||
).start_turn()
|
||||
deadline = time.monotonic() + 1
|
||||
while turn._receiver_error is None and time.monotonic() < deadline:
|
||||
time.sleep(0.001)
|
||||
|
||||
with self.assertRaises(ProviderError) as error:
|
||||
turn.finish()
|
||||
self.assertEqual(error.exception.code, "openai_malformed_event")
|
||||
|
||||
def test_openai_audio_is_bounded_base64(self) -> None:
|
||||
key, terms, temporary = self.make_files()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
transport = FakeTransport(
|
||||
['{"type":"session.created"}', '{"type":"session.updated"}',
|
||||
'{"type":"input_audio_buffer.committed","item_id":"i"}',
|
||||
'{"type":"conversation.item.input_audio_transcription.completed","item_id":"i","transcript":""}']
|
||||
)
|
||||
backend = OpenAIBackend(
|
||||
OpenAIConfig("gpt-live-transcribe", key, terms),
|
||||
transport_factory=lambda _endpoint, **_kwargs: transport,
|
||||
timeout=1,
|
||||
)
|
||||
turn = backend.start_turn()
|
||||
turn.enqueue_pcm(b"\x01\x02")
|
||||
self.assertEqual(turn.finish(), "")
|
||||
append = json.loads(transport.sent[1])
|
||||
self.assertEqual(base64.b64decode(append["audio"]), b"\x01\x02")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pi_dictate_service.config import (
|
||||
ConfigError,
|
||||
CredentialError,
|
||||
load_config,
|
||||
parse_config,
|
||||
provider_keywords,
|
||||
read_credential,
|
||||
)
|
||||
from pi_dictate_service.recorder import audio_level_window_bytes, pw_record_command
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def test_recorder_supports_both_backend_sample_rates(self) -> None:
|
||||
self.assertEqual(pw_record_command(16_000)[5], "16000")
|
||||
self.assertEqual(pw_record_command(24_000)[5], "24000")
|
||||
self.assertEqual(audio_level_window_bytes(24_000), 4_800)
|
||||
|
||||
def test_rejects_unknown_selected_fields_and_invalid_backend_values(self) -> None:
|
||||
for invalid_backend in ([], {}, 1, True, None):
|
||||
with self.subTest(backend=invalid_backend):
|
||||
with self.assertRaisesRegex(ConfigError, "backend must be"):
|
||||
parse_config({"version": 1, "backend": invalid_backend})
|
||||
|
||||
base = {
|
||||
"version": 1,
|
||||
"backend": "openai",
|
||||
"openai": {
|
||||
"model": "gpt-live-transcribe",
|
||||
"apiKeyFile": "~/.config/pi-dictate/credentials/openai-api-key",
|
||||
"termsPath": "/tmp/terms",
|
||||
},
|
||||
}
|
||||
with patch.dict(os.environ, {"HOME": "/tmp/pi-dictate-test"}):
|
||||
invalid = {**base, "openai": {**base["openai"], "endpoint": "https://bad"}}
|
||||
with self.assertRaises(ConfigError):
|
||||
parse_config(invalid)
|
||||
with self.assertRaises(ConfigError):
|
||||
parse_config({**base, "version": 2})
|
||||
configured = parse_config(
|
||||
{**base, "openai": {**base["openai"], "delay": "low"}}
|
||||
)
|
||||
self.assertEqual(configured.openai.delay, "low")
|
||||
with self.assertRaises(ConfigError):
|
||||
parse_config({**base, "openai": {**base["openai"], "delay": "fast"}})
|
||||
with self.assertRaises(ConfigError):
|
||||
parse_config({**base, "openai": {**base["openai"], "termsPath": "relative/terms"}})
|
||||
|
||||
def test_reads_only_one_trailing_newline_from_a_private_credential(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "key"
|
||||
path.write_text("secret\n", encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
self.assertEqual(read_credential(path), "secret")
|
||||
|
||||
path.chmod(0o640)
|
||||
with self.assertRaises(CredentialError):
|
||||
read_credential(path)
|
||||
|
||||
path.chmod(0o600)
|
||||
path.write_text("secret\nextra", encoding="utf-8")
|
||||
with self.assertRaises(CredentialError):
|
||||
read_credential(path)
|
||||
|
||||
def test_rejects_a_credential_reached_through_a_symlinked_directory(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
actual = root / "actual"
|
||||
actual.mkdir()
|
||||
key = actual / "key"
|
||||
key.write_text("secret", encoding="utf-8")
|
||||
key.chmod(0o600)
|
||||
linked = root / "credentials"
|
||||
linked.symlink_to(actual, target_is_directory=True)
|
||||
|
||||
with self.assertRaisesRegex(CredentialError, "ancestry"):
|
||||
read_credential(linked / "key")
|
||||
|
||||
def test_config_file_is_regular_private_and_provider_terms_reject_cr(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"backend": "local",
|
||||
"local": {
|
||||
"adapter": "nemo-parakeet",
|
||||
"modelPath": "/tmp/model.nemo",
|
||||
"modelSha256": "0" * 64,
|
||||
"termsPath": "/tmp/terms",
|
||||
"boostAlpha": 1.0,
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path.chmod(0o600)
|
||||
self.assertEqual(load_config(config_path).backend, "local")
|
||||
|
||||
terms = root / "terms"
|
||||
terms.write_bytes(b"bad\rterm")
|
||||
with self.assertRaises(ConfigError):
|
||||
provider_keywords(terms)
|
||||
terms.write_bytes(b"safe\nbad\x00term\n")
|
||||
with self.assertRaises(ConfigError):
|
||||
provider_keywords(terms)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "install.sh"
|
||||
SYSTEMD_ROOT = SCRIPT.parents[2] / "systemd/user"
|
||||
LEGACY_SERVICE = """# Managed by pi-dictate install.sh
|
||||
[Unit]
|
||||
Description=Pi local Parakeet dictation service
|
||||
Requires=pi-dictate.socket
|
||||
After=pipewire.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/share/pi-dictate/venv/bin/pi-dictate-service
|
||||
Environment=PI_DICTATE_MODEL_PATH=%h/.local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3.nemo
|
||||
Environment=PI_DICTATE_TERMS_PATH=%h/.local/share/pi-dictate/config/coding-terms.txt
|
||||
Environment=PI_DICTATE_BOOST_ALPHA=1.0
|
||||
Environment=PI_DICTATE_IDLE_SECONDS=1800
|
||||
Environment=HF_HUB_OFFLINE=1
|
||||
Environment=TRANSFORMERS_OFFLINE=1
|
||||
Environment=NEMO_HOME=/tmp/pi-dictate-nemo
|
||||
Environment=HF_HOME=/tmp/pi-dictate-huggingface
|
||||
Environment=TRITON_CACHE_DIR=/tmp/pi-dictate-triton
|
||||
Environment=NUMBA_CACHE_DIR=/tmp/pi-dictate-numba
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
UMask=0077
|
||||
LimitCORE=0
|
||||
MemorySwapMax=0
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectClock=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
IPAddressDeny=any
|
||||
LockPersonality=yes
|
||||
|
||||
# The socket unit owns %t/pi-dictate.sock. The service must not unlink it.
|
||||
"""
|
||||
|
||||
|
||||
class InstallFlowTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.directory.name)
|
||||
self.home = self.root / "home"
|
||||
self.home.mkdir()
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
uv = self.bin / "uv"
|
||||
# A real uv sync creates the requested environment. Keep the fake
|
||||
# faithful to that contract so transactional environment swapping is
|
||||
# exercised without installing dependencies.
|
||||
uv.write_text("#!/bin/sh\nmkdir -p \"$UV_PROJECT_ENVIRONMENT\"\nexit 0\n", encoding="utf-8")
|
||||
uv.chmod(0o755)
|
||||
systemctl = self.bin / "systemctl"
|
||||
systemctl.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
systemctl.chmod(0o755)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.directory.cleanup()
|
||||
|
||||
def run_installer(self, *arguments: str) -> subprocess.CompletedProcess[str]:
|
||||
environment = {
|
||||
**os.environ,
|
||||
"HOME": str(self.home),
|
||||
"XDG_CONFIG_HOME": str(self.home / ".config"),
|
||||
"PATH": f"{self.bin}:{os.environ['PATH']}",
|
||||
}
|
||||
return subprocess.run(
|
||||
[str(SCRIPT), *arguments],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_real_staged_cloud_venv_python_runs_after_transactional_move(self) -> None:
|
||||
real_uv = shutil.which("uv")
|
||||
self.assertIsNotNone(real_uv)
|
||||
(self.bin / "uv").unlink()
|
||||
source = self.root / "openai-key"
|
||||
source.write_text("test-key", encoding="utf-8")
|
||||
|
||||
result = self.run_installer(
|
||||
"install", "--backend", "openai", "--api-key-file", str(source)
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
python = self.home / ".local/share/pi-dictate/venv/bin/python"
|
||||
executed = subprocess.run(
|
||||
[str(python), "-c", "import pi_dictate_service"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(executed.returncode, 0, executed.stderr)
|
||||
|
||||
def test_upgrades_the_previous_service_unit_and_preserves_its_idle_setting(self) -> None:
|
||||
unit_dir = self.home / ".config/systemd/user"
|
||||
unit_dir.mkdir(parents=True)
|
||||
(unit_dir / "pi-dictate.service").write_text(LEGACY_SERVICE, encoding="utf-8")
|
||||
(unit_dir / "pi-dictate.socket").write_bytes(
|
||||
(SYSTEMD_ROOT / "pi-dictate.socket").read_bytes()
|
||||
)
|
||||
source = self.root / "custom.nemo"
|
||||
source.write_bytes(b"custom archive")
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
|
||||
result = self.run_installer(
|
||||
"install",
|
||||
"--backend",
|
||||
"local",
|
||||
"--model-source",
|
||||
str(source),
|
||||
"--model-sha256",
|
||||
digest,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
(unit_dir / "pi-dictate.service").read_bytes(),
|
||||
(SYSTEMD_ROOT / "pi-dictate.service").read_bytes(),
|
||||
)
|
||||
config = json.loads(
|
||||
(self.home / ".config/pi-dictate/config.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(config["idleSeconds"], 1800)
|
||||
|
||||
def test_rejects_symlinked_install_ancestry(self) -> None:
|
||||
outside = self.root / "outside"
|
||||
outside.mkdir()
|
||||
(self.home / ".local").symlink_to(outside, target_is_directory=True)
|
||||
source = self.root / "custom.nemo"
|
||||
source.write_bytes(b"custom archive")
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
|
||||
result = self.run_installer(
|
||||
"install",
|
||||
"--backend",
|
||||
"local",
|
||||
"--model-source",
|
||||
str(source),
|
||||
"--model-sha256",
|
||||
digest,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("symlinked path ancestry", result.stderr)
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
|
||||
def test_cloud_install_uses_only_cloud_profile_and_managed_key(self) -> None:
|
||||
source = self.root / "meta-key"
|
||||
source.write_text("test-key\n", encoding="utf-8")
|
||||
|
||||
result = self.run_installer(
|
||||
"install", "--backend", "meta", "--api-key-file", str(source)
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn("test-key", result.stdout + result.stderr)
|
||||
self.assertNotIn("nvidia", result.stdout)
|
||||
self.assertFalse(
|
||||
(self.home / ".local/share/pi-dictate/models").exists()
|
||||
)
|
||||
config_path = self.home / ".config/pi-dictate/config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(config["backend"], "meta")
|
||||
self.assertEqual(config["meta"]["model"], "muse-voice-transcribe-1.0")
|
||||
self.assertEqual(config["meta"]["languageBias"], [])
|
||||
self.assertEqual(
|
||||
(self.home / ".config/pi-dictate/credentials/meta-api-key").read_text(
|
||||
encoding="utf-8"
|
||||
),
|
||||
"test-key",
|
||||
)
|
||||
self.assertEqual(stat.S_IMODE(config_path.stat().st_mode), 0o600)
|
||||
self.assertEqual(
|
||||
stat.S_IMODE(
|
||||
(self.home / ".config/pi-dictate/credentials/meta-api-key").stat().st_mode
|
||||
),
|
||||
0o600,
|
||||
)
|
||||
self.assertEqual(
|
||||
(self.home / ".local/share/pi-dictate/.dependency-profile").read_text(
|
||||
encoding="utf-8"
|
||||
),
|
||||
"cloud\n",
|
||||
)
|
||||
|
||||
def test_check_rejects_values_that_runtime_configuration_rejects(self) -> None:
|
||||
source = self.root / "openai-key"
|
||||
source.write_text("test-key", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
self.run_installer(
|
||||
"install", "--backend", "openai", "--api-key-file", str(source)
|
||||
).returncode,
|
||||
0,
|
||||
)
|
||||
config_path = self.home / ".config/pi-dictate/config.json"
|
||||
valid = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
invalid_configs = []
|
||||
boolean_version = {**valid, "version": True}
|
||||
invalid_configs.append(boolean_version)
|
||||
unhashable_backend = {**valid, "backend": []}
|
||||
invalid_configs.append(unhashable_backend)
|
||||
long_model = json.loads(json.dumps(valid))
|
||||
long_model["openai"]["model"] = "m" * 129
|
||||
invalid_configs.append(long_model)
|
||||
unhashable_delay = json.loads(json.dumps(valid))
|
||||
unhashable_delay["openai"]["delay"] = []
|
||||
invalid_configs.append(unhashable_delay)
|
||||
relative_terms = json.loads(json.dumps(valid))
|
||||
relative_terms["openai"]["termsPath"] = "relative/terms.txt"
|
||||
invalid_configs.append(relative_terms)
|
||||
|
||||
for invalid in invalid_configs:
|
||||
with self.subTest(invalid=invalid):
|
||||
config_path.write_text(json.dumps(invalid), encoding="utf-8")
|
||||
config_path.chmod(0o600)
|
||||
result = self.run_installer("check")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
|
||||
def test_check_rejects_extra_dependency_marker_newlines(self) -> None:
|
||||
source = self.root / "openai-key"
|
||||
source.write_text("test-key", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
self.run_installer(
|
||||
"install", "--backend", "openai", "--api-key-file", str(source)
|
||||
).returncode,
|
||||
0,
|
||||
)
|
||||
marker = self.home / ".local/share/pi-dictate/.dependency-profile"
|
||||
marker.write_bytes(b"cloud\n\n")
|
||||
marker.chmod(0o600)
|
||||
|
||||
result = self.run_installer("check")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("dependency profile", result.stderr)
|
||||
|
||||
def test_uninstall_removes_an_exact_openai_dropin(self) -> None:
|
||||
source = self.root / "openai-key"
|
||||
source.write_text("test-key", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
self.run_installer(
|
||||
"install", "--backend", "openai", "--api-key-file", str(source)
|
||||
).returncode,
|
||||
0,
|
||||
)
|
||||
|
||||
result = self.run_installer("uninstall")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(
|
||||
(self.home / ".config/systemd/user/pi-dictate.service.d/10-pi-dictate-network.conf").exists()
|
||||
)
|
||||
|
||||
def test_openai_configure_preserves_live_verified_delay(self) -> None:
|
||||
source = self.root / "openai-key"
|
||||
source.write_text("test-key", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
self.run_installer(
|
||||
"install", "--backend", "openai", "--api-key-file", str(source)
|
||||
).returncode,
|
||||
0,
|
||||
)
|
||||
config_path = self.home / ".config/pi-dictate/config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config["openai"]["delay"] = "low"
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
config_path.chmod(0o600)
|
||||
|
||||
result = self.run_installer("configure", "--backend", "openai")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
configured = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(configured["openai"]["delay"], "low")
|
||||
|
||||
def test_meta_configure_preserves_valid_language_bias(self) -> None:
|
||||
source = self.root / "meta-key"
|
||||
source.write_text("test-key", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
self.run_installer(
|
||||
"install", "--backend", "meta", "--api-key-file", str(source)
|
||||
).returncode,
|
||||
0,
|
||||
)
|
||||
config_path = self.home / ".config/pi-dictate/config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config["meta"]["languageBias"] = ["en", "fr"]
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
config_path.chmod(0o600)
|
||||
|
||||
result = self.run_installer("configure", "--backend", "meta")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
configured = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(configured["meta"]["languageBias"], ["en", "fr"])
|
||||
|
||||
def test_local_custom_archive_requires_and_records_explicit_digest(self) -> None:
|
||||
source = self.root / "custom.nemo"
|
||||
source.write_bytes(b"custom archive")
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
|
||||
result = self.run_installer(
|
||||
"install",
|
||||
"--backend",
|
||||
"local",
|
||||
"--model-source",
|
||||
str(source),
|
||||
"--model-sha256",
|
||||
digest,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
config = json.loads(
|
||||
(self.home / ".config/pi-dictate/config.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(config["local"]["modelSha256"], digest)
|
||||
installed = self.home / (
|
||||
".local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/"
|
||||
"parakeet-tdt-0.6b-v3.nemo"
|
||||
)
|
||||
self.assertEqual(installed.read_bytes(), source.read_bytes())
|
||||
|
||||
def test_replacing_a_custom_local_model_preserves_its_configured_path(self) -> None:
|
||||
first = self.root / "first.nemo"
|
||||
first.write_bytes(b"first archive")
|
||||
first_digest = hashlib.sha256(first.read_bytes()).hexdigest()
|
||||
self.assertEqual(
|
||||
self.run_installer(
|
||||
"install", "--backend", "local", "--model-source", str(first),
|
||||
"--model-sha256", first_digest,
|
||||
).returncode,
|
||||
0,
|
||||
)
|
||||
config_path = self.home / ".config/pi-dictate/config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
custom_model = self.root / "custom-models/model.nemo"
|
||||
custom_model.parent.mkdir()
|
||||
custom_model.write_bytes(first.read_bytes())
|
||||
config["local"]["modelPath"] = str(custom_model)
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
config_path.chmod(0o600)
|
||||
second = self.root / "second.nemo"
|
||||
second.write_bytes(b"second archive")
|
||||
second_digest = hashlib.sha256(second.read_bytes()).hexdigest()
|
||||
|
||||
result = self.run_installer(
|
||||
"configure", "--backend", "local", "--model-source", str(second),
|
||||
"--model-sha256", second_digest,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
configured = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(configured["local"]["modelPath"], str(custom_model))
|
||||
self.assertEqual(custom_model.read_bytes(), second.read_bytes())
|
||||
self.assertFalse(list(custom_model.parent.glob("*.pi-dictate-rollback.*")))
|
||||
|
||||
def test_switching_from_cloud_to_local_accepts_a_local_archive(self) -> None:
|
||||
key = self.root / "meta-key"
|
||||
key.write_text("test-key", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
self.run_installer(
|
||||
"install", "--backend", "meta", "--api-key-file", str(key)
|
||||
).returncode,
|
||||
0,
|
||||
)
|
||||
source = self.root / "custom.nemo"
|
||||
source.write_bytes(b"custom archive")
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
|
||||
result = self.run_installer(
|
||||
"configure",
|
||||
"--backend",
|
||||
"local",
|
||||
"--model-source",
|
||||
str(source),
|
||||
"--model-sha256",
|
||||
digest,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
config = json.loads(
|
||||
(self.home / ".config/pi-dictate/config.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(config["backend"], "local")
|
||||
self.assertEqual(config["local"]["modelSha256"], digest)
|
||||
self.assertIn("meta", config)
|
||||
|
||||
def test_systemd_failure_rolls_back_staged_install(self) -> None:
|
||||
old_venv = self.home / ".local/share/pi-dictate/venv"
|
||||
old_venv.mkdir(parents=True)
|
||||
(old_venv / "previous-runtime").write_text("old", encoding="utf-8")
|
||||
source = self.root / "meta-key"
|
||||
source.write_text("test-key", encoding="utf-8")
|
||||
(self.bin / "systemctl").write_text(
|
||||
"#!/bin/sh\necho \"$*\" >> \"$HOME/systemctl.log\"\n"
|
||||
"case \"$*\" in *'is-enabled'*) exit 1;; "
|
||||
"*'stop pi-dictate.service'*) exit 9;; esac\nexit 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(self.bin / "systemctl").chmod(0o755)
|
||||
|
||||
result = self.run_installer(
|
||||
"install", "--backend", "meta", "--api-key-file", str(source)
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.home / ".config/pi-dictate/config.json").exists())
|
||||
self.assertFalse((self.home / ".local/share/pi-dictate/.dependency-profile").exists())
|
||||
self.assertFalse((self.home / ".local/share/pi-dictate/config/coding-terms.txt").exists())
|
||||
self.assertEqual((old_venv / "previous-runtime").read_text(encoding="utf-8"), "old")
|
||||
systemctl_calls = (self.home / "systemctl.log").read_text(encoding="utf-8")
|
||||
self.assertGreaterEqual(systemctl_calls.count("daemon-reload"), 1)
|
||||
self.assertIn("disable pi-dictate.socket", systemctl_calls)
|
||||
|
||||
def test_invalid_cloud_key_does_not_commit_a_dependency_marker(self) -> None:
|
||||
source = self.root / "invalid-key"
|
||||
source.write_text("two\nlines\n", encoding="utf-8")
|
||||
|
||||
result = self.run_installer(
|
||||
"install", "--backend", "meta", "--api-key-file", str(source)
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(
|
||||
(self.home / ".local/share/pi-dictate/.dependency-profile").exists()
|
||||
)
|
||||
|
||||
def test_dictionary_symlink_is_rejected(self) -> None:
|
||||
dictionary = self.home / ".local/share/pi-dictate/config/coding-terms.txt"
|
||||
dictionary.parent.mkdir(parents=True)
|
||||
target = self.root / "unexpected-terms.txt"
|
||||
target.write_text("unexpected", encoding="utf-8")
|
||||
dictionary.symlink_to(target)
|
||||
source = self.root / "custom.nemo"
|
||||
source.write_bytes(b"custom archive")
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
|
||||
result = self.run_installer(
|
||||
"install",
|
||||
"--backend",
|
||||
"local",
|
||||
"--model-source",
|
||||
str(source),
|
||||
"--model-sha256",
|
||||
digest,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("non-regular coding dictionary", result.stderr)
|
||||
|
||||
def test_uninstall_preserves_config_unless_purge_is_requested(self) -> None:
|
||||
source = self.root / "meta-key"
|
||||
source.write_text("test-key", encoding="utf-8")
|
||||
self.assertEqual(
|
||||
self.run_installer(
|
||||
"install", "--backend", "meta", "--api-key-file", str(source)
|
||||
).returncode,
|
||||
0,
|
||||
)
|
||||
|
||||
result = self.run_installer("uninstall")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue((self.home / ".config/pi-dictate/config.json").exists())
|
||||
self.assertFalse((self.home / ".local/share/pi-dictate").exists())
|
||||
|
||||
# Reinstalling from the preserved key is possible without passing a
|
||||
# literal secret or a second key source.
|
||||
result = self.run_installer("install", "--backend", "meta")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(
|
||||
self.run_installer("uninstall", "--purge-config").returncode, 0
|
||||
)
|
||||
self.assertFalse((self.home / ".config/pi-dictate").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
@@ -24,8 +25,11 @@ class InstallerTests(unittest.TestCase):
|
||||
home / ".local/share/pi-dictate"
|
||||
),
|
||||
}
|
||||
command = [str(SCRIPT), *arguments, "--dry-run"]
|
||||
if not any(action in arguments for action in ("uninstall", "check")):
|
||||
command.extend(("--model-source", str(model), "--model-sha256", hashlib.sha256(model.read_bytes()).hexdigest()))
|
||||
return subprocess.run(
|
||||
[str(SCRIPT), *arguments, "--dry-run", "--model-source", str(model)],
|
||||
command,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -47,7 +51,9 @@ class InstallerTests(unittest.TestCase):
|
||||
self.assertNotIn("ln -s", result.stdout)
|
||||
self.assertIn(".nemo.install.", result.stdout)
|
||||
self.assertIn("mv -f", result.stdout)
|
||||
self.assertIn("uv venv --relocatable", result.stdout)
|
||||
self.assertIn("uv sync --frozen --no-dev --no-editable", result.stdout)
|
||||
self.assertIn("--reinstall-package pi-dictate-service", result.stdout)
|
||||
self.assertIn("Would install pi-dictate", result.stdout)
|
||||
self.assertFalse((home / ".local/share/pi-dictate").exists())
|
||||
|
||||
@@ -87,6 +93,25 @@ class InstallerTests(unittest.TestCase):
|
||||
"[Socket]\nListenStream=/tmp/unrelated.sock\n",
|
||||
)
|
||||
|
||||
def test_uninstall_refuses_a_modified_managed_unit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
home = Path(directory)
|
||||
model = home / "model.nemo"
|
||||
model.write_bytes(b"model")
|
||||
unit = home / ".config/systemd/user/pi-dictate.socket"
|
||||
unit.parent.mkdir(parents=True)
|
||||
unit.write_text(
|
||||
"# Managed by pi-dictate install.sh\n[Socket]\nListenStream=/tmp/tampered.sock\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_installer(home, model, "uninstall")
|
||||
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("unmanaged systemd unit", result.stderr)
|
||||
self.assertNotIn("systemctl --user disable", result.stdout)
|
||||
self.assertTrue(unit.exists())
|
||||
|
||||
def test_reinstall_preserves_an_existing_host_dictionary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
home = Path(directory)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from pi_dictate_service.live_smoke import MAX_SAMPLE_BYTES, _read_sample
|
||||
|
||||
|
||||
class LiveSmokeTests(unittest.TestCase):
|
||||
def test_reads_one_bounded_even_pcm_fixture(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
sample = Path(directory) / "sample.pcm"
|
||||
sample.write_bytes(b"\x00\x01" * 100)
|
||||
self.assertEqual(_read_sample(sample), sample.read_bytes())
|
||||
|
||||
def test_rejects_symlink_odd_and_oversized_fixtures(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
valid = root / "valid.pcm"
|
||||
valid.write_bytes(b"\x00\x00")
|
||||
link = root / "link.pcm"
|
||||
link.symlink_to(valid)
|
||||
odd = root / "odd.pcm"
|
||||
odd.write_bytes(b"x")
|
||||
oversized = root / "oversized.pcm"
|
||||
oversized.write_bytes(b"x" * (MAX_SAMPLE_BYTES + 2))
|
||||
for path in (link, odd, oversized):
|
||||
with self.subTest(path=path), self.assertRaises((OSError, ValueError)):
|
||||
_read_sample(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -38,12 +38,20 @@ class FakeNemoModel:
|
||||
class FakeConcreteParakeetModel:
|
||||
restored_path: str | None = None
|
||||
restored_location: object | None = None
|
||||
restored_bytes: bytes | None = None
|
||||
replace_before_restore: tuple[Path, bytes] | None = None
|
||||
restored: FakeNemoModel | None = None
|
||||
|
||||
@classmethod
|
||||
def restore_from(cls, restore_path: str, map_location: object) -> FakeNemoModel:
|
||||
cls.restored_path = restore_path
|
||||
cls.restored_location = map_location
|
||||
if cls.replace_before_restore is not None:
|
||||
path, replacement = cls.replace_before_restore
|
||||
replacement_path = path.with_suffix(".replacement")
|
||||
replacement_path.write_bytes(replacement)
|
||||
replacement_path.replace(path)
|
||||
cls.restored_bytes = Path(restore_path).read_bytes()
|
||||
self = FakeNemoModel()
|
||||
cls.restored = self
|
||||
return self
|
||||
@@ -57,6 +65,13 @@ class FakeNumpy(ModuleType):
|
||||
|
||||
|
||||
class NemoParakeetModelTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
FakeConcreteParakeetModel.restored_path = None
|
||||
FakeConcreteParakeetModel.restored_location = None
|
||||
FakeConcreteParakeetModel.restored_bytes = None
|
||||
FakeConcreteParakeetModel.replace_before_restore = None
|
||||
FakeConcreteParakeetModel.restored = None
|
||||
|
||||
def test_load_is_lazy_offline_applies_coding_terms_and_transcribes_in_memory_audio(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
model_path = Path(directory) / "parakeet.nemo"
|
||||
@@ -78,7 +93,8 @@ class NemoParakeetModelTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(os.environ["HF_HUB_OFFLINE"], "1")
|
||||
self.assertEqual(os.environ["TRANSFORMERS_OFFLINE"], "1")
|
||||
self.assertEqual(FakeConcreteParakeetModel.restored_path, str(model_path))
|
||||
self.assertRegex(FakeConcreteParakeetModel.restored_path or "", r"^/proc/self/fd/\d+$")
|
||||
self.assertEqual(FakeConcreteParakeetModel.restored_bytes, b"model")
|
||||
self.assertEqual(FakeConcreteParakeetModel.restored_location, "device:cuda")
|
||||
self.assertTrue(FakeConcreteParakeetModel.restored.eval_called)
|
||||
self.assertEqual(FakeConcreteParakeetModel.restored.to_device, "cuda")
|
||||
@@ -107,6 +123,22 @@ class NemoParakeetModelTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "128 UTF-8 bytes"):
|
||||
load_coding_terms(terms_path)
|
||||
|
||||
def test_load_uses_the_verified_inode_if_the_configured_path_is_replaced(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
model_path = Path(directory) / "parakeet.nemo"
|
||||
model_path.write_bytes(b"verified model")
|
||||
FakeConcreteParakeetModel.replace_before_restore = (model_path, b"replacement")
|
||||
with patch.dict(sys.modules, self.fake_modules(), clear=False):
|
||||
adapter = NemoParakeetModel(
|
||||
model_path,
|
||||
expected_sha256=hashlib.sha256(b"verified model").hexdigest(),
|
||||
)
|
||||
|
||||
adapter.load()
|
||||
|
||||
self.assertEqual(model_path.read_bytes(), b"replacement")
|
||||
self.assertEqual(FakeConcreteParakeetModel.restored_bytes, b"verified model")
|
||||
|
||||
def test_refuses_a_model_that_no_longer_matches_the_pinned_hash(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
model_path = Path(directory) / "parakeet.nemo"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import unittest
|
||||
|
||||
from pi_dictate_service.protocol import MAX_FRAME_BYTES, FrameDecoder
|
||||
|
||||
|
||||
class FrameDecoderRegressionTests(unittest.TestCase):
|
||||
def test_accepts_pipelined_bounded_frames_larger_than_aggregate_limit(self) -> None:
|
||||
decoder = FrameDecoder()
|
||||
frame = b'{"version":2,"type":"ping","requestId":"request"}\n'
|
||||
payload = frame * ((MAX_FRAME_BYTES // len(frame)) + 1)
|
||||
|
||||
self.assertGreater(len(payload), MAX_FRAME_BYTES + 1)
|
||||
decoded = decoder.feed(payload)
|
||||
|
||||
self.assertEqual(len(decoded), payload.count(b"\n"))
|
||||
self.assertEqual(decoded[0]["requestId"], "request")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -36,6 +36,18 @@ class TrackingStream(io.BytesIO):
|
||||
return value
|
||||
|
||||
|
||||
class BlockingTrackingStream(TrackingStream):
|
||||
def __init__(self, value: bytes, events: list[str], name: str) -> None:
|
||||
super().__init__(value, events, name)
|
||||
self.allow_eof = threading.Event()
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
value = super().read(size)
|
||||
if not value:
|
||||
self.allow_eof.wait(1)
|
||||
return value
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -45,6 +57,7 @@ class FakeProcess:
|
||||
returncode: int | None = None,
|
||||
timeout: bool = False,
|
||||
terminate_returncode: int = -15,
|
||||
kill_stalls: bool = False,
|
||||
) -> None:
|
||||
self.pid = 1234
|
||||
self.stdout = stdout
|
||||
@@ -52,6 +65,7 @@ class FakeProcess:
|
||||
self.returncode = returncode
|
||||
self._timeout = timeout
|
||||
self._terminate_returncode = terminate_returncode
|
||||
self._kill_stalls = kill_stalls
|
||||
self.events = stdout._events
|
||||
|
||||
def poll(self) -> int | None:
|
||||
@@ -64,8 +78,9 @@ class FakeProcess:
|
||||
|
||||
def kill(self) -> None:
|
||||
self.events.append("kill")
|
||||
self.returncode = -9
|
||||
self._timeout = False
|
||||
if not self._kill_stalls:
|
||||
self.returncode = -9
|
||||
self._timeout = False
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
self.events.append("wait")
|
||||
@@ -86,6 +101,22 @@ class ProcessFactory:
|
||||
return self.process
|
||||
|
||||
|
||||
class StreamingSink:
|
||||
def __init__(self) -> None:
|
||||
self.chunks: list[bytes] = []
|
||||
self.finalized = False
|
||||
self.cancelled = False
|
||||
|
||||
def enqueue_pcm(self, chunk: bytes) -> None:
|
||||
self.chunks.append(chunk)
|
||||
|
||||
def close_input(self) -> None:
|
||||
self.finalized = True
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
@@ -95,6 +126,16 @@ class Clock:
|
||||
|
||||
|
||||
class PwRecorderTests(unittest.TestCase):
|
||||
def test_spawn_failure_is_already_cleanup_complete(self) -> None:
|
||||
def fail_spawn(*_args: object, **_kwargs: object) -> FakeProcess:
|
||||
raise OSError("spawn failed")
|
||||
|
||||
recorder = PwRecorder(process_factory=fail_spawn)
|
||||
|
||||
with self.assertRaisesRegex(RecorderError, "could not start"):
|
||||
recorder.start()
|
||||
self.assertTrue(recorder.cleanup_complete)
|
||||
|
||||
def make_recorder(
|
||||
self,
|
||||
pcm: bytes = b"\x00\x00\x01\x00",
|
||||
@@ -107,14 +148,23 @@ class PwRecorderTests(unittest.TestCase):
|
||||
clock: Clock | None = None,
|
||||
terminate_returncode: int = -15,
|
||||
stdout_read_size: int | None = None,
|
||||
kill_stalls: bool = False,
|
||||
block_stdout_at_eof: bool = False,
|
||||
pcm_sink: StreamingSink | None = None,
|
||||
) -> tuple[PwRecorder, FakeProcess, ProcessFactory, list[str]]:
|
||||
events: list[str] = []
|
||||
stdout = (
|
||||
BlockingTrackingStream(pcm, events, "stdout")
|
||||
if block_stdout_at_eof
|
||||
else TrackingStream(pcm, events, "stdout", stdout_read_size)
|
||||
)
|
||||
process = FakeProcess(
|
||||
TrackingStream(pcm, events, "stdout", stdout_read_size),
|
||||
stdout,
|
||||
TrackingStream(stderr, events, "stderr"),
|
||||
returncode=returncode,
|
||||
timeout=timeout,
|
||||
terminate_returncode=terminate_returncode,
|
||||
kill_stalls=kill_stalls,
|
||||
)
|
||||
factory = ProcessFactory(process)
|
||||
def signal_group(pid: int, signal_number: int) -> None:
|
||||
@@ -133,9 +183,44 @@ class PwRecorderTests(unittest.TestCase):
|
||||
stop_timeout=0.01,
|
||||
clock=clock or Clock(),
|
||||
process_group_signal=signal_group,
|
||||
pcm_sink=pcm_sink,
|
||||
)
|
||||
return recorder, process, factory, events
|
||||
|
||||
def test_successful_stop_finalizes_streaming_sink(self) -> None:
|
||||
sink = StreamingSink()
|
||||
recorder, process, _factory, _events = self.make_recorder(pcm_sink=sink)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
recorder.stop()
|
||||
|
||||
self.assertTrue(sink.finalized)
|
||||
self.assertFalse(sink.cancelled)
|
||||
|
||||
def test_cancel_aborts_streaming_sink_without_finalizing(self) -> None:
|
||||
sink = StreamingSink()
|
||||
recorder, process, _factory, _events = self.make_recorder(pcm_sink=sink)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
recorder.cancel()
|
||||
|
||||
self.assertTrue(sink.cancelled)
|
||||
self.assertFalse(sink.finalized)
|
||||
|
||||
def test_unexpected_exit_aborts_streaming_sink_without_finalizing(self) -> None:
|
||||
sink = StreamingSink()
|
||||
recorder, process, _factory, _events = self.make_recorder(returncode=3, pcm_sink=sink)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
error = recorder.poll_unexpected()
|
||||
|
||||
self.assertIsNotNone(error)
|
||||
self.assertTrue(sink.cancelled)
|
||||
self.assertFalse(sink.finalized)
|
||||
|
||||
def test_spawns_exact_pw_record_command_and_drains_bounded_streams(self) -> None:
|
||||
recorder, process, factory, events = self.make_recorder(
|
||||
pcm=b"\x00\x00\x01\x00", stderr=b"0123456789", stderr_limit=8
|
||||
@@ -230,6 +315,39 @@ class PwRecorderTests(unittest.TestCase):
|
||||
with self.assertRaises(RecorderError):
|
||||
noisy.stop()
|
||||
|
||||
def test_shutdown_reports_a_bounded_error_when_sigkill_does_not_reap(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
timeout=True,
|
||||
kill_stalls=True,
|
||||
)
|
||||
recorder.start()
|
||||
|
||||
with self.assertRaisesRegex(RecorderError, "after SIGKILL") as error:
|
||||
recorder.stop()
|
||||
|
||||
self.assertEqual(error.exception.code, "recorder_timeout")
|
||||
self.assertIn("kill", process.events)
|
||||
|
||||
def test_shutdown_retries_process_and_drainer_cleanup_after_double_timeout(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
timeout=True,
|
||||
kill_stalls=True,
|
||||
block_stdout_at_eof=True,
|
||||
)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
with self.assertRaises(RecorderError):
|
||||
recorder.stop()
|
||||
|
||||
self.assertFalse(recorder.cleanup_complete)
|
||||
process._kill_stalls = False
|
||||
process.stdout.allow_eof.set()
|
||||
|
||||
recorder.cancel()
|
||||
|
||||
self.assertTrue(recorder.cleanup_complete)
|
||||
|
||||
def test_shutdown_timeout_escalates_to_kill_and_fails(self) -> None:
|
||||
recorder, _process, _factory, events = self.make_recorder(timeout=True)
|
||||
recorder.start()
|
||||
@@ -240,6 +358,22 @@ class PwRecorderTests(unittest.TestCase):
|
||||
self.assertEqual(failed.exception.code, "recorder_timeout")
|
||||
self.assertLess(events.index("terminate"), events.index("kill"))
|
||||
|
||||
def test_stop_rejects_an_exit_observed_after_the_last_listening_poll(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder()
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
|
||||
# The process was alive during the listening poll, then exited cleanly
|
||||
# before stop could signal it. A partial buffer is not a valid capture.
|
||||
self.assertIsNone(recorder.poll_unexpected())
|
||||
process.returncode = 0
|
||||
|
||||
with self.assertRaises(RecorderError) as failed:
|
||||
recorder.stop()
|
||||
|
||||
self.assertEqual(failed.exception.code, "recorder_exit")
|
||||
self.assertEqual(recorder.buffered_bytes, 0)
|
||||
|
||||
def test_classifies_unexpected_exit_code_and_signal(self) -> None:
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
stderr=b"pipewire failed", returncode=7
|
||||
@@ -288,6 +422,25 @@ class PwRecorderTests(unittest.TestCase):
|
||||
self.assertEqual(failed.exception.code, "duration_limit")
|
||||
self.assertEqual(recorder.buffered_bytes, 0)
|
||||
|
||||
def test_duration_limit_reports_a_bounded_error_when_sigkill_does_not_reap(self) -> None:
|
||||
clock = Clock()
|
||||
recorder, process, _factory, _events = self.make_recorder(
|
||||
pcm=b"\x00\x00" * 3,
|
||||
max_bytes=4,
|
||||
timeout=True,
|
||||
kill_stalls=True,
|
||||
clock=clock,
|
||||
)
|
||||
recorder.start()
|
||||
self.assertTrue(process.stdout.eof.wait(1))
|
||||
clock.now = 1
|
||||
|
||||
failure = recorder.poll_unexpected()
|
||||
|
||||
self.assertIsNotNone(failure)
|
||||
self.assertEqual(failure.code, "recorder_timeout")
|
||||
self.assertIn("kill", process.events)
|
||||
|
||||
def test_duration_limit_kills_a_recorder_that_ignores_sigterm(self) -> None:
|
||||
clock = Clock()
|
||||
recorder, process, _factory, events = self.make_recorder(
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
from concurrent.futures import Future
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
import errno
|
||||
import selectors
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pi_dictate_service.protocol import MAX_TRANSCRIPT_BYTES, PROTOCOL_VERSION, parse_request
|
||||
from pi_dictate_service.protocol import (
|
||||
MAX_FRAME_BYTES,
|
||||
MAX_TRANSCRIPT_BYTES,
|
||||
PROTOCOL_VERSION,
|
||||
encode_frame,
|
||||
parse_request,
|
||||
)
|
||||
from pi_dictate_service.recorder import Capture, RecorderError
|
||||
from pi_dictate_service.server import DictateServiceCore, Outbound
|
||||
from pi_dictate_service.server import DictateServiceCore, Outbound, serve
|
||||
|
||||
|
||||
class Clock:
|
||||
@@ -93,6 +104,154 @@ class FakeRecorder:
|
||||
return failure
|
||||
|
||||
|
||||
class SpawnFailRecorder(FakeRecorder):
|
||||
cleanup_complete = True
|
||||
|
||||
def start(self) -> None:
|
||||
raise RecorderError("recorder_spawn", "could not start pw-record")
|
||||
|
||||
|
||||
class SpawnFailRecorderFactory:
|
||||
def __call__(self) -> SpawnFailRecorder:
|
||||
return SpawnFailRecorder()
|
||||
|
||||
|
||||
class DelayedCleanupRecorder(FakeRecorder):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.cleanup_complete = False
|
||||
self.cleanup_attempts = 0
|
||||
self.cleanup_started = False
|
||||
|
||||
def stop(self) -> Capture:
|
||||
self.cleanup_started = True
|
||||
raise RecorderError("recorder_timeout", "pw-record cleanup did not complete")
|
||||
|
||||
def poll_unexpected(self) -> RecorderError | None:
|
||||
if not self.cleanup_started:
|
||||
return None
|
||||
self.cleanup_attempts += 1
|
||||
if self.cleanup_attempts >= 3:
|
||||
self.cleanup_complete = True
|
||||
return None
|
||||
|
||||
|
||||
class DelayedCleanupRecorderFactory:
|
||||
def __init__(self) -> None:
|
||||
self.instance = DelayedCleanupRecorder()
|
||||
|
||||
def __call__(self) -> DelayedCleanupRecorder:
|
||||
return self.instance
|
||||
|
||||
|
||||
class DelayedCloseTurn:
|
||||
sample_rate_hz = 16_000
|
||||
retain_pcm = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.close_calls = 0
|
||||
|
||||
@property
|
||||
def cleanup_complete(self) -> bool:
|
||||
return self.close_calls >= 3
|
||||
|
||||
def enqueue_pcm(self, chunk: bytes) -> None:
|
||||
return None
|
||||
|
||||
def finish(self, capture: Capture | None = None) -> str:
|
||||
return "cloud result"
|
||||
|
||||
def poll_error(self) -> None:
|
||||
return None
|
||||
|
||||
def cancel(self) -> None:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
self.close_calls += 1
|
||||
|
||||
|
||||
class StreamingTurn:
|
||||
sample_rate_hz = 24_000
|
||||
retain_pcm = False
|
||||
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
self.closed = False
|
||||
|
||||
@property
|
||||
def cleanup_complete(self) -> bool:
|
||||
return self.closed
|
||||
|
||||
def enqueue_pcm(self, chunk: bytes) -> None:
|
||||
self.events.append(f"audio-{chunk.decode('ascii')}")
|
||||
|
||||
def finish(self, capture: Capture | None = None) -> str:
|
||||
self.events.append("finish")
|
||||
self.assert_capture = capture
|
||||
return "cloud result"
|
||||
|
||||
def poll_error(self) -> None:
|
||||
return None
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.events.append("cancel")
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class StreamingRecorder(FakeRecorder):
|
||||
def __init__(self, sink: StreamingTurn, events: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.sink = sink
|
||||
self.events = events
|
||||
self.capture = Capture(b"", -15, 15, b"")
|
||||
|
||||
def start(self) -> None:
|
||||
super().start()
|
||||
self.sink.enqueue_pcm(b"a")
|
||||
self.sink.enqueue_pcm(b"b")
|
||||
|
||||
def stop(self) -> Capture:
|
||||
self.events.append("recorder-stop")
|
||||
return super().stop()
|
||||
|
||||
|
||||
class StreamingRecorderFactory:
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
self.sample_rate_hz: int | None = None
|
||||
self.instance: StreamingRecorder | None = None
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
sample_rate_hz: int,
|
||||
pcm_sink: StreamingTurn,
|
||||
retain_pcm: bool,
|
||||
) -> StreamingRecorder:
|
||||
self.sample_rate_hz = sample_rate_hz
|
||||
if retain_pcm:
|
||||
raise AssertionError("cloud capture must not retain PCM")
|
||||
self.instance = StreamingRecorder(pcm_sink, self.events)
|
||||
return self.instance
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
sample_rate_hz = 16_000
|
||||
descriptor = type("Descriptor", (), {"backend": "fake", "model": "fake-model"})()
|
||||
|
||||
def __init__(self, turn: DelayedCloseTurn | StreamingTurn) -> None:
|
||||
self.turn = turn
|
||||
|
||||
def start_turn(self) -> DelayedCloseTurn:
|
||||
return self.turn
|
||||
|
||||
def cancel_prepare(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class RecorderFactory:
|
||||
def __init__(self) -> None:
|
||||
self.instances: list[FakeRecorder] = []
|
||||
@@ -271,6 +430,121 @@ class StopAndInferenceTests(CoreTestCase):
|
||||
self.assertEqual(completed[0]["transcript"], "dictated text")
|
||||
self.assertEqual(completed[1]["state"], "idle")
|
||||
|
||||
def test_realtime_backend_streams_ordered_pcm_and_finalizes_after_recorder_stop(self) -> None:
|
||||
events: list[str] = []
|
||||
turn = StreamingTurn(events)
|
||||
recorders = StreamingRecorderFactory(events)
|
||||
core = DictateServiceCore(
|
||||
backend=FakeBackend(turn),
|
||||
recorder_factory=recorders,
|
||||
executor=self.executor,
|
||||
clock=self.clock,
|
||||
idle_seconds=900,
|
||||
)
|
||||
core.accepted("a")
|
||||
core.handle_request("a", parse_request({"version": 2, "type": "hello", "requestId": "hello"}))
|
||||
core.drain_events()
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2,
|
||||
"type": "start",
|
||||
"requestId": "start",
|
||||
"generationId": "g1",
|
||||
}))
|
||||
self.executor.run_next()
|
||||
core.poll()
|
||||
self.assertEqual(recorders.sample_rate_hz, 24_000)
|
||||
self.assertEqual(events, ["audio-a", "audio-b"])
|
||||
core.drain_events()
|
||||
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2,
|
||||
"type": "stop",
|
||||
"requestId": "stop",
|
||||
"generationId": "g1",
|
||||
}))
|
||||
self.executor.run_next()
|
||||
core.poll()
|
||||
self.executor.run_next()
|
||||
core.poll()
|
||||
|
||||
self.assertEqual(events, ["audio-a", "audio-b", "recorder-stop", "finish"])
|
||||
result = [event.frame for event in core.drain_events() if event.frame["type"] == "result"]
|
||||
self.assertEqual(result[0]["transcript"], "cloud result")
|
||||
self.assertIsNone(core.active_generation_id)
|
||||
|
||||
def test_cancel_after_prepare_completion_tracks_turn_cleanup_before_release(self) -> None:
|
||||
events: list[str] = []
|
||||
turn = StreamingTurn(events)
|
||||
core = DictateServiceCore(
|
||||
backend=FakeBackend(turn),
|
||||
recorder_factory=StreamingRecorderFactory(events),
|
||||
executor=self.executor,
|
||||
clock=self.clock,
|
||||
idle_seconds=900,
|
||||
)
|
||||
core.accepted("a")
|
||||
core.handle_request("a", parse_request({"version": 2, "type": "hello", "requestId": "hello"}))
|
||||
core.drain_events()
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2,
|
||||
"type": "start",
|
||||
"requestId": "start",
|
||||
"generationId": "g1",
|
||||
}))
|
||||
self.executor.run_next()
|
||||
# The future has completed, but the socket loop has not yet applied it.
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2,
|
||||
"type": "cancel",
|
||||
"requestId": "cancel",
|
||||
"generationId": "g1",
|
||||
}))
|
||||
|
||||
core.poll()
|
||||
|
||||
self.assertIn("cancel", events)
|
||||
self.assertTrue(turn.cleanup_complete)
|
||||
self.assertIsNone(core.active_generation_id)
|
||||
|
||||
def test_completed_turn_retries_bounded_cleanup_before_releasing_generation(self) -> None:
|
||||
turn = DelayedCloseTurn()
|
||||
core = DictateServiceCore(
|
||||
backend=FakeBackend(turn),
|
||||
recorder_factory=self.recorders,
|
||||
executor=self.executor,
|
||||
clock=self.clock,
|
||||
idle_seconds=900,
|
||||
)
|
||||
core.accepted("a")
|
||||
core.handle_request("a", parse_request({"version": 2, "type": "hello", "requestId": "hello"}))
|
||||
core.drain_events()
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2,
|
||||
"type": "start",
|
||||
"requestId": "start",
|
||||
"generationId": "g1",
|
||||
}))
|
||||
self.executor.run_next()
|
||||
core.poll()
|
||||
core.drain_events()
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2,
|
||||
"type": "stop",
|
||||
"requestId": "stop",
|
||||
"generationId": "g1",
|
||||
}))
|
||||
self.executor.run_next()
|
||||
core.poll()
|
||||
self.executor.run_next()
|
||||
|
||||
core.poll()
|
||||
self.assertEqual(core.active_generation_id, "g1")
|
||||
self.assertEqual(turn.close_calls, 2)
|
||||
|
||||
core.poll()
|
||||
self.assertIsNone(core.active_generation_id)
|
||||
self.assertEqual(turn.close_calls, 3)
|
||||
|
||||
def test_empty_transcript_is_a_result(self) -> None:
|
||||
self.model.transcript = ""
|
||||
self.load_to_listening()
|
||||
@@ -321,6 +595,8 @@ class StopAndInferenceTests(CoreTestCase):
|
||||
recorder = self.load_to_listening()
|
||||
recorder.unexpected = RecorderError("recorder_exit", "pw-record exited with code 3")
|
||||
|
||||
self.core.poll()
|
||||
self.executor.run_next()
|
||||
self.core.poll()
|
||||
failed = self.frames(self.core.drain_events(), "a")
|
||||
|
||||
@@ -332,12 +608,286 @@ class StopAndInferenceTests(CoreTestCase):
|
||||
recorder.unexpected = RecorderError("recorder_exit", "pw-record exited with code 0")
|
||||
|
||||
failed = self.request("a", "stop", "stop", "g1")
|
||||
self.executor.run_next()
|
||||
self.core.poll()
|
||||
failed.extend(self.frames(self.core.drain_events(), "a"))
|
||||
|
||||
self.assertEqual(failed[0]["code"], "recorder_exit")
|
||||
self.assertEqual(failed[-1]["state"], "idle")
|
||||
self.assertEqual(self.executor.pending, [])
|
||||
|
||||
|
||||
def test_recorder_spawn_failure_releases_generation_without_cleanup_loop(self) -> None:
|
||||
core = DictateServiceCore(
|
||||
model=self.model,
|
||||
recorder_factory=SpawnFailRecorderFactory(),
|
||||
executor=self.executor,
|
||||
clock=self.clock,
|
||||
idle_seconds=900,
|
||||
)
|
||||
core.accepted("a")
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2, "type": "hello", "requestId": "hello"
|
||||
}))
|
||||
core.drain_events()
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2,
|
||||
"type": "start",
|
||||
"requestId": "start",
|
||||
"generationId": "g1",
|
||||
}))
|
||||
self.executor.run_next()
|
||||
|
||||
core.poll()
|
||||
|
||||
events = self.frames(core.drain_events(), "a")
|
||||
self.assertEqual(events[-2]["code"], "recorder_spawn")
|
||||
self.assertEqual(events[-1]["state"], "idle")
|
||||
self.assertIsNone(core.active_generation_id)
|
||||
self.assertEqual(self.executor.pending, [])
|
||||
|
||||
def test_unreaped_recorder_keeps_generation_busy_until_cleanup_completes(self) -> None:
|
||||
recorders = DelayedCleanupRecorderFactory()
|
||||
core = DictateServiceCore(
|
||||
model=self.model,
|
||||
recorder_factory=recorders,
|
||||
executor=self.executor,
|
||||
clock=self.clock,
|
||||
idle_seconds=900,
|
||||
)
|
||||
core.accepted("a")
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2, "type": "hello", "requestId": "hello"
|
||||
}))
|
||||
core.drain_events()
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2,
|
||||
"type": "start",
|
||||
"requestId": "start",
|
||||
"generationId": "g1",
|
||||
}))
|
||||
self.executor.run_next()
|
||||
core.poll()
|
||||
core.drain_events()
|
||||
core.handle_request("a", parse_request({
|
||||
"version": 2,
|
||||
"type": "stop",
|
||||
"requestId": "stop",
|
||||
"generationId": "g1",
|
||||
}))
|
||||
self.executor.run_next()
|
||||
|
||||
core.poll()
|
||||
self.assertEqual(core.active_generation_id, "g1")
|
||||
# First reap the cancellation worker scheduled by the terminal stop.
|
||||
self.executor.run_next()
|
||||
core.poll()
|
||||
for attempt in range(1, 4):
|
||||
self.executor.run_next()
|
||||
core.poll()
|
||||
if attempt < 3:
|
||||
self.assertEqual(core.active_generation_id, "g1")
|
||||
self.assertIsNone(core.active_generation_id)
|
||||
self.assertEqual(recorders.instance.cleanup_attempts, 3)
|
||||
|
||||
|
||||
class SocketLoopRegressionTests(unittest.TestCase):
|
||||
def test_output_overflow_does_not_use_the_closed_socket_from_same_selector_event(self) -> None:
|
||||
frame = {"padding": "x" * (MAX_FRAME_BYTES - len(encode_frame({"padding": ""})))}
|
||||
self.assertEqual(len(encode_frame(frame)), MAX_FRAME_BYTES)
|
||||
client = _FakeClientSocket()
|
||||
listener = _FakeListener(client)
|
||||
selector = _FakeSelector(client)
|
||||
core = _FakeLoopCore(
|
||||
Outbound("client-1", frame),
|
||||
)
|
||||
|
||||
with patch("pi_dictate_service.server.selectors.DefaultSelector", return_value=selector):
|
||||
serve(listener, core)
|
||||
|
||||
self.assertEqual(client.send_calls, 0)
|
||||
self.assertTrue(client.closed)
|
||||
self.assertTrue(listener.closed)
|
||||
|
||||
def test_close_does_not_wait_for_owned_executor_workers(self) -> None:
|
||||
executor = _ShutdownRecordingExecutor()
|
||||
with patch("pi_dictate_service.server._DaemonExecutor", return_value=executor):
|
||||
core = DictateServiceCore(model=FakeModel())
|
||||
core.accepted("a")
|
||||
core.handle_request(
|
||||
"a", parse_request({"version": PROTOCOL_VERSION, "type": "hello", "requestId": "h"})
|
||||
)
|
||||
core.drain_events()
|
||||
core.handle_request(
|
||||
"a",
|
||||
parse_request(
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "start",
|
||||
"requestId": "s",
|
||||
"generationId": "g",
|
||||
}
|
||||
),
|
||||
)
|
||||
core.close()
|
||||
|
||||
self.assertEqual(executor.shutdown_args, (False, True))
|
||||
|
||||
def test_real_owned_executor_does_not_block_interpreter_exit(self) -> None:
|
||||
program = """
|
||||
import threading
|
||||
import time
|
||||
from pi_dictate_service.protocol import parse_request
|
||||
from pi_dictate_service.server import DictateServiceCore
|
||||
|
||||
class StuckModel:
|
||||
loaded = False
|
||||
def load(self):
|
||||
threading.Event().wait()
|
||||
def transcribe(self, _samples):
|
||||
return ''
|
||||
|
||||
core = DictateServiceCore(model=StuckModel())
|
||||
core.accepted('a')
|
||||
core.handle_request('a', parse_request({'version': 2, 'type': 'hello', 'requestId': 'h'}))
|
||||
core.drain_events()
|
||||
core.handle_request('a', parse_request({
|
||||
'version': 2, 'type': 'start', 'requestId': 's', 'generationId': 'g'
|
||||
}))
|
||||
time.sleep(0.05)
|
||||
core.close()
|
||||
print('closed')
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", program],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "closed")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeSelectorKey:
|
||||
data: str | None
|
||||
|
||||
|
||||
class _FakeSelector:
|
||||
def __init__(self, client: "_FakeClientSocket") -> None:
|
||||
self._events = [
|
||||
[(_FakeSelectorKey(None), selectors.EVENT_READ)],
|
||||
[(_FakeSelectorKey("client-1"), selectors.EVENT_READ | selectors.EVENT_WRITE)],
|
||||
]
|
||||
self.client = client
|
||||
self.unregistered: list[object] = []
|
||||
self.closed = False
|
||||
|
||||
def register(self, fileobj: object, _events: int, _data: object = None) -> None:
|
||||
return None
|
||||
|
||||
def unregister(self, fileobj: object) -> None:
|
||||
self.unregistered.append(fileobj)
|
||||
|
||||
def modify(self, fileobj: object, _events: int, _data: object = None) -> None:
|
||||
if fileobj is self.client and self.client.closed:
|
||||
raise OSError(errno.EBADF, "bad file descriptor")
|
||||
|
||||
def select(self, timeout: float | None = None) -> list[tuple[_FakeSelectorKey, int]]:
|
||||
return self._events.pop(0)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeClientSocket:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.send_calls = 0
|
||||
self._read = True
|
||||
|
||||
def setblocking(self, _value: bool) -> None:
|
||||
return None
|
||||
|
||||
def recv(self, _size: int) -> bytes:
|
||||
if self._read:
|
||||
self._read = False
|
||||
return b"{broken}\n"
|
||||
return b""
|
||||
|
||||
def send(self, _data: bytearray) -> int:
|
||||
self.send_calls += 1
|
||||
raise OSError(errno.EBADF, "bad file descriptor")
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeListener:
|
||||
def __init__(self, client: _FakeClientSocket) -> None:
|
||||
self.client = client
|
||||
self.closed = False
|
||||
self._accepted = False
|
||||
|
||||
def setblocking(self, _value: bool) -> None:
|
||||
return None
|
||||
|
||||
def accept(self) -> tuple[_FakeClientSocket, object]:
|
||||
if self._accepted:
|
||||
raise BlockingIOError
|
||||
self._accepted = True
|
||||
return self.client, None
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeLoopCore:
|
||||
def __init__(self, outbound: Outbound) -> None:
|
||||
self.outbound = outbound
|
||||
self.accepted_client: str | None = None
|
||||
self._outbound_sent = False
|
||||
self._exit_checks = 0
|
||||
|
||||
def should_exit(self) -> bool:
|
||||
self._exit_checks += 1
|
||||
return self._exit_checks >= 3
|
||||
|
||||
def poll(self) -> None:
|
||||
return None
|
||||
|
||||
def drain_events(self) -> list[Outbound]:
|
||||
if self.accepted_client is not None and not self._outbound_sent:
|
||||
self._outbound_sent = True
|
||||
return [self.outbound]
|
||||
return []
|
||||
|
||||
def accepted(self, client_id: str) -> None:
|
||||
self.accepted_client = client_id
|
||||
|
||||
def disconnect(self, _client_id: str) -> None:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _ShutdownRecordingExecutor:
|
||||
def __init__(self) -> None:
|
||||
self.shutdown_args: tuple[bool, bool] | None = None
|
||||
|
||||
def submit(self, _function: object, *_args: object) -> Future[object]:
|
||||
return Future()
|
||||
|
||||
def shutdown(self, *, wait: bool = True, cancel_futures: bool = False) -> None:
|
||||
self.shutdown_args = (wait, cancel_futures)
|
||||
if wait:
|
||||
raise AssertionError("shutdown waited for a hung worker")
|
||||
|
||||
|
||||
class IdleTests(CoreTestCase):
|
||||
def test_only_valid_requests_reset_idle_and_workers_suspend_then_restart_timer(self) -> None:
|
||||
self.core.accepted("a")
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pi_dictate_service.config import ConfigError
|
||||
from pi_dictate_service.startup import validate_startup
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = REPOSITORY_ROOT / "services/dictate/install.sh"
|
||||
|
||||
|
||||
class SystemdProfileTests(unittest.TestCase):
|
||||
def run_install(self, home: Path, fake_bin: Path, *arguments: str) -> subprocess.CompletedProcess[str]:
|
||||
environment = {
|
||||
**os.environ,
|
||||
"HOME": str(home),
|
||||
"XDG_CONFIG_HOME": str(home / ".config"),
|
||||
"PATH": f"{fake_bin}:{os.environ['PATH']}",
|
||||
}
|
||||
return subprocess.run(
|
||||
[str(SCRIPT), *arguments],
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def make_fake_bin(self, root: Path) -> Path:
|
||||
fake_bin = root / "bin"
|
||||
fake_bin.mkdir()
|
||||
uv = fake_bin / "uv"
|
||||
# Match uv sync's contract so transactional environment swapping is
|
||||
# exercised without installing dependencies.
|
||||
uv.write_text(
|
||||
"#!/bin/sh\nmkdir -p \"$UV_PROJECT_ENVIRONMENT\"\nexit 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
uv.chmod(0o755)
|
||||
systemctl = fake_bin / "systemctl"
|
||||
systemctl.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
systemctl.chmod(0o755)
|
||||
return fake_bin
|
||||
|
||||
def test_generated_cloud_dropin_replaces_offline_network_restrictions(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
fake_bin = self.make_fake_bin(root)
|
||||
key = root / "key"
|
||||
key.write_text("test-key", encoding="utf-8")
|
||||
|
||||
result = self.run_install(
|
||||
home, fake_bin, "install", "--backend", "openai", "--api-key-file", str(key)
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
dropin = (
|
||||
home / ".config/systemd/user/pi-dictate.service.d/"
|
||||
"10-pi-dictate-network.conf"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("Environment=PI_DICTATE_NETWORK_MODE=cloud", dropin)
|
||||
self.assertIn(
|
||||
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", dropin
|
||||
)
|
||||
self.assertIn("IPAddressDeny=", dropin)
|
||||
self.assertNotIn("IPAddressDeny=any", dropin)
|
||||
self.assertNotIn("HF_HUB_OFFLINE=1", dropin)
|
||||
self.assertIn(
|
||||
f'BindReadOnlyPaths="{home}/.config/pi-dictate/credentials/openai-api-key"',
|
||||
dropin,
|
||||
)
|
||||
self.assertIn(
|
||||
f'BindReadOnlyPaths="{home}/.local/share/pi-dictate/config/coding-terms.txt"',
|
||||
dropin,
|
||||
)
|
||||
|
||||
def test_check_rejects_tampered_network_dropin(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
fake_bin = self.make_fake_bin(root)
|
||||
key = root / "key"
|
||||
key.write_text("test-key", encoding="utf-8")
|
||||
result = self.run_install(
|
||||
home, fake_bin, "install", "--backend", "meta", "--api-key-file", str(key)
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
dropin = (
|
||||
home / ".config/systemd/user/pi-dictate.service.d/"
|
||||
"10-pi-dictate-network.conf"
|
||||
)
|
||||
with dropin.open("a", encoding="utf-8") as stream:
|
||||
stream.write("IPAddressAllow=any\\n")
|
||||
|
||||
result = self.run_install(home, fake_bin, "check")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("expected managed policy", result.stderr)
|
||||
|
||||
def test_startup_requires_the_exact_validation_command(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
fake_bin = self.make_fake_bin(root)
|
||||
key = root / "key"
|
||||
key.write_text("test-key", encoding="utf-8")
|
||||
result = self.run_install(
|
||||
home, fake_bin, "install", "--backend", "meta", "--api-key-file", str(key)
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
environment = {
|
||||
"HOME": str(home),
|
||||
"XDG_CONFIG_HOME": str(home / ".config"),
|
||||
"PI_DICTATE_CONFIG_PATH": str(home / ".config/pi-dictate/config.json"),
|
||||
"PI_DICTATE_DEPENDENCY_PROFILE_PATH": str(
|
||||
home / ".local/share/pi-dictate/.dependency-profile"
|
||||
),
|
||||
"PI_DICTATE_NETWORK_MODE": "cloud",
|
||||
}
|
||||
with patch.dict(os.environ, environment, clear=False):
|
||||
validate_startup()
|
||||
service = home / ".config/systemd/user/pi-dictate.service"
|
||||
text = service.read_text(encoding="utf-8")
|
||||
service.write_text(
|
||||
"\n".join(
|
||||
"ExecStartPre=/bin/true" if line.startswith("ExecStartPre=") else line
|
||||
for line in text.splitlines()
|
||||
) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ConfigError, "hardening"):
|
||||
validate_startup()
|
||||
|
||||
def test_startup_rejects_a_world_writable_socket_unit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
fake_bin = self.make_fake_bin(root)
|
||||
key = root / "key"
|
||||
key.write_text("test-key", encoding="utf-8")
|
||||
result = self.run_install(
|
||||
home, fake_bin, "install", "--backend", "meta", "--api-key-file", str(key)
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
environment = {
|
||||
"HOME": str(home),
|
||||
"XDG_CONFIG_HOME": str(home / ".config"),
|
||||
"PI_DICTATE_CONFIG_PATH": str(home / ".config/pi-dictate/config.json"),
|
||||
"PI_DICTATE_DEPENDENCY_PROFILE_PATH": str(
|
||||
home / ".local/share/pi-dictate/.dependency-profile"
|
||||
),
|
||||
"PI_DICTATE_NETWORK_MODE": "cloud",
|
||||
}
|
||||
socket_unit = home / ".config/systemd/user/pi-dictate.socket"
|
||||
socket_unit.write_text(
|
||||
socket_unit.read_text(encoding="utf-8").replace(
|
||||
"SocketMode=0600", "SocketMode=0666"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, environment, clear=False):
|
||||
with self.assertRaisesRegex(ConfigError, "socket systemd policy"):
|
||||
validate_startup()
|
||||
|
||||
def test_local_dropin_binds_custom_model_and_dictionary_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
fake_bin = self.make_fake_bin(root)
|
||||
source = root / "source.nemo"
|
||||
source.write_bytes(b"model")
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
result = self.run_install(
|
||||
home,
|
||||
fake_bin,
|
||||
"install",
|
||||
"--backend",
|
||||
"local",
|
||||
"--model-source",
|
||||
str(source),
|
||||
"--model-sha256",
|
||||
digest,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
custom_model = home / "custom model.nemo"
|
||||
custom_terms = home / "custom terms.txt"
|
||||
custom_model.write_bytes(source.read_bytes())
|
||||
custom_terms.write_text("JSON\n", encoding="utf-8")
|
||||
config_path = home / ".config/pi-dictate/config.json"
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config["local"]["modelPath"] = str(custom_model)
|
||||
config["local"]["termsPath"] = str(custom_terms)
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
config_path.chmod(0o600)
|
||||
|
||||
result = self.run_install(home, fake_bin, "configure", "--backend", "local")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
dropin = (home / ".config/systemd/user/pi-dictate.service.d/10-pi-dictate-network.conf").read_text(encoding="utf-8")
|
||||
self.assertIn(f'BindReadOnlyPaths="{custom_model}"', dropin)
|
||||
self.assertIn(f'BindReadOnlyPaths="{custom_terms}"', dropin)
|
||||
environment = {
|
||||
"HOME": str(home),
|
||||
"XDG_CONFIG_HOME": str(home / ".config"),
|
||||
"PI_DICTATE_CONFIG_PATH": str(config_path),
|
||||
"PI_DICTATE_DEPENDENCY_PROFILE_PATH": str(
|
||||
home / ".local/share/pi-dictate/.dependency-profile"
|
||||
),
|
||||
"PI_DICTATE_NETWORK_MODE": "offline",
|
||||
}
|
||||
with patch.dict(os.environ, environment, clear=False):
|
||||
validate_startup()
|
||||
|
||||
def test_local_dropin_and_profile_deny_are_restrictive(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
fake_bin = self.make_fake_bin(root)
|
||||
model = root / "model"
|
||||
model.write_bytes(b"model")
|
||||
# Dry-run keeps this test independent of the pinned model bytes.
|
||||
result = self.run_install(
|
||||
home,
|
||||
fake_bin,
|
||||
"install",
|
||||
"--backend",
|
||||
"local",
|
||||
"--model-source",
|
||||
str(model),
|
||||
"--model-sha256",
|
||||
hashlib.sha256(model.read_bytes()).hexdigest(),
|
||||
"--dry-run",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("offline network hardening", result.stdout)
|
||||
|
||||
profile = json.loads(
|
||||
(REPOSITORY_ROOT / "profiles/nono/pi-local.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
self.assertIn("$HOME/.local/share/pi-dictate", profile["filesystem"]["deny"])
|
||||
# User decision: the profile is intentionally unchanged from HEAD.
|
||||
self.assertNotIn("$HOME/.config/pi-dictate", profile["filesystem"]["deny"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,12 +1,21 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from pi_dictate_service.startup import EXPECTED_BASE_UNIT_SHA256
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
UNIT_DIR = REPOSITORY_ROOT / "systemd/user"
|
||||
|
||||
|
||||
class SystemdUnitTests(unittest.TestCase):
|
||||
def test_startup_hash_matches_the_checked_in_service_unit(self) -> None:
|
||||
self.assertEqual(
|
||||
hashlib.sha256((UNIT_DIR / "pi-dictate.service").read_bytes()).hexdigest(),
|
||||
EXPECTED_BASE_UNIT_SHA256,
|
||||
)
|
||||
|
||||
def test_socket_unit_owns_the_exact_private_runtime_path(self) -> None:
|
||||
socket_unit = (UNIT_DIR / "pi-dictate.socket").read_text(encoding="utf-8")
|
||||
self.assertIn("ListenStream=%t/pi-dictate.sock", socket_unit)
|
||||
@@ -24,11 +33,20 @@ class SystemdUnitTests(unittest.TestCase):
|
||||
"TRANSFORMERS_OFFLINE=1",
|
||||
"TRITON_CACHE_DIR=/tmp/pi-dictate-triton",
|
||||
"NUMBA_CACHE_DIR=/tmp/pi-dictate-numba",
|
||||
"TimeoutStopSec=5s",
|
||||
"LimitCORE=0",
|
||||
"MemorySwapMax=0",
|
||||
"ProtectSystem=strict",
|
||||
"ProtectHome=read-only",
|
||||
"ProtectHome=tmpfs",
|
||||
"BindReadOnlyPaths=%h/.local/share/pi-dictate",
|
||||
"BindReadOnlyPaths=%h/.local/share/uv/python",
|
||||
"BindReadOnlyPaths=%h/.config/pi-dictate/config.json",
|
||||
"BindReadOnlyPaths=%h/.config/systemd/user/pi-dictate.service",
|
||||
"BindReadOnlyPaths=%h/.config/systemd/user/pi-dictate.service.d",
|
||||
"BindReadOnlyPaths=%h/.config/systemd/user/pi-dictate.socket",
|
||||
"BindReadOnlyPaths=%t/pipewire-0",
|
||||
"RestrictAddressFamilies=AF_UNIX",
|
||||
"SystemCallFilter=~bind listen",
|
||||
"IPAddressDeny=any",
|
||||
):
|
||||
self.assertIn(expected, service_unit)
|
||||
|
||||
@@ -173,6 +173,10 @@ class UnixLoopTests(unittest.TestCase):
|
||||
class ActivationTests(unittest.TestCase):
|
||||
def test_inherited_listener_requires_one_fd_for_this_process_and_wraps_fd_three(self) -> None:
|
||||
listener = Mock()
|
||||
listener.family = socket.AF_UNIX
|
||||
listener.type = socket.SOCK_STREAM
|
||||
listener.getsockopt.return_value = 1
|
||||
listener.getsockname.return_value = "/run/user/1000/pi-dictate.sock"
|
||||
with patch("pi_dictate_service.server.os.getpid", return_value=42), patch(
|
||||
"pi_dictate_service.server.socket.socket", return_value=listener
|
||||
) as socket_factory:
|
||||
@@ -189,7 +193,9 @@ class ActivationTests(unittest.TestCase):
|
||||
inherited_listener({"LISTEN_PID": "41", "LISTEN_FDS": "1"})
|
||||
|
||||
def test_main_requires_local_model_path_and_reads_idle_override(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch.dict(os.environ, {}, clear=True), patch(
|
||||
"pi_dictate_service.server.os.path.isfile", return_value=False
|
||||
):
|
||||
with self.assertRaisesRegex(SystemExit, "PI_DICTATE_MODEL_PATH"):
|
||||
main()
|
||||
|
||||
@@ -201,10 +207,12 @@ class ActivationTests(unittest.TestCase):
|
||||
"PI_DICTATE_MODEL_PATH": "/models/parakeet.nemo",
|
||||
"PI_DICTATE_TERMS_PATH": "/config/coding-terms.txt",
|
||||
"PI_DICTATE_BOOST_ALPHA": "0.5",
|
||||
"PI_DICTATE_IDLE_SECONDS": "12.5",
|
||||
"PI_DICTATE_IDLE_SECONDS": "120",
|
||||
},
|
||||
clear=True,
|
||||
), patch("pi_dictate_service.server.NemoParakeetModel", return_value=model) as adapter, patch(
|
||||
), patch("pi_dictate_service.server.os.path.isfile", return_value=False), patch(
|
||||
"pi_dictate_service.server.NemoParakeetModel", return_value=model
|
||||
) as adapter, patch(
|
||||
"pi_dictate_service.server.DictateServiceCore"
|
||||
) as core_factory, patch(
|
||||
"pi_dictate_service.server.inherited_listener", return_value=listener
|
||||
@@ -217,7 +225,7 @@ class ActivationTests(unittest.TestCase):
|
||||
terms_path="/config/coding-terms.txt",
|
||||
boost_alpha=0.5,
|
||||
)
|
||||
core_factory.assert_called_once_with(model=model, idle_seconds=12.5)
|
||||
core_factory.assert_called_once_with(model=model, idle_seconds=120)
|
||||
serve_loop.assert_called_once_with(listener, core)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user