diff --git a/src/labthings_fastapi/testing.py b/src/labthings_fastapi/testing.py new file mode 100644 index 00000000..68a886ac --- /dev/null +++ b/src/labthings_fastapi/testing.py @@ -0,0 +1,210 @@ +"""Test harnesses to help with writitng tests for things..""" + +from __future__ import annotations +from concurrent.futures import Future +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Mapping, + ParamSpec, + TypeVar, + Iterable, +) +from tempfile import TemporaryDirectory +from unittest.mock import Mock + +from .utilities import class_attributes +from .thing_slots import ThingSlot +from .thing_server_interface import ThingServerInterface + +if TYPE_CHECKING: + from .thing import Thing + +Params = ParamSpec("Params") +ReturnType = TypeVar("ReturnType") + + +class MockThingServerInterface(ThingServerInterface): + r"""A mock class that simulates a ThingServerInterface without the server. + + This allows a `.Thing` to be instantiated but not connected to a server. + The methods normally provided by the server are mocked, specifically: + + * The `name` is set by an argument to `__init__`\ . + * `start_async_task_soon` silently does nothing, i.e. the async function + will not be run. + * The settings folder will either be specified when the class is initialised, + or a temporary folder will be created. + * `get_thing_states` will return an empty dictionary. + """ + + def __init__(self, name: str, settings_folder: str | None = None) -> None: + """Initialise a ThingServerInterface. + + :param name: The name of the Thing we're providing an interface to. + :param settings_folder: The location where we should save settings. + By default, this is a temporary directory. + """ + # We deliberately don't call super().__init__(), as it won't work without + # a server. + self._name: str = name + self._settings_tempdir: TemporaryDirectory | None = None + self._settings_folder = settings_folder + self._mocks: list[Mock] = [] + + def start_async_task_soon( + self, async_function: Callable[Params, Awaitable[ReturnType]], *args: Any + ) -> Future[ReturnType]: + r"""Do nothing, as there's no event loop to use. + + This returns a `concurrent.futures.Future` object that is already cancelled, + in order to avoid accidental hangs in test code that attempts to wait for + the future object to resolve. Cancelling it may cause errors if you need + the return value. + + If you need the async code to run, it's best to add the `.Thing` to a + `lt.ThingServer` instead. Using a test client will start an event loop + in a background thread, and allow you to use a real `.ThingServerInterface` + without the overhead of actually starting an HTTP server. + + :param async_function: the asynchronous function to call. + :param \*args: positional arguments to be provided to the function. + + :returns: a `concurrent.futures.Future` object that has been cancelled. + """ + f: Future[ReturnType] = Future() + f.cancel() + return f + + @property + def settings_folder(self) -> str: + """The path to a folder where persistent files may be saved. + + This will create a temporary folder the first time it is called, + and return the same folder on subsequent calls. + + :returns: the path to a temporary folder. + """ + if self._settings_folder: + return self._settings_folder + if not self._settings_tempdir: + self._settings_tempdir = TemporaryDirectory() + return self._settings_tempdir.name + + @property + def path(self) -> str: + """The path, relative to the server's base URL, of the Thing. + + A ThingServerInterface is specific to one Thing, so this path points + to the base URL of the Thing, i.e. the Thing Description's endpoint. + """ + return f"/{self.name}/" + + def get_thing_states(self) -> Mapping[str, Any]: + """Return an empty dictionary to mock the metadata dictionary. + + :returns: an empty dictionary. + """ + return {} + + +ThingSubclass = TypeVar("ThingSubclass", bound="Thing") + + +def create_thing_without_server( + cls: type[ThingSubclass], + *args: Any, + settings_folder: str | None = None, + mock_all_slots: bool = False, + **kwargs: Any, +) -> ThingSubclass: + r"""Create a `.Thing` and supply a mock ThingServerInterface. + + This function is intended for use in testing, where it will enable a `.Thing` + to be created without a server, by supplying a `.MockThingServerInterface` + instead of a real `.ThingServerInterface`\ . + + The name of the Thing will be taken from the class name, lowercased. + + :param cls: The `.Thing` subclass to instantiate. + :param \*args: positional arguments to ``__init__``. + :param settings_folder: The path to the settings folder. A temporary folder + is used by default. + :param mock_all_slots: Set to True to create a `unittest.mock.Mock` object + connected to each thing slot. It follows the default of the specified + to the slot. So if an optional slot has a default of `None`, no mock + will be provided. + :param \**kwargs: keyword arguments to ``__init__``. + + :returns: an instance of ``cls`` with a `.MockThingServerInterface` + so that it will function without a server. + + :raises ValueError: if a keyword argument called 'thing_server_interface' + is supplied, as this would conflict with the mock interface. + """ + name = cls.__name__.lower() + if "thing_server_interface" in kwargs: + msg = "You may not supply a keyword argument called 'thing_server_interface'." + raise ValueError(msg) + + msi = MockThingServerInterface(name=name, settings_folder=settings_folder) + # Note: we must ignore misc typing errors above because mypy flags an error + # that `thing_server_interface` is multiply specified. + # This is a conflict with *args, if we had only **kwargs it would not flag + # any error. + # Given that args and kwargs are dynamically typed anyway, this does not + # lose us much. + thing = cls(*args, **kwargs, thing_server_interface=msi) # type: ignore[misc] + if mock_all_slots: + _mock_slots(thing) + return thing + + +def _mock_slots(thing: Thing) -> None: + """Mock the slots of a thing created by create_thing_without_server. + + :param thing: The thing to mock the slots of. + :raises TypeError: If this was called on a Thing with a real ThingServerInterface + """ + # Populate a mapping of mocks pretending to be the things on the server + mocks = {} + for attr_name, attr in class_attributes(thing): + if isinstance(attr, ThingSlot): + # Simply use the class of the first type that can be used. + mock_class = attr.thing_type[0] + + # The names of the mocks we need to create to make a mapping of mock + # things for the slot to connect to. + mock_names = [] + if attr.default is ...: + # if default use the name of the slot with mock + mock_names.append(f"mock-{attr_name}") + elif isinstance(attr.default, str): + mock_names.append(attr.default) + elif isinstance(attr.default, Iterable): + mock_names = list(attr.default) + # Note: If attr.default is None it will connect to None so no need for + # adding anything mapping of mocks. + + # Add mock to dictionary + for name in mock_names: + mock = Mock(spec=mock_class) + mock.name = name + mocks[name] = mock + # Store a copy of this mock in the mock server interface so it isn't + # garbage collected. + interface = thing._thing_server_interface + if isinstance(interface, MockThingServerInterface): + interface._mocks.append(mock) + else: + raise TypeError( + "Slots may not be mocked when a Thing is attached to a real " + "server." + ) + + # Finally connect the mocked slots. + for _attr_name, attr in class_attributes(thing): + if isinstance(attr, ThingSlot): + attr.connect(thing, mocks, ...) diff --git a/src/labthings_fastapi/thing_server_interface.py b/src/labthings_fastapi/thing_server_interface.py index 85f52ff3..07ee5a42 100644 --- a/src/labthings_fastapi/thing_server_interface.py +++ b/src/labthings_fastapi/thing_server_interface.py @@ -3,15 +3,21 @@ from __future__ import annotations from concurrent.futures import Future import os -from tempfile import TemporaryDirectory -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Mapping, ParamSpec, TypeVar +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Mapping, + ParamSpec, + TypeVar, +) from weakref import ref, ReferenceType from .exceptions import ServerNotRunningError if TYPE_CHECKING: from .server import ThingServer - from .thing import Thing Params = ParamSpec("Params") @@ -135,134 +141,3 @@ def get_thing_states(self) -> Mapping[str, Any]: :return: a dictionary of metadata, with the `.Thing` names as keys. """ return {k: v.thing_state for k, v in self._get_server().things.items()} - - -class MockThingServerInterface(ThingServerInterface): - r"""A mock class that simulates a ThingServerInterface without the server. - - This allows a `.Thing` to be instantiated but not connected to a server. - The methods normally provided by the server are mocked, specifically: - - * The `name` is set by an argument to `__init__`\ . - * `start_async_task_soon` silently does nothing, i.e. the async function - will not be run. - * The settings folder will either be specified when the class is initialised, - or a temporary folder will be created. - * `get_thing_states` will return an empty dictionary. - """ - - def __init__(self, name: str, settings_folder: str | None = None) -> None: - """Initialise a ThingServerInterface. - - :param name: The name of the Thing we're providing an interface to. - :param settings_folder: The location where we should save settings. - By default, this is a temporary directory. - """ - # We deliberately don't call super().__init__(), as it won't work without - # a server. - self._name: str = name - self._settings_tempdir: TemporaryDirectory | None = None - self._settings_folder = settings_folder - - def start_async_task_soon( - self, async_function: Callable[Params, Awaitable[ReturnType]], *args: Any - ) -> Future[ReturnType]: - r"""Do nothing, as there's no event loop to use. - - This returns a `concurrent.futures.Future` object that is already cancelled, - in order to avoid accidental hangs in test code that attempts to wait for - the future object to resolve. Cancelling it may cause errors if you need - the return value. - - If you need the async code to run, it's best to add the `.Thing` to a - `lt.ThingServer` instead. Using a test client will start an event loop - in a background thread, and allow you to use a real `.ThingServerInterface` - without the overhead of actually starting an HTTP server. - - :param async_function: the asynchronous function to call. - :param \*args: positional arguments to be provided to the function. - - :returns: a `concurrent.futures.Future` object that has been cancelled. - """ - f: Future[ReturnType] = Future() - f.cancel() - return f - - @property - def settings_folder(self) -> str: - """The path to a folder where persistent files may be saved. - - This will create a temporary folder the first time it is called, - and return the same folder on subsequent calls. - - :returns: the path to a temporary folder. - """ - if self._settings_folder: - return self._settings_folder - if not self._settings_tempdir: - self._settings_tempdir = TemporaryDirectory() - return self._settings_tempdir.name - - @property - def path(self) -> str: - """The path, relative to the server's base URL, of the Thing. - - A ThingServerInterface is specific to one Thing, so this path points - to the base URL of the Thing, i.e. the Thing Description's endpoint. - """ - return f"/{self.name}/" - - def get_thing_states(self) -> Mapping[str, Any]: - """Return an empty dictionary to mock the metadata dictionary. - - :returns: an empty dictionary. - """ - return {} - - -ThingSubclass = TypeVar("ThingSubclass", bound="Thing") - - -def create_thing_without_server( - cls: type[ThingSubclass], - *args: Any, - settings_folder: str | None = None, - **kwargs: Any, -) -> ThingSubclass: - r"""Create a `.Thing` and supply a mock ThingServerInterface. - - This function is intended for use in testing, where it will enable a `.Thing` - to be created without a server, by supplying a `.MockThingServerInterface` - instead of a real `.ThingServerInterface`\ . - - The name of the Thing will be taken from the class name, lowercased. - - :param cls: The `.Thing` subclass to instantiate. - :param \*args: positional arguments to ``__init__``. - :param settings_folder: The path to the settings folder. A temporary folder - is used by default. - :param \**kwargs: keyword arguments to ``__init__``. - - :returns: an instance of ``cls`` with a `.MockThingServerInterface` - so that it will function without a server. - - :raises ValueError: if a keyword argument called 'thing_server_interface' - is supplied, as this would conflict with the mock interface. - """ - name = cls.__name__.lower() - if "thing_server_interface" in kwargs: - msg = "You may not supply a keyword argument called 'thing_server_interface'." - raise ValueError(msg) - return cls( - *args, - **kwargs, - thing_server_interface=MockThingServerInterface( - name=name, settings_folder=settings_folder - ), - ) # type: ignore[misc] - # Note: we must ignore misc typing errors above because mypy flags an error - # that `thing_server_interface` is multiply specified. - # This is a conflict with *args, if we had only **kwargs it would not flag - # any error. - # Given that args and kwargs are dynamically typed anyway, this does not - # lose us much. diff --git a/tests/old_dependency_tests/test_directthingclient.py b/tests/old_dependency_tests/test_directthingclient.py index 6cc2aa91..e68d6711 100644 --- a/tests/old_dependency_tests/test_directthingclient.py +++ b/tests/old_dependency_tests/test_directthingclient.py @@ -8,7 +8,7 @@ import pytest import labthings_fastapi as lt from labthings_fastapi.deps import DirectThingClient, direct_thing_client_class -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server from ..temp_client import poll_task diff --git a/tests/test_actions.py b/tests/test_actions.py index 5a788b2f..3f434ced 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -3,7 +3,7 @@ import pytest import functools -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server from .temp_client import poll_task, get_link from labthings_fastapi.example_things import MyThing import labthings_fastapi as lt diff --git a/tests/test_blob_output.py b/tests/test_blob_output.py index ae955320..b4638ce8 100644 --- a/tests/test_blob_output.py +++ b/tests/test_blob_output.py @@ -8,7 +8,7 @@ from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server class TextBlob(lt.blob.Blob): diff --git a/tests/test_example_thing.py b/tests/test_example_thing.py index 1598c88c..ca63ad3d 100644 --- a/tests/test_example_thing.py +++ b/tests/test_example_thing.py @@ -6,7 +6,7 @@ ) import pytest -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server def test_mything(): diff --git a/tests/test_locking_decorator.py b/tests/test_locking_decorator.py index c2566774..7caec3a8 100644 --- a/tests/test_locking_decorator.py +++ b/tests/test_locking_decorator.py @@ -7,7 +7,7 @@ import pytest import labthings_fastapi as lt -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server from .temp_client import poll_task diff --git a/tests/test_logs.py b/tests/test_logs.py index a366776c..e2163ca3 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -17,7 +17,7 @@ ) import labthings_fastapi as lt from labthings_fastapi.exceptions import LogConfigurationError -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server class ThingThatLogs(lt.Thing): diff --git a/tests/test_numpy_type.py b/tests/test_numpy_type.py index 10fb5bf8..5179590f 100644 --- a/tests/test_numpy_type.py +++ b/tests/test_numpy_type.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, RootModel import numpy as np -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server from labthings_fastapi.types.numpy import NDArray, DenumpifyingDict import labthings_fastapi as lt diff --git a/tests/test_settings.py b/tests/test_settings.py index aea1520e..10df42f4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient import labthings_fastapi as lt -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server from .temp_client import poll_task diff --git a/tests/test_thing.py b/tests/test_thing.py index 988a0648..514b5090 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -1,6 +1,6 @@ from labthings_fastapi.example_things import MyThing from labthings_fastapi import ThingServer -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server def test_td_validates(): diff --git a/tests/test_thing_server_interface.py b/tests/test_thing_server_interface.py index 899ed48a..fc2765ef 100644 --- a/tests/test_thing_server_interface.py +++ b/tests/test_thing_server_interface.py @@ -3,20 +3,23 @@ import gc import os import tempfile +from typing import Mapping +from unittest.mock import Mock from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt -from labthings_fastapi.exceptions import ServerNotRunningError +from labthings_fastapi.exceptions import ServerNotRunningError, ThingNotConnectedError from labthings_fastapi.thing_server_interface import ( - MockThingServerInterface, ThingServerInterface, ThingServerMissingError, +) +from labthings_fastapi.testing import ( + MockThingServerInterface, create_thing_without_server, ) - NAME = "testname" EXAMPLE_THING_STATE = {"foo": "bar"} @@ -27,6 +30,41 @@ def thing_state(self): return EXAMPLE_THING_STATE +class DifferentExampleThing(lt.Thing): + pass + + +class AnotherExampleThing(lt.Thing): + pass + + +class UnusedExampleThing(lt.Thing): + pass + + +class GroupedThing(lt.Thing): + pass + + +class DifferentGroupedThing(lt.Thing): + pass + + +DIF_EXAMPLE_NAME = "diffy" +DIF_GROUPED_NAMES = ["snap", "crackle", "pop"] + + +class ExampleWithSlots(lt.Thing): + example: ExampleThing = lt.thing_slot() + dif_example: DifferentExampleThing = lt.thing_slot("diffy") + optionally_another_example: AnotherExampleThing | None = lt.thing_slot() + unused_option: UnusedExampleThing | None = lt.thing_slot(None) + grouped_things: Mapping[str, GroupedThing] = lt.thing_slot() + dif_grouped_things: Mapping[str, DifferentGroupedThing] = lt.thing_slot( + DIF_GROUPED_NAMES + ) + + @pytest.fixture def server(): """Return a LabThings server""" @@ -184,3 +222,59 @@ def test_create_thing_without_server(): # We can't supply the interface as a kwarg with pytest.raises(ValueError, match="may not supply"): create_thing_without_server(ExampleThing, thing_server_interface=None) + + +def test_not_mocking_slots(): + """Check slots are not mocked by default.""" + slotty = create_thing_without_server(ExampleWithSlots) + + with pytest.raises(ThingNotConnectedError): + _ = slotty.example + with pytest.raises(ThingNotConnectedError): + _ = slotty.dif_example + with pytest.raises(ThingNotConnectedError): + _ = slotty.optionally_another_example + with pytest.raises(ThingNotConnectedError): + _ = slotty.unused_option + with pytest.raises(ThingNotConnectedError): + _ = slotty.grouped_things + with pytest.raises(ThingNotConnectedError): + _ = slotty.dif_grouped_things + + +def test_mocking_slots(): + """Check the type of things and thing connections is correctly determined.""" + slotty = create_thing_without_server(ExampleWithSlots, mock_all_slots=True) + + # example is a mock pretending to be an ExampleThing + assert isinstance(slotty.example, ExampleThing) + assert isinstance(slotty.example, Mock) + + # dif_example is a mock pretending to be a DifferentExampleThing, its name is set. + assert isinstance(slotty.dif_example, DifferentExampleThing) + assert isinstance(slotty.dif_example, Mock) + assert slotty.dif_example.name == DIF_EXAMPLE_NAME + + # optionally_another_example, was optional but should be a mock pretending to be + # a AnotherExampleThing + assert isinstance(slotty.optionally_another_example, AnotherExampleThing) + assert isinstance(slotty.optionally_another_example, Mock) + + # unused_option was an optional slot, but defaults to None. So should be None + assert slotty.unused_option is None + + # The grouped_things should be a mapping + assert isinstance(slotty.grouped_things, Mapping) + for thing in slotty.grouped_things.values(): + # All of the things should be mocks pretenting to be a GroupedThing + assert isinstance(thing, GroupedThing) + # No default so only one was created + assert len(slotty.grouped_things) == 1 + + # The dif_grouped_things should be a mapping + assert isinstance(slotty.dif_grouped_things, Mapping) + # The keys should be set from DIF_GROUPED_NAMES + assert set(DIF_GROUPED_NAMES) == set(slotty.dif_grouped_things.keys()) + # These should also be the thing names + grouped_thing_names = {i.name for i in slotty.dif_grouped_things.values()} + assert set(DIF_GROUPED_NAMES) == grouped_thing_names diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 41e5ee38..92284c9f 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -5,7 +5,7 @@ PropertyNotObservableError, InvocationCancelledError, ) -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server class ThingWithProperties(lt.Thing): diff --git a/typing_tests/thing_definitions.py b/typing_tests/thing_definitions.py index 01c4c27a..fba5c394 100644 --- a/typing_tests/thing_definitions.py +++ b/typing_tests/thing_definitions.py @@ -26,7 +26,7 @@ from typing_extensions import assert_type import typing -from labthings_fastapi.thing_server_interface import create_thing_without_server +from labthings_fastapi.testing import create_thing_without_server def optional_int_factory() -> int | None: