canvas 0.4.0__py3-none-any.whl → 0.6.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 (60) hide show
  1. {canvas-0.4.0.dist-info → canvas-0.6.0.dist-info}/METADATA +4 -2
  2. {canvas-0.4.0.dist-info → canvas-0.6.0.dist-info}/RECORD +60 -23
  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/base.py +12 -9
  7. canvas_sdk/commands/base.py +2 -1
  8. canvas_sdk/effects/banner_alert/add_banner_alert.py +13 -2
  9. canvas_sdk/effects/banner_alert/remove_banner_alert.py +2 -2
  10. canvas_sdk/effects/banner_alert/tests.py +20 -4
  11. canvas_sdk/effects/base.py +2 -0
  12. canvas_sdk/effects/patient_chart_summary_configuration.py +1 -0
  13. canvas_sdk/effects/protocol_card/protocol_card.py +7 -2
  14. canvas_sdk/effects/protocol_card/tests.py +2 -2
  15. canvas_sdk/v1/data/base.py +11 -8
  16. canvas_sdk/v1/data/condition.py +6 -2
  17. canvas_sdk/v1/data/detected_issue.py +52 -0
  18. canvas_sdk/v1/data/patient.py +1 -1
  19. canvas_sdk/v1/data/protocol_override.py +58 -0
  20. canvas_sdk/value_set/__init__.py +0 -0
  21. canvas_sdk/value_set/v2022/__init__.py +0 -0
  22. plugin_runner/authentication.py +3 -7
  23. plugin_runner/plugin_runner.py +48 -26
  24. plugin_runner/sandbox.py +23 -8
  25. plugin_runner/tests/__init__.py +0 -0
  26. plugin_runner/tests/data/plugins/.gitkeep +0 -0
  27. plugin_runner/tests/fixtures/plugins/example_plugin/CANVAS_MANIFEST.json +29 -0
  28. plugin_runner/tests/fixtures/plugins/example_plugin/README.md +12 -0
  29. plugin_runner/tests/fixtures/plugins/example_plugin/__init__.py +0 -0
  30. plugin_runner/tests/fixtures/plugins/example_plugin/protocols/__init__.py +0 -0
  31. plugin_runner/tests/fixtures/plugins/example_plugin/protocols/my_protocol.py +18 -0
  32. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/CANVAS_MANIFEST.json +29 -0
  33. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/README.md +12 -0
  34. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/other_module/__init__.py +0 -0
  35. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/other_module/base.py +3 -0
  36. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/protocols/__init__.py +0 -0
  37. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/protocols/my_protocol.py +18 -0
  38. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/CANVAS_MANIFEST.json +29 -0
  39. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/README.md +12 -0
  40. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/other_module/__init__.py +0 -0
  41. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/other_module/base.py +6 -0
  42. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/protocols/__init__.py +0 -0
  43. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/protocols/my_protocol.py +18 -0
  44. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/CANVAS_MANIFEST.json +29 -0
  45. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/README.md +12 -0
  46. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/other_module/__init__.py +0 -0
  47. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/other_module/base.py +8 -0
  48. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/protocols/__init__.py +0 -0
  49. plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/protocols/my_protocol.py +18 -0
  50. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/CANVAS_MANIFEST.json +29 -0
  51. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/README.md +12 -0
  52. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/__init__.py +0 -0
  53. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/base.py +3 -0
  54. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/__init__.py +0 -0
  55. plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/my_protocol.py +18 -0
  56. plugin_runner/tests/test_plugin_runner.py +208 -0
  57. plugin_runner/tests/test_sandbox.py +113 -0
  58. settings.py +23 -0
  59. {canvas-0.4.0.dist-info → canvas-0.6.0.dist-info}/WHEEL +0 -0
  60. {canvas-0.4.0.dist-info → canvas-0.6.0.dist-info}/entry_points.txt +0 -0
@@ -178,12 +178,28 @@ def test_protocol_that_adds_banner_alert(
178
178
  "placement": [AddBannerAlert.Placement.APPOINTMENT_CARD],
179
179
  "intent": AddBannerAlert.Intent.INFO,
180
180
  },
