diff --git a/.github/workflows/test-project.yml b/.github/workflows/test-project.yml index 3f07c32..f2a48a1 100644 --- a/.github/workflows/test-project.yml +++ b/.github/workflows/test-project.yml @@ -14,8 +14,6 @@ jobs: strategy: matrix: include: - - os: "ubuntu-latest" - python-version: "3.9" - os: "ubuntu-latest" python-version: "3.10" - os: "ubuntu-latest" @@ -51,7 +49,7 @@ jobs: pip install -e .[dev] - name: Run tests run: | - pytest --cov src/scadable --cov-config=.coveragerc --cov-report lcov + pytest --cov scadable --cov-config=.coveragerc --cov-report lcov # - name: Upload test coverage to coveralls.io # uses: coverallsapp/github-action@v2 # with: diff --git a/pyproject.toml b/pyproject.toml index 03bcd51..2de35e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,11 @@ authors = [ ] description = "A Python library for scalable and modular software development." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', @@ -23,8 +22,9 @@ classifiers = [ "Operating System :: OS Independent", ] license = "Apache-2.0" -license-files = [ "LICENSE" ] +license-files = ["LICENSE"] dependencies = [ + "websockets >= 13.0" ] [project.optional-dependencies] @@ -32,6 +32,7 @@ dev = [ "pytest", "coverage", "pytest-cov", + "pytest-asyncio", "pre-commit" ] @@ -39,3 +40,7 @@ dev = [ [project.urls] "Homepage" = "https://github.com/scadable/library-python" "Bug Tracker" = "https://github.com/scadable/library-python/issues" + +[tool.ruff] +# Ignore all tests +extend-exclude = ["tests"] diff --git a/scadable/__init__.py b/scadable/__init__.py new file mode 100644 index 0000000..4867b6e --- /dev/null +++ b/scadable/__init__.py @@ -0,0 +1,3 @@ +from .facility import Facility + +__all__ = ["Facility"] diff --git a/scadable/connection.py b/scadable/connection.py new file mode 100644 index 0000000..e3d1229 --- /dev/null +++ b/scadable/connection.py @@ -0,0 +1,132 @@ +from websockets.asyncio import client +from websockets.asyncio.client import ClientConnection +from typing import Callable, Awaitable + + +class ConnectionFactory: # pragma: no cover + """ + Abstract Connection Factory + + These factories create 'Connection' types which live_telemetry will use. This is passed into the DeviceFactory + which creates devices that we can subscribe to. + + We expose create_connection(device_id) so we can pass in an instance of any type of factory + """ + + def create_connection(self, api_key: str, device_id: str) -> "Connection": + """ + Creates a connection to a device + :param api_key: API key of the connection you want to create + :param device_id: Device Id of the device + :return: Connection + """ + raise NotImplementedError + + +class WebsocketConnectionFactory(ConnectionFactory): + """ + A factory that creates websocket connections. + + Instance Attributes: + connection_type: ws or wss depending on the connection type + """ + + def __init__(self, dest_uri: str, connection_type="wss"): + """ + Init for a Websocket Factory + :param dest_uri: Destination URI of the websocket + :param connection_type: Connection type (wss or ws) + """ + self.connection_type = connection_type + self._dest_uri = dest_uri + + def create_connection(self, api_key: str, device_id: str) -> "Connection": + """ + Creates a connection to a device + :param api_key: API key of the connection you want to create + :param device_id: Device Id of the device + :return: WebsocketConnection + """ + return WebsocketConnection( + f"{self.connection_type}://{self._dest_uri}?token={api_key}&deviceid={device_id}" + ) + + +class Connection: # pragma: no cover + """ + Abstract Connection + + A generic connection that the device uses to send and receive messages. + + We expose: + - connect(func) + - send_message(str) + - stop() + to interact with the connection. + """ + + async def connect(self, handler: Callable[[str], Awaitable]): + """ + Connects to a server + :param handler: Function that handles messages + :return: None + """ + raise NotImplementedError + + async def send_message(self, message: str): + """ + Sends a message through the connection + :param message: Message to be sent + :return: None + """ + raise NotImplementedError + + async def stop(self): + """ + Ends the connection + :return: None + """ + raise NotImplementedError + + +class WebsocketConnection(Connection): + """ + A class representing a Websocket Connection + + Instance Attributes: + dest_uri: full uri of the destination, e.g. wss://localhost:8765&apikey=a&deviceid=b + """ + + def __init__(self, dest_uri: str): + super().__init__() + self.dest_uri = dest_uri + self._ws: ClientConnection | None = None + + async def connect(self, handler: Callable[[str], Awaitable]): + """ + Starts the websocket connection to the server to receive data + :return: None + """ + async with client.connect(self.dest_uri) as ws: + self._ws = ws + async for message in ws: + await handler(message) + + self._ws = None + + async def send_message(self, message: str): + """ + Sends a message to the websocket connection + :param message: Message to send + :return: + """ + if self._ws: + await self._ws.send(message) + + async def stop(self): + """ + Ends the websocket connection to the server gracefully + :return: None + """ + if self._ws: + await self._ws.close() diff --git a/scadable/device.py b/scadable/device.py new file mode 100644 index 0000000..8b9fd2d --- /dev/null +++ b/scadable/device.py @@ -0,0 +1,138 @@ +import asyncio +from typing import Callable, Awaitable, Any +from .connection import Connection + + +class DeviceManager: + """ + A class to manage created Devices + + Instance attributes: + devices: dict that maps deviceid->Device + """ + + def __init__(self): + self.devices: dict[str, Device] = {} + + def __getitem__(self, device_id: str) -> "Device": + """ + Returns a device from a device id + + :param device_id: device id + :return: Device with associated device id + """ + return self.devices[device_id] + + def __contains__(self, device_id): + """ + Returns whether device id is created + + :param device_id: device id + :return: If device id is created + """ + return device_id in self.devices + + def create_device(self, device_id: str, connection: Connection | None) -> "Device": + """ + Creates a device if not already created, otherwise return the already created one + + :param device_id: ID of the device we connect to + :param connection: The connection that should be used for live_telemetry + :return: Created device + """ + if device_id in self.devices: + device = self.devices[device_id] + else: + device = Device(device_id=device_id, connection=connection) + self.devices[device_id] = device + + return device + + def remove_device(self, device_id: str): + """ + Removes a device from our manager + + :param device_id: Device Id to remove + :return: + """ + if device_id in self.devices: + device = self.devices[device_id] # noqa + # TODO: figure out what to do here + del self.devices[device_id] + + +class Device: + """ + A class that represents a single device + + Instance attributes: + connection: connection that the device will read messages from + device_id: device id of the device + raw_bus: set of subscribed handlers that will be called when receiving a raw response + parsed_bus: set of subscribed handlers that will be called after parsing a response + """ + + def __init__(self, device_id: str, connection: Connection | None): + self.connection = connection + self.device_id = device_id + + # Bus that contains all functions that handle raw data + self.raw_bus: set[Callable[[str], Awaitable[Any]]] = set() + # Bus that contains all functions that handle parsed data + self.parsed_bus: set[Callable[[str], Awaitable[Any]]] = set() + + def raw_live_telemetry(self, subscriber: Callable[[str], Awaitable]): + """ + Decorator that adds a function to our bus + Throws an error if no connection was specified + + :param subscriber: Function that subscribes to raw data + :return: subscriber + """ + if not self.connection: + raise RuntimeError( + f"No connection was specified for device {self.device_id}" + ) + + self.raw_bus.add(subscriber) + return subscriber + + def live_telemetry(self, subscriber: Callable[[str], Awaitable]): + """ + Decorator that adds a function to our bus + Throws an error if no connection was specified + + :param subscriber: Function that subscribes to raw data + :return: subscriber + """ + if not self.connection: + raise RuntimeError( + f"No connection was specified for device {self.device_id}" + ) + + self.parsed_bus.add(subscriber) + return subscriber + + async def _handle_raw(self, data: str): + """ + Internal method to parse raw data and send it to a different bus + :param data: raw data that was received by the connection + :return: None + """ + await asyncio.gather(*[s(data) for s in self.raw_bus]) + # TODO: parse data + await asyncio.gather(*[s(data) for s in self.parsed_bus]) + + async def start_live_telemetry(self): + """ + Starts the connection to the server to receive live telemetry for this particular device + This function is called when we want to initialize a connection to a single device + + :return: None + """ + if self.connection: + await self.connection.connect(self._handle_raw) + else: + raise RuntimeError( + f"No connection was specified for device {self.device_id}" + ) diff --git a/scadable/facility.py b/scadable/facility.py new file mode 100644 index 0000000..c711901 --- /dev/null +++ b/scadable/facility.py @@ -0,0 +1,101 @@ +from .device import DeviceManager, Device +from .connection import ConnectionFactory + + +class Facility: + """ + A class to manage everything related to a Facility + + Instance attributes: + device_manager: DeviceManager, a default one is created if not specified + connection_factory: ConnectionFactory that is used to init long lived connections (e.g. websockets) for live telemetry + """ + + def __init__( + self, + api_key: str, + device_manager: DeviceManager | None = None, + connection_factory: ConnectionFactory | None = None, + ): + """ + Initializes the Facility Class + + :param api_key: API Key associated with the facility + :param device_manager: Optional if you want to specify your own device manager, default is created + :param connection_factory: Optional if you want to specify your own connection factory, otherwise none is specified + """ + self._api_key = api_key + + if device_manager: + self.device_manager = device_manager + else: + self.device_manager = DeviceManager() + + if connection_factory: + self.connection_factory = connection_factory + else: + self.connection_factory = None + + def create_device(self, device_id: str, create_connection=False) -> Device: + """ + Creates a device associated with the factory + + :param device_id: Device id to be created + :param create_connection: Whether to create a connection for each device to be used in live telemetry, default is False + :return: Created device + """ + if create_connection: + if self.connection_factory: + conn = self.connection_factory.create_connection( + api_key=self._api_key, device_id=device_id + ) + else: + raise RuntimeError( + "Facility was never initialized with a connection factory" + ) + else: + conn = None + + return self.device_manager.create_device(device_id, conn) + + def create_many_devices( + self, device_ids: list[str], create_connection=False + ) -> list[Device]: + """ + Creates many devices associated with the factory + + :param device_ids: List of device ids to be created + :param create_connection: Whether to create a connection for each device to be used in live telemetry, default is False + :return: List of created devices + """ + return [ + self.create_device(device_id=i, create_connection=create_connection) + for i in device_ids + ] + + def live_telemetry(self, device_ids: list[str] | str): + """ + Decorator to register a function with device id(s) + + :param device_ids: Single device (str) or multiple devices list(str) + :return: Function + """ + if isinstance(device_ids, str): + device_ids = [device_ids] + + def decorator(subscriber): + if not ( + error_device_ids := [ + i for i in device_ids if i not in self.device_manager + ] + ): + for d_id in device_ids: + self.device_manager[d_id].live_telemetry(subscriber=subscriber) + else: + raise RuntimeError( + f"Device ids: {error_device_ids} were not found in device manager" + ) + + return subscriber + + return decorator diff --git a/src/scadable/__init__.py b/src/scadable/__init__.py deleted file mode 100644 index ad35e5a..0000000 --- a/src/scadable/__init__.py +++ /dev/null @@ -1 +0,0 @@ -print("Hello World") diff --git a/tests/mock_connection.py b/tests/mock_connection.py new file mode 100644 index 0000000..71232e6 --- /dev/null +++ b/tests/mock_connection.py @@ -0,0 +1,26 @@ +from scadable.connection import ConnectionFactory, Connection + + +class TestConnectionFactory(ConnectionFactory): + __test__ = False + + def create_connection(self, api_key, device_id): + return TestConnection() + + +class TestConnection(Connection): + __test__ = False + + def __init__(self): + super().__init__() + self.handler = None + + async def connect(self, handler): + self.handler = handler + + async def send_message(self, message): + if self.handler: + await self.handler(message) + + async def stop(self): + pass diff --git a/tests/test_connection_type.py b/tests/test_connection_type.py new file mode 100644 index 0000000..873a2aa --- /dev/null +++ b/tests/test_connection_type.py @@ -0,0 +1,86 @@ +import asyncio +from websockets.asyncio import server as WeboscketServer +from scadable.connection import WebsocketConnection, WebsocketConnectionFactory +import pytest +import pytest_asyncio + + +async def echo_server(connection: WeboscketServer.ServerConnection): + async for message in connection: + await connection.send(message) + + +class BasicWebsocketServer: + def __init__(self, uri="localhost", port=8765): + self.uri = uri + self.port = port + + self.handler = echo_server + self.server: WeboscketServer.Server | None = None + + def full_uri(self): + return f"{self.uri}:{self.port}" + + async def _handle(self, connection): + if self.handler: + await self.handler(connection) + + def set_handler(self, handler): + self.handler = handler + + async def start(self): + if self.server: + await self.stop() + + self.server = await WeboscketServer.serve(self._handle, self.uri, self.port) + + async def stop(self): + if self.server: + self.server.close() + await self.server.wait_closed() + self.server = None + + +@pytest_asyncio.fixture(scope="session") +async def websocket_server(): + server = BasicWebsocketServer() + yield server + + +@pytest.mark.asyncio +async def test_ws_connection_factory(websocket_server): + factory = WebsocketConnectionFactory( + dest_uri=websocket_server.full_uri(), connection_type="ws" + ) + assert factory._dest_uri == websocket_server.full_uri() + assert factory.connection_type == "ws" + + conn = factory.create_connection("apikey", "deviceid") + assert isinstance(conn, WebsocketConnection) + assert ( + conn.dest_uri + == f"ws://{websocket_server.full_uri()}?token=apikey&deviceid=deviceid" + ) + + +@pytest.mark.asyncio +async def test_connection(websocket_server): + await websocket_server.start() + factory = WebsocketConnectionFactory( + dest_uri=websocket_server.full_uri(), connection_type="ws" + ) + conn = factory.create_connection("apikey", "deviceid") + + echo_messages = [] + + async def handler(message): + echo_messages.append(message) + await conn.stop() + + async def run(): + await asyncio.sleep(1) + await conn.send_message("test") + + await asyncio.gather(conn.connect(handler), run()) + + assert echo_messages == ["test"] diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..653e6cc --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,98 @@ +import pytest +from scadable.device import DeviceManager, Device +from .mock_connection import * + + +@pytest.fixture(scope="function") +def connection_factory(): + factory = TestConnectionFactory() + return factory + + +@pytest.fixture(scope="function") +def device_manager(): + manager = DeviceManager() + return manager + + +def test_device_manager_create_device(device_manager): + # Creates a new device + d1 = device_manager.create_device("abc", None) + assert isinstance(d1, Device) + assert len(device_manager.devices) == 1 + + # Test to see if same device id returns the same + d2 = device_manager.create_device("abc", None) + assert d1 == d2 + assert len(device_manager.devices) == 1 + + +def test_device_manager_get_device(device_manager): + d1 = device_manager.create_device("abc", None) + d2 = device_manager["abc"] + + assert d1 == d2 + + +def test_device_manager_remove_device(device_manager): + # Creates a new device + d1 = device_manager.create_device("abc", None) + + device_manager.remove_device(d1.device_id) + assert len(device_manager.devices) == 0 + + +@pytest.mark.asyncio +async def test_device_raw_telemetry(connection_factory, device_manager): + conn = connection_factory.create_connection("apikey", "abc") + device = device_manager.create_device("abc", conn) + + messages = [] + + # Basic Decorator Usage + @device.raw_live_telemetry + async def handle(m): + messages.append(m) + + await device.start_live_telemetry() + await device.connection.send_message("test") + + assert messages == ["test"] + + +@pytest.mark.asyncio +async def test_device_parsed_telemetry(connection_factory, device_manager): + conn = connection_factory.create_connection("apikey", "abc") + device = device_manager.create_device("abc", conn) + + messages = [] + + # Basic Decorator Usage + @device.live_telemetry + async def handle(m): + messages.append(m) + + await device.start_live_telemetry() + await device.connection.send_message("test") + + assert messages == ["test"] + + +@pytest.mark.asyncio +async def test_device_no_conn(device_manager): + device = device_manager.create_device("abc", None) + + messages = [] + + with pytest.raises(RuntimeError): + @device.raw_live_telemetry + async def handle(m): + messages.append(m) + + with pytest.raises(RuntimeError): + @device.live_telemetry + async def handle(m): + messages.append(m) + + with pytest.raises(RuntimeError): + await device.start_live_telemetry() diff --git a/tests/test_facility.py b/tests/test_facility.py new file mode 100644 index 0000000..65e33ad --- /dev/null +++ b/tests/test_facility.py @@ -0,0 +1,107 @@ +import pytest +import scadable +from scadable.device import DeviceManager +from .mock_connection import * + + +def test_init_default(): + facility = scadable.Facility("apikey") + + assert facility._api_key == "apikey" + assert isinstance(facility.device_manager, DeviceManager) + assert facility.connection_factory is None + + +def test_init_custom(): + dm = DeviceManager() + cf = TestConnectionFactory() + + facility = scadable.Facility("apikey1", dm, cf) + assert facility._api_key == "apikey1" + assert facility.device_manager == dm + assert facility.connection_factory == cf + + +def test_create_device_no_conn(): + facility = scadable.Facility("apikey1") + + dev = facility.create_device("abc") + + assert dev.device_id == "abc" + assert len(facility.device_manager.devices) == 1 + assert facility.device_manager["abc"] == dev + + +def test_create_device_conn(): + dm = DeviceManager() + cf = TestConnectionFactory() + + facility = scadable.Facility("apikey1", dm, cf) + dev = facility.create_device("abc", create_connection=True) + + assert dev.connection is not None + + +def test_create_device_conn_no_conn(): + facility = scadable.Facility("apikey1") + + with pytest.raises(RuntimeError): + facility.create_device("abc", create_connection=True) + + +def test_create_many_devices_no_conn(): + facility = scadable.Facility("apikey1") + + dev = facility.create_many_devices(["abc", "def"]) + + assert len(dev) == 2 + assert dev[0].device_id == "abc" + assert dev[1].device_id == "def" + assert len(facility.device_manager.devices) == 2 + + +def test_create_many_devices_conn(): + dm = DeviceManager() + cf = TestConnectionFactory() + + facility = scadable.Facility("apikey1", dm, cf) + dev = facility.create_many_devices(["mambo", "hachimi"], create_connection=True) + + assert len(dev) == 2 + assert dev[0].connection is not None + assert dev[1].connection is not None + + +def test_create_many_devices_conn_no_conn(): + facility = scadable.Facility("teio") + + with pytest.raises(RuntimeError): + facility.create_many_devices(["mambo", "hachimi"], create_connection=True) + + +def test_live_telemetry_decorator(): + dm = DeviceManager() + cf = TestConnectionFactory() + + facility = scadable.Facility(":3", dm, cf) + + facility.create_many_devices(["mambo", "hachimi"], create_connection=True) + + @facility.live_telemetry("mambo") + def handler(data): + pass + + assert len(facility.device_manager["mambo"].parsed_bus) == 1 + assert len(facility.device_manager["hachimi"].parsed_bus) == 0 + + @facility.live_telemetry(["mambo", "hachimi"]) + def handler1(data): + pass + + assert len(facility.device_manager["mambo"].parsed_bus) == 2 + assert len(facility.device_manager["hachimi"].parsed_bus) == 1 + + with pytest.raises(RuntimeError): + @facility.live_telemetry(["mambo", "hachimi", "eieimun"]) + def handler2(data): + pass diff --git a/tests/test_import.py b/tests/test_import.py index 3c50747..65cf54a 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -1,2 +1,2 @@ def test_module_import(): - import src.scadable # noqa + import scadable # noqa