canvas 0.3.1__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 (76) hide show
  1. {canvas-0.3.1.dist-info → canvas-0.5.0.dist-info}/METADATA +4 -2
  2. {canvas-0.3.1.dist-info → canvas-0.5.0.dist-info}/RECORD +76 -37
  3. canvas_cli/apps/emit/emit.py +1 -1
  4. canvas_cli/apps/logs/logs.py +6 -6
  5. canvas_cli/apps/plugin/plugin.py +11 -7
  6. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +3 -7
  7. canvas_cli/tests.py +12 -5
  8. canvas_cli/utils/context/context.py +2 -2
  9. canvas_cli/utils/context/tests.py +5 -4
  10. canvas_cli/utils/print/print.py +1 -1
  11. canvas_cli/utils/print/tests.py +2 -3
  12. canvas_generated/messages/events_pb2.py +2 -2
  13. canvas_generated/messages/events_pb2.pyi +112 -0
  14. canvas_sdk/base.py +2 -1
  15. canvas_sdk/commands/base.py +25 -25
  16. canvas_sdk/commands/tests/protocol/tests.py +5 -3
  17. canvas_sdk/commands/tests/test_utils.py +8 -44
  18. canvas_sdk/commands/tests/unit/tests.py +3 -3
  19. canvas_sdk/data/client.py +1 -1
  20. canvas_sdk/effects/banner_alert/tests.py +12 -4
  21. canvas_sdk/effects/patient_chart_summary_configuration.py +1 -0
  22. canvas_sdk/effects/protocol_card/protocol_card.py +1 -1
  23. canvas_sdk/effects/protocol_card/tests.py +2 -2
  24. canvas_sdk/protocols/clinical_quality_measure.py +1 -0
  25. canvas_sdk/utils/http.py +2 -2
  26. canvas_sdk/v1/data/common.py +46 -0
  27. canvas_sdk/v1/data/detected_issue.py +52 -0
  28. canvas_sdk/v1/data/imaging.py +102 -0
  29. canvas_sdk/v1/data/lab.py +182 -10
  30. canvas_sdk/v1/data/patient.py +4 -1
  31. canvas_sdk/v1/data/protocol_override.py +58 -0
  32. canvas_sdk/v1/data/questionnaire.py +4 -2
  33. canvas_sdk/value_set/__init__.py +0 -0
  34. canvas_sdk/value_set/tests/test_value_sets.py +9 -6
  35. canvas_sdk/value_set/v2022/__init__.py +0 -0
  36. canvas_sdk/value_set/v2022/intervention.py +0 -24
  37. canvas_sdk/value_set/value_set.py +24 -21
  38. plugin_runner/authentication.py +3 -7
  39. plugin_runner/plugin_runner.py +106 -33
  40. plugin_runner/sandbox.py +23 -9
  41. plugin_runner/tests/__init__.py +0 -0
  42. plugin_runner/tests/data/plugins/.gitkeep +0 -0
  43. plugin_runner/tests/fixtures/plugins/example_plugin/CANVAS_MANIFEST.json +29 -0
  44. plugin_runner/tests/fixtures/plugins/example_plugin/README.md +12 -0
  45. plugin_runner/tests/fixtures/plugins/example_plugin/__init__.py +0 -0
  46. plugin_runner/tests/fixtures/plugins/example_plugin/protocols/__init__.py +0 -0
  47. plugin_runner/tests/fixtures/plugins/example_plugin/protocols/my_protocol.py +18 -0
  48. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/CANVAS_MANIFEST.json +29 -0
  49. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/README.md +12 -0
  50. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/other_module/__init__.py +0 -0
  51. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/other_module/base.py +3 -0
  52. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/protocols/__init__.py +0 -0
  53. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/protocols/my_protocol.py +18 -0
  54. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/CANVAS_MANIFEST.json +29 -0
  55. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/README.md +12 -0
  56. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/other_module/__init__.py +0 -0
  57. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/other_module/base.py +6 -0
  58. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/protocols/__init__.py +0 -0
  59. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/protocols/my_protocol.py +18 -0
  60. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/CANVAS_MANIFEST.json +29 -0
  61. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/README.md +12 -0
  62. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/other_module/__init__.py +0 -0
  63. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/other_module/base.py +8 -0
  64. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/protocols/__init__.py +0 -0
  65. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/protocols/my_protocol.py +18 -0
  66. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/CANVAS_MANIFEST.json +29 -0
  67. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/README.md +12 -0
  68. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/__init__.py +0 -0
  69. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/base.py +3 -0
  70. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/__init__.py +0 -0
  71. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/my_protocol.py +18 -0
  72. plugin_runner/tests/test_plugin_runner.py +208 -0
  73. plugin_runner/tests/test_sandbox.py +113 -0
  74. settings.py +23 -0
  75. {canvas-0.3.1.dist-info → canvas-0.5.0.dist-info}/WHEEL +0 -0
  76. {canvas-0.3.1.dist-info → canvas-0.5.0.dist-info}/entry_points.txt +0 -0
