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
@@ -7,14 +7,24 @@ from bluesky.protocols import Reading
7
7
  from event_model import DataKey, Dtype, Limits
8
8
 
9
9
  from ._table import Table
10
- from ._utils import Callback, StrictEnum, T
10
+ from ._utils import Callback, StrictEnum, get_enum_cls
11
11
 
12
12
  DTypeScalar_co = TypeVar("DTypeScalar_co", covariant=True, bound=np.generic)
13
- Array1D = np.ndarray[tuple[int], np.dtype[DTypeScalar_co]]
13
+ """A numpy dtype like [](#numpy.float64)."""
14
+
15
+
16
+ # To be a 1D array shape should really be tuple[int], but np.array()
17
+ # currently produces tuple[int, ...] even when it has 1D input args
18
+ # https://github.com/numpy/numpy/issues/28077#issuecomment-2566485178
19
+ Array1D = np.ndarray[tuple[int, ...], np.dtype[DTypeScalar_co]]
20
+ """A type alias for a 1D numpy array with a specific scalar data type.
21
+
22
+ E.g. `Array1D[np.float64]` is a 1D numpy array of 64-bit floats."""
23
+
14
24
  Primitive = bool | int | float | str
15
- # NOTE: if you change this union then update the docs to match
16
25
  SignalDatatype = (
17
26
  Primitive
27
+ | StrictEnum
18
28
  | Array1D[np.bool_]
19
29
  | Array1D[np.int8]
20
30
  | Array1D[np.uint8]
@@ -27,23 +37,33 @@ SignalDatatype = (
27
37
  | Array1D[np.float32]
28
38
  | Array1D[np.float64]
29
39
  | np.ndarray
30
- | StrictEnum
31
40
  | Sequence[str]
32
41
  | Sequence[StrictEnum]
33
42
  | Table
34
43
  )
44
+ """The supported [](#Signal) datatypes:
45
+
46
+ - A python primitive [](#bool), [](#int), [](#float), [](#str)
47
+ - A [](#StrictEnum) or [](#SubsetEnum) subclass
48
+ - A fixed datatype [](#Array1D) of numpy bool, signed and unsigned integers or float
49
+ - A [](#numpy.ndarray) which can change dimensions and datatype at runtime
50
+ - A sequence of [](#str)
51
+ - A sequence of [](#StrictEnum) or [](#SubsetEnum) subclass
52
+ - A [](#Table) subclass
53
+ """
35
54
  # TODO: These typevars will not be needed when we drop python 3.11
36
55
  # as you can do MyConverter[SignalType: SignalTypeUnion]:
37
56
  # rather than MyConverter(Generic[SignalType])
38
57
  PrimitiveT = TypeVar("PrimitiveT", bound=Primitive)
39
58
  SignalDatatypeT = TypeVar("SignalDatatypeT", bound=SignalDatatype)
59
+ """A typevar for a [](#SignalDatatype)."""
40
60
  SignalDatatypeV = TypeVar("SignalDatatypeV", bound=SignalDatatype)
41
61
  EnumT = TypeVar("EnumT", bound=StrictEnum)
42
62
  TableT = TypeVar("TableT", bound=Table)
43
63
 
44
64
 
45
65
  class SignalBackend(Generic[SignalDatatypeT]):
46
- """A read/write/monitor backend for a Signals"""
66
+ """A read/write/monitor backend for a Signals."""
47
67
 
48
68
  def __init__(self, datatype: type[SignalDatatypeT] | None):
49
69
  self.datatype = datatype
@@ -52,36 +72,37 @@ class SignalBackend(Generic[SignalDatatypeT]):
52
72
  def source(self, name: str, read: bool) -> str:
53
73
  """Return source of signal.
54
74
 
55
- Signals may pass a name to the backend, which can be used or discarded.
75
+ :param name: The name of the signal, which can be used or discarded.
76
+ :param read: True if we want the source for reading, False if writing.
56
77
  """
57
78
 
58
79
  @abstractmethod
59
80
  async def connect(self, timeout: float):
60
- """Connect to underlying hardware"""
81
+ """Connect to underlying hardware."""
61
82
 
62
83
  @abstractmethod
63
84
  async def put(self, value: SignalDatatypeT | None, wait: bool):
64
- """Put a value to the PV, if wait then wait for completion"""
85
+ """Put a value to the PV, if wait then wait for completion."""
65
86
 
66
87
  @abstractmethod
67
88
  async def get_datakey(self, source: str) -> DataKey:
68
- """Metadata like source, dtype, shape, precision, units"""
89
+ """Metadata like source, dtype, shape, precision, units."""
69
90
 
70
91
  @abstractmethod
71
92
  async def get_reading(self) -> Reading[SignalDatatypeT]:
72
- """The current value, timestamp and severity"""
93
+ """Return the current value, timestamp and severity."""
73
94
 
74
95
  @abstractmethod
75
96
  async def get_value(self) -> SignalDatatypeT:
76
- """The current value"""
97
+ """Return the current value."""
77
98
 
78
99
  @abstractmethod
79
100
  async def get_setpoint(self) -> SignalDatatypeT:
80
- """The point that a signal was requested to move to."""
101
+ """Return the point that a signal was requested to move to."""
81
102
 
82
103
  @abstractmethod
83
- def set_callback(self, callback: Callback[T] | None) -> None:
84
- """Observe changes to the current value, timestamp and severity"""
104
+ def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None:
105
+ """Observe changes to the current value, timestamp and severity."""
85
106
 
86
107
 
87
108
  _primitive_dtype: dict[type[Primitive], Dtype] = {
@@ -93,10 +114,19 @@ _primitive_dtype: dict[type[Primitive], Dtype] = {
93
114
 
94
115
 
95
116
  class SignalMetadata(TypedDict, total=False):
117
+ """Metadata for a signal. No field is required."""
118
+
96
119
  limits: Limits
120
+ """The control, display, warning and alarm limits for a numeric datatype."""
121
+
97
122
  choices: list[str]
123
+ """The choice of possible values for an enum datatype."""
124
+
98
125
  precision: int
126
+ """The number of digits after the decimal place to display for a float datatype."""
127
+
99
128
  units: str
129
+ """The engineering units of the value for a numeric datatype."""
100
130
 
101
131
 
102
132
  def _datakey_dtype(datatype: type[SignalDatatype]) -> Dtype:
@@ -153,6 +183,7 @@ def make_datakey(
153
183
  source: str,
154
184
  metadata: SignalMetadata,
155
185
  ) -> DataKey:
186
+ """Make a DataKey for a given datatype."""
156
187
  dtn = _datakey_dtype_numpy(datatype, value)
157
188
  return DataKey(
158
189
  dtype=_datakey_dtype(datatype),
@@ -162,3 +193,18 @@ def make_datakey(
162
193
  source=source,
163
194
  **metadata,
164
195
  )
196
+
197
+
198
+ def make_metadata(
199
+ datatype: type[SignalDatatypeT] | None,
200
+ units: str | None = None,
201
+ precision: int | None = None,
202
+ ) -> SignalMetadata:
203
+ metadata: SignalMetadata = {}
204
+ if units is not None:
205
+ metadata["units"] = units
206
+ if precision is not None:
207
+ metadata["precision"] = precision
208
+ if enum_cls := get_enum_cls(datatype):
209
+ metadata["choices"] = [v.value for v in enum_cls]
210
+ return metadata
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import time
4
+ import typing
4
5
  from abc import abstractmethod
5
6
  from collections.abc import Sequence
6
7
  from dataclasses import dataclass
@@ -19,9 +20,9 @@ from ._signal_backend import (
19
20
  SignalBackend,
20
21
  SignalDatatype,
21
22
  SignalDatatypeT,
22
- SignalMetadata,
23
23
  TableT,
24
24
  make_datakey,
25
+ make_metadata,
25
26
  )
26
27
  from ._table import Table
27
28
  from ._utils import Callback, get_dtype, get_enum_cls
@@ -94,9 +95,9 @@ class TableSoftConverter(SoftConverter[TableT]):
94
95
  @lru_cache
95
96
  def make_converter(datatype: type[SignalDatatype]) -> SoftConverter:
96
97
  enum_cls = get_enum_cls(datatype)
97
- if datatype == Sequence[str]:
98
+ if datatype in (Sequence[str], typing.Sequence[str]):
98
99
  return SequenceStrSoftConverter()
99
- elif get_origin(datatype) == Sequence and enum_cls:
100
+ elif get_origin(datatype) in (Sequence, typing.Sequence) and enum_cls:
100
101
  return SequenceEnumSoftConverter(enum_cls)
101
102
  elif datatype is np.ndarray:
102
103
  return NDArraySoftConverter()
@@ -114,7 +115,16 @@ def make_converter(datatype: type[SignalDatatype]) -> SoftConverter:
114
115
 
115
116
 
116
117
  class SoftSignalBackend(SignalBackend[SignalDatatypeT]):
117
- """An backend to a soft Signal, for test signals see ``MockSignalBackend``."""
118
+ """An backend to a soft Signal, for test signals see [](#MockSignalBackend).
119
+
120
+ :param datatype: The datatype of the signal, defaults to float if not given.
121
+ :param initial_value:
122
+ The initial value of the signal, defaults to the "empty", "zero" or
123
+ "default" value of the datatype if not given.
124
+ :param units: The units for numeric datatypes.
125
+ :param precision:
126
+ The number of digits after the decimal place to display for a float datatype.
127
+ """
118
128
 
119
129
  def __init__(
120
130
  self,
@@ -126,13 +136,7 @@ class SoftSignalBackend(SignalBackend[SignalDatatypeT]):
126
136
  # Create the right converter for the datatype
127
137
  self.converter = make_converter(datatype or float)
128
138
  # Add the extra static metadata to the dictionary
129
- self.metadata: SignalMetadata = {}
130
- if units is not None:
131
- self.metadata["units"] = units
132
- if precision is not None:
133
- self.metadata["precision"] = precision
134
- if enum_cls := get_enum_cls(datatype):
135
- self.metadata["choices"] = [v.value for v in enum_cls]
139
+ self.metadata = make_metadata(datatype, units, precision)
136
140
  # Create and set the initial value
137
141
  self.initial_value = self.converter.write_value(initial_value)
138
142
  self.reading: Reading[SignalDatatypeT]
@@ -141,6 +145,7 @@ class SoftSignalBackend(SignalBackend[SignalDatatypeT]):
141
145
  super().__init__(datatype)
142
146
 
143
147
  def set_value(self, value: SignalDatatypeT):
148
+ """Set the current value, alarm and timestamp."""
144
149
  self.reading = Reading(
145
150
  value=self.converter.write_value(value),
146
151
  timestamp=time.monotonic(),
@@ -175,7 +180,8 @@ class SoftSignalBackend(SignalBackend[SignalDatatypeT]):
175
180
  return self.reading["value"]
176
181
 
177
182
  def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None:
183
+ if callback and self.callback:
184
+ raise RuntimeError("Cannot set a callback when one is already set")
178
185
  if callback:
179
- assert not self.callback, "Cannot set a callback when one is already set"
180
186
  callback(self.reading)
181
187
  self.callback = callback
@@ -1,15 +1,13 @@
1
1
  """Equivalent of bluesky.protocols.Status for asynchronous tasks."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import asyncio
4
6
  import functools
5
7
  import time
6
- from collections.abc import AsyncIterator, Callable, Coroutine
8
+ from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine
7
9
  from dataclasses import asdict, replace
8
- from typing import (
9
- Generic,
10
- TypeVar,
11
- cast,
12
- )
10
+ from typing import Generic
13
11
 
14
12
  from bluesky.protocols import Status
15
13
 
@@ -17,12 +15,9 @@ from ._device import Device
17
15
  from ._protocol import Watcher
18
16
  from ._utils import Callback, P, T, WatcherUpdate
19
17
 
20
- AS = TypeVar("AS", bound="AsyncStatus")
21
- WAS = TypeVar("WAS", bound="WatchableAsyncStatus")
22
-
23
18
 
24
- class AsyncStatusBase(Status):
25
- """Convert asyncio awaitable to bluesky Status interface"""
19
+ class AsyncStatusBase(Status, Awaitable[None]):
20
+ """Convert asyncio awaitable to bluesky Status interface."""
26
21
 
27
22
  def __init__(self, awaitable: Coroutine | asyncio.Task, name: str | None = None):
28
23
  if isinstance(awaitable, asyncio.Task):
@@ -47,6 +42,12 @@ class AsyncStatusBase(Status):
47
42
  callback(self)
48
43
 
49
44
  def exception(self, timeout: float | None = 0.0) -> BaseException | None:
45
+ """Return any exception raised by the task.
46
+
47
+ :param timeout:
48
+ Taken for compatibility with the Status interface, but must be 0.0 as we
49
+ cannot wait for an async function in a sync call.
50
+ """
50
51
  if timeout != 0.0:
51
52
  raise ValueError(
52
53
  "cannot honour any timeout other than 0 in an asynchronous function"
@@ -88,27 +89,52 @@ class AsyncStatusBase(Status):
88
89
 
89
90
 
90
91
  class AsyncStatus(AsyncStatusBase):
91
- """Convert asyncio awaitable to bluesky Status interface"""
92
+ """Convert an asyncio awaitable to bluesky Status interface.
93
+
94
+ :param awaitable: The coroutine or task to await.
95
+ :param name: The name of the device, if available.
96
+
97
+ For example:
98
+ ```python
99
+ status = AsyncStatus(asyncio.sleep(1))
100
+ assert not status.done
101
+ await status # waits for 1 second
102
+ assert status.done
103
+ ```
104
+ """
92
105
 
93
106
  @classmethod
94
- def wrap(cls: type[AS], f: Callable[P, Coroutine]) -> Callable[P, AS]:
95
- """Wrap an async function in an AsyncStatus."""
107
+ def wrap(cls, f: Callable[P, Coroutine]) -> Callable[P, AsyncStatus]:
108
+ """Wrap an async function in an AsyncStatus and return it.
109
+
110
+ Used to make an async function conform to a bluesky protocol.
111
+
112
+ For example:
113
+ ```python
114
+ class MyDevice(Device):
115
+ @AsyncStatus.wrap
116
+ async def trigger(self):
117
+ await asyncio.sleep(1)
118
+ ```
119
+ """
96
120
 
97
121
  @functools.wraps(f)
98
- def wrap_f(*args: P.args, **kwargs: P.kwargs) -> AS:
122
+ def wrap_f(*args: P.args, **kwargs: P.kwargs) -> AsyncStatus:
99
123
  if args and isinstance(args[0], Device):
100
124
  name = args[0].name
101
125
  else:
102
126
  name = None
103
127
  return cls(f(*args, **kwargs), name=name)
104
128
 
105
- # type is actually functools._Wrapped[P, Awaitable, P, AS]
106
- # but functools._Wrapped is not necessarily available
107
- return cast(Callable[P, AS], wrap_f)
129
+ return wrap_f
108
130
 
109
131
 
110
132
  class WatchableAsyncStatus(AsyncStatusBase, Generic[T]):
111
- """Convert AsyncIterator of WatcherUpdates to bluesky Status interface."""
133
+ """Convert an asyncio async iterable to bluesky Status and Watcher interface.
134
+
135
+ :param iterator: The async iterable to await.
136
+ :param name: The name of the device, if available.
137
+ """
112
138
 
113
139
  def __init__(
114
140
  self, iterator: AsyncIterator[WatcherUpdate[T]], name: str | None = None
@@ -135,30 +161,52 @@ class WatchableAsyncStatus(AsyncStatusBase, Generic[T]):
135
161
  watcher(**vals)
136
162
 
137
163
  def watch(self, watcher: Watcher):
164
+ """Add a watcher to the status.
165
+
166
+ It is called:
167
+ - immediately if there has already been an update
168
+ - on every subsequent update
169
+ """
138
170
  self._watchers.append(watcher)
139
171
  if self._last_update:
140
172
  self._update_watcher(watcher, self._last_update)
141
173
 
142
174
  @classmethod
143
175
  def wrap(
144
- cls: type[WAS],
176
+ cls,
145
177
  f: Callable[P, AsyncIterator[WatcherUpdate[T]]],
146
- ) -> Callable[P, WAS]:
147
- """Wrap an AsyncIterator in a WatchableAsyncStatus."""
178
+ ) -> Callable[P, WatchableAsyncStatus[T]]:
179
+ """Wrap an AsyncIterator in a WatchableAsyncStatus.
180
+
181
+ For example:
182
+ ```python
183
+ class MyDevice(Device):
184
+ @WatchableAsyncStatus.wrap
185
+ async def trigger(self):
186
+ # sleep for a second, updating on progress every 0.1 seconds
187
+ for i in range(10):
188
+ yield WatcherUpdate(initial=0, current=i*0.1, target=1)
189
+ await asyncio.sleep(0.1)
190
+ ```
191
+ """
148
192
 
149
193
  @functools.wraps(f)
150
- def wrap_f(*args: P.args, **kwargs: P.kwargs) -> WAS:
194
+ def wrap_f(*args: P.args, **kwargs: P.kwargs) -> WatchableAsyncStatus[T]:
151
195
  if args and isinstance(args[0], Device):
152
196
  name = args[0].name
153
197
  else:
154
198
  name = None
155
199
  return cls(f(*args, **kwargs), name=name)
156
200
 
157
- return cast(Callable[P, WAS], wrap_f)
201
+ return wrap_f
158
202
 
159
203
 
160
204
  @AsyncStatus.wrap
161
205
  async def completed_status(exception: Exception | None = None):
206
+ """Return a completed AsyncStatus.
207
+
208
+ :param exception: If given, then raise this exception when awaited.
209
+ """
162
210
  if exception:
163
211
  raise exception
164
212
  return None
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Callable, Sequence
4
- from typing import Annotated, Any, TypeVar, get_origin
4
+ from typing import Annotated, Any, TypeVar, get_origin, get_type_hints
5
5
 
6
6
  import numpy as np
7
7
  from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -27,7 +27,30 @@ def _make_default_factory(dtype: np.dtype) -> Callable[[], np.ndarray]:
27
27
 
28
28
 
29
29
  class Table(BaseModel):
30
- """An abstraction of a Table of str to numpy array."""
30
+ """An abstraction of a Table where each field is a column.
31
+
32
+ For example:
33
+ ```python
34
+ >>> from ophyd_async.core import Table, Array1D
35
+ >>> import numpy as np
36
+ >>> from collections.abc import Sequence
37
+ >>> class MyTable(Table):
38
+ ... a: Array1D[np.int8]
39
+ ... b: Sequence[str]
40
+ ...
41
+ >>> t = MyTable(a=[1, 2], b=["x", "y"])
42
+ >>> len(t) # the length is the number of rows
43
+ 2
44
+ >>> t2 = t + t # adding tables together concatenates them
45
+ >>> t2.a
46
+ array([1, 2, 1, 2], dtype=int8)
47
+ >>> t2.b
48
+ ['x', 'y', 'x', 'y']
49
+ >>> t2[1] # slice a row
50
+ array([(2, b'y')], dtype=[('a', 'i1'), ('b', 'S40')])
51
+
52
+ ```
53
+ """
31
54
 
32
55
  # You can use Table in 2 ways:
33
56
  # 1. Table(**whatever_pva_gives_us) when pvi adds a Signal to a Device that is not
@@ -39,12 +62,20 @@ class Table(BaseModel):
39
62
  # so it is strictly checked against the BaseModel we are supplied.
40
63
  model_config = ConfigDict(extra="allow")
41
64
 
65
+ # Add an init method to match the above model config, otherwise the type
66
+ # checker will not think we can pass arbitrary kwargs into the base class init...
67
+ def __init__(self, **kwargs):
68
+ super().__init__(**kwargs)
69
+
42
70
  @classmethod
43
71
  def __init_subclass__(cls):
44
- # But forbit extra in subclasses so it gets validated
72
+ # ...but forbid extra in subclasses so it gets validated
45
73
  cls.model_config = ConfigDict(validate_assignment=True, extra="forbid")
46
74
  # Change fields to have the correct annotations
47
- for k, anno in cls.__annotations__.items():
75
+ # TODO: refactor so we don't need this to break circular imports
76
+ from ._signal_backend import Array1D
77
+
78
+ for k, anno in get_type_hints(cls, localns={"Array1D": Array1D}).items():
48
79
  if get_origin(anno) is np.ndarray:
49
80
  dtype = get_dtype(anno)
50
81
  new_anno = Annotated[
@@ -62,7 +93,6 @@ class Table(BaseModel):
62
93
 
63
94
  def __add__(self, right: TableSubclass) -> TableSubclass:
64
95
  """Concatenate the arrays in field values."""
65
-
66
96
  if type(right) is not type(self):
67
97
  raise RuntimeError(
68
98
  f"{right} is not a `Table`, or is not the same "
@@ -78,10 +108,8 @@ class Table(BaseModel):
78
108
  }
79
109
  )
80
110
 
81
- def __eq__(self, value: object) -> bool:
82
- return super().__eq__(value)
83
-
84
111
  def numpy_dtype(self) -> np.dtype:
112
+ """Return a numpy dtype for a single row."""
85
113
  dtype = []
86
114
  for k, v in self:
87
115
  if isinstance(v, np.ndarray):
@@ -93,19 +121,24 @@ class Table(BaseModel):
93
121
  return np.dtype(dtype)
94
122
 
95
123
  def numpy_table(self, selection: slice | None = None) -> np.ndarray:
124
+ """Return a numpy array of the whole table."""
96
125
  array = None
97
126
  for k, v in self:
98
127
  if selection:
99
128
  v = v[selection]
100
129
  if array is None:
101
130
  array = np.empty(v.shape, dtype=self.numpy_dtype())
102
- array[k] = v
103
- assert array is not None
131
+ array[k] = v # type: ignore
132
+ if array is None:
133
+ msg = "No arrays found in table"
134
+ raise ValueError(msg)
104
135
  return array
105
136
 
106
137
  @model_validator(mode="before")
107
138
  @classmethod
108
- def validate_array_dtypes(cls, data: Any) -> Any:
139
+ def _validate_array_dtypes(cls, data: Any) -> Any:
140
+ # Validates that array datatypes given in the table are of the
141
+ # correct format.
109
142
  if isinstance(data, dict):
110
143
  data_dict = data
111
144
  elif isinstance(data, Table):
@@ -123,19 +156,23 @@ class Table(BaseModel):
123
156
  # Convert to correct dtype, but only if we don't lose precision
124
157
  # as a result
125
158
  cast_value = np.array(data_value).astype(expected_dtype)
126
- assert np.array_equal(data_value, cast_value), (
127
- f"{field_name}: Cannot cast {data_value} to {expected_dtype} "
128
- "without losing precision"
129
- )
159
+ if not np.array_equal(data_value, cast_value):
160
+ msg = (
161
+ f"{field_name}: Cannot cast {data_value} to {expected_dtype} "
162
+ "without losing precision"
163
+ )
164
+ raise ValueError(msg)
130
165
  data_dict[field_name] = cast_value
131
166
  return data_dict
132
167
 
133
168
  @model_validator(mode="after")
134
- def validate_lengths(self) -> Table:
169
+ def _validate_lengths(self) -> Table:
135
170
  lengths: dict[int, set[str]] = {}
136
171
  for field_name, field_value in self:
137
172
  lengths.setdefault(len(field_value), set()).add(field_name)
138
- assert len(lengths) <= 1, f"Columns should be same length, got {lengths=}"
173
+ if len(lengths) > 1:
174
+ msg = f"Columns should be same length, got {lengths=}"
175
+ raise ValueError(msg)
139
176
  return self
140
177
 
141
178
  def __len__(self) -> int: