Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# canvas-plugins

event-driven plugin SDK and runtime for Canvas Medical EHR. published as the `canvas` PyPI package.

## project layout

- `canvas_sdk/` - SDK for plugin authors (handlers, effects, commands, events, data models)
- `plugin_runner/` - gRPC service that loads/sandboxes/executes plugins via `RestrictedPython`
- `canvas_cli/` - typer CLI (`canvas init`, `canvas install`, `canvas emit`, etc.)
- `canvas_generated/` - auto-generated protobuf python; **do not edit directly**
- `protobufs/` - source `.proto` definitions
- `pubsub/` - redis pub/sub for plugin reload signals and log streaming
- `logger/` - structured logging with logstash + redis
- `example-plugins/` - tens of reference plugins
- `settings.py` - django settings (sqlite in tests, postgres in prod)
- `conftest.py` - root pytest fixtures

## companion repo

`../canvas/home-app/` is the main Canvas app. cross-repo changes are common:
- protobuf changes here must be copied to home-app's `canvas_generated/`
- new SDK data models (`canvas_sdk/v1/data/`) mirror home-app Django models (read-only)
- home-app's `plugin_io/database_views.py` exposes DB views for SDK models
- run `bin/generate-protobufs` after changing `.proto` files

## key architecture

- plugins declare handled events in `CANVAS_MANIFEST.json`
- `plugin_runner.plugin_runner.EVENT_HANDLER_MAP` maps event names → handler classes
- `LOADED_PLUGINS` tracks installed plugin metadata
- handlers extend `BaseHandler`, implement `compute()` returning effects
- 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
- plugin code runs in a `RestrictedPython` sandbox with an allowed-imports whitelist
- `plugin_runner/allowed-module-imports.json` is auto-generated by pre-commit

## tooling

- **python**: 3.11, 3.12 (requires >=3.11, <3.13)
- **package manager**: `uv` (>=0.8.0)
- **build**: hatchling
- **release**: python-semantic-release, tag-based versioning

## commands

```sh
uv run pytest -m "not integtest" # unit tests
uv run pytest path/to/test.py -k name # specific test
uv run mypy canvas_sdk/ plugin_runner/ # type checking (or use bin/mypy)
uv run ruff check --fix . # lint
uv run ruff format . # format
bin/generate-protobufs # regenerate protobuf python
uv run python -m plugin_runner.generate_allowed_imports # update import whitelist
```

## code style

- prefer simple test functions over test classes
- use `pydantic` for command validation, real types over `Any`

## testing

- pytest with django plugin, uses sqlite in test mode
- `conftest.py` fixtures: `install_test_plugin`, `load_test_plugins`, `cli_runner`
- plugin test fixtures live in `plugin_runner/tests/fixtures/plugins/`
- CI tests against python 3.11 and 3.12 matrix
- example plugins each tested independently in CI
- integration tests dispatched to the canvas repo
10 changes: 5 additions & 5 deletions canvas_cli/apps/plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,10 @@ def _get_meta_properties(protocol_path: Path, classname: str) -> dict[str, str]:
return meta


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

