ophyd-async 0.8.0a2__py3-none-any.whl → 0.8.0a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. ophyd_async/_version.py +1 -1
  2. ophyd_async/core/__init__.py +9 -1
  3. ophyd_async/core/_device.py +71 -49
  4. ophyd_async/core/_device_filler.py +208 -130
  5. ophyd_async/core/_mock_signal_backend.py +10 -7
  6. ophyd_async/core/_mock_signal_utils.py +14 -11
  7. ophyd_async/core/_readable.py +128 -129
  8. ophyd_async/core/_signal.py +22 -24
  9. ophyd_async/core/_soft_signal_backend.py +2 -0
  10. ophyd_async/core/_utils.py +64 -11
  11. ophyd_async/epics/adaravis/_aravis_io.py +1 -1
  12. ophyd_async/epics/adcore/_core_io.py +1 -1
  13. ophyd_async/epics/adcore/_single_trigger.py +6 -10
  14. ophyd_async/epics/adkinetix/_kinetix_io.py +1 -1
  15. ophyd_async/epics/adpilatus/_pilatus_io.py +1 -1
  16. ophyd_async/epics/advimba/_vimba_io.py +1 -1
  17. ophyd_async/epics/core/__init__.py +26 -0
  18. ophyd_async/epics/{signal → core}/_aioca.py +3 -6
  19. ophyd_async/epics/core/_epics_connector.py +53 -0
  20. ophyd_async/epics/core/_epics_device.py +13 -0
  21. ophyd_async/epics/{signal → core}/_p4p.py +3 -6
  22. ophyd_async/epics/core/_pvi_connector.py +91 -0
  23. ophyd_async/epics/{signal → core}/_signal.py +31 -16
  24. ophyd_async/epics/{signal/_common.py → core/_util.py} +19 -1
  25. ophyd_async/epics/demo/_mover.py +4 -5
  26. ophyd_async/epics/demo/_sensor.py +9 -12
  27. ophyd_async/epics/eiger/_eiger_io.py +1 -1
  28. ophyd_async/epics/eiger/_odin_io.py +1 -1
  29. ophyd_async/epics/motor.py +4 -5
  30. ophyd_async/epics/signal.py +11 -0
  31. ophyd_async/fastcs/core.py +2 -2
  32. ophyd_async/plan_stubs/_ensure_connected.py +2 -4
  33. ophyd_async/sim/demo/_sim_motor.py +3 -4
  34. ophyd_async/tango/base_devices/_base_device.py +48 -48
  35. ophyd_async/tango/demo/_counter.py +6 -16
  36. ophyd_async/tango/demo/_mover.py +3 -4
  37. {ophyd_async-0.8.0a2.dist-info → ophyd_async-0.8.0a4.dist-info}/METADATA +1 -1
  38. {ophyd_async-0.8.0a2.dist-info → ophyd_async-0.8.0a4.dist-info}/RECORD +42 -40
  39. {ophyd_async-0.8.0a2.dist-info → ophyd_async-0.8.0a4.dist-info}/WHEEL +1 -1
  40. ophyd_async/epics/pvi/__init__.py +0 -3
  41. ophyd_async/epics/pvi/_pvi.py +0 -73
  42. ophyd_async/epics/signal/__init__.py +0 -20
  43. {ophyd_async-0.8.0a2.dist-info → ophyd_async-0.8.0a4.dist-info}/LICENSE +0 -0
  44. {ophyd_async-0.8.0a2.dist-info → ophyd_async-0.8.0a4.dist-info}/entry_points.txt +0 -0
  45. {ophyd_async-0.8.0a2.dist-info → ophyd_async-0.8.0a4.dist-info}/top_level.txt +0 -0
@@ -1,13 +1,13 @@
1
1
  import asyncio
2
2
  from collections.abc import Callable
3
3
  from functools import cached_property
4
- from unittest.mock import AsyncMock, Mock
4
+ from unittest.mock import AsyncMock
5
5
 
6
6
  from bluesky.protocols import Descriptor, Reading
7
7
 
8
8
  from ._signal_backend import SignalBackend, SignalDatatypeT
9
9
  from ._soft_signal_backend import SoftSignalBackend
