ophyd-async 0.7.0__py3-none-any.whl → 0.8.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 (70) hide show
  1. ophyd_async/_version.py +2 -2
  2. ophyd_async/core/__init__.py +23 -8
  3. ophyd_async/core/_detector.py +5 -10
  4. ophyd_async/core/_device.py +139 -66
  5. ophyd_async/core/_device_filler.py +191 -0
  6. ophyd_async/core/_device_save_loader.py +6 -7
  7. ophyd_async/core/_mock_signal_backend.py +32 -40
  8. ophyd_async/core/_mock_signal_utils.py +22 -16
  9. ophyd_async/core/_protocol.py +28 -8
  10. ophyd_async/core/_readable.py +5 -5
  11. ophyd_async/core/_signal.py +140 -152
  12. ophyd_async/core/_signal_backend.py +131 -64
  13. ophyd_async/core/_soft_signal_backend.py +125 -194
  14. ophyd_async/core/_status.py +22 -6
  15. ophyd_async/core/_table.py +97 -100
  16. ophyd_async/core/_utils.py +71 -18
  17. ophyd_async/epics/adaravis/_aravis_controller.py +2 -2
  18. ophyd_async/epics/adaravis/_aravis_io.py +7 -5
  19. ophyd_async/epics/adcore/_core_io.py +4 -6
  20. ophyd_async/epics/adcore/_hdf_writer.py +2 -2
  21. ophyd_async/epics/adcore/_utils.py +15 -10
  22. ophyd_async/epics/adkinetix/__init__.py +2 -1
  23. ophyd_async/epics/adkinetix/_kinetix_controller.py +6 -3
  24. ophyd_async/epics/adkinetix/_kinetix_io.py +3 -4
  25. ophyd_async/epics/adpilatus/_pilatus_controller.py +2 -2
  26. ophyd_async/epics/adpilatus/_pilatus_io.py +2 -3
  27. ophyd_async/epics/adsimdetector/_sim_controller.py +2 -2
  28. ophyd_async/epics/advimba/__init__.py +4 -1
  29. ophyd_async/epics/advimba/_vimba_controller.py +6 -3
  30. ophyd_async/epics/advimba/_vimba_io.py +7 -8
  31. ophyd_async/epics/demo/_sensor.py +8 -4
  32. ophyd_async/epics/eiger/_eiger.py +1 -2
  33. ophyd_async/epics/eiger/_eiger_controller.py +1 -1
  34. ophyd_async/epics/eiger/_eiger_io.py +2 -4
  35. ophyd_async/epics/eiger/_odin_io.py +4 -4
  36. ophyd_async/epics/pvi/__init__.py +2 -2
  37. ophyd_async/epics/pvi/_pvi.py +56 -321
  38. ophyd_async/epics/signal/__init__.py +3 -4
  39. ophyd_async/epics/signal/_aioca.py +184 -236
  40. ophyd_async/epics/signal/_common.py +35 -49
  41. ophyd_async/epics/signal/_p4p.py +254 -387
  42. ophyd_async/epics/signal/_signal.py +63 -21
  43. ophyd_async/fastcs/core.py +9 -0
  44. ophyd_async/fastcs/panda/__init__.py +4 -4
  45. ophyd_async/fastcs/panda/_block.py +18 -13
  46. ophyd_async/fastcs/panda/_control.py +3 -5
  47. ophyd_async/fastcs/panda/_hdf_panda.py +5 -19
  48. ophyd_async/fastcs/panda/_table.py +29 -51
  49. ophyd_async/fastcs/panda/_trigger.py +8 -8
  50. ophyd_async/fastcs/panda/_writer.py +2 -5
  51. ophyd_async/plan_stubs/_ensure_connected.py +3 -1
  52. ophyd_async/plan_stubs/_fly.py +2 -2
  53. ophyd_async/plan_stubs/_nd_attributes.py +5 -4
  54. ophyd_async/py.typed +0 -0
  55. ophyd_async/sim/demo/_pattern_detector/_pattern_detector_controller.py +1 -2
  56. ophyd_async/tango/__init__.py +2 -4
  57. ophyd_async/tango/base_devices/_base_device.py +76 -143
  58. ophyd_async/tango/demo/_counter.py +2 -2
  59. ophyd_async/tango/demo/_mover.py +2 -2
  60. ophyd_async/tango/signal/__init__.py +2 -4
  61. ophyd_async/tango/signal/_signal.py +29 -50
  62. ophyd_async/tango/signal/_tango_transport.py +38 -40
  63. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0a2.dist-info}/METADATA +8 -12
  64. ophyd_async-0.8.0a2.dist-info/RECORD +110 -0
  65. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0a2.dist-info}/WHEEL +1 -1
  66. ophyd_async/epics/signal/_epics_transport.py +0 -34
  67. ophyd_async-0.7.0.dist-info/RECORD +0 -108
  68. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0a2.dist-info}/LICENSE +0 -0
  69. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0a2.dist-info}/entry_points.txt +0 -0
  70. {ophyd_async-0.7.0.dist-info → ophyd_async-0.8.0a2.dist-info}/top_level.txt +0 -0
