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
|
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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user