Skip to content

Commit b605942

Browse files
795 Care about where a plan is sourced (#807)
Fixes #795
1 parent e18b9be commit b605942

File tree

10 files changed

+90
-6
lines changed

10 files changed

+90
-6
lines changed

docs/how-to/write-plans.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ def my_plan(
2828
...
2929
```
3030

31-
The type annotations (e.g. `: str`, `: int`, `-> MsgGenerator`) are required as blueapi uses them to detect that this function is intended to be a plan and generate its runtime API.
31+
## Detection
32+
33+
The type annotations in the example above (e.g. `: str`, `: int`, `-> MsgGenerator`) are required as blueapi uses them to detect that this function is intended to be a plan and generate its runtime API. If there is an [`__all__` dunder](https://docs.python.org/3/tutorial/modules.html#importing-from-a-package) present in the module, blueapi will read that and import anything within that qualifies as a plan, per its type annotations. If not it will read everything in the module that hasn't been imported, for example it will ignore a plan imported from another module.
3234

3335
**Input annotations should be as broad as possible**, the least specific implementation that is sufficient to accomplish the requirements of the plan. For example, if a plan is written to drive a specific motor (`MyMotor`), but only uses the general methods on the [`Movable` protocol](https://blueskyproject.io/bluesky/main/hardware.html#bluesky.protocols.Movable), it should take `Movable` as a parameter annotation rather than `MyMotor`.
3436

src/blueapi/core/context.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616

1717
from blueapi import utils
1818
from blueapi.config import EnvironmentConfig, SourceKind
19-
from blueapi.utils import BlueapiPlanModelConfig, load_module_all
19+
from blueapi.utils import (
20+
BlueapiPlanModelConfig,
21+
is_function_sourced_from_module,
22+
load_module_all,
23+
)
2024

2125
from .bluesky_types import (
2226
BLUESKY_PROTOCOLS,
@@ -99,7 +103,15 @@ def plan_2(...) -> MsgGenerator:
99103
"""
100104

101105
for obj in load_module_all(module):
102-
if is_bluesky_plan_generator(obj):
106+
# The rule here is that we only inspect objects defined in the module
107+
# (as opposed to objects imported from other modules) to determine if
108+
# they are valid plans, unless there is an __all__ defined in the module,
109+
# in which case we only inspect objects listed there, regardless of their
110+
# original source module.
111+
if is_bluesky_plan_generator(obj) and (
112+
hasattr(module, "__all__")
113+
or is_function_sourced_from_module(obj, module)
114+
):
103115
self.register_plan(obj)
104116

105117
def with_device_module(self, module: ModuleType) -> None:

src/blueapi/utils/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from .connect_devices import connect_devices
33
from .file_permissions import get_owner_gid, is_sgid_set
44
from .invalid_config_error import InvalidConfigError
5-
from .modules import load_module_all
5+
from .modules import is_function_sourced_from_module, load_module_all
66
from .serialization import serialize
77
from .thread_exception import handle_all_exceptions
88

@@ -17,4 +17,5 @@
1717
"connect_devices",
1818
"is_sgid_set",
1919
"get_owner_gid",
20+
"is_function_sourced_from_module",
2021
]

src/blueapi/utils/modules.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from collections.abc import Iterable
1+
import importlib
2+
from collections.abc import Callable, Iterable
23
from types import ModuleType
34
from typing import Any
45

@@ -34,3 +35,17 @@ def get_named_subset(names: list[str]):
3435
for name, value in mod.__dict__.items():
3536
if not name.startswith("_"):
3637
yield value
38+
39+
40+
def is_function_sourced_from_module(
41+
func: Callable[..., Any], module: ModuleType
42+
) -> bool:
43+
"""
44+
Check if a function is originally from a particular module, useful to detect
45+
whether it actually comes from a nested import.
46+
47+
Args:
48+
func: Object to check
49+
module: Module to check against object
50+
"""
51+
return importlib.import_module(func.__module__) is module
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from bluesky.utils import MsgGenerator
2+
from tests.unit_tests.core.fake_plan_module import plan_a, plan_b # noqa: F401
3+
4+
5+
def plan_c(c: bool) -> MsgGenerator[None]: ...
6+
def plan_d(d: int) -> MsgGenerator[int]: ...
7+
8+
9+
__all__ = ["plan_a", "plan_d"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from bluesky.utils import MsgGenerator
2+
from tests.unit_tests.core.fake_plan_module import plan_a, plan_b # noqa: F401
3+
4+
5+
def plan_c(c: bool) -> MsgGenerator[None]: ...
6+
def plan_d(d: int) -> MsgGenerator[int]: ...

tests/unit_tests/core/test_context.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,20 @@ def test_add_plan_from_module(empty_context: BlueskyContext) -> None:
170170
assert EXPECTED_PLANS == empty_context.plans.keys()
171171

172172

173+
def test_only_plans_from_source_module_detected(empty_context: BlueskyContext) -> None:
174+
import tests.unit_tests.core.fake_plan_module_with_imports as plan_module
175+
176+
empty_context.with_plan_module(plan_module)
177+
assert {"plan_c", "plan_d"} == empty_context.plans.keys()
178+
179+
180+
def test_only_plans_from_all_in_module_detected(empty_context: BlueskyContext) -> None:
181+
import tests.unit_tests.core.fake_plan_module_with_all as plan_module
182+
183+
empty_context.with_plan_module(plan_module)
184+
assert {"plan_a", "plan_d"} == empty_context.plans.keys()
185+
186+
173187
def test_add_named_device(empty_context: BlueskyContext, sim_motor: SynAxis) -> None:
174188
empty_context.register_device(sim_motor)
175189
assert empty_context.devices[SIM_MOTOR_NAME] is sim_motor

tests/unit_tests/utils/functions_a.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
def a(): ...
2+
3+
4+
def b(): ...

tests/unit_tests/utils/functions_b.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .functions_a import a, b # noqa: F401
2+
3+
4+
def c(): ...
5+
6+
7+
def d(): ...

tests/unit_tests/utils/test_modules.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from importlib import import_module
22

3-
from blueapi.utils import load_module_all
3+
from blueapi.utils import is_function_sourced_from_module, load_module_all
44

55

66
def test_imports_all():
@@ -11,3 +11,17 @@ def test_imports_all():
1111
def test_imports_everything_without_all():
1212
module = import_module(".lacksall", package="tests.unit_tests.utils")
1313
assert list(load_module_all(module)) == [3, "hello", 9]
14+
15+
16+
def test_source_is_in_module():
17+
module = import_module(".functions_b", package="tests.unit_tests.utils")
18+
c = module.__dict__["c"]
19+
assert callable(c)
20+
assert is_function_sourced_from_module(c, module)
21+
22+
23+
def test_source_is_not_in_module():
24+
module = import_module(".functions_b", package="tests.unit_tests.utils")
25+
a = module.__dict__["a"]
26+
assert callable(a)
27+
assert not is_function_sourced_from_module(a, module)

0 commit comments

Comments
 (0)