Skip to content

Commit 03a46ea

Browse files
committed
protocols → handlers
1 parent 07b3cae commit 03a46ea

File tree

126 files changed

+349
-284
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

126 files changed

+349
-284
lines changed

CLAUDE.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# canvas-plugins
2+
3+
event-driven plugin SDK and runtime for Canvas Medical EHR. published as the `canvas` PyPI package.
4+
5+
## project layout
6+
7+
- `canvas_sdk/` - SDK for plugin authors (handlers, effects, commands, events, data models)
8+
- `plugin_runner/` - gRPC service that loads/sandboxes/executes plugins via `RestrictedPython`
9+
- `canvas_cli/` - typer CLI (`canvas init`, `canvas install`, `canvas emit`, etc.)
10+
- `canvas_generated/` - auto-generated protobuf python; **do not edit directly**
11+
- `protobufs/` - source `.proto` definitions
12+
- `pubsub/` - redis pub/sub for plugin reload signals and log streaming
13+
- `logger/` - structured logging with logstash + redis
14+
- `example-plugins/` - tens of reference plugins
15+
- `settings.py` - django settings (sqlite in tests, postgres in prod)
16+
- `conftest.py` - root pytest fixtures
17+
18+
## companion repo
19+
20+
`../canvas/home-app/` is the main Canvas app. cross-repo changes are common:
21+
- protobuf changes here must be copied to home-app's `canvas_generated/`
22+
- new SDK data models (`canvas_sdk/v1/data/`) mirror home-app Django models (read-only)
23+
- home-app's `plugin_io/database_views.py` exposes DB views for SDK models
24+
- run `bin/generate-protobufs` after changing `.proto` files
25+
26+
## key architecture
27+
28+
- plugins declare handled events in `CANVAS_MANIFEST.json`
29+
- `plugin_runner.plugin_runner.EVENT_HANDLER_MAP` maps event names → handler classes
30+
- `LOADED_PLUGINS` tracks installed plugin metadata
31+
- handlers extend `BaseHandler`, implement `compute()` returning effects
32+
- prefer "handlers" over "protocols" everywhere: use `handlers` key in `CANVAS_MANIFEST.json`, `BaseHandler` over `BaseProtocol`, `canvas_sdk.handlers` over `canvas_sdk.protocols`. the `protocols` names still work but are legacy
33+
- plugin code runs in a `RestrictedPython` sandbox with an allowed-imports whitelist
34+
- `plugin_runner/allowed-module-imports.json` is auto-generated by pre-commit
35+
36+
## tooling
37+
38+
- **python**: 3.11, 3.12 (requires >=3.11, <3.13)
39+
- **package manager**: `uv` (>=0.8.0)
40+
- **build**: hatchling
41+
- **release**: python-semantic-release, tag-based versioning
42+
43+
## commands
44+
45+
```sh
46+
uv run pytest -m "not integtest" # unit tests
47+
uv run pytest path/to/test.py -k name # specific test
48+
uv run mypy canvas_sdk/ plugin_runner/ # type checking (or use bin/mypy)
49+
uv run ruff check --fix . # lint
50+
uv run ruff format . # format
51+
bin/generate-protobufs # regenerate protobuf python
52+
uv run python -m plugin_runner.generate_allowed_imports # update import whitelist
53+
```
54+
55+
## code style
56+
57+
- prefer simple test functions over test classes
58+
- use `pydantic` for command validation, real types over `Any`
59+
60+
## testing
61+
62+
- pytest with django plugin, uses sqlite in test mode
63+
- `conftest.py` fixtures: `install_test_plugin`, `load_test_plugins`, `cli_runner`
64+
- plugin test fixtures live in `plugin_runner/tests/fixtures/plugins/`
65+
- CI tests against python 3.11 and 3.12 matrix
66+
- example plugins each tested independently in CI
67+
- integration tests dispatched to the canvas repo

canvas_cli/apps/plugin/plugin.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,10 @@ def _get_meta_properties(protocol_path: Path, classname: str) -> dict[str, str]:
186186
return meta
187187

188188

