diff --git a/src/socketry/__init__.py b/src/socketry/__init__.py index a4b9a11..853df5b 100644 --- a/src/socketry/__init__.py +++ b/src/socketry/__init__.py @@ -1,6 +1,13 @@ """Python API and CLI for controlling Jackery portable power stations.""" -from socketry.client import AuthenticationError, Client, Device, MqttError, Subscription +from socketry.client import ( + AuthenticationError, + Client, + Device, + MqttError, + SocketryError, + Subscription, +) from socketry.properties import MODEL_NAMES, PROPERTIES, Setting __all__ = [ @@ -11,5 +18,6 @@ "MqttError", "PROPERTIES", "Setting", + "SocketryError", "Subscription", ] diff --git a/src/socketry/client.py b/src/socketry/client.py index 9b6f243..c826e1f 100644 --- a/src/socketry/client.py +++ b/src/socketry/client.py @@ -55,11 +55,15 @@ _TOKEN_EXPIRY_BUFFER = 3600 # seconds before expiry to trigger proactive refresh -class TokenExpiredError(RuntimeError): +class SocketryError(Exception): + """Base exception for all socketry errors.""" + + +class TokenExpiredError(SocketryError): """Raised when the Jackery API returns error code 10402 (token expired).""" -class AuthenticationError(Exception): +class AuthenticationError(SocketryError): """Raised when login fails after an automatic re-authentication attempt. Triggered when the Jackery API returns an auth error (session invalidated @@ -68,7 +72,7 @@ class AuthenticationError(Exception): """ -class _SessionInvalidatedError(RuntimeError): +class _SessionInvalidatedError(SocketryError): """Internal sentinel: API returned a non-token-expiry auth/API error. Raised by HTTP helpers to signal that the current session is rejected by @@ -77,7 +81,7 @@ class _SessionInvalidatedError(RuntimeError): """ -class MqttError(ConnectionError): +class MqttError(SocketryError, ConnectionError): """Raised when an MQTT operation fails. Wraps :class:`aiomqtt.MqttError` so callers do not need to import diff --git a/tests/test_client.py b/tests/test_client.py index 8813a15..242100a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -21,6 +21,7 @@ Client, Device, MqttError, + SocketryError, Subscription, TokenExpiredError, _build_command_payload, @@ -231,7 +232,7 @@ async def test_api_error_code(self): with aioresponses() as m: m.get(_PROPERTY_URL, payload={"code": 10600, "msg": "Auth failed"}) async with aiohttp.ClientSession() as session: - with pytest.raises(RuntimeError, match="Property fetch failed"): + with pytest.raises(_SessionInvalidatedError, match="Property fetch failed"): await _fetch_device_properties("fake-token", "DEV001", session) async def test_http_error(self): @@ -1895,12 +1896,12 @@ async def test_fetch_device_properties_raises_session_invalidated(self): with pytest.raises(_SessionInvalidatedError, match="Property fetch failed"): await _fetch_device_properties("stale-token", "DEV001", session) - async def test_fetch_device_properties_session_invalidated_is_runtime_error(self): - """_SessionInvalidatedError is a RuntimeError subclass (backwards compat).""" + async def test_fetch_device_properties_session_invalidated_is_socketry_error(self): + """_SessionInvalidatedError is a SocketryError subclass.""" with aioresponses() as m: m.get(_PROPERTY_URL, payload={"code": 10600, "msg": "Auth failed"}) async with aiohttp.ClientSession() as session: - with pytest.raises(RuntimeError): + with pytest.raises(SocketryError): await _fetch_device_properties("stale-token", "DEV001", session) async def test_fetch_all_devices_raises_session_invalidated(self): @@ -2192,3 +2193,35 @@ def test_authentication_error_is_exception_subclass(self): def test_authentication_error_not_runtime_error(self): """AuthenticationError is NOT a RuntimeError — it's a distinct exception type.""" assert not issubclass(AuthenticationError, RuntimeError) + + def test_authentication_error_is_socketry_error(self): + assert issubclass(AuthenticationError, SocketryError) + + +class TestSocketryErrorExported: + """SocketryError is exported from the top-level socketry package.""" + + def test_socketry_error_importable(self): + import socketry + + assert hasattr(socketry, "SocketryError") + assert socketry.SocketryError is SocketryError + + def test_socketry_error_is_exception_subclass(self): + assert issubclass(SocketryError, Exception) + + def test_socketry_error_not_runtime_error(self): + assert not issubclass(SocketryError, RuntimeError) + + def test_all_public_errors_are_socketry_errors(self): + """Every public exception is a SocketryError subclass.""" + assert issubclass(AuthenticationError, SocketryError) + assert issubclass(MqttError, SocketryError) + + def test_mqtt_error_is_still_connection_error(self): + """MqttError retains its ConnectionError base for broad except clauses.""" + assert issubclass(MqttError, ConnectionError) + + def test_internal_errors_are_socketry_errors(self): + assert issubclass(TokenExpiredError, SocketryError) + assert issubclass(_SessionInvalidatedError, SocketryError) diff --git a/uv.lock b/uv.lock index b79956b..be5fa39 100644 --- a/uv.lock +++ b/uv.lock @@ -1067,7 +1067,7 @@ wheels = [ [[package]] name = "socketry" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "aiohttp" },