ophyd-async 0.9.0a2__py3-none-any.whl → 0.10.0a2__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 (151) 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 +97 -62
  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 +106 -125
  8. ophyd_async/core/_device.py +69 -63
  9. ophyd_async/core/_device_filler.py +65 -1
  10. ophyd_async/core/_flyer.py +14 -5
  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 +44 -35
  17. ophyd_async/core/_settings.py +36 -27
  18. ophyd_async/core/_signal.py +262 -170
  19. ophyd_async/core/_signal_backend.py +56 -13
  20. ophyd_async/core/_soft_signal_backend.py +16 -11
  21. ophyd_async/core/_status.py +72 -24
  22. ophyd_async/core/_table.py +41 -11
  23. ophyd_async/core/_utils.py +96 -49
  24. ophyd_async/core/_yaml_settings.py +2 -0
  25. ophyd_async/epics/__init__.py +1 -0
  26. ophyd_async/epics/adandor/_andor.py +2 -2
  27. ophyd_async/epics/adandor/_andor_controller.py +4 -2
  28. ophyd_async/epics/adandor/_andor_io.py +2 -4
  29. ophyd_async/epics/adaravis/__init__.py +5 -0
  30. ophyd_async/epics/adaravis/_aravis.py +4 -8
  31. ophyd_async/epics/adaravis/_aravis_controller.py +20 -43
  32. ophyd_async/epics/adaravis/_aravis_io.py +13 -28
  33. ophyd_async/epics/adcore/__init__.py +23 -8
  34. ophyd_async/epics/adcore/_core_detector.py +42 -2
  35. ophyd_async/epics/adcore/_core_io.py +124 -99
  36. ophyd_async/epics/adcore/_core_logic.py +106 -27
  37. ophyd_async/epics/adcore/_core_writer.py +12 -8
  38. ophyd_async/epics/adcore/_hdf_writer.py +21 -38
  39. ophyd_async/epics/adcore/_single_trigger.py +2 -2
  40. ophyd_async/epics/adcore/_utils.py +2 -2
  41. ophyd_async/epics/adkinetix/__init__.py +2 -1
  42. ophyd_async/epics/adkinetix/_kinetix.py +3 -3
  43. ophyd_async/epics/adkinetix/_kinetix_controller.py +4 -2
  44. ophyd_async/epics/adkinetix/_kinetix_io.py +12 -13
  45. ophyd_async/epics/adpilatus/__init__.py +5 -0
  46. ophyd_async/epics/adpilatus/_pilatus.py +1 -1
  47. ophyd_async/epics/adpilatus/_pilatus_controller.py +5 -24
  48. ophyd_async/epics/adpilatus/_pilatus_io.py +11 -9
  49. ophyd_async/epics/adsimdetector/__init__.py +8 -1
  50. ophyd_async/epics/adsimdetector/_sim.py +4 -14
  51. ophyd_async/epics/adsimdetector/_sim_controller.py +17 -0
  52. ophyd_async/epics/adsimdetector/_sim_io.py +10 -0
  53. ophyd_async/epics/advimba/__init__.py +10 -1
  54. ophyd_async/epics/advimba/_vimba.py +3 -2
  55. ophyd_async/epics/advimba/_vimba_controller.py +4 -2
  56. ophyd_async/epics/advimba/_vimba_io.py +23 -28
  57. ophyd_async/epics/core/_aioca.py +35 -16
  58. ophyd_async/epics/core/_epics_connector.py +4 -0
  59. ophyd_async/epics/core/_epics_device.py +2 -0
  60. ophyd_async/epics/core/_p4p.py +10 -2
  61. ophyd_async/epics/core/_pvi_connector.py +65 -8
  62. ophyd_async/epics/core/_signal.py +51 -51
  63. ophyd_async/epics/core/_util.py +4 -4
  64. ophyd_async/epics/demo/__init__.py +16 -0
  65. ophyd_async/epics/demo/__main__.py +31 -0
  66. ophyd_async/epics/demo/_ioc.py +32 -0
  67. ophyd_async/epics/demo/_motor.py +82 -0
  68. ophyd_async/epics/demo/_point_detector.py +42 -0
  69. ophyd_async/epics/demo/_point_detector_channel.py +22 -0
  70. ophyd_async/epics/demo/_stage.py +15 -0
  71. ophyd_async/epics/{sim/mover.db → demo/motor.db} +2 -1
  72. ophyd_async/epics/demo/point_detector.db +59 -0
  73. ophyd_async/epics/demo/point_detector_channel.db +21 -0
  74. ophyd_async/epics/eiger/_eiger.py +1 -3
  75. ophyd_async/epics/eiger/_eiger_controller.py +11 -4
  76. ophyd_async/epics/eiger/_eiger_io.py +2 -0
  77. ophyd_async/epics/eiger/_odin_io.py +1 -2
  78. ophyd_async/epics/motor.py +65 -28
  79. ophyd_async/epics/signal.py +4 -1
  80. ophyd_async/epics/testing/_example_ioc.py +21 -9
  81. ophyd_async/epics/testing/_utils.py +3 -0
  82. ophyd_async/epics/testing/test_records.db +8 -0
  83. ophyd_async/epics/testing/test_records_pva.db +17 -16
  84. ophyd_async/fastcs/__init__.py +1 -0
  85. ophyd_async/fastcs/core.py +6 -0
  86. ophyd_async/fastcs/odin/__init__.py +1 -0
  87. ophyd_async/fastcs/panda/__init__.py +8 -6
  88. ophyd_async/fastcs/panda/_block.py +29 -9
  89. ophyd_async/fastcs/panda/_control.py +5 -0
  90. ophyd_async/fastcs/panda/_hdf_panda.py +2 -0
  91. ophyd_async/fastcs/panda/_table.py +9 -6
  92. ophyd_async/fastcs/panda/_trigger.py +23 -9
  93. ophyd_async/fastcs/panda/_writer.py +27 -30
  94. ophyd_async/plan_stubs/__init__.py +2 -0
  95. ophyd_async/plan_stubs/_ensure_connected.py +1 -0
  96. ophyd_async/plan_stubs/_fly.py +2 -4
  97. ophyd_async/plan_stubs/_nd_attributes.py +2 -0
  98. ophyd_async/plan_stubs/_panda.py +1 -0
  99. ophyd_async/plan_stubs/_settings.py +43 -16
  100. ophyd_async/plan_stubs/_utils.py +3 -0
  101. ophyd_async/plan_stubs/_wait_for_awaitable.py +1 -1
  102. ophyd_async/sim/__init__.py +24 -14
  103. ophyd_async/sim/__main__.py +43 -0
  104. ophyd_async/sim/_blob_detector.py +33 -0
  105. ophyd_async/sim/_blob_detector_controller.py +48 -0
  106. ophyd_async/sim/_blob_detector_writer.py +105 -0
  107. ophyd_async/sim/_mirror_horizontal.py +46 -0
  108. ophyd_async/sim/_mirror_vertical.py +74 -0
  109. ophyd_async/sim/_motor.py +233 -0
  110. ophyd_async/sim/_pattern_generator.py +124 -0
  111. ophyd_async/sim/_point_detector.py +86 -0
  112. ophyd_async/sim/_stage.py +19 -0
  113. ophyd_async/tango/__init__.py +1 -0
  114. ophyd_async/tango/core/__init__.py +6 -1
  115. ophyd_async/tango/core/_base_device.py +41 -33
  116. ophyd_async/tango/core/_converters.py +81 -0
  117. ophyd_async/tango/core/_signal.py +18 -32
  118. ophyd_async/tango/core/_tango_readable.py +2 -19
  119. ophyd_async/tango/core/_tango_transport.py +136 -60
  120. ophyd_async/tango/core/_utils.py +47 -0
  121. ophyd_async/tango/{sim → demo}/_counter.py +2 -0
  122. ophyd_async/tango/{sim → demo}/_detector.py +2 -0
  123. ophyd_async/tango/{sim → demo}/_mover.py +5 -4
  124. ophyd_async/tango/{sim → demo}/_tango/_servers.py +4 -0
  125. ophyd_async/tango/testing/__init__.py +6 -0
  126. ophyd_async/tango/testing/_one_of_everything.py +200 -0
  127. ophyd_async/testing/__init__.py +29 -7
  128. ophyd_async/testing/_assert.py +145 -83
  129. ophyd_async/testing/_mock_signal_utils.py +56 -70
  130. ophyd_async/testing/_one_of_everything.py +41 -21
  131. ophyd_async/testing/_single_derived.py +89 -0
  132. ophyd_async/testing/_utils.py +3 -0
  133. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info}/METADATA +25 -26
  134. ophyd_async-0.10.0a2.dist-info/RECORD +149 -0
  135. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info}/WHEEL +1 -1
  136. ophyd_async/epics/sim/__init__.py +0 -54
  137. ophyd_async/epics/sim/_ioc.py +0 -29
  138. ophyd_async/epics/sim/_mover.py +0 -101
  139. ophyd_async/epics/sim/_sensor.py +0 -37
  140. ophyd_async/epics/sim/sensor.db +0 -19
  141. ophyd_async/sim/_pattern_detector/__init__.py +0 -13
  142. ophyd_async/sim/_pattern_detector/_pattern_detector.py +0 -42
  143. ophyd_async/sim/_pattern_detector/_pattern_detector_controller.py +0 -69
  144. ophyd_async/sim/_pattern_detector/_pattern_detector_writer.py +0 -41
  145. ophyd_async/sim/_pattern_detector/_pattern_generator.py +0 -214
  146. ophyd_async/sim/_sim_motor.py +0 -107
  147. ophyd_async-0.9.0a2.dist-info/RECORD +0 -129
  148. /ophyd_async/tango/{sim → demo}/__init__.py +0 -0
  149. /ophyd_async/tango/{sim → demo}/_tango/__init__.py +0 -0
  150. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info/licenses}/LICENSE +0 -0
  151. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,200 @@
