canvas 0.15.0__py3-none-any.whl → 0.17.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 (59) hide show
  1. {canvas-0.15.0.dist-info → canvas-0.17.0.dist-info}/METADATA +25 -31
  2. {canvas-0.15.0.dist-info → canvas-0.17.0.dist-info}/RECORD +70 -53
  3. {canvas-0.15.0.dist-info → canvas-0.17.0.dist-info}/WHEEL +1 -1
  4. canvas-0.17.0.dist-info/entry_points.txt +2 -0
  5. canvas_cli/apps/plugin/plugin.py +1 -1
  6. canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json +6 -3
  7. canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/applications/my_application.py +4 -1
  8. canvas_cli/utils/validators/manifest_schema.py +9 -2
  9. canvas_generated/messages/effects_pb2.py +2 -2
  10. canvas_generated/messages/effects_pb2.pyi +14 -0
  11. canvas_generated/messages/events_pb2.py +2 -2
  12. canvas_generated/messages/events_pb2.pyi +40 -0
  13. canvas_sdk/commands/tests/protocol/tests.py +12 -2
  14. canvas_sdk/commands/tests/test_utils.py +4 -9
  15. canvas_sdk/effects/banner_alert/tests.py +5 -2
  16. canvas_sdk/effects/launch_modal.py +14 -3
  17. canvas_sdk/handlers/action_button.py +33 -16
  18. canvas_sdk/templates/__init__.py +3 -0
  19. canvas_sdk/templates/tests/__init__.py +0 -0
  20. canvas_sdk/templates/tests/test_utils.py +43 -0
  21. canvas_sdk/templates/utils.py +44 -0
  22. canvas_sdk/utils/http.py +1 -1
  23. canvas_sdk/v1/data/__init__.py +23 -1
  24. canvas_sdk/v1/data/allergy_intolerance.py +22 -2
  25. canvas_sdk/v1/data/appointment.py +56 -0
  26. canvas_sdk/v1/data/assessment.py +40 -0
  27. canvas_sdk/v1/data/base.py +35 -22
  28. canvas_sdk/v1/data/billing.py +2 -2
  29. canvas_sdk/v1/data/care_team.py +60 -0
  30. canvas_sdk/v1/data/command.py +1 -1
  31. canvas_sdk/v1/data/common.py +53 -0
  32. canvas_sdk/v1/data/condition.py +19 -3
  33. canvas_sdk/v1/data/coverage.py +294 -0
  34. canvas_sdk/v1/data/detected_issue.py +1 -0
  35. canvas_sdk/v1/data/lab.py +26 -3
  36. canvas_sdk/v1/data/medication.py +13 -3
  37. canvas_sdk/v1/data/note.py +5 -1
  38. canvas_sdk/v1/data/observation.py +15 -3
  39. canvas_sdk/v1/data/patient.py +140 -1
  40. canvas_sdk/v1/data/protocol_override.py +18 -2
  41. canvas_sdk/v1/data/questionnaire.py +15 -2
  42. canvas_sdk/value_set/hcc2018.py +55369 -0
  43. plugin_runner/plugin_installer.py +21 -13
  44. plugin_runner/plugin_runner.py +67 -14
  45. plugin_runner/sandbox.py +28 -0
  46. plugin_runner/tests/fixtures/plugins/test_render_template/CANVAS_MANIFEST.json +47 -0
  47. plugin_runner/tests/fixtures/plugins/test_render_template/README.md +11 -0
  48. plugin_runner/tests/fixtures/plugins/test_render_template/protocols/__init__.py +0 -0
  49. plugin_runner/tests/fixtures/plugins/test_render_template/protocols/my_protocol.py +43 -0
  50. plugin_runner/tests/fixtures/plugins/test_render_template/templates/template.html +10 -0
  51. plugin_runner/tests/test_plugin_runner.py +37 -51
  52. plugin_runner/tests/test_sandbox.py +21 -1
  53. protobufs/canvas_generated/messages/effects.proto +225 -0
  54. protobufs/canvas_generated/messages/events.proto +1049 -0
  55. protobufs/canvas_generated/messages/plugins.proto +9 -0
  56. protobufs/canvas_generated/services/plugin_runner.proto +12 -0
  57. settings.py +14 -1
  58. canvas-0.15.0.dist-info/entry_points.txt +0 -3
  59. plugin_runner/plugin_synchronizer.py +0 -92