181
- '{"patient": "uuid", "key": "test-key", "data": {"narrative": "hellooo", "placement": ["appointment_card"], "intent": "info", "href": null}}',
181
+ '{"patient": "uuid", "patient_filter": null, "key": "test-key", "data": {"narrative": "hellooo", "placement": ["appointment_card"], "intent": "info", "href": null}}',
182
+ ),
183
+ (
184
+ AddBannerAlert,
185
+ {
186
+ "patient_filter": {"active": True},
187
+ "key": "test-key",
188
+ "narrative": "hellooo",
189
+ "placement": [AddBannerAlert.Placement.APPOINTMENT_CARD],
190
+ "intent": AddBannerAlert.Intent.INFO,
191
+ },
192
+ '{"patient": null, "patient_filter": {"active": true}, "key": "test-key", "data": {"narrative": "hellooo", "placement": ["appointment_card"], "intent": "info", "href": null}}',
182
193
  ),
183
194
  (
184
195
  RemoveBannerAlert,
185
196
  {"patient_id": "uuid", "key": "testeroo"},
186
- '{"patient": "uuid", "key": "testeroo"}',
197
+ '{"patient": "uuid", "patient_filter": null, "key": "testeroo"}',
198
+ ),
199
+ (
200
+ RemoveBannerAlert,
201
+ {"patient_filter": {"active": True}, "key": "testeroo"},
202
+ '{"patient": null, "patient_filter": {"active": true}, "key": "testeroo"}',
187
203
  ),
188
204
  ],
189
205
  )
