Skip to content

Commit c81cc7e

Browse files
committed
rename dynamicapplication to EmbeddedApplication. Added scopes and tests
1 parent 0e2687f commit c81cc7e

File tree

4 files changed

+437
-55
lines changed

4 files changed

+437
-55
lines changed

canvas_sdk/handlers/application.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import warnings
1+
import importlib.metadata
22
from abc import ABC, abstractmethod
3+
from enum import StrEnum
4+
5+
import deprecation
36

47
from canvas_sdk.effects import Effect
58
from canvas_sdk.effects.show_application import ShowApplicationEffect
69
from canvas_sdk.events import EventType
710
from canvas_sdk.handlers import BaseHandler
811
from canvas_sdk.handlers.utils import normalize_effects
912

13+
version = importlib.metadata.version("canvas")
14+
1015

1116
class Application(BaseHandler, ABC):
1217
"""An embeddable application that can be registered to Canvas."""
@@ -44,19 +49,25 @@ def identifier(self) -> str:
4449
return f"{self.__class__.__module__}:{self.__class__.__qualname__}"
4550

4651

47-
class DynamicApplication(Application, ABC):
52+
class ApplicationScope(StrEnum):
53+
"""Available scopes for embedded applications."""
54+
55+
NOTE = "note"
56+
57+
58+
class EmbeddedApplication(Application, ABC):
4859
"""An embeddable application that can be registered to Canvas."""
4960

5061
NAME: str
51-
SCOPE: str
62+
SCOPE: ApplicationScope
5263
IDENTIFIER: str | None = None
5364
PRIORITY: int = 0
5465

