Skip to content

Commit f005662

Browse files
committed
Try to remove background thread
1 parent f67c15a commit f005662

29 files changed

+560
-206
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ coverage.xml
4545
cov.xml
4646
.pytest_cache/
4747
.mypy_cache/
48+
.benchmarks/
4849

4950
# Translations
5051
*.mo

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"console": "integratedTerminal",
1717
"env": {
1818
// Enable break on exception when debugging tests (see: tests/conftest.py)
19-
"PYTEST_RAISE": "1",
19+
"PYTEST_RAISE": "1"
2020
},
2121
}
2222
]

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dev = [
3434
"pydata-sphinx-theme>=0.12",
3535
"pyright",
3636
"pytest",
37+
"pytest-benchmark",
3738
"pytest-cov",
3839
"pytest-mock",
3940
"pytest-asyncio",
@@ -69,7 +70,7 @@ reportMissingImports = false # Ignore missing stubs in imported modules
6970
[tool.pytest.ini_options]
7071
# Run pytest with all our checkers, and don't spam us with massive tracebacks on error
7172
addopts = """
72-
--tb=native -vv --doctest-modules --doctest-glob="*.rst"
73+
--tb=native -vv --doctest-modules --doctest-glob="*.rst" --benchmark-sort=mean --benchmark-autosave --benchmark-columns="mean, min, max, outliers, ops, rounds"
7374
"""
7475
# https://iscinumpy.gitlab.io/post/bound-version-constraints/#watch-for-warnings
7576
filterwarnings = "error"

src/fastcs/attributes.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,10 @@ def __init__(
6969
allowed_values: list[T] | None = None,
7070
description: str | None = None,
7171
) -> None:
72-
assert (
73-
datatype.dtype in ATTRIBUTE_TYPES
74-
), f"Attr type must be one of {ATTRIBUTE_TYPES}, received type {datatype.dtype}"
72+
assert datatype.dtype in ATTRIBUTE_TYPES, (
73+
f"Attr type must be one of {ATTRIBUTE_TYPES}"
74+
f", received type {datatype.dtype}"
75+
)
7576
self._datatype: DataType[T] = datatype
7677
self._access_mode: AttrMode = access_mode
7778
self._group = group

src/fastcs/backend.py

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import asyncio
22
from collections import defaultdict
33
from collections.abc import Callable
4-
from concurrent.futures import Future
54
from types import MethodType
65

7-
from softioc.asyncio_dispatcher import AsyncioDispatcher
8-
96
from .attributes import AttrR, AttrW, Sender, Updater
107
from .controller import Controller, SingleMapping
118
from .exceptions import FastCSException
@@ -15,19 +12,15 @@ class Backend:
1512
def __init__(
1613
self,
1714
controller: Controller,
18-
loop: asyncio.AbstractEventLoop | None = None,
15+
loop: asyncio.AbstractEventLoop,
1916
):
20-
self.dispatcher = AsyncioDispatcher(loop)
21-
self._loop = self.dispatcher.loop
17+
self._loop = loop
2218
self._controller = controller
2319

2420
self._initial_coros = [controller.connect]
25-
self._scan_futures: set[Future] = set()
26-
27-
asyncio.run_coroutine_threadsafe(
28-
self._controller.initialise(), self._loop
29-
).result()
21+
self._scan_tasks: set[asyncio.Task] = set()
3022

23+
loop.run_until_complete(self._controller.initialise())
3124
self._link_process_tasks()
3225

3326
def _link_process_tasks(self):
@@ -36,28 +29,26 @@ def _link_process_tasks(self):
3629
_link_attribute_sender_class(single_mapping)
3730

3831
def __del__(self):
39-
self.stop_scan_futures()
32+
self._stop_scan_tasks()
4033

41-
def run(self):
42-
self._run_initial_futures()
43-
self.start_scan_futures()
34+
async def serve(self):
35+
await self._run_initial_tasks()
36+
await self._start_scan_tasks()
4437

45-
def _run_initial_futures(self):
38+
async def _run_initial_tasks(self):
4639
for coro in self._initial_coros:
47-
future = asyncio.run_coroutine_threadsafe(coro(), self._loop)
48-
future.result()
40+
await coro()
4941

50-
def start_scan_futures(self):
51-
self._scan_futures = {
52-
asyncio.run_coroutine_threadsafe(coro(), self._loop)
53-
for coro in _get_scan_coros(self._controller)
42+
async def _start_scan_tasks(self):
43+
self._scan_tasks = {
44+
self._loop.create_task(coro()) for coro in _get_scan_coros(self._controller)
5445
}
5546

56-
def stop_scan_futures(self):
57-
for future in self._scan_futures:
58-
if not future.done():
47+
def _stop_scan_tasks(self):
48+
for task in self._scan_tasks:
49+
if not task.done():
5950
try:
60-
future.cancel()
51+
task.cancel()
6152
except asyncio.CancelledError:
6253
pass
6354

@@ -83,9 +74,9 @@ def _link_attribute_sender_class(single_mapping: SingleMapping) -> None:
8374
for attr_name, attribute in single_mapping.attributes.items():
8475
match attribute:
8576
case AttrW(sender=Sender()):
86-
assert (
87-
not attribute.has_process_callback()
88-
), f"Cannot assign both put method and Sender object to {attr_name}"
77+
assert not attribute.has_process_callback(), (
78+
f"Cannot assign both put method and Sender object to {attr_name}"
79+
)
8980

9081
callback = _create_sender_callback(attribute, single_mapping.controller)
9182
attribute.set_process_callback(callback)

src/fastcs/launch.py

Lines changed: 58 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import inspect
23
import json
34
from pathlib import Path
@@ -19,7 +20,9 @@
1920
from .transport.tango.options import TangoOptions
2021

2122
# Define a type alias for transport options
22-
TransportOptions: TypeAlias = EpicsOptions | TangoOptions | RestOptions | GraphQLOptions
23+
TransportOptions: TypeAlias = list[
24+
EpicsOptions | TangoOptions | RestOptions | GraphQLOptions
25+
]
2326

2427

2528
class FastCS:
@@ -28,48 +31,63 @@ def __init__(
2831
controller: Controller,
2932
transport_options: TransportOptions,
3033
):
31-
self._backend = Backend(controller)
32-
self._transport: TransportAdapter
33-
match transport_options:
34-
case EpicsOptions():
35-
from .transport.epics.adapter import EpicsTransport
36-
37-
self._transport = EpicsTransport(
38-
controller,
39-
self._backend.dispatcher,
40-
transport_options,
41-
)
42-
case GraphQLOptions():
43-
from .transport.graphQL.adapter import GraphQLTransport
44-
45-
self._transport = GraphQLTransport(
46-
controller,
47-
transport_options,
48-
)
49-
case TangoOptions():
50-
from .transport.tango.adapter import TangoTransport
51-
52-
self._transport = TangoTransport(
53-
controller,
54-
transport_options,
55-
)
56-
case RestOptions():
57-
from .transport.rest.adapter import RestTransport
58-
59-
self._transport = RestTransport(
60-
controller,
61-
transport_options,
62-
)
34+
self._loop = asyncio.get_event_loop()
35+
self._backend = Backend(controller, self._loop)
36+
transport: TransportAdapter
37+
self._transports: list[TransportAdapter] = []
38+
for option in transport_options:
39+
match option:
40+
case EpicsOptions():
41+
from .transport.epics.adapter import EpicsTransport
42+
43+
transport = EpicsTransport(
44+
controller,
45+
self._loop,
46+
option,
47+
)
48+
case TangoOptions():
49+
from .transport.tango.adapter import TangoTransport
50+
51+
transport = TangoTransport(
52+
controller,
53+
self._loop,
54+
option,
55+
)
56+
case RestOptions():
57+
from .transport.rest.adapter import RestTransport
58+
59+
transport = RestTransport(
60+
controller,
61+
option,
62+
)
63+
case GraphQLOptions():
64+
from .transport.graphQL.adapter import GraphQLTransport
65+
66+
transport = GraphQLTransport(
67+
controller,
68+
option,
69+
)
70+
self._transports.append(transport)
6371

6472
def create_docs(self) -> None:
65-
self._transport.create_docs()
73+
for transport in self._transports:
74+
if hasattr(transport.options, "docs"):
75+
transport.create_docs()
6676

6777
def create_gui(self) -> None:
68-
self._transport.create_gui()
78+
for transport in self._transports:
79+
if hasattr(transport.options, "gui"):
80+
transport.create_docs()
6981

70-
def run(self) -> None:
71-
self._backend.run()
72-
self._transport.run()
82+
def run(self):
83+
self._loop.run_until_complete(
84+
self.serve(),
85+
)
86+
87+
async def serve(self) -> None:
88+
coros = [self._backend.serve()]
89+
coros.extend([transport.serve() for transport in self._transports])
90+
await asyncio.gather(*coros)
7391

7492

7593
def launch(
@@ -158,10 +176,8 @@ def run(
158176
instance_options.transport,
159177
)
160178

161-
if "gui" in options_yaml["transport"]:
162-
instance.create_gui()
163-
if "docs" in options_yaml["transport"]:
164-
instance.create_docs()
179+
instance.create_gui()
180+
instance.create_docs()
165181
instance.run()
166182

167183
@launch_typer.command(name="version", help=f"{controller_class.__name__} version")

src/fastcs/transport/adapter.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from abc import ABC, abstractmethod
2+
from typing import Any
23

34

45
class TransportAdapter(ABC):
6+
@property
57
@abstractmethod
6-
def run(self) -> None:
8+
def options(self) -> Any:
9+
pass
10+
11+
@abstractmethod
12+
async def serve(self) -> None:
713
pass
814

915
@abstractmethod
Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from softioc.asyncio_dispatcher import AsyncioDispatcher
1+
import asyncio
22

33
from fastcs.controller import Controller
44
from fastcs.transport.adapter import TransportAdapter
@@ -13,20 +13,30 @@ class EpicsTransport(TransportAdapter):
1313
def __init__(
1414
self,
1515
controller: Controller,
16-
dispatcher: AsyncioDispatcher,
16+
loop: asyncio.AbstractEventLoop,
1717
options: EpicsOptions | None = None,
1818
) -> None:
19-
self.options = options or EpicsOptions()
2019
self._controller = controller
21-
self._dispatcher = dispatcher
20+
self._loop = loop
21+
self._options = options or EpicsOptions()
2222
self._pv_prefix = self.options.ioc.pv_prefix
23-
self._ioc = EpicsIOC(self.options.ioc.pv_prefix, controller)
23+
self._ioc = EpicsIOC(
24+
self.options.ioc.pv_prefix,
25+
controller,
26+
self._options.ioc,
27+
)
28+
29+
@property
30+
def options(self) -> EpicsOptions:
31+
return self._options
2432

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

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

31-
def run(self):
32-
self._ioc.run(self._dispatcher)
39+
async def serve(self) -> None:
40+
self._ioc.run(self._loop)
41+
while True:
42+
await asyncio.sleep(1)

src/fastcs/transport/epics/ioc.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
from collections.abc import Callable
23
from dataclasses import asdict
34
from types import MethodType
@@ -50,7 +51,7 @@ def __init__(
5051
controller: Controller,
5152
options: EpicsIOCOptions | None = None,
5253
):
53-
self.options = options or EpicsIOCOptions()
54+
self._options = options or EpicsIOCOptions()
5455
self._controller = controller
5556
_add_pvi_info(f"{pv_prefix}:PVI")
5657
_add_sub_controller_pvi_info(pv_prefix, controller)
@@ -60,18 +61,12 @@ def __init__(
6061

6162
def run(
6263
self,
63-
dispatcher: AsyncioDispatcher,
64+
loop: asyncio.AbstractEventLoop,
6465
) -> None:
66+
dispatcher = AsyncioDispatcher(loop) # Needs running loop
6567
builder.LoadDatabase()
6668
softioc.iocInit(dispatcher)
6769

68-
if self.options.terminal:
69-
context = {
70-
"dispatcher": dispatcher,
71-
"controller": self._controller,
72-
}
73-
softioc.interactive_ioc(context)
74-
7570

7671
def _add_pvi_info(
7772
pvi: str,

src/fastcs/transport/epics/options.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ class EpicsGUIOptions:
2323

2424
@dataclass
2525
class EpicsIOCOptions:
26-
terminal: bool = True
2726
pv_prefix: str = "MY-DEVICE-PREFIX"
2827

2928

0 commit comments

Comments
 (0)