Skip to content

Commit 48b1aaa

Browse files
Add a mock_all_slots option to create_thing_without_server
1 parent c9bfd2e commit 48b1aaa

File tree

1 file changed

+61
-8
lines changed

1 file changed

+61
-8
lines changed

src/labthings_fastapi/thing_server_interface.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,22 @@
44
from concurrent.futures import Future
55
import os
66
from tempfile import TemporaryDirectory
7-
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Mapping, ParamSpec, TypeVar
7+
from typing import (
8+
TYPE_CHECKING,
9+
Any,
10+
Awaitable,
11+
Callable,
12+
Mapping,
13+
ParamSpec,
14+
TypeVar,
15+
Iterable,
16+
)
817
from weakref import ref, ReferenceType
18+
from unittest.mock import Mock
919

1020
from .exceptions import ServerNotRunningError
21+
from .utilities import class_attributes
22+
from .thing_slots import ThingSlot
1123

1224
if TYPE_CHECKING:
1325
from .server import ThingServer
@@ -152,6 +164,7 @@ def __init__(self, name: str, settings_folder: str | None = None) -> None:
152164
self._name: str = name
153165
self._settings_tempdir: TemporaryDirectory | None = None
154166
self._settings_folder = settings_folder
167+
self._mocks: list[Mock] = []
155168

156169
def start_async_task_soon(
157170
self, async_function: Callable[Params, Awaitable[ReturnType]], *args: Any
@@ -216,6 +229,7 @@ def create_thing_without_server(
216229
cls: type[ThingSubclass],
217230
*args: Any,
218231
settings_folder: str | None = None,
232+
mock_all_slots: bool = False,
219233
**kwargs: Any,
220234
) -> ThingSubclass:
221235
r"""Create a `.Thing` and supply a mock ThingServerInterface.
@@ -230,6 +244,10 @@ def create_thing_without_server(
230244
:param \*args: positional arguments to ``__init__``.
231245
:param settings_folder: The path to the settings folder. A temporary folder
232246
is used by default.
247+
:param mock_all_slots: Set to True to create a `unittest.mock.Mock` object
248+
connected to each thing slot. It follows the default of the specified
249+
to the slot. So if an optional slot has a default of `None`, no mock
250+
will be provided.
233251
:param \**kwargs: keyword arguments to ``__init__``.
234252
235253
:returns: an instance of ``cls`` with a `.MockThingServerInterface`
@@ -242,16 +260,51 @@ def create_thing_without_server(
242260
if "thing_server_interface" in kwargs:
243261
msg = "You may not supply a keyword argument called 'thing_server_interface'."
244262
raise ValueError(msg)
245-
return cls(
246-
*args,
247-
**kwargs,
248-
thing_server_interface=MockThingServerInterface(
249-
name=name, settings_folder=settings_folder
250-
),
251-
) # type: ignore[misc]
263+
264+
msi = MockThingServerInterface(name=name, settings_folder=settings_folder)
252265
# Note: we must ignore misc typing errors above because mypy flags an error
253266
# that `thing_server_interface` is multiply specified.
254267
# This is a conflict with *args, if we had only **kwargs it would not flag
255268
# any error.
256269
# Given that args and kwargs are dynamically typed anyway, this does not
257270
# lose us much.
271+
thing = cls(*args, **kwargs, thing_server_interface=msi) # type: ignore[misc]
272+
if mock_all_slots:
273+
_mock_slots(thing)
274+
return thing
275+
276+
277+
def _mock_slots(thing: Thing) -> None:
278+
"""Mock the slots of a thing created by create_thing_without_server.
279+
280+
:param thing: The thing to mock the slots of
281+
"""
282+
for attr_name, attr in class_attributes(thing):
283+
if isinstance(attr, ThingSlot):
284+
# Simply use the class of the first type that can be used.
285+
mock_class = attr.thing_type[0]
286+
287+
# The names of the mocks we need to create to make a mapping of mock
288+
# things for the slot to connect to.
289+
mock_names = []
290+
if attr.default is ...:
291+
# if default use the name of the slot with mock
292+
mock_names.append(f"mock-{attr_name}")
293+
elif isinstance(attr.default, str):
294+
mock_names.append(attr.default)
295+
elif isinstance(attr.default, Iterable):
296+
mock_names = attr.default
297+
# Note: If attr.default is None it will connect to None so no need for
298+
# adding anything mapping of mocks.
299+
300+
# Populate a mapping of mocks pretending to be the things on the server
301+
mocks = {}
302+
for name in mock_names:
303+
mock = Mock(spec=mock_class)
304+
mock.name = name
305+
mocks[name] = mock
306+
# Store a copy of this mock in the mock server interface
307+
# so it isn't garbage collected.
308+
thing._thing_server_interface._mocks.append(mock)
309+
310+
attr.connect(thing, mocks, ...)

0 commit comments

Comments
 (0)