5566
def compute(self) -> list[Effect]:
5667
"""Handle the application events."""
5768
match self.event.type:
5869
case EventType.APPLICATION__ON_GET:
59-
if self.visible():
70+
if self._matches_scope() and self.visible():
6071
return [
6172
ShowApplicationEffect(
6273
name=self.NAME,
@@ -69,47 +80,49 @@ def compute(self) -> list[Effect]:
6980
case _:
7081
return super().compute()
7182

83+
def _matches_scope(self) -> bool:
84+
"""Check if the event scope matches the application scope."""
85+
return self.event.context.get("scope") == self.SCOPE
86+
7287
def open_by_default(self) -> bool:
7388
"""Open the application by default."""
7489
return False
7590

7691
def visible(self) -> bool:
7792
"""Determine whether the application should be visible."""
78-
return self.context.get("scope") == self.SCOPE
93+
return True
7994

8095
@property
8196
def identifier(self) -> str:
8297
"""The application identifier."""
8398
return self.IDENTIFIER if self.IDENTIFIER else super().identifier
8499

85100

86-
class NoteApplication(DynamicApplication):
101+
class NoteApplication(EmbeddedApplication):
87102
"""An Application that can be shown in a note."""
88103

89-
SCOPE = "note"
104+
SCOPE = ApplicationScope.NOTE
90105

91106
def on_open(self) -> Effect | list[Effect]:
92107
"""Delegate to handle() for backward compatibility with old plugins."""
93108
# If a subclass overrides handle(), call it for backward compat.
94109
# New plugins should override on_open() directly.
95110
return self.handle()
96111

112+
@deprecation.deprecated(
113+
deprecated_in="0.111.0",
114+
removed_in="1.0.0",
115+
current_version=version,
116+
details="Use 'on_open' instead",
117+
)
97118
def handle(self) -> list[Effect]:
98-
"""Method to handle application click/on_open.
99-
100-
.. deprecated::
101-
Override :meth:`on_open` instead.
102-
"""
103-
warnings.warn(
104-
"NoteApplication.handle() is deprecated. Override on_open() instead.",
105-
DeprecationWarning,
106-
stacklevel=2,
107-
)
119+
"""Method to handle application click/on_open."""
108120
return []
109121

110122

111123
__exports__ = (
112124
"Application",
113-
"DynamicApplication",
125+
"ApplicationScope",
126+
"EmbeddedApplication",
114127
"NoteApplication",
115128
)
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import json
2+
3+
import pytest
4+
5+
from canvas_sdk.effects import Effect, EffectType
6+
from canvas_sdk.effects.launch_modal import LaunchModalEffect
7+
from canvas_sdk.events import Event, EventRequest, EventType
8+
from canvas_sdk.handlers.application import ApplicationScope, EmbeddedApplication
9+
10+
11+
class ExampleEmbeddedApplication(EmbeddedApplication):
12+
"""A concrete EmbeddedApplication scoped to 'note' for testing."""
13+
14+
NAME = "Test Embedded App"
15+
SCOPE = ApplicationScope.NOTE
16+
IDENTIFIER = "test_plugin__embedded_app"
17+
18+
def on_open(self) -> list[Effect]:
19+
"""On open method."""
20+
return [LaunchModalEffect(url="https://example.com/embedded").apply()]
21+
22+
def on_context_change(self) -> list[Effect]:
23+
"""On context change method."""
24+
return [LaunchModalEffect(url="https://example.com/context").apply()]
25+
26+
27+
class CustomVisibilityApp(EmbeddedApplication):
28+
"""An EmbeddedApplication with custom visibility logic."""
29+
30+
NAME = "Custom Visibility"
31+
SCOPE = ApplicationScope.NOTE
32+
33+
def visible(self) -> bool:
34+
"""Return True if the application is visible."""
35+
return self.event.context.get("show_app") is True
36+
37+
def on_open(self) -> list[Effect]:
38+
"""On open method."""
39+
return []
40+
41+
42+
class DefaultOpenApp(EmbeddedApplication):
43+
"""An EmbeddedApplication that opens by default with custom priority."""
44+
45+
NAME = "Default Open"
46+
SCOPE = ApplicationScope.NOTE
47+
PRIORITY = 10
48+
49+
def open_by_default(self) -> bool:
50+
"""Application open by default."""
51+
return True
52+
53+
def on_open(self) -> list[Effect]:
54+
"""On open method."""
55+
return []
56+
57+
58+
class NoIdentifierApp(EmbeddedApplication):
59+
"""An EmbeddedApplication without an explicit IDENTIFIER."""
60+
61+
NAME = "No ID"
62+
SCOPE = ApplicationScope.NOTE
63+
64+
def on_open(self) -> list[Effect]:
65+
"""On open method."""
66+
return []
67+
68+
69+
def _make_event(
70+
event_type: EventType,
71+
target: str = "",
72+
context: dict | None = None,
73+
) -> Event:
74+
"""Create an Event from the given type, target, and context."""
75+
return Event(
76+
EventRequest(
77+
type=event_type,
78+
target=target,
79+
context=json.dumps(context or {}),
80+
)
81+
)
82+
83+
84+
def _make_on_get_event(scope: str = "note", **extra_context: object) -> Event:
85+
"""Create an APPLICATION__ON_GET event with the given scope and optional extra context."""
86+
return _make_event(EventType.APPLICATION__ON_GET, context={"scope": scope, **extra_context})
87+
88+
89+
def test_embedded_application_is_abstract() -> None:
90+
"""Verify EmbeddedApplication cannot be instantiated directly."""
91+
with pytest.raises(TypeError):
92+
EmbeddedApplication( # type: ignore[abstract]
93+
_make_on_get_event()
94+
)
95+
96+
97+
def test_on_get_returns_show_application_effect() -> None:
98+
"""Verify ON_GET returns a SHOW_APPLICATION effect with the correct payload."""
99+
app = ExampleEmbeddedApplication(_make_on_get_event())
100+
result = app.compute()
101+
102+
assert len(result) == 1
103+
assert result[0].type == EffectType.SHOW_APPLICATION
104+
payload = json.loads(result[0].payload)["data"]
105+
assert payload["name"] == "Test Embedded App"
106+
assert payload["identifier"] == "test_plugin__embedded_app"
107+
assert payload["open_by_default"] is False
108+
assert payload["priority"] == 0
109+
110+
111+
def test_on_get_respects_open_by_default_and_priority() -> None:
112+
"""Verify ON_GET includes open_by_default and priority in the effect payload."""
113+
app = DefaultOpenApp(_make_on_get_event())
114+
result = app.compute()
115+
116+
assert len(result) == 1
117+
payload = json.loads(result[0].payload)["data"]
118+
assert payload["open_by_default"] is True
119+
assert payload["priority"] == 10
120+
121+
122+
@pytest.mark.parametrize(
123+
"scope,extra_context,app_class,reason",
124+
[
125+
("chart", {}, ExampleEmbeddedApplication, "scope does not match"),
126+
("note", {}, CustomVisibilityApp, "visible() returns False"),
127+
("chart", {"show_app": True}, CustomVisibilityApp, "scope mismatch overrides visible()"),
128+
],
129+
ids=["wrong-scope", "not-visible", "scope-overrides-visible"],
130+
)
131+
def test_on_get_returns_empty(
132+
scope: str,
133+
extra_context: dict,
134+
app_class: type[EmbeddedApplication],
135+
reason: str,
136+
) -> None:
137+
"""Verify ON_GET returns no effects when the application should not be shown."""
138+
app = app_class(_make_on_get_event(scope=scope, **extra_context))
139+
assert app.compute() == [], reason
140+
141+
142+
def test_on_get_with_custom_visibility() -> None:
143+
"""Verify ON_GET respects custom visible() logic when scope matches."""
144+
app = CustomVisibilityApp(_make_on_get_event(scope="note", show_app=True))
145+
result = app.compute()
146+
147+
assert len(result) == 1
148+
assert result[0].type == EffectType.SHOW_APPLICATION
149+
150+
151+
@pytest.mark.parametrize(
152+
"app_class,expected_identifier",
153+
[
154+
(ExampleEmbeddedApplication, "test_plugin__embedded_app"),
155+
(
156+
NoIdentifierApp,
157+
f"{NoIdentifierApp.__module__}:{NoIdentifierApp.__qualname__}",
158+
),
159+
],
160+
ids=["explicit-identifier", "fallback-to-module-qualname"],
161+
)
162+
def test_identifier_resolution(
163+
app_class: type[EmbeddedApplication], expected_identifier: str
164+
) -> None:
165+
"""Verify identifier uses IDENTIFIER when set, otherwise falls back to module:qualname."""
166+
app = app_class(_make_on_get_event())
167+
assert app.identifier == expected_identifier
168+
169+
170+
def test_on_open_dispatches_to_on_open() -> None:
171+
"""Verify ON_OPEN dispatches to on_open() when the target matches."""
172+
target = "test_plugin__embedded_app"
173+
event = _make_event(EventType.APPLICATION__ON_OPEN, target=target)
174+
app = ExampleEmbeddedApplication(event)
175+
result = app.compute()
176+
177+
assert len(result) == 1
178+
assert result[0].type == EffectType.LAUNCH_MODAL
179+
180+
181+
def test_on_open_returns_empty_when_target_does_not_match() -> None:
182+
"""Verify ON_OPEN returns no effects when the target does not match."""
183+
event = _make_event(EventType.APPLICATION__ON_OPEN, target="wrong")
184+
app = ExampleEmbeddedApplication(event)
185+
assert app.compute() == []
186+
187+
188+
def test_on_context_change_dispatches_when_target_matches() -> None:
189+
"""Verify ON_CONTEXT_CHANGE dispatches to on_context_change() when the target matches."""
190+
target = "test_plugin__embedded_app"
191+
event = _make_event(EventType.APPLICATION__ON_CONTEXT_CHANGE, target=target)
192+
app = ExampleEmbeddedApplication(event)
193+
result = app.compute()
194+
195+
assert len(result) == 1
196+
assert result[0].type == EffectType.LAUNCH_MODAL
197+
198+
199+
def test_on_context_change_returns_empty_when_target_does_not_match() -> None:
200+
"""Verify ON_CONTEXT_CHANGE returns no effects when the target does not match."""
201+
event = _make_event(EventType.APPLICATION__ON_CONTEXT_CHANGE, target="wrong")
202+
app = ExampleEmbeddedApplication(event)
203+
assert app.compute() == []
204+
205+
206+
def test_unknown_event_type_returns_empty() -> None:
207+
"""Verify compute returns no effects for an unhandled event type."""
208+
target = "test_plugin__embedded_app"
209+
event = _make_event(EventType.UNKNOWN, target=target)
210+
app = ExampleEmbeddedApplication(event)
211+
assert app.compute() == []

0 commit comments

Comments
 (0)