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
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):

View File

@@ -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