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.
- {canvas-0.4.0.dist-info → canvas-0.6.0.dist-info}/METADATA +4 -2
- {canvas-0.4.0.dist-info → canvas-0.6.0.dist-info}/RECORD +60 -23
- canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +2 -6
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +108 -0
- canvas_sdk/base.py +12 -9
- canvas_sdk/commands/base.py +2 -1
- canvas_sdk/effects/banner_alert/add_banner_alert.py +13 -2
- canvas_sdk/effects/banner_alert/remove_banner_alert.py +2 -2
- canvas_sdk/effects/banner_alert/tests.py +20 -4
- canvas_sdk/effects/base.py +2 -0
- canvas_sdk/effects/patient_chart_summary_configuration.py +1 -0
- canvas_sdk/effects/protocol_card/protocol_card.py +7 -2
- canvas_sdk/effects/protocol_card/tests.py +2 -2
- canvas_sdk/v1/data/base.py +11 -8
- canvas_sdk/v1/data/condition.py +6 -2
- canvas_sdk/v1/data/detected_issue.py +52 -0
- canvas_sdk/v1/data/patient.py +1 -1
- canvas_sdk/v1/data/protocol_override.py +58 -0
- canvas_sdk/value_set/__init__.py +0 -0
- canvas_sdk/value_set/v2022/__init__.py +0 -0
- plugin_runner/authentication.py +3 -7
- plugin_runner/plugin_runner.py +48 -26
- plugin_runner/sandbox.py +23 -8
- plugin_runner/tests/__init__.py +0 -0
- plugin_runner/tests/data/plugins/.gitkeep +0 -0
- plugin_runner/tests/fixtures/plugins/example_plugin/CANVAS_MANIFEST.json +29 -0
- plugin_runner/tests/fixtures/plugins/example_plugin/README.md +12 -0
- plugin_runner/tests/fixtures/plugins/example_plugin/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/example_plugin/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/example_plugin/protocols/my_protocol.py +18 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/CANVAS_MANIFEST.json +29 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/README.md +12 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/other_module/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/other_module/base.py +3 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/protocols/my_protocol.py +18 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/CANVAS_MANIFEST.json +29 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/README.md +12 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/other_module/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/other_module/base.py +6 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v2/protocols/my_protocol.py +18 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/CANVAS_MANIFEST.json +29 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/README.md +12 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/other_module/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/other_module/base.py +8 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v3/protocols/my_protocol.py +18 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/CANVAS_MANIFEST.json +29 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/README.md +12 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/base.py +3 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/my_protocol.py +18 -0
- plugin_runner/tests/test_plugin_runner.py +208 -0
- plugin_runner/tests/test_sandbox.py +113 -0
- settings.py +23 -0
- {canvas-0.4.0.dist-info → canvas-0.6.0.dist-info}/WHEEL +0 -0
- {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
|
),
|
canvas_sdk/effects/base.py
CHANGED
|
@@ -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 {
|
|
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 (
|
canvas_sdk/v1/data/base.py
CHANGED
|
@@ -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) -> "
|
|
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
|
|
17
|
+
return CommittableQuerySet(self.model, using=self._db).filter(deleted=False)
|
|
18
18
|
|
|
19
|
-
|
|
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) -> "
|
|
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(
|
|
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"]) ->
|
|
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
|
|
canvas_sdk/v1/data/condition.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
from django.db import models
|
|
2
2
|
|
|
3
|
-
from canvas_sdk.v1.data.base import
|
|
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 =
|
|
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
|
+
)
|
canvas_sdk/v1/data/patient.py
CHANGED
|
@@ -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
|
plugin_runner/authentication.py
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
)
|
plugin_runner/plugin_runner.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
224
|
+
module_path = package_path / str(module_name.replace(".", "/") + ".py")
|
|
203
225
|
|
|
204
|
-
if not
|
|
205
|
-
raise
|
|
226
|
+
if not module_path.exists():
|
|
227
|
+
raise ModuleNotFoundError(f'Could not load module "{module_name}"')
|
|
206
228
|
|
|
207
|
-
|
|
208
|
-
source_code = origin.read_text()
|
|
229
|
+
source_code = module_path.read_text()
|
|
209
230
|
|
|
210
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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__(
|
|
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.
|
|
File without changes
|
|
File without changes
|
|
@@ -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!")]
|
plugin_runner/tests/fixtures/plugins/test_module_imports_outside_plugin_v1/CANVAS_MANIFEST.json
ADDED
|
@@ -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
|
+
}
|