ophyd-async 0.9.0a2__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 (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 +37 -8
  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 +137 -81
  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 +87 -0
  132. ophyd_async/testing/_utils.py +3 -0
  133. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a1.dist-info}/METADATA +25 -26
  134. ophyd_async-0.10.0a1.dist-info/RECORD +149 -0
  135. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a1.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.0a1.dist-info/licenses}/LICENSE +0 -0
  151. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a1.dist-info}/top_level.txt +0 -0
@@ -7,17 +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
+ """A numpy dtype like [](#numpy.float64)."""
14
+
15
+
13
16
  # To be a 1D array shape should really be tuple[int], but np.array()
14
17
  # currently produces tuple[int, ...] even when it has 1D input args
15
18
  # https://github.com/numpy/numpy/issues/28077#issuecomment-2566485178
16
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
+
17
24
  Primitive = bool | int | float | str
18
- # NOTE: if you change this union then update the docs to match
19
25
  SignalDatatype = (
20
26
  Primitive
27
+ | StrictEnum
21
28
  | Array1D[np.bool_]
22
29
  | Array1D[np.int8]
23
30
  | Array1D[np.uint8]
@@ -30,23 +37,33 @@ SignalDatatype = (
30
37
  | Array1D[np.float32]
31
38
  | Array1D[np.float64]
32
39
  | np.ndarray
33
- | StrictEnum
34
40
  | Sequence[str]
35
41
  | Sequence[StrictEnum]
36
42
  | Table
37
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
+ """
38
54
  # TODO: These typevars will not be needed when we drop python 3.11
39
55
  # as you can do MyConverter[SignalType: SignalTypeUnion]:
40
56
  # rather than MyConverter(Generic[SignalType])
41
57
  PrimitiveT = TypeVar("PrimitiveT", bound=Primitive)
42
58
  SignalDatatypeT = TypeVar("SignalDatatypeT", bound=SignalDatatype)
59
+ """A typevar for a [](#SignalDatatype)."""
43
60
  SignalDatatypeV = TypeVar("SignalDatatypeV", bound=SignalDatatype)
44
61
  EnumT = TypeVar("EnumT", bound=StrictEnum)
45
62
  TableT = TypeVar("TableT", bound=Table)
46
63
 
47
64
 
48
65
  class SignalBackend(Generic[SignalDatatypeT]):
49
- """A read/write/monitor backend for a Signals"""
66
+ """A read/write/monitor backend for a Signals."""
50
67
 
51
68
  def __init__(self, datatype: type[SignalDatatypeT] | None):
52
69
  self.datatype = datatype
@@ -55,36 +72,37 @@ class SignalBackend(Generic[SignalDatatypeT]):
55
72
  def source(self, name: str, read: bool) -> str:
56
73
  """Return source of signal.
57
74
 
58
- 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.
59
77
  """
60
78
 
61
79
  @abstractmethod
62
80
  async def connect(self, timeout: float):
63
- """Connect to underlying hardware"""
81
+ """Connect to underlying hardware."""
64
82
 
65
83
  @abstractmethod
66
84
  async def put(self, value: SignalDatatypeT | None, wait: bool):
67
- """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."""
68
86
 
69
87
  @abstractmethod
70
88
  async def get_datakey(self, source: str) -> DataKey:
71
- """Metadata like source, dtype, shape, precision, units"""
89
+ """Metadata like source, dtype, shape, precision, units."""
72
90
 
73
91
  @abstractmethod
74
92
  async def get_reading(self) -> Reading[SignalDatatypeT]:
75
- """The current value, timestamp and severity"""
93
+ """Return the current value, timestamp and severity."""
76
94
 
77
95
  @abstractmethod
78
96
  async def get_value(self) -> SignalDatatypeT:
79
- """The current value"""
97
+ """Return the current value."""
80
98
 
81
99
  @abstractmethod
82
100
  async def get_setpoint(self) -> SignalDatatypeT:
83
- """The point that a signal was requested to move to."""
101
+ """Return the point that a signal was requested to move to."""
84
102
 
85
103
  @abstractmethod
86
- def set_callback(self, callback: Callback[T] | None) -> None:
87
- """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."""
88
106
 
89
107
 
90
108
  _primitive_dtype: dict[type[Primitive], Dtype] = {
@@ -96,10 +114,19 @@ _primitive_dtype: dict[type[Primitive], Dtype] = {
96
114
 
97
115
 
98
116
  class SignalMetadata(TypedDict, total=False):
117
+ """Metadata for a signal. No field is required."""
118
+
99
119
  limits: Limits
120
+ """The control, display, warning and alarm limits for a numeric datatype."""
121
+
100
122
  choices: list[str]
123
+ """The choice of possible values for an enum datatype."""
124
+
101
125
  precision: int
126
+ """The number of digits after the decimal place to display for a float datatype."""
127
+
102
128
  units: str
129
+ """The engineering units of the value for a numeric datatype."""
103
130
 
104
131
 
105
132
  def _datakey_dtype(datatype: type[SignalDatatype]) -> Dtype:
@@ -156,6 +183,7 @@ def make_datakey(
156
183
  source: str,
157
184
  metadata: SignalMetadata,
158
185
  ) -> DataKey:
186
+ """Make a DataKey for a given datatype."""
159
187
  dtn = _datakey_dtype_numpy(datatype, value)
160
188
  return DataKey(
161
189
  dtype=_datakey_dtype(datatype),
@@ -165,3 +193,18 @@ def make_datakey(
165
193
  source=source,
166
194
  **metadata,
167
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(),
@@ -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
@@ -40,16 +63,19 @@ class Table(BaseModel):
40
63
  model_config = ConfigDict(extra="allow")
41
64
 
42
65
  # Add an init method to match the above model config, otherwise the type
43
- # checker will not think we can pass arbitrary kwargs into the base class init
66
+ # checker will not think we can pass arbitrary kwargs into the base class init...
44
67
  def __init__(self, **kwargs):
45
68
  super().__init__(**kwargs)
46
69
 
47
70
  @classmethod
48
71
  def __init_subclass__(cls):
49
- # But forbit extra in subclasses so it gets validated
72
+ # ...but forbid extra in subclasses so it gets validated
50
73
  cls.model_config = ConfigDict(validate_assignment=True, extra="forbid")
51
74
  # Change fields to have the correct annotations
52
- 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():
53
79
  if get_origin(anno) is np.ndarray:
54
80
  dtype = get_dtype(anno)
55
81
  new_anno = Annotated[
@@ -67,7 +93,6 @@ class Table(BaseModel):
67
93
 
68
94
  def __add__(self, right: TableSubclass) -> TableSubclass:
69
95
  """Concatenate the arrays in field values."""
70
-
71
96
  if type(right) is not type(self):
72
97
  raise RuntimeError(
73
98
  f"{right} is not a `Table`, or is not the same "
@@ -84,6 +109,7 @@ class Table(BaseModel):
84
109
  )
85
110
 
86
111
  def numpy_dtype(self) -> np.dtype:
112
+ """Return a numpy dtype for a single row."""
87
113
  dtype = []
88
114
  for k, v in self:
89
115
  if isinstance(v, np.ndarray):
@@ -95,6 +121,7 @@ class Table(BaseModel):
95
121
  return np.dtype(dtype)
96
122
 
97
123
  def numpy_table(self, selection: slice | None = None) -> np.ndarray:
124
+ """Return a numpy array of the whole table."""
98
125
  array = None
99
126
  for k, v in self:
100
127
  if selection:
@@ -109,7 +136,9 @@ class Table(BaseModel):
109
136
 
110
137
  @model_validator(mode="before")
111
138
  @classmethod
112
- 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.
113
142
  if isinstance(data, dict):
114
143
  data_dict = data
115
144
  elif isinstance(data, Table):
@@ -137,7 +166,7 @@ class Table(BaseModel):
137
166
  return data_dict
138
167
 
139
168
  @model_validator(mode="after")
140
- def validate_lengths(self) -> Table:
169
+ def _validate_lengths(self) -> Table:
141
170
  lengths: dict[int, set[str]] = {}
142
171
  for field_name, field_value in self:
143
172
  lengths.setdefault(len(field_value), set()).add(field_name)