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.

Files changed (70) hide show
  1. {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/METADATA +1 -3
  2. {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/RECORD +69 -46
  3. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +1 -1
  4. canvas_generated/messages/effects_pb2.py +2 -2
  5. canvas_generated/messages/effects_pb2.pyi +6 -0
  6. canvas_generated/messages/events_pb2.py +2 -2
  7. canvas_generated/messages/events_pb2.pyi +8 -0
  8. canvas_sdk/commands/tests/protocol/tests.py +3 -1
  9. canvas_sdk/commands/tests/test_utils.py +76 -18
  10. canvas_sdk/effects/banner_alert/tests.py +41 -20
  11. canvas_sdk/effects/questionnaire_result.py +29 -0
  12. canvas_sdk/events/base.py +1 -3
  13. canvas_sdk/handlers/action_button.py +5 -2
  14. canvas_sdk/handlers/application.py +1 -1
  15. canvas_sdk/handlers/cron_task.py +1 -1
  16. canvas_sdk/protocols/clinical_quality_measure.py +1 -1
  17. canvas_sdk/v1/apps.py +7 -0
  18. canvas_sdk/v1/data/__init__.py +75 -4
  19. canvas_sdk/v1/data/allergy_intolerance.py +3 -7
  20. canvas_sdk/v1/data/billing.py +2 -5
  21. canvas_sdk/v1/data/command.py +19 -8
  22. canvas_sdk/v1/data/condition.py +3 -7
  23. canvas_sdk/v1/data/detected_issue.py +4 -9
  24. canvas_sdk/v1/data/device.py +4 -8
  25. canvas_sdk/v1/data/imaging.py +12 -17
  26. canvas_sdk/v1/data/lab.py +16 -29
  27. canvas_sdk/v1/data/medication.py +3 -7
  28. canvas_sdk/v1/data/note.py +7 -14
  29. canvas_sdk/v1/data/observation.py +4 -11
  30. canvas_sdk/v1/data/organization.py +1 -2
  31. canvas_sdk/v1/data/patient.py +0 -1
  32. canvas_sdk/v1/data/practicelocation.py +2 -4
  33. canvas_sdk/v1/data/protocol_override.py +3 -6
  34. canvas_sdk/v1/data/questionnaire.py +5 -15
  35. canvas_sdk/v1/data/staff.py +5 -7
  36. canvas_sdk/v1/data/task.py +5 -11
  37. canvas_sdk/v1/data/user.py +0 -1
  38. canvas_sdk/v1/models.py +4 -0
  39. plugin_runner/aws_headers.py +77 -0
  40. plugin_runner/plugin_installer.py +26 -8
  41. plugin_runner/plugin_runner.py +5 -25
  42. plugin_runner/sandbox.py +105 -9
  43. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/CANVAS_MANIFEST.json +38 -0
  44. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/README.md +11 -0
  45. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/protocols/__init__.py +0 -0
  46. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/protocols/my_protocol.py +33 -0
  47. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/templates/__init__.py +3 -0
  48. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/templates/base.py +6 -0
  49. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/utils/__init__.py +5 -0
  50. plugin_runner/tests/fixtures/plugins/test_implicit_imports_plugin/utils/base.py +4 -0
  51. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/CANVAS_MANIFEST.json +29 -0
  52. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/README.md +12 -0
  53. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/other_module/__init__.py +0 -0
  54. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/other_module/base.py +10 -0
  55. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/protocols/__init__.py +0 -0
  56. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_plugin/protocols/my_protocol.py +18 -0
  57. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/CANVAS_MANIFEST.json +29 -0
  58. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/README.md +12 -0
  59. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/other_module/__init__.py +0 -0
  60. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/other_module/base.py +10 -0
  61. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/protocols/__init__.py +0 -0
  62. plugin_runner/tests/fixtures/plugins/test_module_forbidden_imports_runtime_plugin/protocols/my_protocol.py +18 -0
  63. plugin_runner/tests/test_application.py +9 -9
  64. plugin_runner/tests/test_plugin_installer.py +22 -13
  65. plugin_runner/tests/test_plugin_runner.py +171 -32
  66. plugin_runner/tests/test_sandbox.py +18 -14
  67. settings.py +7 -1
  68. canvas_sdk/models/__init__.py +0 -7
  69. {canvas-0.13.3.dist-info → canvas-0.15.0.dist-info}/WHEEL +0 -0
  70. {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="canvas_sdk.QuestionnaireQuestionMap") # type: ignore[misc, var-annotated]
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="canvas_sdk.InterviewQuestionnaireMap", # type: ignore[misc]
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)
@@ -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
  )
@@ -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)
@@ -6,7 +6,6 @@ class CanvasUser(models.Model):
6
6
 
7
7
  class Meta:
8
8
  managed = False
9
- app_label = "canvas_sdk"
10
9
  db_table = "canvas_sdk_data_api_auth_user_001"
11
10
 
12
11
  dbid = models.BigIntegerField(db_column="dbid", primary_key=True)
@@ -0,0 +1,4 @@
1
+ # Normally we would register Django models in the models module, but they are in "data" for legacy
2
+ # reasons. For this reason, we import * from data to avoid duplication.
3
+
4
+ from .data import * # noqa: F403
@@ -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
- s3 = boto3.client("s3")
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
- s3.download_fileobj(
108
- "canvas-client-media",
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
 
@@ -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 sandbox_from_package(package_path: pathlib.Path) -> dict[str, Any]:
264
+ def sandbox_from_module(base_path: pathlib.Path, module_name: str) -> Any:
265
265
  """Sandbox the code execution."""
266
- package_name = package_path.name
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
- source_code = module_path.read_text()
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, node: ast.ImportFrom, name: str | None, allow_magic_methods: bool = False
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__(self, source_code: str, namespace: str | None = None) -> None:
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
- @cached_property
209
- def package_name(self) -> str | None:
210
- """Return the root package name."""
211
- return self.namespace.split(".")[0] if self.namespace else None
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.