canvas 0.34.1__py3-none-any.whl → 0.35.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 (60) hide show
  1. {canvas-0.34.1.dist-info → canvas-0.35.1.dist-info}/METADATA +2 -2
  2. {canvas-0.34.1.dist-info → canvas-0.35.1.dist-info}/RECORD +59 -51
  3. canvas_generated/messages/effects_pb2.py +4 -4
  4. canvas_generated/messages/effects_pb2.pyi +22 -2
  5. canvas_generated/messages/events_pb2.py +2 -2
  6. canvas_generated/messages/events_pb2.pyi +30 -0
  7. canvas_sdk/base.py +56 -0
  8. canvas_sdk/commands/base.py +22 -46
  9. canvas_sdk/commands/commands/adjust_prescription.py +0 -10
  10. canvas_sdk/commands/commands/allergy.py +0 -1
  11. canvas_sdk/commands/commands/assess.py +2 -2
  12. canvas_sdk/commands/commands/change_medication.py +58 -0
  13. canvas_sdk/commands/commands/close_goal.py +0 -1
  14. canvas_sdk/commands/commands/diagnose.py +0 -1
  15. canvas_sdk/commands/commands/exam.py +0 -1
  16. canvas_sdk/commands/commands/family_history.py +0 -1
  17. canvas_sdk/commands/commands/follow_up.py +4 -2
  18. canvas_sdk/commands/commands/goal.py +8 -7
  19. canvas_sdk/commands/commands/history_present_illness.py +0 -1
  20. canvas_sdk/commands/commands/imaging_order.py +9 -8
  21. canvas_sdk/commands/commands/instruct.py +2 -2
  22. canvas_sdk/commands/commands/lab_order.py +10 -9
  23. canvas_sdk/commands/commands/medical_history.py +0 -1
  24. canvas_sdk/commands/commands/medication_statement.py +0 -1
  25. canvas_sdk/commands/commands/past_surgical_history.py +0 -1
  26. canvas_sdk/commands/commands/perform.py +3 -2
  27. canvas_sdk/commands/commands/plan.py +0 -1
  28. canvas_sdk/commands/commands/prescribe.py +0 -9
  29. canvas_sdk/commands/commands/refer.py +10 -10
  30. canvas_sdk/commands/commands/refill.py +0 -9
  31. canvas_sdk/commands/commands/remove_allergy.py +0 -1
  32. canvas_sdk/commands/commands/resolve_condition.py +3 -2
  33. canvas_sdk/commands/commands/review_of_systems.py +0 -1
  34. canvas_sdk/commands/commands/stop_medication.py +0 -1
  35. canvas_sdk/commands/commands/structured_assessment.py +0 -1
  36. canvas_sdk/commands/commands/task.py +0 -4
  37. canvas_sdk/commands/commands/update_diagnosis.py +8 -6
  38. canvas_sdk/commands/commands/update_goal.py +0 -1
  39. canvas_sdk/commands/commands/vitals.py +0 -1
  40. canvas_sdk/effects/note/__init__.py +10 -0
  41. canvas_sdk/effects/note/appointment.py +148 -0
  42. canvas_sdk/effects/note/base.py +129 -0
  43. canvas_sdk/effects/note/note.py +79 -0
  44. canvas_sdk/effects/patient/__init__.py +3 -0
  45. canvas_sdk/effects/patient/base.py +123 -0
  46. canvas_sdk/utils/http.py +7 -26
  47. canvas_sdk/utils/metrics.py +192 -0
  48. canvas_sdk/utils/plugins.py +24 -0
  49. canvas_sdk/v1/data/__init__.py +4 -0
  50. canvas_sdk/v1/data/message.py +82 -0
  51. plugin_runner/load_all_plugins.py +0 -3
  52. plugin_runner/plugin_runner.py +159 -198
  53. plugin_runner/sandbox.py +3 -0
  54. protobufs/canvas_generated/messages/effects.proto +13 -0
  55. protobufs/canvas_generated/messages/events.proto +16 -0
  56. pubsub/pubsub.py +10 -1
  57. settings.py +8 -0
  58. canvas_sdk/utils/stats.py +0 -74
  59. {canvas-0.34.1.dist-info → canvas-0.35.1.dist-info}/WHEEL +0 -0
  60. {canvas-0.34.1.dist-info → canvas-0.35.1.dist-info}/entry_points.txt +0 -0