@@ -1,30 +1,6 @@
1
1
  from ..value_set import ValueSet
2
2
 
3
3
 
4
- class HospiceCareAmbulatory(ValueSet):
5
- """
6
- **Clinical Focus:** The purpose of this value set is to represent concepts of interventions to identify patients receiving hospice care outside of a hospital or long term care facility.
7
-
8
- **Data Element Scope:** This value set may use a model element related to Procedure or Intervention.
9
-
10
- **Inclusion Criteria:** Includes concepts that represent a procedure or intervention for hospice care.
11
-
12
- **Exclusion Criteria:** Excludes concepts that represent palliative care or comfort measures.
13
-
14
- ** Used in:** CMS90v11, CMS134v10, CMS165v10, CMS146v10, CMS124v10, CMS139v10, CMS154v10, CMS56v10, CMS74v11, CMS75v10, CMS137v10, CMS136v11, CMS128v10, CMS122v10, CMS153v10, CMS66v10, CMS130v10, CMS155v10, CMS127v10, CMS117v10, CMS131v10, CMS156v10, CMS125v10
15
- """
16
-
17
- VALUE_SET_NAME = "Hospice care ambulatory"
18
- OID = "2.16.840.1.113762.1.4.1108.15"
19
- DEFINITION_VERSION = "20170504"
20
- EXPANSION_VERSION = "eCQM Update 2021-05-06"
21
-
22
- SNOMEDCT = {
23
- "385763009", # Hospice care (regime/therapy)
24
- "385765002", # Hospice care management (procedure)
25
- }
26
-
27
-
28
4
  class PalliativeCareIntervention(ValueSet):