1
+ import textwrap
2
+ from dataclasses import dataclass
3
+ from typing import Any, Generic, TypeVar
4
+
5
+ import numpy as np
6
+
7
+ from ophyd_async.core import (
8
+ Array1D,
9
+ DTypeScalar_co,
10
+ StrictEnum,
11
+ )
12
+ from ophyd_async.testing import float_array_value, int_array_value
13
+ from tango import AttrDataFormat, AttrWriteType, DevState
14
+ from tango.server import Device, attribute, command
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class ExampleStrEnum(StrictEnum):
20
+ A = "AAA"
21
+ B = "BBB"
22
+ C = "CCC"
23
+
24
+
25
+ def int_image_value(
26
+ dtype: type[DTypeScalar_co],
27
+ ):
28
+ # how do we type this?
29
+ array_1d = int_array_value(dtype)
30
+ return np.vstack((array_1d, array_1d))
31
+
32
+
33
+ def float_image_value(
34
+ dtype: type[DTypeScalar_co],
35
+ ):
36
+ # how do we type this?
37
+ array_1d = float_array_value(dtype)
38
+ return np.vstack((array_1d, array_1d))
39
+
40
+
41
+ def _valid_command(dformat: AttrDataFormat, dtype: str):
42
+ if dtype == "DevUChar":
43
+ return False
44
+ if dformat != AttrDataFormat.SCALAR and dtype in ["DevState", "DevEnum"]:
45
+ return False
46
+ return True
47
+
48
+
49
+ @dataclass
50
+ class AttributeData(Generic[T]):
51
+ name: str
52
+ tango_type: str
53
+ initial_scalar: T
54
+ initial_spectrum: Array1D
55
+
56
+
57
+ _all_attribute_definitions = [
58
+ AttributeData(
59
+ "str",
60
+ "DevString",
61
+ "test_string",
62
+ np.array(["one", "two", "three"], dtype=str),
63
+ ),
64
+ AttributeData(
65
+ "bool",
66
+ "DevBoolean",
67
+ True,
68
+ np.array([False, True], dtype=bool),
69
+ ),
70
+ AttributeData("strenum", "DevEnum", 1, np.array([0, 1, 2])),
71
+ AttributeData("int8", "DevShort", 1, int_array_value(np.int8)),
72
+ AttributeData("uint8", "DevUChar", 1, int_array_value(np.uint8)),
73
+ AttributeData("int16", "DevShort", 1, int_array_value(np.int16)),
74
+ AttributeData("uint16", "DevUShort", 1, int_array_value(np.uint16)),
75
+ AttributeData("int32", "DevLong", 1, int_array_value(np.int32)),
76
+ AttributeData("uint32", "DevULong", 1, int_array_value(np.uint32)),
77
+ AttributeData("int64", "DevLong64", 1, int_array_value(np.int64)),
78
+ AttributeData("uint64", "DevULong64", 1, int_array_value(np.uint64)),
79
+ AttributeData("float32", "DevFloat", 1.234, float_array_value(np.float32)),
80
+ AttributeData("float64", "DevDouble", 1.234, float_array_value(np.float64)),
81
+ AttributeData(
82
+ "my_state",
83
+ "DevState",
84
+ DevState.INIT,
85
+ np.array([DevState.INIT, DevState.ON, DevState.MOVING], dtype=DevState),
86
+ ),
87
+ ]
88
+
89
+
90
+ class OneOfEverythingTangoDevice(Device):
91
+ attr_values = {}
92
+ initial_values = {}
93
+
94
+ def _add_attr(self, attr: attribute, initial_value):
95
+ self.attr_values[attr.name] = initial_value
96
+ self.initial_values[attr.name] = initial_value
97
+ self.add_attribute(attr)
98
+ self.set_change_event(attr.name, True, False)
99
+
100
+ def add_scalar_attr(self, name: str, dtype: str, initial_value: Any):
101
+ attr = attribute(
102
+ name=name,
103
+ dtype=dtype,
104
+ dformat=AttrDataFormat.SCALAR,
105
+ access=AttrWriteType.READ_WRITE,
106
+ fget=self.read,
107
+ fset=self.write,
108
+ enum_labels=[e.value for e in ExampleStrEnum],
109
+ )
110
+ self._add_attr(attr, initial_value)
111
+
112
+ def add_array_attrs(self, name: str, dtype: str, initial_value: np.ndarray):
113
+ spectrum_name = f"{name}_spectrum"
114
+ spectrum_attr = attribute(
115
+ name=spectrum_name,
116
+ dtype=dtype,
117
+ dformat=AttrDataFormat.SPECTRUM,
118
+ access=AttrWriteType.READ_WRITE,
119
+ fget=self.read,
120
+ fset=self.write,
121
+ max_dim_x=initial_value.shape[-1],
122
+ enum_labels=[e.value for e in ExampleStrEnum],
123
+ )
124
+ image_name = f"{name}_image"
125
+ image_attr = attribute(
126
+ name=image_name,
127
+ dtype=dtype,
128
+ dformat=AttrDataFormat.IMAGE,
129
+ access=AttrWriteType.READ_WRITE,
130
+ fget=self.read,
131
+ fset=self.write,
132
+ max_dim_x=initial_value.shape[-1],
133
+ max_dim_y=2,
134
+ enum_labels=[e.value for e in ExampleStrEnum],
135
+ )
136
+ self._add_attr(spectrum_attr, initial_value)
137
+ # have image just be 2 of the initial spectrum stacked
138
+ self._add_attr(image_attr, np.vstack((initial_value, initial_value)))
139
+
140
+ def add_scalar_command(self, name: str, dtype: str):
141
+ if _valid_command(AttrDataFormat.SCALAR, dtype):
142
+ self.add_command(
143
+ command(
144
+ f=getattr(self, f"{name}_cmd"),
145
+ dtype_in=dtype,
146
+ dtype_out=dtype,
147
+ dformat_in=AttrDataFormat.SCALAR,
148
+ dformat_out=AttrDataFormat.SCALAR,
149
+ ),
150
+ )
151
+
152
+ def add_spectrum_command(self, name: str, dtype: str):
153
+ if _valid_command(AttrDataFormat.SPECTRUM, dtype):
154
+ self.add_command(
155
+ command(
156
+ f=getattr(self, f"{name}_spectrum_cmd"),
157
+ dtype_in=dtype,
158
+ dtype_out=dtype,
159
+ dformat_in=AttrDataFormat.SPECTRUM,
160
+ dformat_out=AttrDataFormat.SPECTRUM,
161
+ ),
162
+ )
163
+
164
+ def initialize_dynamic_attributes(self):
165
+ for attr_data in _all_attribute_definitions:
166
+ self.add_scalar_attr(
167
+ attr_data.name, attr_data.tango_type, attr_data.initial_scalar
168
+ )
169
+ self.add_array_attrs(
170
+ attr_data.name, attr_data.tango_type, attr_data.initial_spectrum
171
+ )
172
+ self.add_scalar_command(attr_data.name, attr_data.tango_type)
173
+ self.add_spectrum_command(attr_data.name, attr_data.tango_type)
174
+
175
+ @command
176
+ def reset_values(self):
177
+ for attr_name in self.attr_values:
178
+ self.attr_values[attr_name] = self.initial_values[attr_name]
179
+
180
+ def read(self, attr):
181
+ value = self.attr_values[attr.get_name()]
182
+ attr.set_value(value)
183
+
184
+ def write(self, attr):
185
+ new_value = attr.get_write_value()
186
+ self.attr_values[attr.get_name()] = new_value
187
+ self.push_change_event(attr.get_name(), new_value)
188
+
189
+ echo_command_code = textwrap.dedent(
190
+ """\
191
+ def {}(self, arg):
192
+ return arg
193
+ """
194
+ )
195
+
196
+ for attr_data in _all_attribute_definitions:
197
+ if _valid_command(AttrDataFormat.SCALAR, attr_data.tango_type):
198
+ exec(echo_command_code.format(f"{attr_data.name}_cmd"))
199
+ if _valid_command(AttrDataFormat.SPECTRUM, attr_data.tango_type):
200
+ exec(echo_command_code.format(f"{attr_data.name}_spectrum_cmd"))
@@ -1,7 +1,10 @@
1
+ """Utilities for testing devices."""
2
+
1
3
  from . import __pytest_assert_rewrite # noqa: F401
2
4
  from ._assert import (
3
5
  ApproxTable,
4
6
  MonitorQueue,
7
+ StatusWatcher,
5
8
  approx_value,
6
9
  assert_configuration,
7
10
  assert_describe_signal,
@@ -14,7 +17,6 @@ from ._mock_signal_utils import (
14
17
  get_mock,
15
18
  get_mock_put,
16
19
  mock_puts_blocked,
17
- reset_mock_put_calls,
18
20
  set_mock_put_proceeds,
19
21
  set_mock_value,
20
22
  set_mock_values,
@@ -24,24 +26,36 @@ from ._one_of_everything import (
24
26
  ExampleTable,
25
27
  OneOfEverythingDevice,
26
28
  ParentOfEverythingDevice,
29
+ float_array_value,
30
+ int_array_value,
31
+ )
32
+ from ._single_derived import (
33
+ BeamstopPosition,
34
+ Exploder,
35
+ MovableBeamstop,
36
+ ReadOnlyBeamstop,
27
37
  )
28
38
  from ._wait_for_pending import wait_for_pending_wakeups
29
39
 
40
+ # The order of this list determines the order of the documentation,
41
+ # so does not match the alphabetical order of the imports
30
42
  __all__ = [
31
43
  "approx_value",
44
+ # Assert functions
45
+ "assert_value",
46
+ "assert_reading",
32
47
  "assert_configuration",
33
48
  "assert_describe_signal",
34
49
  "assert_emitted",
35
- "assert_reading",
36
- "assert_value",
37
- "callback_on_mock_put",
50
+ # Mocking utilities
38
51
  "get_mock",
52
+ "set_mock_value",
53
+ "set_mock_values",
39
54
  "get_mock_put",
55
+ "callback_on_mock_put",
40
56
  "mock_puts_blocked",
41
- "reset_mock_put_calls",
42
57
  "set_mock_put_proceeds",
43
- "set_mock_value",
44
- "set_mock_values",
58
+ # Wait for pending wakeups
45
59
  "wait_for_pending_wakeups",
46
60
  "ExampleEnum",
47
61
  "ExampleTable",
@@ -49,4 +63,12 @@ __all__ = [
49
63
  "ParentOfEverythingDevice",
50
64
  "MonitorQueue",
51
65
  "ApproxTable",
66
+ "StatusWatcher",
67
+ "int_array_value",
68
+ "float_array_value",
69
+ # Derived examples
70
+ "BeamstopPosition",
71
+ "Exploder",
72
+ "MovableBeamstop",
73
+ "ReadOnlyBeamstop",
52
74
  ]
@@ -1,7 +1,8 @@
1
1
  import asyncio
2
- import time
2
+ from collections.abc import Mapping
3
3
  from contextlib import AbstractContextManager
4
- from typing import Any
4
+ from typing import Any, cast
5
+ from unittest.mock import Mock, call
5
6
 
6
7
  import pytest
7
8
  from bluesky.protocols import Reading
@@ -13,89 +14,89 @@ from ophyd_async.core import (
13
14
  SignalDatatypeT,
14
15
  SignalR,
15
16
  Table,
17
+ WatchableAsyncStatus,
18
+ Watcher,
16
19
  )
17
20
 
21
+ from ._utils import T
22
+
18
23
 
19
24
  def approx_value(value: Any):
25
+ """Allow any value to be compared to another in tests.
26
+
27
+ This is needed because numpy arrays give a numpy array back when compared,
28
+ not a bool. This means that you can't ``assert array1==array2``. Numpy
29
+ arrays can be wrapped with `pytest.approx`, but this doesn't work for
30
+ `Table` instances: in this case we use `ApproxTable`.
31
+ """
20
32
  return ApproxTable(value) if isinstance(value, Table) else pytest.approx(value)
21
33
 
22
34
 
23
35
  async def assert_value(signal: SignalR[SignalDatatypeT], value: Any) -> None:
24
- """Assert a signal's value and compare it an expected signal.
25
-
26
- Parameters
27
- ----------
28
- signal:
29
- signal with get_value.
30
- value:
31
- The expected value from the signal.
32
-
33
- Notes
34
- -----
35
- Example usage::
36
- await assert_value(signal, value)
36
+ """Assert that a Signal has the given value.
37
37
 
38
+ :param signal: Signal with get_value.
39
+ :param value: The expected value from the signal.
38
40
  """
39
41
  actual_value = await signal.get_value()
40
42
  assert approx_value(value) == actual_value
41
43
 
42
44
 
43
45
  async def assert_reading(
44
- readable: AsyncReadable, expected_reading: dict[str, Reading]
46
+ readable: AsyncReadable,
47
+ expected_reading: Mapping[str, Mapping[str, Any]],
45
48
  ) -> None:
46
- """Assert readings from readable.
47
-
48
- Parameters
49
- ----------
50
- readable:
51
- Callable with readable.read function that generate readings.
52
-
53
- reading:
54
- The expected readings from the readable.
55
-
56
- Notes
57
- -----
58
- Example usage::
59
- await assert_reading(readable, reading)
49
+ """Assert that a readable Device has the given reading.
60
50
 
51
+ :param readable: Device with an async ``read()`` method to get the reading from.
52
+ :param expected_reading: The expected reading from the readable.
61
53
  """
62
54
  actual_reading = await readable.read()
63
- approx_expected_reading = {
64
- k: dict(v, value=approx_value(expected_reading[k]["value"]))
65
- for k, v in expected_reading.items()
55
+ _assert_readings_approx_equal(expected_reading, actual_reading)
56
+
57
+
58
+ def _approx_reading(expected: Mapping[str, Any], actual: Reading) -> Reading:
59
+ ret = dict(
60
+ expected,
61
+ value=approx_value(expected["value"]),
62
+ timestamp=pytest.approx(expected["timestamp"], rel=0.1)
63
+ if "timestamp" in expected
64
+ else actual["timestamp"],
65
+ )
66
+ if "alarm_severity" in actual and "alarm_severity" not in expected:
67
+ ret["alarm_severity"] = actual["alarm_severity"]
68
+ return cast(Reading, ret)
69
+
70
+
71
+ def _assert_readings_approx_equal(
72
+ expected: Mapping[str, Mapping[str, Any]], actual: Mapping[str, Reading]
73
+ ):
74
+ assert actual == {
75
+ k: _approx_reading(v, actual[k]) for k, v in expected.items() if k in actual
66
76
  }
67
- assert actual_reading == approx_expected_reading
68
77
 
69
78
 
70
79
  async def assert_configuration(
71
80
  configurable: AsyncConfigurable,
72
- configuration: dict[str, Reading],
81
+ configuration: dict[str, dict[str, Any]],
73
82
  ) -> None:
74
- """Assert readings from Configurable.
75
-
76
- Parameters
77
- ----------
78
- configurable:
79
- Configurable with Configurable.read function that generate readings.
80
-
81
- configuration:
82
- The expected readings from configurable.
83
-
84
- Notes
85
- -----
86
- Example usage::
87
- await assert_configuration(configurable configuration)
83
+ """Assert that a configurable Device has the given configuration.
88
84
 
85
+ :param configurable:
86
+ Device with an async ``read_configuration()`` method to get the
87
+ configuration from.
88
+ :param configuration: The expected configuration from the configurable.
89
89
  """
90
90
  actual_configuration = await configurable.read_configuration()
91
- approx_expected_configuration = {
92
- k: dict(v, value=approx_value(configuration[k]["value"]))
93
- for k, v in configuration.items()
94
- }
95
- assert actual_configuration == approx_expected_configuration
91
+ _assert_readings_approx_equal(configuration, actual_configuration)
96
92
 
97
93
 
98
94
  async def assert_describe_signal(signal: SignalR, /, **metadata):
95
+ """Assert the describe of a signal matches the expected metadata.
96
+
97
+ :param signal: The signal to describe.
98
+ :param metadata: The expected metadata.
99
+ """
99
100
  actual_describe = await signal.describe()
100
101
  assert list(actual_describe) == [signal.name]
101
102
  (actual_datakey,) = actual_describe.values()
@@ -104,23 +105,18 @@ async def assert_describe_signal(signal: SignalR, /, **metadata):
104
105
 
105
106
 
106
107
  def assert_emitted(docs: dict[str, list[dict]], **numbers: int):
107
- """Assert emitted document generated by running a Bluesky plan
108
-
109
- Parameters
110
- ----------
111
- Doc:
112
- A dictionary
113
-
114
- numbers:
115
- expected emission in kwarg from
116
-
117
- Notes
118
- -----
119
- Example usage::
120
- docs = defaultdict(list)
121
- RE.subscribe(lambda name, doc: docs[name].append(doc))
122
- RE(my_plan())
123
- assert_emitted(docs, start=1, descriptor=1, event=1, stop=1)
108
+ """Assert emitted document generated by running a Bluesky plan.
109
+
110
+ :param docs: A mapping of document type -> list of documents that have been emitted.
111
+ :param numbers: The number of each document type expected.
112
+
113
+ :example:
114
+ ```python
115
+ docs = defaultdict(list)
116
+ RE.subscribe(lambda name, doc: docs[name].append(doc))
117
+ RE(my_plan())
118
+ assert_emitted(docs, start=1, descriptor=1, event=1, stop=1)
119
+ ```
124
120
  """
125
121
  assert list(docs) == list(numbers)
126
122
  actual_numbers = {name: len(d) for name, d in docs.items()}
@@ -128,6 +124,14 @@ def assert_emitted(docs: dict[str, list[dict]], **numbers: int):
128
124
 
129
125
 
130
126
  class ApproxTable:
127
+ """For approximating two tables are equivalent.
128
+
129
+ :param expected: The expected table.
130
+ :param rel: The relative tolerance.
131
+ :param abs: The absolute tolerance.
132
+ :param nan_ok: Whether NaNs are allowed.
133
+ """
134
+
131
135
  def __init__(self, expected: Table, rel=None, abs=None, nan_ok: bool = False):
132
136
  self.expected = expected
133
137
  self.rel = rel
@@ -144,29 +148,23 @@ class ApproxTable:
144
148
 
145
149
 
146
150
  class MonitorQueue(AbstractContextManager):
151
+ """Monitors a `Signal` and stores its updates."""
152
+
147
153
  def __init__(self, signal: SignalR):
148
154
  self.signal = signal
149
155
  self.updates: asyncio.Queue[dict[str, Reading]] = asyncio.Queue()
150
- self.signal.subscribe(self.updates.put_nowait)
151
156
 
152
157
  async def assert_updates(self, expected_value):
153
158
  # Get an update, value and reading
154
- expected_type = type(expected_value)
155
- expected_value = approx_value(expected_value)
156
- update = await self.updates.get()
157
- value = await self.signal.get_value()
158
- reading = await self.signal.read()
159
- # Check they match what we expected
160
- assert value == expected_value
161
- assert type(value) is expected_type
159
+ update = await asyncio.wait_for(self.updates.get(), timeout=5)
160
+ await assert_value(self.signal, expected_value)
162
161
  expected_reading = {
163
162
  self.signal.name: {
164
163
  "value": expected_value,
165
- "timestamp": pytest.approx(time.time(), rel=0.1),
166
- "alarm_severity": 0,
167
164
  }
168
165
  }
169
- assert reading == update == expected_reading
166
+ await assert_reading(self.signal, expected_reading)
167
+ _assert_readings_approx_equal(expected_reading, update)
170
168
 
171
169
  def __enter__(self):
172
170
  self.signal.subscribe(self.updates.put_nowait)
@@ -174,3 +172,67 @@ class MonitorQueue(AbstractContextManager):
174
172
 
175
173
  def __exit__(self, exc_type, exc_value, traceback):
176
174
  self.signal.clear_sub(self.updates.put_nowait)
175
+
176
+
177
+ class StatusWatcher(Watcher[T]):
178
+ """Watches an `AsyncStatus`, storing the calls within."""
179
+
180
+ def __init__(self, status: WatchableAsyncStatus) -> None:
181
+ self._event = asyncio.Event()
182
+ self.mock = Mock()
183
+ """Mock that stores watcher updates from the status."""
184
+ status.watch(self)
185
+
186
+ def __call__(
187
+ self,
188
+ current: T | None = None,
189
+ initial: T | None = None,
190
+ target: T | None = None,
191
+ name: str | None = None,
192
+ unit: str | None = None,
193
+ precision: int | None = None,
194
+ fraction: float | None = None,
195
+ time_elapsed: float | None = None,
196
+ time_remaining: float | None = None,
197
+ ) -> Any:
198
+ self.mock(
199
+ current=current,
200
+ initial=initial,
201
+ target=target,
202
+ name=name,
203
+ unit=unit,
204
+ precision=precision,
205
+ fraction=fraction,
206
+ time_elapsed=time_elapsed,
207
+ time_remaining=time_remaining,
208
+ )
209
+ self._event.set()
210
+
211
+ async def wait_for_call(
212
+ self,
213
+ current: T | None = None,
214
+ initial: T | None = None,
215
+ target: T | None = None,
216
+ name: str | None = None,
217
+ unit: str | None = None,
218
+ precision: int | None = None,
219
+ fraction: float | None = None,
220
+ # Any so we can use pytest.approx
221
+ time_elapsed: float | Any = None,
222
+ time_remaining: float | Any = None,
223
+ ):
224
+ await asyncio.wait_for(self._event.wait(), timeout=1)
225
+ assert self.mock.call_count == 1
226
+ assert self.mock.call_args == call(
227
+ current=current,
228
+ initial=initial,
229
+ target=target,
230
+ name=name,
231
+ unit=unit,
232
+ precision=precision,
233
+ fraction=fraction,
234
+ time_elapsed=time_elapsed,
235
+ time_remaining=time_remaining,
236
+ )
237
+ self.mock.reset_mock()
238
+ self._event.clear()