Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove background thread #98

Merged
merged 12 commits into from
Jan 24, 2025
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ coverage.xml
cov.xml
.pytest_cache/
.mypy_cache/
.benchmarks/

# Translations
*.mo
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dev = [
"pydata-sphinx-theme>=0.12",
"pyright",
"pytest",
"pytest-benchmark",
"pytest-cov",
"pytest-mock",
"pytest-asyncio",
Expand Down Expand Up @@ -69,7 +70,7 @@ reportMissingImports = false # Ignore missing stubs in imported modules
[tool.pytest.ini_options]
# Run pytest with all our checkers, and don't spam us with massive tracebacks on error
addopts = """
--tb=native -vv --doctest-modules --doctest-glob="*.rst"
--tb=native -vv --doctest-modules --doctest-glob="*.rst" --benchmark-sort=mean --benchmark-columns="mean, min, max, outliers, ops, rounds"
"""
# https://iscinumpy.gitlab.io/post/bound-version-constraints/#watch-for-warnings
filterwarnings = "error"
Expand Down
7 changes: 4 additions & 3 deletions src/fastcs/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ def __init__(
allowed_values: list[T] | None = None,
description: str | None = None,
) -> None:
assert (
datatype.dtype in ATTRIBUTE_TYPES
), f"Attr type must be one of {ATTRIBUTE_TYPES}, received type {datatype.dtype}"
assert datatype.dtype in ATTRIBUTE_TYPES, (
f"Attr type must be one of {ATTRIBUTE_TYPES}"
f", received type {datatype.dtype}"
)
self._datatype: DataType[T] = datatype
self._access_mode: AttrMode = access_mode
self._group = group
Expand Down
49 changes: 20 additions & 29 deletions src/fastcs/backend.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import asyncio
from collections import defaultdict
from collections.abc import Callable
from concurrent.futures import Future
from types import MethodType

from softioc.asyncio_dispatcher import AsyncioDispatcher

from .attributes import AttrR, AttrW, Sender, Updater
from .controller import Controller, SingleMapping
from .exceptions import FastCSException
Expand All @@ -15,19 +12,15 @@ class Backend:
def __init__(
self,
controller: Controller,
loop: asyncio.AbstractEventLoop | None = None,
loop: asyncio.AbstractEventLoop,
):
self.dispatcher = AsyncioDispatcher(loop)
self._loop = self.dispatcher.loop
self._loop = loop
self._controller = controller

self._initial_coros = [controller.connect]
self._scan_futures: set[Future] = set()

asyncio.run_coroutine_threadsafe(
self._controller.initialise(), self._loop
).result()
self._scan_tasks: set[asyncio.Task] = set()

loop.run_until_complete(self._controller.initialise())
self._link_process_tasks()

def _link_process_tasks(self):
Expand All @@ -36,28 +29,26 @@ def _link_process_tasks(self):
_link_attribute_sender_class(single_mapping)

def __del__(self):
self.stop_scan_futures()
self._stop_scan_tasks()

def run(self):
self._run_initial_futures()
self.start_scan_futures()
async def serve(self):
await self._run_initial_tasks()
await self._start_scan_tasks()

def _run_initial_futures(self):
async def _run_initial_tasks(self):
marcelldls marked this conversation as resolved.
Show resolved Hide resolved
for coro in self._initial_coros:
future = asyncio.run_coroutine_threadsafe(coro(), self._loop)
future.result()
await coro()

def start_scan_futures(self):
self._scan_futures = {
asyncio.run_coroutine_threadsafe(coro(), self._loop)
for coro in _get_scan_coros(self._controller)
async def _start_scan_tasks(self):
self._scan_tasks = {
self._loop.create_task(coro()) for coro in _get_scan_coros(self._controller)
}

def stop_scan_futures(self):
for future in self._scan_futures:
if not future.done():
def _stop_scan_tasks(self):
for task in self._scan_tasks:
if not task.done():
try:
future.cancel()
task.cancel()
except asyncio.CancelledError:
pass

Expand All @@ -83,9 +74,9 @@ def _link_attribute_sender_class(single_mapping: SingleMapping) -> None:
for attr_name, attribute in single_mapping.attributes.items():
match attribute:
case AttrW(sender=Sender()):
assert (
not attribute.has_process_callback()
), f"Cannot assign both put method and Sender object to {attr_name}"
assert not attribute.has_process_callback(), (
f"Cannot assign both put method and Sender object to {attr_name}"
)

callback = _create_sender_callback(attribute, single_mapping.controller)
attribute.set_process_callback(callback)
Expand Down
100 changes: 58 additions & 42 deletions src/fastcs/launch.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import inspect
import json
from pathlib import Path
Expand All @@ -19,7 +20,9 @@
from .transport.tango.options import TangoOptions

# Define a type alias for transport options
TransportOptions: TypeAlias = EpicsOptions | TangoOptions | RestOptions | GraphQLOptions
TransportOptions: TypeAlias = list[
EpicsOptions | TangoOptions | RestOptions | GraphQLOptions
]


class FastCS:
Expand All @@ -28,48 +31,63 @@
controller: Controller,
transport_options: TransportOptions,
):
self._backend = Backend(controller)
self._transport: TransportAdapter
match transport_options:
case EpicsOptions():
from .transport.epics.adapter import EpicsTransport

self._transport = EpicsTransport(
controller,
self._backend.dispatcher,
transport_options,
)
case GraphQLOptions():
from .transport.graphQL.adapter import GraphQLTransport

self._transport = GraphQLTransport(
controller,
transport_options,
)
case TangoOptions():
from .transport.tango.adapter import TangoTransport

self._transport = TangoTransport(
controller,
transport_options,
)
case RestOptions():
from .transport.rest.adapter import RestTransport

self._transport = RestTransport(
controller,
transport_options,
)
self._loop = asyncio.get_event_loop()
self._backend = Backend(controller, self._loop)
transport: TransportAdapter
self._transports: list[TransportAdapter] = []
for option in transport_options:
match option:
case EpicsOptions():
from .transport.epics.adapter import EpicsTransport

transport = EpicsTransport(
controller,
self._loop,
option,
)
case TangoOptions():
from .transport.tango.adapter import TangoTransport

transport = TangoTransport(
controller,
self._loop,
option,
)
case RestOptions():
from .transport.rest.adapter import RestTransport

transport = RestTransport(
controller,
option,
)
case GraphQLOptions():
from .transport.graphQL.adapter import GraphQLTransport

transport = GraphQLTransport(
controller,
option,
)
self._transports.append(transport)

def create_docs(self) -> None:
self._transport.create_docs()
for transport in self._transports:
if hasattr(transport.options, "docs"):
transport.create_docs()

Check warning on line 75 in src/fastcs/launch.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/launch.py#L73-L75

Added lines #L73 - L75 were not covered by tests

def create_gui(self) -> None:
self._transport.create_gui()
for transport in self._transports:
if hasattr(transport.options, "gui"):
transport.create_docs()

Check warning on line 80 in src/fastcs/launch.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/launch.py#L78-L80

Added lines #L78 - L80 were not covered by tests

def run(self) -> None:
self._backend.run()
self._transport.run()
def run(self):
self._loop.run_until_complete(
self.serve(),
)

async def serve(self) -> None:
coros = [self._backend.serve()]
coros.extend([transport.serve() for transport in self._transports])
await asyncio.gather(*coros)


def launch(
Expand Down Expand Up @@ -158,10 +176,8 @@
instance_options.transport,
)

if "gui" in options_yaml["transport"]:
instance.create_gui()
if "docs" in options_yaml["transport"]:
instance.create_docs()
instance.create_gui()
instance.create_docs()
instance.run()

@launch_typer.command(name="version", help=f"{controller_class.__name__} version")
Expand Down
8 changes: 7 additions & 1 deletion src/fastcs/transport/adapter.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from abc import ABC, abstractmethod
from typing import Any


class TransportAdapter(ABC):
@property
@abstractmethod
def run(self) -> None:
def options(self) -> Any:
pass

Check warning on line 9 in src/fastcs/transport/adapter.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/adapter.py#L9

Added line #L9 was not covered by tests

@abstractmethod
async def serve(self) -> None:
pass

@abstractmethod
Expand Down
24 changes: 17 additions & 7 deletions src/fastcs/transport/epics/adapter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from softioc.asyncio_dispatcher import AsyncioDispatcher
import asyncio

from fastcs.controller import Controller
from fastcs.transport.adapter import TransportAdapter
Expand All @@ -13,20 +13,30 @@ class EpicsTransport(TransportAdapter):
def __init__(
self,
controller: Controller,
dispatcher: AsyncioDispatcher,
loop: asyncio.AbstractEventLoop,
options: EpicsOptions | None = None,
) -> None:
self.options = options or EpicsOptions()
self._controller = controller
self._dispatcher = dispatcher
self._loop = loop
self._options = options or EpicsOptions()
self._pv_prefix = self.options.ioc.pv_prefix
self._ioc = EpicsIOC(self.options.ioc.pv_prefix, controller)
self._ioc = EpicsIOC(
self.options.ioc.pv_prefix,
controller,
self._options.ioc,
)

@property
def options(self) -> EpicsOptions:
return self._options

def create_docs(self) -> None:
EpicsDocs(self._controller).create_docs(self.options.docs)

def create_gui(self) -> None:
EpicsGUI(self._controller, self._pv_prefix).create_gui(self.options.gui)

def run(self):
self._ioc.run(self._dispatcher)
async def serve(self) -> None:
self._ioc.run(self._loop)
while True:
await asyncio.sleep(1)
13 changes: 4 additions & 9 deletions src/fastcs/transport/epics/ioc.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
from collections.abc import Callable
from dataclasses import asdict
from types import MethodType
Expand Down Expand Up @@ -50,7 +51,7 @@ def __init__(
controller: Controller,
options: EpicsIOCOptions | None = None,
):
self.options = options or EpicsIOCOptions()
self._options = options or EpicsIOCOptions()
self._controller = controller
_add_pvi_info(f"{pv_prefix}:PVI")
_add_sub_controller_pvi_info(pv_prefix, controller)
Expand All @@ -60,18 +61,12 @@ def __init__(

def run(
self,
dispatcher: AsyncioDispatcher,
loop: asyncio.AbstractEventLoop,
) -> None:
dispatcher = AsyncioDispatcher(loop) # Needs running loop
builder.LoadDatabase()
softioc.iocInit(dispatcher)

if self.options.terminal:
context = {
"dispatcher": dispatcher,
"controller": self._controller,
}
softioc.interactive_ioc(context)


def _add_pvi_info(
pvi: str,
Expand Down
1 change: 0 additions & 1 deletion src/fastcs/transport/epics/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ class EpicsGUIOptions:

@dataclass
class EpicsIOCOptions:
terminal: bool = True
pv_prefix: str = "MY-DEVICE-PREFIX"


Expand Down
6 changes: 3 additions & 3 deletions src/fastcs/transport/epics/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ def attr_is_enum(attribute: Attribute) -> bool:

"""
match attribute:
case Attribute(
datatype=String(), allowed_values=allowed_values
) if allowed_values is not None and len(allowed_values) <= MBB_MAX_CHOICES:
case Attribute(datatype=String(), allowed_values=allowed_values) if (
allowed_values is not None and len(allowed_values) <= MBB_MAX_CHOICES
):
return True
case _:
return False
Expand Down
10 changes: 7 additions & 3 deletions src/fastcs/transport/graphQL/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@
controller: Controller,
options: GraphQLOptions | None = None,
):
self.options = options or GraphQLOptions()
self._options = options or GraphQLOptions()
self._server = GraphQLServer(controller)

@property
def options(self) -> GraphQLOptions:
return self._options

Check warning on line 19 in src/fastcs/transport/graphQL/adapter.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/adapter.py#L19

Added line #L19 was not covered by tests

def create_docs(self) -> None:
raise NotImplementedError

def create_gui(self) -> None:
raise NotImplementedError

def run(self) -> None:
self._server.run(self.options.gql)
async def serve(self) -> None:
await self._server.serve(self.options.gql)

Check warning on line 28 in src/fastcs/transport/graphQL/adapter.py

View check run for this annotation

Codecov / codecov/patch

src/fastcs/transport/graphQL/adapter.py#L28

Added line #L28 was not covered by tests
Loading
Loading