ophyd-async 0.9.0a1__py3-none-any.whl → 0.10.0a1__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 (157) hide show
  1. ophyd_async/__init__.py +5 -8
  2. ophyd_async/_docs_parser.py +12 -0
  3. ophyd_async/_version.py +9 -4
  4. ophyd_async/core/__init__.py +102 -74
  5. ophyd_async/core/_derived_signal.py +271 -0
  6. ophyd_async/core/_derived_signal_backend.py +300 -0
  7. ophyd_async/core/_detector.py +158 -153
  8. ophyd_async/core/_device.py +143 -115
  9. ophyd_async/core/_device_filler.py +82 -9
  10. ophyd_async/core/_flyer.py +16 -7
  11. ophyd_async/core/_hdf_dataset.py +29 -22
  12. ophyd_async/core/_log.py +14 -23
  13. ophyd_async/core/_mock_signal_backend.py +11 -3
  14. ophyd_async/core/_protocol.py +65 -45
  15. ophyd_async/core/_providers.py +28 -9
  16. ophyd_async/core/_readable.py +74 -58
  17. ophyd_async/core/_settings.py +113 -0
  18. ophyd_async/core/_signal.py +304 -174
  19. ophyd_async/core/_signal_backend.py +60 -14
  20. ophyd_async/core/_soft_signal_backend.py +18 -12
  21. ophyd_async/core/_status.py +72 -24
  22. ophyd_async/core/_table.py +54 -17
  23. ophyd_async/core/_utils.py +101 -52
  24. ophyd_async/core/_yaml_settings.py +66 -0
  25. ophyd_async/epics/__init__.py +1 -0
  26. ophyd_async/epics/adandor/__init__.py +9 -0
  27. ophyd_async/epics/adandor/_andor.py +45 -0
  28. ophyd_async/epics/adandor/_andor_controller.py +51 -0
  29. ophyd_async/epics/adandor/_andor_io.py +34 -0
  30. ophyd_async/epics/adaravis/__init__.py +8 -1
  31. ophyd_async/epics/adaravis/_aravis.py +23 -41
  32. ophyd_async/epics/adaravis/_aravis_controller.py +23 -55
  33. ophyd_async/epics/adaravis/_aravis_io.py +13 -28
  34. ophyd_async/epics/adcore/__init__.py +36 -14
  35. ophyd_async/epics/adcore/_core_detector.py +81 -0
  36. ophyd_async/epics/adcore/_core_io.py +145 -95
  37. ophyd_async/epics/adcore/_core_logic.py +179 -88
  38. ophyd_async/epics/adcore/_core_writer.py +223 -0
  39. ophyd_async/epics/adcore/_hdf_writer.py +51 -92
  40. ophyd_async/epics/adcore/_jpeg_writer.py +26 -0
  41. ophyd_async/epics/adcore/_single_trigger.py +6 -5
  42. ophyd_async/epics/adcore/_tiff_writer.py +26 -0
  43. ophyd_async/epics/adcore/_utils.py +3 -2
  44. ophyd_async/epics/adkinetix/__init__.py +2 -1
  45. ophyd_async/epics/adkinetix/_kinetix.py +32 -27
  46. ophyd_async/epics/adkinetix/_kinetix_controller.py +11 -21
  47. ophyd_async/epics/adkinetix/_kinetix_io.py +12 -13
  48. ophyd_async/epics/adpilatus/__init__.py +7 -2
  49. ophyd_async/epics/adpilatus/_pilatus.py +28 -40
  50. ophyd_async/epics/adpilatus/_pilatus_controller.py +25 -22
  51. ophyd_async/epics/adpilatus/_pilatus_io.py +11 -9
  52. ophyd_async/epics/adsimdetector/__init__.py +8 -1
  53. ophyd_async/epics/adsimdetector/_sim.py +22 -16
  54. ophyd_async/epics/adsimdetector/_sim_controller.py +9 -43
  55. ophyd_async/epics/adsimdetector/_sim_io.py +10 -0
  56. ophyd_async/epics/advimba/__init__.py +10 -1
  57. ophyd_async/epics/advimba/_vimba.py +26 -25
  58. ophyd_async/epics/advimba/_vimba_controller.py +12 -24
  59. ophyd_async/epics/advimba/_vimba_io.py +23 -28
  60. ophyd_async/epics/core/_aioca.py +66 -30
  61. ophyd_async/epics/core/_epics_connector.py +4 -0
  62. ophyd_async/epics/core/_epics_device.py +2 -0
  63. ophyd_async/epics/core/_p4p.py +50 -18
  64. ophyd_async/epics/core/_pvi_connector.py +65 -8
  65. ophyd_async/epics/core/_signal.py +51 -51
  66. ophyd_async/epics/core/_util.py +5 -5
  67. ophyd_async/epics/demo/__init__.py +11 -49
  68. ophyd_async/epics/demo/__main__.py +31 -0
  69. ophyd_async/epics/demo/_ioc.py +32 -0
  70. ophyd_async/epics/demo/_motor.py +82 -0
  71. ophyd_async/epics/demo/_point_detector.py +42 -0
  72. ophyd_async/epics/demo/_point_detector_channel.py +22 -0
  73. ophyd_async/epics/demo/_stage.py +15 -0
  74. ophyd_async/epics/demo/{mover.db → motor.db} +2 -1
  75. ophyd_async/epics/demo/point_detector.db +59 -0
  76. ophyd_async/epics/demo/point_detector_channel.db +21 -0
  77. ophyd_async/epics/eiger/_eiger.py +1 -3
  78. ophyd_async/epics/eiger/_eiger_controller.py +11 -4
  79. ophyd_async/epics/eiger/_eiger_io.py +2 -0
  80. ophyd_async/epics/eiger/_odin_io.py +1 -2
  81. ophyd_async/epics/motor.py +83 -38
  82. ophyd_async/epics/signal.py +4 -1
  83. ophyd_async/epics/testing/__init__.py +14 -14
  84. ophyd_async/epics/testing/_example_ioc.py +68 -73
  85. ophyd_async/epics/testing/_utils.py +19 -44
  86. ophyd_async/epics/testing/test_records.db +16 -0
  87. ophyd_async/epics/testing/test_records_pva.db +17 -16
  88. ophyd_async/fastcs/__init__.py +1 -0
  89. ophyd_async/fastcs/core.py +6 -0
  90. ophyd_async/fastcs/odin/__init__.py +1 -0
  91. ophyd_async/fastcs/panda/__init__.py +8 -8
  92. ophyd_async/fastcs/panda/_block.py +29 -9
  93. ophyd_async/fastcs/panda/_control.py +12 -2
  94. ophyd_async/fastcs/panda/_hdf_panda.py +5 -1
  95. ophyd_async/fastcs/panda/_table.py +13 -7
  96. ophyd_async/fastcs/panda/_trigger.py +23 -9
  97. ophyd_async/fastcs/panda/_writer.py +27 -30
  98. ophyd_async/plan_stubs/__init__.py +16 -0
  99. ophyd_async/plan_stubs/_ensure_connected.py +12 -17
  100. ophyd_async/plan_stubs/_fly.py +3 -5
  101. ophyd_async/plan_stubs/_nd_attributes.py +9 -5
  102. ophyd_async/plan_stubs/_panda.py +14 -0
  103. ophyd_async/plan_stubs/_settings.py +152 -0
  104. ophyd_async/plan_stubs/_utils.py +3 -0
  105. ophyd_async/plan_stubs/_wait_for_awaitable.py +13 -0
  106. ophyd_async/sim/__init__.py +29 -0
  107. ophyd_async/sim/__main__.py +43 -0
  108. ophyd_async/sim/_blob_detector.py +33 -0
  109. ophyd_async/sim/_blob_detector_controller.py +48 -0
  110. ophyd_async/sim/_blob_detector_writer.py +105 -0
  111. ophyd_async/sim/_mirror_horizontal.py +46 -0
  112. ophyd_async/sim/_mirror_vertical.py +74 -0
  113. ophyd_async/sim/_motor.py +233 -0
  114. ophyd_async/sim/_pattern_generator.py +124 -0
  115. ophyd_async/sim/_point_detector.py +86 -0
  116. ophyd_async/sim/_stage.py +19 -0
  117. ophyd_async/tango/__init__.py +1 -0
  118. ophyd_async/tango/core/__init__.py +6 -1
  119. ophyd_async/tango/core/_base_device.py +41 -33
  120. ophyd_async/tango/core/_converters.py +81 -0
  121. ophyd_async/tango/core/_signal.py +21 -33
  122. ophyd_async/tango/core/_tango_readable.py +2 -19
  123. ophyd_async/tango/core/_tango_transport.py +148 -74
  124. ophyd_async/tango/core/_utils.py +47 -0
  125. ophyd_async/tango/demo/_counter.py +2 -0
  126. ophyd_async/tango/demo/_detector.py +2 -0
  127. ophyd_async/tango/demo/_mover.py +10 -6
  128. ophyd_async/tango/demo/_tango/_servers.py +4 -0
  129. ophyd_async/tango/testing/__init__.py +6 -0
  130. ophyd_async/tango/testing/_one_of_everything.py +200 -0
  131. ophyd_async/testing/__init__.py +48 -7
  132. ophyd_async/testing/__pytest_assert_rewrite.py +4 -0
  133. ophyd_async/testing/_assert.py +200 -96
  134. ophyd_async/testing/_mock_signal_utils.py +59 -73
  135. ophyd_async/testing/_one_of_everything.py +146 -0
  136. ophyd_async/testing/_single_derived.py +87 -0
  137. ophyd_async/testing/_utils.py +3 -0
  138. {ophyd_async-0.9.0a1.dist-info → ophyd_async-0.10.0a1.dist-info}/METADATA +25 -26
  139. ophyd_async-0.10.0a1.dist-info/RECORD +149 -0
  140. {ophyd_async-0.9.0a1.dist-info → ophyd_async-0.10.0a1.dist-info}/WHEEL +1 -1
  141. ophyd_async/core/_device_save_loader.py +0 -274
  142. ophyd_async/epics/demo/_mover.py +0 -95
  143. ophyd_async/epics/demo/_sensor.py +0 -37
  144. ophyd_async/epics/demo/sensor.db +0 -19
  145. ophyd_async/fastcs/panda/_utils.py +0 -16
  146. ophyd_async/sim/demo/__init__.py +0 -19
  147. ophyd_async/sim/demo/_pattern_detector/__init__.py +0 -13
  148. ophyd_async/sim/demo/_pattern_detector/_pattern_detector.py +0 -42
  149. ophyd_async/sim/demo/_pattern_detector/_pattern_detector_controller.py +0 -62
  150. ophyd_async/sim/demo/_pattern_detector/_pattern_detector_writer.py +0 -41
  151. ophyd_async/sim/demo/_pattern_detector/_pattern_generator.py +0 -207
  152. ophyd_async/sim/demo/_sim_motor.py +0 -107
  153. ophyd_async/sim/testing/__init__.py +0 -0
  154. ophyd_async-0.9.0a1.dist-info/RECORD +0 -119
  155. ophyd_async-0.9.0a1.dist-info/entry_points.txt +0 -2
  156. {ophyd_async-0.9.0a1.dist-info → ophyd_async-0.10.0a1.dist-info/licenses}/LICENSE +0 -0
  157. {ophyd_async-0.9.0a1.dist-info → ophyd_async-0.10.0a1.dist-info}/top_level.txt +0 -0
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import sys
5
- from collections.abc import Coroutine, Iterator, Mapping, MutableMapping
5
+ from collections.abc import Awaitable, Callable, Iterator, Mapping, MutableMapping
6
6
  from functools import cached_property