@@ -204,7 +220,7 @@ def test_banner_alert_apply_method_succeeds_with_all_required_fields(
204
220
  AddBannerAlert,
205
221
  [
206
222
  "5 validation errors for AddBannerAlert",
207
- "Field 'patient_id' is required to apply an AddBannerAlert [type=missing",
223
+ "Field 'patient_id' or 'patient_filter' is required to apply an AddBannerAlert [type=missing",
208
224
  "Field 'key' is required to apply an AddBannerAlert [type=missing",
209
225
  "Field 'narrative' is required to apply an AddBannerAlert [type=missing",
210
226
  "Field 'placement' is required to apply an AddBannerAlert [type=missing",
@@ -215,7 +231,7 @@ def test_banner_alert_apply_method_succeeds_with_all_required_fields(
215
231
  RemoveBannerAlert,
216
232
  [
217
233
  "2 validation errors for RemoveBannerAlert",
218
- "Field 'patient_id' is required to apply a RemoveBannerAlert [type=missing",
234
+ "Field 'patient_id' or 'patient_filter' is required to apply a RemoveBannerAlert [type=missing",
219
235
  "Field 'key' is required to apply a RemoveBannerAlert [type=missing",
220
236
  ],
221
237
  ),
@@ -10,6 +10,8 @@ class _BaseEffect(Model):
10
10
  A Canvas Effect that changes user behavior or autonomously performs activities on behalf of users.
11
11
  """
12
12
 
13
+ patient_filter: dict | None = None
14
+
13
15
  class Meta:
14
16
  effect_type = EffectType.UNKNOWN_EFFECT
15
17
 
@@ -25,6 +25,7 @@ class PatientChartSummaryConfiguration(_BaseEffect):
25
25
  IMMUNIZATIONS = "immunizations"
26
26
  SURGICAL_HISTORY = "surgical_history"
27
27
  FAMILY_HISTORY = "family_history"
28
+ CODING_GAPS = "coding_gaps"
28
29
 
29
30
  sections: list[Section] = Field(min_length=1)
30
31
 
@@ -42,7 +42,7 @@ class ProtocolCard(_BaseEffect):
42
42
 
43
43
  class Meta:
44
44
  effect_type = EffectType.ADD_OR_UPDATE_PROTOCOL_CARD
45
- apply_required_fields = ("patient_id", "key")
45
+ apply_required_fields = ("patient_id|patient_filter", "key")
46
46
 
47
47
  patient_id: str | None = None
48
48
  key: str | None = None
@@ -68,7 +68,12 @@ class ProtocolCard(_BaseEffect):
68
68
  @property
69
69
  def effect_payload(self) -> dict[str, Any]:
70
70
  """The payload of the effect."""
71
- return {"patient": self.patient_id, "key": self.key, "data": self.values}
71
+ return {
72
+ "patient": self.patient_id,
73
+ "patient_filter": self.patient_filter,
74
+ "key": self.key,
75
+ "data": self.values,
76
+ }
72
77
 
73
78
  def add_recommendation(
74
79
  self,
@@ -25,7 +25,7 @@ def test_apply_method_succeeds_with_patient_id_and_key() -> None:
25
25
  applied = p.apply()
26
26
  assert (
27
27
  applied.payload
28
- == '{"patient": "uuid", "key": "something-unique", "data": {"title": "", "narrative": "", "recommendations": [], "status": "due", "feedback_enabled": false}}'
28
+ == '{"patient": "uuid", "patient_filter": null, "key": "something-unique", "data": {"title": "", "narrative": "", "recommendations": [], "status": "due", "feedback_enabled": false}}'
29
29
  )
30
30
 
31
31
 
@@ -38,7 +38,7 @@ def test_apply_method_raises_error_without_patient_id_and_key() -> None:
38
38
 
39
39
  assert "2 validation errors for ProtocolCard" in err_msg
40
40
  assert (
41
- "Field 'patient_id' is required to apply a ProtocolCard [type=missing, input_value=None, input_type=NoneType]"
41
+ "Field 'patient_id' or 'patient_filter' is required to apply a ProtocolCard [type=missing, input_value=None, input_type=NoneType]"
42
42
  in err_msg
43
43
  )
44
44
  assert (
@@ -1,5 +1,5 @@
1
1
  from collections.abc import Container
2
- from typing import TYPE_CHECKING, Type, cast
2
+ from typing import TYPE_CHECKING, Self, Type, cast
3
3
 
4
4
  from django.db import models
5
5
  from django.db.models import Q
@@ -11,25 +11,28 @@ if TYPE_CHECKING:
11
11
  class CommittableModelManager(models.Manager):
12
12
  """A manager for commands that can be committed."""
13
13
 
14
- def get_queryset(self) -> "models.QuerySet":
14
+ def get_queryset(self) -> "CommittableQuerySet":
15
15
  """Return a queryset that filters out deleted objects."""
16
16
  # TODO: Should we just filter these out at the view level?
17
- return super().get_queryset().filter(deleted=False)
17
+ return CommittableQuerySet(self.model, using=self._db).filter(deleted=False)
18
18
 
19
- def committed(self) -> "models.QuerySet":
19
+
20
+ class CommittableQuerySet(models.QuerySet):
21
+ """A queryset for committable objects."""
22
+
23
+ def committed(self) -> "Self":
20
24
  """Return a queryset that filters for objects that have been committed."""
21
- # The committer_id IS set, and the entered_in_error_id IS NOT set
22
25
  return self.filter(committer_id__isnull=False, entered_in_error_id__isnull=True)
23
26
 
24
- def for_patient(self, patient_id: str) -> "models.QuerySet":
27
+ def for_patient(self, patient_id: str) -> "Self":
25
28
  """Return a queryset that filters objects for a specific patient."""
26
29
  return self.filter(patient__id=patient_id)
27
30
 
28
31
 
29
- class ValueSetLookupQuerySet(models.QuerySet):
32
+ class ValueSetLookupQuerySet(CommittableQuerySet):
30
33
  """A QuerySet that can filter objects based on a ValueSet."""
31
34
 
32
- def find(self, value_set: Type["ValueSet"]) -> models.QuerySet:
35
+ def find(self, value_set: Type["ValueSet"]) -> "Self":
33
36
  """
34
37
  Filters conditions, medications, etc. to those found in the inherited ValueSet class that is passed.
35
38
 
@@ -1,6 +1,10 @@
1
1
  from django.db import models
2
2
 
3
- from canvas_sdk.v1.data.base import CommittableModelManager, ValueSetLookupQuerySet
3
+ from canvas_sdk.v1.data.base import (
4
+ CommittableModelManager,
5
+ CommittableQuerySet,
6
+ ValueSetLookupQuerySet,
7
+ )
4
8
  from canvas_sdk.v1.data.patient import Patient
5
9
  from canvas_sdk.v1.data.user import CanvasUser
6
10
 
@@ -19,7 +23,7 @@ class Condition(models.Model):
19
23
  app_label = "canvas_sdk"
20
24
  db_table = "canvas_sdk_data_api_condition_001"
21
25
 
22
- objects = CommittableModelManager.from_queryset(ConditionQuerySet)()
26
+ objects = ConditionQuerySet.as_manager()
23
27
 
24
28
  id = models.UUIDField()
25
29
  dbid = models.BigIntegerField(primary_key=True)
@@ -0,0 +1,52 @@
1
+ from django.db import models
2
+
3
+ from canvas_sdk.v1.data import Patient
4
+ from canvas_sdk.v1.data.user import CanvasUser
5
+
6
+
7
+ class DetectedIssue(models.Model):
8
+ """DetectedIssue."""
9
+
10
+ class Meta:
11
+ managed = False
12
+ app_label = "canvas_sdk"
13
+ db_table = "canvas_sdk_data_api_detectedissue_001"
14
+
15
+ id = models.UUIDField()
16
+ dbid = models.BigIntegerField(primary_key=True)
17
+ created = models.DateTimeField()
18
+ modified = models.DateTimeField()
19
+ identified = models.DateTimeField()
20
+ deleted = models.BooleanField()
21
+ originator = models.ForeignKey(CanvasUser, on_delete=models.DO_NOTHING)
22
+ committer = models.ForeignKey(CanvasUser, on_delete=models.DO_NOTHING)
23
+ entered_in_error = models.ForeignKey(CanvasUser, on_delete=models.DO_NOTHING)
24
+ patient = models.ForeignKey(
25
+ Patient, on_delete=models.DO_NOTHING, related_name="detected_issues"
26
+ )
27
+ code = models.CharField()
28
+ status = models.CharField()
29
+ severity = models.CharField()
30
+ reference = models.CharField()
31
+ issue_identifier = models.CharField()
32
+ issue_identifier_system = models.CharField()
33
+ detail = models.TextField()
34
+
35
+
36
+ class DetectedIssueEvidence(models.Model):
37
+ """DetectedIssueEvidence."""
38
+
39
+ class Meta:
40
+ managed = False
41
+ app_label = "canvas_sdk"
42
+ db_table = "canvas_sdk_data_api_detectedissueevidence_001"
43
+
44
+ dbid = models.BigIntegerField(primary_key=True)
45
+ system = models.CharField()
46
+ version = models.CharField()
47
+ code = models.CharField()
48
+ display = models.CharField()
49
+ user_selected = models.BooleanField()
50
+ detected_issue = models.ForeignKey(
51
+ DetectedIssue, on_delete=models.DO_NOTHING, related_name="evidence"
52
+ )
@@ -1,4 +1,4 @@
1
- from typing import Self
1
+ from typing import TYPE_CHECKING, Self
2
2
 
3
3
  from django.db import models
4
4
 
@@ -0,0 +1,58 @@
1
+ from django.db import models
2
+
3
+ from canvas_sdk.v1.data.base import CommittableModelManager
4
+ from canvas_sdk.v1.data.patient import Patient
5
+ from canvas_sdk.v1.data.user import CanvasUser
6
+
7
+
8
+ class IntervalUnit(models.TextChoices):
9
+ """ProtocolOverride cycle IntervalUnit."""
10
+
11
+ DAYS = "days", "days"
12
+ MONTHS = "months", "months"
13
+ YEARS = "years", "years"
14
+
15
+
16
+ class Status(models.TextChoices):
17
+ """ProtocolOverride Status."""
18
+
19
+ ACTIVE = "active", "active"
20
+ INACTIVE = "inactive", "inactive"
21
+
22
+
23
+ class ProtocolOverride(models.Model):
24
+ """ProtocolOverride."""
25
+
26
+ class Meta:
27
+ managed = False
28
+ app_label = "canvas_sdk"
29
+ db_table = "canvas_sdk_data_api_protocoloverride_001"
30
+
31
+ objects = CommittableModelManager()
32
+
33
+ id = models.UUIDField()
34
+ dbid = models.BigIntegerField(primary_key=True)
35
+ created = models.DateTimeField()
36
+ modified = models.DateTimeField()
37
+ deleted = models.BooleanField()
38
+ committer = models.ForeignKey(CanvasUser, on_delete=models.DO_NOTHING)
39
+ entered_in_error = models.ForeignKey(CanvasUser, on_delete=models.DO_NOTHING)
40
+ patient = models.ForeignKey(
41
+ Patient,
42
+ on_delete=models.DO_NOTHING,
43
+ related_name="protocol_overrides",
44
+ )
45
+ protocol_key = models.CharField()
46
+ is_adjustment = models.BooleanField()
47
+ reference_date = models.DateTimeField()
48
+ cycle_in_days = models.IntegerField()
49
+ is_snooze = models.BooleanField()
50
+ snooze_date = models.DateField()
51
+ snoozed_days = models.IntegerField()
52
+ # reason_id = models.BigIntegerField()
53
+ snooze_comment = models.TextField()
54
+ narrative = models.CharField()
55
+ # note_id = models.BigIntegerField()
56
+ cycle_quantity = models.IntegerField()
57
+ cycle_unit = models.CharField(choices=IntervalUnit.choices)
58
+ status = models.CharField(choices=Status.choices)
File without changes
File without changes
@@ -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, AsyncGenerator, Optional, TypedDict, cast
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,7 @@ 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)
55
45
 
56
46
 
57
47
  class DataAccess(TypedDict):
@@ -113,7 +103,7 @@ class PluginRunner(PluginRunnerServicer):
113
103
  event_start_time = time.time()
114
104
  event_type = request.type
115
105
  event_name = EventType.Name(event_type)
116
- relevant_plugins = EVENT_PROTOCOL_MAP.get(event_name, [])
106
+ relevant_plugins = EVENT_PROTOCOL_MAP[event_name]
117
107
 
118
108
  if event_type in [EventType.PLUGIN_CREATED, EventType.PLUGIN_UPDATED]:
119
109
  plugin_name = request.target
@@ -197,18 +187,50 @@ def handle_hup_cb(_signum: int, _frame: Optional[FrameType]) -> None:
197
187
  load_plugins()
198
188
 
199
189
 
200
- 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:
201
223
  """Sandbox the code execution."""
202
- spec = importlib.util.find_spec(module_name)
224
+ module_path = package_path / str(module_name.replace(".", "/") + ".py")
203
225
 
204
- if not spec or not spec.origin:
205
- 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}"')
206
228
 