@@ -14,9 +14,17 @@ import requests
14
14
  from psycopg import Connection
15
15
  from psycopg.rows import dict_row
16
16
 
17
- import settings
18
17
  from plugin_runner.aws_headers import aws_sig_v4_headers
19
18
  from plugin_runner.exceptions import InvalidPluginFormat, PluginInstallationError
19
+ from settings import (
20
+ AWS_ACCESS_KEY_ID,
21
+ AWS_REGION,
22
+ AWS_SECRET_ACCESS_KEY,
23
+ CUSTOMER_IDENTIFIER,
24
+ MEDIA_S3_BUCKET_NAME,
25
+ PLUGIN_DIRECTORY,
26
+ SECRETS_FILE_NAME,
27
+ )
20
28
 
21
29
  # Plugin "packages" include this prefix in the database record for the plugin and the S3 bucket key.
22
30
  UPLOAD_TO_PREFIX = "plugins"
@@ -100,19 +108,19 @@ def _extract_rows_to_dict(rows: list) -> dict[str, PluginAttributes]:
100
108
  def download_plugin(plugin_package: str) -> Generator[Path, None, None]:
101
109
  """Download the plugin package from the S3 bucket."""
102
110
  method = "GET"
103
- host = f"s3-{settings.AWS_REGION}.amazonaws.com"
104
- bucket = settings.MEDIA_S3_BUCKET_NAME
105
- customer_identifier = settings.CUSTOMER_IDENTIFIER
111
+ host = f"s3-{AWS_REGION}.amazonaws.com"
112
+ bucket = MEDIA_S3_BUCKET_NAME
113
+ customer_identifier = CUSTOMER_IDENTIFIER
106
114
  path = f"/{bucket}/{customer_identifier}/{plugin_package}"
107
115
  payload = b"This is required for the AWS headers because it is part of the signature"
108
116
  pre_auth_headers: dict[str, str] = {}
109
117
  query: dict[str, str] = {}