29
5
  """
30
6
  **Clinical Focus:** The purpose of this value set is to represent concepts for palliative care interventions.
@@ -1,5 +1,7 @@
1
1
  from collections import defaultdict
2
- from typing import Dict, Union, cast
2
+ from typing import Union, cast
3
+
4
+ from django.utils.functional import classproperty
3
5
 
4
6
 
5
7
  class CodeConstants:
@@ -33,7 +35,7 @@ class CodeConstants:
33
35
  URL_NDC = "http://hl7.org/fhir/sid/ndc"
34
36
 
35
37
 
36
- class CodeConstantsURLMapping:
38
+ class CodeConstantsURLMappingMixin:
37
39
  """A class that maps code systems to their URLs."""
38
40
 
39
41
  CODE_SYSTEM_MAPPING = {
@@ -53,21 +55,21 @@ class CodeConstantsURLMapping:
53
55
  }
54
56
 
55
57
 
56
- class CombinedValueSet(CodeConstantsURLMapping):
58
+ class CombinedValueSet(CodeConstantsURLMappingMixin):
57
59
  """A class representing a combination of two value sets."""
58
60
 
59
61
  def __init__(
60
62
  self,
61
- value_set_1: Union["ValueSet", "CombinedValueSet"],
62
- value_set_2: Union["ValueSet", "CombinedValueSet"],
63
+ value_set_1: Union[type["ValueSet"], "CombinedValueSet"],
64
+ value_set_2: Union[type["ValueSet"], "CombinedValueSet"],
63
65
  ) -> None:
64
66
  self.value_set_1 = value_set_1
65
67
  self.value_set_2 = value_set_2
66
68
 
67
69
  @property
68
- def values(self) -> Dict[str, set]:
70
+ def values(self) -> dict[str, set]:
69
71
  """A property that returns the combined values from both value sets."""
70
- values: Dict[str, set] = defaultdict(set)
72
+ values: dict[str, set] = defaultdict(set)
71
73
 
72
74
  for vs in [self.value_set_1, self.value_set_2]:
73
75
  sub_values = vs.values
@@ -77,7 +79,7 @@ class CombinedValueSet(CodeConstantsURLMapping):
77
79
 
78
80
  return values
79
81
 
80
- def __or__(self, value_set: Union["ValueSet", "CombinedValueSet"]) -> "CombinedValueSet":
82
+ def __or__(self, value_set: Union[type["ValueSet"], "CombinedValueSet"]) -> "CombinedValueSet":
81
83
  """Implements the `|` (or) operator to combine value sets."""
82
84
  return CombinedValueSet(self, value_set)
83
85
 
@@ -85,22 +87,23 @@ class CombinedValueSet(CodeConstantsURLMapping):
85
87
  class ValueSystems(type):
86
88
  """A metaclass for defining a ValueSet."""
87
89
 
88
- @property
89
- def values(cls) -> dict[str, set]:
90
- """A property that returns a dictionary of code systems and their associated values."""
91
- return {
92
- system: getattr(cls, system)
93
- for system in cast(ValueSet, cls).CODE_SYSTEM_MAPPING.keys()
94
- if hasattr(cls, system)
95
- }
90
+ def __or__(self, value_set: Union[type["ValueSet"], "CombinedValueSet"]) -> "CombinedValueSet": # type: ignore[override]
91
+ """Implements the `|` (or) operator."""
92
+ return CombinedValueSet(cast(type["ValueSet"], self), value_set)
96
93
 
97
- def __or__(self, value_set: Union["ValueSet", "CombinedValueSet"]) -> CombinedValueSet: # type: ignore[override]
94
+ def __ror__(self, value_set: Union[type["ValueSet"], "CombinedValueSet"]) -> "CombinedValueSet": # type: ignore[override]
98
95
  """Implements the `|` (or) operator."""
99
- return CombinedValueSet(cast(ValueSet, self), value_set)
96
+ return self.__or__(value_set)
100
97
 
101
98
 
102
- class ValueSet(CodeConstantsURLMapping, metaclass=ValueSystems):
99
+ class ValueSet(CodeConstantsURLMappingMixin, metaclass=ValueSystems):
103
100
  """The Base class for a ValueSet."""
104
101
 
105
- values: dict[str, set]
106
- pass
102
+ @classproperty
103
+ def values(cls) -> dict[str, set]:
104
+ """A property that returns a dictionary of code systems and their associated values."""
105
+ return {
106
+ system: getattr(cls, system)
107
+ for system in cls.CODE_SYSTEM_MAPPING.keys()
108
+ if hasattr(cls, system)
109
+ }
@@ -1,23 +1,19 @@
1
1
  import os
2
- from typing import cast
3
2
 
4
3
  import arrow
5
4
  from jwt import encode
6
5
 
7
6
  from logger import log
7
+ from settings import PLUGIN_RUNNER_SIGNING_KEY
8
8
 
9
9
  ONE_DAY_IN_MINUTES = 60 * 24
10
10
 
11
- INSECURE_DEFAULT_SIGNING_KEY = "INSECURE_KEY"
12
-
13
11
 
14
12
  def token_for_plugin(
15
13
  plugin_name: str,
16
14
  audience: str,
17
15
  issuer: str = "plugin-runner",
18
- jwt_signing_key: str = cast(
19
- str, os.getenv("PLUGIN_RUNNER_SIGNING_KEY", INSECURE_DEFAULT_SIGNING_KEY)
20
- ),
16
+ jwt_signing_key: str = PLUGIN_RUNNER_SIGNING_KEY,
21
17
  expiration_minutes: int = ONE_DAY_IN_MINUTES,
22
18
  extra_kwargs: dict | None = None,
23
19
  ) -> str:
@@ -27,7 +23,7 @@ def token_for_plugin(
27
23
  if not extra_kwargs:
28
24
  extra_kwargs = {}
29
25
 
30
- if jwt_signing_key == INSECURE_DEFAULT_SIGNING_KEY:
26
+ if not jwt_signing_key:
31
27
  log.warning(
32
28
  "Using an insecure JWT signing key for GraphQL access. Set the PLUGIN_RUNNER_SIGNING_KEY environment variable to avoid this message."
33
29
  )
@@ -1,15 +1,15 @@
1
1
  import asyncio
2
- import importlib.util
3
2
  import json
4
3
  import os
5
4
  import pathlib
5
+ import pkgutil
6
6
  import signal
7
7
  import sys
8
8
  import time
9
9
  import traceback
10
10
  from collections import defaultdict
11
11
  from types import FrameType
12
- from typing import Any, Optional
12
+ from typing import Any, AsyncGenerator, Optional, TypedDict
13
13
 
14
14
  import grpc
15
15
  import statsd
@@ -30,17 +30,7 @@ from logger import log
30
30
  from plugin_runner.authentication import token_for_plugin
31
31
  from plugin_runner.plugin_synchronizer import publish_message
32
32
  from plugin_runner.sandbox import Sandbox
33
-
34
- ENV = os.getenv("ENV", "development")
35
-
36
- IS_PRODUCTION = ENV == "production"
37
-
38
- MANIFEST_FILE_NAME = "CANVAS_MANIFEST.json"
39
-
40
- SECRETS_FILE_NAME = "SECRETS.json"
41
-
42
- # specify a local plugin directory for development
43
- PLUGIN_DIRECTORY = "/plugin-runner/custom-plugins" if IS_PRODUCTION else "./custom-plugins"
33
+ from settings import MANIFEST_FILE_NAME, PLUGIN_DIRECTORY, SECRETS_FILE_NAME
44
34
 
45
35
  # when we import plugins we'll use the module name directly so we need to add the plugin
46
36
  # directory to the path
@@ -51,7 +41,50 @@ sys.path.append(PLUGIN_DIRECTORY)
51
41
  LOADED_PLUGINS: dict = {}
52
42
 
53
43
  # a global dictionary of events to protocol class names
54
- EVENT_PROTOCOL_MAP: dict = {}
44
+ EVENT_PROTOCOL_MAP: dict[str, list] = defaultdict(list)
45
+
46
+
47
+ class DataAccess(TypedDict):
48
+ """DataAccess."""
49
+
50
+ event: str
51
+ read: list[str]
52
+ write: list[str]
53
+
54
+
55
+ Protocol = TypedDict(
56
+ "Protocol",
57
+ {
58
+ "class": str,
59
+ "data_access": DataAccess,
60
+ },
61
+ )
62
+
63
+
64
+ class Components(TypedDict):
65
+ """Components."""
66
+
67
+ protocols: list[Protocol]
68
+ commands: list[dict]
69
+ content: list[dict]
70
+ effects: list[dict]
71
+ views: list[dict]
72
+
73
+
74
+ class PluginManifest(TypedDict):
75
+ """PluginManifest."""
76
+
77
+ sdk_version: str
78
+ plugin_version: str
79
+ name: str
80
+ description: str
81
+ components: Components
82
+ secrets: list[dict]
83
+ tags: dict[str, str]
84
+ references: list[str]
85
+ license: str
86
+ diagram: bool
87
+ readme: str
55
88
 
56
89
 
57
90
  class PluginRunner(PluginRunnerServicer):
@@ -63,11 +96,19 @@ class PluginRunner(PluginRunnerServicer):
63
96
 
64
97
  sandbox: Sandbox
65
98
 
66
- async def HandleEvent(self, request: Event, context: Any) -> EventResponse:
99
+ async def HandleEvent(
100
+ self, request: Event, context: Any
101
+ ) -> AsyncGenerator[EventResponse, None]:
67
102
  """This is invoked when an event comes in."""
68
103
  event_start_time = time.time()
69
- event_name = EventType.Name(request.type)
70
- relevant_plugins = EVENT_PROTOCOL_MAP.get(event_name, [])
104
+ event_type = request.type
105
+ event_name = EventType.Name(event_type)
106
+ relevant_plugins = EVENT_PROTOCOL_MAP[event_name]
107
+
108
+ if event_type in [EventType.PLUGIN_CREATED, EventType.PLUGIN_UPDATED]:
109
+ plugin_name = request.target
110
+ # filter only for the plugin(s) that were created/updated
111
+ relevant_plugins = [p for p in relevant_plugins if p.startswith(f"{plugin_name}:")]
71
112
 
72
113
  effect_list = []
73
114
 
@@ -129,7 +170,7 @@ class PluginRunner(PluginRunnerServicer):
129
170
 
130
171
  async def ReloadPlugins(
131
172
  self, request: ReloadPluginsRequest, context: Any
132
- ) -> ReloadPluginsResponse:
173
+ ) -> AsyncGenerator[ReloadPluginsResponse, None]:
133
174
  """This is invoked when we need to reload plugins."""
134
175
  try:
135
176
  load_plugins()
@@ -146,18 +187,50 @@ def handle_hup_cb(_signum: int, _frame: Optional[FrameType]) -> None:
146
187
  load_plugins()
147
188
 
148
189
 
149
- def sandbox_from_module_name(module_name: str) -> Any:
190
+ def find_modules(base_path: pathlib.Path, prefix: str | None = None) -> list[str]:
191
+ """Find all modules in the specified package path."""
192
+ modules: list[str] = []
193
+
194
+ for file_finder, module_name, is_pkg in pkgutil.iter_modules(
195
+ [base_path.as_posix()],
196
+ ):
197
+ if is_pkg:
198
+ modules = modules + find_modules(
199
+ base_path / module_name,
200
+ prefix=f"{prefix}.{module_name}" if prefix else module_name,
201
+ )
202
+ else:
203
+ modules.append(f"{prefix}.{module_name}" if prefix else module_name)
204
+
205
+ return modules
206
+
207
+
208
+ def sandbox_from_package(package_path: pathlib.Path) -> dict[str, Any]:
209
+ """Sandbox the code execution."""
210
+ package_name = package_path.name
211
+ available_modules = find_modules(package_path)
212
+ sandboxes = {}
213
+
214
+ for module_name in available_modules:
215
+ result = sandbox_from_module(package_path, module_name)
216
+ full_module_name = f"{package_name}.{module_name}"
217
+ sandboxes[full_module_name] = result
218
+
219
+ return sandboxes
220
+
221
+
222
+ def sandbox_from_module(package_path: pathlib.Path, module_name: str) -> Any:
150
223
  """Sandbox the code execution."""
151
- spec = importlib.util.find_spec(module_name)
224
+ module_path = package_path / str(module_name.replace(".", "/") + ".py")
152
225
 
153
- if not spec or not spec.origin:
154
- raise Exception(f'Could not load plugin "{module_name}"')
226
+ if not module_path.exists():
227
+ raise ModuleNotFoundError(f'Could not load module "{module_name}"')
155
228
 
156
- origin = pathlib.Path(spec.origin)
157
- source_code = origin.read_text()
229
+ source_code = module_path.read_text()
158
230
 
159
- sandbox = Sandbox(source_code)
231
+ full_module_name = f"{package_path.name}.{module_name}"
160
232
 
233
+ sandbox = Sandbox(source_code, module_name=full_module_name)
161
234
  return sandbox.execute()
162
235
 
163
236
 
@@ -166,13 +239,13 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
166
239
  log.info(f"Loading {path}")
167
240
 
168
241
  manifest_file = path / MANIFEST_FILE_NAME
169
- manifest_json = manifest_file.read_text()
242
+ manifest_json_str = manifest_file.read_text()
170
243
 
171
244
  # the name is the folder name underneath the plugins directory
172
245
  name = path.name
173
246
 
174
247
  try:
175
- manifest_json = json.loads(manifest_json)
248
+ manifest_json: PluginManifest = json.loads(manifest_json_str)
176
249
  except Exception as e:
177
250
  log.error(f'Unable to load plugin "{name}": {e}')
178
251
  return
@@ -189,6 +262,7 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
189
262
  # TODO add existing schema validation from Michela here
190
263
  try:
191
264
  protocols = manifest_json["components"]["protocols"]
265
+ results = sandbox_from_package(path)
192
266
  except Exception as e:
193
267
  log.error(f'Unable to load plugin "{name}": {str(e)}')
194
268
  return
@@ -207,7 +281,7 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
207
281
  if name_and_class in LOADED_PLUGINS:
208
282
  log.info(f"Reloading plugin '{name_and_class}'")
209
283
 
210
- result = sandbox_from_module_name(protocol_module)
284
+ result = results[protocol_module]
211
285
 
212
286
  LOADED_PLUGINS[name_and_class]["active"] = True
213
287
 
@@ -217,7 +291,7 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
217
291
  else:
218
292
  log.info(f"Loading plugin '{name_and_class}'")
219
293
 
220
- result = sandbox_from_module_name(protocol_module)
294
+ result = results[protocol_module]
221
295
 
222
296
  LOADED_PLUGINS[name_and_class] = {
223
297
  "active": True,
@@ -234,8 +308,7 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
234
308
 
235
309
  def refresh_event_type_map() -> None:
236
310
  """Ensure the event subscriptions are up to date."""
237
- global EVENT_PROTOCOL_MAP
238
- EVENT_PROTOCOL_MAP = defaultdict(list)
311
+ EVENT_PROTOCOL_MAP.clear()
239
312
 
240
313
  for name, plugin in LOADED_PLUGINS.items():
241
314
  if hasattr(plugin["class"], "RESPONDS_TO"):
@@ -264,8 +337,8 @@ def load_plugins(specified_plugin_paths: list[str] | None = None) -> None:
264
337
  for plugin_path in plugin_paths:
265
338
  # when we import plugins we'll use the module name directly so we need to add the plugin
266
339
  # directory to the path
267
- path_to_append = f"./{plugin_path.parent}"
268
- sys.path.append(path_to_append)
340
+ path_to_append = pathlib.Path(".") / plugin_path.parent
341
+ sys.path.append(path_to_append.as_posix())
269
342
  else:
270
343
  candidates = os.listdir(PLUGIN_DIRECTORY)
271
344
 
plugin_runner/sandbox.py CHANGED
@@ -75,12 +75,6 @@ def _is_known_module(name: str) -> bool:
75
75
  return any(name.startswith(m) for m in ALLOWED_MODULES)
76
76
 
77
77
 
78
- def _safe_import(name: str, *args: Any, **kwargs: Any) -> Any:
79
- if not _is_known_module(name):
80
- raise ImportError(f"{name!r} is not an allowed import.")
81
- return __import__(name, *args, **kwargs)
82
-
83
-
84
78
  def _unrestricted(_ob: Any, *args: Any, **kwargs: Any) -> Any:
85
79
  """Return the given object, unmodified."""
86
80
  return _ob
@@ -96,6 +90,7 @@ class Sandbox:
96
90
 
97
91
  source_code: str
98
92
  namespace: str
93
+ module_name: str | None
99
94
 
100
95
  class Transformer(RestrictingNodeTransformer):
101
96
  """A node transformer for customizing the sandbox compiler."""
@@ -113,7 +108,7 @@ class Sandbox:
113
108
  => 'from _a import x' is ok, because '_a' is not added to the scope.
114
109
  """