207
- origin = pathlib.Path(spec.origin)
208
- source_code = origin.read_text()
229
+ source_code = module_path.read_text()
209
230
 
210
- sandbox = Sandbox(source_code)
231
+ full_module_name = f"{package_path.name}.{module_name}"
211
232
 
233
+ sandbox = Sandbox(source_code, module_name=full_module_name)
212
234
  return sandbox.execute()
213
235
 
214
236
 
@@ -240,6 +262,7 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
240
262
  # TODO add existing schema validation from Michela here
241
263
  try:
242
264
  protocols = manifest_json["components"]["protocols"]
265
+ results = sandbox_from_package(path)
243
266
  except Exception as e:
244
267
  log.error(f'Unable to load plugin "{name}": {str(e)}')
245
268
  return
@@ -258,7 +281,7 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
258
281
  if name_and_class in LOADED_PLUGINS:
259
282
  log.info(f"Reloading plugin '{name_and_class}'")
260
283
 
261
- result = sandbox_from_module_name(protocol_module)
284
+ result = results[protocol_module]
262
285
 
263
286
  LOADED_PLUGINS[name_and_class]["active"] = True
264
287
 
@@ -268,7 +291,7 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
268
291
  else:
269
292
  log.info(f"Loading plugin '{name_and_class}'")
