Add tests for action modules loading.
MODIFY
.gitignore
.gitignore
@@ -111,3 +111,4 @@ examples/moodbot/models/
.dir-locals.el
*.DS_Store
+tests/executor_test_packages

MODIFY
rasa_sdk/executor.py
rasa_sdk/executor.py
@@ -218,6 +218,10 @@ class ActionExecutor:
"""
module = importlib.import_module(name)
+ if module.__file__:
+ # If the module we're importing is a namespace package (a package
+ # without __init__.py), then there's nothing to watch for the
+ # package itself.
timestamp = os.path.getmtime(module.__file__)
self._modules[module.__file__] = (timestamp, module)
@@ -233,17 +237,17 @@ class ActionExecutor:
self._register_all_actions()
def _register_all_actions(self) -> None:
+ import inspect
+
actions = utils.all_subclasses(Action)
for action in actions:
- meta = action.__dict__.get("Meta", False)
- abstract = getattr(meta, "abstract", False)
if (
not action.__module__.startswith("rasa_core.")
and not action.__module__.startswith("rasa.")
and not action.__module__.startswith("rasa_sdk.")
and not action.__module__.startswith("rasa_core_sdk.")
- and not abstract
+ and not inspect.isabstract(action)
):
self.register_action(action)

MODIFY
tests/test_actions.py
tests/test_actions.py
@@ -2,12 +2,11 @@ from typing import List, Dict, Text, Any
from rasa_sdk import Action, Tracker
from rasa_sdk.events import SlotSet
-from rasa_sdk.executor import ActionExecutor, CollectingDispatcher
+from rasa_sdk.executor import CollectingDispatcher
from rasa_sdk.utils import is_coroutine_action
class CustomAsyncAction(Action):
- @classmethod
def name(cls) -> Text:
return "custom_async_action"
@@ -20,30 +19,7 @@ class CustomAsyncAction(Action):
return [SlotSet("test", "foo"), SlotSet("test2", "boo")]
-class CustomActionBase(Action):
- @classmethod
- def name(cls) -> Text:
- # Name method needed to test if base action was registered
- return "base_action"
-
- class Meta:
- abstract = True
-
- @staticmethod
- def some_common_feature() -> Text:
- return "test"
-
- def run(
- self,
- dispatcher: CollectingDispatcher,
- tracker: Tracker,
- domain: Dict[Text, Any],
- ) -> List[Dict[Text, Any]]:
- raise NotImplementedError
-
-
-class CustomAction(CustomActionBase):
- @classmethod
+class CustomAction(Action):
def name(cls) -> Text:
return "custom_action"
@@ -53,23 +29,7 @@ class CustomAction(CustomActionBase):
tracker: Tracker,
domain: Dict[Text, Any],
) -> List[Dict[Text, Any]]:
- return [SlotSet("test", self.some_common_feature())]
-
-
-def test_action_registration():
- executor = ActionExecutor()
- executor.register_package("tests")
- assert CustomAction.name() in executor.actions
- assert CustomActionBase.name() not in executor.actions
-
-
-def test_abstract_action():
- dispatcher = CollectingDispatcher()
- tracker = Tracker("test", {}, {}, [], False, None, {}, "listen")
- domain = {}
-
- events = CustomAction().run(dispatcher, tracker, domain)
- assert events == [SlotSet("test", "test")]
+ return [SlotSet("test", "bar")]
def test_action_async_check():

MODIFY
tests/test_endpoint.py
tests/test_endpoint.py
@@ -42,7 +42,7 @@ def test_server_webhook_custom_action_returns_200():
request, response = app.test_client.post("/webhook", data=json.dumps(data))
events = response.json.get("events")
- assert events == [SlotSet("test", "test")]
+ assert events == [SlotSet("test", "bar")]
assert response.status == 200

MODIFY
tests/test_executor.py
tests/test_executor.py
-import pytest
+import os
+import shutil
+import random
+import string
+import time
+from typing import Text
+import pytest
from rasa_sdk.executor import ActionExecutor, CollectingDispatcher
+TEST_PACKAGE_BASE = "tests/executor_test_packages"
+
+ACTION_TEMPLATE = """
+from rasa_sdk import Action
+
+class {class_name}(Action):
+ def name(self):
+ return "{action_name}"
+
+ async def run(self, dispatcher, tracker, domain):
+ dispatcher.utter_message("{message}")
+ return []
+"""
+
+
+def _write_action_file(
+ package_path: Text,
+ file_name: Text,
+ class_name: Text,
+ action_name: Text,
+ message: Text = None,
+) -> None:
+ with open(os.path.join(package_path, file_name), "w") as f:
+ f.write(
+ ACTION_TEMPLATE.format(
+ class_name=class_name, action_name=action_name, message=message
+ )
+ )
+
@pytest.mark.parametrize(
"noevent", [1, ["asd"], "asd", 3.2, None, {"event_without_event_property": 4}]
@@ -30,3 +65,135 @@ def test_deprecated_utter_elements():
"image": None,
"attachment": None,
}
+
+
+@pytest.fixture()
+def package_path() -> None:
+ # Create an empty directory somewhere reached by the Python import search
+ # paths
+ name = "".join(random.choice(string.ascii_lowercase) for _ in range(10))
+ package_path = os.path.join(TEST_PACKAGE_BASE, name)
+
+ # Ensure package dir is clean
+ shutil.rmtree(package_path, ignore_errors=True)
+ os.makedirs(package_path)
+ yield package_path
+
+ # Cleanup
+ shutil.rmtree(package_path, ignore_errors=True)
+
+
+@pytest.fixture()
+def executor() -> ActionExecutor:
+ return ActionExecutor()
+
+
+@pytest.fixture()
+def dispatcher() -> CollectingDispatcher:
+ return CollectingDispatcher()
+
+
+def test_load_action_from_init(executor: ActionExecutor, package_path: Text):
+ # Actions should be loaded from packages
+ _write_action_file(package_path, "__init__.py", "InitAction", "init_action")
+ executor.register_package(package_path.replace("/", "."))
+
+ assert "init_action" in executor.actions
+
+
+def test_load_submodules_in_package(executor: ActionExecutor, package_path: Text):
+ # If there's submodules inside a package, they should be picked up correctly
+ _write_action_file(package_path, "__init__.py", "Action1", "action1")
+ _write_action_file(package_path, "a1.py", "Action2", "action2")
+ _write_action_file(package_path, "a2.py", "Action3", "action3")
+
+ executor.register_package(package_path.replace("/", "."))
+
+ for name in ["action1", "action2", "action3"]:
+ assert name in executor.actions
+
+
+def test_load_submodules_in_namespace_package(
+ executor: ActionExecutor, package_path: Text
+):
+ # If there's submodules inside a namespace package (a package without
+ # __init__.py), they should be picked up correctly
+ _write_action_file(package_path, "foo.py", "FooAction", "foo_action")
+ _write_action_file(package_path, "bar.py", "BarAction", "bar_action")
+
+ executor.register_package(package_path.replace("/", "."))
+
+ for name in ["foo_action", "bar_action"]:
+ assert name in executor.actions
+
+
+def test_load_module_directly(executor: ActionExecutor, package_path: Text):
+ _write_action_file(
+ package_path, "test_module.py", "TestModuleAction", "test_module_action"
+ )
+
+ # Load a module directly, not a package. This is equivalent to loading
+ # "action" when there's an "action.py" file in the module search path.
+ executor.register_package(package_path.replace("/", ".") + ".test_module")
+
+ assert "test_module_action" in executor.actions
+
+
+async def test_reload_module(
+ executor: ActionExecutor, dispatcher: CollectingDispatcher, package_path: Text
+):
+ action_class = "MyAction"
+ action_file = "my_action.py"
+ action_name = "my_action"
+
+ _write_action_file(
+ package_path, action_file, action_class, action_name, message="foobar"
+ )
+
+ executor.register_package(package_path.replace("/", "."))
+
+ action_v1 = executor.actions.get(action_name)
+ assert action_v1
+
+ await action_v1(dispatcher, None, None)
+
+ assert dispatcher.messages[0] == {
+ "text": "foobar",
+ "buttons": [],
+ "elements": [],
+ "custom": {},
+ "template": None,
+ "image": None,
+ "attachment": None,
+ }
+
+ # Write the action file again, but change its contents
+ _write_action_file(
+ package_path, action_file, action_class, action_name, message="hello!"
+ )
+
+ # Manually set the file's timestamp 10 seconds into the future, otherwise
+ # Python will load the class already compiled in __pycache__, which is the
+ # previous version.
+ mod_time = time.time() + 10
+ os.utime(os.path.join(package_path, action_file), times=(mod_time, mod_time))
+
+ # Reload modules
+ executor.reload()
+ dispatcher.messages.clear()
+
+ action_v2 = executor.actions.get(action_name)
+ assert action_v2
+
+ await action_v2(dispatcher, None, None)
+
+ # The message should have changed
+ assert dispatcher.messages[0] == {
+ "text": "hello!",
+ "buttons": [],
+ "elements": [],
+ "custom": {},
+ "template": None,
+ "image": None,
+ "attachment": None,
+ }

Add tests for action modules loading.