@@ -1,24 +1,28 @@
1
- import asyncio
2
1
  import json
3
2
  import os
4
3
  import pathlib
5
4
  import pickle
6
5
  import pkgutil
7
6
  import sys
8
- import time
7
+ import threading
9
8
  import traceback
10
9
  from collections import defaultdict
11
- from collections.abc import AsyncGenerator
10
+ from collections.abc import Iterable
11
+ from concurrent.futures import ThreadPoolExecutor
12
12
  from http import HTTPStatus
13
+ from time import sleep
13
14
  from typing import Any, TypedDict
14
15
 
15
16
  import grpc
16
- import redis.asyncio as redis
17
+ import redis
17
18
  import sentry_sdk
18
- from asgiref.sync import sync_to_async
19
- from django.db import connections
19
+ from django.core.signals import request_finished, request_started
20
+ from redis.backoff import ExponentialBackoff
21
+ from redis.exceptions import ConnectionError, TimeoutError
22
+ from redis.retry import Retry
20
23
  from sentry_sdk.integrations.logging import ignore_logger
21
24
 
25
+ import settings
22
26
  from canvas_generated.messages.effects_pb2 import EffectType
23
27
  from canvas_generated.messages.plugins_pb2 import ReloadPluginsRequest, ReloadPluginsResponse
