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.

Files changed (31) hide show
  1. {canvas-0.12.0.dist-info → canvas-0.13.1.dist-info}/METADATA +3 -1
  2. {canvas-0.12.0.dist-info → canvas-0.13.1.dist-info}/RECORD +31 -19
  3. canvas_cli/apps/plugin/plugin.py +13 -4
  4. canvas_cli/templates/plugins/application/cookiecutter.json +4 -0
  5. canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json +28 -0
  6. canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/README.md +11 -0
  7. canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/applications/__init__.py +0 -0
  8. canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/applications/my_application.py +12 -0
  9. canvas_cli/templates/plugins/application/{{ cookiecutter.__project_slug }}/assets/python-logo.png +0 -0
  10. canvas_cli/utils/validators/manifest_schema.py +18 -1
  11. canvas_generated/messages/effects_pb2.py +2 -2
  12. canvas_generated/messages/effects_pb2.pyi +2 -0
  13. canvas_generated/messages/events_pb2.py +2 -2
  14. canvas_generated/messages/events_pb2.pyi +2 -0
  15. canvas_sdk/effects/__init__.py +3 -1
  16. canvas_sdk/effects/launch_modal.py +24 -0
  17. canvas_sdk/handlers/application.py +29 -0
  18. canvas_sdk/handlers/base.py +8 -4
  19. canvas_sdk/protocols/base.py +3 -1
  20. plugin_runner/exceptions.py +14 -0
  21. plugin_runner/plugin_installer.py +208 -0
  22. plugin_runner/plugin_runner.py +37 -23
  23. plugin_runner/plugin_synchronizer.py +10 -7
  24. plugin_runner/sandbox.py +2 -6
  25. plugin_runner/tests/test_application.py +65 -0
  26. plugin_runner/tests/test_plugin_installer.py +118 -0
  27. plugin_runner/tests/test_plugin_runner.py +4 -4
  28. plugin_runner/tests/test_sandbox.py +2 -2
  29. settings.py +5 -2
  30. {canvas-0.12.0.dist-info → canvas-0.13.1.dist-info}/WHEEL +0 -0
  31. {canvas-0.12.0.dist-info → canvas-0.13.1.dist-info}/entry_points.txt +0 -0
@@ -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
@@ -1,7 +1,9 @@
1
+ from abc import ABC
2
+
1
3
  from canvas_sdk.handlers.base import BaseHandler
2
4
 
3
5
 
4
- class BaseProtocol(BaseHandler):
6
+ class BaseProtocol(BaseHandler, ABC):
5
7
  """
6
8
  The class that protocols inherit from.
7
9
  """
@@ -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
@@ -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 protocol class names
46
- EVENT_PROTOCOL_MAP: dict[str, list] = defaultdict(list)
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 = EVENT_PROTOCOL_MAP[event_name]
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
- protocol_class = plugin["class"]
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
- protocol = protocol_class(event, secrets)
139
+ handler = handler_class(event, secrets)
128
140
  classname = (
129
- protocol.__class__.__name__
130
- if isinstance(protocol, ClinicalQualityMeasure)
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, protocol.compute)
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 protocol didn't actually run.
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, module_name=full_module_name)
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
- protocols = manifest_json["components"]["protocols"]
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 protocol in protocols:
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
- protocol_module, protocol_class = protocol["class"].split(":")
320
- name_and_class = f"{name}:{protocol_module}:{protocol_class}"
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}': '{protocol['class']}'")
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[protocol_module]
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[protocol_class]
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[protocol_module]
353
+ result = results[handler_module]
340
354
 
341
355
  LOADED_PLUGINS[name_and_class] = {
342
356
  "active": True,
343
- "class": result[protocol_class],
357
+ "class": result[handler_class],
344
358
  "sandbox": result,
345
- "protocol": protocol,
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
- EVENT_PROTOCOL_MAP.clear()
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
- EVENT_PROTOCOL_MAP[responds_to].append(name)
377
+ EVENT_HANDLER_MAP[responds_to].append(name)
364
378
  elif isinstance(responds_to, list):
365
379
  for event in responds_to:
366
- EVENT_PROTOCOL_MAP[event].append(name)
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
- 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")
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.module_name.split(".")[0] if self.module_name else None
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]