canvas 0.13.3__py3-none-any.whl → 0.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of canvas might be problematic. Click here for more details.

Files changed (70) hide show
  1. {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/METADATA +1 -3
  2. {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/RECORD +69 -46
  3. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +1 -1
  4. canvas_generated/messages/effects_pb2.py +2 -2
  5. canvas_generated/messages/effects_pb2.pyi +6 -0
  6. canvas_generated/messages/events_pb2.py +2 -2
  7. canvas_generated/messages/events_pb2.pyi +8 -0
  8. canvas_sdk/commands/tests/protocol/tests.py +3 -1
  9. canvas_sdk/commands/tests/test_utils.py +76 -18
  10. canvas_sdk/effects/banner_alert/tests.py +41 -20
  11. canvas_sdk/effects/questionnaire_result.py +29 -0
  12. canvas_sdk/events/base.py +1 -3
  13. canvas_sdk/handlers/action_button.py +5 -2
  14. canvas_sdk/handlers/application.py +1 -1
  15. canvas_sdk/handlers/cron_task.py +1 -1
  16. canvas_sdk/protocols/clinical_quality_measure.py +1 -1
  17. canvas_sdk/v1/apps.py +7 -0
  18. canvas_sdk/v1/data/__init__.py +75 -4
  19. canvas_sdk/v1/data/allergy_intolerance.py +3 -7
  20. canvas_sdk/v1/data/billing.py +2 -5
  21. canvas_sdk/v1/data/command.py +19 -8
  22. canvas_sdk/v1/data/condition.py +3 -7
  23. canvas_sdk/v1/data/detected_issue.py +4 -9
  24. canvas_sdk/v1/data/device.py +4 -8
  25. canvas_sdk/v1/data/imaging.py +12 -17
  26. canvas_sdk/v1/data/lab.py +16 -29
  27. canvas_sdk/v1/data/medication.py +3 -7
  28. canvas_sdk/v1/data/note.py +7 -14
  29. canvas_sdk/v1/data/observation.py +4 -11
  30. canvas_sdk/v1/data/organization.py +1 -2
  31. canvas_sdk/v1/data/patient.py +0 -1
  32. canvas_sdk/v1/data/practicelocation.py +2 -4
  33. canvas_sdk/v1/data/protocol_override.py +3 -6
  34. canvas_sdk/v1/data/questionnaire.py +5 -15
  35. canvas_sdk/v1/data/staff.py +5 -7
  36. canvas_sdk/v1/data/task.py +5 -11
  37. canvas_sdk/v1/data/user.py +0 -1
  38. canvas_sdk/v1/models.py +4 -0
  39. plugin_runner/aws_headers.py +77 -0
  40. plugin_runner/plugin_installer.py +26 -8
  41. plugin_runner/plugin_runner.py +5 -25
  42. plugin_runner/sandbox.py +105 -9
  43. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/CANVAS_MANIFEST.json +38 -0
  44. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/README.md +11 -0
  45. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/protocols/__init__.py +0 -0
  46. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/protocols/my_protocol.py +33 -0
  47. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/templates/__init__.py +3 -0
  48. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/templates/base.py +6 -0
  49. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/utils/__init__.py +5 -0
  50. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/utils/base.py +4 -0
  51. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/CANVAS_MANIFEST.json +29 -0
  52. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/README.md +12 -0
  53. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/other_module/__init__.py +0 -0
  54. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/other_module/base.py +10 -0
  55. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/protocols/__init__.py +0 -0
  56. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/protocols/my_protocol.py +18 -0
  57. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/CANVAS_MANIFEST.json +29 -0
  58. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/README.md +12 -0
  59. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/other_module/__init__.py +0 -0
  60. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/other_module/base.py +10 -0
  61. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/protocols/__init__.py +0 -0
  62. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/protocols/my_protocol.py +18 -0
  63. plugin_runner/tests/test_application.py +9 -9
  64. plugin_runner/tests/test_plugin_installer.py +22 -13
  65. plugin_runner/tests/test_plugin_runner.py +171 -32
  66. plugin_runner/tests/test_sandbox.py +18 -14
  67. settings.py +7 -1
  68. canvas_sdk/models/__init__.py +0 -7
  69. {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/WHEEL +0 -0
  70. {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,33 @@
1
+ from canvas_sdk.effects import Effect
2
+ from canvas_sdk.events import EventType
3
+ from canvas_sdk.protocols import BaseProtocol
4
+ from logger import log
5
+
6
+
7
+ class Forbidden(BaseProtocol):
8
+ """You should put a helpful description of this protocol's behavior here."""
9
+
10
+ # Name the event type you wish to run in response to
11
+ RESPONDS_TO = EventType.Name(EventType.UNKNOWN)
12
+
13
+ def compute(self) -> list[Effect]:
14
+ """This method gets called when an event of the type RESPONDS_TO is fired."""
15
+ from test_implicit_imports_plugin.utils.base import OtherClass
16
+
17
+ OtherClass()
18
+
19
+ return []
20
+
21
+
22
+ class Allowed(BaseProtocol):
23
+ """You should put a helpful description of this protocol's behavior here."""
24
+
25
+ RESPONDS_TO = EventType.Name(EventType.UNKNOWN)
26
+
27
+ def compute(self) -> list[Effect]:
28
+ """This method gets called when an event of the type RESPONDS_TO is fired."""
29
+ from test_implicit_imports_plugin.templates import Template
30
+
31
+ log.info(Template().render())
32
+
33
+ return []
@@ -0,0 +1,3 @@
1
+ from test_implicit_imports_plugin.templates.base import Template
2
+
3
+ __all__ = ("Template",)
@@ -0,0 +1,6 @@
1
+ class Template:
2
+ """A template class for testing implicit imports."""
3
+
4
+ def render(self) -> str:
5
+ """Renders the template."""
6
+ return "Hello, World!"
@@ -0,0 +1,5 @@
1
+ import os
2
+
3
+ from logger import log
4
+
5
+ log.info(f"os list dir: {os.listdir('.')}")
@@ -0,0 +1,4 @@
1
+ class OtherClass:
2
+ """This class is used to test implicit imports."""
3
+
4
+ pass
@@ -0,0 +1,29 @@
1
+ {
2
+ "sdk_version": "0.1.4",
3
+ "plugin_version": "0.0.1",
4
+ "name": "test_module_forbidden_imports_plugin",
5
+ "description": "Edit the description in CANVAS_MANIFEST.json",
6
+ "components": {
7
+ "protocols": [
8
+ {
9
+ "class": "test_module_forbidden_imports_plugin.protocols.my_protocol:Protocol",
10
+ "description": "A protocol that does xyz...",
11
+ "data_access": {
12
+ "event": "",
13
+ "read": [],
14
+ "write": []
15
+ }
16
+ }
17
+ ],
18
+ "commands": [],
19
+ "content": [],
20
+ "effects": [],
21
+ "views": []
22
+ },
23
+ "secrets": [],
24
+ "tags": {},
25
+ "references": [],
26
+ "license": "",
27
+ "diagram": false,
28
+ "readme": "./README.md"
29
+ }
@@ -0,0 +1,12 @@
1
+ ==========================
2
+ test_module_forbidden_imports_plugin
3
+ ==========================
4
+
5
+ ## Description
6
+
7
+ A description of this plugin
8
+
9
+ ### Important Note!
10
+
11
+ The CANVAS_MANIFEST.json is used when installing your plugin. Please ensure it
12
+ gets updated if you add, remove, or rename protocols.
@@ -0,0 +1,10 @@
1
+ import os
2
+
3
+ from logger import log
4
+
5
+ log.info(f"This is a forbidden import. {os}")
6
+
7
+
8
+ def import_me() -> str:
9
+ """Test method."""
10
+ return "Successfully imported!"
@@ -0,0 +1,18 @@
1
+ from test_module_forbidden_imports_plugin.other_module.base import import_me
2
+
3
+ from canvas_sdk.effects import Effect, EffectType
4
+ from canvas_sdk.events import EventType
5
+ from canvas_sdk.protocols import BaseProtocol
6
+
7
+
8
+ class Protocol(BaseProtocol):
9
+ """
10
+ You should put a helpful description of this protocol's behavior here.
11
+ """
12
+
13
+ # Name the event type you wish to run in response to
14
+ RESPONDS_TO = EventType.Name(EventType.UNKNOWN)
15
+
16
+ def compute(self) -> list[Effect]:
17
+ """This method gets called when an event of the type RESPONDS_TO is fired."""
18
+ return [Effect(type=EffectType.LOG, payload=import_me())]
@@ -0,0 +1,29 @@
1
+ {
2
+ "sdk_version": "0.1.4",
3
+ "plugin_version": "0.0.1",
4
+ "name": "test_module_forbidden_imports_runtime_plugin",
5
+ "description": "Edit the description in CANVAS_MANIFEST.json",
6
+ "components": {
7
+ "protocols": [
8
+ {
9
+ "class": "test_module_forbidden_imports_runtime_plugin.protocols.my_protocol:Protocol",
10
+ "description": "A protocol that does xyz...",
11
+ "data_access": {
12
+ "event": "",
13
+ "read": [],
14
+ "write": []
15
+ }
16
+ }
17
+ ],
18
+ "commands": [],
19
+ "content": [],
20
+ "effects": [],
21
+ "views": []
22
+ },
23
+ "secrets": [],
24
+ "tags": {},
25
+ "references": [],
26
+ "license": "",
27
+ "diagram": false,
28
+ "readme": "./README.md"
29
+ }
@@ -0,0 +1,12 @@
1
+ ==========================
2
+ test_module_forbidden_imports_runtime_plugin
3
+ ==========================
4
+
5
+ ## Description
6
+
7
+ A description of this plugin
8
+
9
+ ### Important Note!
10
+
11
+ The CANVAS_MANIFEST.json is used when installing your plugin. Please ensure it
12
+ gets updated if you add, remove, or rename protocols.
@@ -0,0 +1,10 @@
1
+ import os
2
+
3
+ from logger import log
4
+
5
+ log.info(f"This is a forbidden import. {os}")
6
+
7
+
8
+ def import_me() -> str:
9
+ """Test method."""
10
+ return "Successfully imported!"
@@ -0,0 +1,18 @@
1
+ from canvas_sdk.effects import Effect, EffectType
2
+ from canvas_sdk.events import EventType
3
+ from canvas_sdk.protocols import BaseProtocol
4
+
5
+
6
+ class Protocol(BaseProtocol):
7
+ """
8
+ You should put a helpful description of this protocol's behavior here.
9
+ """
10
+
11
+ # Name the event type you wish to run in response to
12
+ RESPONDS_TO = EventType.Name(EventType.UNKNOWN)
13
+
14
+ def compute(self) -> list[Effect]:
15
+ """This method gets called when an event of the type RESPONDS_TO is fired."""
16
+ from test_module_forbidden_imports_runtime_plugin.other_module.base import import_me
17
+
18
+ return [Effect(type=EffectType.LOG, payload=import_me())]
@@ -6,7 +6,7 @@ from canvas_sdk.events import Event, EventRequest, EventType
6
6
  from canvas_sdk.handlers.application import Application
7
7
 
8
8
 
9
- class TestApplication(Application):
9
+ class ExampleApplication(Application):
10
10
  """A concrete implementation of the Application class for testing."""
11
11
 
12
12
  def on_open(self) -> Effect:
@@ -15,9 +15,9 @@ class TestApplication(Application):
15
15
 
16
16
 
17
17
  @pytest.fixture
18
- def app_instance(event: Event) -> TestApplication:
18
+ def app_instance(event: Event) -> ExampleApplication:
19
19
  """Provide an instance of the TestApplication with a mocked event."""
20
- app = TestApplication(event)
20
+ app = ExampleApplication(event)
21
21
  return app
22
22
 
23
23
 
@@ -25,7 +25,7 @@ def test_compute_event_not_targeted() -> None:
25
25
  """Test that compute filters out events not targeted for the app."""
26
26
  request = EventRequest(type=EventType.APPLICATION__ON_OPEN, target="some_identifier")
27
27
  event = Event(request)
28
- app = TestApplication(event)
28
+ app = ExampleApplication(event)
29
29
 
30
30
  result = app.compute()
31
31
 
@@ -36,10 +36,10 @@ def test_compute_event_targeted() -> None:
36
36
  """Test that compute processes events targeted for the app."""
37
37
  request = EventRequest(
38
38
  type=EventType.APPLICATION__ON_OPEN,
39
- target=f"{TestApplication.__module__}:{TestApplication.__qualname__}",
39
+ target=f"{ExampleApplication.__module__}:{ExampleApplication.__qualname__}",
40
40
  )
41
41
  event = Event(request)
42
- app = TestApplication(event)
42
+ app = ExampleApplication(event)
43
43
  result = app.compute()
44
44
 
45
45
  assert len(result) == 1, "Expected a single effect if the event target is the app identifier"
@@ -48,13 +48,13 @@ def test_compute_event_targeted() -> None:
48
48
 
49
49
  def test_identifier_property() -> None:
50
50
  """Test the identifier property of the Application class."""
51
- expected_identifier = f"{TestApplication.__module__}:{TestApplication.__qualname__}"
51
+ expected_identifier = f"{ExampleApplication.__module__}:{ExampleApplication.__qualname__}"
52
52
  request = EventRequest(
53
53
  type=EventType.APPLICATION__ON_OPEN,
54
- target=f"{TestApplication.__module__}:{TestApplication.__qualname__}",
54
+ target=f"{ExampleApplication.__module__}:{ExampleApplication.__qualname__}",
55
55
  )
56
56
  event = Event(request)
57
- app = TestApplication(event)
57
+ app = ExampleApplication(event)
58
58
 
59
59
  assert app.identifier == expected_identifier, "The identifier property is incorrect"
60
60
 
@@ -2,11 +2,10 @@ import json
2
2
  import tarfile
3
3
  import tempfile
4
4
  from pathlib import Path
5
- from unittest.mock import MagicMock
5
+ from unittest.mock import MagicMock, patch
6
6
 
7
7
  from pytest_mock import MockerFixture
8
8
 
9
- import settings
10
9
  from plugin_runner.plugin_installer import (
11
10
  PluginAttributes,
12
11
  _extract_rows_to_dict,
@@ -84,8 +83,19 @@ def test_plugin_installation_from_tarball(mocker: MockerFixture) -> None:
84
83
  tarball_2 = _create_tarball("plugin2")
85
84
 
86
85
  mocker.patch("plugin_runner.plugin_installer.enabled_plugins", return_value=mock_plugins)
86
+
87
+ def mock_download_plugin(package: str) -> MagicMock:
88
+ mock_context = mocker.Mock()
89
+ if package == "plugins/plugin1.tar.gz":
90
+ mock_context.__enter__ = mocker.Mock(return_value=tarball_1)
91
+ elif package == "plugins/plugin2.tar":
92
+ mock_context.__enter__ = mocker.Mock(return_value=tarball_2)
93
+ mock_context.__exit__ = mocker.Mock(return_value=None)
94
+ return mock_context
95
+
87
96
  mocker.patch(
88
- "plugin_runner.plugin_installer.download_plugin", side_effect=[tarball_1, tarball_2]
97
+ "plugin_runner.plugin_installer.download_plugin",
98
+ side_effect=mock_download_plugin,
89
99
  )
90
100
 
91
101
  install_plugins()
@@ -106,13 +116,12 @@ def test_plugin_installation_from_tarball(mocker: MockerFixture) -> None:
106
116
 
107
117
  def test_download(mocker: MockerFixture) -> None:
108
118
  """Test that the plugin package can be written to disk, mocking out S3."""
109
- mock_s3_client = MagicMock()
110
- mocker.patch("boto3.client", return_value=mock_s3_client)
111
-
112
- plugin_package = "plugins/plugin1.tar.gz"
113
- with download_plugin(plugin_package) as plugin_path:
114
- assert plugin_path.exists()
115
-
116
- mock_s3_client.download_fileobj.assert_called_once_with(
117
- "canvas-client-media", f"{settings.CUSTOMER_IDENTIFIER}/{plugin_package}", mocker.ANY
118
- )
119
+ mock_response = MagicMock()
120
+ mock_response.status_code = 200
121
+ mock_response.content = b"some content in a file"
122
+ with patch("requests.request", return_value=mock_response) as mock_request:
123
+ plugin_package = "plugins/plugin1.tar.gz"
124
+ with download_plugin(plugin_package) as plugin_path:
125
+ assert plugin_path.exists()
126
+ assert plugin_path.read_bytes() == b"some content in a file"
127
+ mock_request.assert_called_once()
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import shutil
2
3
  from collections.abc import Generator
3
4
  from pathlib import Path
@@ -7,18 +8,18 @@ import pytest
7
8
 
8
9
  from canvas_generated.messages.effects_pb2 import EffectType
9
10
  from canvas_generated.messages.plugins_pb2 import ReloadPluginsRequest
10
- from canvas_sdk.events import EventRequest, EventType
11
+ from canvas_sdk.events import Event, EventRequest, EventType
11
12
  from plugin_runner.plugin_runner import (
12
13
  EVENT_HANDLER_MAP,
13
14
  LOADED_PLUGINS,
14
15
  PluginRunner,
16
+ load_or_reload_plugin,
15
17
  load_plugins,
16
- sandbox_from_package,
17
18
  )
18
19
 
19
20
 
20
21
  @pytest.fixture
21
- def setup_test_plugin(request: pytest.FixtureRequest) -> Generator[Path, None, None]:
22
+ def install_test_plugin(request: pytest.FixtureRequest) -> Generator[Path, None, None]:
22
23
  """Copies a specified plugin from the fixtures directory to the data directory
23
24
  and removes it after the test.
24
25
 
@@ -51,6 +52,17 @@ def setup_test_plugin(request: pytest.FixtureRequest) -> Generator[Path, None, N
51
52
  shutil.rmtree(dest_plugin_path)
52
53
 
53
54
 
55
+ @pytest.fixture
56
+ def load_test_plugins() -> Generator[None, None, None]:
57
+ """Manages the lifecycle of test plugins by loading and unloading them."""
58
+ try:
59
+ load_plugins()
60
+ yield
61
+ finally:
62
+ LOADED_PLUGINS.clear()
63
+ EVENT_HANDLER_MAP.clear()
64
+
65
+
54
66
  @pytest.fixture
55
67
  def plugin_runner() -> PluginRunner:
56
68
  """Fixture to initialize PluginRunner with mocks."""
@@ -59,11 +71,9 @@ def plugin_runner() -> PluginRunner:
59
71
  return runner
60
72
 
61
73
 
62
- @pytest.mark.parametrize("setup_test_plugin", ["example_plugin"], indirect=True)
63
- def test_load_plugins_with_valid_plugin(setup_test_plugin: Path) -> None:
74
+ @pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
75
+ def test_load_plugins_with_valid_plugin(install_test_plugin: Path, load_test_plugins: None) -> None:
64
76
  """Test loading plugins with a valid plugin."""
65
- load_plugins()
66
-
67
77
  assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
68
78
  assert (
69
79
  LOADED_PLUGINS["example_plugin:example_plugin.protocols.my_protocol:Protocol"]["active"]
@@ -72,12 +82,11 @@ def test_load_plugins_with_valid_plugin(setup_test_plugin: Path) -> None:
72
82
 
73
83
 
74
84
  @pytest.mark.asyncio
75
- @pytest.mark.parametrize("setup_test_plugin", ["test_module_imports_plugin"], indirect=True)
85
+ @pytest.mark.parametrize("install_test_plugin", ["test_module_imports_plugin"], indirect=True)
76
86
  async def test_load_plugins_with_plugin_that_imports_other_modules_within_plugin_package(
77
- setup_test_plugin: Path, plugin_runner: PluginRunner
87
+ install_test_plugin: Path, plugin_runner: PluginRunner, load_test_plugins: None
78
88
  ) -> None:
79
89
  """Test loading plugins with a valid plugin that imports other modules within the current plugin package."""
80
- load_plugins()
81
90
  assert (
82
91
  "test_module_imports_plugin:test_module_imports_plugin.protocols.my_protocol:Protocol"
83
92
  in LOADED_PLUGINS
@@ -102,7 +111,7 @@ async def test_load_plugins_with_plugin_that_imports_other_modules_within_plugin
102
111
 
103
112
 
104
113
  @pytest.mark.parametrize(
105
- "setup_test_plugin",
114
+ "install_test_plugin",
106
115
  [
107
116
  "test_module_imports_outside_plugin_v1",
108
117
  "test_module_imports_outside_plugin_v2",
@@ -111,18 +120,108 @@ async def test_load_plugins_with_plugin_that_imports_other_modules_within_plugin
111
120
  indirect=True,
112
121
  )
113
122
  def test_load_plugins_with_plugin_that_imports_other_modules_outside_plugin_package(
114
- setup_test_plugin: Path,
123
+ install_test_plugin: Path, caplog: pytest.LogCaptureFixture
115
124
  ) -> None:
116
125
  """Test loading plugins with an invalid plugin that imports other modules outside the current plugin package."""
117
- with pytest.raises(ImportError, match="is not an allowed import"):
118
- sandbox_from_package(setup_test_plugin)
126
+ with caplog.at_level(logging.ERROR):
127
+ load_or_reload_plugin(install_test_plugin)
128
+
129
+ assert any(
130
+ "Error importing module" in record.message for record in caplog.records
131
+ ), "log.error() was not called with the expected message."
132
+
133
+
134
+ @pytest.mark.parametrize(
135
+ "install_test_plugin",
136
+ [
137
+ "test_module_forbidden_imports_plugin",
138
+ ],
139
+ indirect=True,
140
+ )
141
+ def test_load_plugins_with_plugin_that_imports_forbidden_modules(
142
+ install_test_plugin: Path, caplog: pytest.LogCaptureFixture
143
+ ) -> None:
144
+ """Test loading plugins with an invalid plugin that imports forbidden modules."""
145
+ with caplog.at_level(logging.ERROR):
146
+ load_or_reload_plugin(install_test_plugin)
147
+
148
+ assert any(
149
+ "Error importing module" in record.message for record in caplog.records
150
+ ), "log.error() was not called with the expected message."
151
+
152
+
153
+ @pytest.mark.parametrize(
154
+ "install_test_plugin",
155
+ [
156
+ "test_module_forbidden_imports_runtime_plugin",
157
+ ],
158
+ indirect=True,
159
+ )
160
+ def test_load_plugins_with_plugin_that_imports_forbidden_modules_at_runtime(
161
+ install_test_plugin: Path,
162
+ ) -> None:
163
+ """Test loading plugins with an invalid plugin that imports forbidden modules at runtime."""
164
+ with pytest.raises(ImportError, match="is not an allowed import."):
165
+ load_or_reload_plugin(install_test_plugin)
166
+ class_handler = LOADED_PLUGINS[
167
+ "test_module_forbidden_imports_runtime_plugin:test_module_forbidden_imports_runtime_plugin.protocols.my_protocol:Protocol"
168
+ ]["class"]
169
+ class_handler(Event(EventRequest(type=EventType.UNKNOWN))).compute()
170
+
171
+
172
+ @pytest.mark.parametrize(
173
+ "install_test_plugin",
174
+ [
175
+ "test_implicit_imports_plugin",
176
+ ],
177
+ indirect=True,
178
+ )
179
+ def test_plugin_that_implicitly_imports_allowed_modules(
180
+ install_test_plugin: Path, caplog: pytest.LogCaptureFixture
181
+ ) -> None:
182
+ """Test loading plugins with a plugin that implicitly imports allowed modules."""
183
+ with caplog.at_level(logging.INFO):
184
+ load_or_reload_plugin(install_test_plugin)
185
+ class_handler = LOADED_PLUGINS[
186
+ "test_implicit_imports_plugin:test_implicit_imports_plugin.protocols.my_protocol:Allowed"
187
+ ]["class"]
188
+ class_handler(Event(EventRequest(type=EventType.UNKNOWN))).compute()
189
+
190
+ assert any(
191
+ "Hello, World!" in record.message for record in caplog.records
192
+ ), "log.info() with Template.render() was not called."
119
193
 
120
194
 
121
- @pytest.mark.parametrize("setup_test_plugin", ["example_plugin"], indirect=True)
122
- def test_reload_plugin(setup_test_plugin: Path) -> None:
195
+ @pytest.mark.parametrize(
196
+ "install_test_plugin",
197
+ [
198
+ "test_implicit_imports_plugin",
199
+ ],
200
+ indirect=True,
201
+ )
202
+ def test_plugin_that_implicitly_imports_forbidden_modules(
203
+ install_test_plugin: Path, caplog: pytest.LogCaptureFixture
204
+ ) -> None:
205
+ """Test loading plugins with an invalid plugin that implicitly imports forbidden modules."""
206
+ with (
207
+ caplog.at_level(logging.INFO),
208
+ pytest.raises(ImportError, match="'os' is not an allowed import."),
209
+ ):
210
+ load_or_reload_plugin(install_test_plugin)
211
+ class_handler = LOADED_PLUGINS[
212
+ "test_implicit_imports_plugin:test_implicit_imports_plugin.protocols.my_protocol:Forbidden"
213
+ ]["class"]
214
+ class_handler(Event(EventRequest(type=EventType.UNKNOWN))).compute()
215
+
216
+ assert (
217
+ any("os list dir" in record.message for record in caplog.records) is False
218
+ ), "log.info() with os.listdir() was called."
219
+
220
+
221
+ @pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
222
+ def test_reload_plugin(install_test_plugin: Path, load_test_plugins: None) -> None:
123
223
  """Test reloading a plugin."""
124
224
  load_plugins()
125
- load_plugins()
126
225
 
127
226
  assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
128
227
  assert (
@@ -131,18 +230,22 @@ def test_reload_plugin(setup_test_plugin: Path) -> None:
131
230
  )
132
231
 
133
232
 
134
- @pytest.mark.parametrize("setup_test_plugin", ["example_plugin"], indirect=True)
135
- def test_remove_plugin_should_be_removed_from_loaded_plugins(setup_test_plugin: Path) -> None:
233
+ @pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
234
+ def test_remove_plugin_should_be_removed_from_loaded_plugins(
235
+ install_test_plugin: Path, load_test_plugins: None
236
+ ) -> None:
136
237
  """Test removing a plugin."""
137
- load_plugins()
138
238
  assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
139
- shutil.rmtree(setup_test_plugin)
239
+ shutil.rmtree(install_test_plugin)
140
240
  load_plugins()
141
241
  assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" not in LOADED_PLUGINS
142
242
 
143
243
 
144
- @pytest.mark.parametrize("setup_test_plugin", ["example_plugin"], indirect=True)
145
- def test_load_plugins_should_refresh_event_protocol_map(setup_test_plugin: Path) -> None:
244
+ @pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
245
+ @pytest.mark.parametrize("load_test_plugins", [None], indirect=True)
246
+ def test_load_plugins_should_refresh_event_protocol_map(
247
+ load_test_plugins: None, install_test_plugin: Path
248
+ ) -> None:
146
249
  """Test that the event protocol map is refreshed when loading plugins."""
147
250
  assert EVENT_HANDLER_MAP == {}
148
251
  load_plugins()
@@ -153,13 +256,11 @@ def test_load_plugins_should_refresh_event_protocol_map(setup_test_plugin: Path)
153
256
 
154
257
 
155
258
  @pytest.mark.asyncio
156
- @pytest.mark.parametrize("setup_test_plugin", ["example_plugin"], indirect=True)
259
+ @pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
157
260
  async def test_handle_plugin_event_returns_expected_result(
158
- setup_test_plugin: Path, plugin_runner: PluginRunner
261
+ install_test_plugin: Path, plugin_runner: PluginRunner, load_test_plugins: None
159
262
  ) -> None:
160
263
  """Test that HandleEvent successfully calls the relevant plugins and returns the expected result."""
161
- load_plugins()
162
-
163
264
  event = EventRequest(type=EventType.UNKNOWN)
164
265
 
165
266
  result = []
@@ -174,11 +275,11 @@ async def test_handle_plugin_event_returns_expected_result(
174
275
 
175
276
 
176
277
  @pytest.mark.asyncio
177
- @pytest.mark.parametrize("setup_test_plugin", ["example_plugin"], indirect=True)
178
- async def test_reload_plugins_event_handler_successfully_loads_plugins(
179
- setup_test_plugin: Path, plugin_runner: PluginRunner
278
+ @pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
279
+ async def test_reload_plugins_event_handler_successfully_publishes_message(
280
+ install_test_plugin: Path, plugin_runner: PluginRunner
180
281
  ) -> None:
181
- """Test ReloadPlugins Event handler successfully loads plugins."""
282
+ """Test ReloadPlugins Event handler successfully publishes a message with restart action."""
182
283
  with patch("plugin_runner.plugin_runner.publish_message", MagicMock()) as mock_publish_message:
183
284
  request = ReloadPluginsRequest()
184
285
 
@@ -190,4 +291,42 @@ async def test_reload_plugins_event_handler_successfully_loads_plugins(
190
291
 
191
292
  assert len(result) == 1
192
293
  assert result[0].success is True
193
- assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
294
+
295
+
296
+ @pytest.mark.asyncio
297
+ @pytest.mark.parametrize("install_test_plugin", ["test_module_imports_plugin"], indirect=True)
298
+ async def test_changes_to_plugin_modules_should_be_reflected_after_reload(
299
+ install_test_plugin: Path, load_test_plugins: None, plugin_runner: PluginRunner
300
+ ) -> None:
301
+ """Test that changes to plugin modules are reflected after reloading the plugin."""
302
+ event = EventRequest(type=EventType.UNKNOWN)
303
+
304
+ result = []
305
+ async for response in plugin_runner.HandleEvent(event, None):
306
+ result.append(response)
307
+
308
+ assert len(result) == 1
309
+ assert result[0].success is True
310
+ assert len(result[0].effects) == 1
311
+ assert result[0].effects[0].type == EffectType.LOG
312
+ assert result[0].effects[0].payload == "Successfully imported!"
313
+
314
+ NEW_CODE = """
315
+ def import_me() -> str:
316
+ return "Successfully changed!"
317
+ """
318
+ file_path = install_test_plugin / "other_module" / "base.py"
319
+ file_path.write_text(NEW_CODE, encoding="utf-8")
320
+
321
+ # Reload the plugin
322
+ load_plugins()
323
+
324
+ result = []
325
+ async for response in plugin_runner.HandleEvent(event, None):
326
+ result.append(response)
327
+
328
+ assert len(result) == 1
329
+ assert result[0].success is True
330
+ assert len(result[0].effects) == 1
331
+ assert result[0].effects[0].type == EffectType.LOG
332
+ assert result[0].effects[0].payload == "Successfully changed!"