Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/socketry/__init__.py
Original file line number Diff line number Diff line change
@@ -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__ = [
Expand All @@ -11,5 +18,6 @@
"MqttError",
"PROPERTIES",
"Setting",
"SocketryError",
"Subscription",
]
12 changes: 8 additions & 4 deletions src/socketry/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
41 changes: 37 additions & 4 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Client,
Device,
MqttError,
SocketryError,
Subscription,
TokenExpiredError,
_build_command_payload,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.