ophyd_async/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.7.0'
16
- __version_tuple__ = version_tuple = (0, 7, 0)
15
+ __version__ = version = '0.8.0a2'
16
+ __version_tuple__ = version_tuple = (0, 8, 0)
@@ -5,7 +5,8 @@ from ._detector import (
5
5
  StandardDetector,
6
6
  TriggerInfo,
7
7
  )
8
- from ._device import Device, DeviceCollector, DeviceVector
8
+ from ._device import Device, DeviceCollector, DeviceConnector, DeviceVector
9
+ from ._device_filler import DeviceFiller
9
10
  from ._device_save_loader import (
10
11
  all_at_once,
11
12
  get_signal_values,
@@ -22,6 +23,7 @@ from ._log import config_ophyd_async_logging
22
23
  from ._mock_signal_backend import MockSignalBackend
23
24
  from ._mock_signal_utils import (
24
25
  callback_on_mock_put,
26
+ get_mock,
25
27
  get_mock_put,
26
28
  mock_puts_blocked,
27
29
  reset_mock_put_calls,
@@ -62,9 +64,11 @@ from ._signal import (
62
64
  wait_for_value,
63
65
  )
64
66
  from ._signal_backend import (
65
- RuntimeSubsetEnum,
67
+ Array1D,
66
68
  SignalBackend,
67
- SubsetEnum,
69
+ SignalDatatype,
70
+ SignalDatatypeT,
71
+ make_datakey,
68
72
  )
69
73
  from ._soft_signal_backend import SignalMetadata, SoftSignalBackend
70
74
  from ._status import AsyncStatus, WatchableAsyncStatus, completed_status
@@ -73,14 +77,17 @@ from ._utils import (
73
77
  CALCULATE_TIMEOUT,
74
78
  DEFAULT_TIMEOUT,
75
79
  CalculatableTimeout,
80
+ Callback,
76
81
  NotConnected,
77
- ReadingValueCallback,
82
+ Reference,
83
+ StrictEnum,
84
+ SubsetEnum,
78
85
  T,
79
86
  WatcherUpdate,
80
87
  get_dtype,
88
+ get_enum_cls,
81
89
  get_unique,
82
90
  in_micros,
83
- is_pydantic_model,
84
91
  wait_for_connection,
85
92
  )
86
93
 
@@ -91,8 +98,10 @@ __all__ = [
91
98
  "StandardDetector",
92
99
  "TriggerInfo",
93
100
  "Device",
101
+ "DeviceConnector",
94
102
  "DeviceCollector",
95
103
  "DeviceVector",
104
+ "DeviceFiller",
96
105
  "all_at_once",
97
106
  "get_signal_values",
98
107
  "load_device",
@@ -108,6 +117,7 @@ __all__ = [
108
117
  "config_ophyd_async_logging",
109
118
  "MockSignalBackend",
110
119
  "callback_on_mock_put",
120
+ "get_mock",
111
121
  "get_mock_put",
112
122
  "mock_puts_blocked",
113
123
  "reset_mock_put_calls",
@@ -146,25 +156,30 @@ __all__ = [
146
156
  "soft_signal_r_and_setter",
147
157
  "soft_signal_rw",
148
158
  "wait_for_value",
149
- "RuntimeSubsetEnum",
159
+ "Array1D",
150
160
  "SignalBackend",
161
+ "make_datakey",
162
+ "StrictEnum",
151
163
  "SubsetEnum",
164
+ "SignalDatatype",
165
+ "SignalDatatypeT",
152
166
  "SignalMetadata",
153
167
  "SoftSignalBackend",
154
168
  "AsyncStatus",
155
169
  "WatchableAsyncStatus",
156
170
  "DEFAULT_TIMEOUT",
157
171
  "CalculatableTimeout",
172
+ "Callback",
158
173
  "CALCULATE_TIMEOUT",
159
174
  "NotConnected",
160
- "ReadingValueCallback",
175
+ "Reference",
161
176
  "Table",
162
177
  "T",
163
178
  "WatcherUpdate",
164
179
  "get_dtype",
180
+ "get_enum_cls",
165
181
  "get_unique",
166
182
  "in_micros",
167
- "is_pydantic_model",
168
183
  "wait_for_connection",
169
184
  "completed_status",
170
185
  ]
@@ -4,11 +4,7 @@ import asyncio
4
4
  import time
5
5
  from abc import ABC, abstractmethod
6
6
  from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterator, Sequence
7
- from enum import Enum
8
7
  from functools import cached_property
9
- from typing import (
10
- Generic,
11
- )
12
8
 
13
9
  from bluesky.protocols import (
14
10
  Collectable,
@@ -23,14 +19,14 @@ from bluesky.protocols import (
23
19
  from event_model import DataKey
24
20
  from pydantic import BaseModel, Field, NonNegativeInt, computed_field
25
21
 
26
- from ._device import Device
22
+ from ._device import Device, DeviceConnector
27
23
  from ._protocol import AsyncConfigurable, AsyncReadable
28
24
  from ._signal import SignalR
29
25
  from ._status import AsyncStatus, WatchableAsyncStatus
30
- from ._utils import DEFAULT_TIMEOUT, T, WatcherUpdate, merge_gathered_dicts
26
+ from ._utils import DEFAULT_TIMEOUT, StrictEnum, WatcherUpdate, merge_gathered_dicts
31
27
 
32
28
 
33
- class DetectorTrigger(str, Enum):
29
+ class DetectorTrigger(StrictEnum):
34
30
  """Type of mechanism for triggering a detector to take frames"""
35
31
 
36
32
  #: Detector generates internal trigger for given rate
@@ -172,7 +168,6 @@ class StandardDetector(
172
168
  Flyable,
173
169
  Collectable,
174
170
  WritesStreamAssets,
175
- Generic[T],
176
171
  ):
177
172
  """
178
173
  Useful detector base class for step and fly scanning detectors.
@@ -185,6 +180,7 @@ class StandardDetector(
185
180
  writer: DetectorWriter,
186
181
  config_sigs: Sequence[SignalR] = (),
187
182
  name: str = "",
183
+ connector: DeviceConnector | None = None,
188
184
  ) -> None:
189
185
  """
190
186
  Constructor
@@ -213,8 +209,7 @@ class StandardDetector(
213
209
  self._completable_frames: int = 0
214
210
  self._number_of_triggers_iter: Iterator[int] | None = None
215
211
  self._initial_frame: int = 0
216
-
217
- super().__init__(name)
212
+ super().__init__(name, connector=connector)
218
213
 
219
214
  @property
220
215
  def controller(self) -> DetectorController:
@@ -1,39 +1,80 @@
1
- """Base device"""
1
+ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import sys
5
- from collections.abc import Coroutine, Generator, Iterator
6
- from functools import cached_property
5
+ from collections.abc import Coroutine, Iterator, Mapping, MutableMapping
7
6
  from logging import LoggerAdapter, getLogger
8
- from typing import (
9
- Any,
10
- Optional,
11
- TypeVar,
12
- )
7
+ from typing import Any, TypeVar
8
+ from unittest.mock import Mock
13
9
 
14
10
  from bluesky.protocols import HasName
15
11
  from bluesky.run_engine import call_in_bluesky_event_loop, in_bluesky_event_loop
16
12
 
13
+ from ._protocol import Connectable
17
14
  from ._utils import DEFAULT_TIMEOUT, NotConnected, wait_for_connection
18
15
 
16
+ _device_mocks: dict[Device, Mock] = {}
19
17
 
20
- class Device(HasName):
21
- """Common base class for all Ophyd Async Devices.
22
18
 
23
- By default, names and connects all Device children.
24
- """
19
+ class DeviceConnector:
20
+ """Defines how a `Device` should be connected and type hints processed."""
21
+
22
+ def create_children_from_annotations(self, device: Device):
23
+ """Used when children can be created from introspecting the hardware.
24
+
25
+ Some control systems allow introspection of a device to determine what
26
+ children it has. To allow this to work nicely with typing we add these
27
+ hints to the Device like so::
28
+
29
+ my_signal: SignalRW[int]
30
+ my_device: MyDevice
31
+
32
+ This method will be run during ``Device.__init__``, and is responsible
33
+ for turning all of those type hints into real Signal and Device instances.
34
+
35
+ Subsequent runs of this function should do nothing, to allow it to be
36
+ called early in Devices that need to pass references to their children
37
+ during ``__init__``.
38
+ """
39
+
40
+ async def connect(
41
+ self,
42
+ device: Device,
43
+ mock: bool | Mock,
44
+ timeout: float,
45
+ force_reconnect: bool,
46
+ ):
47
+ """Used during ``Device.connect``.
48
+
49
+ This is called when a previous connect has not been done, or has been
50
+ done in a different mock more. It should connect the Device and all its
51
+ children.
52
+ """
53
+ coros = {}
54
+ for name, child_device in device.children():
55
+ child_mock = getattr(mock, name) if mock else mock # Mock() or False
56
+ coros[name] = child_device.connect(
57
+ mock=child_mock, timeout=timeout, force_reconnect=force_reconnect
58
+ )
59
+ await wait_for_connection(**coros)
60
+
61
+
62
+ class Device(HasName, Connectable):
63
+ """Common base class for all Ophyd Async Devices."""
25
64
 
26
65
  _name: str = ""
27
66
  #: The parent Device if it exists
28
- parent: Optional["Device"] = None
67
+ parent: Device | None = None
29
68
  # None if connect hasn't started, a Task if it has
30
69
  _connect_task: asyncio.Task | None = None
70
+ # If not None, then this is the mock arg of the previous connect
71
+ # to let us know if we can reuse an existing connection
72
+ _connect_mock_arg: bool | None = None
31
73
 
32
- # Used to check if the previous connect was mocked,
33
- # if the next mock value differs then we fail
34
- _previous_connect_was_mock = None
35
-
36
- def __init__(self, name: str = "") -> None:
74
+ def __init__(
75
+ self, name: str = "", connector: DeviceConnector | None = None
76
+ ) -> None:
77
+ self._connector = connector or DeviceConnector()
37
78
  self.set_name(name)
38
79
 
39
80
  @property
@@ -41,13 +82,7 @@ class Device(HasName):
41
82
  """Return the name of the Device"""
42
83
  return self._name
43
84
 
44
- @cached_property
45
- def log(self):
46
- return LoggerAdapter(
47
- getLogger("ophyd_async.devices"), {"ophyd_async_device_name": self.name}
48
- )
49
-
50
- def children(self) -> Iterator[tuple[str, "Device"]]:
85
+ def children(self) -> Iterator[tuple[str, Device]]:
51
86
  for attr_name, attr in self.__dict__.items():
52
87
  if attr_name != "parent" and isinstance(attr, Device):
53
88
  yield attr_name, attr
@@ -60,23 +95,32 @@ class Device(HasName):
60
95
  name:
61
96
  New name to set
62
97
  """
63
-
64
- # Ensure self.log is recreated after a name change
65
- if hasattr(self, "log"):
66
- del self.log
67
-
68
98
  self._name = name
69
- for attr_name, child in self.children():
70
- child_name = f"{name}-{attr_name.rstrip('_')}" if name else ""
99
+ # Ensure self.log is recreated after a name change
100
+ self.log = LoggerAdapter(
101
+ getLogger("ophyd_async.devices"), {"ophyd_async_device_name": self.name}
102
+ )
103
+ for child_name, child in self.children():
104
+ child_name = f"{self.name}-{child_name.strip('_')}" if self.name else ""
71
105
  child.set_name(child_name)
72
- child.parent = self
106
+
107
+ def __setattr__(self, name: str, value: Any) -> None:
108
+ if name == "parent":
109
+ if self.parent not in (value, None):
110
+ raise TypeError(
111
+ f"Cannot set the parent of {self} to be {value}: "
112
+ f"it is already a child of {self.parent}"
113
+ )
114
+ elif isinstance(value, Device):
115
+ value.parent = self
116
+ return super().__setattr__(name, value)
73
117
 
74
118
  async def connect(
75
119
  self,
76
- mock: bool = False,
120
+ mock: bool | Mock = False,
77
121
  timeout: float = DEFAULT_TIMEOUT,
78
122
  force_reconnect: bool = False,
79
- ):
123
+ ) -> None:
80
124
  """Connect self and all child Devices.
81
125
 
82
126
  Contains a timeout that gets propagated to child.connect methods.
@@ -88,41 +132,32 @@ class Device(HasName):
88
132
  timeout:
89
133
  Time to wait before failing with a TimeoutError.
90
134
  """
91
-
92
- if (
93
- self._previous_connect_was_mock is not None
94
- and self._previous_connect_was_mock != mock
95
- ):
96
- raise RuntimeError(
97
- f"`connect(mock={mock})` called on a `Device` where the previous "
98
- f"connect was `mock={self._previous_connect_was_mock}`. Changing mock "
99
- "value between connects is not permitted."
100
- )
101
- self._previous_connect_was_mock = mock
102
-
103
- # If previous connect with same args has started and not errored, can use it
104
- can_use_previous_connect = self._connect_task and not (
105
- self._connect_task.done() and self._connect_task.exception()
135
+ uses_mock = bool(mock)
136
+ can_use_previous_connect = (
137
+ uses_mock is self._connect_mock_arg
138
+ and self._connect_task
139
+ and not (self._connect_task.done() and self._connect_task.exception())
106
140
  )
141
+ if mock is True:
142
+ mock = Mock() # create a new Mock if one not provided
107
143
  if force_reconnect or not can_use_previous_connect:
108
- # Kick off a connection
109
- coros = {
110
- name: child_device.connect(
111
- mock, timeout=timeout, force_reconnect=force_reconnect
112
- )
113
- for name, child_device in self.children()
114
- }
115
- self._connect_task = asyncio.create_task(wait_for_connection(**coros))
144
+ self._connect_mock_arg = uses_mock
145
+ if self._connect_mock_arg:
146
+ _device_mocks[self] = mock
147
+ coro = self._connector.connect(
148
+ device=self, mock=mock, timeout=timeout, force_reconnect=force_reconnect
149
+ )
150
+ self._connect_task = asyncio.create_task(coro)
116
151
 
117
152
  assert self._connect_task, "Connect task not created, this shouldn't happen"
118
153
  # Wait for it to complete
119
154
  await self._connect_task
120
155
 
121
156
 
122
- VT = TypeVar("VT", bound=Device)
157
+ DeviceT = TypeVar("DeviceT", bound=Device)
123
158
 
124
159
 
125
- class DeviceVector(dict[int, VT], Device):
160
+ class DeviceVector(MutableMapping[int, DeviceT], Device):
126
161
  """
127
162
  Defines device components with indices.
128
163
 
@@ -131,10 +166,48 @@ class DeviceVector(dict[int, VT], Device):
131
166
  :class:`~ophyd_async.epics.demo.DynamicSensorGroup`
132
167
  """
133
168
 
134
- def children(self) -> Generator[tuple[str, Device], None, None]:
135
- for attr_name, attr in self.items():
136
- if isinstance(attr, Device):
137
- yield str(attr_name), attr
169
+ def __init__(
170
+ self,
171
+ children: Mapping[int, DeviceT],
172
+ name: str = "",
173
+ ) -> None:
174
+ self._children = dict(children)
175
+ super().__init__(name=name)
176
+
177
+ def __setattr__(self, name: str, child: Any) -> None:
178
+ if name != "parent" and isinstance(child, Device):
179
+ raise AttributeError(
180
+ "DeviceVector can only have integer named children, "
181
+ "set via device_vector[i] = child"
182
+ )
183
+ super().__setattr__(name, child)
184
+
185
+ def __getitem__(self, key: int) -> DeviceT:
186
+ return self._children[key]
187
+
188
+ def __setitem__(self, key: int, value: DeviceT) -> None:
189
+ # Check the types on entry to dict to make sure we can't accidentally
190
+ # make a non-integer named child
191
+ assert isinstance(key, int), f"Expected int, got {key}"
192
+ assert isinstance(value, Device), f"Expected Device, got {value}"
193
+ self._children[key] = value
194
+ value.parent = self
195
+
196
+ def __delitem__(self, key: int) -> None:
197
+ del self._children[key]
198
+
199
+ def __iter__(self) -> Iterator[int]:
200
+ yield from self._children
201
+
202
+ def __len__(self) -> int:
203
+ return len(self._children)
204
+
205
+ def children(self) -> Iterator[tuple[str, Device]]:
206
+ for key, child in self._children.items():
207
+ yield str(key), child
208
+
209
+ def __hash__(self): # to allow DeviceVector to be used as dict keys and in sets
210
+ return hash(id(self))
138
211
 
139
212
 
140
213
  class DeviceCollector:
@@ -195,12 +268,12 @@ class DeviceCollector:
195
268
  ), "No previous frame to the one with self in it, this shouldn't happen"
196
269
  return caller_frame.f_locals
197
270
 
198
- def __enter__(self) -> "DeviceCollector":
271
+ def __enter__(self) -> DeviceCollector:
199
272
  # Stash the names that were defined before we were called
200
273
  self._names_on_enter = set(self._caller_locals())
201
274
  return self
202
275
 
203
- async def __aenter__(self) -> "DeviceCollector":
276
+ async def __aenter__(self) -> DeviceCollector:
204
277
  return self.__enter__()
205
278
 
206
279
  async def _on_exit(self) -> None:
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from typing import (
6
+ Generic,
7
+ NoReturn,
8
+ TypeVar,
9
+ get_args,
10
+ get_origin,
11
+ get_type_hints,
12
+ )
13
+
14
+ from ._device import Device, DeviceConnector, DeviceVector
15
+ from ._signal import Signal, SignalX
16
+ from ._signal_backend import SignalBackend, SignalDatatype
17
+ from ._utils import get_origin_class
18
+
19
+
20
+ def _strip_number_from_string(string: str) -> tuple[str, int | None]:
21
+ match = re.match(r"(.*?)(\d*)$", string)
22
+ assert match
23
+
24
+ name = match.group(1)
25
+ number = match.group(2) or None
26
+ if number is None:
27
+ return name, None
28
+ else:
29
+ return name, int(number)
30
+
31
+
32
+ SignalBackendT = TypeVar("SignalBackendT", bound=SignalBackend)
33
+ DeviceConnectorT = TypeVar("DeviceConnectorT", bound=DeviceConnector)
34
+
35
+
36
+ class DeviceFiller(Generic[SignalBackendT, DeviceConnectorT]):
37
+ def __init__(
38
+ self,
39
+ device: Device,
40
+ signal_backend_factory: Callable[[type[SignalDatatype] | None], SignalBackendT],
41
+ device_connector_factory: Callable[[], DeviceConnectorT],
42
+ ):
43
+ self._device = device
44
+ self._signal_backend_factory = signal_backend_factory
45
+ self._device_connector_factory = device_connector_factory
46
+ self._vectors: dict[str, DeviceVector] = {}
47
+ self._vector_device_type: dict[str, type[Device] | None] = {}
48
+ self._signal_backends: dict[str, tuple[SignalBackendT, type[Signal]]] = {}
49
+ self._device_connectors: dict[str, DeviceConnectorT] = {}
50
+ # Get type hints on the class, not the instance
51
+ # https://github.com/python/cpython/issues/124840
52
+ self._annotations = get_type_hints(type(device))
53
+ for name, annotation in self._annotations.items():
54
+ # names have a trailing underscore if the clash with a bluesky verb,
55
+ # so strip this off to get what the CS will provide
56
+ stripped_name = name.rstrip("_")
57
+ origin = get_origin_class(annotation)
58
+ if name == "parent" or name.startswith("_") or not origin:
59
+ # Ignore
60
+ pass
61
+ elif issubclass(origin, Signal):
62
+ # SignalX doesn't need datatype, all others need one
63
+ datatype = self.get_datatype(name)
64
+ if origin != SignalX and datatype is None:
65
+ self._raise(
66
+ name,
67
+ f"Expected SignalX or SignalR/W/RW[type], got {annotation}",
68
+ )
69
+ self._signal_backends[stripped_name] = (
70
+ self.make_child_signal(name, origin),
71
+ origin,
72
+ )
73
+ elif origin == DeviceVector:
74
+ # DeviceVector needs a type of device
75
+ args = get_args(annotation) or [None]
76
+ child_origin = get_origin(args[0]) or args[0]
77
+ if child_origin is None or not issubclass(child_origin, Device):
78
+ self._raise(
79
+ name,
80
+ f"Expected DeviceVector[SomeDevice], got {annotation}",
81
+ )
82
+ self.make_device_vector(name, child_origin)
83
+ elif issubclass(origin, Device):
84
+ self._device_connectors[stripped_name] = self.make_child_device(
85
+ name, origin
86
+ )
87
+
88
+ def unfilled(self) -> set[str]:
89
+ return set(self._device_connectors).union(self._signal_backends)
90
+
91
+ def _raise(self, name: str, error: str) -> NoReturn:
92
+ raise TypeError(f"{type(self._device).__name__}.{name}: {error}")
93
+
94
+ def make_device_vector(self, name: str, device_type: type[Device] | None):
95
+ self._vectors[name] = DeviceVector({})
96
+ self._vector_device_type[name] = device_type
97
+ setattr(self._device, name, self._vectors[name])
98
+
99
+ def make_device_vectors(self, names: list[str]):
100
+ basenames: dict[str, set[int]] = {}
101
+ for name in names:
102
+ basename, number = _strip_number_from_string(name)
103
+ if number is not None:
104
+ basenames.setdefault(basename, set()).add(number)
105
+ for basename, numbers in basenames.items():
106
+ # If contiguous numbers starting at 1 then it's a device vector
107
+ length = len(numbers)
108
+ if length > 1 and numbers == set(range(1, length + 1)):
109
+ # DeviceVector needs a type of device
110
+ self.make_device_vector(basename, None)
111
+
112
+ def get_datatype(self, name: str) -> type[SignalDatatype] | None:
113
+ # Get dtype from SignalRW[dtype] or DeviceVector[SignalRW[dtype]]
114
+ basename, _ = _strip_number_from_string(name)
115
+ if basename in self._vectors:
116
+ # We decided to put it in a device vector, so get datatype from that
117
+ annotation = self._annotations.get(basename, None)
118
+ if annotation:
119
+ annotation = get_args(annotation)[0]
120
+ else:
121
+ # It's not a device vector, so get it from the full name
122
+ annotation = self._annotations.get(name, None)
123
+ args = get_args(annotation)
124
+ if args and get_origin_class(args[0]):
125
+ return args[0]
126
+
127
+ def make_child_signal(self, name: str, signal_type: type[Signal]) -> SignalBackendT:
128
+ if name in self._signal_backends:
129
+ # We made it above
130
+ backend, expected_signal_type = self._signal_backends.pop(name)
131
+ else:
132
+ # We need to make a new one
133
+ basename, number = _strip_number_from_string(name)
134
+ child = getattr(self._device, name, None)
135
+ backend = self._signal_backend_factory(self.get_datatype(name))
136
+ signal = signal_type(backend)
137
+ if basename in self._vectors and isinstance(number, int):
138
+ # We need to add a new entry to an existing DeviceVector
139
+ expected_signal_type = self._vector_device_type[basename] or signal_type
140
+ self._vectors[basename][number] = signal
141
+ elif child is None:
142
+ # We need to add a new child to the top level Device
143
+ expected_signal_type = signal_type
144
+ setattr(self._device, name, signal)
145
+ else:
146
+ self._raise(name, f"Cannot make child as it would shadow {child}")
147
+ if signal_type is not expected_signal_type:
148
+ self._raise(
149
+ name,
150
+ f"is a {signal_type.__name__} not a {expected_signal_type.__name__}",
151
+ )
152
+ return backend
153
+
154
+ def make_child_device(
155
+ self, name: str, device_type: type[Device] = Device
156
+ ) -> DeviceConnectorT:
157
+ basename, number = _strip_number_from_string(name)
158
+ child = getattr(self._device, name, None)
159
+ if connector := self._device_connectors.pop(name, None):
160
+ # We made it above
161
+ return connector
162
+ elif basename in self._vectors and isinstance(number, int):
163
+ # We need to add a new entry to an existing DeviceVector
164
+ vector_device_type = self._vector_device_type[basename] or device_type
165
+ assert issubclass(
166
+ vector_device_type, Device
167
+ ), f"{vector_device_type} is not a Device"
168
+ connector = self._device_connector_factory()
169
+ device = vector_device_type(connector=connector)
170
+ self._vectors[basename][number] = device
171
+ elif child is None:
172
+ # We need to add a new child to the top level Device
173
+ connector = self._device_connector_factory()
174
+ device = device_type(connector=connector)
175
+ setattr(self._device, name, device)
176
+ else:
177
+ self._raise(name, f"Cannot make child as it would shadow {child}")
178
+ connector.create_children_from_annotations(device)
179
+ return connector
180
+
181
+ def make_soft_device_vector_entries(self, num: int):
182
+ for basename, cls in self._vector_device_type.items():
183
+ assert cls, "Shouldn't happen"
184
+ for i in range(num):
185
+ name = f"{basename}{i + 1}"
186
+ if issubclass(cls, Signal):
187
+ self.make_child_signal(name, cls)
188
+ elif issubclass(cls, Device):
189
+ self.make_child_device(name, cls)
190
+ else:
191
+ self._raise(name, f"Can't make {cls}")
@@ -27,11 +27,8 @@ def pydantic_model_abstraction_representer(
27
27
  return dumper.represent_data(model.model_dump(mode="python"))
28
28
 
29
29
 
30
- class OphydDumper(yaml.Dumper):
31
- def represent_data(self, data: Any) -> Any:
32
- if isinstance(data, Enum):
33
- return self.represent_data(data.value)
34
- return super().represent_data(data)
30
+ def enum_representer(dumper: yaml.Dumper, enum: Enum) -> yaml.Node:
31
+ return dumper.represent_data(enum.value)
35
32
 
36
33
 
37
34
  def get_signal_values(
@@ -74,7 +71,7 @@ def get_signal_values(
74
71
  for key, value in zip(selected_signals, selected_values, strict=False)
75
72
  }
76
73
  # Ignored values place in with value None so we know which ones were ignored
77
- named_values.update({key: None for key in ignore})
74
+ named_values.update(dict.fromkeys(ignore))
78
75
  return named_values
79
76
 
80
77
 
@@ -111,6 +108,7 @@ def walk_rw_signals(
111
108
  path_prefix = ""
112
109
 
113
110
  signals: dict[str, SignalRW[Any]] = {}
111
+
114
112
  for attr_name, attr in device.children():
115
113
  dot_path = f"{path_prefix}{attr_name}"
116
114
  if type(attr) is SignalRW:
@@ -145,9 +143,10 @@ def save_to_yaml(phases: Sequence[dict[str, Any]], save_path: str | Path) -> Non
145
143
  pydantic_model_abstraction_representer,
146
144
  Dumper=yaml.Dumper,
147
145
  )
146
+ yaml.add_multi_representer(Enum, enum_representer, Dumper=yaml.Dumper)
148
147
 
149
148
  with open(save_path, "w") as file:
150
- yaml.dump(phases, file, Dumper=OphydDumper, default_flow_style=False)
149
+ yaml.dump(phases, file)
151
150
 
152
151
 
153
152
  def load_from_yaml(save_path: str) -> Sequence[dict[str, Any]]: