canvas 0.14.0__py3-none-any.whl → 0.16.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.
- {canvas-0.14.0.dist-info → canvas-0.16.0.dist-info}/METADATA +2 -2
- {canvas-0.14.0.dist-info → canvas-0.16.0.dist-info}/RECORD +87 -52
- canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json +6 -3
- canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/applications/my_application.py +4 -1
- canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +1 -1
- canvas_cli/utils/validators/manifest_schema.py +9 -2
- canvas_generated/messages/effects_pb2.py +2 -2
- canvas_generated/messages/effects_pb2.pyi +18 -0
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +48 -0
- canvas_sdk/commands/tests/protocol/tests.py +3 -1
- canvas_sdk/commands/tests/test_utils.py +76 -18
- canvas_sdk/effects/banner_alert/tests.py +41 -20
- canvas_sdk/effects/launch_modal.py +14 -3
- canvas_sdk/events/base.py +1 -3
- canvas_sdk/handlers/action_button.py +33 -13
- canvas_sdk/handlers/application.py +1 -1
- canvas_sdk/handlers/cron_task.py +1 -1
- canvas_sdk/protocols/clinical_quality_measure.py +1 -1
- canvas_sdk/templates/__init__.py +3 -0
- canvas_sdk/templates/tests/__init__.py +0 -0
- canvas_sdk/templates/tests/test_utils.py +43 -0
- canvas_sdk/templates/utils.py +44 -0
- canvas_sdk/v1/apps.py +7 -0
- canvas_sdk/v1/data/__init__.py +98 -5
- canvas_sdk/v1/data/allergy_intolerance.py +25 -9
- canvas_sdk/v1/data/appointment.py +56 -0
- canvas_sdk/v1/data/assessment.py +40 -0
- canvas_sdk/v1/data/base.py +35 -22
- canvas_sdk/v1/data/billing.py +4 -7
- canvas_sdk/v1/data/care_team.py +60 -0
- canvas_sdk/v1/data/command.py +8 -10
- canvas_sdk/v1/data/common.py +53 -0
- canvas_sdk/v1/data/condition.py +22 -10
- canvas_sdk/v1/data/coverage.py +294 -0
- canvas_sdk/v1/data/detected_issue.py +5 -9
- canvas_sdk/v1/data/device.py +4 -8
- canvas_sdk/v1/data/imaging.py +12 -17
- canvas_sdk/v1/data/lab.py +41 -31
- canvas_sdk/v1/data/medication.py +16 -10
- canvas_sdk/v1/data/note.py +11 -14
- canvas_sdk/v1/data/observation.py +19 -14
- canvas_sdk/v1/data/organization.py +1 -2
- canvas_sdk/v1/data/patient.py +140 -2
- canvas_sdk/v1/data/practicelocation.py +2 -4
- canvas_sdk/v1/data/protocol_override.py +21 -8
- canvas_sdk/v1/data/questionnaire.py +20 -17
- canvas_sdk/v1/data/staff.py +5 -7
- canvas_sdk/v1/data/task.py +5 -11
- canvas_sdk/v1/data/user.py +0 -1
- canvas_sdk/v1/models.py +4 -0
- canvas_sdk/value_set/hcc2018.py +55369 -0
- plugin_runner/plugin_installer.py +1 -1
- plugin_runner/plugin_runner.py +5 -25
- plugin_runner/sandbox.py +133 -9
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/CANVAS_MANIFEST.json +38 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/README.md +11 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/protocols/my_protocol.py +33 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/templates/__init__.py +3 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/templates/base.py +6 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/utils/__init__.py +5 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/utils/base.py +4 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/CANVAS_MANIFEST.json +29 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/README.md +12 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/other_module/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/other_module/base.py +10 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/protocols/my_protocol.py +18 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/CANVAS_MANIFEST.json +29 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/README.md +12 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/other_module/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/other_module/base.py +10 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/protocols/my_protocol.py +18 -0
- plugin_runner/tests/fixtures/plugins/test_render_template/CANVAS_MANIFEST.json +47 -0
- plugin_runner/tests/fixtures/plugins/test_render_template/README.md +11 -0
- plugin_runner/tests/fixtures/plugins/test_render_template/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_render_template/protocols/my_protocol.py +43 -0
- plugin_runner/tests/fixtures/plugins/test_render_template/templates/template.html +10 -0
- plugin_runner/tests/test_application.py +9 -9
- plugin_runner/tests/test_plugin_installer.py +12 -1
- plugin_runner/tests/test_plugin_runner.py +159 -66
- plugin_runner/tests/test_sandbox.py +26 -14
- settings.py +13 -1
- canvas_sdk/models/__init__.py +0 -8
- {canvas-0.14.0.dist-info → canvas-0.16.0.dist-info}/WHEEL +0 -0
- {canvas-0.14.0.dist-info → canvas-0.16.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from canvas_sdk.effects import Effect, EffectType
|
|
2
|
+
from canvas_sdk.events import EventType
|
|
3
|
+
from canvas_sdk.protocols import BaseProtocol
|
|
4
|
+
from canvas_sdk.templates import render_to_string
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ValidTemplate(BaseProtocol):
|
|
8
|
+
"""You should put a helpful description of this protocol's behavior here."""
|
|
9
|
+
|
|
10
|
+
RESPONDS_TO = [EventType.Name(EventType.UNKNOWN)]
|
|
11
|
+
|
|
12
|
+
def compute(self) -> list[Effect]:
|
|
13
|
+
"""This method gets called when an event of the type RESPONDS_TO is fired."""
|
|
14
|
+
return [
|
|
15
|
+
Effect(type=EffectType.LOG, payload=render_to_string("templates/template.html", None))
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidTemplate(BaseProtocol):
|
|
20
|
+
"""You should put a helpful description of this protocol's behavior here."""
|
|
21
|
+
|
|
22
|
+
RESPONDS_TO = [EventType.Name(EventType.UNKNOWN)]
|
|
23
|
+
|
|
24
|
+
def compute(self) -> list[Effect]:
|
|
25
|
+
"""This method gets called when an event of the type RESPONDS_TO is fired."""
|
|
26
|
+
return [
|
|
27
|
+
Effect(type=EffectType.LOG, payload=render_to_string("templates/template1.html", None))
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ForbiddenTemplate(BaseProtocol):
|
|
32
|
+
"""You should put a helpful description of this protocol's behavior here."""
|
|
33
|
+
|
|
34
|
+
RESPONDS_TO = [EventType.Name(EventType.UNKNOWN)]
|
|
35
|
+
|
|
36
|
+
def compute(self) -> list[Effect]:
|
|
37
|
+
"""This method gets called when an event of the type RESPONDS_TO is fired."""
|
|
38
|
+
return [
|
|
39
|
+
Effect(
|
|
40
|
+
type=EffectType.LOG,
|
|
41
|
+
payload=render_to_string("../../templates/template.html", None),
|
|
42
|
+
)
|
|
43
|
+
]
|
|
@@ -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
|
|
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) ->
|
|
18
|
+
def app_instance(event: Event) -> ExampleApplication:
|
|
19
19
|
"""Provide an instance of the TestApplication with a mocked event."""
|
|
20
|
-
app =
|
|
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 =
|
|
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"{
|
|
39
|
+
target=f"{ExampleApplication.__module__}:{ExampleApplication.__qualname__}",
|
|
40
40
|
)
|
|
41
41
|
event = Event(request)
|
|
42
|
-
app =
|
|
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"{
|
|
51
|
+
expected_identifier = f"{ExampleApplication.__module__}:{ExampleApplication.__qualname__}"
|
|
52
52
|
request = EventRequest(
|
|
53
53
|
type=EventType.APPLICATION__ON_OPEN,
|
|
54
|
-
target=f"{
|
|
54
|
+
target=f"{ExampleApplication.__module__}:{ExampleApplication.__qualname__}",
|
|
55
55
|
)
|
|
56
56
|
event = Event(request)
|
|
57
|
-
app =
|
|
57
|
+
app = ExampleApplication(event)
|
|
58
58
|
|
|
59
59
|
assert app.identifier == expected_identifier, "The identifier property is incorrect"
|
|
60
60
|
|
|
@@ -83,8 +83,19 @@ def test_plugin_installation_from_tarball(mocker: MockerFixture) -> None:
|
|
|
83
83
|
tarball_2 = _create_tarball("plugin2")
|
|
84
84
|
|
|
85
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
|
+
|
|
86
96
|
mocker.patch(
|
|
87
|
-
"plugin_runner.plugin_installer.download_plugin",
|
|
97
|
+
"plugin_runner.plugin_installer.download_plugin",
|
|
98
|
+
side_effect=mock_download_plugin,
|
|
88
99
|
)
|
|
89
100
|
|
|
90
101
|
install_plugins()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import shutil
|
|
2
|
-
from collections.abc import Generator
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from unittest.mock import MagicMock, patch
|
|
5
5
|
|
|
@@ -7,50 +7,16 @@ import pytest
|
|
|
7
7
|
|
|
8
8
|
from canvas_generated.messages.effects_pb2 import EffectType
|
|
9
9
|
from canvas_generated.messages.plugins_pb2 import ReloadPluginsRequest
|
|
10
|
-
from canvas_sdk.events import EventRequest, EventType
|
|
10
|
+
from canvas_sdk.events import Event, EventRequest, EventType
|
|
11
11
|
from plugin_runner.plugin_runner import (
|
|
12
12
|
EVENT_HANDLER_MAP,
|
|
13
13
|
LOADED_PLUGINS,
|
|
14
14
|
PluginRunner,
|
|
15
|
+
load_or_reload_plugin,
|
|
15
16
|
load_plugins,
|
|
16
|
-
sandbox_from_package,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
@pytest.fixture
|
|
21
|
-
def setup_test_plugin(request: pytest.FixtureRequest) -> Generator[Path, None, None]:
|
|
22
|
-
"""Copies a specified plugin from the fixtures directory to the data directory
|
|
23
|
-
and removes it after the test.
|
|
24
|
-
|
|
25
|
-
Parameters:
|
|
26
|
-
- request.param: The name of the plugin package to copy.
|
|
27
|
-
|
|
28
|
-
Yields:
|
|
29
|
-
- Path to the copied plugin directory.
|
|
30
|
-
"""
|
|
31
|
-
# Define base directories
|
|
32
|
-
base_dir = Path("./plugin_runner/tests")
|
|
33
|
-
fixture_plugin_dir = base_dir / "fixtures" / "plugins"
|
|
34
|
-
data_plugin_dir = base_dir / "data" / "plugins"
|
|
35
|
-
|
|
36
|
-
# The plugin name should be passed as a parameter to the fixture
|
|
37
|
-
plugin_name = request.param # Expected to be a str
|
|
38
|
-
src_plugin_path = fixture_plugin_dir / plugin_name
|
|
39
|
-
dest_plugin_path = data_plugin_dir / plugin_name
|
|
40
|
-
|
|
41
|
-
# Ensure the data plugin directory exists
|
|
42
|
-
data_plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
-
|
|
44
|
-
# Copy the specific plugin from fixtures to data
|
|
45
|
-
try:
|
|
46
|
-
shutil.copytree(src_plugin_path, dest_plugin_path)
|
|
47
|
-
yield dest_plugin_path # Provide the path to the test
|
|
48
|
-
finally:
|
|
49
|
-
# Cleanup: remove data/plugins directory after the test
|
|
50
|
-
if dest_plugin_path.exists():
|
|
51
|
-
shutil.rmtree(dest_plugin_path)
|
|
52
|
-
|
|
53
|
-
|
|
54
20
|
@pytest.fixture
|
|
55
21
|
def plugin_runner() -> PluginRunner:
|
|
56
22
|
"""Fixture to initialize PluginRunner with mocks."""
|
|
@@ -59,11 +25,9 @@ def plugin_runner() -> PluginRunner:
|
|
|
59
25
|
return runner
|
|
60
26
|
|
|
61
27
|
|
|
62
|
-
@pytest.mark.parametrize("
|
|
63
|
-
def test_load_plugins_with_valid_plugin(
|
|
28
|
+
@pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
|
|
29
|
+
def test_load_plugins_with_valid_plugin(install_test_plugin: Path, load_test_plugins: None) -> None:
|
|
64
30
|
"""Test loading plugins with a valid plugin."""
|
|
65
|
-
load_plugins()
|
|
66
|
-
|
|
67
31
|
assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
|
|
68
32
|
assert (
|
|
69
33
|
LOADED_PLUGINS["example_plugin:example_plugin.protocols.my_protocol:Protocol"]["active"]
|
|
@@ -72,12 +36,11 @@ def test_load_plugins_with_valid_plugin(setup_test_plugin: Path) -> None:
|
|
|
72
36
|
|
|
73
37
|
|
|
74
38
|
@pytest.mark.asyncio
|
|
75
|
-
@pytest.mark.parametrize("
|
|
39
|
+
@pytest.mark.parametrize("install_test_plugin", ["test_module_imports_plugin"], indirect=True)
|
|
76
40
|
async def test_load_plugins_with_plugin_that_imports_other_modules_within_plugin_package(
|
|
77
|
-
|
|
41
|
+
install_test_plugin: Path, plugin_runner: PluginRunner, load_test_plugins: None
|
|
78
42
|
) -> None:
|
|
79
43
|
"""Test loading plugins with a valid plugin that imports other modules within the current plugin package."""
|
|
80
|
-
load_plugins()
|
|
81
44
|
assert (
|
|
82
45
|
"test_module_imports_plugin:test_module_imports_plugin.protocols.my_protocol:Protocol"
|
|
83
46
|
in LOADED_PLUGINS
|
|
@@ -102,7 +65,7 @@ async def test_load_plugins_with_plugin_that_imports_other_modules_within_plugin
|
|
|
102
65
|
|
|
103
66
|
|
|
104
67
|
@pytest.mark.parametrize(
|
|
105
|
-
"
|
|
68
|
+
"install_test_plugin",
|
|
106
69
|
[
|
|
107
70
|
"test_module_imports_outside_plugin_v1",
|
|
108
71
|
"test_module_imports_outside_plugin_v2",
|
|
@@ -111,18 +74,108 @@ async def test_load_plugins_with_plugin_that_imports_other_modules_within_plugin
|
|
|
111
74
|
indirect=True,
|
|
112
75
|
)
|
|
113
76
|
def test_load_plugins_with_plugin_that_imports_other_modules_outside_plugin_package(
|
|
114
|
-
|
|
77
|
+
install_test_plugin: Path, caplog: pytest.LogCaptureFixture
|
|
115
78
|
) -> None:
|
|
116
79
|
"""Test loading plugins with an invalid plugin that imports other modules outside the current plugin package."""
|
|
117
|
-
with
|
|
118
|
-
|
|
80
|
+
with caplog.at_level(logging.ERROR):
|
|
81
|
+
load_or_reload_plugin(install_test_plugin)
|
|
82
|
+
|
|
83
|
+
assert any(
|
|
84
|
+
"Error importing module" in record.message for record in caplog.records
|
|
85
|
+
), "log.error() was not called with the expected message."
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize(
|
|
89
|
+
"install_test_plugin",
|
|
90
|
+
[
|
|
91
|
+
"test_module_forbidden_imports_plugin",
|
|
92
|
+
],
|
|
93
|
+
indirect=True,
|
|
94
|
+
)
|
|
95
|
+
def test_load_plugins_with_plugin_that_imports_forbidden_modules(
|
|
96
|
+
install_test_plugin: Path, caplog: pytest.LogCaptureFixture
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Test loading plugins with an invalid plugin that imports forbidden modules."""
|
|
99
|
+
with caplog.at_level(logging.ERROR):
|
|
100
|
+
load_or_reload_plugin(install_test_plugin)
|
|
119
101
|
|
|
102
|
+
assert any(
|
|
103
|
+
"Error importing module" in record.message for record in caplog.records
|
|
104
|
+
), "log.error() was not called with the expected message."
|
|
120
105
|
|
|
121
|
-
|
|
122
|
-
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize(
|
|
108
|
+
"install_test_plugin",
|
|
109
|
+
[
|
|
110
|
+
"test_module_forbidden_imports_runtime_plugin",
|
|
111
|
+
],
|
|
112
|
+
indirect=True,
|
|
113
|
+
)
|
|
114
|
+
def test_load_plugins_with_plugin_that_imports_forbidden_modules_at_runtime(
|
|
115
|
+
install_test_plugin: Path,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Test loading plugins with an invalid plugin that imports forbidden modules at runtime."""
|
|
118
|
+
with pytest.raises(ImportError, match="is not an allowed import."):
|
|
119
|
+
load_or_reload_plugin(install_test_plugin)
|
|
120
|
+
class_handler = LOADED_PLUGINS[
|
|
121
|
+
"test_module_forbidden_imports_runtime_plugin:test_module_forbidden_imports_runtime_plugin.protocols.my_protocol:Protocol"
|
|
122
|
+
]["class"]
|
|
123
|
+
class_handler(Event(EventRequest(type=EventType.UNKNOWN))).compute()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.parametrize(
|
|
127
|
+
"install_test_plugin",
|
|
128
|
+
[
|
|
129
|
+
"test_implicit_imports_plugin",
|
|
130
|
+
],
|
|
131
|
+
indirect=True,
|
|
132
|
+
)
|
|
133
|
+
def test_plugin_that_implicitly_imports_allowed_modules(
|
|
134
|
+
install_test_plugin: Path, caplog: pytest.LogCaptureFixture
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Test loading plugins with a plugin that implicitly imports allowed modules."""
|
|
137
|
+
with caplog.at_level(logging.INFO):
|
|
138
|
+
load_or_reload_plugin(install_test_plugin)
|
|
139
|
+
class_handler = LOADED_PLUGINS[
|
|
140
|
+
"test_implicit_imports_plugin:test_implicit_imports_plugin.protocols.my_protocol:Allowed"
|
|
141
|
+
]["class"]
|
|
142
|
+
class_handler(Event(EventRequest(type=EventType.UNKNOWN))).compute()
|
|
143
|
+
|
|
144
|
+
assert any(
|
|
145
|
+
"Hello, World!" in record.message for record in caplog.records
|
|
146
|
+
), "log.info() with Template.render() was not called."
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.parametrize(
|
|
150
|
+
"install_test_plugin",
|
|
151
|
+
[
|
|
152
|
+
"test_implicit_imports_plugin",
|
|
153
|
+
],
|
|
154
|
+
indirect=True,
|
|
155
|
+
)
|
|
156
|
+
def test_plugin_that_implicitly_imports_forbidden_modules(
|
|
157
|
+
install_test_plugin: Path, caplog: pytest.LogCaptureFixture
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Test loading plugins with an invalid plugin that implicitly imports forbidden modules."""
|
|
160
|
+
with (
|
|
161
|
+
caplog.at_level(logging.INFO),
|
|
162
|
+
pytest.raises(ImportError, match="'os' is not an allowed import."),
|
|
163
|
+
):
|
|
164
|
+
load_or_reload_plugin(install_test_plugin)
|
|
165
|
+
class_handler = LOADED_PLUGINS[
|
|
166
|
+
"test_implicit_imports_plugin:test_implicit_imports_plugin.protocols.my_protocol:Forbidden"
|
|
167
|
+
]["class"]
|
|
168
|
+
class_handler(Event(EventRequest(type=EventType.UNKNOWN))).compute()
|
|
169
|
+
|
|
170
|
+
assert (
|
|
171
|
+
any("os list dir" in record.message for record in caplog.records) is False
|
|
172
|
+
), "log.info() with os.listdir() was called."
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
|
|
176
|
+
def test_reload_plugin(install_test_plugin: Path, load_test_plugins: None) -> None:
|
|
123
177
|
"""Test reloading a plugin."""
|
|
124
178
|
load_plugins()
|
|
125
|
-
load_plugins()
|
|
126
179
|
|
|
127
180
|
assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
|
|
128
181
|
assert (
|
|
@@ -131,18 +184,22 @@ def test_reload_plugin(setup_test_plugin: Path) -> None:
|
|
|
131
184
|
)
|
|
132
185
|
|
|
133
186
|
|
|
134
|
-
@pytest.mark.parametrize("
|
|
135
|
-
def test_remove_plugin_should_be_removed_from_loaded_plugins(
|
|
187
|
+
@pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
|
|
188
|
+
def test_remove_plugin_should_be_removed_from_loaded_plugins(
|
|
189
|
+
install_test_plugin: Path, load_test_plugins: None
|
|
190
|
+
) -> None:
|
|
136
191
|
"""Test removing a plugin."""
|
|
137
|
-
load_plugins()
|
|
138
192
|
assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
|
|
139
|
-
shutil.rmtree(
|
|
193
|
+
shutil.rmtree(install_test_plugin)
|
|
140
194
|
load_plugins()
|
|
141
195
|
assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" not in LOADED_PLUGINS
|
|
142
196
|
|
|
143
197
|
|
|
144
|
-
@pytest.mark.parametrize("
|
|
145
|
-
|
|
198
|
+
@pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
|
|
199
|
+
@pytest.mark.parametrize("load_test_plugins", [None], indirect=True)
|
|
200
|
+
def test_load_plugins_should_refresh_event_protocol_map(
|
|
201
|
+
load_test_plugins: None, install_test_plugin: Path
|
|
202
|
+
) -> None:
|
|
146
203
|
"""Test that the event protocol map is refreshed when loading plugins."""
|
|
147
204
|
assert EVENT_HANDLER_MAP == {}
|
|
148
205
|
load_plugins()
|
|
@@ -153,13 +210,11 @@ def test_load_plugins_should_refresh_event_protocol_map(setup_test_plugin: Path)
|
|
|
153
210
|
|
|
154
211
|
|
|
155
212
|
@pytest.mark.asyncio
|
|
156
|
-
@pytest.mark.parametrize("
|
|
213
|
+
@pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
|
|
157
214
|
async def test_handle_plugin_event_returns_expected_result(
|
|
158
|
-
|
|
215
|
+
install_test_plugin: Path, plugin_runner: PluginRunner, load_test_plugins: None
|
|
159
216
|
) -> None:
|
|
160
217
|
"""Test that HandleEvent successfully calls the relevant plugins and returns the expected result."""
|
|
161
|
-
load_plugins()
|
|
162
|
-
|
|
163
218
|
event = EventRequest(type=EventType.UNKNOWN)
|
|
164
219
|
|
|
165
220
|
result = []
|
|
@@ -174,11 +229,11 @@ async def test_handle_plugin_event_returns_expected_result(
|
|
|
174
229
|
|
|
175
230
|
|
|
176
231
|
@pytest.mark.asyncio
|
|
177
|
-
@pytest.mark.parametrize("
|
|
178
|
-
async def
|
|
179
|
-
|
|
232
|
+
@pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
|
|
233
|
+
async def test_reload_plugins_event_handler_successfully_publishes_message(
|
|
234
|
+
install_test_plugin: Path, plugin_runner: PluginRunner
|
|
180
235
|
) -> None:
|
|
181
|
-
"""Test ReloadPlugins Event handler successfully
|
|
236
|
+
"""Test ReloadPlugins Event handler successfully publishes a message with restart action."""
|
|
182
237
|
with patch("plugin_runner.plugin_runner.publish_message", MagicMock()) as mock_publish_message:
|
|
183
238
|
request = ReloadPluginsRequest()
|
|
184
239
|
|
|
@@ -190,4 +245,42 @@ async def test_reload_plugins_event_handler_successfully_loads_plugins(
|
|
|
190
245
|
|
|
191
246
|
assert len(result) == 1
|
|
192
247
|
assert result[0].success is True
|
|
193
|
-
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.mark.asyncio
|
|
251
|
+
@pytest.mark.parametrize("install_test_plugin", ["test_module_imports_plugin"], indirect=True)
|
|
252
|
+
async def test_changes_to_plugin_modules_should_be_reflected_after_reload(
|
|
253
|
+
install_test_plugin: Path, load_test_plugins: None, plugin_runner: PluginRunner
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Test that changes to plugin modules are reflected after reloading the plugin."""
|
|
256
|
+
event = EventRequest(type=EventType.UNKNOWN)
|
|
257
|
+
|
|
258
|
+
result = []
|
|
259
|
+
async for response in plugin_runner.HandleEvent(event, None):
|
|
260
|
+
result.append(response)
|
|
261
|
+
|
|
262
|
+
assert len(result) == 1
|
|
263
|
+
assert result[0].success is True
|
|
264
|
+
assert len(result[0].effects) == 1
|
|
265
|
+
assert result[0].effects[0].type == EffectType.LOG
|
|
266
|
+
assert result[0].effects[0].payload == "Successfully imported!"
|
|
267
|
+
|
|
268
|
+
NEW_CODE = """
|
|
269
|
+
def import_me() -> str:
|
|
270
|
+
return "Successfully changed!"
|
|
271
|
+
"""
|
|
272
|
+
file_path = install_test_plugin / "other_module" / "base.py"
|
|
273
|
+
file_path.write_text(NEW_CODE, encoding="utf-8")
|
|
274
|
+
|
|
275
|
+
# Reload the plugin
|
|
276
|
+
load_plugins()
|
|
277
|
+
|
|
278
|
+
result = []
|
|
279
|
+
async for response in plugin_runner.HandleEvent(event, None):
|
|
280
|
+
result.append(response)
|
|
281
|
+
|
|
282
|
+
assert len(result) == 1
|
|
283
|
+
assert result[0].success is True
|
|
284
|
+
assert len(result[0].effects) == 1
|
|
285
|
+
assert result[0].effects[0].type == EffectType.LOG
|
|
286
|
+
assert result[0].effects[0].payload == "Successfully changed!"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from plugin_runner.sandbox import Sandbox
|
|
3
|
+
from plugin_runner.sandbox import FORBIDDEN_ASSIGNMENTS, Sandbox
|
|
4
4
|
|
|
5
5
|
# Sample code strings for testing various scenarios
|
|
6
6
|
VALID_CODE = """
|
|
@@ -28,11 +28,23 @@ CODE_WITH_FORBIDDEN_FUNC_NAME = """
|
|
|
28
28
|
builtins = {}
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
import
|
|
33
|
-
result =
|
|
31
|
+
SOURCE_CODE_MODULE = """
|
|
32
|
+
import module.b
|
|
33
|
+
result = module.b
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
+
CODE_WITH_FORBIDDEN_ASSIGNMENTS = [
|
|
37
|
+
code
|
|
38
|
+
for var in FORBIDDEN_ASSIGNMENTS
|
|
39
|
+
for code in [
|
|
40
|
+
f"{var} = 'test'",
|
|
41
|
+
f"test = {var} = 'test'",
|
|
42
|
+
f"test = {var} = test2 = 'test'",
|
|
43
|
+
f"(a, (b, c), (d, ({var}, f))) = (1, (2, 3), (4, (5, 6)))",
|
|
44
|
+
f"(a, (b, c), (d, [{var}, f])) = (1, (2, 3), (4, [5, 6]))",
|
|
45
|
+
]
|
|
46
|
+
]
|
|
47
|
+
|
|
36
48
|
|
|
37
49
|
def test_valid_code_execution() -> None:
|
|
38
50
|
"""Test execution of valid code in the sandbox."""
|
|
@@ -69,6 +81,14 @@ def test_forbidden_name() -> None:
|
|
|
69
81
|
sandbox.execute()
|
|
70
82
|
|
|
71
83
|
|
|
84
|
+
@pytest.mark.parametrize("code", CODE_WITH_FORBIDDEN_ASSIGNMENTS)
|
|
85
|
+
def test_forbidden_assignment(code: str) -> None:
|
|
86
|
+
"""Test that forbidden assignments are blocked by Transformer."""
|
|
87
|
+
sandbox = Sandbox(code)
|
|
88
|
+
with pytest.raises(RuntimeError, match="Code is invalid"):
|
|
89
|
+
sandbox.execute()
|
|
90
|
+
|
|
91
|
+
|
|
72
92
|
def test_code_with_warnings() -> None:
|
|
73
93
|
"""Test that the sandbox captures warnings for restricted names or usage."""
|
|
74
94
|
code_with_warning = """
|
|
@@ -110,16 +130,8 @@ print("Hello, Sandbox!")
|
|
|
110
130
|
assert "Hello, Sandbox!" in scope["_print"].txt, "Print output should be captured."
|
|
111
131
|
|
|
112
132
|
|
|
113
|
-
def test_sandbox_module_name_imports_within_package() -> None:
|
|
114
|
-
"""Test that modules within the same package can be imported."""
|
|
115
|
-
sandbox_module_a = Sandbox(source_code=SOURCE_CODE_MODULE_OS, namespace="os.a")
|
|
116
|
-
result = sandbox_module_a.execute()
|
|
117
|
-
|
|
118
|
-
assert "os" in result
|
|
119
|
-
|
|
120
|
-
|
|
121
133
|
def test_sandbox_denies_module_name_import_outside_package() -> None:
|
|
122
134
|
"""Test that modules outside the root package cannot be imported."""
|
|
123
|
-
sandbox_module_a = Sandbox(source_code=
|
|
124
|
-
with pytest.raises(ImportError, match="
|
|
135
|
+
sandbox_module_a = Sandbox(source_code=SOURCE_CODE_MODULE, namespace="other_module.a")
|
|
136
|
+
with pytest.raises(ImportError, match="module.b' is not an allowed import."):
|
|
125
137
|
sandbox_module_a.execute()
|
settings.py
CHANGED
|
@@ -19,7 +19,9 @@ INTEGRATION_TEST_CLIENT_SECRET = os.getenv("INTEGRATION_TEST_CLIENT_SECRET")
|
|
|
19
19
|
|
|
20
20
|
GRAPHQL_ENDPOINT = os.getenv("GRAPHQL_ENDPOINT", "http://localhost:8000/plugins-graphql")
|
|
21
21
|
|
|
22
|
-
INSTALLED_APPS = [
|
|
22
|
+
INSTALLED_APPS = [
|
|
23
|
+
"canvas_sdk.v1",
|
|
24
|
+
]
|
|
23
25
|
|
|
24
26
|
SECRET_KEY = os.getenv(
|
|
25
27
|
"SECRET_KEY",
|
|
@@ -70,3 +72,13 @@ PLUGIN_DIRECTORY = os.getenv(
|
|
|
70
72
|
MANIFEST_FILE_NAME = "CANVAS_MANIFEST.json"
|
|
71
73
|
|
|
72
74
|
SECRETS_FILE_NAME = "SECRETS.json"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
TEMPLATES = [
|
|
78
|
+
{
|
|
79
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
80
|
+
"DIRS": [],
|
|
81
|
+
"APP_DIRS": False,
|
|
82
|
+
"OPTIONS": {},
|
|
83
|
+
},
|
|
84
|
+
]
|
canvas_sdk/models/__init__.py
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# ruff: noqa
|
|
2
|
-
# register all models in the app so they can be used with apps.get_model()
|
|
3
|
-
from canvas_sdk.v1.data.command import Command
|
|
4
|
-
from canvas_sdk.v1.data.condition import Condition
|
|
5
|
-
from canvas_sdk.v1.data.note import Note
|
|
6
|
-
from canvas_sdk.v1.data.patient import Patient
|
|
7
|
-
from canvas_sdk.v1.data.user import CanvasUser
|
|
8
|
-
from canvas_sdk.v1.data.questionnaire import Interview
|
|
File without changes
|
|
File without changes
|