189-
def _get_protocols_with_new_cqm_properties(
189+
def _get_handlers_with_new_cqm_properties(
190190
protocol_classes: Iterable[dict[str, Any]], plugin: Path
191191
) -> Iterable[dict[str, Any]] | None:
192-
"""Extract the meta properties of any ClinicalQualityMeasure Protocols included in the plugin if they have changed."""
192+
"""Extract the meta properties of any ClinicalQualityMeasure handlers included in the plugin if they have changed."""
193193
has_updates = False
194194
protocol_props = []
195195
for p in protocol_classes:
@@ -229,8 +229,8 @@ def parse_secrets(secrets: builtins.list[str]) -> builtins.list[str]:
229229

230230
def init(
231231
plugin_type: str = typer.Argument(
232-
"protocol",
233-
help="The type of plugin to create. Options are 'application' or 'protocol'.",
232+
"handler",
233+
help="The type of plugin to create. Options are 'application' or 'handler'.",
234234
),
235235
) -> None:
236236
"""Create a new plugin."""
@@ -604,7 +604,7 @@ def validate_manifest(
604604
try:
605605
manifest_json = json.loads(manifest.read_text())
606606
protocols = manifest_json.get("components", {}).get("protocols", [])
607-
if new_protocols := _get_protocols_with_new_cqm_properties(protocols, plugin_name):
607+
if new_protocols := _get_handlers_with_new_cqm_properties(protocols, plugin_name):
608608
print(
609609
f"Updating the CANVAS_MANIFEST.json file for {plugin_name} with CQM meta properties"
610610
)

canvas_sdk/protocols/base.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44

55

66
class BaseProtocol(BaseHandler, ABC):
7-
"""
8-
The class that protocols inherit from.
9-
"""
7+
"""Deprecated alias for BaseHandler. Use canvas_sdk.handlers.base.BaseHandler instead."""
108

119
pass
1210

canvas_sdk/tests/caching/test_plugins.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,13 @@ def test_plugin_successfully_sets_gets_key_value_in_cache(
7878
install_test_plugin: Path, load_test_plugins: None
7979
) -> None:
8080
"""Test that the plugin successfully sets and gets a key-value pair in the cache."""
81-
plugin = LOADED_PLUGINS["test_caching_api:test_caching_api.protocols.my_protocol:Protocol"]
81+
plugin = LOADED_PLUGINS["test_caching_api:test_caching_api.handlers.my_protocol:Protocol"]
8282
effects = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
8383

8484
assert effects[0].payload == "bar"
8585

8686
plugin = LOADED_PLUGINS[
87-
"test_caching_api:test_caching_api.protocols.my_secondary_protocol:Protocol"
87+
"test_caching_api:test_caching_api.handlers.my_secondary_protocol:Protocol"
8888
]
8989
effects = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
9090

@@ -97,7 +97,7 @@ def test_plugin_access_to_private_properties_cache_is_forbidden(
9797
) -> None:
9898
"""Test that plugin access to private properties of the cache api is forbidden."""
9999
assert (
100-
"test_caching_api:test_caching_api.protocols.my_protocol:ForbiddenProtocol"
100+
"test_caching_api:test_caching_api.handlers.my_protocol:ForbiddenProtocol"
101101
not in LOADED_PLUGINS
102102
)
103103

canvas_sdk/tests/questionnaires/test_utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
def test_from_yaml_valid_questionnaire(install_test_plugin: Path, load_test_plugins: None) -> None:
1616
"""Test that the from_yaml function loads a valid questionnaire."""
1717
plugin = LOADED_PLUGINS[
18-
"test_load_questionnaire:test_load_questionnaire.protocols.my_protocol:ValidQuestionnaire"
18+
"test_load_questionnaire:test_load_questionnaire.handlers.my_protocol:ValidQuestionnaire"
1919
]
2020
result: list[Effect] = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
2121

@@ -39,7 +39,7 @@ def test_from_yaml_invalid_questionnaire(
3939
) -> None:
4040
"""Test that the from_yaml function raises an error for invalid questionnaires."""
4141
plugin = LOADED_PLUGINS[
42-
"test_load_questionnaire:test_load_questionnaire.protocols.my_protocol:InvalidQuestionnaire"
42+
"test_load_questionnaire:test_load_questionnaire.handlers.my_protocol:InvalidQuestionnaire"
4343
]
4444
with pytest.raises(FileNotFoundError):
4545
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
@@ -51,7 +51,7 @@ def test_from_yaml_forbidden_questionnaire(
5151
) -> None:
5252
"""Test that the from_yaml function raises an error for a questionnaire outside plugin package."""
5353
plugin = LOADED_PLUGINS[
54-
"test_load_questionnaire:test_load_questionnaire.protocols.my_protocol:ForbiddenQuestionnaire"
54+
"test_load_questionnaire:test_load_questionnaire.handlers.my_protocol:ForbiddenQuestionnaire"
5555
]
5656
with pytest.raises(PermissionError):
5757
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()

canvas_sdk/tests/templates/test_utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def test_render_to_string_valid_template(
1313
) -> None:
1414
"""Test that the render_to_string function loads and renders a valid template."""
1515
plugin = LOADED_PLUGINS[
16-
"test_render_template:test_render_template.protocols.my_protocol:ValidTemplate"
16+
"test_render_template:test_render_template.handlers.my_protocol:ValidTemplate"
1717
]
1818
result: list[Effect] = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
1919
assert "This is always here" in result[0].payload
@@ -26,7 +26,7 @@ def test_render_to_string_valid_child_template(
2626
) -> None:
2727
"""Test that the render_to_string function allows template inheritance."""
2828
plugin = LOADED_PLUGINS[
29-
"test_render_template:test_render_template.protocols.my_protocol:TemplateInheritance"
29+
"test_render_template:test_render_template.handlers.my_protocol:TemplateInheritance"
3030
]
3131
result: list[Effect] = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
3232
assert "This is always here" in result[0].payload
@@ -40,7 +40,7 @@ def test_render_to_string_invalid_template(
4040
) -> None:
4141
"""Test that the render_to_string function raises an error for invalid templates."""
4242
plugin = LOADED_PLUGINS[
43-
"test_render_template:test_render_template.protocols.my_protocol:InvalidTemplate"
43+
"test_render_template:test_render_template.handlers.my_protocol:InvalidTemplate"
4444
]
4545
with pytest.raises(FileNotFoundError):
4646
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
@@ -52,7 +52,7 @@ def test_render_to_string_forbidden_template(
5252
) -> None:
5353
"""Test that the render_to_string function raises an error for a template outside plugin package."""
5454
plugin = LOADED_PLUGINS[
55-
"test_render_template:test_render_template.protocols.my_protocol:ForbiddenTemplate"
55+
"test_render_template:test_render_template.handlers.my_protocol:ForbiddenTemplate"
5656
]
5757
with pytest.raises(PermissionError):
5858
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()

canvas_sdk/tests/utils/test_metrics.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,15 +156,15 @@ def test_track_plugins_usage_adds_plugin_tags(mock_time: MagicMock) -> None:
156156
with (
157157
patch(
158158
"canvas_sdk.utils.plugins.is_plugin_caller",
159-
return_value=(True, "my_plugin.protocols.handler"),
159+
return_value=(True, "my_plugin.handlers.handler"),
160160
),
161161
measure("block", track_plugins_usage=True, client=client),
162162
):
163163
pass
164164

165165
tags = pipeline.timing.call_args.kwargs["tags"]
166166
assert tags["plugin"] == "my_plugin"
167-
assert tags["handler"] == "my_plugin.protocols.handler"
167+
assert tags["handler"] == "my_plugin.handlers.handler"
168168

169169

170170
@patch("canvas_sdk.utils.metrics.time")
@@ -218,7 +218,7 @@ def test_track_memory_usage_captures_rss_delta(
218218
@patch("canvas_sdk.utils.metrics.log")
219219
@patch(
220220
"canvas_sdk.utils.plugins.is_plugin_caller",
221-
return_value=(True, "test_plugin.protocols.handler"),
221+
return_value=(True, "test_plugin.handlers.handler"),
222222
)
223223
@patch("canvas_sdk.utils.metrics.psutil")
224224
@patch("canvas_sdk.utils.metrics.time")
@@ -269,7 +269,7 @@ def test_memory_below_threshold_no_warning(
269269
@patch("canvas_sdk.utils.metrics.log")
270270
@patch(
271271
"canvas_sdk.utils.plugins.is_plugin_caller",
272-
return_value=(True, "test_plugin.protocols.handler"),
272+
return_value=(True, "test_plugin.handlers.handler"),
273273
)
274274
@patch("canvas_sdk.utils.metrics.psutil")
275275
@patch("canvas_sdk.utils.metrics.time")

example-plugins/abnormal_lab_task_notification/abnormal_lab_task_notification/CANVAS_MANIFEST.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"name": "abnormal_lab_task_notification",
55
"description": "A plugin that creates task notifications for abnormal lab values",
66
"components": {
7-
"protocols": [
7+
"handlers": [
88
{
99
"class": "abnormal_lab_task_notification.handlers.abnormal_lab_handler:AbnormalLabHandler",
1010
"description": "Monitors lab reports and creates tasks for abnormal values",

example-plugins/ai_note_titles/ai_note_titles/CANVAS_MANIFEST.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"name": "ai_note_titles",
55
"description": "Edit the description in CANVAS_MANIFEST.json",
66
"components": {
7-
"protocols": [
7+
"handlers": [
88
{
99
"class": "ai_note_titles.handlers.rename_note:Handler",
1010
"description": "Renames Notes when locked using OpenAI and the contents of the Note"

example-plugins/api_samples/api_samples/CANVAS_MANIFEST.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"name": "api_samples",
55
"description": "Example usages of the SimpleAPI handler",
66
"components": {
7-
"protocols": [
7+
"handlers": [
88
{
99
"class": "api_samples.routes.hello_world:HelloWorldAPI",
1010
"description": "Returns a json message"

0 commit comments

Comments
 (0)