From d23cafc0687818ced5fe03f4d8ca3239ef9af121 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Tue, 12 Aug 2025 08:27:48 +0000 Subject: [PATCH 01/14] feat: add generic interactive terminal --- pyproject.toml | 3 ++- src/fastcs/launch.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 53af0c339..0fe7c5b46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,8 @@ dependencies = [ "pytango", "softioc>=4.5.0", "strawberry-graphql", - "p4p" + "p4p", + "IPython", ] dynamic = ["version"] license.file = "LICENSE" diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index de2c8dad9..a45efd412 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -2,10 +2,13 @@ import inspect import json import signal +from collections.abc import Coroutine +from functools import partial from pathlib import Path from typing import Annotated, Any, Optional, TypeAlias, get_type_hints import typer +from IPython.terminal.embed import InteractiveShellEmbed from pydantic import BaseModel, create_model from ruamel.yaml import YAML @@ -36,6 +39,7 @@ def __init__( transport_options: TransportOptions, ): self._loop = asyncio.get_event_loop() + self._controller = controller self._backend = Backend(controller, self._loop) transport: TransportAdapter self._transports: list[TransportAdapter] = [] @@ -100,12 +104,42 @@ def run(self): async def serve(self) -> None: coros = [self._backend.serve()] - coros.extend([transport.serve() for transport in self._transports]) + context = {"controller": self._backend} + for transport in self._transports: + context.update(transport.context()) + coros.append(transport.serve()) + coros.append(self._interactive_shell(context)) try: await asyncio.gather(*coros) except asyncio.CancelledError: pass + async def _interactive_shell(self, context: dict[str, Any]): + """Spawn interactive shell in another thread and wait for it to complete.""" + + def run(coro: Coroutine[None, None, None]): + """Run coroutine on FastCS event loop from IPython thread.""" + + def wrapper(): + asyncio.create_task(coro) + + self._loop.call_soon_threadsafe(wrapper) + + async def interactive_shell( + context: dict[str, object], stop_event: asyncio.Event + ): + """Run interactive shell in a new thread.""" + shell = InteractiveShellEmbed() + await asyncio.to_thread(partial(shell.mainloop, local_ns=context)) + + stop_event.set() + + context["run"] = run + + stop_event = asyncio.Event() + self._loop.create_task(interactive_shell(context, stop_event)) + await stop_event.wait() + def launch( controller_class: type[Controller], From 06aace6bb9ba44cbb6d0ffeebeb0b7da6e864305 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Tue, 12 Aug 2025 08:28:42 +0000 Subject: [PATCH 02/14] wip: add pva adapter context to 'dbl' --- src/fastcs/transport/adapter.py | 3 +++ src/fastcs/transport/epics/pva/adapter.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/fastcs/transport/adapter.py b/src/fastcs/transport/adapter.py index c3ed79143..eebdf44e2 100644 --- a/src/fastcs/transport/adapter.py +++ b/src/fastcs/transport/adapter.py @@ -22,3 +22,6 @@ def create_docs(self) -> None: @abstractmethod def create_gui(self) -> None: pass + + def context(self) -> dict[str, Any]: + return {} diff --git a/src/fastcs/transport/epics/pva/adapter.py b/src/fastcs/transport/epics/pva/adapter.py index 569442e23..57cd446e9 100644 --- a/src/fastcs/transport/epics/pva/adapter.py +++ b/src/fastcs/transport/epics/pva/adapter.py @@ -3,6 +3,7 @@ from fastcs.transport.epics.docs import EpicsDocs from fastcs.transport.epics.gui import PvaEpicsGUI from fastcs.transport.epics.pva.options import EpicsPVAOptions +from fastcs.util import snake_to_pascal from .ioc import P4PIOC @@ -33,3 +34,19 @@ def create_docs(self) -> None: def create_gui(self) -> None: PvaEpicsGUI(self._controller_api, self._pv_prefix).create_gui(self.options.gui) + + def print_all(self) -> None: + def parse_attributes(api: ControllerAPI) -> list: + prefix = ":".join([self._pv_prefix] + list(api.path)) + attrs = [ + f"{prefix}:{snake_to_pascal(attribute)}" + for attribute in api.attributes.keys() + ] + for sub_api in api.sub_apis.values(): + attrs.extend(parse_attributes(sub_api)) + return attrs + + print(*parse_attributes(self._controller_api), sep="\n") + + def context(self) -> dict: + return {"print_all": self.print_all} From feaa2ccae146905bd4521bd78025075c228c4c18 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Tue, 12 Aug 2025 11:49:51 +0000 Subject: [PATCH 03/14] wip: add set and read pv functions for pva adapter --- src/fastcs/transport/epics/pva/adapter.py | 43 +++++++++++++++++++---- src/fastcs/util.py | 10 ++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/fastcs/transport/epics/pva/adapter.py b/src/fastcs/transport/epics/pva/adapter.py index 57cd446e9..4300944fa 100644 --- a/src/fastcs/transport/epics/pva/adapter.py +++ b/src/fastcs/transport/epics/pva/adapter.py @@ -1,9 +1,10 @@ +from fastcs.attributes import Attribute, AttrR, AttrW from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter from fastcs.transport.epics.docs import EpicsDocs from fastcs.transport.epics.gui import PvaEpicsGUI from fastcs.transport.epics.pva.options import EpicsPVAOptions -from fastcs.util import snake_to_pascal +from fastcs.util import pascal_to_snake, snake_to_pascal from .ioc import P4PIOC @@ -35,18 +36,48 @@ def create_docs(self) -> None: def create_gui(self) -> None: PvaEpicsGUI(self._controller_api, self._pv_prefix).create_gui(self.options.gui) - def print_all(self) -> None: - def parse_attributes(api: ControllerAPI) -> list: + def print_pvs(self) -> None: + def _parse_attributes(api: ControllerAPI) -> list: prefix = ":".join([self._pv_prefix] + list(api.path)) attrs = [ f"{prefix}:{snake_to_pascal(attribute)}" for attribute in api.attributes.keys() ] for sub_api in api.sub_apis.values(): - attrs.extend(parse_attributes(sub_api)) + attrs.extend(_parse_attributes(sub_api)) return attrs - print(*parse_attributes(self._controller_api), sep="\n") + print(*_parse_attributes(self._controller_api), sep="\n") + + def _find_pv(self, pv: str) -> Attribute | None: + pv_path = pv.split(":") + api_path = pv_path[1:-1] + attr = pv_path[-1] + + def _filter_attributes(api: ControllerAPI): + if api.path == api_path: + return api.attributes.get(pascal_to_snake(attr)) + for sub_api in api.sub_apis.values(): + attribute = _filter_attributes(sub_api) + if attribute is not None: + return attribute + return None + + return _filter_attributes(self._controller_api) + + async def set_pv(self, pv: str, value) -> None: + attribute = self._find_pv(pv) + assert isinstance(attribute, AttrW) + await attribute.sender.put(attribute, value) + + async def read_pv(self, pv: str) -> None: + attribute = self._find_pv(pv) + assert isinstance(attribute, AttrR) + print(f"{pv}: {attribute.get()}") def context(self) -> dict: - return {"print_all": self.print_all} + return { + "print_pvs": self.print_pvs, + "set_pv": self.set_pv, + "read_pv": self.read_pv, + } diff --git a/src/fastcs/util.py b/src/fastcs/util.py index e4f526a49..708b6a8c5 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -14,6 +14,16 @@ def snake_to_pascal(name: str) -> str: return name +def pascal_to_snake(name: str) -> str: + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", name): + return name + + s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) + s = re.sub(r"([A-Z]+)([A-Z][a-z0-9])", r"\1_\2", s) + + return s.lower() + + def numpy_to_fastcs_datatype(np_type) -> DataType: """Converts numpy types to fastcs types for widget creation. Only types important for widget creation are explicitly converted From 335b67955688c29802e0d284882b9612e90623c8 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Tue, 12 Aug 2025 13:08:28 +0000 Subject: [PATCH 04/14] refactor: make context an abstract method --- src/fastcs/transport/adapter.py | 1 + src/fastcs/transport/epics/ca/adapter.py | 4 ++++ src/fastcs/transport/graphQL/adapter.py | 5 +++++ src/fastcs/transport/rest/adapter.py | 5 +++++ src/fastcs/transport/tango/adapter.py | 4 ++++ 5 files changed, 19 insertions(+) diff --git a/src/fastcs/transport/adapter.py b/src/fastcs/transport/adapter.py index eebdf44e2..ed324a79c 100644 --- a/src/fastcs/transport/adapter.py +++ b/src/fastcs/transport/adapter.py @@ -23,5 +23,6 @@ def create_docs(self) -> None: def create_gui(self) -> None: pass + @abstractmethod def context(self) -> dict[str, Any]: return {} diff --git a/src/fastcs/transport/epics/ca/adapter.py b/src/fastcs/transport/epics/ca/adapter.py index f2b001cbe..0d24235fd 100644 --- a/src/fastcs/transport/epics/ca/adapter.py +++ b/src/fastcs/transport/epics/ca/adapter.py @@ -1,4 +1,5 @@ import asyncio +from typing import Any from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter @@ -42,3 +43,6 @@ async def serve(self) -> None: self._ioc.run(self._loop) while True: await asyncio.sleep(1) + + def context(self) -> dict[str, Any]: + return {} diff --git a/src/fastcs/transport/graphQL/adapter.py b/src/fastcs/transport/graphQL/adapter.py index b59deb9d3..3e6cd9583 100644 --- a/src/fastcs/transport/graphQL/adapter.py +++ b/src/fastcs/transport/graphQL/adapter.py @@ -1,3 +1,5 @@ +from typing import Any + from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter @@ -28,3 +30,6 @@ def create_gui(self) -> None: async def serve(self) -> None: await self._server.serve(self.options.gql) + + def context(self) -> dict[str, Any]: + return {} diff --git a/src/fastcs/transport/rest/adapter.py b/src/fastcs/transport/rest/adapter.py index 130c73480..6bc517f44 100644 --- a/src/fastcs/transport/rest/adapter.py +++ b/src/fastcs/transport/rest/adapter.py @@ -1,3 +1,5 @@ +from typing import Any + from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter @@ -28,3 +30,6 @@ def create_gui(self) -> None: async def serve(self) -> None: await self._server.serve(self.options.rest) + + def context(self) -> dict[str, Any]: + return {} diff --git a/src/fastcs/transport/tango/adapter.py b/src/fastcs/transport/tango/adapter.py index 283adabcb..037dec969 100644 --- a/src/fastcs/transport/tango/adapter.py +++ b/src/fastcs/transport/tango/adapter.py @@ -1,4 +1,5 @@ import asyncio +from typing import Any from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter @@ -35,3 +36,6 @@ async def serve(self) -> None: self.options.dsr, ) await coro + + def context(self) -> dict[str, Any]: + return {} From ef3cb65a5c522c0668fa74df580e24e0a471094c Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Tue, 12 Aug 2025 13:14:52 +0000 Subject: [PATCH 05/14] wip[docs]: add docstring to util function --- src/fastcs/util.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/fastcs/util.py b/src/fastcs/util.py index 708b6a8c5..54840fbb8 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -15,6 +15,9 @@ def snake_to_pascal(name: str) -> str: def pascal_to_snake(name: str) -> str: + """Converts string from Pascal case to snake case. + If string is not a valid Pascal case it will be returned unchanged + """ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", name): return name From dfadeb23b72340f9320bdef6dc44ac0762bd5084 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Tue, 12 Aug 2025 14:29:36 +0000 Subject: [PATCH 06/14] refactor: start term only if transports serving --- src/fastcs/launch.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index a45efd412..874c2a340 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -104,11 +104,12 @@ def run(self): async def serve(self) -> None: coros = [self._backend.serve()] - context = {"controller": self._backend} - for transport in self._transports: - context.update(transport.context()) - coros.append(transport.serve()) - coros.append(self._interactive_shell(context)) + if self._transports: + context = {"controller": self._backend} + for transport in self._transports: + context.update(transport.context()) + coros.append(transport.serve()) + coros.append(self._interactive_shell(context)) try: await asyncio.gather(*coros) except asyncio.CancelledError: From 1fccd5b1521acece7e184170edf737df6030ba22 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Wed, 13 Aug 2025 09:09:33 +0000 Subject: [PATCH 07/14] feat: remove wip features for pva --- src/fastcs/transport/epics/pva/adapter.py | 49 +---------------------- src/fastcs/util.py | 13 ------ 2 files changed, 2 insertions(+), 60 deletions(-) diff --git a/src/fastcs/transport/epics/pva/adapter.py b/src/fastcs/transport/epics/pva/adapter.py index 4300944fa..26249279f 100644 --- a/src/fastcs/transport/epics/pva/adapter.py +++ b/src/fastcs/transport/epics/pva/adapter.py @@ -1,10 +1,8 @@ -from fastcs.attributes import Attribute, AttrR, AttrW from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter from fastcs.transport.epics.docs import EpicsDocs from fastcs.transport.epics.gui import PvaEpicsGUI from fastcs.transport.epics.pva.options import EpicsPVAOptions -from fastcs.util import pascal_to_snake, snake_to_pascal from .ioc import P4PIOC @@ -36,48 +34,5 @@ def create_docs(self) -> None: def create_gui(self) -> None: PvaEpicsGUI(self._controller_api, self._pv_prefix).create_gui(self.options.gui) - def print_pvs(self) -> None: - def _parse_attributes(api: ControllerAPI) -> list: - prefix = ":".join([self._pv_prefix] + list(api.path)) - attrs = [ - f"{prefix}:{snake_to_pascal(attribute)}" - for attribute in api.attributes.keys() - ] - for sub_api in api.sub_apis.values(): - attrs.extend(_parse_attributes(sub_api)) - return attrs - - print(*_parse_attributes(self._controller_api), sep="\n") - - def _find_pv(self, pv: str) -> Attribute | None: - pv_path = pv.split(":") - api_path = pv_path[1:-1] - attr = pv_path[-1] - - def _filter_attributes(api: ControllerAPI): - if api.path == api_path: - return api.attributes.get(pascal_to_snake(attr)) - for sub_api in api.sub_apis.values(): - attribute = _filter_attributes(sub_api) - if attribute is not None: - return attribute - return None - - return _filter_attributes(self._controller_api) - - async def set_pv(self, pv: str, value) -> None: - attribute = self._find_pv(pv) - assert isinstance(attribute, AttrW) - await attribute.sender.put(attribute, value) - - async def read_pv(self, pv: str) -> None: - attribute = self._find_pv(pv) - assert isinstance(attribute, AttrR) - print(f"{pv}: {attribute.get()}") - - def context(self) -> dict: - return { - "print_pvs": self.print_pvs, - "set_pv": self.set_pv, - "read_pv": self.read_pv, - } + def context(self): + return {} diff --git a/src/fastcs/util.py b/src/fastcs/util.py index 54840fbb8..e4f526a49 100644 --- a/src/fastcs/util.py +++ b/src/fastcs/util.py @@ -14,19 +14,6 @@ def snake_to_pascal(name: str) -> str: return name -def pascal_to_snake(name: str) -> str: - """Converts string from Pascal case to snake case. - If string is not a valid Pascal case it will be returned unchanged - """ - if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", name): - return name - - s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) - s = re.sub(r"([A-Z]+)([A-Z][a-z0-9])", r"\1_\2", s) - - return s.lower() - - def numpy_to_fastcs_datatype(np_type) -> DataType: """Converts numpy types to fastcs types for widget creation. Only types important for widget creation are explicitly converted From 0d8d29097b51cac45d8e73914d5559c0891a9921 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Wed, 13 Aug 2025 12:35:18 +0000 Subject: [PATCH 08/14] feat: disable interactive terminal in tests --- src/fastcs/launch.py | 20 +++++++++++++++----- tests/conftest.py | 3 +++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index 874c2a340..c81f4cbfe 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -1,6 +1,7 @@ import asyncio import inspect import json +import os import signal from collections.abc import Coroutine from functools import partial @@ -104,12 +105,21 @@ def run(self): async def serve(self) -> None: coros = [self._backend.serve()] - if self._transports: - context = {"controller": self._backend} - for transport in self._transports: - context.update(transport.context()) - coros.append(transport.serve()) + context = { + "controller": self._controller, + "controller_api": self._backend.controller_api, + "transports": [ + transport.__class__.__name__ for transport in self._transports + ], + } + + for transport in self._transports: + coros.append(transport.serve()) + context.update(transport.context()) + + if not os.getenv("NOT_INTERACTIVE"): coros.append(self._interactive_shell(context)) + try: await asyncio.gather(*coros) except asyncio.CancelledError: diff --git a/tests/conftest.py b/tests/conftest.py index 80a76711d..7f44604a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,6 +84,9 @@ def pytest_internalerror(excinfo: pytest.ExceptionInfo[Any]): PV_PREFIX = "".join(random.choice(string.ascii_lowercase) for _ in range(12)) HERE = Path(os.path.dirname(os.path.abspath(__file__))) +# Prevent interactive terminal from running in tests +os.environ["NOT_INTERACTIVE"] = "true" + def _run_ioc_as_subprocess( pv_prefix: str, From dc675d1f430a3f33acf6315123507040dee57751 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Wed, 13 Aug 2025 12:35:40 +0000 Subject: [PATCH 09/14] refactor: remove while true from ca serve --- src/fastcs/transport/epics/ca/adapter.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/fastcs/transport/epics/ca/adapter.py b/src/fastcs/transport/epics/ca/adapter.py index 0d24235fd..e9d497f8f 100644 --- a/src/fastcs/transport/epics/ca/adapter.py +++ b/src/fastcs/transport/epics/ca/adapter.py @@ -41,8 +41,6 @@ def create_gui(self) -> None: async def serve(self) -> None: print(f"Running FastCS IOC: {self._pv_prefix}") self._ioc.run(self._loop) - while True: - await asyncio.sleep(1) def context(self) -> dict[str, Any]: return {} From be1ebba8a78017fb4da5499f6f066a7d2fab05b5 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Wed, 13 Aug 2025 12:40:08 +0000 Subject: [PATCH 10/14] refactor: make context a transport property --- src/fastcs/launch.py | 2 +- src/fastcs/transport/adapter.py | 2 +- src/fastcs/transport/epics/ca/adapter.py | 4 ---- src/fastcs/transport/epics/pva/adapter.py | 3 --- src/fastcs/transport/graphQL/adapter.py | 5 ----- src/fastcs/transport/rest/adapter.py | 5 ----- src/fastcs/transport/tango/adapter.py | 4 ---- 7 files changed, 2 insertions(+), 23 deletions(-) diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index c81f4cbfe..7547c99c2 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -115,7 +115,7 @@ async def serve(self) -> None: for transport in self._transports: coros.append(transport.serve()) - context.update(transport.context()) + context.update(transport.context) if not os.getenv("NOT_INTERACTIVE"): coros.append(self._interactive_shell(context)) diff --git a/src/fastcs/transport/adapter.py b/src/fastcs/transport/adapter.py index ed324a79c..c872265da 100644 --- a/src/fastcs/transport/adapter.py +++ b/src/fastcs/transport/adapter.py @@ -23,6 +23,6 @@ def create_docs(self) -> None: def create_gui(self) -> None: pass - @abstractmethod + @property def context(self) -> dict[str, Any]: return {} diff --git a/src/fastcs/transport/epics/ca/adapter.py b/src/fastcs/transport/epics/ca/adapter.py index e9d497f8f..50601a95c 100644 --- a/src/fastcs/transport/epics/ca/adapter.py +++ b/src/fastcs/transport/epics/ca/adapter.py @@ -1,5 +1,4 @@ import asyncio -from typing import Any from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter @@ -41,6 +40,3 @@ def create_gui(self) -> None: async def serve(self) -> None: print(f"Running FastCS IOC: {self._pv_prefix}") self._ioc.run(self._loop) - - def context(self) -> dict[str, Any]: - return {} diff --git a/src/fastcs/transport/epics/pva/adapter.py b/src/fastcs/transport/epics/pva/adapter.py index 26249279f..569442e23 100644 --- a/src/fastcs/transport/epics/pva/adapter.py +++ b/src/fastcs/transport/epics/pva/adapter.py @@ -33,6 +33,3 @@ def create_docs(self) -> None: def create_gui(self) -> None: PvaEpicsGUI(self._controller_api, self._pv_prefix).create_gui(self.options.gui) - - def context(self): - return {} diff --git a/src/fastcs/transport/graphQL/adapter.py b/src/fastcs/transport/graphQL/adapter.py index 3e6cd9583..b59deb9d3 100644 --- a/src/fastcs/transport/graphQL/adapter.py +++ b/src/fastcs/transport/graphQL/adapter.py @@ -1,5 +1,3 @@ -from typing import Any - from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter @@ -30,6 +28,3 @@ def create_gui(self) -> None: async def serve(self) -> None: await self._server.serve(self.options.gql) - - def context(self) -> dict[str, Any]: - return {} diff --git a/src/fastcs/transport/rest/adapter.py b/src/fastcs/transport/rest/adapter.py index 6bc517f44..130c73480 100644 --- a/src/fastcs/transport/rest/adapter.py +++ b/src/fastcs/transport/rest/adapter.py @@ -1,5 +1,3 @@ -from typing import Any - from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter @@ -30,6 +28,3 @@ def create_gui(self) -> None: async def serve(self) -> None: await self._server.serve(self.options.rest) - - def context(self) -> dict[str, Any]: - return {} diff --git a/src/fastcs/transport/tango/adapter.py b/src/fastcs/transport/tango/adapter.py index 037dec969..283adabcb 100644 --- a/src/fastcs/transport/tango/adapter.py +++ b/src/fastcs/transport/tango/adapter.py @@ -1,5 +1,4 @@ import asyncio -from typing import Any from fastcs.controller_api import ControllerAPI from fastcs.transport.adapter import TransportAdapter @@ -36,6 +35,3 @@ async def serve(self) -> None: self.options.dsr, ) await coro - - def context(self) -> dict[str, Any]: - return {} From 3e6fe27a210924b1651209006fa2b1d13fbfeb37 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Wed, 13 Aug 2025 14:23:44 +0000 Subject: [PATCH 11/14] feat: error if common context found --- src/fastcs/launch.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index 7547c99c2..2679845cd 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -115,6 +115,14 @@ async def serve(self) -> None: for transport in self._transports: coros.append(transport.serve()) + common_context = context.keys() & transport.context.keys() + if common_context: + raise LaunchError( + "Duplicate context keys found between " + f"current context { ({k: context[k] for k in common_context}) } " + f"and {transport.__class__.__name__} context: " + f"{ ({k: transport.context[k] for k in common_context}) }" + ) context.update(transport.context) if not os.getenv("NOT_INTERACTIVE"): From 87e56b28e7839cca5cb92f7e7224a89bbbb65c0f Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Thu, 14 Aug 2025 11:06:36 +0000 Subject: [PATCH 12/14] tests:check attr increment instead of checking stdout --- docs/snippets/static02.py | 2 +- docs/snippets/static03.py | 2 +- src/fastcs/launch.py | 4 +--- tests/conftest.py | 3 --- tests/example_p4p_ioc.py | 4 ++++ tests/transport/epics/pva/test_p4p.py | 32 +++++++++++---------------- 6 files changed, 20 insertions(+), 27 deletions(-) diff --git a/docs/snippets/static02.py b/docs/snippets/static02.py index 034ddbd62..376777993 100644 --- a/docs/snippets/static02.py +++ b/docs/snippets/static02.py @@ -7,4 +7,4 @@ class TemperatureController(Controller): fastcs = FastCS(TemperatureController(), []) -fastcs.run() +# fastcs.run() # Commented as this will block diff --git a/docs/snippets/static03.py b/docs/snippets/static03.py index 5775d01ca..57d210c07 100644 --- a/docs/snippets/static03.py +++ b/docs/snippets/static03.py @@ -9,4 +9,4 @@ class TemperatureController(Controller): fastcs = FastCS(TemperatureController(), []) -fastcs.run() +# fastcs.run() # Commented as this will block diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index 2679845cd..2164dcff1 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -1,7 +1,6 @@ import asyncio import inspect import json -import os import signal from collections.abc import Coroutine from functools import partial @@ -125,8 +124,7 @@ async def serve(self) -> None: ) context.update(transport.context) - if not os.getenv("NOT_INTERACTIVE"): - coros.append(self._interactive_shell(context)) + coros.append(self._interactive_shell(context)) try: await asyncio.gather(*coros) diff --git a/tests/conftest.py b/tests/conftest.py index 7f44604a3..80a76711d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,9 +84,6 @@ def pytest_internalerror(excinfo: pytest.ExceptionInfo[Any]): PV_PREFIX = "".join(random.choice(string.ascii_lowercase) for _ in range(12)) HERE = Path(os.path.dirname(os.path.abspath(__file__))) -# Prevent interactive terminal from running in tests -os.environ["NOT_INTERACTIVE"] = "true" - def _run_ioc_as_subprocess( pv_prefix: str, diff --git a/tests/example_p4p_ioc.py b/tests/example_p4p_ioc.py index 5fff09915..fd6fe3f4c 100644 --- a/tests/example_p4p_ioc.py +++ b/tests/example_p4p_ioc.py @@ -41,6 +41,7 @@ async def d(self): print("D: RUNNING") await asyncio.sleep(0.1) print("D: FINISHED") + await self.j.set(self.j.get() + 1) e: AttrR = AttrR(Bool()) @@ -62,6 +63,9 @@ async def i(self): else: self.fail_on_next_e = True print("I: FINISHED") + await self.j.set(self.j.get() + 1) + + j: AttrR = AttrR(Int()) def run(pv_prefix="P4P_TEST_DEVICE"): diff --git a/tests/transport/epics/pva/test_p4p.py b/tests/transport/epics/pva/test_p4p.py index 16479c199..d328d2478 100644 --- a/tests/transport/epics/pva/test_p4p.py +++ b/tests/transport/epics/pva/test_p4p.py @@ -104,31 +104,29 @@ async def test_scan_method(p4p_subprocess: tuple[str, Queue]): @pytest.mark.asyncio async def test_command_method(p4p_subprocess: tuple[str, Queue]): - QUEUE_TIMEOUT = 1 - pv_prefix, stdout_queue = p4p_subprocess + pv_prefix, _ = p4p_subprocess d_values = asyncio.Queue() i_values = asyncio.Queue() + j_values = asyncio.Queue() ctxt = Context("pva") d_monitor = ctxt.monitor(f"{pv_prefix}:Child1:D", d_values.put) i_monitor = ctxt.monitor(f"{pv_prefix}:Child1:I", i_values.put) + j_monitor = ctxt.monitor(f"{pv_prefix}:Child1:J", j_values.put) try: - if not stdout_queue.empty(): - raise RuntimeError("stdout_queue not empty", stdout_queue.get()) + j_initial_value = await j_values.get() assert (await d_values.get()).raw.value is False await ctxt.put(f"{pv_prefix}:Child1:D", True) assert (await d_values.get()).raw.value is True + # D process hangs for 0.1s, so we wait slightly longer await asyncio.sleep(0.2) + # Value returns to False, signifying completed process assert (await d_values.get()).raw.value is False - - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "D: RUNNING" - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "D: FINISHED" - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" + # D process increments J by 1 + assert (await j_values.get()).raw.value == j_initial_value + 1 # First run fails - assert stdout_queue.empty() before_command_value = (await i_values.get()).raw assert before_command_value["value"] is False assert before_command_value["alarm"]["severity"] == 0 @@ -143,30 +141,26 @@ async def test_command_method(p4p_subprocess: tuple[str, Queue]): assert ( after_command_value["alarm"]["message"] == "I: FAILED WITH THIS WEIRD ERROR" ) - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "I: RUNNING" - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" + # Failed I process does not increment J + assert j_values.empty() # Second run succeeds - assert stdout_queue.empty() await ctxt.put(f"{pv_prefix}:Child1:I", True) assert (await i_values.get()).raw.value is True await asyncio.sleep(0.2) after_command_value = (await i_values.get()).raw + # Successful I process increments J by 1 + assert (await j_values.get()).raw.value == j_initial_value + 2 # On the second run the command succeeded so we left the error state assert after_command_value["value"] is False assert after_command_value["alarm"]["severity"] == 0 assert after_command_value["alarm"]["message"] == "" - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "I: RUNNING" - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "I: FINISHED" - assert stdout_queue.get(timeout=QUEUE_TIMEOUT) == "\n" - assert stdout_queue.empty() - finally: d_monitor.close() i_monitor.close() + j_monitor.close() @pytest.mark.asyncio From 1c77842f2ce62e4748f97debfcf045c1240a9408 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Thu, 14 Aug 2025 11:09:45 +0000 Subject: [PATCH 13/14] tests: amend ioc pvi assertion --- tests/transport/epics/pva/test_p4p.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/transport/epics/pva/test_p4p.py b/tests/transport/epics/pva/test_p4p.py index d328d2478..12460613e 100644 --- a/tests/transport/epics/pva/test_p4p.py +++ b/tests/transport/epics/pva/test_p4p.py @@ -60,6 +60,7 @@ async def test_ioc(p4p_subprocess: tuple[str, Queue]): "g": {"rw": f"{pv_prefix}:Child1:G"}, "h": {"rw": f"{pv_prefix}:Child1:H"}, "i": {"x": f"{pv_prefix}:Child1:I"}, + "j": {"r": f"{pv_prefix}:Child1:J"}, } From 4153834ca64996cf7bf65d7b10ec7c1b3a04a7f1 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Thu, 14 Aug 2025 11:57:36 +0000 Subject: [PATCH 14/14] tests: add test for context error --- src/fastcs/launch.py | 2 +- tests/test_launch.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index 2164dcff1..a96fdaa45 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -116,7 +116,7 @@ async def serve(self) -> None: coros.append(transport.serve()) common_context = context.keys() & transport.context.keys() if common_context: - raise LaunchError( + raise RuntimeError( "Duplicate context keys found between " f"current context { ({k: context[k] for k in common_context}) } " f"and {transport.__class__.__name__} context: " diff --git a/tests/test_launch.py b/tests/test_launch.py index cad6041cb..9b1aa4b4f 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -145,3 +145,17 @@ def test_get_schema(data): ref_schema = YAML(typ="safe").load(data / "schema.json") assert target_schema == ref_schema + + +def test_error_if_identical_context_in_transports(mocker: MockerFixture, data): + mocker.patch("fastcs.launch.FastCS.create_gui") + mocker.patch("fastcs.launch.FastCS.create_docs") + mocker.patch( + "fastcs.transport.adapter.TransportAdapter.context", + new_callable=mocker.PropertyMock, + return_value={"controller": "test"}, + ) + app = _launch(IsHinted) + result = runner.invoke(app, ["run", str(data / "config.yaml")]) + assert isinstance(result.exception, RuntimeError) + assert "Duplicate context keys found" in result.exception.args[0]