44from concurrent .futures import Future
55import os
66from 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+ )
817from weakref import ref , ReferenceType
18+ from unittest .mock import Mock
919
1020from .exceptions import ServerNotRunningError
21+ from .utilities import class_attributes
22+ from .thing_slots import ThingSlot
1123
1224if 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,53 @@ 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 = list (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 so it isn't
307+ # garbage collected.
308+ # Note that this causes mypy to throw an `attr-defined` error as _mock
309+ # only exists in the MockThingServerInterface
310+ thing ._thing_server_interface ._mocks .append (mock ) # type: ignore[attr-defined]
311+
312+ attr .connect (thing , mocks , ...)
0 commit comments