115
110
  for name in node.names:
116
- if "*" in name.name and not _is_known_module(node.module):
111
+ if "*" in name.name and node.module and not _is_known_module(node.module):
117
112
  self.error(node, '"*" imports are not allowed.')
118
113
  self.check_name(node, name.name)
119
114
  if name.asname:
@@ -204,12 +199,20 @@ class Sandbox:
204
199
  # Impossible Case only ctx Load, Store and Del are defined in ast.
205
200
  raise NotImplementedError(f"Unknown ctx type: {type(node.ctx)}")
206
201
 
207
- def __init__(self, source_code: str, namespace: str | None = None) -> None:
202
+ def __init__(
203
+ self, source_code: str, namespace: str | None = None, module_name: str | None = None
204
+ ) -> None:
208
205
  if source_code is None:
209
206
  raise TypeError("source_code may not be None")
207
+ self.module_name = module_name
210
208
  self.namespace = namespace or "protocols"
211
209
  self.source_code = source_code
212
210
 
211
+ @cached_property
212
+ def package_name(self) -> str | None:
213
+ """Return the root package name."""
214
+ return self.module_name.split(".")[0] if self.module_name else None
215
+
213
216
  @cached_property
214
217
  def scope(self) -> dict[str, Any]:
215
218
  """Return the scope used for evaluation."""