24
28
  from canvas_generated.services.plugin_runner_pb2_grpc import (
@@ -29,7 +33,8 @@ from canvas_sdk.effects import Effect
29
33
  from canvas_sdk.effects.simple_api import Response
30
34
  from canvas_sdk.events import Event, EventRequest, EventResponse, EventType
31
35
  from canvas_sdk.protocols import ClinicalQualityMeasure
32
- from canvas_sdk.utils.stats import get_duration_ms, statsd_client
36
+ from canvas_sdk.utils import metrics
37
+ from canvas_sdk.utils.metrics import measured
33
38
  from logger import log
34
39
  from plugin_runner.authentication import token_for_plugin
35
40
  from plugin_runner.installation import install_plugins
@@ -39,7 +44,6 @@ from settings import (
39
44
  CUSTOMER_IDENTIFIER,
40
45
  ENV,
41
46
  IS_PRODUCTION_CUSTOMER,
42
- IS_TESTING,
43
47
  MANIFEST_FILE_NAME,
44
48
  PLUGIN_DIRECTORY,
45
49
  REDIS_ENDPOINT,
@@ -149,164 +153,147 @@ class PluginManifest(TypedDict):
149
153
  readme: str
150
154
 
151
155
 
152
- @sync_to_async
153
- def reconnect_if_needed() -> None:
154
- """
155
- Reconnect to the database if the connection has been closed.
156
-
157
- NOTE: Django database functions are not async-safe and must be called
158
- via e.g. sync_to_async. They will silently fail and block the handler
159
- if called in an async context directly.
160
- """
161
- if IS_TESTING:
162
- return
163
-
164
- connection = connections["default"]
165
-
166
- if not connection.is_usable():
167
- log.debug("Connection was unusable, reconnecting...")
168
- connection.connect()
169
-
170
-
171
156
  class PluginRunner(PluginRunnerServicer):
172
157
  """This process runs provided plugins that register interest in incoming events."""
173
158
 
174
159
  sandbox: Sandbox
175
160
 
176
- async def HandleEvent(
177
- self, request: EventRequest, context: Any
178
- ) -> AsyncGenerator[EventResponse, None]:
161
+ def HandleEvent(self, request: EventRequest, context: Any) -> Iterable[EventResponse]:
179
162
  """This is invoked when an event comes in."""
180
- event_start_time = time.time()
181
163
  event = Event(request)
182
- event_type = event.type
183
- event_name = event.name
184
- relevant_plugins = EVENT_HANDLER_MAP[event_name]
185
- relevant_plugin_handlers = []
186
-
187
- log.debug(f"Processing {relevant_plugins} for {event_name}")
188
- sentry_sdk.set_tag("event-name", event_name)
189
-
190
- if relevant_plugins:
191
- await reconnect_if_needed()
192
-
193
- if event_type in [EventType.PLUGIN_CREATED, EventType.PLUGIN_UPDATED]:
194
- plugin_name = event.target.id
195
- # filter only for the plugin(s) that were created/updated
196
- relevant_plugins = [p for p in relevant_plugins if p.startswith(f"{plugin_name}:")]
197
- elif event_type in {EventType.SIMPLE_API_AUTHENTICATE, EventType.SIMPLE_API_REQUEST}:
198
- # The target plugin's name will be part of the home-app URL path, so other plugins that
199
- # respond to SimpleAPI request events are not relevant
200
- plugin_name = event.context["plugin_name"]
201
- relevant_plugins = [p for p in relevant_plugins if p.startswith(f"{plugin_name}:")]
202
-
203
- effect_list = []
204
-
205
- for plugin_name in relevant_plugins:
206
- log.debug(f"Processing {plugin_name}")
207
- sentry_sdk.set_tag("plugin-name", plugin_name)
208
-
209
- plugin = LOADED_PLUGINS[plugin_name]
210
- handler_class = plugin["class"]
211
- base_plugin_name = plugin_name.split(":")[0]
164
+ with metrics.measure(
165
+ metrics.get_qualified_name(self.HandleEvent), extra_tags={"event": event.name}
166
+ ):
167
+ event_type = event.type
168
+ event_name = event.name
169
+ relevant_plugins = EVENT_HANDLER_MAP[event_name]
170
+ relevant_plugin_handlers = []
171
+
172
+ log.debug(f"Processing {relevant_plugins} for {event_name}")
173
+ sentry_sdk.set_tag("event-name", event_name)
174
+
175
+ if relevant_plugins:
176
+ # Send the Django request_started signal
177
+ request_started.send(sender=self.__class__)
178
+
179
+ if event_type in [EventType.PLUGIN_CREATED, EventType.PLUGIN_UPDATED]:
180
+ plugin_name = event.target.id
181
+ # filter only for the plugin(s) that were created/updated
182
+ relevant_plugins = [p for p in relevant_plugins if p.startswith(f"{plugin_name}:")]
183
+ elif event_type in {EventType.SIMPLE_API_AUTHENTICATE, EventType.SIMPLE_API_REQUEST}:
184
+ # The target plugin's name will be part of the home-app URL path, so other plugins that
185
+ # respond to SimpleAPI request events are not relevant
186
+ plugin_name = event.context["plugin_name"]
187
+ relevant_plugins = [p for p in relevant_plugins if p.startswith(f"{plugin_name}:")]
188
+
189
+ effect_list = []
190
+
191
+ for plugin_name in relevant_plugins:
192
+ log.debug(f"Processing {plugin_name}")
193
+ sentry_sdk.set_tag("plugin-name", plugin_name)
194
+
195
+ plugin = LOADED_PLUGINS[plugin_name]
196
+ handler_class = plugin["class"]
197
+ base_plugin_name = plugin_name.split(":")[0]
198
+
199
+ secrets = plugin.get("secrets", {})
200
+
201
+ secrets.update(
202
+ {"graphql_jwt": token_for_plugin(plugin_name=plugin_name, audience="home")}
203
+ )
212
204
 
213
- secrets = plugin.get("secrets", {})
205
+ try:
206
+ handler = handler_class(event, secrets, ENVIRONMENT)
214
207
 
215
- secrets.update(
216
- {"graphql_jwt": token_for_plugin(plugin_name=plugin_name, audience="home")}
217
- )
208
+ if not handler.accept_event():
209
+ continue
210
+ relevant_plugin_handlers.append(handler_class)
218
211
 
219
- try:
220
- handler = handler_class(event, secrets, ENVIRONMENT)
221
-
222
- if not handler.accept_event():
212
+ classname = (
213
+ handler.__class__.__name__
214
+ if isinstance(handler, ClinicalQualityMeasure)
215
+ else None
216
+ )
217
+ handler_name = metrics.get_qualified_name(handler.compute)
218
+ with metrics.measure(
219
+ name=handler_name,
220
+ extra_tags={
221
+ "plugin": base_plugin_name,
222
+ "event": event_name,
223
+ },
224
+ ):
225
+ _effects = handler.compute()
226
+ effects = [
227
+ Effect(
228
+ type=effect.type,
229
+ payload=effect.payload,
230
+ plugin_name=base_plugin_name,
231
+ classname=classname,
232
+ handler_name=handler_name,
233
+ )
234
+ for effect in _effects
235
+ ]
236
+
237
+ effects = validate_effects(effects)
238
+
239
+ apply_effects_to_context(effects, event=event)
240
+
241
+ log.info(f"{plugin_name}.compute() completed.")
242
+
243
+ except Exception as e:
244
+ log.error(f"Encountered exception in plugin {plugin_name}:")
245
+
246
+ for error_line_with_newlines in traceback.format_exception(e):
247
+ for error_line in error_line_with_newlines.split("\n"):
248
+ log.error(error_line)
249
+
250
+ sentry_sdk.capture_exception(e)
223
251
  continue
224
- relevant_plugin_handlers.append(handler_class)
225
252
 
226
- classname = (
227
- handler.__class__.__name__
228
- if isinstance(handler, ClinicalQualityMeasure)
229
- else None
230
- )
231
-
232
- compute_start_time = time.time()
233
- _effects = await sync_to_async(handler.compute)()
234
- effects = [
235
- Effect(
236
- type=effect.type,
237
- payload=effect.payload,
238
- plugin_name=base_plugin_name,
239
- classname=classname,
253
+ effect_list += effects
254
+
255
+ sentry_sdk.set_tag("plugin-name", None)
256
+
257
+ # Special handling for SimpleAPI requests: if there were no relevant handlers (as determined
258
+ # by calling ignore_event on handlers), then set the effects list to be a single 404 Not
259
+ # Found response effect. If multiple handlers were able to respond, log an error and set the
260
+ # effects list to be a single 500 Internal Server Error response effect.
261
+ if event.type in {EventType.SIMPLE_API_AUTHENTICATE, EventType.SIMPLE_API_REQUEST}:
262
+ if len(relevant_plugin_handlers) == 0:
263
+ effect_list = [Response(status_code=HTTPStatus.NOT_FOUND).apply()]
264
+ elif len(relevant_plugin_handlers) > 1:
265
+ log.error(
266
+ f"Multiple handlers responded to {EventType.Name(EventType.SIMPLE_API_REQUEST)}"
267
+ f" {event.context['path']}"
240
268
  )
241
- for effect in _effects
242
- ]
243
-
244
- effects = validate_effects(effects)
245
-
246
- apply_effects_to_context(effects, event=event)
247
-
248
- compute_duration = get_duration_ms(compute_start_time)
249
-
250
- log.info(f"{plugin_name}.compute() completed ({compute_duration} ms)")
251
- statsd_client.timing(
252
- "plugins.protocol_duration_ms",
253
- delta=compute_duration,
254
- tags={"plugin": plugin_name},
255
- )
256
- except Exception as e:
257
- log.error(f"Encountered exception in plugin {plugin_name}:")
258
-
259
- for error_line_with_newlines in traceback.format_exception(e):
260
- for error_line in error_line_with_newlines.split("\n"):
261
- log.error(error_line)
262
-
263
- sentry_sdk.capture_exception(e)
269
+ effect_list = [Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()]
264
270
 
265
- continue
266
-
267
- effect_list += effects
268
-
269
- sentry_sdk.set_tag("plugin-name", None)
270
-
271
- # Special handling for SimpleAPI requests: if there were no relevant handlers (as determined
272
- # by calling ignore_event on handlers), then set the effects list to be a single 404 Not
273
- # Found response effect. If multiple handlers were able to respond, log an error and set the
274
- # effects list to be a single 500 Internal Server Error response effect.
275
- if event.type in {EventType.SIMPLE_API_AUTHENTICATE, EventType.SIMPLE_API_REQUEST}:
276
- if len(relevant_plugin_handlers) == 0:
277
- effect_list = [Response(status_code=HTTPStatus.NOT_FOUND).apply()]
278
- elif len(relevant_plugin_handlers) > 1:
279
- log.error(
280
- f"Multiple handlers responded to {EventType.Name(EventType.SIMPLE_API_REQUEST)}"
281
- f" {event.context['path']}"
282
- )
283
- effect_list = [Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()]
284
-
285
- event_duration = get_duration_ms(event_start_time)
271
+ # Don't log anything if a plugin handler didn't actually run.
272
+ if relevant_plugins:
273
+ # Send the Django request_finished signal
274
+ request_finished.send(sender=self.__class__)
286
275
 
287
- # Don't log anything if a plugin handler didn't actually run.
288
- if relevant_plugins:
289
- log.info(f"Responded to Event {event_name} ({event_duration} ms)")
290
- statsd_client.timing(
291
- "plugins.event_duration_ms", delta=event_duration, tags={"event": event_name}
292
- )
276
+ log.info(f"Responded to Event {event_name}.")
293
277
 
294
- yield EventResponse(success=True, effects=effect_list)
278
+ yield EventResponse(success=True, effects=effect_list)
295
279
 
296
- async def ReloadPlugins(
280
+ def ReloadPlugins(
297
281
  self, request: ReloadPluginsRequest, context: Any
298
- ) -> AsyncGenerator[ReloadPluginsResponse, None]:
282
+ ) -> Iterable[ReloadPluginsResponse]:
299
283
  """This is invoked when we need to reload plugins."""
300
284
  log.info("Reloading plugins...")
301
285
  try:
302
- await publish_message(message={"action": "reload"})
286
+ publish_message(message={"action": "reload"})
303
287
  except ImportError:
304
288
  yield ReloadPluginsResponse(success=False)
305
289
  else:
306
290
  yield ReloadPluginsResponse(success=True)
307
291
 
308
292
 
309
- async def synchronize_plugins(run_once: bool = False) -> None:
293
+ STOP_SYNCHRONIZER = threading.Event()
294
+
295
+
296
+ def synchronize_plugins(run_once: bool = False) -> None:
310
297
  """
311
298
  Listen for messages on the pubsub channel that will indicate it is
312
299
  necessary to reinstall and reload plugins.
@@ -315,16 +302,10 @@ async def synchronize_plugins(run_once: bool = False) -> None:
315
302
 
316
303
  _, pubsub = get_client()
317
304
 
318
- await pubsub.psubscribe(CHANNEL_NAME)
319
-
320
- while True:
321
- message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=5.0)
305
+ pubsub.psubscribe(CHANNEL_NAME)
322
306
 
323
- await pubsub.check_health()
324
-
325
- if not pubsub.connection.is_connected: # type: ignore
326
- log.info("synchronize_plugins: reconnecting to Redis")
327
- await pubsub.connection.connect() # type: ignore
307
+ while not STOP_SYNCHRONIZER.is_set():
308
+ message = pubsub.get_message(ignore_subscribe_messages=True, timeout=5.0)
328
309
 
329
310
  if message is None:
330
311
  continue
@@ -360,21 +341,21 @@ async def synchronize_plugins(run_once: bool = False) -> None:
360
341
  break
361
342
 
362
343
 
363
- async def synchronize_plugins_and_report_errors() -> None:
344
+ def synchronize_plugins_and_report_errors() -> None:
364
345
  """
365
346
  Run synchronize_plugins() in perpetuity and report any encountered errors.
366
347
  """
367
348
  log.info("synchronize_plugins: starting loop...")
368
349
 
369
- while True:
350
+ while not STOP_SYNCHRONIZER.is_set():
370
351
  try:
371
- await synchronize_plugins()
352
+ synchronize_plugins()
372
353
  except Exception as e:
373
354
  log.error(f"synchronize_plugins: error: {e}")
374
355
  sentry_sdk.capture_exception(e)
375
356
 
376
357
  # don't crush redis if we're retrying in a tight loop
377
- await asyncio.sleep(0.5)
358
+ sleep(0.5)
378
359
 
379
360
 
380
361
  def validate_effects(effects: list[Effect]) -> list[Effect]:
@@ -437,17 +418,22 @@ def find_modules(base_path: pathlib.Path, prefix: str | None = None) -> list[str
437
418
  return modules
438
419
 
439
420
 
440
- async def publish_message(message: dict) -> None:
421
+ def publish_message(message: dict) -> None:
441
422
  """Publish a message to the pubsub channel."""
442
423
  log.info(f'Publishing message to pubsub channel "{CHANNEL_NAME}"')
443
424
  client, _ = get_client()
444
425
 
445
- await client.publish(CHANNEL_NAME, pickle.dumps(message))
426
+ client.publish(CHANNEL_NAME, pickle.dumps(message))
446
427
 
447
428
 
448
429
  def get_client() -> tuple[redis.Redis, redis.client.PubSub]:
449
- """Return an async Redis client and pubsub object."""
450
- client = redis.Redis.from_url(REDIS_ENDPOINT)
430
+ """Return a Redis client and pubsub object."""
431
+ client = redis.from_url(
432
+ REDIS_ENDPOINT,
433
+ retry=Retry(backoff=ExponentialBackoff(), retries=10),
434
+ retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError],
435
+ health_check_interval=1,
436
+ )
451
437
  pubsub = client.pubsub()
452
438
 
453
439
  return client, pubsub
@@ -560,6 +546,7 @@ def refresh_event_type_map() -> None:
560
546
  log.warning(f"Unknown RESPONDS_TO type: {type(responds_to)}")
561
547
 
562
548
 
549
+ @measured
563
550
  def load_plugins(specified_plugin_paths: list[str] | None = None) -> None:
564
551
  """Load the plugins."""
565
552
  # first mark each plugin as inactive since we want to remove it from
@@ -596,67 +583,41 @@ def load_plugins(specified_plugin_paths: list[str] | None = None) -> None:
596
583
 
597
584
  refresh_event_type_map()
598
585
 
599
- log_nr_event_handlers()
600
-
601
-
602
- def log_nr_event_handlers() -> None:
603
- """Log the number of event handlers for each event."""
604
- for key in EventType.keys(): # noqa: SIM118
605
- value = len(EVENT_HANDLER_MAP[key]) if key in EVENT_HANDLER_MAP else 0
606
- statsd_client.timing("plugins.event_nr_handlers", value, tags={"event": key})
607
-
608
-
609
- _cleanup_coroutines = []
610
-
611
586
 
612
- async def serve(specified_plugin_paths: list[str] | None = None) -> None:
613
- """Run the server."""
587
+ # NOTE: specified_plugin_paths powers the `canvas run-plugins` command
588
+ def main(specified_plugin_paths: list[str] | None = None) -> None:
589
+ """Run the server and the synchronize_plugins loop."""
614
590
  port = "50051"
615
591
 
616
- server = grpc.aio.server()
592
+ executor = ThreadPoolExecutor(max_workers=settings.PLUGIN_RUNNER_MAX_WORKERS)
593
+ server = grpc.server(thread_pool=executor)
617
594
  server.add_insecure_port("127.0.0.1:" + port)
618
595
 
619
596
  add_PluginRunnerServicer_to_server(PluginRunner(), server)
620
597
 
621
598
  log.info(f"Starting server, listening on port {port}")
622
599
 
623
- # Only install plugins if the plugin runner was not started from the CLI
600
+ # Only install plugins and start the synchronizer thread if the plugin runner was not started
601
+ # from the CLI
602
+ synchronizer_thread = threading.Thread(target=synchronize_plugins_and_report_errors)
624
603
  if specified_plugin_paths is None:
625
604
  install_plugins()
605
+ STOP_SYNCHRONIZER.clear()
606
+ synchronizer_thread.start()
626
607
 
627
608
  load_plugins(specified_plugin_paths)
628
609
 
629
- await server.start()
630
-
631
- async def server_graceful_shutdown() -> None:
632
- log.info("Starting graceful shutdown...")
633
- await server.stop(5)
634
-
635
- _cleanup_coroutines.append(server_graceful_shutdown())
636
-
637
- await server.wait_for_termination()
638
-
639
-
640
- # NOTE: specified_plugin_paths powers the `canvas run-plugins` command
641
- def main(specified_plugin_paths: list[str] | None = None) -> None:
642
- """Run the server and the synchronize_plugins loop."""
643
- loop = asyncio.new_event_loop()
644
-
645
- asyncio.set_event_loop(loop)
610
+ server.start()
646
611
 
647
612
  try:
648
- coroutines = [serve(specified_plugin_paths)]
649
-
650
- # Only start the synchronizer if the plugin runner was not started from the CLI
651
- if specified_plugin_paths is None:
652
- coroutines.append(synchronize_plugins_and_report_errors())
653
-
654
- loop.run_until_complete(asyncio.gather(*coroutines))
613
+ server.wait_for_termination()
655
614
  except KeyboardInterrupt:
656
615
  pass
657
616
  finally:
658
- loop.run_until_complete(*_cleanup_coroutines)
659
- loop.close()
617
+ executor.shutdown(wait=True, cancel_futures=True)
618
+ if synchronizer_thread.is_alive():
619
+ STOP_SYNCHRONIZER.set()
620
+ synchronizer_thread.join()
660
621
 
661
622
 
662
623
  if __name__ == "__main__":
plugin_runner/sandbox.py CHANGED
@@ -217,6 +217,9 @@ STANDARD_LIBRARY_MODULES = {
217
217
  "uuid4",
218
218
  "UUID",
219
219
  },
220
+ "zoneinfo": {
221
+ "ZoneInfo",
222
+ },
220
223
  }
221
224
 
222
225
 
@@ -169,6 +169,12 @@ enum EffectType {
169
169
  COMMIT_REFER_COMMAND = 142;
170
170
  ENTER_IN_ERROR_REFER_COMMAND = 143;
171
171
 
172
+ ORIGINATE_CHANGE_MEDICATION_COMMAND = 150;
173
+ EDIT_CHANGE_MEDICATION_COMMAND = 151;
174
+ DELETE_CHANGE_MEDICATION_COMMAND = 152;
175
+ COMMIT_CHANGE_MEDICATION_COMMAND = 153;
176
+ ENTER_IN_ERROR_CHANGE_MEDICATION_COMMAND = 154;
177
+
172
178
  CREATE_QUESTIONNAIRE_RESULT = 138;
173
179
 
174
180
  ANNOTATE_PATIENT_CHART_CONDITION_RESULTS = 200;
@@ -256,6 +262,12 @@ enum EffectType {
256
262
  SIMPLE_API_RESPONSE = 4000;
257
263
 
258
264
  UPDATE_USER = 5000;
265
+
266
+ CREATE_NOTE = 6000;
267
+ CREATE_APPOINTMENT = 6001;
268
+ CREATE_SCHEDULE_EVENT = 6002;
269
+
270
+ CREATE_PATIENT = 6003;
259
271
  }
260
272
 
261
273
  message Effect {
@@ -263,6 +275,7 @@ message Effect {
263
275
  string payload = 2;
264
276
  string plugin_name = 3;
265
277
  string classname = 4;
278
+ string handler_name = 5;
266
279
  //Oneof effect_payload {
267
280
  // ...
268
281
  //}
@@ -1028,6 +1028,22 @@ enum EventType {
1028
1028
  DEFER_CODING_GAP_COMMAND__POST_EXECUTE_ACTION = 60011;
1029
1029
  DEFER_CODING_GAP_COMMAND__POST_INSERTED_INTO_NOTE = 60012;
1030
1030
 
1031
+ CHANGE_MEDICATION_COMMAND__PRE_ORIGINATE = 61000;
1032
+ CHANGE_MEDICATION_COMMAND__POST_ORIGINATE = 61001;
1033
+ CHANGE_MEDICATION_COMMAND__PRE_UPDATE = 61002;
1034
+ CHANGE_MEDICATION_COMMAND__POST_UPDATE = 61003;
1035
+ CHANGE_MEDICATION_COMMAND__PRE_COMMIT = 61004;
1036
+ CHANGE_MEDICATION_COMMAND__POST_COMMIT = 61005;
1037
+ CHANGE_MEDICATION_COMMAND__PRE_DELETE = 61006;
1038
+ CHANGE_MEDICATION_COMMAND__POST_DELETE = 61007;
1039
+ CHANGE_MEDICATION_COMMAND__PRE_ENTER_IN_ERROR = 61008;
1040
+ CHANGE_MEDICATION_COMMAND__POST_ENTER_IN_ERROR = 61009;
1041
+ CHANGE_MEDICATION_COMMAND__PRE_EXECUTE_ACTION = 61010;
1042
+ CHANGE_MEDICATION_COMMAND__POST_EXECUTE_ACTION = 61011;
1043
+ CHANGE_MEDICATION_COMMAND__POST_INSERTED_INTO_NOTE = 61012;
1044
+ CHANGE_MEDICATION__MEDICATION__PRE_SEARCH = 61013;
1045
+ CHANGE_MEDICATION__MEDICATION__POST_SEARCH = 61014;
1046
+
1031
1047
  SHOW_NOTE_HEADER_BUTTON = 70000;
1032
1048
  SHOW_NOTE_FOOTER_BUTTON = 70001;
1033
1049
  ACTION_BUTTON_CLICKED = 70002;
pubsub/pubsub.py CHANGED
@@ -2,6 +2,9 @@ import os
2
2
  from typing import Any
3
3
 
4
4
  import redis
5
+ from redis.backoff import ExponentialBackoff
6
+ from redis.exceptions import ConnectionError, TimeoutError
7
+ from redis.retry import Retry
5
8
 
6
9
  REDIS_ENDPOINT = os.getenv("REDIS_ENDPOINT")
7
10
  CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER")
@@ -24,7 +27,13 @@ class PubSubBase:
24
27
 
25
28
  def _create_client(self) -> redis.Redis | None:
26
29
  if self.redis_endpoint and self.channel:
27
- return redis.Redis.from_url(self.redis_endpoint, decode_responses=True)
30
+ return redis.Redis.from_url(
31
+ self.redis_endpoint,
32
+ decode_responses=True,
33
+ retry=Retry(backoff=ExponentialBackoff(), retries=10),
34
+ retry_on_error=[ConnectionError, TimeoutError, ConnectionResetError],
35
+ health_check_interval=1,
36
+ )
28
37
 
29
38
  return None
30
39
 
settings.py CHANGED
@@ -14,6 +14,7 @@ ENV = os.getenv("ENV", "development")
14
14
  IS_PRODUCTION = ENV == "production"
15
15
  IS_PRODUCTION_CUSTOMER = env_to_bool("IS_PRODUCTION_CUSTOMER", IS_PRODUCTION)
16
16
  IS_TESTING = env_to_bool("IS_TESTING", "pytest" in sys.argv[0] or sys.argv[0] == "-c")
17
+ IS_SCRIPT = env_to_bool("IS_SCRIPT", "plugin_runner.py" not in sys.argv[0])
17
18
  CUSTOMER_IDENTIFIER = os.getenv("CUSTOMER_IDENTIFIER", "local")
18
19
  APP_NAME = os.getenv("APP_NAME")
19
20
 
@@ -24,6 +25,9 @@ INTEGRATION_TEST_CLIENT_SECRET = os.getenv("INTEGRATION_TEST_CLIENT_SECRET")
24
25
  GRAPHQL_ENDPOINT = os.getenv("GRAPHQL_ENDPOINT", "http://localhost:8000/plugins-graphql")
25
26
  REDIS_ENDPOINT = os.getenv("REDIS_ENDPOINT", f"redis://{APP_NAME}-redis:6379")
26
27
 
28
+
29
+ METRICS_ENABLED = env_to_bool("PLUGINS_METRICS_ENABLED", not IS_SCRIPT)
30
+
27
31
  INSTALLED_APPS = [
28
32
  "canvas_sdk.v1",
29
33
  ]
@@ -42,12 +46,15 @@ CANVAS_SDK_DB_PASSWORD = os.getenv("CANVAS_SDK_DB_PASSWORD", "app")
42
46
  CANVAS_SDK_DB_HOST = os.getenv("CANVAS_SDK_DB_HOST", "home-app-db")
43
47
  CANVAS_SDK_DB_PORT = os.getenv("CANVAS_SDK_DB_PORT", "5432")
44
48
 
49
+ PLUGIN_RUNNER_MAX_WORKERS = int(os.getenv("PLUGIN_RUNNER_MAX_WORKERS", 5))
50
+
45
51
  if os.getenv("DATABASE_URL"):
46
52
  parsed_url = parse.urlparse(os.getenv("DATABASE_URL"))
47
53
 
48
54
  DATABASES = {
49
55
  "default": {
50
56
  "ENGINE": "django.db.backends.postgresql",
57
+ "OPTIONS": {"pool": {"min_size": 2, "max_size": PLUGIN_RUNNER_MAX_WORKERS}},
51
58
  "NAME": parsed_url.path[1:],
52
59
  "USER": os.getenv("CANVAS_SDK_DATABASE_ROLE"),
53
60
  "PASSWORD": os.getenv("CANVAS_SDK_DATABASE_ROLE_PASSWORD"),
@@ -59,6 +66,7 @@ else:
59
66
  DATABASES = {
60
67
  "default": {
61
68
  "ENGINE": "django.db.backends.postgresql",
69
+ "OPTIONS": {"pool": {"min_size": 2, "max_size": PLUGIN_RUNNER_MAX_WORKERS}},
62
70
  "NAME": CANVAS_SDK_DB_NAME,
63
71
  "USER": CANVAS_SDK_DB_USERNAME,
64
72
  "PASSWORD": CANVAS_SDK_DB_PASSWORD,