From 1c509cb65ada2ef8c78f8fe9638d94ce037ae34d Mon Sep 17 00:00:00 2001 From: Dirk-Jan Faber Date: Wed, 21 Oct 2026 13:24:30 +0000 Subject: [PATCH] Stop re-exporting websocket classes from s2python.connection 'websockets' is an optional dependency (the 'ws' extra), but s2python.connection and s2python.connection.async_ imported BlockingWebsocketClientRM and WebsocketClientMedium eagerly. Importing anything from s2python.connection (control types, AssetDetails, ...) therefore raised ImportError without 'websockets' installed, and loaded the websocket and sync connection stack even when using another medium. Remove both re-exports. Users import them from their own modules: from s2python.connection.quickstarts import BlockingWebsocketClientRM from s2python.connection.async_.medium.websocket import WebsocketClientMedium This is a breaking change for code using the old import paths. --- examples/async_frbc_rm.py | 3 +- examples/quickstart_websocket_rm.py | 3 +- examples/sync_frbc_rm.py | 2 +- src/s2python/connection/__init__.py | 2 - src/s2python/connection/async_/__init__.py | 2 - src/s2python/connection/quickstarts.py | 2 +- tests/unit/connection/init_test.py | 62 ++++++++++++++++++++++ 7 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 tests/unit/connection/init_test.py diff --git a/examples/async_frbc_rm.py b/examples/async_frbc_rm.py index 827988e..7c01bf1 100644 --- a/examples/async_frbc_rm.py +++ b/examples/async_frbc_rm.py @@ -34,7 +34,8 @@ FRBCActuatorStatus, ) from s2python.connection import AssetDetails -from s2python.connection.async_ import S2AsyncConnection, WebsocketClientMedium +from s2python.connection.async_ import S2AsyncConnection +from s2python.connection.async_.medium.websocket import WebsocketClientMedium from s2python.connection.async_.control_type.class_based import ( FRBCControlType, NoControlControlType, diff --git a/examples/quickstart_websocket_rm.py b/examples/quickstart_websocket_rm.py index 187c3cf..0341900 100644 --- a/examples/quickstart_websocket_rm.py +++ b/examples/quickstart_websocket_rm.py @@ -29,7 +29,8 @@ FRBCStorageStatus, FRBCActuatorStatus, ) -from s2python.connection import AssetDetails, BlockingWebsocketClientRM +from s2python.connection import AssetDetails +from s2python.connection.quickstarts import BlockingWebsocketClientRM from s2python.connection.sync import S2SyncConnection from s2python.connection.sync.control_type.class_based import FRBCControlType, NoControlControlType diff --git a/examples/sync_frbc_rm.py b/examples/sync_frbc_rm.py index 0607fc1..cb3db72 100644 --- a/examples/sync_frbc_rm.py +++ b/examples/sync_frbc_rm.py @@ -36,7 +36,7 @@ ) from s2python.connection import AssetDetails from s2python.connection.sync import S2SyncConnection -from s2python.connection.async_ import WebsocketClientMedium +from s2python.connection.async_.medium.websocket import WebsocketClientMedium from s2python.connection.sync.control_type.class_based import ( FRBCControlType, NoControlControlType, diff --git a/src/s2python/connection/__init__.py b/src/s2python/connection/__init__.py index c7a0d92..d626749 100644 --- a/src/s2python/connection/__init__.py +++ b/src/s2python/connection/__init__.py @@ -2,7 +2,6 @@ from s2python.connection.connection_events import ConnectionStarted, ConnectionStopped, S2ConnectionEvent from s2python.connection.control_type import RoleHandler from s2python.connection.types import S2ConnectionEventsAndMessages -from s2python.connection.quickstarts import BlockingWebsocketClientRM __all__ = [ "AssetDetails", @@ -11,5 +10,4 @@ "S2ConnectionEvent", "RoleHandler", "S2ConnectionEventsAndMessages", - "BlockingWebsocketClientRM" ] diff --git a/src/s2python/connection/async_/__init__.py b/src/s2python/connection/async_/__init__.py index 52ca6dc..2d88024 100644 --- a/src/s2python/connection/async_/__init__.py +++ b/src/s2python/connection/async_/__init__.py @@ -1,7 +1,6 @@ from s2python.connection.async_.connection import S2AsyncConnection, CouldNotReceiveStatusReceptionError from s2python.connection.async_.message_handlers import S2EventHandlerAsync from s2python.connection.async_.medium.s2_medium import S2MediumConnection -from s2python.connection.async_.medium.websocket import WebsocketClientMedium __all__ = [ @@ -9,5 +8,4 @@ "CouldNotReceiveStatusReceptionError", "S2EventHandlerAsync", "S2MediumConnection", - "WebsocketClientMedium" ] diff --git a/src/s2python/connection/quickstarts.py b/src/s2python/connection/quickstarts.py index 3f85d21..42c65f7 100644 --- a/src/s2python/connection/quickstarts.py +++ b/src/s2python/connection/quickstarts.py @@ -3,7 +3,7 @@ import threading from s2python.connection.asset_details import AssetDetails -from s2python.connection.async_ import WebsocketClientMedium +from s2python.connection.async_.medium.websocket import WebsocketClientMedium from s2python.connection.sync import S2SyncConnection from s2python.connection.sync.control_type.class_based import ResourceManagerHandler, S2ControlType diff --git a/tests/unit/connection/init_test.py b/tests/unit/connection/init_test.py new file mode 100644 index 0000000..f9c97c7 --- /dev/null +++ b/tests/unit/connection/init_test.py @@ -0,0 +1,62 @@ +"""Tests that s2python.connection does not require the optional 'websockets' dependency.""" + +import os +import subprocess +import sys + +import pytest + +SRC_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "src") + + +def _run_without_websockets(code: str) -> subprocess.CompletedProcess[str]: + # Setting sys.modules['websockets'] to None makes any import of it raise ImportError. + script = "import sys\nsys.modules['websockets'] = None\n" + code + python_path = [SRC_DIR] + if os.environ.get("PYTHONPATH"): + python_path.append(os.environ["PYTHONPATH"]) + env = dict(os.environ, PYTHONPATH=os.pathsep.join(python_path)) + return subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True, env=env, check=False + ) + + +@pytest.mark.parametrize( + "import_statement", + [ + "import s2python.connection", + "from s2python.connection import AssetDetails", + "import s2python.connection.async_", + "import s2python.connection.sync", + "from s2python.connection.async_.control_type.class_based import OMBCControlType", + "from s2python.connection.sync.control_type.class_based import OMBCControlType", + ], +) +def test__import__works_without_websockets(import_statement: str) -> None: + # Arrange + code = import_statement + "\nassert 's2python.connection.async_.medium.websocket' not in sys.modules\n" + + # Act + result = _run_without_websockets(code) + + # Assert + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "import_statement", + [ + "from s2python.connection.quickstarts import BlockingWebsocketClientRM", + "from s2python.connection.async_.medium.websocket import WebsocketClientMedium", + ], +) +def test__import__websocket_modules_require_websockets(import_statement: str) -> None: + # Arrange + code = import_statement + + # Act + result = _run_without_websockets(code) + + # Assert + assert result.returncode != 0 + assert "The 'websockets' package is required" in result.stderr