diff --git a/src/tuimble/client.py b/src/tuimble/client.py index 48b9b08..056cf7c 100644 --- a/src/tuimble/client.py +++ b/src/tuimble/client.py @@ -9,12 +9,26 @@ event loop (e.g. Textual's call_from_thread). from __future__ import annotations import logging +import socket from dataclasses import dataclass from typing import Callable log = logging.getLogger(__name__) +class ConnectionFailed(Exception): + """Connection attempt failed. + + Attributes: + retryable: Whether the caller should attempt reconnection. + False for authentication rejections, True for network errors. + """ + + def __init__(self, message: str, *, retryable: bool = True): + super().__init__(message) + self.retryable = retryable + + @dataclass class User: session_id: int @@ -138,8 +152,14 @@ class MumbleClient: # -- connection ---------------------------------------------------------- def connect(self): - """Connect to the Mumble server (blocking).""" + """Connect to the Mumble server (blocking). + + Raises: + ConnectionFailed: On any connection failure. Check the + ``retryable`` attribute to decide whether to retry. + """ import pymumble_py3 as pymumble + import pymumble_py3.constants as const kwargs = { "port": self._port, @@ -150,19 +170,39 @@ class MumbleClient: kwargs["certfile"] = self._certfile if self._keyfile: kwargs["keyfile"] = self._keyfile - self._mumble = pymumble.Mumble( - self._host, - self._username, - **kwargs, - ) - self._mumble.set_codec_profile("audio") - self._mumble.set_receive_sound(True) - self._register_callbacks() - self._mumble.start() - self._mumble.is_ready() # blocks until handshake completes + try: + self._mumble = pymumble.Mumble( + self._host, + self._username, + **kwargs, + ) + self._mumble.set_codec_profile("audio") + self._mumble.set_receive_sound(True) + self._register_callbacks() + + self._mumble.start() + self._mumble.is_ready() # blocks until handshake completes + except (socket.error, OSError) as exc: + self._connected = False + raise ConnectionFailed( + f"network error: {exc}", retryable=True, + ) from exc + except Exception as exc: + self._connected = False + raise ConnectionFailed(str(exc), retryable=True) from exc + + if self._mumble.connected != const.PYMUMBLE_CONN_STATE_CONNECTED: + self._connected = False + raise ConnectionFailed( + "server rejected connection", retryable=False, + ) + self._connected = True - log.info("connected to %s:%d as %s", self._host, self._port, self._username) + log.info( + "connected to %s:%d as %s", + self._host, self._port, self._username, + ) def disconnect(self): """Disconnect from the server.""" @@ -174,6 +214,16 @@ class MumbleClient: self._connected = False log.info("disconnected") + def reconnect(self): + """Disconnect and reconnect to the same server. + + Raises: + ConnectionFailed: On connection failure (see ``connect``). + """ + self.disconnect() + self._mumble = None + self.connect() + # -- actions ------------------------------------------------------------- def send_text(self, message: str): diff --git a/tests/test_client.py b/tests/test_client.py index 020c58d..6bc597c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,8 +1,8 @@ """Tests for MumbleClient dispatcher and callback wiring.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -from tuimble.client import MumbleClient +from tuimble.client import ConnectionFailed, MumbleClient def test_default_state(): @@ -83,3 +83,40 @@ def test_cert_custom(): ) assert client._certfile == "/path/cert.pem" assert client._keyfile == "/path/key.pem" + + +def test_connection_failed_retryable(): + exc = ConnectionFailed("network error", retryable=True) + assert str(exc) == "network error" + assert exc.retryable is True + + +def test_connection_failed_not_retryable(): + exc = ConnectionFailed("auth rejected", retryable=False) + assert str(exc) == "auth rejected" + assert exc.retryable is False + + +def test_connection_failed_default_retryable(): + exc = ConnectionFailed("something broke") + assert exc.retryable is True + + +def test_reconnect_resets_state(): + client = MumbleClient(host="localhost") + client._connected = True + client._mumble = MagicMock() + + with patch.object(client, "connect"): + client.reconnect() + + # disconnect should have cleared _mumble before connect + assert client._connected is False + + +def test_disconnect_clears_connected(): + client = MumbleClient(host="localhost") + client._connected = True + client._mumble = MagicMock() + client.disconnect() + assert client.connected is False