10
- from ._utils import Callback
10
+ from ._utils import Callback, LazyMock
11
11
 
12
12
 
13
13
  class MockSignalBackend(SignalBackend[SignalDatatypeT]):
@@ -16,7 +16,7 @@ class MockSignalBackend(SignalBackend[SignalDatatypeT]):
16
16
  def __init__(
17
17
  self,
18
18
  initial_backend: SignalBackend[SignalDatatypeT],
19
- mock: Mock,
19
+ mock: LazyMock,
20
20
  ) -> None:
21
21
  if isinstance(initial_backend, MockSignalBackend):
22
22
  raise ValueError("Cannot make a MockSignalBackend for a MockSignalBackend")
@@ -34,11 +34,14 @@ class MockSignalBackend(SignalBackend[SignalDatatypeT]):
34
34
 
35
35
  # use existing Mock if provided
36
36
  self.mock = mock
37
- self.put_mock = AsyncMock(name="put", spec=Callable)
38
- self.mock.attach_mock(self.put_mock, "put")
39
-
40
37
  super().__init__(datatype=self.initial_backend.datatype)
41
38
 
39
+ @cached_property
40
+ def put_mock(self) -> AsyncMock:
41
+ put_mock = AsyncMock(name="put", spec=Callable)
42
+ self.mock().attach_mock(put_mock, "put")
43
+ return put_mock
44
+
42
45
  def set_value(self, value: SignalDatatypeT):
43
46
  self.soft_backend.set_value(value)
44
47
 
@@ -46,7 +49,7 @@ class MockSignalBackend(SignalBackend[SignalDatatypeT]):
46
49
  return f"mock+{self.initial_backend.source(name, read)}"
47
50
 
48
51
  async def connect(self, timeout: float) -> None:
49
- pass
52
+ raise RuntimeError("It is not possible to connect a MockSignalBackend")
50
53
 
51
54
  @cached_property
52
55
  def put_proceeds(self) -> asyncio.Event:
@@ -2,17 +2,26 @@ from collections.abc import Awaitable, Callable, Iterable
2
2
  from contextlib import asynccontextmanager, contextmanager
3
3
  from unittest.mock import AsyncMock, Mock
4
4
 
5
- from ._device import Device, _device_mocks
5
+ from ._device import Device
6
6
  from ._mock_signal_backend import MockSignalBackend
7
- from ._signal import Signal, SignalR, _mock_signal_backends
7
+ from ._signal import Signal, SignalConnector, SignalR
8
8
  from ._soft_signal_backend import SignalDatatypeT
9
+ from ._utils import LazyMock
10
+
11
+
12
+ def get_mock(device: Device | Signal) -> Mock:
13
+ mock = device._mock # noqa: SLF001
14
+ assert isinstance(mock, LazyMock), f"Device {device} not connected in mock mode"
15
+ return mock()
9
16
 
10
17
 
11
18
  def _get_mock_signal_backend(signal: Signal) -> MockSignalBackend:
12
- assert (
13
- signal in _mock_signal_backends
19
+ connector = signal._connector # noqa: SLF001
20
+ assert isinstance(connector, SignalConnector), f"Expected Signal, got {signal}"
21
+ assert isinstance(
22
+ connector.backend, MockSignalBackend
14
23
  ), f"Signal {signal} not connected in mock mode"
15
- return _mock_signal_backends[signal]
24
+ return connector.backend
16
25
 
17
26
 
18
27
  def set_mock_value(signal: Signal[SignalDatatypeT], value: SignalDatatypeT):
@@ -45,12 +54,6 @@ def get_mock_put(signal: Signal) -> AsyncMock:
45
54
  return _get_mock_signal_backend(signal).put_mock
46
55
 
47
56
 
48
- def get_mock(device: Device | Signal) -> Mock:
49
- if isinstance(device, Signal):
50
- return _get_mock_signal_backend(device).mock
51
- return _device_mocks[device]
52
-
53
-
54
57
  def reset_mock_put_calls(signal: Signal):
55
58
  backend = _get_mock_signal_backend(signal)
56
59
  backend.put_mock.reset_mock()
@@ -1,6 +1,8 @@
1
1
  import warnings
2
- from collections.abc import Callable, Generator, Sequence
2
+ from collections.abc import Awaitable, Callable, Generator, Sequence
3
3
  from contextlib import contextmanager
4
+ from enum import Enum
5
+ from typing import Any, cast
4
6
 
5
7
  from bluesky.protocols import HasHints, Hints, Reading
6
8
  from event_model import DataKey
@@ -11,11 +13,61 @@ from ._signal import SignalR
11
13
  from ._status import AsyncStatus
12
14
  from ._utils import merge_gathered_dicts
13
15
 
14
- ReadableChild = AsyncReadable | AsyncConfigurable | AsyncStageable | HasHints
15
- ReadableChildWrapper = (
16
- Callable[[ReadableChild], ReadableChild]
17
- | type["ConfigSignal"]
18
- | type["HintedSignal"]
16
+
17
+ class StandardReadableFormat(Enum):
18
+ """Declare how a `Device` should contribute to the `StandardReadable` verbs."""
19
+
20
+ #: Detect which verbs the child supports and contribute to:
21
+ #:
22
+ #: - ``read()``, ``describe()`` if it is `bluesky.protocols.Readable`
23
+ #: - ``read_configuration()``, ``describe_configuration()`` if it is
24
+ #: `bluesky.protocols.Configurable`
25
+ #: - ``stage()``, ``unstage()`` if it is `bluesky.protocols.Stageable`
26
+ #: - ``hints`` if it `bluesky.protocols.HasHints`
27
+ CHILD = "CHILD"
28
+ #: Contribute the `Signal` value to ``read_configuration()`` and
29
+ #: ``describe_configuration()``
30
+ CONFIG_SIGNAL = "CONFIG_SIGNAL"
31
+ #: Contribute the monitored `Signal` value to ``read()`` and ``describe()``` and
32
+ #: put the signal name in ``hints``
33
+ HINTED_SIGNAL = "HINTED_SIGNAL"
34
+ #: Contribute the uncached `Signal` value to ``read()`` and ``describe()```
35
+ UNCACHED_SIGNAL = "UNCACHED_SIGNAL"
36
+ #: Contribute the uncached `Signal` value to ``read()`` and ``describe()``` and
37
+ #: put the signal name in ``hints``
38
+ HINTED_UNCACHED_SIGNAL = "HINTED_UNCACHED_SIGNAL"
39
+
40
+ def __call__(self, parent: Device, child: Device):
41
+ if not isinstance(parent, StandardReadable):
42
+ raise TypeError(f"Expected parent to be StandardReadable, got {parent}")
43
+ parent.add_readables([child], self)
44
+
45
+
46
+ # Back compat
47
+ class _WarningMatcher:
48
+ def __init__(self, name: str, target: StandardReadableFormat):
49
+ self._name = name
50
+ self._target = target
51
+
52
+ def __eq__(self, value: object) -> bool:
53
+ warnings.warn(
54
+ DeprecationWarning(
55
+ f"Use `StandardReadableFormat.{self._target.name}` "
56
+ f"instead of `{self._name}`"
57
+ ),
58
+ stacklevel=2,
59
+ )
60
+ return value == self._target
61
+
62
+
63
+ def _compat_format(name: str, target: StandardReadableFormat) -> StandardReadableFormat:
64
+ return cast(StandardReadableFormat, _WarningMatcher(name, target))
65
+
66
+
67
+ ConfigSignal = _compat_format("ConfigSignal", StandardReadableFormat.CONFIG_SIGNAL)
68
+ HintedSignal: Any = _compat_format("HintedSignal", StandardReadableFormat.HINTED_SIGNAL)
69
+ HintedSignal.uncached = _compat_format(
70
+ "HintedSignal.uncached", StandardReadableFormat.HINTED_UNCACHED_SIGNAL
19
71
  )
20
72
 
21
73
 
@@ -31,38 +83,13 @@ class StandardReadable(
31
83
 
32
84
  # These must be immutable types to avoid accidental sharing between
33
85
  # different instances of the class
34
- _readables: tuple[AsyncReadable, ...] = ()
35
- _configurables: tuple[AsyncConfigurable, ...] = ()
86
+ _describe_config_funcs: tuple[Callable[[], Awaitable[dict[str, DataKey]]], ...] = ()
87
+ _read_config_funcs: tuple[Callable[[], Awaitable[dict[str, Reading]]], ...] = ()
88
+ _describe_funcs: tuple[Callable[[], Awaitable[dict[str, DataKey]]], ...] = ()
89
+ _read_funcs: tuple[Callable[[], Awaitable[dict[str, Reading]]], ...] = ()
36
90
  _stageables: tuple[AsyncStageable, ...] = ()
37
91
  _has_hints: tuple[HasHints, ...] = ()
38
92
 
39
- def set_readable_signals(
40
- self,
41
- read: Sequence[SignalR] = (),
42
- config: Sequence[SignalR] = (),
43
- read_uncached: Sequence[SignalR] = (),
44
- ):
45
- """
46
- Parameters
47
- ----------
48
- read:
49
- Signals to make up :meth:`~StandardReadable.read`
50
- conf:
51
- Signals to make up :meth:`~StandardReadable.read_configuration`
52
- read_uncached:
53
- Signals to make up :meth:`~StandardReadable.read` that won't be cached
54
- """
55
- warnings.warn(
56
- DeprecationWarning(
57
- "Migrate to `add_children_as_readables` context manager or "
58
- "`add_readables` method"
59
- ),
60
- stacklevel=2,
61
- )
62
- self.add_readables(read, wrapper=HintedSignal)
63
- self.add_readables(config, wrapper=ConfigSignal)
64
- self.add_readables(read_uncached, wrapper=HintedSignal.uncached)
65
-
66
93
  @AsyncStatus.wrap
67
94
  async def stage(self) -> None:
68
95
  for sig in self._stageables:
@@ -75,19 +102,17 @@ class StandardReadable(
75
102
 
76
103
  async def describe_configuration(self) -> dict[str, DataKey]:
77
104
  return await merge_gathered_dicts(
78
- [sig.describe_configuration() for sig in self._configurables]
105
+ [func() for func in self._describe_config_funcs]
79
106
  )
80
107
 
81
108
  async def read_configuration(self) -> dict[str, Reading]:
82
- return await merge_gathered_dicts(
83
- [sig.read_configuration() for sig in self._configurables]
84
- )
109
+ return await merge_gathered_dicts([func() for func in self._read_config_funcs])
85
110
 
86
111
  async def describe(self) -> dict[str, DataKey]:
87
- return await merge_gathered_dicts([sig.describe() for sig in self._readables])
112
+ return await merge_gathered_dicts([func() for func in self._describe_funcs])
88
113
 
89
114
  async def read(self) -> dict[str, Reading]:
90
- return await merge_gathered_dicts([sig.read() for sig in self._readables])
115
+ return await merge_gathered_dicts([func() for func in self._read_funcs])
91
116
 
92
117
  @property
93
118
  def hints(self) -> Hints:
@@ -127,27 +152,13 @@ class StandardReadable(
127
152
  @contextmanager
128
153
  def add_children_as_readables(
129
154
  self,
130
- wrapper: ReadableChildWrapper | None = None,
155
+ format: StandardReadableFormat = StandardReadableFormat.CHILD,
131
156
  ) -> Generator[None, None, None]:
132
- """Context manager to wrap adding Devices
157
+ """Context manager that calls `add_readables` on child Devices added within.
133
158
 
134
- Add Devices to this class instance inside the Context Manager to automatically
135
- add them to the correct fields, based on the Device's interfaces.
136
-
137
- The provided wrapper class will be applied to all Devices and can be used to
138
- specify their behaviour.
139
-
140
- Parameters
141
- ----------
142
- wrapper:
143
- Wrapper class to apply to all Devices created inside the context manager.
144
-
145
- See Also
146
- --------
147
- :func:`~StandardReadable.add_readables`
148
- :class:`ConfigSignal`
149
- :class:`HintedSignal`
150
- :meth:`HintedSignal.uncached`
159
+ Scans ``self.children()`` on entry and exit to context manager, and calls
160
+ `add_readables` on any that are added with the provided
161
+ `StandardReadableFormat`.
151
162
  """
152
163
 
153
164
  dict_copy = dict(self.children())
@@ -167,95 +178,83 @@ class StandardReadable(
167
178
  flattened_values.append(value)
168
179
 
169
180
  new_devices = list(filter(lambda x: isinstance(x, Device), flattened_values))
170
- self.add_readables(new_devices, wrapper)
181
+ self.add_readables(new_devices, format)
171
182
 
172
183
  def add_readables(
173
184
  self,
174
- devices: Sequence[ReadableChild],
175
- wrapper: ReadableChildWrapper | None = None,
185
+ devices: Sequence[Device],
186
+ format: StandardReadableFormat = StandardReadableFormat.CHILD,
176
187
  ) -> None:
177
- """Add the given devices to the lists of known Devices
188
+ """Add devices to contribute to various bluesky verbs.
178
189
 
179
- Add the provided Devices to the relevant fields, based on the Signal's
180
- interfaces.
190
+ Use output from the given devices to contribute to the verbs of the following
191
+ interfaces:
181
192
 
182
- The provided wrapper class will be applied to all Devices and can be used to
183
- specify their behaviour.
193
+ - `bluesky.protocols.Readable`
194
+ - `bluesky.protocols.Configurable`
195
+ - `bluesky.protocols.Stageable`
196
+ - `bluesky.protocols.HasHints`
184
197
 
185
198
  Parameters
186
199
  ----------
187
200
  devices:
188
201
  The devices to be added
189
- wrapper:
190
- Wrapper class to apply to all Devices created inside the context manager.
191
-
192
- See Also
193
- --------
194
- :func:`~StandardReadable.add_children_as_readables`
195
- :class:`ConfigSignal`
196
- :class:`HintedSignal`
197
- :meth:`HintedSignal.uncached`
202
+ format:
203
+ Determines which of the devices functions are added to which verb as per the
204
+ `StandardReadableFormat` documentation
198
205
  """
199
206
 
200
- for readable in devices:
201
- obj = readable
202
- if wrapper:
203
- obj = wrapper(readable)
204
-
205
- if isinstance(obj, AsyncReadable):
206
- self._readables += (obj,)
207
-
208
- if isinstance(obj, AsyncConfigurable):
209
- self._configurables += (obj,)
210
-
211
- if isinstance(obj, AsyncStageable):
212
- self._stageables += (obj,)
213
-
214
- if isinstance(obj, HasHints):
215
- self._has_hints += (obj,)
216
-
217
-
218
- class ConfigSignal(AsyncConfigurable):
219
- def __init__(self, signal: ReadableChild) -> None:
220
- assert isinstance(signal, SignalR), f"Expected signal, got {signal}"
207
+ for device in devices:
208
+ match format:
209
+ case StandardReadableFormat.CHILD:
210
+ if isinstance(device, AsyncConfigurable):
211
+ self._describe_config_funcs += (device.describe_configuration,)
212
+ self._read_config_funcs += (device.read_configuration,)
213
+ if isinstance(device, AsyncReadable):
214
+ self._describe_funcs += (device.describe,)
215
+ self._read_funcs += (device.read,)
216
+ if isinstance(device, AsyncStageable):
217
+ self._stageables += (device,)
218
+ if isinstance(device, HasHints):
219
+ self._has_hints += (device,)
220
+ case StandardReadableFormat.CONFIG_SIGNAL:
221
+ assert isinstance(device, SignalR), f"{device} is not a SignalR"
222
+ self._describe_config_funcs += (device.describe,)
223
+ self._read_config_funcs += (device.read,)
224
+ case StandardReadableFormat.HINTED_SIGNAL:
225
+ assert isinstance(device, SignalR), f"{device} is not a SignalR"
226
+ self._describe_funcs += (device.describe,)
227
+ self._read_funcs += (device.read,)
228
+ self._stageables += (device,)
229
+ self._has_hints += (_HintsFromName(device),)
230
+ case StandardReadableFormat.UNCACHED_SIGNAL:
231
+ assert isinstance(device, SignalR), f"{device} is not a SignalR"
232
+ self._describe_funcs += (device.describe,)
233
+ self._read_funcs += (_UncachedRead(device),)
234
+ case StandardReadableFormat.HINTED_UNCACHED_SIGNAL:
235
+ assert isinstance(device, SignalR), f"{device} is not a SignalR"
236
+ self._describe_funcs += (device.describe,)
237
+ self._read_funcs += (_UncachedRead(device),)
238
+ self._has_hints += (_HintsFromName(device),)
239
+
240
+
241
+ class _UncachedRead:
242
+ def __init__(self, signal: SignalR) -> None:
221
243
  self.signal = signal
222
244
 
223
- async def read_configuration(self) -> dict[str, Reading]:
224
- return await self.signal.read()
225
-
226
- async def describe_configuration(self) -> dict[str, DataKey]:
227
- return await self.signal.describe()
228
-
229
- @property
230
- def name(self) -> str:
231
- return self.signal.name
245
+ async def __call__(self) -> dict[str, Reading]:
246
+ return await self.signal.read(cached=False)
232
247
 
233
248
 
234
- class HintedSignal(HasHints, AsyncReadable):
235
- def __init__(self, signal: ReadableChild, allow_cache: bool = True) -> None:
236
- assert isinstance(signal, SignalR), f"Expected signal, got {signal}"
237
- self.signal = signal
238
- self.cached = None if allow_cache else allow_cache
239
- if allow_cache:
240
- self.stage = signal.stage
241
- self.unstage = signal.unstage
242
-
243
- async def read(self) -> dict[str, Reading]:
244
- return await self.signal.read(cached=self.cached)
245
-
246
- async def describe(self) -> dict[str, DataKey]:
247
- return await self.signal.describe()
249
+ class _HintsFromName(HasHints):
250
+ def __init__(self, device: Device) -> None:
251
+ self.device = device
248
252
 
249
253
  @property
250
254
  def name(self) -> str:
251
- return self.signal.name
255
+ return self.device.name
252
256
 
253
257
  @property
254
258
  def hints(self) -> Hints:
255
- if self.signal.name == "":
256
- return {"fields": []}
257
- return {"fields": [self.signal.name]}
258
-
259
- @classmethod
260
- def uncached(cls, signal: ReadableChild) -> "HintedSignal":
261
- return cls(signal, allow_cache=False)
259
+ fields = [self.name] if self.name else []
260
+ return {"fields": fields}
@@ -4,7 +4,6 @@ import asyncio
4
4
  import functools
5
5
  from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
6
6
  from typing import Any, Generic, cast
7
- from unittest.mock import Mock
8
7
 
9
8
  from bluesky.protocols import (
10
9
  Locatable,
@@ -30,9 +29,14 @@ from ._signal_backend import (
30
29
  )
31
30
  from ._soft_signal_backend import SoftSignalBackend
32
31
  from ._status import AsyncStatus
33
- from ._utils import CALCULATE_TIMEOUT, DEFAULT_TIMEOUT, CalculatableTimeout, Callback, T
34
-
35
- _mock_signal_backends: dict[Device, MockSignalBackend] = {}
32
+ from ._utils import (
33
+ CALCULATE_TIMEOUT,
34
+ DEFAULT_TIMEOUT,
35
+ CalculatableTimeout,
36
+ Callback,
37
+ LazyMock,
38
+ T,
39
+ )
36
40
 
37
41
 
38
42
  async def _wait_for(coro: Awaitable[T], timeout: float | None, source: str) -> T:
@@ -54,26 +58,28 @@ class SignalConnector(DeviceConnector):
54
58
  def __init__(self, backend: SignalBackend):
55
59
  self.backend = self._init_backend = backend
56
60
 
57
- async def connect(
58
- self,
59
- device: Device,
60
- mock: bool | Mock,
61
- timeout: float,
62
- force_reconnect: bool,
63
- ):
64
- if mock:
65
- self.backend = MockSignalBackend(self._init_backend, mock)
66
- _mock_signal_backends[device] = self.backend
67
- else:
68
- self.backend = self._init_backend
61
+ async def connect_mock(self, device: Device, mock: LazyMock):
62
+ self.backend = MockSignalBackend(self._init_backend, mock)
63
+
64
+ async def connect_real(self, device: Device, timeout: float, force_reconnect: bool):
65
+ self.backend = self._init_backend
69
66
  device.log.debug(f"Connecting to {self.backend.source(device.name, read=True)}")
70
67
  await self.backend.connect(timeout)
71
68
 
72
69
 
70
+ class _ChildrenNotAllowed(dict[str, Device]):
71
+ def __setitem__(self, key: str, value: Device) -> None:
72
+ raise AttributeError(
73
+ f"Cannot add Device or Signal child {key}={value} of Signal, "
74
+ "make a subclass of Device instead"
75
+ )
76
+
77
+
73
78
  class Signal(Device, Generic[SignalDatatypeT]):
74
79
  """A Device with the concept of a value, with R, RW, W and X flavours"""
75
80
 
76
81
  _connector: SignalConnector
82
+ _child_devices = _ChildrenNotAllowed() # type: ignore
77
83
 
78
84
  def __init__(
79
85
  self,
@@ -89,14 +95,6 @@ class Signal(Device, Generic[SignalDatatypeT]):
89
95
  """Like ca://PV_PREFIX:SIGNAL, or "" if not set"""
90
96
  return self._connector.backend.source(self.name, read=True)
91
97
 
92
- def __setattr__(self, name: str, value: Any) -> None:
93
- if name != "parent" and isinstance(value, Device):
94
- raise AttributeError(
95
- f"Cannot add Device or Signal {value} as a child of Signal {self}, "
96
- "make a subclass of Device instead"
97
- )
98
- return super().__setattr__(name, value)
99
-
100
98
 
101
99
  class _SignalCache(Generic[SignalDatatypeT]):
102
100
  def __init__(self, backend: SignalBackend[SignalDatatypeT], signal: Signal):
@@ -4,6 +4,7 @@ import time
4
4
  from abc import abstractmethod
5
5
  from collections.abc import Sequence
6
6
  from dataclasses import dataclass
7
+ from functools import lru_cache
7
8
  from typing import Any, Generic, get_origin
8
9
 
9
10
  import numpy as np
@@ -90,6 +91,7 @@ class TableSoftConverter(SoftConverter[TableT]):
90
91
  raise TypeError(f"Cannot convert {value} to {self.datatype}")
91
92
 
92
93
 
94
+ @lru_cache
93
95
  def make_converter(datatype: type[SignalDatatype]) -> SoftConverter:
94
96
  enum_cls = get_enum_cls(datatype)
95
97
  if datatype == Sequence[str]:
@@ -5,7 +5,16 @@ import logging
5
5
  from collections.abc import Awaitable, Callable, Iterable, Sequence
6
6
  from dataclasses import dataclass
7
7
  from enum import Enum, EnumMeta
8
- from typing import Any, Generic, Literal, ParamSpec, TypeVar, get_args, get_origin
8
+ from typing import (
9
+ Any,
10
+ Generic,
11
+ Literal,
12
+ ParamSpec,
13
+ TypeVar,
14
+ get_args,
15
+ get_origin,
16
+ )
17
+ from unittest.mock import Mock
9
18
 
10
19
  import numpy as np
11
20
 
@@ -112,20 +121,29 @@ async def wait_for_connection(**coros: Awaitable[None]):
112
121
 
113
122
  Expected kwargs should be a mapping of names to coroutine tasks to execute.
114
123
  """
115
- results = await asyncio.gather(*coros.values(), return_exceptions=True)
116
- exceptions = {}
124
+ exceptions: dict[str, Exception] = {}
125
+ if len(coros) == 1:
126
+ # Single device optimization
127
+ name, coro = coros.popitem()
128
+ try:
129
+ await coro
130
+ except Exception as e:
131
+ exceptions[name] = e
132
+ else:
133
+ # Use gather to connect in parallel
134
+ results = await asyncio.gather(*coros.values(), return_exceptions=True)
135
+ for name, result in zip(coros, results, strict=False):
136
+ if isinstance(result, Exception):
137
+ exceptions[name] = result
117
138
 
118
- for name, result in zip(coros, results, strict=False):
119
- if isinstance(result, Exception):
120
- exceptions[name] = result
121
- if not isinstance(result, NotConnected):
139
+ if exceptions:
140
+ for name, exception in exceptions.items():
141
+ if not isinstance(exception, NotConnected):
122
142
  logging.exception(
123
143
  f"device `{name}` raised unexpected exception "
124
- f"{type(result).__name__}",
125
- exc_info=result,
144
+ f"{type(exception).__name__}",
145
+ exc_info=exception,
126
146
  )
127
-
128
- if exceptions:
129
147
  raise NotConnected(exceptions)
130
148
 
131
149
 
@@ -244,3 +262,38 @@ class Reference(Generic[T]):
244
262
 
245
263
  def __call__(self) -> T:
246
264
  return self._obj
265
+
266
+
267
+ class LazyMock:
268
+ """A lazily created Mock to be used when connecting in mock mode.
269
+
270
+ Creating Mocks is reasonably expensive when each Device (and Signal)
271
+ requires its own, and the tree is only used when ``Signal.set()`` is
272
+ called. This class allows a tree of lazily connected Mocks to be
273
+ constructed so that when the leaf is created, so are its parents.
274
+ Any calls to the child are then accessible from the parent mock.
275
+
276
+ >>> parent = LazyMock()
277
+ >>> child = parent.child("child")
278
+ >>> child_mock = child()
279
+ >>> child_mock() # doctest: +ELLIPSIS
280
+ <Mock name='mock.child()' id='...'>
281
+ >>> parent_mock = parent()
282
+ >>> parent_mock.mock_calls
283
+ [call.child()]
284
+ """
285
+
286
+ def __init__(self, name: str = "", parent: LazyMock | None = None) -> None:
287
+ self.parent = parent
288
+ self.name = name
289
+ self._mock: Mock | None = None
290
+
291
+ def child(self, name: str) -> LazyMock:
292
+ return LazyMock(name, self)
293
+
294
+ def __call__(self) -> Mock:
295
+ if self._mock is None:
296
+ self._mock = Mock(spec=object)
297
+ if self.parent is not None:
298
+ self.parent().attach_mock(self._mock, self.name)
299
+ return self._mock
@@ -1,6 +1,6 @@
1
1
  from ophyd_async.core import StrictEnum, SubsetEnum
2
2
  from ophyd_async.epics import adcore
3
- from ophyd_async.epics.signal import epics_signal_rw_rbv
3
+ from ophyd_async.epics.core import epics_signal_rw_rbv
4
4
 
5
5
 
6
6
  class AravisTriggerMode(StrictEnum):
@@ -1,5 +1,5 @@
1
1
  from ophyd_async.core import Device, StrictEnum
2
- from ophyd_async.epics.signal import (
2
+ from ophyd_async.epics.core import (
3
3
  epics_signal_r,
4
4
  epics_signal_rw,
5
5
  epics_signal_rw_rbv,
@@ -3,13 +3,8 @@ from collections.abc import Sequence
3
3
 
4
4
  from bluesky.protocols import Triggerable
5
5
 
6
- from ophyd_async.core import (
7
- AsyncStatus,
8
- ConfigSignal,
9
- HintedSignal,
10
- SignalR,
11
- StandardReadable,
12
- )
6
+ from ophyd_async.core import AsyncStatus, SignalR, StandardReadable
7
+ from ophyd_async.core import StandardReadableFormat as Format
13
8
 
14
9
  from ._core_io import ADBaseIO, NDPluginBaseIO
15
10
  from ._utils import ImageMode
@@ -24,14 +19,15 @@ class SingleTriggerDetector(StandardReadable, Triggerable):
24
19
  **plugins: NDPluginBaseIO,
25
20
  ) -> None:
26
21
  self.drv = drv
27
- self.__dict__.update(plugins)
22
+ for k, v in plugins.items():
23
+ setattr(self, k, v)
28
24
 
29
25
  self.add_readables(
30
26
  [self.drv.array_counter, *read_uncached],
31
- wrapper=HintedSignal.uncached,
27
+ Format.HINTED_UNCACHED_SIGNAL,
32
28
  )
33
29
 
34
- self.add_readables([self.drv.acquire_time], wrapper=ConfigSignal)
30
+ self.add_readables([self.drv.acquire_time], Format.CONFIG_SIGNAL)
35
31
 
36
32
  super().__init__(name=name)
37
33