canvas 0.13.0__py3-none-any.whl → 0.13.2__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: canvas
3
- Version: 0.13.0
3
+ Version: 0.13.2
4
4
  Summary: SDK to customize event-driven actions in your Canvas instance
5
5
  License: MIT
6
6
  Author: Canvas Team
@@ -10,6 +10,8 @@ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Dist: boto3 (>=1.35.88,<2.0.0)
14
+ Requires-Dist: boto3-stubs[s3] (>=1.35.88,<2.0.0)
13
15
  Requires-Dist: cookiecutter
14
16
  Requires-Dist: cron-converter (>=1.2.1,<2.0.0)
15
17
  Requires-Dist: deprecation (>=2.1.0,<3.0.0)
@@ -232,8 +232,10 @@ logger/__init__.py,sha256=9o2iRCjzFEhfULgXvrrECRFK-4IslWJTqKKjTCEUbq8,61
232
232
  logger/logger.py,sha256=axf7UffBJtETjwDCtmi1IaaJKsvcFj8zaLfouGsq68A,1847
233
233
  plugin_runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
234
234
  plugin_runner/authentication.py,sha256=SDPso2AogtLAV_H0LuMDp99IMZuF3oTq-Q_AXAvJ8uc,1116
235
+ plugin_runner/exceptions.py,sha256=YnRZiQVzbU3HrVlmEXLje_np99009YnhTRVHHyBCtqc,433
236
+ plugin_runner/plugin_installer.py,sha256=Ah3A1j6vO8wi4HPRYVlxF1w-GqbuTvLmDCQvLQK3Oy8,7020
235
237
  plugin_runner/plugin_runner.py,sha256=at5BTolHnVWQci-GTUbvJsI-hsjeR9lDCnSz9OhIJ34,15296
236
- plugin_runner/plugin_synchronizer.py,sha256=t3zzfDw-bDK_hvUDQ434qaQuKLGgBGndzaCRAnSpuTU,2554
238
+ plugin_runner/plugin_synchronizer.py,sha256=EwBpKopmArjXxsE9hWpvHRSaVjCq4mg8kXxK3hQMeCk,2684
237
239
  plugin_runner/sandbox.py,sha256=JUbphdu1iY0rPU6DdMEjFA32BBsZXHUz1KguCYpmrug,9384
238
240
  plugin_runner/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
239
241
  plugin_runner/tests/data/plugins/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -267,12 +269,13 @@ plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/other_module/bas
267
269
  plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
268
270
  plugin_runner/tests/fixtures/plugins/test_module_imports_plugin/protocols/my_protocol.py,sha256=V_6DPXsJaD12zGyKqGjYY2JFtKEk4E0eIAgBWax_MGc,629
269
271
  plugin_runner/tests/test_application.py,sha256=ZirW14UGorfkQ_VSbnoKtmTvx6lHFS2EqndYNczIQfo,2391
272
+ plugin_runner/tests/test_plugin_installer.py,sha256=BETY7stpf_DZdSBLSSBc_NeAWcLU0m_cptG8C8_rk1Q,3937
270
273
  plugin_runner/tests/test_plugin_runner.py,sha256=pj9C3-GNsTyUYNIkOiihmxAO67FQE1GU5BoM727SnxA,7539
271
274
  plugin_runner/tests/test_sandbox.py,sha256=6Jw2Akcl1KQpXb01FxDvI_ZToJzOcpABwFkXK3fl8BU,3434
272
275
  pubsub/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
273
276
  pubsub/pubsub.py,sha256=pyTW0JU8mtaqiAV6g6xjZwel1CVy2EonPMU-_vkmhUM,1044
274
- settings.py,sha256=6YTybI0eNeuDamnXdRrEUOBICf9bM1uW1lCwj4IR9sU,2081
275
- canvas-0.13.0.dist-info/METADATA,sha256=DQc9FKPwqLuUlsFahpNTTT8BPJ5X58Mmuo7EU4FgRKo,4703
276
- canvas-0.13.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
277
- canvas-0.13.0.dist-info/entry_points.txt,sha256=VSmSo1IZ3aEfL7enmLmlWSraS_IIkoXNVeyXzgRxFiY,46
278
- canvas-0.13.0.dist-info/RECORD,,
277
+ settings.py,sha256=UG0jV2Sht7ftEJjqEyLKn9fld0jAtblqO7oJdfjNYvA,2144
278
+ canvas-0.13.2.dist-info/METADATA,sha256=JikPMsbC7-iYOeMXZv-pSWyq06V0H3D1Des1-IFnfro,4793
279
+ canvas-0.13.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
280
+ canvas-0.13.2.dist-info/entry_points.txt,sha256=VSmSo1IZ3aEfL7enmLmlWSraS_IIkoXNVeyXzgRxFiY,46
281
+ canvas-0.13.2.dist-info/RECORD,,
@@ -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
@@ -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
- check_output(["./manage.py", "install_plugins_v2"], cwd="/app", stderr=STDOUT)
75
+ print("plugin-synchronizer: installing plugins after receiving restart message")
76
+ install_plugins()
74
77
  except CalledProcessError as e:
75
- print("plugin-synchronizer: `./manage.py install_plugins_v2` failed:", e)
78
+ print("plugin-synchronizer: `install_plugins` failed:", e)
76
79
 
77
80
  try:
78
81
  print("plugin-synchronizer: sending SIGHUP to plugin-runner")
