canvas 0.13.3__py3-none-any.whl → 0.15.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.13.3.dist-info → canvas-0.15.0.dist-info}/METADATA +1 -3
- {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/RECORD +69 -46
- canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +1 -1
- canvas_generated/messages/effects_pb2.py +2 -2
- canvas_generated/messages/effects_pb2.pyi +6 -0
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +8 -0
- canvas_sdk/commands/tests/protocol/tests.py +3 -1
- canvas_sdk/commands/tests/test_utils.py +76 -18
- canvas_sdk/effects/banner_alert/tests.py +41 -20
- canvas_sdk/effects/questionnaire_result.py +29 -0
- canvas_sdk/events/base.py +1 -3
- canvas_sdk/handlers/action_button.py +5 -2
- canvas_sdk/handlers/application.py +1 -1
- canvas_sdk/handlers/cron_task.py +1 -1
- canvas_sdk/protocols/clinical_quality_measure.py +1 -1
- canvas_sdk/v1/apps.py +7 -0
- canvas_sdk/v1/data/__init__.py +75 -4
- canvas_sdk/v1/data/allergy_intolerance.py +3 -7
- canvas_sdk/v1/data/billing.py +2 -5
- canvas_sdk/v1/data/command.py +19 -8
- canvas_sdk/v1/data/condition.py +3 -7
- canvas_sdk/v1/data/detected_issue.py +4 -9
- canvas_sdk/v1/data/device.py +4 -8
- canvas_sdk/v1/data/imaging.py +12 -17
- canvas_sdk/v1/data/lab.py +16 -29
- canvas_sdk/v1/data/medication.py +3 -7
- canvas_sdk/v1/data/note.py +7 -14
- canvas_sdk/v1/data/observation.py +4 -11
- canvas_sdk/v1/data/organization.py +1 -2
- canvas_sdk/v1/data/patient.py +0 -1
- canvas_sdk/v1/data/practicelocation.py +2 -4
- canvas_sdk/v1/data/protocol_override.py +3 -6
- canvas_sdk/v1/data/questionnaire.py +5 -15
- canvas_sdk/v1/data/staff.py +5 -7
- canvas_sdk/v1/data/task.py +5 -11
- canvas_sdk/v1/data/user.py +0 -1
- canvas_sdk/v1/models.py +4 -0
- plugin_runner/aws_headers.py +77 -0
- plugin_runner/plugin_installer.py +26 -8
- plugin_runner/plugin_runner.py +5 -25
- plugin_runner/sandbox.py +105 -9
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/CANVAS_MANIFEST.json +38 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/README.md +11 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/protocols/my_protocol.py +33 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/templates/__init__.py +3 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/templates/base.py +6 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/utils/__init__.py +5 -0
- plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/utils/base.py +4 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/CANVAS_MANIFEST.json +29 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/README.md +12 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/other_module/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/other_module/base.py +10 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/protocols/my_protocol.py +18 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/CANVAS_MANIFEST.json +29 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/README.md +12 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/other_module/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/other_module/base.py +10 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/protocols/__init__.py +0 -0
- plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/protocols/my_protocol.py +18 -0
- plugin_runner/tests/test_application.py +9 -9
- plugin_runner/tests/test_plugin_installer.py +22 -13
- plugin_runner/tests/test_plugin_runner.py +171 -32
- plugin_runner/tests/test_sandbox.py +18 -14
- settings.py +7 -1
- canvas_sdk/models/__init__.py +0 -7
- {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/WHEEL +0 -0
- {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/entry_points.txt +0 -0
|
@@ -3,12 +3,10 @@ from collections.abc import Container
|
|
|
3
3
|
from django.db import models
|
|
4
4
|
from django.db.models import Q
|
|
5
5
|
|
|
6
|
-
from canvas_sdk.v1.data import Patient
|
|
7
6
|
from canvas_sdk.v1.data.base import (
|
|
8
7
|
CommittableModelManager,
|
|
9
8
|
ValueSetLookupByNameQuerySet,
|
|
10
9
|
)
|
|
11
|
-
from canvas_sdk.v1.data.user import CanvasUser
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
class ResponseOptionSet(models.Model):
|
|
@@ -16,7 +14,6 @@ class ResponseOptionSet(models.Model):
|
|
|
16
14
|
|
|
17
15
|
class Meta:
|
|
18
16
|
managed = False
|
|
19
|
-
app_label = "canvas_sdk"
|
|
20
17
|
db_table = "canvas_sdk_data_api_responseoptionset_001"
|
|
21
18
|
|
|
22
19
|
dbid = models.BigIntegerField(primary_key=True)
|
|
@@ -35,7 +32,6 @@ class ResponseOption(models.Model):
|
|
|
35
32
|
|
|
36
33
|
class Meta:
|
|
37
34
|
managed = False
|
|
38
|
-
app_label = "canvas_sdk"
|
|
39
35
|
db_table = "canvas_sdk_data_api_responseoption_001"
|
|
40
36
|
|
|
41
37
|
dbid = models.BigIntegerField(primary_key=True)
|
|
@@ -57,7 +53,6 @@ class Question(models.Model):
|
|
|
57
53
|
|
|
58
54
|
class Meta:
|
|
59
55
|
managed = False
|
|
60
|
-
app_label = "canvas_sdk"
|
|
61
56
|
db_table = "canvas_sdk_data_api_question_001"
|
|
62
57
|
|
|
63
58
|
id = models.UUIDField()
|
|
@@ -89,7 +84,6 @@ class Questionnaire(models.Model):
|
|
|
89
84
|
|
|
90
85
|
class Meta:
|
|
91
86
|
managed = False
|
|
92
|
-
app_label = "canvas_sdk"
|
|
93
87
|
db_table = "canvas_sdk_data_api_questionnaire_001"
|
|
94
88
|
|
|
95
89
|
objects = models.Manager.from_queryset(QuestionnaireValueSetLookupQuerySet)()
|
|
@@ -109,7 +103,7 @@ class Questionnaire(models.Model):
|
|
|
109
103
|
code_system = models.CharField()
|
|
110
104
|
code = models.CharField()
|
|
111
105
|
search_tags = models.CharField()
|
|
112
|
-
questions = models.ManyToManyField(Question, through="
|
|
106
|
+
questions = models.ManyToManyField(Question, through="v1.QuestionnaireQuestionMap") # type: ignore[misc, var-annotated]
|
|
113
107
|
use_in_shx = models.BooleanField()
|
|
114
108
|
carry_forward = models.TextField()
|
|
115
109
|
|
|
@@ -119,7 +113,6 @@ class QuestionnaireQuestionMap(models.Model):
|
|
|
119
113
|
|
|
120
114
|
class Meta:
|
|
121
115
|
managed = False
|
|
122
|
-
app_label = "canvas_sdk"
|
|
123
116
|
db_table = "canvas_sdk_data_api_questionnairequestionmap_001"
|
|
124
117
|
|
|
125
118
|
dbid = models.BigIntegerField(primary_key=True)
|
|
@@ -135,7 +128,6 @@ class Interview(models.Model):
|
|
|
135
128
|
|
|
136
129
|
class Meta:
|
|
137
130
|
managed = False
|
|
138
|
-
app_label = "canvas_sdk"
|
|
139
131
|
db_table = "canvas_sdk_data_api_interview_001"
|
|
140
132
|
|
|
141
133
|
objects = CommittableModelManager()
|
|
@@ -143,20 +135,20 @@ class Interview(models.Model):
|
|
|
143
135
|
id = models.UUIDField()
|
|
144
136
|
dbid = models.BigIntegerField(primary_key=True)
|
|
145
137
|
deleted = models.BooleanField()
|
|
146
|
-
committer = models.ForeignKey(CanvasUser, on_delete=models.DO_NOTHING, null=True)
|
|
147
|
-
entered_in_error = models.ForeignKey(CanvasUser, on_delete=models.DO_NOTHING, null=True)
|
|
138
|
+
committer = models.ForeignKey("v1.CanvasUser", on_delete=models.DO_NOTHING, null=True)
|
|
139
|
+
entered_in_error = models.ForeignKey("v1.CanvasUser", on_delete=models.DO_NOTHING, null=True)
|
|
148
140
|
status = models.CharField()
|
|
149
141
|
name = models.CharField()
|
|
150
142
|
language_id = models.BigIntegerField()
|
|
151
143
|
use_case_in_charting = models.CharField()
|
|
152
144
|
patient = models.ForeignKey(
|
|
153
|
-
Patient, on_delete=models.DO_NOTHING, related_name="interviews", null=True
|
|
145
|
+
"v1.Patient", on_delete=models.DO_NOTHING, related_name="interviews", null=True
|
|
154
146
|
)
|
|
155
147
|
note_id = models.BigIntegerField()
|
|
156
148
|
appointment_id = models.BigIntegerField()
|
|
157
149
|
questionnaires = models.ManyToManyField( # type: ignore[var-annotated]
|
|
158
150
|
Questionnaire,
|
|
159
|
-
through="
|
|
151
|
+
through="v1.InterviewQuestionnaireMap", # type: ignore[misc]
|
|
160
152
|
)
|
|
161
153
|
progress_status = models.CharField()
|
|
162
154
|
created = models.DateTimeField()
|
|
@@ -168,7 +160,6 @@ class InterviewQuestionnaireMap(models.Model):
|
|
|
168
160
|
|
|
169
161
|
class Meta:
|
|
170
162
|
managed = False
|
|
171
|
-
app_label = "canvas_sdk"
|
|
172
163
|
db_table = "canvas_sdk_data_api_interviewquestionnairemap_001"
|
|
173
164
|
|
|
174
165
|
dbid = models.BigIntegerField(primary_key=True)
|
|
@@ -184,7 +175,6 @@ class InterviewQuestionResponse(models.Model):
|
|
|
184
175
|
|
|
185
176
|
class Meta:
|
|
186
177
|
managed = False
|
|
187
|
-
app_label = "canvas_sdk"
|
|
188
178
|
db_table = "canvas_sdk_data_api_interviewquestionresponse_001"
|
|
189
179
|
|
|
190
180
|
dbid = models.BigIntegerField(primary_key=True)
|
canvas_sdk/v1/data/staff.py
CHANGED
|
@@ -3,7 +3,6 @@ from django.db import models
|
|
|
3
3
|
from timezone_utils.fields import TimeZoneField
|
|
4
4
|
|
|
5
5
|
from canvas_sdk.v1.data.common import PersonSex, TaxIDType
|
|
6
|
-
from canvas_sdk.v1.data.user import CanvasUser
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
class Staff(models.Model):
|
|
@@ -11,7 +10,6 @@ class Staff(models.Model):
|
|
|
11
10
|
|
|
12
11
|
class Meta:
|
|
13
12
|
managed = False
|
|
14
|
-
app_label = "canvas_sdk"
|
|
15
13
|
db_table = "canvas_sdk_data_api_staff_001"
|
|
16
14
|
|
|
17
15
|
def __str__(self) -> str:
|
|
@@ -44,7 +42,7 @@ class Staff(models.Model):
|
|
|
44
42
|
active = models.BooleanField()
|
|
45
43
|
# TODO - uncomment when PracticeLocation field is developed
|
|
46
44
|
# primary_practice_location = models.ForeignKey(
|
|
47
|
-
# PracticeLocation, on_delete=models.DO_NOTHING, null=True
|
|
45
|
+
# 'v1.PracticeLocation', on_delete=models.DO_NOTHING, null=True
|
|
48
46
|
# )
|
|
49
47
|
npi_number = models.CharField()
|
|
50
48
|
nadean_number = models.CharField()
|
|
@@ -54,12 +52,12 @@ class Staff(models.Model):
|
|
|
54
52
|
tax_id_type = models.CharField(choices=TaxIDType.choices)
|
|
55
53
|
spi_number = models.CharField()
|
|
56
54
|
# TODO - uncomment when Language is developed
|
|
57
|
-
# language = models.ForeignKey(Language, on_delete=models.DO_NOTHING, related_name="staff_speakers", null=True)
|
|
58
|
-
# language_secondary = models.ForeignKey(Language, on_delete=models.DO_NOTHING, related_name="staff_secondary_speakers", null=True)
|
|
55
|
+
# language = models.ForeignKey('v1.Language', on_delete=models.DO_NOTHING, related_name="staff_speakers", null=True)
|
|
56
|
+
# language_secondary = models.ForeignKey('v1.Language', on_delete=models.DO_NOTHING, related_name="staff_secondary_speakers", null=True)
|
|
59
57
|
personal_meeting_room_link = models.URLField(null=True)
|
|
60
58
|
state = models.JSONField()
|
|
61
|
-
user = models.ForeignKey(CanvasUser, on_delete=models.DO_NOTHING, null=True)
|
|
59
|
+
user = models.ForeignKey("v1.CanvasUser", on_delete=models.DO_NOTHING, null=True)
|
|
62
60
|
schedule_column_ordering = models.IntegerField()
|
|
63
61
|
default_supervising_provider = models.ForeignKey(
|
|
64
|
-
"Staff", on_delete=models.DO_NOTHING, related_name="supervising_team", null=True
|
|
62
|
+
"v1.Staff", on_delete=models.DO_NOTHING, related_name="supervising_team", null=True
|
|
65
63
|
)
|
canvas_sdk/v1/data/task.py
CHANGED
|
@@ -2,8 +2,6 @@ from django.contrib.postgres.fields import ArrayField
|
|
|
2
2
|
from django.db import models
|
|
3
3
|
|
|
4
4
|
from canvas_sdk.v1.data.common import ColorEnum, Origin
|
|
5
|
-
from canvas_sdk.v1.data.patient import Patient
|
|
6
|
-
from canvas_sdk.v1.data.staff import Staff
|
|
7
5
|
|
|
8
6
|
|
|
9
7
|
class TaskType(models.TextChoices):
|
|
@@ -39,7 +37,6 @@ class Task(models.Model):
|
|
|
39
37
|
|
|
40
38
|
class Meta:
|
|
41
39
|
managed = False
|
|
42
|
-
app_label = "canvas_sdk"
|
|
43
40
|
db_table = "canvas_sdk_data_api_task_001"
|
|
44
41
|
|
|
45
42
|
id = models.UUIDField()
|
|
@@ -47,15 +44,15 @@ class Task(models.Model):
|
|
|
47
44
|
created = models.DateTimeField()
|
|
48
45
|
modified = models.DateTimeField()
|
|
49
46
|
creator = models.ForeignKey(
|
|
50
|
-
Staff, on_delete=models.DO_NOTHING, related_name="creator_tasks", null=True
|
|
47
|
+
"v1.Staff", on_delete=models.DO_NOTHING, related_name="creator_tasks", null=True
|
|
51
48
|
)
|
|
52
49
|
assignee = models.ForeignKey(
|
|
53
|
-
Staff, on_delete=models.DO_NOTHING, related_name="assignee_tasks", null=True
|
|
50
|
+
"v1.Staff", on_delete=models.DO_NOTHING, related_name="assignee_tasks", null=True
|
|
54
51
|
)
|
|
55
52
|
# TODO - uncomment when Team model is created
|
|
56
|
-
# team = models.ForeignKey(Team, on_delete=models.DO_NOTHING, related_name="tasks", null=True)
|
|
53
|
+
# team = models.ForeignKey('v1.Team', on_delete=models.DO_NOTHING, related_name="tasks", null=True)
|
|
57
54
|
patient = models.ForeignKey(
|
|
58
|
-
Patient, on_delete=models.DO_NOTHING, blank=True, related_name="tasks", null=True
|
|
55
|
+
"v1.Patient", on_delete=models.DO_NOTHING, blank=True, related_name="tasks", null=True
|
|
59
56
|
)
|
|
60
57
|
task_type = models.CharField(choices=TaskType.choices)
|
|
61
58
|
tag = models.CharField()
|
|
@@ -70,7 +67,6 @@ class TaskComment(models.Model):
|
|
|
70
67
|
|
|
71
68
|
class Meta:
|
|
72
69
|
managed = False
|
|
73
|
-
app_label = "canvas_sdk"
|
|
74
70
|
db_table = "canvas_sdk_data_api_taskcomment_001"
|
|
75
71
|
|
|
76
72
|
id = models.UUIDField()
|
|
@@ -78,7 +74,7 @@ class TaskComment(models.Model):
|
|
|
78
74
|
created = models.DateTimeField()
|
|
79
75
|
modified = models.DateTimeField()
|
|
80
76
|
creator = models.ForeignKey(
|
|
81
|
-
Staff, on_delete=models.DO_NOTHING, related_name="comments", null=True
|
|
77
|
+
"v1.Staff", on_delete=models.DO_NOTHING, related_name="comments", null=True
|
|
82
78
|
)
|
|
83
79
|
task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, related_name="comments", null=True)
|
|
84
80
|
body = models.TextField()
|
|
@@ -89,7 +85,6 @@ class TaskLabel(models.Model):
|
|
|
89
85
|
|
|
90
86
|
class Meta:
|
|
91
87
|
managed = False
|
|
92
|
-
app_label = "canvas_sdk"
|
|
93
88
|
db_table = "canvas_sdk_data_api_tasklabel_001"
|
|
94
89
|
|
|
95
90
|
id = models.UUIDField()
|
|
@@ -108,7 +103,6 @@ class TaskTaskLabel(models.Model):
|
|
|
108
103
|
|
|
109
104
|
class Meta:
|
|
110
105
|
managed = False
|
|
111
|
-
app_label = "canvas_sdk"
|
|
112
106
|
db_table = "canvas_sdk_data_api_tasktasklabel_001"
|
|
113
107
|
|
|
114
108
|
dbid = models.BigIntegerField(primary_key=True)
|
canvas_sdk/v1/data/user.py
CHANGED
canvas_sdk/v1/models.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Shamelessly cribbed from https://charemza.name/blog/posts/aws/python/you-might-not-need-boto-3/
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import urllib.parse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def aws_sig_v4_headers(
|
|
10
|
+
access_key_id: str,
|
|
11
|
+
secret_access_key: str,
|
|
12
|
+
pre_auth_headers: dict[str, str],
|
|
13
|
+
service: str,
|
|
14
|
+
region: str,
|
|
15
|
+
host: str,
|
|
16
|
+
method: str,
|
|
17
|
+
path: str,
|
|
18
|
+
query: dict[str, str],
|
|
19
|
+
payload: bytes,
|
|
20
|
+
) -> dict[str, str]:
|
|
21
|
+
"""Constructs signed headers for use in requests made to AWS."""
|
|
22
|
+
algorithm = "AWS4-HMAC-SHA256"
|
|
23
|
+
now = datetime.datetime.utcnow()
|
|
24
|
+
amzdate = now.strftime("%Y%m%dT%H%M%SZ")
|
|
25
|
+
datestamp = now.strftime("%Y%m%d")
|
|
26
|
+
payload_hash = hashlib.sha256(payload).hexdigest()
|
|
27
|
+
credential_scope = f"{datestamp}/{region}/{service}/aws4_request"
|
|
28
|
+
|
|
29
|
+
pre_auth_headers_lower = {
|
|
30
|
+
header_key.lower(): " ".join(header_value.split())
|
|
31
|
+
for header_key, header_value in pre_auth_headers.items()
|
|
32
|
+
}
|
|
33
|
+
required_headers = {
|
|
34
|
+
"host": host,
|
|
35
|
+
"x-amz-content-sha256": payload_hash,
|
|
36
|
+
"x-amz-date": amzdate,
|
|
37
|
+
}
|
|
38
|
+
headers = {**pre_auth_headers_lower, **required_headers}
|
|
39
|
+
header_keys = sorted(headers.keys())
|
|
40
|
+
signed_headers = ";".join(header_keys)
|
|
41
|
+
|
|
42
|
+
def signature() -> str:
|
|
43
|
+
def canonical_request() -> str:
|
|
44
|
+
canonical_uri = urllib.parse.quote(path, safe="/~")
|
|
45
|
+
quoted_query = sorted(
|
|
46
|
+
(urllib.parse.quote(key, safe="~"), urllib.parse.quote(value, safe="~"))
|
|
47
|
+
for key, value in query.items()
|
|
48
|
+
)
|
|
49
|
+
canonical_querystring = "&".join(f"{key}={value}" for key, value in quoted_query)
|
|
50
|
+
canonical_headers = "".join(f"{key}:{headers[key]}\n" for key in header_keys)
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
f"{method}\n{canonical_uri}\n{canonical_querystring}\n"
|
|
54
|
+
+ f"{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def sign(key: bytes, msg: str) -> bytes:
|
|
58
|
+
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
59
|
+
|
|
60
|
+
string_to_sign = (
|
|
61
|
+
f"{algorithm}\n{amzdate}\n{credential_scope}\n"
|
|
62
|
+
+ hashlib.sha256(canonical_request().encode("utf-8")).hexdigest()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
date_key = sign(("AWS4" + secret_access_key).encode("utf-8"), datestamp)
|
|
66
|
+
region_key = sign(date_key, region)
|
|
67
|
+
service_key = sign(region_key, service)
|
|
68
|
+
request_key = sign(service_key, "aws4_request")
|
|
69
|
+
return sign(request_key, string_to_sign).hex()
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
**pre_auth_headers,
|
|
73
|
+
"x-amz-date": amzdate,
|
|
74
|
+
"x-amz-content-sha256": payload_hash,
|
|
75
|
+
"Authorization": f"{algorithm} Credential={access_key_id}/{credential_scope}, "
|
|
76
|
+
f"SignedHeaders={signed_headers}, Signature=" + signature(),
|
|
77
|
+
}
|
|
@@ -9,12 +9,13 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any, TypedDict
|
|
10
10
|
from urllib import parse
|
|
11
11
|
|
|
12
|
-
import boto3
|
|
13
12
|
import psycopg
|
|
13
|
+
import requests
|
|
14
14
|
from psycopg import Connection
|
|
15
15
|
from psycopg.rows import dict_row
|
|
16
16
|
|
|
17
17
|
import settings
|
|
18
|
+
from plugin_runner.aws_headers import aws_sig_v4_headers
|
|
18
19
|
from plugin_runner.exceptions import InvalidPluginFormat, PluginInstallationError
|
|
19
20
|
|
|
20
21
|
# Plugin "packages" include this prefix in the database record for the plugin and the S3 bucket key.
|
|
@@ -96,19 +97,36 @@ def _extract_rows_to_dict(rows: list) -> dict[str, PluginAttributes]:
|
|
|
96
97
|
|
|
97
98
|
|
|
98
99
|
@contextmanager
|
|
99
|
-
def download_plugin(plugin_package: str) -> Generator:
|
|
100
|
+
def download_plugin(plugin_package: str) -> Generator[Path, None, None]:
|
|
100
101
|
"""Download the plugin package from the S3 bucket."""
|
|
101
|
-
|
|
102
|
+
method = "GET"
|
|
103
|
+
host = f"s3-{settings.AWS_REGION}.amazonaws.com"
|
|
104
|
+
bucket = settings.MEDIA_S3_BUCKET_NAME
|
|
105
|
+
customer_identifier = settings.CUSTOMER_IDENTIFIER
|
|
106
|
+
path = f"/{bucket}/{customer_identifier}/{plugin_package}"
|
|
107
|
+
payload = b"This is required for the AWS headers because it is part of the signature"
|
|
108
|
+
pre_auth_headers: dict[str, str] = {}
|
|
109
|
+
query: dict[str, str] = {}
|
|
110
|
+
headers = aws_sig_v4_headers(
|
|
111
|
+
settings.AWS_ACCESS_KEY_ID,
|
|
112
|
+
settings.AWS_SECRET_ACCESS_KEY,
|
|
113
|
+
pre_auth_headers,
|
|
114
|
+
"s3",
|
|
115
|
+
settings.AWS_REGION,
|
|
116
|
+
host,
|
|
117
|
+
method,
|
|
118
|
+
path,
|
|
119
|
+
query,
|
|
120
|
+
payload,
|
|
121
|
+
)
|
|
122
|
+
|
|
102
123
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
103
124
|
prefix_dir = Path(temp_dir) / UPLOAD_TO_PREFIX
|
|
104
125
|
prefix_dir.mkdir() # create an intermediate directory reflecting the prefix
|
|
105
126
|
download_path = Path(temp_dir) / plugin_package
|
|
106
127
|
with open(download_path, "wb") as download_file:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
f"{settings.CUSTOMER_IDENTIFIER}/{plugin_package}",
|
|
110
|
-
download_file,
|
|
111
|
-
)
|
|
128
|
+
response = requests.request(method=method, url=f"https://{host}{path}", headers=headers)
|
|
129
|
+
download_file.write(response.content)
|
|
112
130
|
yield download_path
|
|
113
131
|
|
|
114
132
|
|
plugin_runner/plugin_runner.py
CHANGED
|
@@ -261,32 +261,15 @@ def find_modules(base_path: pathlib.Path, prefix: str | None = None) -> list[str
|
|
|
261
261
|
return modules
|
|
262
262
|
|
|
263
263
|
|
|
264
|
-
def
|
|
264
|
+
def sandbox_from_module(base_path: pathlib.Path, module_name: str) -> Any:
|
|
265
265
|
"""Sandbox the code execution."""
|
|
266
|
-
|
|
267
|
-
available_modules = find_modules(package_path)
|
|
268
|
-
sandboxes = {}
|
|
269
|
-
|
|
270
|
-
for module_name in available_modules:
|
|
271
|
-
result = sandbox_from_module(package_path, module_name)
|
|
272
|
-
full_module_name = f"{package_name}.{module_name}"
|
|
273
|
-
sandboxes[full_module_name] = result
|
|
274
|
-
|
|
275
|
-
return sandboxes
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def sandbox_from_module(package_path: pathlib.Path, module_name: str) -> Any:
|
|
279
|
-
"""Sandbox the code execution."""
|
|
280
|
-
module_path = package_path / str(module_name.replace(".", "/") + ".py")
|
|
266
|
+
module_path = base_path / str(module_name.replace(".", "/") + ".py")
|
|
281
267
|
|
|
282
268
|
if not module_path.exists():
|
|
283
269
|
raise ModuleNotFoundError(f'Could not load module "{module_name}"')
|
|
284
270
|
|
|
285
|
-
|
|
271
|
+
sandbox = Sandbox(module_path, namespace=module_name)
|
|
286
272
|
|
|
287
|
-
full_module_name = f"{package_path.name}.{module_name}"
|
|
288
|
-
|
|
289
|
-
sandbox = Sandbox(source_code, namespace=full_module_name)
|
|
290
273
|
return sandbox.execute()
|
|
291
274
|
|
|
292
275
|
|
|
@@ -320,7 +303,6 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
|
|
|
320
303
|
handlers = manifest_json["components"].get("protocols", []) + manifest_json[
|
|
321
304
|
"components"
|
|
322
305
|
].get("applications", [])
|
|
323
|
-
results = sandbox_from_package(path)
|
|
324
306
|
except Exception as e:
|
|
325
307
|
log.error(f'Unable to load plugin "{name}": {str(e)}')
|
|
326
308
|
return
|
|
@@ -336,11 +318,11 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
|
|
|
336
318
|
continue
|
|
337
319
|
|
|
338
320
|
try:
|
|
321
|
+
result = sandbox_from_module(path.parent, handler_module)
|
|
322
|
+
|
|
339
323
|
if name_and_class in LOADED_PLUGINS:
|
|
340
324
|
log.info(f"Reloading plugin '{name_and_class}'")
|
|
341
325
|
|
|
342
|
-
result = results[handler_module]
|
|
343
|
-
|
|
344
326
|
LOADED_PLUGINS[name_and_class]["active"] = True
|
|
345
327
|
|
|
346
328
|
LOADED_PLUGINS[name_and_class]["class"] = result[handler_class]
|
|
@@ -349,8 +331,6 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
|
|
|
349
331
|
else:
|
|
350
332
|
log.info(f"Loading plugin '{name_and_class}'")
|
|
351
333
|
|
|
352
|
-
result = results[handler_module]
|
|
353
|
-
|
|
354
334
|
LOADED_PLUGINS[name_and_class] = {
|
|
355
335
|
"active": True,
|
|
356
336
|
"class": result[handler_class],
|
plugin_runner/sandbox.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import ast
|
|
2
2
|
import builtins
|
|
3
|
+
import importlib
|
|
4
|
+
import sys
|
|
3
5
|
from _ast import AnnAssign
|
|
4
6
|
from functools import cached_property
|
|
7
|
+
from pathlib import Path
|
|
5
8
|
from typing import Any, cast
|
|
6
9
|
|
|
7
10
|
from RestrictedPython import (
|
|
@@ -31,6 +34,7 @@ from RestrictedPython.transformer import (
|
|
|
31
34
|
#
|
|
32
35
|
ALLOWED_MODULES = frozenset(
|
|
33
36
|
[
|
|
37
|
+
"__future__",
|
|
34
38
|
"_strptime",
|
|
35
39
|
"arrow",
|
|
36
40
|
"base64",
|
|
@@ -46,8 +50,10 @@ ALLOWED_MODULES = frozenset(
|
|
|
46
50
|
"canvas_sdk.value_set",
|
|
47
51
|
"canvas_sdk.views",
|
|
48
52
|
"contextlib",
|
|
53
|
+
"dataclasses",
|
|
49
54
|
"datetime",
|
|
50
55
|
"dateutil",
|
|
56
|
+
"decimal",
|
|
51
57
|
"django.db.models",
|
|
52
58
|
"django.utils.functional",
|
|
53
59
|
"enum",
|
|
@@ -60,6 +66,7 @@ ALLOWED_MODULES = frozenset(
|
|
|
60
66
|
"math",
|
|
61
67
|
"operator",
|
|
62
68
|
"pickletools",
|
|
69
|
+
"pydantic",
|
|
63
70
|
"random",
|
|
64
71
|
"rapidfuzz",
|
|
65
72
|
"re",
|
|
@@ -88,6 +95,20 @@ def _apply(_ob: Any, *args: Any, **kwargs: Any) -> Any:
|
|
|
88
95
|
return _ob(*args, **kwargs)
|
|
89
96
|
|
|
90
97
|
|
|
98
|
+
def _find_folder_in_path(file_path: Path, target_folder_name: str) -> Path | None:
|
|
99
|
+
"""Recursively search for a folder with the specified name in the hierarchy of the given file path."""
|
|
100
|
+
file_path = file_path.resolve()
|
|
101
|
+
|
|
102
|
+
if file_path.name == target_folder_name:
|
|
103
|
+
return file_path
|
|
104
|
+
|
|
105
|
+
# If we've reached the root of the file system, return None
|
|
106
|
+
if file_path.parent == file_path:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
return _find_folder_in_path(file_path.parent, target_folder_name)
|
|
110
|
+
|
|
111
|
+
|
|
91
112
|
class Sandbox:
|
|
92
113
|
"""A restricted sandbox for safely executing arbitrary Python code."""
|
|
93
114
|
|
|
@@ -119,7 +140,10 @@ class Sandbox:
|
|
|
119
140
|
return self.node_contents_visit(node)
|
|
120
141
|
|
|
121
142
|
def check_name(
|
|
122
|
-
self,
|
|
143
|
+
self,
|
|
144
|
+
node: ast.ImportFrom,
|
|
145
|
+
name: str | None,
|
|
146
|
+
allow_magic_methods: bool = False,
|
|
123
147
|
) -> None:
|
|
124
148
|
"""Check names if they are allowed.
|
|
125
149
|
|
|
@@ -199,16 +223,28 @@ class Sandbox:
|
|
|
199
223
|
# Impossible Case only ctx Load, Store and Del are defined in ast.
|
|
200
224
|
raise NotImplementedError(f"Unknown ctx type: {type(node.ctx)}")
|
|
201
225
|
|
|
202
|
-
def __init__(
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
source_code: str | Path,
|
|
229
|
+
namespace: str | None = None,
|
|
230
|
+
evaluated_modules: dict[str, bool] | None = None,
|
|
231
|
+
) -> None:
|
|
203
232
|
if source_code is None:
|
|
204
233
|
raise TypeError("source_code may not be None")
|
|
205
|
-
self.namespace = namespace or "protocols"
|
|
206
|
-
self.source_code = source_code
|
|
207
234
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
235
|
+
self.namespace = namespace or "protocols"
|
|
236
|
+
self.package_name = self.namespace.split(".")[0]
|
|
237
|
+
|
|
238
|
+
if isinstance(source_code, Path):
|
|
239
|
+
if not source_code.exists():
|
|
240
|
+
raise FileNotFoundError(f"File not found: {source_code}")
|
|
241
|
+
self.source_code = source_code.read_text()
|
|
242
|
+
package_path = _find_folder_in_path(source_code, self.package_name)
|
|
243
|
+
self.base_path = package_path.parent if package_path else None
|
|
244
|
+
self._evaluated_modules: dict[str, bool] = evaluated_modules or {}
|
|
245
|
+
else:
|
|
246
|
+
self.source_code = source_code
|
|
247
|
+
self.base_path = None
|
|
212
248
|
|
|
213
249
|
@cached_property
|
|
214
250
|
def scope(self) -> dict[str, Any]:
|
|
@@ -266,12 +302,72 @@ class Sandbox:
|
|
|
266
302
|
def _is_known_module(self, name: str) -> bool:
|
|
267
303
|
return bool(
|
|
268
304
|
_is_known_module(name)
|
|
269
|
-
or (self.package_name and name.split(".")[0] == self.package_name)
|
|
305
|
+
or (self.package_name and name.split(".")[0] == self.package_name and self.base_path)
|
|
270
306
|
)
|
|
271
307
|
|
|
308
|
+
def _get_module(self, module_name: str) -> Path:
|
|
309
|
+
"""Get the module path for the given module name."""
|
|
310
|
+
module_relative_path = module_name.replace(".", "/")
|
|
311
|
+
module = Path(cast(Path, self.base_path) / f"{module_relative_path}.py")
|
|
312
|
+
|
|
313
|
+
if not module.exists():
|
|
314
|
+
module = Path(cast(Path, self.base_path) / f"{module_relative_path}/__init__.py")
|
|
315
|
+
|
|
316
|
+
return module
|
|
317
|
+
|
|
318
|
+
def _evaluate_module(self, module_name: str) -> None:
|
|
319
|
+
"""Evaluate the given module in the sandbox.
|
|
320
|
+
If the module to import belongs to the same package as the current module, evaluate it inside a sandbox.
|
|
321
|
+
"""
|
|
322
|
+
if not module_name.startswith(self.package_name) or module_name in self._evaluated_modules:
|
|
323
|
+
return # Skip modules outside the package or already evaluated.
|
|
324
|
+
|
|
325
|
+
module = self._get_module(module_name)
|
|
326
|
+
self._evaluate_implicit_imports(module)
|
|
327
|
+
|
|
328
|
+
# Re-check after evaluating implicit imports to avoid duplicate evaluations.
|
|
329
|
+
if module_name not in self._evaluated_modules:
|
|
330
|
+
Sandbox(
|
|
331
|
+
module, namespace=module_name, evaluated_modules=self._evaluated_modules
|
|
332
|
+
).execute()
|
|
333
|
+
self._evaluated_modules[module_name] = True
|
|
334
|
+
|
|
335
|
+
# Reload the module if already imported to ensure the latest version is used.
|
|
336
|
+
if sys.modules.get(module_name):
|
|
337
|
+
importlib.reload(sys.modules[module_name])
|
|
338
|
+
|
|
339
|
+
def _evaluate_implicit_imports(self, module: Path) -> None:
|
|
340
|
+
"""Evaluate implicit imports in the sandbox."""
|
|
341
|
+
# Determine the parent module to check for implicit imports.
|
|
342
|
+
parent = module.parent.parent if module.name == "__init__.py" else module.parent
|
|
343
|
+
base_path = cast(Path, self.base_path)
|
|
344
|
+
|
|
345
|
+
# Skip evaluation if the parent module is outside the base path or already the source code root.
|
|
346
|
+
if not parent.is_relative_to(base_path) or parent == base_path:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
module_name = parent.relative_to(base_path).as_posix().replace("/", ".")
|
|
350
|
+
init_file = parent / "__init__.py"
|
|
351
|
+
|
|
352
|
+
if module_name not in self._evaluated_modules:
|
|
353
|
+
if init_file.exists():
|
|
354
|
+
# Mark as evaluated to prevent infinite recursion.
|
|
355
|
+
self._evaluated_modules[module_name] = True
|
|
356
|
+
Sandbox(
|
|
357
|
+
init_file, namespace=module_name, evaluated_modules=self._evaluated_modules
|
|
358
|
+
).execute()
|
|
359
|
+
else:
|
|
360
|
+
# Mark as evaluated even if no init file exists to prevent redundant checks.
|
|
361
|
+
self._evaluated_modules[module_name] = True
|
|
362
|
+
|
|
363
|
+
self._evaluate_implicit_imports(parent)
|
|
364
|
+
|
|
272
365
|
def _safe_import(self, name: str, *args: Any, **kwargs: Any) -> Any:
|
|
273
366
|
if not (self._is_known_module(name)):
|
|
274
367
|
raise ImportError(f"{name!r} is not an allowed import.")
|
|
368
|
+
|
|
369
|
+
self._evaluate_module(name)
|
|
370
|
+
|
|
275
371
|
return __import__(name, *args, **kwargs)
|
|
276
372
|
|
|
277
373
|
def execute(self) -> dict:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sdk_version": "0.1.4",
|
|
3
|
+
"plugin_version": "0.0.1",
|
|
4
|
+
"name": "test_implicit_imports_plugin",
|
|
5
|
+
"description": "Edit the description in CANVAS_MANIFEST.json",
|
|
6
|
+
"components": {
|
|
7
|
+
"protocols": [
|
|
8
|
+
{
|
|
9
|
+
"class": "test_implicit_imports_plugin.protocols.my_protocol:Forbidden",
|
|
10
|
+
"description": "A protocol that does xyz...",
|
|
11
|
+
"data_access": {
|
|
12
|
+
"event": "",
|
|
13
|
+
"read": [],
|
|
14
|
+
"write": []
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"class": "test_implicit_imports_plugin.protocols.my_protocol:Allowed",
|
|
19
|
+
"description": "A protocol that does xyz...",
|
|
20
|
+
"data_access": {
|
|
21
|
+
"event": "",
|
|
22
|
+
"read": [],
|
|
23
|
+
"write": []
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"commands": [],
|
|
28
|
+
"content": [],
|
|
29
|
+
"effects": [],
|
|
30
|
+
"views": []
|
|
31
|
+
},
|
|
32
|
+
"secrets": [],
|
|
33
|
+
"tags": {},
|
|
34
|
+
"references": [],
|
|
35
|
+
"license": "",
|
|
36
|
+
"diagram": false,
|
|
37
|
+
"readme": "./README.md"
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
test_forbiden_implicit_imports_plugin
|
|
2
|
+
=====================================
|
|
3
|
+
|
|
4
|
+
## Description
|
|
5
|
+
|
|
6
|
+
A description of this plugin
|
|
7
|
+
|
|
8
|
+
### Important Note!
|
|
9
|
+
|
|
10
|
+
The CANVAS_MANIFEST.json is used when installing your plugin. Please ensure it
|
|
11
|
+
gets updated if you add, remove, or rename protocols.
|
|
File without changes
|