110
118
  headers = aws_sig_v4_headers(
111
- settings.AWS_ACCESS_KEY_ID,
112
- settings.AWS_SECRET_ACCESS_KEY,
119
+ AWS_ACCESS_KEY_ID,
120
+ AWS_SECRET_ACCESS_KEY,
113
121
  pre_auth_headers,
114
122
  "s3",
115
- settings.AWS_REGION,
123
+ AWS_REGION,
116
124
  host,
117
125
  method,
118
126
  path,
@@ -135,7 +143,7 @@ def install_plugin(plugin_name: str, attributes: PluginAttributes) -> None:
135
143
  try:
136
144
  print(f"Installing plugin '{plugin_name}'")
137
145
 
138
- plugin_installation_path = Path(settings.PLUGIN_DIRECTORY) / plugin_name
146
+ plugin_installation_path = Path(PLUGIN_DIRECTORY) / plugin_name
139
147
 
140
148
  # if plugin exists, first uninstall it
141
149
  if plugin_installation_path.exists():
@@ -175,7 +183,7 @@ def install_plugin_secrets(plugin_name: str, secrets: dict[str, str]) -> None:
175
183
  """Write the plugin's secrets to disk in the package's directory."""
176
184
  print(f"Writing plugin secrets for '{plugin_name}'")
177
185
 
178
- secrets_path = Path(settings.PLUGIN_DIRECTORY) / plugin_name / settings.SECRETS_FILE_NAME
186
+ secrets_path = Path(PLUGIN_DIRECTORY) / plugin_name / SECRETS_FILE_NAME
179
187
 
180
188
  # Did the plugin ship a secrets.json? TOO BAD, IT'S GONE NOW.
181
189
  if Path(secrets_path).exists():
@@ -199,7 +207,7 @@ def disable_plugin(plugin_name: str) -> None:
199
207
 
200
208
  def uninstall_plugin(plugin_name: str) -> None:
201
209
  """Remove the plugin from the filesystem."""
202
- plugin_path = Path(settings.PLUGIN_DIRECTORY) / plugin_name
210
+ plugin_path = Path(PLUGIN_DIRECTORY) / plugin_name
203
211
 
204
212
  if plugin_path.exists():
205
213
  shutil.rmtree(plugin_path)
@@ -207,10 +215,10 @@ def uninstall_plugin(plugin_name: str) -> None:
207
215
 
208
216
  def install_plugins() -> None:
209
217
  """Install all enabled plugins."""
210
- if Path(settings.PLUGIN_DIRECTORY).exists():
211
- shutil.rmtree(settings.PLUGIN_DIRECTORY)
218
+ if Path(PLUGIN_DIRECTORY).exists():
219
+ shutil.rmtree(PLUGIN_DIRECTORY)
212
220
 
213
- os.mkdir(settings.PLUGIN_DIRECTORY)
221
+ os.mkdir(PLUGIN_DIRECTORY)
214
222
 
215
223
  for plugin_name, attributes in enabled_plugins().items():
216
224
  try:
@@ -2,17 +2,17 @@ import asyncio
2
2
  import json
3
3
  import os
4
4
  import pathlib
5
+ import pickle
5
6
  import pkgutil
6
- import signal
7
7
  import sys
8
8
  import time
9
9
  import traceback
10
10
  from collections import defaultdict
11
11
  from collections.abc import AsyncGenerator
12
- from types import FrameType
13
12
  from typing import Any, TypedDict
14
13
 
15
14
  import grpc
15
+ import redis.asyncio as redis
16
16
  import statsd
17
17
 
18
18
  from canvas_generated.messages.effects_pb2 import EffectType
@@ -30,9 +30,15 @@ from canvas_sdk.protocols import ClinicalQualityMeasure
30
30
  from canvas_sdk.utils.stats import get_duration_ms, tags_to_line_protocol
31
31
  from logger import log
32
32
  from plugin_runner.authentication import token_for_plugin
33
- from plugin_runner.plugin_synchronizer import publish_message
33
+ from plugin_runner.plugin_installer import install_plugins
34
34
  from plugin_runner.sandbox import Sandbox
35
- from settings import MANIFEST_FILE_NAME, PLUGIN_DIRECTORY, SECRETS_FILE_NAME
35
+ from settings import (
36
+ CHANNEL_NAME,
37
+ MANIFEST_FILE_NAME,
38
+ PLUGIN_DIRECTORY,
39
+ REDIS_ENDPOINT,
40
+ SECRETS_FILE_NAME,
41
+ )
36
42
 
37
43
  # when we import plugins we'll use the module name directly so we need to add the plugin
38
44
  # directory to the path
@@ -192,14 +198,50 @@ class PluginRunner(PluginRunnerServicer):
192
198
  self, request: ReloadPluginsRequest, context: Any
193
199
  ) -> AsyncGenerator[ReloadPluginsResponse, None]:
194
200
  """This is invoked when we need to reload plugins."""
201
+ log.info("Reloading plugins...")
195
202
  try:
196
- publish_message({"action": "restart"})
203
+ await publish_message(message={"action": "reload"})
197
204
  except ImportError:
198
205
  yield ReloadPluginsResponse(success=False)
199
206
  else:
200
207
  yield ReloadPluginsResponse(success=True)
201
208
 
202
209
 
210
+ async def synchronize_plugins(max_iterations: None | int = None) -> None:
211
+ """Listen for messages on the pubsub channel that will indicate it is necessary to reinstall and reload plugins."""
212
+ client, pubsub = get_client()
213
+ await pubsub.psubscribe(CHANNEL_NAME)
214
+ log.info("Listening for messages on pubsub channel")
215
+ iterations: int = 0
216
+ while (
217
+ max_iterations is None or iterations < max_iterations
218
+ ): # max_iterations == -1 means infinite iterations
219
+ iterations += 1
220
+ message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None)
221
+ if message is not None:
222
+ log.info("Received message from pubsub channel")
223
+
224
+ message_type = message.get("type", "")
225
+
226
+ if message_type != "pmessage":
227
+ continue
228
+
229
+ data = pickle.loads(message.get("data", pickle.dumps({})))
230
+
231
+ if "action" not in data:
232
+ continue
233
+
234
+ if data["action"] == "reload":
235
+ try:
236
+ log.info(
237
+ "plugin-synchronizer: installing and reloading plugins after receiving command"
238
+ )
239
+ install_plugins()
240
+ load_plugins()
241
+ except Exception as e:
242
+ print("plugin-synchronizer: `install_plugins` failed:", e)
243
+
244
+
203
245
  def validate_effects(effects: list[Effect]) -> list[Effect]:
204
246
  """Validates the effects based on predefined rules.
205
247
  Keeps only the first AUTOCOMPLETE_SEARCH_RESULTS effect and preserve all non-search-related effects.
@@ -237,12 +279,6 @@ def apply_effects_to_context(effects: list[Effect], event: Event) -> Event:
237
279
  return event
238
280
 
239
281
 
240
- def handle_hup_cb(_signum: int, _frame: FrameType | None) -> None:
241
- """handle_hup_cb."""
242
- log.info("Received SIGHUP, reloading plugins...")
243
- load_plugins()
244
-
245
-
246
282
  def find_modules(base_path: pathlib.Path, prefix: str | None = None) -> list[str]:
247
283
  """Find all modules in the specified package path."""
248
284
  modules: list[str] = []
@@ -273,6 +309,22 @@ def sandbox_from_module(base_path: pathlib.Path, module_name: str) -> Any:
273
309
  return sandbox.execute()
274
310
 
275
311
 
312
+ async def publish_message(message: dict) -> None:
313
+ """Publish a message to the pubsub channel."""
314
+ log.info("Publishing message to pubsub channel")
315
+ client, _ = get_client()
316
+
317
+ await client.publish(CHANNEL_NAME, pickle.dumps(message))
318
+
319
+
320
+ def get_client() -> tuple[redis.Redis, redis.client.PubSub]:
321
+ """Return an async Redis client and pubsub object."""
322
+ client = redis.Redis.from_url(REDIS_ENDPOINT)
323
+ pubsub = client.pubsub()
324
+
325
+ return client, pubsub
326
+
327
+
276
328
  def load_or_reload_plugin(path: pathlib.Path) -> None:
277
329
  """Given a path, load or reload a plugin."""
278
330
  log.info(f"Loading {path}")
@@ -415,6 +467,7 @@ async def serve(specified_plugin_paths: list[str] | None = None) -> None:
415
467
 
416
468
  log.info(f"Starting server, listening on port {port}")
417
469
 
470
+ install_plugins()
418
471
  load_plugins(specified_plugin_paths)
419
472
 
420
473
  await server.start()
@@ -434,10 +487,10 @@ def run_server(specified_plugin_paths: list[str] | None = None) -> None:
434
487
 
435
488
  asyncio.set_event_loop(loop)
436
489
 
437
- signal.signal(signal.SIGHUP, handle_hup_cb)
438
-
439
490
  try:
440
- loop.run_until_complete(serve(specified_plugin_paths))
491
+ loop.run_until_complete(
492
+ asyncio.gather(serve(specified_plugin_paths), synchronize_plugins())
493
+ )
441
494
  except KeyboardInterrupt:
442
495
  pass
443
496
  finally:
plugin_runner/sandbox.py CHANGED
@@ -46,6 +46,7 @@ ALLOWED_MODULES = frozenset(
46
46
  "canvas_sdk.handlers",
47
47
  "canvas_sdk.protocols",
48
48
  "canvas_sdk.utils",
49
+ "canvas_sdk.templates",
49
50
  "canvas_sdk.v1",
50
51
  "canvas_sdk.value_set",
51
52
  "canvas_sdk.views",
@@ -81,6 +82,14 @@ ALLOWED_MODULES = frozenset(
81
82
  )
82
83
 
83
84
 
85
+ ##
86
+ # FORBIDDEN_ASSIGNMENTS
87
+ #
88
+ # The names in this list are forbidden to be assigned to in a sandboxed runtime.
89
+ #
90
+ FORBIDDEN_ASSIGNMENTS = frozenset(["__name__", "__is_plugin__"])
91
+
92
+
84
93
  def _is_known_module(name: str) -> bool:
85
94
  return any(name.startswith(m) for m in ALLOWED_MODULES)
86
95
 
@@ -174,6 +183,24 @@ class Sandbox:
174
183
  elif name in FORBIDDEN_FUNC_NAMES:
175
184
  self.error(node, f'"{name}" is a reserved name.')
176
185
 
186
+ def visit_Assign(self, node: ast.Assign) -> ast.AST:
187
+ """Check for forbidden assignments."""
188
+ for target in node.targets:
189
+ if isinstance(target, ast.Name) and target.id in FORBIDDEN_ASSIGNMENTS:
190
+ self.error(node, f"Assignments to '{target.id}' are not allowed.")
191
+ elif isinstance(target, ast.Tuple | ast.List):
192
+ self.check_for_name_in_iterable(target)
193
+
194
+ return super().visit_Assign(node)
195
+
196
+ def check_for_name_in_iterable(self, iterable_node: ast.Tuple | ast.List) -> None:
197
+ """Check if any element of an iterable is a forbidden assignment."""
198
+ for elt in iterable_node.elts:
199
+ if isinstance(elt, ast.Name) and elt.id in FORBIDDEN_ASSIGNMENTS:
200
+ self.error(iterable_node, f"Assignments to '{elt.id}' are not allowed.")
201
+ elif isinstance(elt, ast.Tuple | ast.List):
202
+ self.check_for_name_in_iterable(elt)
203
+
177
204
  def visit_Attribute(self, node: ast.Attribute) -> ast.AST:
178
205
  """Checks and mutates attribute access/assignment.