@@ -0,0 +1,118 @@
1
+ import json
2
+ import tarfile
3
+ import tempfile
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock
6
+
7
+ from pytest_mock import MockerFixture
8
+
9
+ import settings
10
+ from plugin_runner.plugin_installer import (
11
+ PluginAttributes,
12
+ _extract_rows_to_dict,
13
+ download_plugin,
14
+ install_plugins,
15
+ uninstall_plugin,
16
+ )
17
+
18
+
19
+ def _create_tarball(name: str) -> Path:
20
+ # Create a temporary tarball file
21
+ temp_dir = tempfile.mkdtemp()
22
+ tarball_path = Path(temp_dir) / f"{name}.tar.gz"
23
+
24
+ # Add some files to the tarball
25
+ with tarfile.open(tarball_path, "w:gz") as tar:
26
+ for i in range(3):
27
+ file_path = Path(temp_dir) / f"file{i}.txt"
28
+ file_path.write_text(f"Content of file {i}")
29
+ tar.add(file_path, arcname=f"file{i}.txt")
30
+
31
+ # Return a Path handle to the tarball
32
+ return tarball_path
33
+
34
+
35
+ def test_extract_rows_to_dict() -> None:
36
+ """Test that database rows can be extracted to a dictionary with secrets appropriately attributed to plugin."""
37
+ rows = [
38
+ {
39
+ "name": "plugin1",
40
+ "version": "1.0",
41
+ "package": "package1",
42
+ "key": "key1",
43
+ "value": "value1",
44
+ },
45
+ {
46
+ "name": "plugin1",
47
+ "version": "1.0",
48
+ "package": "package1",
49
+ "key": "key2",
50
+ "value": "value2",
51
+ },
52
+ {"name": "plugin2", "version": "2.0", "package": "package2", "key": None, "value": None},
53
+ ]
54
+
55
+ expected_output = {
56
+ "plugin1": {
57
+ "version": "1.0",
58
+ "package": "package1",
59
+ "secrets": {"key1": "value1", "key2": "value2"},
60
+ },
61
+ "plugin2": {
62
+ "version": "2.0",
63
+ "package": "package2",
64
+ "secrets": {},
65
+ },
66
+ }
67
+
68
+ result = _extract_rows_to_dict(rows)
69
+ assert result == expected_output
70
+
71
+
72
+ def test_plugin_installation_from_tarball(mocker: MockerFixture) -> None:
73
+ """Test that plugins can be installed from tarballs."""
74
+ mock_plugins = {
75
+ "plugin1": PluginAttributes(
76
+ version="1.0", package="plugins/plugin1.tar.gz", secrets={"key1": "value1"}
77
+ ),
78
+ "plugin2": PluginAttributes(
79
+ version="1.0", package="plugins/plugin2.tar", secrets={"key2": "value2"}
80
+ ),
81
+ }
82
+
83
+ tarball_1 = _create_tarball("plugin1")
84
+ tarball_2 = _create_tarball("plugin2")
85
+
86
+ mocker.patch("plugin_runner.plugin_installer.enabled_plugins", return_value=mock_plugins)
87
+ mocker.patch(
88
+ "plugin_runner.plugin_installer.download_plugin", side_effect=[tarball_1, tarball_2]
89
+ )
90
+
91
+ install_plugins()
92
+ assert Path("plugin_runner/tests/data/plugins/plugin1").exists()
93
+ assert Path("plugin_runner/tests/data/plugins/plugin1/SECRETS.json").exists()
94
+ with open("plugin_runner/tests/data/plugins/plugin1/SECRETS.json") as f:
95
+ assert json.load(f) == mock_plugins["plugin1"]["secrets"]
96
+ assert Path("plugin_runner/tests/data/plugins/plugin2").exists()
97
+ assert Path("plugin_runner/tests/data/plugins/plugin2/SECRETS.json").exists()
98
+ with open("plugin_runner/tests/data/plugins/plugin2/SECRETS.json") as f:
99
+ assert json.load(f) == mock_plugins["plugin2"]["secrets"]
100
+
101
+ uninstall_plugin("plugin1")
102
+ uninstall_plugin("plugin2")
103
+ assert not Path("plugin_runner/tests/data/plugins/plugin1").exists()
104
+ assert not Path("plugin_runner/tests/data/plugins/plugin2").exists()
105
+
106
+
107
+ def test_download(mocker: MockerFixture) -> None:
108
+ """Test that the plugin package can be written to disk, mocking out S3."""
109
+ mock_s3_client = MagicMock()
110
+ mocker.patch("boto3.client", return_value=mock_s3_client)
111
+
112
+ plugin_package = "plugins/plugin1.tar.gz"
113
+ with download_plugin(plugin_package) as plugin_path:
114
+ assert plugin_path.exists()
115
+
116
+ mock_s3_client.download_fileobj.assert_called_once_with(
117
+ "canvas-client-media", f"{settings.CUSTOMER_IDENTIFIER}/{plugin_package}", mocker.ANY
118
+ )
settings.py CHANGED
@@ -11,7 +11,7 @@ load_dotenv()
11
11
  ENV = os.getenv("ENV", "development")
12
12
  IS_PRODUCTION = ENV == "production"
13
13
  IS_TESTING = env_to_bool("IS_TESTING", "pytest" in sys.argv[0] or sys.argv[0] == "-c")
14
-
14
+ CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER", "local")
15
15
 
16
16
  INTEGRATION_TEST_URL = os.getenv("INTEGRATION_TEST_URL")
17
17
  INTEGRATION_TEST_CLIENT_ID = os.getenv("INTEGRATION_TEST_CLIENT_ID")