client: add ConnectionFailed and reconnect method
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user