canvas 0.12.0__py3-none-any.whl → 0.13.1__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.12.0.dist-info → canvas-0.13.1.dist-info}/METADATA +3 -1
- {canvas-0.12.0.dist-info → canvas-0.13.1.dist-info}/RECORD +31 -19
- canvas_cli/apps/plugin/plugin.py +13 -4
- canvas_cli/templates/plugins/application/cookiecutter.json +4 -0
- canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json +28 -0
- canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/README.md +11 -0
- canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/applications/__init__.py +0 -0
- canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/applications/my_application.py +12 -0
- canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/assets/python-logo.png +0 -0
- canvas_cli/utils/validators/manifest_schema.py +18 -1
- canvas_generated/messages/effects_pb2.py +2 -2
- canvas_generated/messages/effects_pb2.pyi +2 -0
- canvas_generated/messages/events_pb2.py +2 -2
- canvas_generated/messages/events_pb2.pyi +2 -0
- canvas_sdk/effects/__init__.py +3 -1
- canvas_sdk/effects/launch_modal.py +24 -0
- canvas_sdk/handlers/application.py +29 -0
- canvas_sdk/handlers/base.py +8 -4
- canvas_sdk/protocols/base.py +3 -1
- plugin_runner/exceptions.py +14 -0
- plugin_runner/plugin_installer.py +208 -0
- plugin_runner/plugin_runner.py +37 -23
- plugin_runner/plugin_synchronizer.py +10 -7
- plugin_runner/sandbox.py +2 -6
- plugin_runner/tests/test_application.py +65 -0
- plugin_runner/tests/test_plugin_installer.py +118 -0
- plugin_runner/tests/test_plugin_runner.py +4 -4
- plugin_runner/tests/test_sandbox.py +2 -2
- settings.py +5 -2
- {canvas-0.12.0.dist-info → canvas-0.13.1.dist-info}/WHEEL +0 -0
- {canvas-0.12.0.dist-info → canvas-0.13.1.dist-info}/entry_points.txt +0 -0
canvas_sdk/handlers/base.py
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import importlib.metadata
|
|
2
|
-
from abc import ABC
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
5
|
import deprecation
|
|
6
6
|
|
|
7
|
+
from canvas_sdk.effects import Effect
|
|
7
8
|
from canvas_sdk.events import Event
|
|
8
9
|
|
|
9
10
|
version = importlib.metadata.version("canvas")
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class BaseHandler(ABC):
|
|
13
|
-
"""
|
|
14
|
-
The class that all handlers inherit from.
|
|
15
|
-
"""
|
|
14
|
+
"""The class that all handlers inherit from."""
|
|
16
15
|
|
|
17
16
|
secrets: dict[str, Any]
|
|
18
17
|
event: Event
|
|
@@ -46,3 +45,8 @@ class BaseHandler(ABC):
|
|
|
46
45
|
def target(self) -> str:
|
|
47
46
|
"""The target id of the event."""
|
|
48
47
|
return self.event.target.id
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def compute(self) -> list[Effect]:
|
|
51
|
+
"""Compute the effects to be applied."""
|
|
52
|
+
pass
|
canvas_sdk/protocols/base.py
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class PluginError(Exception):
|
|
2
|
+
"""An exception raised for plugin-related errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PluginValidationError(PluginError):
|
|
6
|
+
"""An exception raised when a plugin package is not valid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidPluginFormat(PluginValidationError):
|
|
10
|
+
"""An exception raised when the plugin file format is not supported."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PluginInstallationError(PluginError):
|
|
14
|
+
"""An exception raised when a plugin fails to install."""
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tarfile
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, TypedDict
|
|
10
|
+
from urllib import parse
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
import psycopg
|
|
14
|
+
from psycopg import Connection
|
|
15
|
+
from psycopg.rows import dict_row
|
|
16
|
+
|
|
17
|
+
import settings
|
|
18
|
+
from plugin_runner.exceptions import InvalidPluginFormat, PluginInstallationError
|
|
19
|
+
|
|
20
|
+
# Plugin "packages" include this prefix in the database record for the plugin and the S3 bucket key.
|
|
21
|
+
UPLOAD_TO_PREFIX = "plugins"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_database_dict_from_url() -> dict[str, Any]:
|
|
25
|
+
"""Creates a psycopg ready dictionary from the home-app database URL."""
|
|
26
|
+
parsed_url = parse.urlparse(os.getenv("DATABASE_URL"))
|
|
27
|
+
db_name = parsed_url.path[1:]
|
|
28
|
+
return {
|
|
29
|
+
"dbname": db_name,
|
|
30
|
+
"user": parsed_url.username,
|
|
31
|
+
"password": parsed_url.password,
|
|
32
|
+
"host": parsed_url.hostname,
|
|
33
|
+
"port": parsed_url.port,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_database_dict_from_env() -> dict[str, Any]:
|
|
38
|
+
"""Creates a psycopg ready dictionary from the environment variables."""
|
|
39
|
+
APP_NAME = os.getenv("APP_NAME")
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"dbname": APP_NAME,
|
|
43
|
+
"user": os.getenv("DB_USERNAME", "app"),
|
|
44
|
+
"password": os.getenv("DB_PASSWORD", "app"),
|
|
45
|
+
"host": os.getenv("DB_HOST", f"{APP_NAME}-db"),
|
|
46
|
+
"port": os.getenv("DB_PORT", "5432"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def open_database_connection() -> Connection:
|
|
51
|
+
"""Opens a psycopg connection to the home-app database."""
|
|
52
|
+
# When running within Aptible, use the database URL, otherwise pull from the environment variables.
|
|
53
|
+
if os.getenv("DATABASE_URL"):
|
|
54
|
+
database_dict = get_database_dict_from_url()
|
|
55
|
+
else:
|
|
56
|
+
database_dict = get_database_dict_from_env()
|
|
57
|
+
conn = psycopg.connect(**database_dict)
|
|
58
|
+
return conn
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PluginAttributes(TypedDict):
|
|
62
|
+
"""Attributes of a plugin."""
|
|
63
|
+
|
|
64
|
+
version: str
|
|
65
|
+
package: str
|
|
66
|
+
secrets: dict[str, str]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def enabled_plugins() -> dict[str, PluginAttributes]:
|
|
70
|
+
"""Returns a dictionary of enabled plugins and their attributes."""
|
|
71
|
+
conn = open_database_connection()
|
|
72
|
+
|
|
73
|
+
with conn.cursor(row_factory=dict_row) as cursor:
|
|
74
|
+
cursor.execute(
|
|
75
|
+
"select name, package, version, key, value from plugin_io_plugin p "
|
|
76
|
+
"left join plugin_io_pluginsecret s on p.id = s.plugin_id where is_enabled"
|
|
77
|
+
)
|
|
78
|
+
rows = cursor.fetchall()
|
|
79
|
+
plugins = _extract_rows_to_dict(rows)
|
|
80
|
+
|
|
81
|
+
return plugins
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extract_rows_to_dict(rows: list) -> dict[str, PluginAttributes]:
|
|
85
|
+
plugins = {}
|
|
86
|
+
for row in rows:
|
|
87
|
+
if row["name"] not in plugins:
|
|
88
|
+
plugins[row["name"]] = PluginAttributes(
|
|
89
|
+
version=row["version"],
|
|
90
|
+
package=row["package"],
|
|
91
|
+
secrets={row["key"]: row["value"]} if row["key"] else {},
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
plugins[row["name"]]["secrets"][row["key"]] = row["value"]
|
|
95
|
+
return plugins
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@contextmanager
|
|
99
|
+
def download_plugin(plugin_package: str) -> Generator:
|
|
100
|
+
"""Download the plugin package from the S3 bucket."""
|
|
101
|
+
s3 = boto3.client("s3")
|
|
102
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
103
|
+
prefix_dir = Path(temp_dir) / UPLOAD_TO_PREFIX
|
|
104
|
+
prefix_dir.mkdir() # create an intermediate directory reflecting the prefix
|
|
105
|
+
download_path = Path(temp_dir) / plugin_package
|
|
106
|
+
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
|
+
)
|
|
112
|
+
yield download_path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def install_plugin(plugin_name: str, attributes: PluginAttributes) -> None:
|
|
116
|
+
"""Install the given Plugin's package into the runtime."""
|
|
117
|
+
try:
|
|
118
|
+
print(f"Installing plugin '{plugin_name}'")
|
|
119
|
+
|
|
120
|
+
plugin_installation_path = Path(settings.PLUGIN_DIRECTORY) / plugin_name
|
|
121
|
+
|
|
122
|
+
# if plugin exists, first uninstall it
|
|
123
|
+
if plugin_installation_path.exists():
|
|
124
|
+
uninstall_plugin(plugin_name)
|
|
125
|
+
|
|
126
|
+
with download_plugin(attributes["package"]) as plugin_file_path:
|
|
127
|
+
extract_plugin(plugin_file_path, plugin_installation_path)
|
|
128
|
+
|
|
129
|
+
install_plugin_secrets(plugin_name=plugin_name, secrets=attributes["secrets"])
|
|
130
|
+
except Exception as ex:
|
|
131
|
+
print(f"Failed to install plugin '{plugin_name}', version {attributes['version']}")
|
|
132
|
+
raise PluginInstallationError() from ex
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_plugin(plugin_file_path: Path, plugin_installation_path: Path) -> None:
|
|
136
|
+
"""Extract plugin in `file` to the given `path`."""
|
|
137
|
+
archive: tarfile.TarFile | None = None
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
if tarfile.is_tarfile(plugin_file_path):
|
|
141
|
+
try:
|
|
142
|
+
with open(plugin_file_path, "rb") as file:
|
|
143
|
+
archive = tarfile.TarFile.open(fileobj=file)
|
|
144
|
+
archive.extractall(plugin_installation_path, filter="data")
|
|
145
|
+
except tarfile.ReadError as ex:
|
|
146
|
+
print(f"Unreadable tar archive: '{plugin_file_path}'")
|
|
147
|
+
raise InvalidPluginFormat from ex
|
|
148
|
+
else:
|
|
149
|
+
print(f"Unsupported file format: '{plugin_file_path}'")
|
|
150
|
+
raise InvalidPluginFormat
|
|
151
|
+
finally:
|
|
152
|
+
if archive:
|
|
153
|
+
archive.close()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def install_plugin_secrets(plugin_name: str, secrets: dict[str, str]) -> None:
|
|
157
|
+
"""Write the plugin's secrets to disk in the package's directory."""
|
|
158
|
+
print(f"Writing plugin secrets for '{plugin_name}'")
|
|
159
|
+
|
|
160
|
+
secrets_path = Path(settings.PLUGIN_DIRECTORY) / plugin_name / settings.SECRETS_FILE_NAME
|
|
161
|
+
|
|
162
|
+
# Did the plugin ship a secrets.json? TOO BAD, IT'S GONE NOW.
|
|
163
|
+
if Path(secrets_path).exists():
|
|
164
|
+
os.remove(secrets_path)
|
|
165
|
+
|
|
166
|
+
with open(str(secrets_path), "w") as f:
|
|
167
|
+
json.dump(secrets, f)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def disable_plugin(plugin_name: str) -> None:
|
|
171
|
+
"""Disable the given plugin."""
|
|
172
|
+
conn = open_database_connection()
|
|
173
|
+
conn.cursor().execute(
|
|
174
|
+
"update plugin_io_plugin set is_enabled = false where name = %s", (plugin_name,)
|
|
175
|
+
)
|
|
176
|
+
conn.commit()
|
|
177
|
+
conn.close()
|
|
178
|
+
|
|
179
|
+
uninstall_plugin(plugin_name)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def uninstall_plugin(plugin_name: str) -> None:
|
|
183
|
+
"""Remove the plugin from the filesystem."""
|
|
184
|
+
plugin_path = Path(settings.PLUGIN_DIRECTORY) / plugin_name
|
|
185
|
+
|
|
186
|
+
if plugin_path.exists():
|
|
187
|
+
shutil.rmtree(plugin_path)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def install_plugins() -> None:
|
|
191
|
+
"""Install all enabled plugins."""
|
|
192
|
+
if Path(settings.PLUGIN_DIRECTORY).exists():
|
|
193
|
+
shutil.rmtree(settings.PLUGIN_DIRECTORY)
|
|
194
|
+
|
|
195
|
+
os.mkdir(settings.PLUGIN_DIRECTORY)
|
|
196
|
+
|
|
197
|
+
for plugin_name, attributes in enabled_plugins().items():
|
|
198
|
+
try:
|
|
199
|
+
print(f"Installing plugin '{plugin_name}', version {attributes['version']}")
|
|
200
|
+
install_plugin(plugin_name, attributes)
|
|
201
|
+
except PluginInstallationError:
|
|
202
|
+
disable_plugin(plugin_name)
|
|
203
|
+
print(
|
|
204
|
+
f"Installation failed for plugin '{plugin_name}', version {attributes['version']}. The plugin has been disabled"
|
|
205
|
+
)
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
return None
|
plugin_runner/plugin_runner.py
CHANGED
|
@@ -42,8 +42,8 @@ sys.path.append(PLUGIN_DIRECTORY)
|
|
|
42
42
|
# TODO: create typings here for the subkeys
|
|
43
43
|
LOADED_PLUGINS: dict = {}
|
|
44
44
|
|
|
45
|
-
# a global dictionary of events to
|
|
46
|
-
|
|
45
|
+
# a global dictionary of events to handler class names
|
|
46
|
+
EVENT_HANDLER_MAP: dict[str, list] = defaultdict(list)
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
class DataAccess(TypedDict):
|
|
@@ -63,6 +63,17 @@ Protocol = TypedDict(
|
|
|
63
63
|
)
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
ApplicationConfig = TypedDict(
|
|
67
|
+
"ApplicationConfig",
|
|
68
|
+
{
|
|
69
|
+
"class": str,
|
|
70
|
+
"description": str,
|
|
71
|
+
"icon": str,
|
|
72
|
+
"scope": str,
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
66
77
|
class Components(TypedDict):
|
|
67
78
|
"""Components."""
|
|
68
79
|
|
|
@@ -71,6 +82,7 @@ class Components(TypedDict):
|
|
|
71
82
|
content: list[dict]
|
|
72
83
|
effects: list[dict]
|
|
73
84
|
views: list[dict]
|
|
85
|
+
applications: list[ApplicationConfig]
|
|
74
86
|
|
|
75
87
|
|
|
76
88
|
class PluginManifest(TypedDict):
|
|
@@ -106,7 +118,7 @@ class PluginRunner(PluginRunnerServicer):
|
|
|
106
118
|
event = Event(request)
|
|
107
119
|
event_type = event.type
|
|
108
120
|
event_name = event.name
|
|
109
|
-
relevant_plugins =
|
|
121
|
+
relevant_plugins = EVENT_HANDLER_MAP[event_name]
|
|
110
122
|
|
|
111
123
|
if event_type in [EventType.PLUGIN_CREATED, EventType.PLUGIN_UPDATED]:
|
|
112
124
|
plugin_name = event.target.id
|
|
@@ -117,22 +129,22 @@ class PluginRunner(PluginRunnerServicer):
|
|
|
117
129
|
|
|
118
130
|
for plugin_name in relevant_plugins:
|
|
119
131
|
plugin = LOADED_PLUGINS[plugin_name]
|
|
120
|
-
|
|
132
|
+
handler_class = plugin["class"]
|
|
121
133
|
base_plugin_name = plugin_name.split(":")[0]
|
|
122
134
|
|
|
123
135
|
secrets = plugin.get("secrets", {})
|
|
124
136
|
secrets["graphql_jwt"] = token_for_plugin(plugin_name=plugin_name, audience="home")
|
|
125
137
|
|
|
126
138
|
try:
|
|
127
|
-
|
|
139
|
+
handler = handler_class(event, secrets)
|
|
128
140
|
classname = (
|
|
129
|
-
|
|
130
|
-
if isinstance(
|
|
141
|
+
handler.__class__.__name__
|
|
142
|
+
if isinstance(handler, ClinicalQualityMeasure)
|
|
131
143
|
else None
|
|
132
144
|
)
|
|
133
145
|
|
|
134
146
|
compute_start_time = time.time()
|
|
135
|
-
_effects = await asyncio.get_running_loop().run_in_executor(None,
|
|
147
|
+
_effects = await asyncio.get_running_loop().run_in_executor(None, handler.compute)
|
|
136
148
|
effects = [
|
|
137
149
|
Effect(
|
|
138
150
|
type=effect.type,
|
|
@@ -165,7 +177,7 @@ class PluginRunner(PluginRunnerServicer):
|
|
|
165
177
|
|
|
166
178
|
event_duration = get_duration_ms(event_start_time)
|
|
167
179
|
|
|
168
|
-
# Don't log anything if a
|
|
180
|
+
# Don't log anything if a plugin handler didn't actually run.
|
|
169
181
|
if relevant_plugins:
|
|
170
182
|
log.info(f"Responded to Event {event_name} ({event_duration} ms)")
|
|
171
183
|
statsd_tags = tags_to_line_protocol({"event": event_name})
|
|
@@ -275,7 +287,7 @@ def sandbox_from_module(package_path: pathlib.Path, module_name: str) -> Any:
|
|
|
275
287
|
|
|
276
288
|
full_module_name = f"{package_path.name}.{module_name}"
|
|
277
289
|
|
|
278
|
-
sandbox = Sandbox(source_code,
|
|
290
|
+
sandbox = Sandbox(source_code, namespace=full_module_name)
|
|
279
291
|
return sandbox.execute()
|
|
280
292
|
|
|
281
293
|
|
|
@@ -306,43 +318,45 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
|
|
|
306
318
|
|
|
307
319
|
# TODO add existing schema validation from Michela here
|
|
308
320
|
try:
|
|
309
|
-
|
|
321
|
+
handlers = manifest_json["components"].get("protocols", []) + manifest_json[
|
|
322
|
+
"components"
|
|
323
|
+
].get("applications", [])
|
|
310
324
|
results = sandbox_from_package(path)
|
|
311
325
|
except Exception as e:
|
|
312
326
|
log.error(f'Unable to load plugin "{name}": {str(e)}')
|
|
313
327
|
return
|
|
314
328
|
|
|
315
|
-
for
|
|
329
|
+
for handler in handlers:
|
|
316
330
|
# TODO add class colon validation to existing schema validation
|
|
317
331
|
# TODO when we encounter an exception here, disable the plugin in response
|
|
318
332
|
try:
|
|
319
|
-
|
|
320
|
-
name_and_class = f"{name}:{
|
|
333
|
+
handler_module, handler_class = handler["class"].split(":")
|
|
334
|
+
name_and_class = f"{name}:{handler_module}:{handler_class}"
|
|
321
335
|
except ValueError:
|
|
322
|
-
log.error(f"Unable to parse class for plugin '{name}': '{
|
|
336
|
+
log.error(f"Unable to parse class for plugin '{name}': '{handler['class']}'")
|
|
323
337
|
continue
|
|
324
338
|
|
|
325
339
|
try:
|
|
326
340
|
if name_and_class in LOADED_PLUGINS:
|
|
327
341
|
log.info(f"Reloading plugin '{name_and_class}'")
|
|
328
342
|
|
|
329
|
-
result = results[
|
|
343
|
+
result = results[handler_module]
|
|
330
344
|
|
|
331
345
|
LOADED_PLUGINS[name_and_class]["active"] = True
|
|
332
346
|
|
|
333
|
-
LOADED_PLUGINS[name_and_class]["class"] = result[
|
|
347
|
+
LOADED_PLUGINS[name_and_class]["class"] = result[handler_class]
|
|
334
348
|
LOADED_PLUGINS[name_and_class]["sandbox"] = result
|
|
335
349
|
LOADED_PLUGINS[name_and_class]["secrets"] = secrets_json
|
|
336
350
|
else:
|
|
337
351
|
log.info(f"Loading plugin '{name_and_class}'")
|
|
338
352
|
|
|
339
|
-
result = results[
|
|
353
|
+
result = results[handler_module]
|
|
340
354
|
|
|
341
355
|
LOADED_PLUGINS[name_and_class] = {
|
|
342
356
|
"active": True,
|
|
343
|
-
"class": result[
|
|
357
|
+
"class": result[handler_class],
|
|
344
358
|
"sandbox": result,
|
|
345
|
-
"
|
|
359
|
+
"handler": handler,
|
|
346
360
|
"secrets": secrets_json,
|
|
347
361
|
}
|
|
348
362
|
except Exception as err:
|
|
@@ -353,17 +367,17 @@ def load_or_reload_plugin(path: pathlib.Path) -> None:
|
|
|
353
367
|
|
|
354
368
|
def refresh_event_type_map() -> None:
|
|
355
369
|
"""Ensure the event subscriptions are up to date."""
|
|
356
|
-
|
|
370
|
+
EVENT_HANDLER_MAP.clear()
|
|
357
371
|
|
|
358
372
|
for name, plugin in LOADED_PLUGINS.items():
|
|
359
373
|
if hasattr(plugin["class"], "RESPONDS_TO"):
|
|
360
374
|
responds_to = plugin["class"].RESPONDS_TO
|
|
361
375
|
|
|
362
376
|
if isinstance(responds_to, str):
|
|
363
|
-
|
|
377
|
+
EVENT_HANDLER_MAP[responds_to].append(name)
|
|
364
378
|
elif isinstance(responds_to, list):
|
|
365
379
|
for event in responds_to:
|
|
366
|
-
|
|
380
|
+
EVENT_HANDLER_MAP[event].append(name)
|
|
367
381
|
else:
|
|
368
382
|
log.warning(f"Unknown RESPONDS_TO type: {type(responds_to)}")
|
|
369
383
|
|
|
@@ -7,6 +7,8 @@ from subprocess import STDOUT, CalledProcessError, check_output
|
|
|
7
7
|
|
|
8
8
|
import redis
|
|
9
9
|
|
|
10
|
+
from plugin_runner.plugin_installer import install_plugins
|
|
11
|
+
|
|
10
12
|
APP_NAME = os.getenv("APP_NAME")
|
|
11
13
|
|
|
12
14
|
CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER")
|
|
@@ -43,6 +45,11 @@ def publish_message(message: dict) -> None:
|
|
|
43
45
|
def main() -> None:
|
|
44
46
|
"""Listen for messages on the pubsub channel and restart the plugin-runner."""
|
|
45
47
|
print("plugin-synchronizer: starting")
|
|
48
|
+
try:
|
|
49
|
+
print("plugin-synchronizer: installing plugins after web container start")
|
|
50
|
+
install_plugins()
|
|
51
|
+
except CalledProcessError as e:
|
|
52
|
+
print("plugin-synchronizer: `install_plugins` failed:", e)
|
|
46
53
|
|
|
47
54
|
_, pubsub = get_client()
|
|
48
55
|
|
|
@@ -62,17 +69,13 @@ def main() -> None:
|
|
|
62
69
|
if "action" not in data or "client_id" not in data:
|
|
63
70
|
return
|
|
64
71
|
|
|
65
|
-
# Don't respond to our own messages
|
|
66
|
-
if data["client_id"] == CLIENT_ID:
|
|
67
|
-
return
|
|
68
|
-
|
|
69
72
|
if data["action"] == "restart":
|
|
70
73
|
# Run the plugin installer process
|
|
71
74
|
try:
|
|
72
|
-
print("plugin-synchronizer: installing plugins")
|
|
73
|
-
|
|
75
|
+
print("plugin-synchronizer: installing plugins after receiving restart message")
|
|
76
|
+
install_plugins()
|
|
74
77
|
except CalledProcessError as e:
|
|
75
|
-
print("plugin-synchronizer:
|
|
78
|
+
print("plugin-synchronizer: `install_plugins` failed:", e)
|
|
76
79
|
|
|
77
80
|
try:
|
|
78
81
|
print("plugin-synchronizer: sending SIGHUP to plugin-runner")
|
plugin_runner/sandbox.py
CHANGED
|
@@ -93,7 +93,6 @@ class Sandbox:
|
|
|
93
93
|
|
|
94
94
|
source_code: str
|
|
95
95
|
namespace: str
|
|
96
|
-
module_name: str | None
|
|
97
96
|
|
|
98
97
|
class Transformer(RestrictingNodeTransformer):
|
|
99
98
|
"""A node transformer for customizing the sandbox compiler."""
|
|
@@ -200,19 +199,16 @@ class Sandbox:
|
|
|
200
199
|
# Impossible Case only ctx Load, Store and Del are defined in ast.
|
|
201
200
|
raise NotImplementedError(f"Unknown ctx type: {type(node.ctx)}")
|
|
202
201
|
|
|
203
|
-
def __init__(
|
|
204
|
-
self, source_code: str, namespace: str | None = None, module_name: str | None = None
|
|
205
|
-
) -> None:
|
|
202
|
+
def __init__(self, source_code: str, namespace: str | None = None) -> None:
|
|
206
203
|
if source_code is None:
|
|
207
204
|
raise TypeError("source_code may not be None")
|
|
208
|
-
self.module_name = module_name
|
|
209
205
|
self.namespace = namespace or "protocols"
|
|
210
206
|
self.source_code = source_code
|
|
211
207
|
|
|
212
208
|
@cached_property
|
|
213
209
|
def package_name(self) -> str | None:
|
|
214
210
|
"""Return the root package name."""
|
|
215
|
-
return self.
|
|
211
|
+
return self.namespace.split(".")[0] if self.namespace else None
|
|
216
212
|
|
|
217
213
|
@cached_property
|
|
218
214
|
def scope(self) -> dict[str, Any]:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from canvas_sdk.effects import Effect
|
|
4
|
+
from canvas_sdk.effects.launch_modal import LaunchModalEffect
|
|
5
|
+
from canvas_sdk.events import Event, EventRequest, EventType
|
|
6
|
+
from canvas_sdk.handlers.application import Application
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestApplication(Application):
|
|
10
|
+
"""A concrete implementation of the Application class for testing."""
|
|
11
|
+
|
|
12
|
+
def on_open(self) -> Effect:
|
|
13
|
+
"""Handle the application open event by returning a mock effect."""
|
|
14
|
+
return LaunchModalEffect(url="https://example.com").apply()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def app_instance(event: Event) -> TestApplication:
|
|
19
|
+
"""Provide an instance of the TestApplication with a mocked event."""
|
|
20
|
+
app = TestApplication(event)
|
|
21
|
+
return app
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_compute_event_not_targeted() -> None:
|
|
25
|
+
"""Test that compute filters out events not targeted for the app."""
|
|
26
|
+
request = EventRequest(type=EventType.APPLICATION__ON_OPEN, target="some_identifier")
|
|
27
|
+
event = Event(request)
|
|
28
|
+
app = TestApplication(event)
|
|
29
|
+
|
|
30
|
+
result = app.compute()
|
|
31
|
+
|
|
32
|
+
assert result == [], "Expected no effects if the event target is not the app identifier"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_compute_event_targeted() -> None:
|
|
36
|
+
"""Test that compute processes events targeted for the app."""
|
|
37
|
+
request = EventRequest(
|
|
38
|
+
type=EventType.APPLICATION__ON_OPEN,
|
|
39
|
+
target=f"{TestApplication.__module__}:{TestApplication.__qualname__}",
|
|
40
|
+
)
|
|
41
|
+
event = Event(request)
|
|
42
|
+
app = TestApplication(event)
|
|
43
|
+
result = app.compute()
|
|
44
|
+
|
|
45
|
+
assert len(result) == 1, "Expected a single effect if the event target is the app identifier"
|
|
46
|
+
assert isinstance(result[0], Effect), "Effect should be an instance of Effect"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_identifier_property() -> None:
|
|
50
|
+
"""Test the identifier property of the Application class."""
|
|
51
|
+
expected_identifier = f"{TestApplication.__module__}:{TestApplication.__qualname__}"
|
|
52
|
+
request = EventRequest(
|
|
53
|
+
type=EventType.APPLICATION__ON_OPEN,
|
|
54
|
+
target=f"{TestApplication.__module__}:{TestApplication.__qualname__}",
|
|
55
|
+
)
|
|
56
|
+
event = Event(request)
|
|
57
|
+
app = TestApplication(event)
|
|
58
|
+
|
|
59
|
+
assert app.identifier == expected_identifier, "The identifier property is incorrect"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_abstract_method_on_open() -> None:
|
|
63
|
+
"""Test that the abstract method on_open must be implemented."""
|
|
64
|
+
with pytest.raises(TypeError):
|
|
65
|
+
Application(Event(EventRequest(type=EventType.UNKNOWN))) # type: ignore[abstract]
|