179
206
 
@@ -272,6 +299,7 @@ class Sandbox:
272
299
  },
273
300
  "__metaclass__": type,
274
301
  "__name__": self.namespace,
302
+ "__is_plugin__": True,
275
303
  "_write_": _unrestricted,
276
304
  "_getiter_": _unrestricted,
277
305
  "_getitem_": default_guarded_getitem,
@@ -0,0 +1,47 @@
1
+ {
2
+ "sdk_version": "0.1.4",
3
+ "plugin_version": "0.0.1",
4
+ "name": "test_render_template",
5
+ "description": "Edit the description in CANVAS_MANIFEST.json",
6
+ "components": {
7
+ "protocols": [
8
+ {
9
+ "class": "test_render_template.protocols.my_protocol:ValidTemplate",
10
+ "description": "A protocol that does xyz...",
11
+ "data_access": {
12
+ "event": "",
13
+ "read": [],
14
+ "write": []
15
+ }
16
+ },
17
+ {
18
+ "class": "test_render_template.protocols.my_protocol:InvalidTemplate",
19
+ "description": "A protocol that does xyz...",
20
+ "data_access": {
21
+ "event": "",
22
+ "read": [],
23
+ "write": []
24
+ }
25
+ },
26
+ {
27
+ "class": "test_render_template.protocols.my_protocol:ForbiddenTemplate",
28
+ "description": "A protocol that does xyz...",
29
+ "data_access": {
30
+ "event": "",
31
+ "read": [],
32
+ "write": []
33
+ }
34
+ }
35
+ ],
36
+ "commands": [],
37
+ "content": [],
38
+ "effects": [],
39
+ "views": []
40
+ },
41
+ "secrets": [],
42
+ "tags": {},
43
+ "references": [],
44
+ "license": "",
45
+ "diagram": false,
46
+ "readme": "./README.md"
47
+ }
@@ -0,0 +1,11 @@
1
+ test_render_template
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.
@@ -0,0 +1,43 @@
1
+ from canvas_sdk.effects import Effect, EffectType
2
+ from canvas_sdk.events import EventType
3
+ from canvas_sdk.protocols import BaseProtocol
4
+ from canvas_sdk.templates import render_to_string
5
+
6
+
7
+ class ValidTemplate(BaseProtocol):
8
+ """You should put a helpful description of this protocol's behavior here."""
9
+
10
+ RESPONDS_TO = [EventType.Name(EventType.UNKNOWN)]
11
+
12
+ def compute(self) -> list[Effect]:
13
+ """This method gets called when an event of the type RESPONDS_TO is fired."""
14
+ return [
15
+ Effect(type=EffectType.LOG, payload=render_to_string("templates/template.html", None))
16
+ ]
17
+
18
+
19
+ class InvalidTemplate(BaseProtocol):
20
+ """You should put a helpful description of this protocol's behavior here."""
21
+
22
+ RESPONDS_TO = [EventType.Name(EventType.UNKNOWN)]
23
+
24
+ def compute(self) -> list[Effect]:
25
+ """This method gets called when an event of the type RESPONDS_TO is fired."""
26
+ return [
27
+ Effect(type=EffectType.LOG, payload=render_to_string("templates/template1.html", None))
28
+ ]
29
+
30
+
31
+ class ForbiddenTemplate(BaseProtocol):
32
+ """You should put a helpful description of this protocol's behavior here."""
33
+
34
+ RESPONDS_TO = [EventType.Name(EventType.UNKNOWN)]
35
+
36
+ def compute(self) -> list[Effect]:
37
+ """This method gets called when an event of the type RESPONDS_TO is fired."""
38
+ return [
39
+ Effect(
40
+ type=EffectType.LOG,
41
+ payload=render_to_string("../../templates/template.html", None),
42
+ )
43
+ ]
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Title</title>
6
+ </head>
7
+ <body>
8
+
9
+ </body>
10
+ </html>
@@ -1,8 +1,9 @@
1
+ import asyncio
1
2
  import logging
3
+ import pickle
2
4
  import shutil
3
- from collections.abc import Generator
4
5
  from pathlib import Path
5
- from unittest.mock import MagicMock, patch
6
+ from unittest.mock import AsyncMock, MagicMock, patch
6
7
 
7
8
  import pytest
8
9
 
@@ -15,54 +16,10 @@ from plugin_runner.plugin_runner import (
15
16
  PluginRunner,
16
17
  load_or_reload_plugin,
17
18
  load_plugins,
19
+ synchronize_plugins,
18
20
  )
19
21
 
20
22
 
21
- @pytest.fixture
22
- def install_test_plugin(request: pytest.FixtureRequest) -> Generator[Path, None, None]:
23
- """Copies a specified plugin from the fixtures directory to the data directory
24
- and removes it after the test.
25
-
26
- Parameters:
27
- - request.param: The name of the plugin package to copy.
28
-
29
- Yields:
30
- - Path to the copied plugin directory.
31
- """
32
- # Define base directories
33
- base_dir = Path("./plugin_runner/tests")
34
- fixture_plugin_dir = base_dir / "fixtures" / "plugins"
35
- data_plugin_dir = base_dir / "data" / "plugins"
36
-
37
- # The plugin name should be passed as a parameter to the fixture
38
- plugin_name = request.param # Expected to be a str
39
- src_plugin_path = fixture_plugin_dir / plugin_name
40
- dest_plugin_path = data_plugin_dir / plugin_name
41
-
42
- # Ensure the data plugin directory exists
43
- data_plugin_dir.mkdir(parents=True, exist_ok=True)
44
-
45
- # Copy the specific plugin from fixtures to data
46
- try:
47
- shutil.copytree(src_plugin_path, dest_plugin_path)
48
- yield dest_plugin_path # Provide the path to the test
49
- finally:
50
- # Cleanup: remove data/plugins directory after the test
51
- if dest_plugin_path.exists():
52
- shutil.rmtree(dest_plugin_path)
53
-
54
-
55
- @pytest.fixture
56
- def load_test_plugins() -> Generator[None, None, None]:
57
- """Manages the lifecycle of test plugins by loading and unloading them."""
58
- try:
59
- load_plugins()
60
- yield
61
- finally:
62
- LOADED_PLUGINS.clear()
63
- EVENT_HANDLER_MAP.clear()
64
-
65
-
66
23
  @pytest.fixture
67
24
  def plugin_runner() -> PluginRunner:
68
25
  """Fixture to initialize PluginRunner with mocks."""
