client: add ConnectionFailed and reconnect method

This commit is contained in:
Username
2026-02-24 14:34:40 +01:00
parent 0b186f1f0c
commit a041069cc9
2 changed files with 101 additions and 14 deletions

View File

@@ -9,12 +9,26 @@ event loop (e.g. Textual's call_from_thread).
from __future__ import annotations from __future__ import annotations
import logging import logging
import socket
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable from typing import Callable
log = logging.getLogger(__name__) 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 @dataclass
class User: class User:
session_id: int session_id: int
@@ -138,8 +152,14 @@ class MumbleClient:
# -- connection ---------------------------------------------------------- # -- connection ----------------------------------------------------------
def connect(self): 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 as pymumble
import pymumble_py3.constants as const
kwargs = { kwargs = {
"port": self._port, "port": self._port,
@@ -150,19 +170,39 @@ class MumbleClient:
kwargs["certfile"] = self._certfile kwargs["certfile"] = self._certfile
if self._keyfile: if self._keyfile:
kwargs["keyfile"] = 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() try:
self._mumble.is_ready() # blocks until handshake completes 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 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): def disconnect(self):
"""Disconnect from the server.""" """Disconnect from the server."""
@@ -174,6 +214,16 @@ class MumbleClient:
self._connected = False self._connected = False
log.info("disconnected") 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 ------------------------------------------------------------- # -- actions -------------------------------------------------------------
def send_text(self, message: str): def send_text(self, message: str):

View File

@@ -1,8 +1,8 @@
"""Tests for MumbleClient dispatcher and callback wiring.""" """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(): def test_default_state():
@@ -83,3 +83,40 @@ def test_cert_custom():
) )
assert client._certfile == "/path/cert.pem" assert client._certfile == "/path/cert.pem"
assert client._keyfile == "/path/key.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