270
293
 
271
- result = sandbox_from_module_name(protocol_module)
294
+ result = results[protocol_module]
272
295
 
273
296
  LOADED_PLUGINS[name_and_class] = {
274
297
  "active": True,
@@ -285,8 +308,7 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
285
308
 
286
309
  def refresh_event_type_map() -> None:
287
310
  """Ensure the event subscriptions are up to date."""
288
- global EVENT_PROTOCOL_MAP
289
- EVENT_PROTOCOL_MAP = defaultdict(list)
311
+ EVENT_PROTOCOL_MAP.clear()
290
312
 
291
313
  for name, plugin in LOADED_PLUGINS.items():
292
314
  if hasattr(plugin["class"], "RESPONDS_TO"):
plugin_runner/sandbox.py CHANGED
@@ -59,6 +59,7 @@ ALLOWED_MODULES = frozenset(
59
59
  "operator",
60
60
  "pickletools",
61
61
  "random",
62
+ "rapidfuzz",
62
63
  "re",
63
64
  "requests",
64
65
  "string",
@@ -75,12 +76,6 @@ def _is_known_module(name: str) -> bool:
75
76
  return any(name.startswith(m) for m in ALLOWED_MODULES)
76
77
 
77
78
 
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
79
  def _unrestricted(_ob: Any, *args: Any, **kwargs: Any) -> Any:
85
80
  """Return the given object, unmodified."""
86
81
  return _ob
@@ -96,6 +91,7 @@ class Sandbox:
96
91
 
97
92
  source_code: str
98
93
  namespace: str
94
+ module_name: str | None
99
95
 
100
96
  class Transformer(RestrictingNodeTransformer):
101
97
  """A node transformer for customizing the sandbox compiler."""
@@ -204,12 +200,20 @@ class Sandbox:
204
200
  # Impossible Case only ctx Load, Store and Del are defined in ast.
205
201
  raise NotImplementedError(f"Unknown ctx type: {type(node.ctx)}")
206
202
 
207
- def __init__(self, source_code: str, namespace: str | None = None) -> None:
203
+ def __init__(
204
+ self, source_code: str, namespace: str | None = None, module_name: str | None = None
205
+ ) -> None:
208
206
  if source_code is None:
209
207
  raise TypeError("source_code may not be None")
208
+ self.module_name = module_name
210
209
  self.namespace = namespace or "protocols"
211
210
  self.source_code = source_code
212
211
 
212
+ @cached_property
213
+ def package_name(self) -> str | None:
214
+ """Return the root package name."""
215
+ return self.module_name.split(".")[0] if self.module_name else None
216
+
213
217
  @cached_property
214
218
  def scope(self) -> dict[str, Any]:
215
219
  """Return the scope used for evaluation."""
@@ -217,7 +221,7 @@ class Sandbox:
217
221
  "__builtins__": {
218
222
  **safe_builtins.copy(),
219
223
  **utility_builtins.copy(),
220
- "__import__": _safe_import,
224
+ "__import__": self._safe_import,
221
225
  "classmethod": builtins.classmethod,
222
226
  "staticmethod": builtins.staticmethod,
223
227
  "any": builtins.any,
@@ -263,6 +267,17 @@ class Sandbox:
263
267
  """Return warnings encountered when compiling the source code."""
264
268
  return cast(tuple[str, ...], self.compile_result.warnings)
265
269
 
270
+ def _is_known_module(self, name: str) -> bool:
271
+ return bool(
272
+ _is_known_module(name)
273
+ or (self.package_name and name.split(".")[0] == self.package_name)
274
+ )
275
+
276
+ def _safe_import(self, name: str, *args: Any, **kwargs: Any) -> Any:
277
+ if not (self._is_known_module(name)):
278
+ raise ImportError(f"{name!r} is not an allowed import.")
279
+ return __import__(name, *args, **kwargs)
280
+
266
281
  def execute(self) -> dict:
267
282
  """Execute the given code in a restricted sandbox."""
268
283
  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
+ }