@@ -217,7 +220,7 @@ class Sandbox:
217
220
  "__builtins__": {
218
221
  **safe_builtins.copy(),
219
222
  **utility_builtins.copy(),
220
- "__import__": _safe_import,
223
+ "__import__": self._safe_import,
221
224
  "classmethod": builtins.classmethod,
222
225
  "staticmethod": builtins.staticmethod,
223
226
  "any": builtins.any,
@@ -263,6 +266,17 @@ class Sandbox:
263
266
  """Return warnings encountered when compiling the source code."""
264
267
  return cast(tuple[str, ...], self.compile_result.warnings)
265
268
 
269
+ def _is_known_module(self, name: str) -> bool:
270
+ return bool(
271
+ _is_known_module(name)
272
+ or (self.package_name and name.split(".")[0] == self.package_name)
273
+ )
274
+
275
+ def _safe_import(self, name: str, *args: Any, **kwargs: Any) -> Any:
276
+ if not (self._is_known_module(name)):
277
+ raise ImportError(f"{name!r} is not an allowed import.")
278
+ return __import__(name, *args, **kwargs)
279
+
266
280
  def execute(self) -> dict:
267
281
  """Execute the given code in a restricted sandbox."""
268
282
  if self.errors:
File without changes
File without changes
@@ -0,0 +1,29 @@
1
+ {
2
+ "sdk_version": "0.1.4",
3
+ "plugin_version": "0.0.1",
4
+ "name": "example_plugin",
5
+ "description": "Edit the description in CANVAS_MANIFEST.json",
6
+ "components": {
7
+ "protocols": [
8
+ {
9
+ "class": "example_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
+ example_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,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
+ NARRATIVE_STRING = "I was inserted from my plugin's protocol."
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="Hello, world!")]
@@ -0,0 +1,29 @@
1
+ {
2
+ "sdk_version": "0.1.4",
3
+ "plugin_version": "0.0.1",
4
+ "name": "test_module_imports_outside_plugin_v1",
5
+ "description": "Edit the description in CANVAS_MANIFEST.json",
6
+ "components": {
7
+ "protocols": [
8
+ {
9
+ "class": "test_module_imports_outside_plugin_v1.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_imports_outside_plugin_v1
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,3 @@
1
+ def import_me() -> str:
2
+ """Test method."""
3
+ return "Successfully imported!"
@@ -0,0 +1,18 @@
1
+ from test_module_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_imports_outside_plugin_v2",
5
+ "description": "Edit the description in CANVAS_MANIFEST.json",
6
+ "components": {
7
+ "protocols": [
8
+ {
9
+ "class": "test_module_imports_outside_plugin_v2.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_imports_outside_plugin_v2
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,6 @@
1
+ import os
2
+
3
+
4
+ def import_me() -> list[str]:
5
+ """Test method."""
6
+ return os.listdir(".")