Skip to content

Commit

Permalink
Merge branch 'main' into 117_45_extend_asyncstatus_wrap
Browse files Browse the repository at this point in the history
  • Loading branch information
dperl-dls committed May 15, 2024
2 parents 42e6075 + 54a46b9 commit 3be4f14
Show file tree
Hide file tree
Showing 64 changed files with 1,503 additions and 872 deletions.
3 changes: 2 additions & 1 deletion docs/how-to/make-a-simple-device.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ its Python type, which could be:

- A primitive (`str`, `int`, `float`)
- An array (`numpy.typing.NDArray` or ``Sequence[str]``)
- An enum (`enum.Enum`).
- An enum (`enum.Enum`) which **must** also extend `str`
- `str` and ``EnumClass(str, Enum)`` are the only valid ``datatype`` for an enumerated signal.

The rest of the arguments are PV connection information, in this case the PV suffix.

Expand Down
18 changes: 10 additions & 8 deletions docs/how-to/write-tests-for-devices.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,29 @@ Async Tests
...
asyncio_mode = "auto"
Sim Backend
-----------
Mock Backend
------------

Ophyd devices initialized with a sim backend behave in a similar way to mocks, without requiring you to mock out all the dependencies and internals. The `DeviceCollector` can initialize any number of devices, and their signals and sub-devices (recursively), with a sim backend.
Ophyd devices initialized with a mock backend behave in a similar way to mocks, without requiring you to mock out all the dependencies and internals. The `DeviceCollector` can initialize any number of devices, and their signals and sub-devices (recursively), with a mock backend.

.. literalinclude:: ../../tests/epics/demo/test_demo.py
:pyobject: sim_sensor
:pyobject: mock_sensor


Sim Utility Functions
---------------------
Mock Utility Functions
----------------------

Sim signals behave as simply as possible, holding a sensible default value when initialized and retaining any value (in memory) to which they are set. This model breaks down in the case of read-only signals, which cannot be set because there is an expectation of some external device setting them in the real world. There is a utility function, ``set_sim_value``, to mock-set values for sim signals, including read-only ones.
Mock signals behave as simply as possible, holding a sensible default value when initialized and retaining any value (in memory) to which they are set. This model breaks down in the case of read-only signals, which cannot be set because there is an expectation of some external device setting them in the real world. There is a utility function, ``set_mock_value``, to mock-set values for mock signals, including read-only ones.

In addition this example also utilizes helper functions like ``assert_reading`` and ``assert_value`` to ensure the validity of device readings and values. For more information see: :doc:`API.core<../generated/ophyd_async.core>`

.. literalinclude:: ../../tests/epics/demo/test_demo.py
:pyobject: test_sensor_reading_shows_value


There is another utility function, ``set_sim_callback``, for hooking in logic when a sim value changes (e.g. because someone puts to it).
There are several other test utility functions:

Use ``callback_on_mock_put``, for hooking in logic when a mock value changes (e.g. because someone puts to it). This can be called directly, or used as a context, with the callbacks ending after exit.

.. literalinclude:: ../../tests/epics/demo/test_demo.py
:pyobject: test_mover_stopped
Expand Down
2 changes: 1 addition & 1 deletion docs/tutorials/using-existing-devices.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ and run the following:
- If ``connect=True`` (the default), then call `Device.connect` in parallel for
all top level Devices, waiting for up to ``timeout`` seconds. For example,
here we call ``asyncio.wait([det.connect(), samp.connect()])``
- If ``sim=True`` is passed, then don't connect to PVs, but set Devices into
- If ``mock=True`` is passed, then don't connect to PVs, but set Devices into
simulation mode

The Devices we create in this example are a "sample stage" with a couple of
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
"event-model<1.21.0",
"p4p",
"pyyaml",
"colorlog",
]
dynamic = ["version"]
license.file = "LICENSE"
Expand Down Expand Up @@ -64,7 +65,6 @@ dev = [
"tox-direct",
"types-mock",
"types-pyyaml",
"colorlog"
]