@@ -275,24 +232,53 @@ async def test_handle_plugin_event_returns_expected_result(
275
232
 
276
233
 
277
234
  @pytest.mark.asyncio
278
- @pytest.mark.parametrize("install_test_plugin", ["example_plugin"], indirect=True)
279
235
  async def test_reload_plugins_event_handler_successfully_publishes_message(
280
- install_test_plugin: Path, plugin_runner: PluginRunner
236
+ plugin_runner: PluginRunner,
281
237
  ) -> None:
282
238
  """Test ReloadPlugins Event handler successfully publishes a message with restart action."""
283
- with patch("plugin_runner.plugin_runner.publish_message", MagicMock()) as mock_publish_message:
239
+ with patch(
240
+ "plugin_runner.plugin_runner.publish_message", new_callable=AsyncMock
241
+ ) as mock_publish_message:
284
242
  request = ReloadPluginsRequest()
285
243
 
286
244
  result = []
287
245
  async for response in plugin_runner.ReloadPlugins(request, None):
288
246
  result.append(response)
289
247
 
290
- mock_publish_message.assert_called_once_with({"action": "restart"})
248
+ mock_publish_message.assert_called_once_with(message={"action": "reload"})
291
249
 
292
250
  assert len(result) == 1
293
251
  assert result[0].success is True
294
252
 
295
253
 
254
+ @pytest.mark.asyncio
255
+ async def test_synchronize_plugins_calls_install_and_load_plugins() -> None:
256
+ """Test that synchronize_plugins calls install_plugins and load_plugins."""
257
+ with (
258
+ patch("plugin_runner.plugin_runner.get_client", new_callable=MagicMock) as mock_get_client,
259
+ patch(
260
+ "plugin_runner.plugin_runner.install_plugins", new_callable=AsyncMock
261
+ ) as mock_install_plugins,
262
+ patch(
263
+ "plugin_runner.plugin_runner.load_plugins", new_callable=AsyncMock
264
+ ) as mock_load_plugins,
265
+ ):
266
+ mock_client = AsyncMock()
267
+ mock_pubsub = AsyncMock()
268
+ mock_get_client.return_value = (mock_client, mock_pubsub)
269
+ mock_pubsub.get_message.return_value = {
270
+ "type": "pmessage",
271
+ "data": pickle.dumps({"action": "reload"}),
272
+ }
273
+
274
+ task = asyncio.create_task(synchronize_plugins(max_iterations=1))
275
+ await asyncio.sleep(0.1) # Give some time for the coroutine to run
276
+ task.cancel()
277
+
278
+ mock_install_plugins.assert_called_once()
279
+ mock_load_plugins.assert_called_once()
280
+
281
+
296
282
  @pytest.mark.asyncio
297
283
  @pytest.mark.parametrize("install_test_plugin", ["test_module_imports_plugin"], indirect=True)
298
284
  async def test_changes_to_plugin_modules_should_be_reflected_after_reload(
@@ -1,6 +1,6 @@
1
1
  import pytest
2
2
 
3
- from plugin_runner.sandbox import Sandbox
3
+ from plugin_runner.sandbox import FORBIDDEN_ASSIGNMENTS, Sandbox
4
4
 
5
5
  # Sample code strings for testing various scenarios
6
6
  VALID_CODE = """
@@ -33,6 +33,18 @@ import module.b
33
33
  result = module.b
34
34
  """
35
35
 
36
+ CODE_WITH_FORBIDDEN_ASSIGNMENTS = [
37
+ code
38
+ for var in FORBIDDEN_ASSIGNMENTS
39
+ for code in [
40
+ f"{var} = 'test'",
41
+ f"test = {var} = 'test'",
42
+ f"test = {var} = test2 = 'test'",
43
+ f"(a, (b, c), (d, ({var}, f))) = (1, (2, 3), (4, (5, 6)))",
44
+ f"(a, (b, c), (d, [{var}, f])) = (1, (2, 3), (4, [5, 6]))",
45
+ ]
46
+ ]
47
+
36
48
 
37
49
  def test_valid_code_execution() -> None:
38
50
  """Test execution of valid code in the sandbox."""
@@ -69,6 +81,14 @@ def test_forbidden_name() -> None:
69
81
  sandbox.execute()
70
82
 
71
83
 
84
+ @pytest.mark.parametrize("code", CODE_WITH_FORBIDDEN_ASSIGNMENTS)
85
+ def test_forbidden_assignment(code: str) -> None:
86
+ """Test that forbidden assignments are blocked by Transformer."""
87
+ sandbox = Sandbox(code)
88
+ with pytest.raises(RuntimeError, match="Code is invalid"):
89
+ sandbox.execute()
90
+
91
+
72
92
  def test_code_with_warnings() -> None:
73
93
  """Test that the sandbox captures warnings for restricted names or usage."""
74
94
  code_with_warning = """