From 6c31b7b33c9e3d087165e129584fe9df6dce57c4 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 4 Nov 2025 16:37:15 +0000 Subject: [PATCH 1/5] Add a mock_all_slots option to create_thing_without_server --- .../thing_server_interface.py | 71 ++++++++++++++++--- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/src/labthings_fastapi/thing_server_interface.py b/src/labthings_fastapi/thing_server_interface.py index 85f52ff3..d57c0172 100644 --- a/src/labthings_fastapi/thing_server_interface.py +++ b/src/labthings_fastapi/thing_server_interface.py @@ -4,10 +4,22 @@ 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, + Iterable, +) from weakref import ref, ReferenceType +from unittest.mock import Mock from .exceptions import ServerNotRunningError +from .utilities import class_attributes +from .thing_slots import ThingSlot if TYPE_CHECKING: from .server import ThingServer @@ -163,6 +175,7 @@ def __init__(self, name: str, settings_folder: str | None = None) -> None: 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 @@ -227,6 +240,7 @@ 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. @@ -241,6 +255,10 @@ def create_thing_without_server( :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` @@ -253,16 +271,53 @@ def create_thing_without_server( 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] + + 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 + """ + 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. + + # Populate a mapping of mocks pretending to be the things on the server + mocks = {} + 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. + # Note that this causes mypy to throw an `attr-defined` error as _mock + # only exists in the MockThingServerInterface + thing._thing_server_interface._mocks.append(mock) # type: ignore[attr-defined] + + attr.connect(thing, mocks, ...) From fa9d3817fff7fd1e537b769ad6d5a53b0404a6a8 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 4 Nov 2025 18:28:28 +0000 Subject: [PATCH 2/5] Add tests for mocking slots --- tests/test_thing_server_interface.py | 93 +++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/tests/test_thing_server_interface.py b/tests/test_thing_server_interface.py index 899ed48a..30d885fd 100644 --- a/tests/test_thing_server_interface.py +++ b/tests/test_thing_server_interface.py @@ -3,12 +3,14 @@ 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, @@ -27,6 +29,39 @@ 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: DifferentExampleThing | 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, GroupedThing] = lt.thing_slot(DIF_GROUPED_NAMES) + + @pytest.fixture def server(): """Return a LabThings server""" @@ -184,3 +219,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 DifferentExampleThing + assert isinstance(slotty.optionally_another_example, DifferentExampleThing) + 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 From 25dab2af521af842db78bde380f8e8ab36ebee19 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 18 Nov 2025 12:45:40 +0000 Subject: [PATCH 3/5] Move MockThingServerInterface and associated fundtions to testing submidule --- src/labthings_fastapi/testing.py | 200 ++++++++++++++++++ .../thing_server_interface.py | 180 ---------------- .../test_directthingclient.py | 2 +- tests/test_actions.py | 2 +- tests/test_blob_output.py | 2 +- tests/test_example_thing.py | 2 +- tests/test_locking_decorator.py | 2 +- tests/test_logs.py | 2 +- tests/test_numpy_type.py | 2 +- tests/test_settings.py | 2 +- tests/test_thing.py | 2 +- tests/test_thing_server_interface.py | 5 +- tests/test_websocket.py | 2 +- typing_tests/thing_definitions.py | 2 +- 14 files changed, 214 insertions(+), 193 deletions(-) create mode 100644 src/labthings_fastapi/testing.py diff --git a/src/labthings_fastapi/testing.py b/src/labthings_fastapi/testing.py new file mode 100644 index 00000000..ea754a63 --- /dev/null +++ b/src/labthings_fastapi/testing.py @@ -0,0 +1,200 @@ +"""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 + """ + 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. + + # Populate a mapping of mocks pretending to be the things on the server + mocks = {} + 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. + # Note that this causes mypy to throw an `attr-defined` error as _mock + # only exists in the MockThingServerInterface + thing._thing_server_interface._mocks.append(mock) # type: ignore[attr-defined] + + attr.connect(thing, mocks, ...) diff --git a/src/labthings_fastapi/thing_server_interface.py b/src/labthings_fastapi/thing_server_interface.py index d57c0172..07ee5a42 100644 --- a/src/labthings_fastapi/thing_server_interface.py +++ b/src/labthings_fastapi/thing_server_interface.py @@ -3,7 +3,6 @@ from __future__ import annotations from concurrent.futures import Future import os -from tempfile import TemporaryDirectory from typing import ( TYPE_CHECKING, Any, @@ -12,18 +11,13 @@ Mapping, ParamSpec, TypeVar, - Iterable, ) from weakref import ref, ReferenceType -from unittest.mock import Mock from .exceptions import ServerNotRunningError -from .utilities import class_attributes -from .thing_slots import ThingSlot if TYPE_CHECKING: from .server import ThingServer - from .thing import Thing Params = ParamSpec("Params") @@ -147,177 +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 - 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 - """ - 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. - - # Populate a mapping of mocks pretending to be the things on the server - mocks = {} - 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. - # Note that this causes mypy to throw an `attr-defined` error as _mock - # only exists in the MockThingServerInterface - thing._thing_server_interface._mocks.append(mock) # type: ignore[attr-defined] - - attr.connect(thing, mocks, ...) 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 30d885fd..69ff928c 100644 --- a/tests/test_thing_server_interface.py +++ b/tests/test_thing_server_interface.py @@ -12,13 +12,14 @@ import labthings_fastapi as lt 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"} 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: From 37e80716074af8e8af270f1646148a97f36483e2 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 18 Nov 2025 12:59:40 +0000 Subject: [PATCH 4/5] Add in suggested changes from code review --- src/labthings_fastapi/testing.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/labthings_fastapi/testing.py b/src/labthings_fastapi/testing.py index ea754a63..fac07ea9 100644 --- a/src/labthings_fastapi/testing.py +++ b/src/labthings_fastapi/testing.py @@ -165,7 +165,8 @@ def create_thing_without_server( 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 + :param thing: The thing to mock the slots of. + :raises TypeError: If this was called on a Thing with a real ThingServerInterface """ for attr_name, attr in class_attributes(thing): if isinstance(attr, ThingSlot): @@ -193,8 +194,16 @@ def _mock_slots(thing: Thing) -> None: mocks[name] = mock # Store a copy of this mock in the mock server interface so it isn't # garbage collected. - # Note that this causes mypy to throw an `attr-defined` error as _mock - # only exists in the MockThingServerInterface - thing._thing_server_interface._mocks.append(mock) # type: ignore[attr-defined] - + 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 in class_attributes(thing): + if isinstance(attr, ThingSlot): attr.connect(thing, mocks, ...) From f76d2d33db2048006e587fa19c1808f7efbc4cc5 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 18 Nov 2025 13:28:31 +0000 Subject: [PATCH 5/5] Fix mocking slots to pass all mocked things when connecting, and fix test --- src/labthings_fastapi/testing.py | 7 ++++--- tests/test_thing_server_interface.py | 10 ++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/labthings_fastapi/testing.py b/src/labthings_fastapi/testing.py index fac07ea9..68a886ac 100644 --- a/src/labthings_fastapi/testing.py +++ b/src/labthings_fastapi/testing.py @@ -168,6 +168,8 @@ def _mock_slots(thing: Thing) -> None: :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. @@ -186,8 +188,7 @@ def _mock_slots(thing: Thing) -> None: # Note: If attr.default is None it will connect to None so no need for # adding anything mapping of mocks. - # Populate a mapping of mocks pretending to be the things on the server - mocks = {} + # Add mock to dictionary for name in mock_names: mock = Mock(spec=mock_class) mock.name = name @@ -204,6 +205,6 @@ def _mock_slots(thing: Thing) -> None: ) # Finally connect the mocked slots. - for attr in class_attributes(thing): + for _attr_name, attr in class_attributes(thing): if isinstance(attr, ThingSlot): attr.connect(thing, mocks, ...) diff --git a/tests/test_thing_server_interface.py b/tests/test_thing_server_interface.py index 69ff928c..fc2765ef 100644 --- a/tests/test_thing_server_interface.py +++ b/tests/test_thing_server_interface.py @@ -57,10 +57,12 @@ class DifferentGroupedThing(lt.Thing): class ExampleWithSlots(lt.Thing): example: ExampleThing = lt.thing_slot() dif_example: DifferentExampleThing = lt.thing_slot("diffy") - optionally_another_example: DifferentExampleThing | None = lt.thing_slot() + 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, GroupedThing] = lt.thing_slot(DIF_GROUPED_NAMES) + dif_grouped_things: Mapping[str, DifferentGroupedThing] = lt.thing_slot( + DIF_GROUPED_NAMES + ) @pytest.fixture @@ -254,8 +256,8 @@ def test_mocking_slots(): assert slotty.dif_example.name == DIF_EXAMPLE_NAME # optionally_another_example, was optional but should be a mock pretending to be - # a DifferentExampleThing - assert isinstance(slotty.optionally_another_example, DifferentExampleThing) + # 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