[project.scripts]
Expand All @@ -86,7 +86,6 @@ write_to = "src/ophyd_async/_version.py"
addopts = """
--tb=native -vv --strict-markers --doctest-modules
--doctest-glob="*.rst" --doctest-glob="*.md" --ignore=docs/examples
--cov=src/ophyd_async --cov-report term --cov-report xml:cov.xml
"""
# https://iscinumpy.gitlab.io/post/bound-version-constraints/#watch-for-warnings
filterwarnings = "error"
Expand Down
34 changes: 24 additions & 10 deletions src/ophyd_async/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@
walk_rw_signals,
)
from .flyer import HardwareTriggeredFlyable, TriggerLogic
from .mock_signal_backend import (
MockSignalBackend,
)
from .mock_signal_utils import (
assert_mock_put_called_with,
callback_on_mock_put,
mock_puts_blocked,
reset_mock_put_calls,
set_mock_put_proceeds,
set_mock_value,
set_mock_values,
)
from .signal import (
Signal,
SignalR,
Expand All @@ -36,15 +48,12 @@
assert_value,
observe_value,
set_and_wait_for_value,
set_sim_callback,
set_sim_put_proceeds,
set_sim_value,
soft_signal_r_and_backend,
soft_signal_r_and_setter,
soft_signal_rw,
wait_for_value,
)
from .signal_backend import SignalBackend
from .sim_signal_backend import SimSignalBackend
from .soft_signal_backend import SoftSignalBackend
from .standard_readable import ConfigSignal, HintedSignal, StandardReadable
from .utils import (
DEFAULT_TIMEOUT,
Expand All @@ -59,9 +68,15 @@
)