7
7
  from logging import LoggerAdapter, getLogger
8
8
  from typing import Any, TypeVar
@@ -17,7 +17,7 @@ class DeviceConnector:
17
17
  """Defines how a `Device` should be connected and type hints processed."""
18
18
 
19
19
  def create_children_from_annotations(self, device: Device):
20
- """Used when children can be created from introspecting the hardware.
20
+ """Use when children can be created from introspecting the hardware.
21
21
 
22
22
  Some control systems allow introspection of a device to determine what
23
23
  children it has. To allow this to work nicely with typing we add these
@@ -26,15 +26,20 @@ class DeviceConnector:
26
26
  my_signal: SignalRW[int]
27
27
  my_device: MyDevice
28
28
 
29
- This method will be run during ``Device.__init__``, and is responsible
29
+ This method will be run during `Device.__init__`, and is responsible
30
30
  for turning all of those type hints into real Signal and Device instances.
31
31
 
32
32
  Subsequent runs of this function should do nothing, to allow it to be
33
33
  called early in Devices that need to pass references to their children
34
- during ``__init__``.
34
+ during `__init__`.
35
35
  """
36
36
 
37
37
  async def connect_mock(self, device: Device, mock: LazyMock):
38
+ """Use during [](#Device.connect) with `mock=True`.
39
+
40
+ This is called when there is no cached connect done in `mock=True`
41
+ mode. It connects the Device and all its children in mock mode.
42
+ """
38
43
  # Connect serially, no errors to gather up as in mock mode
39
44
  exceptions: dict[str, Exception] = {}
40
45
  for name, child_device in device.children():
@@ -46,11 +51,10 @@ class DeviceConnector:
46
51
  raise NotConnected.with_other_exceptions_logged(exceptions)
47
52
 
48
53
  async def connect_real(self, device: Device, timeout: float, force_reconnect: bool):
49
- """Used during ``Device.connect``.
54
+ """Use during [](#Device.connect) with `mock=False`.
50
55
 
51
- This is called when a previous connect has not been done, or has been
52
- done in a different mock more. It should connect the Device and all its
53
- children.
56
+ This is called when there is no cached connect done in `mock=False`
57
+ mode. It connects the Device and all its children in real mode in parallel.
54
58
  """
55
59
  # Connect in parallel, gathering up NotConnected errors
56
60
  coros = {
@@ -61,11 +65,15 @@ class DeviceConnector:
61
65
 
62
66
 
63
67
  class Device(HasName):
64
- """Common base class for all Ophyd Async Devices."""
68
+ """Common base class for all Ophyd Async Devices.
69
+
70
+ :param name: Optional name of the Device
71
+ :param connector: Optional DeviceConnector instance to use at connect()
72
+ """
65
73
 
66
- _name: str = ""
67
- #: The parent Device if it exists
68
74
  parent: Device | None = None
75
+ """The parent Device if it exists"""
76
+ _name: str = ""
69
77
  # None if connect hasn't started, a Task if it has
70
78
  _connect_task: asyncio.Task | None = None
71
79
  # The mock if we have connected in mock mode
@@ -83,7 +91,7 @@ class Device(HasName):
83
91
 
84
92
  @property
85
93
  def name(self) -> str:
86
- """Return the name of the Device"""
94
+ """Return the name of the Device."""
87
95
  return self._name
88
96
 
89
97
  @cached_property
@@ -91,24 +99,26 @@ class Device(HasName):
91
99
  return {}
92
100
 
93
101
  def children(self) -> Iterator[tuple[str, Device]]:
102
+ """For each attribute that is a Device, yield the name and Device.
103
+
104
+ :yields: `(attr_name, attr)` for each child attribute that is a Device.
105
+ """
94
106
  yield from self._child_devices.items()
95
107
 
96
108
  @cached_property
97
109
  def log(self) -> LoggerAdapter:
110
+ """Return a logger configured with the device name."""
98
111
  return LoggerAdapter(
99
112
  getLogger("ophyd_async.devices"), {"ophyd_async_device_name": self.name}
100
113
  )
101
114
 
102
115
  def set_name(self, name: str, *, child_name_separator: str | None = None) -> None:
103
- """Set ``self.name=name`` and each ``self.child.name=name+"-child"``.
104
-
105
- Parameters
106
- ----------
107
- name:
108
- New name to set
109
- child_name_separator:
110
- Use this as a separator instead of "-". Use "_" instead to make the same
111
- names as the equivalent ophyd sync device.
116
+ """Set `self.name=name` and each `self.child.name=name+"-child"`.
117
+
118
+ :param name: New name to set.
119
+ :param child_name_separator:
120
+ Use this as a separator instead of "-". Use "_" instead to make the
121
+ same names as the equivalent ophyd sync device.
112
122
  """
113
123
  self._name = name
114
124
  if child_name_separator:
@@ -147,21 +157,26 @@ class Device(HasName):
147
157
  timeout: float = DEFAULT_TIMEOUT,
148
158
  force_reconnect: bool = False,
149
159
  ) -> None:
150
- """Connect self and all child Devices.
151
-
152
- Contains a timeout that gets propagated to child.connect methods.
153
-
154
- Parameters
155
- ----------
156
- mock:
157
- If True then use ``MockSignalBackend`` for all Signals
158
- timeout:
159
- Time to wait before failing with a TimeoutError.
160
+ """Connect the device and all child devices.
161
+
162
+ Successful connects will be cached so subsequent calls will return
163
+ immediately. Contains a timeout that gets propagated to child.connect
164
+ methods.
165
+
166
+ :param mock:
167
+ If True then use [](#MockSignalBackend) for all Signals. If passed a
168
+ [](#LazyMock) then pass this down for use within the Signals,
169
+ otherwise create one.
170
+ :param timeout: Time to wait before failing with a TimeoutError.
171
+ :param force_reconnect:
172
+ If True, force a reconnect even if the last connect succeeded.
160
173
  """
161
- assert hasattr(self, "_connector"), (
162
- f"{self}: doesn't have attribute `_connector`,"
163
- " did you call `super().__init__` in your `__init__` method?"
164
- )
174
+ if not hasattr(self, "_connector"):
175
+ msg = (
176
+ f"{self}: doesn't have attribute `_connector`,"
177
+ " did you call `super().__init__` in your `__init__` method?"
178
+ )
179
+ raise RuntimeError(msg)
165
180
  if mock:
166
181
  # Always connect in mock mode serially
167
182
  if isinstance(mock, LazyMock):
@@ -182,7 +197,9 @@ class Device(HasName):
182
197
  self._mock = None
183
198
  coro = self._connector.connect_real(self, timeout, force_reconnect)
184
199
  self._connect_task = asyncio.create_task(coro)
185
- assert self._connect_task, "Connect task not created, this shouldn't happen"
200
+ if not self._connect_task:
201
+ msg = "Connect task not created, this shouldn't happen"
202
+ raise RuntimeError(msg)
186
203
  # Wait for it to complete
187
204
  await self._connect_task
188
205
 
@@ -201,12 +218,9 @@ DeviceT = TypeVar("DeviceT", bound=Device)
201
218
 
202
219
 
203
220
  class DeviceVector(MutableMapping[int, DeviceT], Device):
204
- """
205
- Defines device components with indices.
221
+ """Defines a dictionary of Device children with arbitrary integer keys.
206
222
 
207
- In the below example, foos becomes a dictionary on the parent device
208
- at runtime, so parent.foos[2] returns a FooDevice. For example usage see
209
- :class:`~ophyd_async.epics.demo.DynamicSensorGroup`
223
+ :see-also: [](#implementing-devices) for examples of how to use this class.
210
224
  """
211
225
 
212
226
  def __init__(
@@ -232,8 +246,12 @@ class DeviceVector(MutableMapping[int, DeviceT], Device):
232
246
  def __setitem__(self, key: int, value: DeviceT) -> None:
233
247
  # Check the types on entry to dict to make sure we can't accidentally
234
248
  # make a non-integer named child
235
- assert isinstance(key, int), f"Expected int, got {key}"
236
- assert isinstance(value, Device), f"Expected Device, got {value}"
249
+ if not isinstance(key, int):
250
+ msg = f"Expected int, got {key}"
251
+ raise TypeError(msg)
252
+ if not isinstance(value, Device):
253
+ msg = f"Expected Device, got {value}"
254
+ raise TypeError(msg)
237
255
  self._children[key] = value
238
256
  value.parent = self
239
257
 
@@ -254,94 +272,49 @@ class DeviceVector(MutableMapping[int, DeviceT], Device):
254
272
  return hash(id(self))
255
273
 
256
274
 
257
- class DeviceCollector:
258
- """Collector of top level Device instances to be used as a context manager
259
-
260
- Parameters
261
- ----------
262
- set_name:
263
- If True, call ``device.set_name(variable_name)`` on all collected
264
- Devices
265
- child_name_separator:
266
- Use this as a separator if we call ``set_name``.
267
- connect:
268
- If True, call ``device.connect(mock)`` in parallel on all
269
- collected Devices
270
- mock:
271
- If True, connect Signals in simulation mode
272
- timeout:
273
- How long to wait for connect before logging an exception
274
-
275
- Notes
276
- -----
277
- Example usage::
278
-
279
- [async] with DeviceCollector():
280
- t1x = motor.Motor("BLxxI-MO-TABLE-01:X")
281
- t1y = motor.Motor("pva://BLxxI-MO-TABLE-01:Y")
282
- # Names and connects devices here
283
- assert t1x.comm.velocity.source
284
- assert t1x.name == "t1x"
275
+ class DeviceProcessor:
276
+ """Sync/Async Context Manager that finds all the Devices declared within it.
285
277
 
278
+ Used in `init_devices`
286
279
  """
287
280
 
288
- def __init__(
289
- self,
290
- set_name=True,
291
- child_name_separator: str = "-",
292
- connect=True,
293
- mock=False,
294
- timeout: float = 10.0,
295
- ):
296
- self._set_name = set_name
297
- self._child_name_separator = child_name_separator
298
- self._connect = connect
299
- self._mock = mock
300
- self._timeout = timeout
301
- self._names_on_enter: set[str] = set()
302
- self._objects_on_exit: dict[str, Any] = {}
303
-
304
- def _caller_locals(self):
305
- """Walk up until we find a stack frame that doesn't have us as self"""
281
+ def __init__(self, process_devices: Callable[[dict[str, Device]], Awaitable[None]]):
282
+ self._process_devices = process_devices
283
+ self._locals_on_enter: dict[str, Any] = {}
284
+ self._locals_on_exit: dict[str, Any] = {}
285
+
286
+ def _caller_locals(self) -> dict[str, Any]:
287
+ """Walk up until we find a stack frame that doesn't have us as self."""
306
288
  try:
307
289
  raise ValueError
308
290
  except ValueError:
309
291
  _, _, tb = sys.exc_info()
310
- assert tb, "Can't get traceback, this shouldn't happen"
292
+ if not tb:
293
+ msg = "Can't get traceback, this shouldn't happen"
294
+ raise RuntimeError(msg) # noqa: B904
311
295
  caller_frame = tb.tb_frame
312
296
  while caller_frame.f_locals.get("self", None) is self:
313
297
  caller_frame = caller_frame.f_back
314
- assert (
315
- caller_frame
316
- ), "No previous frame to the one with self in it, this shouldn't happen"
317
- return caller_frame.f_locals
298
+ if not caller_frame:
299
+ msg = (
300
+ "No previous frame to the one with self in it, "
301
+ "this shouldn't happen"
302
+ )
303
+ raise RuntimeError( # noqa: B904
304
+ msg
305
+ )
306
+ return caller_frame.f_locals.copy()
318
307
 
319
- def __enter__(self) -> DeviceCollector:
308
+ def __enter__(self) -> DeviceProcessor:
320
309
  # Stash the names that were defined before we were called
321
- self._names_on_enter = set(self._caller_locals())
310
+ self._locals_on_enter = self._caller_locals()
322
311
  return self
323
312
 
324
- async def __aenter__(self) -> DeviceCollector:
313
+ async def __aenter__(self) -> DeviceProcessor:
325
314
  return self.__enter__()
326
315
 
327
- async def _on_exit(self) -> None:
328
- # Name and kick off connect for devices
329
- connect_coroutines: dict[str, Coroutine] = {}
330
- for name, obj in self._objects_on_exit.items():
331
- if name not in self._names_on_enter and isinstance(obj, Device):
332
- if self._set_name and not obj.name:
333
- obj.set_name(name, child_name_separator=self._child_name_separator)
334
- if self._connect:
335
- connect_coroutines[name] = obj.connect(
336
- self._mock, timeout=self._timeout
337
- )
338
-
339
- # Connect to all the devices
340
- if connect_coroutines:
341
- await wait_for_connection(**connect_coroutines)
342
-
343
316
  async def __aexit__(self, type, value, traceback):
344
- self._objects_on_exit = self._caller_locals()
317
+ self._locals_on_exit = self._caller_locals()
345
318
  await self._on_exit()
346
319
 
347
320
  def __exit__(self, type_, value, traceback):
@@ -350,7 +323,7 @@ class DeviceCollector:
350
323
  "Cannot use DeviceConnector inside a plan, instead use "
351
324
  "`yield from ophyd_async.plan_stubs.ensure_connected(device)`"
352
325
  )
353
- self._objects_on_exit = self._caller_locals()
326
+ self._locals_on_exit = self._caller_locals()
354
327
  try:
355
328
  fut = call_in_bluesky_event_loop(self._on_exit())
356
329
  except RuntimeError as e:
@@ -360,3 +333,58 @@ class DeviceCollector:
360
333
  "user/explanations/event-loop-choice.html for more info."
361
334
  ) from e
362
335
  return fut
336
+
337
+ async def _on_exit(self) -> None:
338
+ # Find all the devices
339
+ devices = {
340
+ name: obj
341
+ for name, obj in self._locals_on_exit.items()
342
+ if isinstance(obj, Device) and self._locals_on_enter.get(name) is not obj
343
+ }
344
+ # Call the provided process function on them
345
+ await self._process_devices(devices)
346
+
347
+
348
+ def init_devices(
349
+ set_name=True,
350
+ child_name_separator: str = "-",
351
+ connect=True,
352
+ mock=False,
353
+ timeout: float = 10.0,
354
+ ):
355
+ """Auto initialize top level Device instances: to be used as a context manager.
356
+
357
+ :param set_name:
358
+ If True, call `device.set_name(variable_name)` on all Devices created
359
+ within the context manager that have an empty `name`.
360
+ :param child_name_separator: Separator for child names if `set_name` is True.
361
+ :param connect:
362
+ If True, call `device.connect(mock, timeout)` in parallel on all Devices
363
+ created within the context manager.
364
+ :param mock: If True, connect Signals in mock mode.
365
+ :param timeout: How long to wait for connect before logging an exception.
366
+ :raises RuntimeError: If used inside a plan, use [](#ensure_connected) instead.
367
+ :raises NotConnected: If devices could not be connected.
368
+
369
+ For example, to connect and name 2 motors in parallel:
370
+ ```python
371
+ [async] with init_devices():
372
+ t1x = motor.Motor("BLxxI-MO-TABLE-01:X")
373
+ t1y = motor.Motor("pva://BLxxI-MO-TABLE-01:Y")
374
+ # Names and connects devices here
375
+ assert t1x.name == "t1x"
376
+ ```
377
+ """
378
+
379
+ async def process_devices(devices: dict[str, Device]):
380
+ if set_name:
381
+ for name, device in devices.items():
382
+ if not device.name:
383
+ device.set_name(name, child_name_separator=child_name_separator)
384
+ if connect:
385
+ coros = {
386
+ name: device.connect(mock, timeout) for name, device in devices.items()
387
+ }
388
+ await wait_for_connection(**coros)
389
+
390
+ return DeviceProcessor(process_devices)
@@ -16,7 +16,7 @@ from typing import (
16
16
  )
17
17
 
18
18
  from ._device import Device, DeviceConnector, DeviceVector
19
- from ._signal import Signal, SignalX
19
+ from ._signal import Ignore, Signal, SignalX
20
20
  from ._signal_backend import SignalBackend, SignalDatatype
21
21
  from ._utils import get_origin_class
22
22
 
@@ -33,12 +33,20 @@ def _get_datatype(annotation: Any) -> type | None:
33
33
  args = get_args(annotation)
34
34
  if len(args) == 1 and get_origin_class(args[0]):
35
35
  return args[0]
36
+ return None
36
37
 
37
38
 
38
39
  def _logical(name: UniqueName) -> LogicalName:
39
40
  return LogicalName(name.rstrip("_"))
40
41
 
41
42
 
43
+ def _check_device_annotation(annotation: Any) -> DeviceAnnotation:
44
+ if not isinstance(annotation, DeviceAnnotation):
45
+ msg = f"Annotation {annotation} is not a DeviceAnnotation"
46
+ raise TypeError(msg)
47
+ return annotation
48
+
49
+
42
50
  @runtime_checkable
43
51
  class DeviceAnnotation(Protocol):
44
52
  @abstractmethod
@@ -46,6 +54,13 @@ class DeviceAnnotation(Protocol):
46
54
 
47
55
 
48
56
  class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
57
+ """For filling signals on introspected devices.
58
+
59
+ :param device: The device to fill.
60
+ :param signal_backend_factory: A callable that returns a SignalBackend.
61
+ :param device_connector_factory: A callable that returns a DeviceConnector.
62
+ """
63
+
49
64
  def __init__(
50
65
  self,
51
66
  device: Device,
@@ -61,6 +76,7 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
61
76
  self._extras: dict[UniqueName, Sequence[Any]] = {}
62
77
  self._signal_datatype: dict[LogicalName, type | None] = {}
63
78
  self._vector_device_type: dict[LogicalName, type[Device] | None] = {}
79
+ self.ignored_signals: set[str] = set()
64
80
  # Backends and Connectors stored ready for the connection phase
65
81
  self._unfilled_backends: dict[
66
82
  LogicalName, tuple[SignalBackendT, type[Signal]]
@@ -101,6 +117,8 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
101
117
  # Get hints with Annotated for wrapping signals and backends
102
118
  extra_hints = get_type_hints(cls, include_extras=True)
103
119
  for attr_name, annotation in hints.items():
120
+ if annotation is Ignore:
121
+ self.ignored_signals.add(attr_name)
104
122
  name = UniqueName(attr_name)
105
123
  origin = get_origin_class(annotation)
106
124
  if (
@@ -131,6 +149,7 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
131
149
  self._uncreated_devices[name] = origin
132
150
 
133
151
  def check_created(self):
152
+ """Check that all Signals and Devices declared in annotations are created."""
134
153
  uncreated = sorted(set(self._uncreated_signals).union(self._uncreated_devices))
135
154
  if uncreated:
136
155
  raise RuntimeError(
@@ -141,6 +160,21 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
141
160
  self,
142
161
  filled=True,
143
162
  ) -> Iterator[tuple[SignalBackendT, list[Any]]]:
163
+ """Create all Signals from annotations.
164
+
165
+ :param filled:
166
+ If True then the Signals created should be considered already filled
167
+ with connection data. If False then `fill_child_signal` needs
168
+ calling at device connection time before the signal can be
169
+ connected.
170
+ :yields: `(backend, extras)`
171
+ The `SignalBackend` that has been created for this Signal, and the
172
+ list of extra annotations that could be used to customize it. For
173
+ example an `EpicsDeviceConnector` consumes `PvSuffix` extras to set the
174
+ write_pv of the backend. Any unhandled extras should be left on the
175
+ list so this class can handle them, e.g. `StandardReadableFormat`
176
+ instances.
177
+ """
144
178
  for name in list(self._uncreated_signals):
145
179
  child_type = self._uncreated_signals.pop(name)
146
180
  backend = self._signal_backend_factory(
@@ -150,8 +184,8 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
150
184
  yield backend, extras
151
185
  signal = child_type(backend)
152
186
  for anno in extras:
153
- assert isinstance(anno, DeviceAnnotation), anno
154
- anno(self._device, signal)
187
+ device_annotation = _check_device_annotation(annotation=anno)
188
+ device_annotation(self._device, signal)
155
189
  setattr(self._device, name, signal)
156
190
  dest = self._filled_backends if filled else self._unfilled_backends
157
191
  dest[_logical(name)] = (backend, child_type)
@@ -160,6 +194,17 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
160
194
  self,
161
195
  filled=True,
162
196
  ) -> Iterator[tuple[DeviceConnectorT, list[Any]]]:
197
+ """Create all Signals from annotations.
198
+
199
+ :param filled:
200
+ If True then the Devices created should be considered already filled
201
+ with connection data. If False then `fill_child_device` needs
202
+ calling at parent device connection time before the child Device can
203
+ be connected.
204
+ :yields: `(connector, extras)`
205
+ The `DeviceConnector` that has been created for this Signal, and the list of
206
+ extra annotations that could be used to customize it.
207
+ """
163
208
  for name in list(self._uncreated_devices):
164
209
  child_type = self._uncreated_devices.pop(name)
165
210
  connector = self._device_connector_factory()
@@ -167,15 +212,21 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
167
212
  yield connector, extras
168
213
  device = child_type(connector=connector)
169
214
  for anno in extras:
170
- assert isinstance(anno, DeviceAnnotation), anno
171
- anno(self._device, device)
215
+ device_annotation = _check_device_annotation(annotation=anno)
216
+ device_annotation(self._device, device)
172
217
  setattr(self._device, name, device)
173
218
  dest = self._filled_connectors if filled else self._unfilled_connectors
174
219
  dest[_logical(name)] = connector
175
220
 
176
221
  def create_device_vector_entries_to_mock(self, num: int):
222
+ """Create num entries for each `DeviceVector`.
223
+
224
+ This is used when the Device is being connected in mock mode.
225
+ """
177
226
  for name, cls in self._vector_device_type.items():
178
- assert cls, "Shouldn't happen"
227
+ if not cls:
228
+ msg = "Malformed device vector"
229
+ raise TypeError(msg)
179
230
  for i in range(1, num + 1):
180
231
  if issubclass(cls, Signal):
181
232
  self.fill_child_signal(name, cls, i)
@@ -185,6 +236,11 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
185
236
  self._raise(name, f"Can't make {cls}")
186
237
 
187
238
  def check_filled(self, source: str):
239
+ """Check that all the created Signals and Devices are filled.
240
+
241
+ :param source: The source of the data that should have done the filling, for
242
+ reporting as an error message
243
+ """
188
244
  unfilled = sorted(set(self._unfilled_connectors).union(self._unfilled_backends))
189
245
  if unfilled:
190
246
  raise RuntimeError(
@@ -207,6 +263,15 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
207
263
  signal_type: type[Signal],
208
264
  vector_index: int | None = None,
209
265
  ) -> SignalBackendT:
266
+ """Mark a Signal as filled, and return its backend for filling.
267
+
268
+ :param name:
269
+ The name without trailing underscore, the name in the control system
270
+ :param signal_type:
271
+ One of the types `SignalR`, `SignalW`, `SignalRW` or `SignalX`
272
+ :param vector_index: If the child is in a `DeviceVector` then what index is it
273
+ :return: The SignalBackend for the filled Signal.
274
+ """
210
275
  name = cast(LogicalName, name)
211
276
  if name in self._unfilled_backends:
212
277
  # We made it above
@@ -242,6 +307,14 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
242
307
  device_type: type[Device] = Device,
243
308
  vector_index: int | None = None,
244
309
  ) -> DeviceConnectorT:
310
+ """Mark a Device as filled, and return its connector for filling.
311
+
312
+ :param name:
313
+ The name without trailing underscore, the name in the control system
314
+ :param device_type: The `Device` subclass to be created
315
+ :param vector_index: If the child is in a `DeviceVector` then what index is it
316
+ :return: The DeviceConnector for the filled Device.
317
+ """
245
318
  name = cast(LogicalName, name)
246
319
  if name in self._unfilled_connectors:
247
320
  # We made it above
@@ -254,9 +327,9 @@ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
254
327
  # We need to add a new entry to a DeviceVector
255
328
  vector = self._ensure_device_vector(name)
256
329
  vector_device_type = self._vector_device_type[name] or device_type
257
- assert issubclass(
258
- vector_device_type, Device
259
- ), f"{vector_device_type} is not a Device"
330
+ if not issubclass(vector_device_type, Device):
331
+ msg = f"{vector_device_type} is not a Device"
332
+ raise TypeError(msg)
260
333
  connector = self._device_connector_factory()
261
334
  vector[vector_index] = vector_device_type(connector=connector)
262
335
  elif child := getattr(self._device, name, None):
@@ -1,5 +1,5 @@
1
1
  from abc import ABC, abstractmethod
2
- from typing import Generic
2
+ from typing import Any, Generic
3
3
 
4
4
  from bluesky.protocols import Flyable, Preparable, Stageable
5
5
 
@@ -9,21 +9,26 @@ from ._utils import T
9
9
 
10
10
 
11
11
  class FlyerController(ABC, Generic[T]):
12
+ """Base class for controlling 'flyable' devices.
13
+
14
+ [`bluesky.protocols.Flyable`](#bluesky.protocols.Flyable).
15
+ """
16
+
12
17
  @abstractmethod
13
- async def prepare(self, value: T):
14
- """Move to the start of the flyscan"""
18
+ async def prepare(self, value: T) -> Any:
19
+ """Move to the start of the flyscan."""
15
20
 
16
21
  @abstractmethod
17
22
  async def kickoff(self):
18
- """Start the flyscan"""
23
+ """Start the flyscan."""
19
24
 
20
25
  @abstractmethod
21
26
  async def complete(self):
22
- """Block until the flyscan is done"""
27
+ """Block until the flyscan is done."""
23
28
 
24
29
  @abstractmethod
25
30
  async def stop(self):
26
- """Stop flying and wait everything to be stopped"""
31
+ """Stop flying and wait everything to be stopped."""
27
32
 
28
33
 
29
34
  class StandardFlyer(
@@ -33,6 +38,11 @@ class StandardFlyer(
33
38
  Flyable,
34
39
  Generic[T],
35
40
  ):
41
+ """Base class for 'flyable' devices.
42
+
43
+ [`bluesky.protocols.Flyable`](#bluesky.protocols.Flyable).
44
+ """
45
+
36
46
  def __init__(
37
47
  self,
38
48
  trigger_logic: FlyerController[T],
@@ -54,7 +64,6 @@ class StandardFlyer(
54
64
  await self._trigger_logic.stop()
55
65
 
56
66
  def prepare(self, value: T) -> AsyncStatus:
57
- """Setup trajectories"""
58
67
  return AsyncStatus(self._prepare(value))
59
68
 
60
69
  async def _prepare(self, value: T) -> None: