Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ tox -p # all envs in parallel (CI equivalent)
```

- `ruff` runs on save in VS Code; `pytest` also runs doctests in `docs/` and `src/`.
- **pyright ad hoc** (not via tox): always `pyright src --pythonpath "$(which python)"`. A bare `pyright src` reports ~115 false positives here (stale numpy-stub resolution) — never trust its count.
- **pyright ad hoc** (not via tox): always `pyright src tests --pythonpath "$(which python)"`. A bare `pyright src tests` reports ~115 false positives here (stale numpy-stub resolution) — never trust its count.
- System tests need a live backend; scope runs to `tests/system_tests/epics` or `.../tango` and run 2–3× to catch flakiness.
- `tests/container_tests` must be a **separate** `pytest` invocation from `tests/system_tests` — never one session. They reach their IOC via the ca-gateway, which needs `EPICS_CA_NAME_SERVERS`, and EPICS reads that once at libca init; `epics/core` imports pyepics, which initialises libca during *collection*, so a shared session silently breaks them. Needs `EXAMPLE_SERVICES_PATH` (a host path).

Expand Down
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,7 @@ commands = [
[
"pyright",
"src",
# Its `assert_type` guards only bite if pyright checks them; `tests` as a
# whole is not clean enough to check, so this is listed by name
"tests/unit_tests/core/test_status_types.py",
"tests",
"--pythonpath",
"{env:VIRTUAL_ENV}/bin/python",
{ replace = "posargs", default = [
Expand Down
9 changes: 6 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
from typing import Any

import pytest
from bluesky.run_engine import RunEngine, TransitionError
from bluesky.run_engine import (
RunEngine,
TransitionError, # pyright: ignore[reportPrivateImportUsage]
)
from pytest import FixtureRequest

from ophyd_async.core import (
Expand Down Expand Up @@ -139,8 +142,8 @@ def _error_and_kill_pending_tasks(
task
for task in asyncio.all_tasks(loop)
if (coro := task.get_coro()) is not None
and hasattr(coro, "__name__")
and coro.__name__ not in _ALLOWED_PYTEST_TASKS
and (name := getattr(coro, "__name__", None)) is not None
and name not in _ALLOWED_PYTEST_TASKS
and not task.done()
}
for task in unfinished_tasks:
Expand Down
6 changes: 3 additions & 3 deletions tests/container_tests/test_adsim_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def prepare_then_count() -> MsgGenerator[None]:

RE(prepare_then_count())

actual_exposure_time: float = RE(bps.rd(adsim.driver.acquire_time)).plan_result
actual_exposure_time: float = RE(bps.rd(adsim.driver.acquire_time)).plan_result # pyright: ignore[reportAttributeAccessIssue]
assert actual_exposure_time == 0.2


Expand All @@ -165,7 +165,7 @@ def test_software_triggering(
) -> None:
docs = run_plan_and_get_documents(RE, bp.count([adsim], num=2))
assert docs == [
RunStart(
RunStart( # pyright: ignore[reportCallIssue]
uid=ANY,
time=ANY,
versions=ANY,
Expand Down Expand Up @@ -240,7 +240,7 @@ def test_software_triggering(
},
object_keys={"adsim": ["adsim"]},
run_start=ANY,
hints={"adsim": {"fields": ["adsim"]}},
hints={"adsim": {"fields": ["adsim"]}}, # pyright: ignore[reportArgumentType]
),
StreamResource(
uid=ANY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ async def test_signal_error_paths(ioc: LifecycleIoc, protocol: str):
# 0 is simply accepted as "the choice at index 0" - there's no
# EPICS-side equivalent of Tango's set()-time TypeError.)
with pytest.raises(ValueError):
await device.subset_enum.set("NOT_A_REAL_CHOICE")
await device.subset_enum.set("NOT_A_REAL_CHOICE") # pyright: ignore[reportArgumentType]

# Wrong inferred datatype at connect time: a_str is a string record.
with pytest.raises(TypeError, match="cannot be coerced to int"):
Expand Down
17 changes: 9 additions & 8 deletions tests/system_tests/epics/core/test_epics_signal_mechanisms.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ async def test_can_read_using_ophyd_async_then_ophyd(

def a_plan():
yield from bps.rd(ophyd_async_sig)
yield from bps.rd(ophyd_signal)
yield from bps.rd(ophyd_signal) # pyright: ignore[reportArgumentType]

RE(a_plan())

Expand Down Expand Up @@ -621,16 +621,17 @@ async def test_all_updates_defaults_to_true(
async def default_all_updates(pv: str) -> bool:
sig = epics_signal_rw(int, pv)
await sig.connect()
backend = sig._connector.backend
backend = _get_epics_backend(sig)

sig.subscribe_reading(lambda v: None)
subscription = backend.subscription
assert isinstance(subscription, Subscription)
try:
sig.subscribe_reading(lambda v: ...)
assert isinstance(backend.subscription, Subscription)
assert isinstance(backend.subscription.all_updates, bool)
return backend.subscription.all_updates
assert isinstance(subscription.all_updates, bool)
return subscription.all_updates
finally:
backend.subscription.close()
sig.clear_sub(lambda v: ...)
subscription.close()
sig.clear_sub(lambda v: None)


@pytest.mark.timeout(TIMEOUT + 0.5)
Expand Down
9 changes: 6 additions & 3 deletions tests/system_tests/epics/eiger/test_eiger_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,21 @@ async def setup_device(RE, ioc_prefixes):
async def test_eiger(RE, ioc_prefixes) -> EigerDetector:
provider = StaticPathProvider(StaticFilenameProvider("test_eiger"), Path(SAVE_PATH))
async with init_devices():
test_eiger = EigerDetector("", provider, ioc_prefixes[0], ioc_prefixes[1])
test_eiger = EigerDetector(
"",
provider,
ioc_prefixes[0],
ioc_prefixes[1], # pyright: ignore[reportCallIssue]
)

return test_eiger


@pytest.mark.skip(reason="Flaky test, see GH-819")
async def test_trigger_saves_file(test_eiger: EigerDetector, setup_device: SetupDevice):
single_shot = TriggerInfo(
exposure_timeout=None,
number_of_events=1,
trigger=DetectorTrigger.INTERNAL,
livetime=None,
)

await test_eiger.stage()
Expand Down
6 changes: 3 additions & 3 deletions tests/system_tests/tango/core/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def distinct_value(self):
serves.
"""
for value in self.random_put_values:
if not np.array_equal(value, self.initial):
if not np.array_equal(value, self.initial): # pyright: ignore[reportArgumentType]
return value
raise ValueError(f"No put value for {self.name} differs from its initial")

Expand Down Expand Up @@ -235,7 +235,7 @@ def add_ads(
name="strenum",
py_type=ExampleStrEnum,
initial=ExampleStrEnum.B,
random_put_values=[
random_put_values=[ # pyright: ignore[reportArgumentType]
ExampleStrEnum.A.value,
ExampleStrEnum.B.value,
ExampleStrEnum.C.value,
Expand Down Expand Up @@ -278,7 +278,7 @@ def add_ads(
"my_state",
DevStateEnum,
DevStateEnum.INIT,
random_put_values=[e.name for e in DevStateEnum],
random_put_values=[e.name for e in DevStateEnum], # pyright: ignore[reportArgumentType]
cmd_name=None,
)

Expand Down
24 changes: 15 additions & 9 deletions tests/system_tests/tango/core/test_base_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@
init_devices,
)
from ophyd_async.core import StandardReadableFormat as Format
from ophyd_async.tango.core import TangoDevice, get_full_attr_trl, get_python_type
from ophyd_async.tango.core import (
TangoDevice,
TangoDeviceConnector,
get_full_attr_trl,
get_python_type,
)
from ophyd_async.tango.demo import (
DemoMotor,
DemoPointDetector,
Expand Down Expand Up @@ -57,7 +62,7 @@ class TestTangoReadable(TangoDevice, StandardReadable):
async def describe_class(fqtrl):
description = {}
values = {}
dev = await AsyncDeviceProxy(fqtrl)
dev = await AsyncDeviceProxy(fqtrl) # pyright: ignore[reportGeneralTypeIssues]

for name in TESTED_FEATURES:
dtype = "none"
Expand Down Expand Up @@ -89,7 +94,7 @@ async def describe_class(fqtrl):

elif name in dev.get_command_list():
cmd_conf = await dev.get_command_config(name)
_, _, descr = get_python_type(cmd_conf)
_, _, descr = get_python_type(cmd_conf) # pyright: ignore[reportGeneralTypeIssues]
shape = []
value = getattr(dev, name)()

Expand Down Expand Up @@ -122,7 +127,7 @@ def get_test_descriptor(python_type: type[T], value: T, is_cmd: bool) -> dict:
if python_type in [str]:
return {"dtype": "string", "shape": []}
if issubclass(python_type, DevState):
return {"dtype": "string", "shape": [], "choices": list(DevState.names.keys())}
return {"dtype": "string", "shape": [], "choices": list(DevState.names.keys())} # pyright: ignore[reportAttributeAccessIssue]
if issubclass(python_type, Enum):
return {
"dtype": "string",
Expand All @@ -132,7 +137,7 @@ def get_test_descriptor(python_type: type[T], value: T, is_cmd: bool) -> dict:

return {
"dtype": "array",
"shape": [np.Inf] if is_cmd else list(np.array(value).shape),
"shape": [np.inf] if is_cmd else list(np.array(value).shape),
}


Expand All @@ -159,6 +164,7 @@ async def test_set_trl(tango_test_device):
values, description = await describe_class(tango_test_device)
test_device = TestTangoReadable(trl="", name="test_device")

assert isinstance(test_device._connector, TangoDeviceConnector)
test_device._connector.set_trl(tango_test_device)
await test_device.connect()

Expand Down Expand Up @@ -275,13 +281,13 @@ async def test_command_autofill(tango_test_device):
await test_device.connect()

assert hasattr(test_device, "echo")
echo = test_device.echo
echo = test_device.echo # pyright: ignore[reportAttributeAccessIssue]
assert hasattr(test_device, "set_msg")
set_msg = test_device.set_msg
set_msg = test_device.set_msg # pyright: ignore[reportAttributeAccessIssue]
assert hasattr(test_device, "get_msg")
get_msg = test_device.get_msg
get_msg = test_device.get_msg # pyright: ignore[reportAttributeAccessIssue]
assert hasattr(test_device, "clear")
clear = test_device.clear
clear = test_device.clear # pyright: ignore[reportAttributeAccessIssue]

assert isinstance(echo, Command)
assert isinstance(set_msg, Command)
Expand Down
37 changes: 20 additions & 17 deletions tests/system_tests/tango/core/test_tango_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@
(Sequence[str], ["a", "b"], "a_str_spectrum_cmd"),
(
TangoLongStringTable,
TangoLongStringTable(long=[1, 2], string=["a", "b"]),
TangoLongStringTable(long=[1, 2], string=["a", "b"]), # pyright: ignore[reportArgumentType]
"long_string_cmd",
),
(
TangoDoubleStringTable,
TangoDoubleStringTable(double=[1.1, 2.2], string=["a", "b"]),
TangoDoubleStringTable(double=[1.1, 2.2], string=["a", "b"]), # pyright: ignore[reportArgumentType]
"double_string_cmd",
),
]
Expand Down Expand Up @@ -104,6 +104,7 @@ async def test_tango_command(
assert isinstance(cmd, TriggerableCommand)
else:
assert isinstance(cmd, Command)
assert cmd.signature is not None
if name in ["int8_spectrum_cmd", "uint8_spectrum_cmd"]:
assert Array1D[np.uint8] == cmd.signature.return_annotation
assert (
Expand All @@ -119,9 +120,9 @@ async def test_tango_command(
assert ctype == list(cmd.signature.parameters.values())[0].annotation

if isinstance(val, np.ndarray):
assert np.array_equal(val, await cmd.execute(val))
assert np.array_equal(val, await cmd.execute(val)) # pyright: ignore[reportCallIssue]
else:
assert val == await cmd.execute(val)
assert val == await cmd.execute(val) # pyright: ignore[reportCallIssue]


@pytest.mark.parametrize("ctype, val, name", TEST_PARAMS)
Expand All @@ -142,7 +143,7 @@ async def test_tango_command_factory(

trl = ""

def spec(x: ctype) -> ctype: ...
def spec(x: ctype) -> ctype: ... # pyright: ignore[reportInvalidTypeForm]

if everything_device_trl.endswith("#dbase=no"):
trl = everything_device_trl[:-9] + f"/{name}" + everything_device_trl[-9:]
Expand Down Expand Up @@ -171,69 +172,70 @@ def spec(x: ctype) -> ctype: ...
if name == "void_cmd":
assert cmd.signature is None
else:
assert cmd.signature is not None
assert expected_datatype == cmd.signature.return_annotation

if isinstance(val, np.ndarray):
assert np.array_equal(val, await cmd.execute(val))
assert np.array_equal(val, await cmd.execute(val)) # pyright: ignore[reportCallIssue]
else:
assert val == await cmd.execute(val)
assert val == await cmd.execute(val) # pyright: ignore[reportCallIssue]


@pytest.mark.asyncio
async def test_tango_command_validation(
everything_device_trl: str,
):
# This should pass
def call_spec(x: float) -> float: ...
def float_to_float_spec(x: float) -> float: ...

trl = ""
if everything_device_trl.endswith("#dbase=no"):
trl = everything_device_trl[:-9] + "/float64_cmd" + everything_device_trl[-9:]
cmd = tango_command(call_spec=call_spec, trl=trl, name="float64_cmd")
cmd = tango_command(call_spec=float_to_float_spec, trl=trl, name="float64_cmd")
await cmd.connect()
ret = await cmd.execute(1.0)
assert ret == 1.0

# This should pass
def call_spec(x: float) -> None: ...
def float_to_none_spec(x: float) -> None: ...

trl = ""
if everything_device_trl.endswith("#dbase=no"):
trl = everything_device_trl[:-9] + "/float64_cmd" + everything_device_trl[-9:]
cmd = tango_command(call_spec=call_spec, trl=trl, name="float64_cmd")
cmd = tango_command(call_spec=float_to_none_spec, trl=trl, name="float64_cmd")
await cmd.connect()
ret = await cmd.execute(1.0)
assert ret == 1.0

# Multiple input params should fail
# Commands with more than one input parameter are not yet supported.
def call_spec(x: float, y: int) -> float: ...
def two_inputs_spec(x: float, y: int) -> float: ...

trl = ""
if everything_device_trl.endswith("#dbase=no"):
trl = everything_device_trl[:-9] + "/float64_cmd" + everything_device_trl[-9:]
with pytest.raises(TypeError) as excinfo:
tango_command(call_spec=call_spec, trl=trl, name="float64_cmd")
tango_command(call_spec=two_inputs_spec, trl=trl, name="float64_cmd")
assert "Commands with more than one input parameter" in str(excinfo.value)

# Mistyped return type should fail unless it is None
def call_spec(x: float) -> int: ...
def float_to_int_spec(x: float) -> int: ...

trl = ""
if everything_device_trl.endswith("#dbase=no"):
trl = everything_device_trl[:-9] + "/float64_cmd" + everything_device_trl[-9:]
cmd = tango_command(call_spec=call_spec, trl=trl, name="float64_cmd")
cmd = tango_command(call_spec=float_to_int_spec, trl=trl, name="float64_cmd")
with pytest.raises(TypeError) as excinfo:
await cmd.connect()
assert "not <class 'int'>" in str(excinfo.value)

# Mistyped input parameter should fail unless it is None
def call_spec(x: Array1D[np.float32]) -> float: ...
def array_input_spec(x: Array1D[np.float32]) -> float: ...

trl = ""
if everything_device_trl.endswith("#dbase=no"):
trl = everything_device_trl[:-9] + "/float64_cmd" + everything_device_trl[-9:]
cmd = tango_command(call_spec=call_spec, trl=trl, name="float64_cmd")
cmd = tango_command(call_spec=array_input_spec, trl=trl, name="float64_cmd")
with pytest.raises(TypeError) as excinfo:
await cmd.connect()
assert "has input parameter of type <class 'float'>, not" in str(excinfo.value)
Expand Down Expand Up @@ -290,6 +292,7 @@ def call_spec(x: float) -> bool: ...
)
cmd = tango_command(call_spec=call_spec, trl=trl, name="float_to_bool_cmd")
await cmd.connect()
assert cmd.signature is not None
assert cmd.signature.return_annotation is bool
assert list(cmd.signature.parameters.values())[0].annotation is float
assert await cmd.execute(1.0) is True
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class _BadAnnotationDevice(TangoDevice, StandardReadable):
# strenum is really a StrictEnum-backed attribute (see TangoTestDevice) -
# datatype of enum commands must be explicitly hinted, so annotating it
# SignalRW[None] here is a genuine mismatch, not a missing hint.
strenum: A[SignalRW[None], Format.HINTED_UNCACHED_SIGNAL]
strenum: A[SignalRW[None], Format.HINTED_UNCACHED_SIGNAL] # pyright: ignore[reportInvalidTypeArguments]


@pytest.mark.asyncio
Expand Down
Loading
Loading