__all__ = [
"assert_mock_put_called_with",
"callback_on_mock_put",
"mock_puts_blocked",
"set_mock_values",
"reset_mock_put_calls",
"SignalBackend",
"SimSignalBackend",
"SoftSignalBackend",
"DetectorControl",
"MockSignalBackend",
"DetectorTrigger",
"DetectorWriter",
"StandardDetector",
Expand All @@ -73,13 +88,12 @@
"SignalW",
"SignalRW",
"SignalX",
"soft_signal_r_and_backend",
"soft_signal_r_and_setter",
"soft_signal_rw",
"observe_value",
"set_and_wait_for_value",
"set_sim_callback",
"set_sim_put_proceeds",
"set_sim_value",
"set_mock_put_proceeds",
"set_mock_value",
"wait_for_value",
"AsyncStatus",
"WatchableAsyncStatus",
Expand Down
31 changes: 22 additions & 9 deletions src/ophyd_async/core/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from __future__ import annotations

import sys
from functools import cached_property
from logging import LoggerAdapter, getLogger
from typing import (
Any,
Coroutine,
Expand Down Expand Up @@ -39,6 +41,12 @@ def name(self) -> str:
"""Return the name of the Device"""
return self._name

@cached_property
def log(self):
return LoggerAdapter(
getLogger("ophyd_async.devices"), {"ophyd_async_device_name": self.name}
)

def children(self) -> Iterator[Tuple[str, Device]]:
for attr_name, attr in self.__dict__.items():
if attr_name != "parent" and isinstance(attr, Device):
Expand All @@ -52,26 +60,31 @@ def set_name(self, name: str):
name:
New name to set
"""

# Ensure self.log is recreated after a name change
if hasattr(self, "log"):
del self.log

self._name = name
for attr_name, child in self.children():
child_name = f"{name}-{attr_name.rstrip('_')}" if name else ""
child.set_name(child_name)
child.parent = self

async def connect(self, sim: bool = False, timeout: float = DEFAULT_TIMEOUT):
async def connect(self, mock: bool = False, timeout: float = DEFAULT_TIMEOUT):
"""Connect self and all child Devices.
Contains a timeout that gets propagated to child.connect methods.
Parameters
----------
sim:
If True then connect in simulation mode.
mock:
If True then use ``MockSignalBackend`` for all Signals
timeout:
Time to wait before failing with a TimeoutError.
"""
coros = {
name: child_device.connect(sim, timeout=timeout)
name: child_device.connect(mock=mock, timeout=timeout)
for name, child_device in self.children()
}
if coros:
Expand Down Expand Up @@ -105,9 +118,9 @@ class DeviceCollector:
If True, call ``device.set_name(variable_name)`` on all collected
Devices
connect:
If True, call ``device.connect(sim)`` in parallel on all
If True, call ``device.connect(mock)`` in parallel on all
collected Devices
sim:
mock:
If True, connect Signals in simulation mode
timeout:
How long to wait for connect before logging an exception
Expand All @@ -129,12 +142,12 @@ def __init__(
self,
set_name=True,
connect=True,
sim=False,
mock=False,
timeout: float = 10.0,
):
self._set_name = set_name
self._connect = connect
self._sim = sim
self._mock = mock
self._timeout = timeout
self._names_on_enter: Set[str] = set()
self._objects_on_exit: Dict[str, Any] = {}
Expand Down Expand Up @@ -168,7 +181,7 @@ async def _on_exit(self) -> None:
obj.set_name(name)
if self._connect:
connect_coroutines[name] = obj.connect(
self._sim, timeout=self._timeout
self._mock, timeout=self._timeout
)

# Connect to all the devices
Expand Down
86 changes: 86 additions & 0 deletions src/ophyd_async/core/mock_signal_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import asyncio
from typing import Optional, Type
from unittest.mock import MagicMock

from bluesky.protocols import Descriptor, Reading

from ophyd_async.core.signal_backend import SignalBackend
from ophyd_async.core.soft_signal_backend import SoftSignalBackend
from ophyd_async.core.utils import DEFAULT_TIMEOUT, ReadingValueCallback, T


class MockSignalBackend(SignalBackend):
def __init__(
self,
datatype: Optional[Type[T]] = None,
initial_backend: Optional[SignalBackend[T]] = None,
) -> None:
if isinstance(initial_backend, MockSignalBackend):
raise ValueError("Cannot make a MockSignalBackend for a MockSignalBackends")

self.initial_backend = initial_backend

if datatype is None:
assert (
self.initial_backend
), "Must supply either initial_backend or datatype"
datatype = self.initial_backend.datatype

self.datatype = datatype

if not isinstance(self.initial_backend, SoftSignalBackend):
# If the backend is a hard signal backend, or not provided,
# then we create a soft signal to mimick it

self.soft_backend = SoftSignalBackend(datatype=datatype)
else:
self.soft_backend = initial_backend

self.mock = MagicMock()

self.put_proceeds = asyncio.Event()
self.put_proceeds.set()

def source(self, name: str) -> str:
self.mock.source(name)
if self.initial_backend:
return f"mock+{self.initial_backend.source(name)}"
return f"mock+{name}"

async def connect(self, timeout: float = DEFAULT_TIMEOUT) -> None:
self.mock.connect(timeout=timeout)

async def put(self, value: Optional[T], wait=True, timeout=None):
self.mock.put(value, wait=wait, timeout=timeout)
await self.soft_backend.put(value, wait=wait, timeout=timeout)

if wait:
await asyncio.wait_for(self.put_proceeds.wait(), timeout=timeout)

def set_value(self, value: T):
self.mock.set_value(value)
self.soft_backend.set_value(value)

async def get_descriptor(self, source: str) -> Descriptor:
self.mock.get_descriptor(source)
return await self.soft_backend.get_descriptor(source)

async def get_reading(self) -> Reading:
self.mock.get_reading()
return await self.soft_backend.get_reading()

async def get_value(self) -> T:
self.mock.get_value()
return await self.soft_backend.get_value()

async def get_setpoint(self) -> T:
"""For a soft signal, the setpoint and readback values are the same."""
self.mock.get_setpoint()
return await self.soft_backend.get_setpoint()

async def get_datakey(self, source: str) -> Descriptor:
return await self.soft_backend.get_datakey(source)

def set_callback(self, callback: Optional[ReadingValueCallback[T]]) -> None:
self.mock.set_callback(callback)
self.soft_backend.set_callback(callback)
Loading

0 comments on commit 3be4f14

Please sign in to comment.