def init(
plugin_type: str = typer.Argument(
"protocol",
help="The type of plugin to create. Options are 'application' or 'protocol'.",
"handler",
help="The type of plugin to create. Options are 'application' or 'handler'.",
),
) -> None:
"""Create a new plugin."""
Expand Down Expand Up @@ -604,7 +604,7 @@ def validate_manifest(
try:
manifest_json = json.loads(manifest.read_text())
protocols = manifest_json.get("components", {}).get("protocols", [])
if new_protocols := _get_protocols_with_new_cqm_properties(protocols, plugin_name):
if new_protocols := _get_handlers_with_new_cqm_properties(protocols, plugin_name):
print(
f"Updating the CANVAS_MANIFEST.json file for {plugin_name} with CQM meta properties"
)
Expand Down
12 changes: 6 additions & 6 deletions canvas_cli/utils/validators/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@


@pytest.fixture
def protocol_manifest_example() -> dict:
"""Return a valid protocol manifest example."""
def handler_manifest_example() -> dict:
"""Return a valid handler manifest example."""
return {
"sdk_version": "0.3.1",
"plugin_version": "1.0.1",
"name": "Prompt to prescribe when assessing condition",
"description": "To assist in ....",
"components": {
"protocols": [
"handlers": [
{
"class": "prompt_to_prescribe.protocols.prompt_when_assessing.PromptWhenAssessing",
"class": "prompt_to_prescribe.handlers.prompt_when_assessing.PromptWhenAssessing",
"description": "probably the same as the plugin's description",
"data_access": {
"event": "",
Expand All @@ -32,6 +32,6 @@ def protocol_manifest_example() -> dict:
}


def test_manifest_file_schema(protocol_manifest_example: dict) -> None:
def test_manifest_file_schema(handler_manifest_example: dict) -> None:
"""Test that no exception raised when a valid manifest file is validated."""
validate_manifest_file(protocol_manifest_example)
validate_manifest_file(handler_manifest_example)
4 changes: 1 addition & 3 deletions canvas_sdk/protocols/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@


class BaseProtocol(BaseHandler, ABC):
"""
The class that protocols inherit from.
"""
"""Deprecated alias for BaseHandler. Use canvas_sdk.handlers.base.BaseHandler instead."""

pass

Expand Down
6 changes: 3 additions & 3 deletions canvas_sdk/tests/caching/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ def test_plugin_successfully_sets_gets_key_value_in_cache(
install_test_plugin: Path, load_test_plugins: None
) -> None:
"""Test that the plugin successfully sets and gets a key-value pair in the cache."""
plugin = LOADED_PLUGINS["test_caching_api:test_caching_api.protocols.my_protocol:Protocol"]
plugin = LOADED_PLUGINS["test_caching_api:test_caching_api.handlers.my_handler:Handler"]
effects = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()

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

plugin = LOADED_PLUGINS[
"test_caching_api:test_caching_api.protocols.my_secondary_protocol:Protocol"
"test_caching_api:test_caching_api.handlers.my_secondary_handler:Handler"
]
effects = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()

Expand All @@ -97,7 +97,7 @@ def test_plugin_access_to_private_properties_cache_is_forbidden(
) -> None:
"""Test that plugin access to private properties of the cache api is forbidden."""
assert (
"test_caching_api:test_caching_api.protocols.my_protocol:ForbiddenProtocol"
"test_caching_api:test_caching_api.handlers.my_handler:ForbiddenHandler"
not in LOADED_PLUGINS
)

Expand Down
6 changes: 3 additions & 3 deletions canvas_sdk/tests/questionnaires/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
def test_from_yaml_valid_questionnaire(install_test_plugin: Path, load_test_plugins: None) -> None:
"""Test that the from_yaml function loads a valid questionnaire."""
plugin = LOADED_PLUGINS[
"test_load_questionnaire:test_load_questionnaire.protocols.my_protocol:ValidQuestionnaire"
"test_load_questionnaire:test_load_questionnaire.handlers.my_protocol:ValidQuestionnaire"
]
result: list[Effect] = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()

Expand All @@ -39,7 +39,7 @@ def test_from_yaml_invalid_questionnaire(
) -> None:
"""Test that the from_yaml function raises an error for invalid questionnaires."""
plugin = LOADED_PLUGINS[
"test_load_questionnaire:test_load_questionnaire.protocols.my_protocol:InvalidQuestionnaire"
"test_load_questionnaire:test_load_questionnaire.handlers.my_protocol:InvalidQuestionnaire"
]
with pytest.raises(FileNotFoundError):
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
Expand All @@ -51,7 +51,7 @@ def test_from_yaml_forbidden_questionnaire(
) -> None:
"""Test that the from_yaml function raises an error for a questionnaire outside plugin package."""
plugin = LOADED_PLUGINS[
"test_load_questionnaire:test_load_questionnaire.protocols.my_protocol:ForbiddenQuestionnaire"
"test_load_questionnaire:test_load_questionnaire.handlers.my_protocol:ForbiddenQuestionnaire"
]
with pytest.raises(PermissionError):
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
Expand Down
8 changes: 4 additions & 4 deletions canvas_sdk/tests/templates/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def test_render_to_string_valid_template(
) -> None:
"""Test that the render_to_string function loads and renders a valid template."""
plugin = LOADED_PLUGINS[
"test_render_template:test_render_template.protocols.my_protocol:ValidTemplate"
"test_render_template:test_render_template.handlers.my_protocol:ValidTemplate"
]
result: list[Effect] = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
assert "This is always here" in result[0].payload
Expand All @@ -26,7 +26,7 @@ def test_render_to_string_valid_child_template(
) -> None:
"""Test that the render_to_string function allows template inheritance."""
plugin = LOADED_PLUGINS[
"test_render_template:test_render_template.protocols.my_protocol:TemplateInheritance"
"test_render_template:test_render_template.handlers.my_protocol:TemplateInheritance"
]
result: list[Effect] = plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
assert "This is always here" in result[0].payload
Expand All @@ -40,7 +40,7 @@ def test_render_to_string_invalid_template(
) -> None:
"""Test that the render_to_string function raises an error for invalid templates."""
plugin = LOADED_PLUGINS[
"test_render_template:test_render_template.protocols.my_protocol:InvalidTemplate"
"test_render_template:test_render_template.handlers.my_protocol:InvalidTemplate"
]
with pytest.raises(FileNotFoundError):
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
Expand All @@ -52,7 +52,7 @@ def test_render_to_string_forbidden_template(
) -> None:
"""Test that the render_to_string function raises an error for a template outside plugin package."""
plugin = LOADED_PLUGINS[
"test_render_template:test_render_template.protocols.my_protocol:ForbiddenTemplate"
"test_render_template:test_render_template.handlers.my_protocol:ForbiddenTemplate"
]
with pytest.raises(PermissionError):
plugin["class"](Event(EventRequest(type=EventType.UNKNOWN))).compute()
8 changes: 4 additions & 4 deletions canvas_sdk/tests/utils/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,15 @@ def test_track_plugins_usage_adds_plugin_tags(mock_time: MagicMock) -> None:
with (
patch(
"canvas_sdk.utils.plugins.is_plugin_caller",
return_value=(True, "my_plugin.protocols.handler"),
return_value=(True, "my_plugin.handlers.handler"),
),
measure("block", track_plugins_usage=True, client=client),
):
pass

tags = pipeline.timing.call_args.kwargs["tags"]
assert tags["plugin"] == "my_plugin"
assert tags["handler"] == "my_plugin.protocols.handler"
assert tags["handler"] == "my_plugin.handlers.handler"


@patch("canvas_sdk.utils.metrics.time")
Expand Down Expand Up @@ -218,7 +218,7 @@ def test_track_memory_usage_captures_rss_delta(
@patch("canvas_sdk.utils.metrics.log")
@patch(
"canvas_sdk.utils.plugins.is_plugin_caller",
return_value=(True, "test_plugin.protocols.handler"),
return_value=(True, "test_plugin.handlers.handler"),
)
@patch("canvas_sdk.utils.metrics.psutil")
@patch("canvas_sdk.utils.metrics.time")
Expand Down Expand Up @@ -269,7 +269,7 @@ def test_memory_below_threshold_no_warning(
@patch("canvas_sdk.utils.metrics.log")
@patch(
"canvas_sdk.utils.plugins.is_plugin_caller",
return_value=(True, "test_plugin.protocols.handler"),
return_value=(True, "test_plugin.handlers.handler"),
)
@patch("canvas_sdk.utils.metrics.psutil")
@patch("canvas_sdk.utils.metrics.time")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "abnormal_lab_task_notification",
"description": "A plugin that creates task notifications for abnormal lab values",
"components": {
"protocols": [
"handlers": [
{
"class": "abnormal_lab_task_notification.handlers.abnormal_lab_handler:AbnormalLabHandler",
"description": "Monitors lab reports and creates tasks for abnormal values",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "ai_note_titles",
"description": "Edit the description in CANVAS_MANIFEST.json",
"components": {
"protocols": [
"handlers": [
{
"class": "ai_note_titles.handlers.rename_note:Handler",
"description": "Renames Notes when locked using OpenAI and the contents of the Note"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "api_samples",
"description": "Example usages of the SimpleAPI handler",
"components": {
"protocols": [
"handlers": [
{
"class": "api_samples.routes.hello_world:HelloWorldAPI",
"description": "Returns a json message"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"name":"appointment_coverage_label",
"description": "Automatically manages appointment labels based on patient insurance coverage status. Adds a 'MISSING_COVERAGE' label to appointments for patients without coverage, and removes the label when coverage is subsequently added.",
"components":{
"protocols":[
"handlers":[
{
"class":"appointment_coverage_label.protocols.appointment_labels:AppointmentLabelsProtocol",
"class":"appointment_coverage_label.handlers.appointment_labels:AppointmentLabelsHandler",
"description": "Monitors appointment and coverage events to automatically add or remove the 'MISSING_COVERAGE' label based on patient insurance coverage status.",
"data_access":{
"event":"read",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ appointment_coverage_label/

### Key Components

- **AppointmentLabelsProtocol** - Main protocol class handling both events
- **AppointmentLabelsHandler** - Main protocol class handling both events
- **handle_coverage_created()** - Removes labels when coverage is added
- **handle_appointment_created()** - Adds labels when appointments are created for patients without coverage
- **compute()** - Routes events to appropriate handlers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@
from canvas_sdk.effects.note import RemoveAppointmentLabel
from canvas_sdk.effects.note.appointment import AddAppointmentLabel
from canvas_sdk.events import EventType
from canvas_sdk.protocols import BaseProtocol
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import Appointment, Coverage, Patient
from logger import log


class AppointmentLabelsProtocol(BaseProtocol):
class AppointmentLabelsHandler(BaseHandler):
"""
Manages the 'MISSING_COVERAGE' label on patient appointments based on insurance coverage status.

This protocol automatically:
This handler automatically:
1. Adds 'MISSING_COVERAGE' labels to all appointments for patients without insurance coverage
2. Removes 'MISSING_COVERAGE' labels from all appointments when insurance coverage is added

The protocol responds to two events:
The handler responds to two events:
- APPOINTMENT_CREATED: Checks if the patient has coverage and adds labels if needed
- COVERAGE_CREATED: Removes labels from all appointments when coverage is added

Expand Down Expand Up @@ -185,15 +185,15 @@ def compute(self) -> list[Effect]:
list[Effect]: List of effects from the appropriate handler, or empty list if
event type is not recognized
"""
log.info(f"AppointmentLabelsProtocol.compute() called for event type: {self.event.type}")
log.info(f"AppointmentLabelsHandler.compute() called for event type: {self.event.type}")

if self.event.type == EventType.APPOINTMENT_CREATED:
return self.handle_appointment_created()
elif self.event.type == EventType.COVERAGE_CREATED:
return self.handle_coverage_created()

log.warning(
f"Received unexpected event type '{self.event.type}' in AppointmentLabelsProtocol. "
f"Received unexpected event type '{self.event.type}' in AppointmentLabelsHandler. "
"No handler available."
)
return []
Loading
Loading