canvas 0.4.0__py3-none-any.whl → 0.5.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 (49) hide show
  1. {canvas-0.4.0.dist-info → canvas-0.5.0.dist-info}/METADATA +3 -2
  2. {canvas-0.4.0.dist-info → canvas-0.5.0.dist-info}/RECORD +49 -12
  3. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +2 -6
  4. canvas_generated/messages/events_pb2.py +2 -2
  5. canvas_generated/messages/events_pb2.pyi +108 -0
  6. canvas_sdk/effects/patient_chart_summary_configuration.py +1 -0
  7. canvas_sdk/v1/data/detected_issue.py +52 -0
  8. canvas_sdk/v1/data/protocol_override.py +58 -0
  9. canvas_sdk/value_set/__init__.py +0 -0
  10. canvas_sdk/value_set/v2022/__init__.py +0 -0
  11. plugin_runner/authentication.py +3 -7
  12. plugin_runner/plugin_runner.py +48 -26
  13. plugin_runner/sandbox.py +22 -8
  14. plugin_runner/tests/__init__.py +0 -0
  15. plugin_runner/tests/data/plugins/.gitkeep +0 -0
  16. plugin_runner/tests/fixtures/plugins/example_plugin/CANVAS_MANIFEST.json +29 -0
  17. plugin_runner/tests/fixtures/plugins/example_plugin/README.md +12 -0
  18. plugin_runner/tests/fixtures/plugins/example_plugin/__init__.py +0 -0
  19. plugin_runner/tests/fixtures/plugins/example_plugin/protocols/__init__.py +0 -0
  20. plugin_runner/tests/fixtures/plugins/example_plugin/protocols/my_protocol.py +18 -0
  21. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/CANVAS_MANIFEST.json +29 -0
  22. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/README.md +12 -0
  23. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/other_module/__init__.py +0 -0
  24. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/other_module/base.py +3 -0
  25. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/protocols/__init__.py +0 -0
  26. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/protocols/my_protocol.py +18 -0
  27. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/CANVAS_MANIFEST.json +29 -0
  28. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/README.md +12 -0
  29. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/other_module/__init__.py +0 -0
  30. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/other_module/base.py +6 -0
  31. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/protocols/__init__.py +0 -0
  32. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/protocols/my_protocol.py +18 -0
  33. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/CANVAS_MANIFEST.json +29 -0
  34. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/README.md +12 -0
  35. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/other_module/__init__.py +0 -0
  36. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/other_module/base.py +8 -0
  37. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/protocols/__init__.py +0 -0
  38. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/protocols/my_protocol.py +18 -0
  39. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/CANVAS_MANIFEST.json +29 -0
  40. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/README.md +12 -0
  41. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/__init__.py +0 -0
  42. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/base.py +3 -0
  43. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/__init__.py +0 -0
  44. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/my_protocol.py +18 -0
  45. plugin_runner/tests/test_plugin_runner.py +208 -0
  46. plugin_runner/tests/test_sandbox.py +113 -0
  47. settings.py +23 -0
  48. {canvas-0.4.0.dist-info → canvas-0.5.0.dist-info}/WHEEL +0 -0
  49. {canvas-0.4.0.dist-info → canvas-0.5.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,208 @@
1
+ import shutil
2
+ from pathlib import Path
3
+ from typing import Generator
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from canvas_generated.messages.effects_pb2 import EffectType
9
+ from canvas_generated.messages.plugins_pb2 import ReloadPluginsRequest
10
+ from canvas_sdk.events import Event, EventType
11
+ from plugin_runner.plugin_runner import (
12
+ EVENT_PROTOCOL_MAP,
13
+ LOADED_PLUGINS,
14
+ PluginRunner,
15
+ load_plugins,
16
+ sandbox_from_package,
17
+ )
18
+
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
+ @pytest.fixture
55
+ def plugin_runner() -> PluginRunner:
56
+ """Fixture to initialize PluginRunner with mocks."""
57
+ runner = PluginRunner()
58
+ runner.statsd_client = MagicMock()
59
+ return runner
60
+
61
+
62
+ @pytest.mark.parametrize("setup_test_plugin", ["example_plugin"], indirect=True)
63
+ def test_load_plugins_with_valid_plugin(setup_test_plugin: Path) -> None:
64
+ """Test loading plugins with a valid plugin."""
65
+ load_plugins()
66
+
67
+ assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
68
+ assert (
69
+ LOADED_PLUGINS["example_plugin:example_plugin.protocols.my_protocol:Protocol"]["active"]
70
+ is True
71
+ )
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ @pytest.mark.parametrize("setup_test_plugin", ["test_module_imports_plugin"], indirect=True)
76
+ async def test_load_plugins_with_plugin_that_imports_other_modules_within_plugin_package(
77
+ setup_test_plugin: Path, plugin_runner: PluginRunner
78
+ ) -> None:
79
+ """Test loading plugins with a valid plugin that imports other modules within the current plugin package."""
80
+ load_plugins()
81
+ assert (
82
+ "test_module_imports_plugin:test_module_imports_plugin.protocols.my_protocol:Protocol"
83
+ in LOADED_PLUGINS
84
+ )
85
+ assert (
86
+ LOADED_PLUGINS[
87
+ "test_module_imports_plugin:test_module_imports_plugin.protocols.my_protocol:Protocol"
88
+ ]["active"]
89
+ is True
90
+ )
91
+
92
+ result = [
93
+ response
94
+ async for response in plugin_runner.HandleEvent(Event(type=EventType.UNKNOWN), None)
95
+ ]
96
+
97
+ assert len(result) == 1
98
+ assert result[0].success is True
99
+ assert len(result[0].effects) == 1
100
+ assert result[0].effects[0].type == EffectType.LOG
101
+ assert result[0].effects[0].payload == "Successfully imported!"
102
+
103
+
104
+ @pytest.mark.parametrize(
105
+ "setup_test_plugin",
106
+ [
107
+ "test_module_imports_outside_plugin_v1",
108
+ "test_module_imports_outside_plugin_v2",
109
+ "test_module_imports_outside_plugin_v3",
110
+ ],
111
+ indirect=True,
112
+ )
113
+ def test_load_plugins_with_plugin_that_imports_other_modules_outside_plugin_package(
114
+ setup_test_plugin: Path,
115
+ ) -> None:
116
+ """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)
119
+
120
+
121
+ @pytest.mark.parametrize("setup_test_plugin", ["example_plugin"], indirect=True)
122
+ def test_reload_plugin(setup_test_plugin: Path) -> None:
123
+ """Test reloading a plugin."""
124
+ load_plugins()
125
+ load_plugins()
126
+
127
+ assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
128
+ assert (
129
+ LOADED_PLUGINS["example_plugin:example_plugin.protocols.my_protocol:Protocol"]["active"]
130
+ is True
131
+ )
132
+
133
+
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:
136
+ """Test removing a plugin."""
137
+ load_plugins()
138
+ assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
139
+ shutil.rmtree(setup_test_plugin)
140
+ load_plugins()
141
+ assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" not in LOADED_PLUGINS
142
+
143
+
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:
146
+ """Test that the event protocol map is refreshed when loading plugins."""
147
+ assert EVENT_PROTOCOL_MAP == {}
148
+ load_plugins()
149
+ assert EventType.Name(EventType.UNKNOWN) in EVENT_PROTOCOL_MAP
150
+ assert EVENT_PROTOCOL_MAP[EventType.Name(EventType.UNKNOWN)] == [
151
+ "example_plugin:example_plugin.protocols.my_protocol:Protocol"
152
+ ]
153
+
154
+
155
+ @pytest.mark.asyncio
156
+ @pytest.mark.parametrize("setup_test_plugin", ["example_plugin"], indirect=True)
157
+ async def test_handle_plugin_event_returns_expected_result(
158
+ setup_test_plugin: Path, plugin_runner: PluginRunner
159
+ ) -> None:
160
+ """Test that HandleEvent successfully calls the relevant plugins and returns the expected result."""
161
+ load_plugins()
162
+
163
+ event = Event(type=EventType.UNKNOWN)
164
+
165
+ result = []
166
+ async for response in plugin_runner.HandleEvent(event, None):
167
+ result.append(response)
168
+
169
+ assert len(result) == 1
170
+ assert result[0].success is True
171
+ assert len(result[0].effects) == 1
172
+ assert result[0].effects[0].type == EffectType.LOG
173
+ assert result[0].effects[0].payload == "Hello, world!"
174
+
175
+
176
+ @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
180
+ ) -> None:
181
+ """Test ReloadPlugins Event handler successfully loads plugins."""
182
+
183
+ with patch("plugin_runner.plugin_runner.publish_message", MagicMock()) as mock_publish_message:
184
+ request = ReloadPluginsRequest()
185
+
186
+ result = []
187
+ async for response in plugin_runner.ReloadPlugins(request, None):
188
+ result.append(response)
189
+
190
+ mock_publish_message.assert_called_once_with({"action": "restart"})
191
+
192
+ assert len(result) == 1
193
+ assert result[0].success is True
194
+ assert "example_plugin:example_plugin.protocols.my_protocol:Protocol" in LOADED_PLUGINS
195
+
196
+
197
+ @pytest.mark.asyncio
198
+ async def test_reload_plugins_import_error(plugin_runner: PluginRunner) -> None:
199
+ """Test ReloadPlugins response when an ImportError occurs."""
200
+ request = ReloadPluginsRequest()
201
+
202
+ with patch("plugin_runner.plugin_runner.load_plugins", side_effect=ImportError):
203
+ responses = []
204
+ async for response in plugin_runner.ReloadPlugins(request, None):
205
+ responses.append(response)
206
+
207
+ assert len(responses) == 1
208
+ assert responses[0].success is False
@@ -0,0 +1,113 @@
1
+ import pytest
2
+
3
+ from plugin_runner.sandbox import Sandbox
4
+
5
+ # Sample code strings for testing various scenarios
6
+ VALID_CODE = """
7
+ x = 10
8
+ y = 20
9
+ result = x + y
10
+ """
11
+
12
+ CODE_WITH_RESTRICTED_IMPORT = """
13
+ import os
14
+ result = os.listdir('.')
15
+ """
16
+
17
+ CODE_WITH_ALLOWED_IMPORT = """
18
+ import json
19
+ result = json.dumps({"key": "value"})
20
+ """
21
+
22
+ CODE_WITH_FORBIDDEN_FUNC_NAME = """
23
+ builtins = {}
24
+ """
25
+
26
+ SOURCE_CODE_MODULE_OS = """
27
+ import os
28
+ result = os.listdir('.')
29
+ """
30
+
31
+
32
+ def test_valid_code_execution() -> None:
33
+ """Test execution of valid code in the sandbox."""
34
+ sandbox = Sandbox(VALID_CODE)
35
+ scope = sandbox.execute()
36
+ assert scope["result"] == 30, "The code should compute result as 30."
37
+
38
+
39
+ def test_disallowed_import() -> None:
40
+ """Test that restricted imports are not allowed."""
41
+ sandbox = Sandbox(CODE_WITH_RESTRICTED_IMPORT)
42
+ with pytest.raises(ImportError, match="os' is not an allowed import."):
43
+ sandbox.execute()
44
+
45
+
46
+ def test_allowed_import() -> None:
47
+ """Test that allowed imports (from ALLOWED_MODULES) work correctly."""
48
+ sandbox = Sandbox(CODE_WITH_ALLOWED_IMPORT)
49
+ scope = sandbox.execute()
50
+ assert scope["result"] == '{"key": "value"}', "JSON encoding should work with allowed imports."
51
+
52
+
53
+ def test_forbidden_name() -> None:
54
+ """Test that forbidden function names are blocked by Transformer."""
55
+ sandbox = Sandbox(CODE_WITH_FORBIDDEN_FUNC_NAME)
56
+ with pytest.raises(RuntimeError, match="Code is invalid"):
57
+ sandbox.execute()
58
+
59
+
60
+ def test_code_with_warnings() -> None:
61
+ """Test that the sandbox captures warnings for restricted names or usage."""
62
+ code_with_warning = """
63
+ _x = 5
64
+ result = _x
65
+ """
66
+ sandbox = Sandbox(code_with_warning)
67
+ assert sandbox.warnings, "There should be warnings for using restricted names."
68
+ scope = sandbox.execute()
69
+ assert scope["result"] == 5, "Code should execute despite warnings."
70
+
71
+
72
+ def test_compile_errors() -> None:
73
+ """Test that compile errors are detected for invalid syntax."""
74
+ invalid_code = """
75
+ def missing_colon()
76
+ return 42
77
+ """
78
+ sandbox = Sandbox(invalid_code)
79
+ with pytest.raises(RuntimeError, match="Code is invalid"):
80
+ sandbox.execute()
81
+
82
+
83
+ def test_sandbox_scope() -> None:
84
+ """Verify the sandbox scope includes expected built-ins and utility functions."""
85
+ sandbox = Sandbox(VALID_CODE)
86
+ scope = sandbox.execute()
87
+ assert "any" in scope["__builtins__"]
88
+ assert scope["__builtins__"]["any"] == any, "'any' function should be accessible in sandbox."
89
+
90
+
91
+ def test_print_collector() -> None:
92
+ """Ensure that PrintCollector is used for capturing prints."""
93
+ code_with_print = """
94
+ print("Hello, Sandbox!")
95
+ """
96
+ sandbox = Sandbox(code_with_print)
97
+ scope = sandbox.execute()
98
+ assert "Hello, Sandbox!" in scope["_print"].txt, "Print output should be captured."
99
+
100
+
101
+ def test_sandbox_module_name_imports_within_package() -> None:
102
+ """Test that modules within the same package can be imported."""
103
+ sandbox_module_a = Sandbox(source_code=SOURCE_CODE_MODULE_OS, module_name="os.a")
104
+ result = sandbox_module_a.execute()
105
+
106
+ assert "os" in result
107
+
108
+
109
+ def test_sandbox_denies_module_name_import_outside_package() -> None:
110
+ """Test that modules outside the root package cannot be imported."""
111
+ sandbox_module_a = Sandbox(source_code=SOURCE_CODE_MODULE_OS, module_name="module.a")
112
+ with pytest.raises(ImportError, match="os' is not an allowed import."):
113
+ sandbox_module_a.execute()
settings.py CHANGED
@@ -1,11 +1,18 @@
1
1
  import os
2
+ import sys
2
3
 
3
4
  from dotenv import load_dotenv
5
+ from env_tools import env_to_bool
4
6
 
5
7
  from canvas_sdk.utils.db import get_database_dict_from_url
6
8
 
7
9
  load_dotenv()
8
10
 
11
+ ENV = os.getenv("ENV", "development")
12
+ IS_PRODUCTION = ENV == "production"
13
+ IS_TESTING = env_to_bool("IS_TESTING", "pytest" in sys.argv[0] or sys.argv[0] == "-c")
14
+
15
+
9
16
  INTEGRATION_TEST_URL = os.getenv("INTEGRATION_TEST_URL")
10
17
  INTEGRATION_TEST_CLIENT_ID = os.getenv("INTEGRATION_TEST_CLIENT_ID")
11
18
  INTEGRATION_TEST_CLIENT_SECRET = os.getenv("INTEGRATION_TEST_CLIENT_SECRET")
@@ -41,3 +48,19 @@ else:
41
48
  }
42
49
 
43
50
  DATABASES = {"default": database_dict}
51
+
52
+
53
+ PLUGIN_RUNNER_SIGNING_KEY = os.getenv("PLUGIN_RUNNER_SIGNING_KEY", "")
54
+
55
+ PLUGIN_DIRECTORY = os.getenv(
56
+ "PLUGIN_DIRECTORY",
57
+ (
58
+ "/plugin-runner/custom-plugins"
59
+ if IS_PRODUCTION
60
+ else "./plugin_runner/tests/data/plugins" if IS_TESTING else "./custom-plugins"
61
+ ),
62
+ )
63
+
64
+ MANIFEST_FILE_NAME = "CANVAS_MANIFEST.json"
65
+
66
+ SECRETS_FILE_NAME = "SECRETS.json"
File without changes