canvas 0.63.0__py3-none-any.whl → 0.89.0__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.
Files changed (185) hide show
  1. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/METADATA +4 -1
  2. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/RECORD +184 -98
  3. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/WHEEL +1 -1
  4. canvas_cli/apps/emit/event_fixtures/UNKNOWN.ndjson +1 -0
  5. canvas_cli/apps/logs/logs.py +386 -22
  6. canvas_cli/main.py +3 -1
  7. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/tests/test_models.py +46 -4
  8. canvas_cli/utils/context/context.py +13 -13
  9. canvas_cli/utils/validators/manifest_schema.py +26 -1
  10. canvas_generated/messages/effects_pb2.py +5 -5
  11. canvas_generated/messages/effects_pb2.pyi +108 -2
  12. canvas_generated/messages/events_pb2.py +6 -6
  13. canvas_generated/messages/events_pb2.pyi +282 -2
  14. canvas_sdk/clients/__init__.py +1 -0
  15. canvas_sdk/clients/llms/__init__.py +17 -0
  16. canvas_sdk/clients/llms/libraries/__init__.py +11 -0
  17. canvas_sdk/clients/llms/libraries/llm_anthropic.py +87 -0
  18. canvas_sdk/clients/llms/libraries/llm_api.py +143 -0
  19. canvas_sdk/clients/llms/libraries/llm_google.py +92 -0
  20. canvas_sdk/clients/llms/libraries/llm_openai.py +98 -0
  21. canvas_sdk/clients/llms/structures/__init__.py +9 -0
  22. canvas_sdk/clients/llms/structures/llm_response.py +33 -0
  23. canvas_sdk/clients/llms/structures/llm_tokens.py +53 -0
  24. canvas_sdk/clients/llms/structures/llm_turn.py +47 -0
  25. canvas_sdk/clients/llms/structures/settings/__init__.py +13 -0
  26. canvas_sdk/clients/llms/structures/settings/llm_settings.py +27 -0
  27. canvas_sdk/clients/llms/structures/settings/llm_settings_anthropic.py +43 -0
  28. canvas_sdk/clients/llms/structures/settings/llm_settings_gemini.py +40 -0
  29. canvas_sdk/clients/llms/structures/settings/llm_settings_gpt4.py +40 -0
  30. canvas_sdk/clients/llms/structures/settings/llm_settings_gpt5.py +48 -0
  31. canvas_sdk/clients/third_party.py +3 -0
  32. canvas_sdk/commands/__init__.py +12 -0
  33. canvas_sdk/commands/base.py +33 -2
  34. canvas_sdk/commands/commands/adjust_prescription.py +4 -0
  35. canvas_sdk/commands/commands/custom_command.py +86 -0
  36. canvas_sdk/commands/commands/family_history.py +17 -1
  37. canvas_sdk/commands/commands/immunization_statement.py +42 -2
  38. canvas_sdk/commands/commands/medication_statement.py +16 -1
  39. canvas_sdk/commands/commands/past_surgical_history.py +16 -1
  40. canvas_sdk/commands/commands/perform.py +18 -1
  41. canvas_sdk/commands/commands/prescribe.py +8 -9
  42. canvas_sdk/commands/commands/refill.py +5 -5
  43. canvas_sdk/commands/commands/resolve_condition.py +5 -5
  44. canvas_sdk/commands/commands/review/__init__.py +3 -0
  45. canvas_sdk/commands/commands/review/base.py +72 -0
  46. canvas_sdk/commands/commands/review/imaging.py +13 -0
  47. canvas_sdk/commands/commands/review/lab.py +13 -0
  48. canvas_sdk/commands/commands/review/referral.py +13 -0
  49. canvas_sdk/commands/commands/review/uncategorized_document.py +13 -0
  50. canvas_sdk/commands/validation.py +43 -0
  51. canvas_sdk/effects/batch_originate.py +22 -0
  52. canvas_sdk/effects/calendar/__init__.py +13 -3
  53. canvas_sdk/effects/calendar/{create_calendar.py → calendar.py} +19 -5
  54. canvas_sdk/effects/calendar/event.py +172 -0
  55. canvas_sdk/effects/claim_label.py +93 -0
  56. canvas_sdk/effects/claim_line_item.py +47 -0
  57. canvas_sdk/effects/claim_queue.py +49 -0
  58. canvas_sdk/effects/fax/__init__.py +3 -0
  59. canvas_sdk/effects/fax/base.py +77 -0
  60. canvas_sdk/effects/fax/note.py +42 -0
  61. canvas_sdk/effects/metadata.py +15 -1
  62. canvas_sdk/effects/note/__init__.py +8 -1
  63. canvas_sdk/effects/note/appointment.py +135 -7
  64. canvas_sdk/effects/note/base.py +17 -0
  65. canvas_sdk/effects/note/message.py +22 -14
  66. canvas_sdk/effects/note/note.py +150 -1
  67. canvas_sdk/effects/observation/__init__.py +11 -0
  68. canvas_sdk/effects/observation/base.py +206 -0
  69. canvas_sdk/effects/patient/__init__.py +2 -0
  70. canvas_sdk/effects/patient/base.py +8 -0
  71. canvas_sdk/effects/payment/__init__.py +11 -0
  72. canvas_sdk/effects/payment/base.py +355 -0
  73. canvas_sdk/effects/payment/post_claim_payment.py +49 -0
  74. canvas_sdk/effects/send_contact_verification.py +42 -0
  75. canvas_sdk/effects/task/__init__.py +2 -1
  76. canvas_sdk/effects/task/task.py +30 -0
  77. canvas_sdk/effects/validation/__init__.py +3 -0
  78. canvas_sdk/effects/validation/base.py +92 -0
  79. canvas_sdk/events/base.py +15 -0
  80. canvas_sdk/handlers/application.py +7 -7
  81. canvas_sdk/handlers/simple_api/api.py +1 -4
  82. canvas_sdk/handlers/simple_api/websocket.py +1 -4
  83. canvas_sdk/handlers/utils.py +14 -0
  84. canvas_sdk/questionnaires/utils.py +1 -0
  85. canvas_sdk/templates/utils.py +17 -4
  86. canvas_sdk/test_utils/factories/FACTORY_GUIDE.md +362 -0
  87. canvas_sdk/test_utils/factories/__init__.py +115 -0
  88. canvas_sdk/test_utils/factories/calendar.py +24 -0
  89. canvas_sdk/test_utils/factories/claim.py +81 -0
  90. canvas_sdk/test_utils/factories/claim_diagnosis_code.py +16 -0
  91. canvas_sdk/test_utils/factories/coverage.py +17 -0
  92. canvas_sdk/test_utils/factories/imaging.py +74 -0
  93. canvas_sdk/test_utils/factories/lab.py +192 -0
  94. canvas_sdk/test_utils/factories/medication_history.py +75 -0
  95. canvas_sdk/test_utils/factories/note.py +52 -0
  96. canvas_sdk/test_utils/factories/organization.py +50 -0
  97. canvas_sdk/test_utils/factories/practicelocation.py +88 -0
  98. canvas_sdk/test_utils/factories/referral.py +81 -0
  99. canvas_sdk/test_utils/factories/staff.py +111 -0
  100. canvas_sdk/test_utils/factories/task.py +66 -0
  101. canvas_sdk/test_utils/factories/uncategorized_clinical_document.py +48 -0
  102. canvas_sdk/utils/metrics.py +4 -1
  103. canvas_sdk/v1/data/__init__.py +66 -7
  104. canvas_sdk/v1/data/allergy_intolerance.py +5 -11
  105. canvas_sdk/v1/data/appointment.py +18 -4
  106. canvas_sdk/v1/data/assessment.py +2 -12
  107. canvas_sdk/v1/data/banner_alert.py +2 -4
  108. canvas_sdk/v1/data/base.py +53 -14
  109. canvas_sdk/v1/data/billing.py +8 -11
  110. canvas_sdk/v1/data/calendar.py +64 -0
  111. canvas_sdk/v1/data/care_team.py +4 -10
  112. canvas_sdk/v1/data/claim.py +172 -66
  113. canvas_sdk/v1/data/claim_diagnosis_code.py +19 -0
  114. canvas_sdk/v1/data/claim_line_item.py +2 -5
  115. canvas_sdk/v1/data/coding.py +19 -0
  116. canvas_sdk/v1/data/command.py +2 -4
  117. canvas_sdk/v1/data/common.py +10 -0
  118. canvas_sdk/v1/data/compound_medication.py +3 -4
  119. canvas_sdk/v1/data/condition.py +4 -9
  120. canvas_sdk/v1/data/coverage.py +66 -26
  121. canvas_sdk/v1/data/detected_issue.py +20 -20
  122. canvas_sdk/v1/data/device.py +2 -14
  123. canvas_sdk/v1/data/discount.py +2 -5
  124. canvas_sdk/v1/data/encounter.py +44 -0
  125. canvas_sdk/v1/data/facility.py +1 -0
  126. canvas_sdk/v1/data/goal.py +2 -14
  127. canvas_sdk/v1/data/imaging.py +4 -30
  128. canvas_sdk/v1/data/immunization.py +7 -15
  129. canvas_sdk/v1/data/lab.py +12 -65
  130. canvas_sdk/v1/data/line_item_transaction.py +2 -5
  131. canvas_sdk/v1/data/medication.py +3 -8
  132. canvas_sdk/v1/data/medication_history.py +142 -0
  133. canvas_sdk/v1/data/medication_statement.py +41 -0
  134. canvas_sdk/v1/data/message.py +4 -8
  135. canvas_sdk/v1/data/note.py +37 -38
  136. canvas_sdk/v1/data/observation.py +9 -36
  137. canvas_sdk/v1/data/organization.py +70 -9
  138. canvas_sdk/v1/data/patient.py +8 -12
  139. canvas_sdk/v1/data/patient_consent.py +4 -14
  140. canvas_sdk/v1/data/payment_collection.py +2 -5
  141. canvas_sdk/v1/data/posting.py +3 -9
  142. canvas_sdk/v1/data/practicelocation.py +66 -7
  143. canvas_sdk/v1/data/protocol_override.py +3 -4
  144. canvas_sdk/v1/data/protocol_result.py +3 -3
  145. canvas_sdk/v1/data/questionnaire.py +10 -26
  146. canvas_sdk/v1/data/reason_for_visit.py +2 -6
  147. canvas_sdk/v1/data/referral.py +41 -17
  148. canvas_sdk/v1/data/staff.py +34 -26
  149. canvas_sdk/v1/data/stop_medication_event.py +27 -0
  150. canvas_sdk/v1/data/task.py +30 -11
  151. canvas_sdk/v1/data/team.py +2 -4
  152. canvas_sdk/v1/data/uncategorized_clinical_document.py +84 -0
  153. canvas_sdk/v1/data/user.py +14 -0
  154. canvas_sdk/v1/data/utils.py +5 -0
  155. canvas_sdk/value_set/v2026/__init__.py +1 -0
  156. canvas_sdk/value_set/v2026/adverse_event.py +157 -0
  157. canvas_sdk/value_set/v2026/allergy.py +116 -0
  158. canvas_sdk/value_set/v2026/assessment.py +466 -0
  159. canvas_sdk/value_set/v2026/communication.py +496 -0
  160. canvas_sdk/value_set/v2026/condition.py +52934 -0
  161. canvas_sdk/value_set/v2026/device.py +315 -0
  162. canvas_sdk/value_set/v2026/diagnostic_study.py +5243 -0
  163. canvas_sdk/value_set/v2026/encounter.py +2714 -0
  164. canvas_sdk/value_set/v2026/immunization.py +297 -0
  165. canvas_sdk/value_set/v2026/individual_characteristic.py +339 -0
  166. canvas_sdk/value_set/v2026/intervention.py +1703 -0
  167. canvas_sdk/value_set/v2026/laboratory_test.py +1831 -0
  168. canvas_sdk/value_set/v2026/medication.py +8218 -0
  169. canvas_sdk/value_set/v2026/no_qdm_category_assigned.py +26493 -0
  170. canvas_sdk/value_set/v2026/physical_exam.py +342 -0
  171. canvas_sdk/value_set/v2026/procedure.py +27869 -0
  172. canvas_sdk/value_set/v2026/symptom.py +625 -0
  173. logger/logger.py +30 -31
  174. logger/logstash.py +282 -0
  175. logger/pubsub.py +26 -0
  176. plugin_runner/allowed-module-imports.json +940 -9
  177. plugin_runner/generate_allowed_imports.py +1 -0
  178. plugin_runner/installation.py +2 -2
  179. plugin_runner/plugin_runner.py +21 -24
  180. plugin_runner/sandbox.py +34 -0
  181. protobufs/canvas_generated/messages/effects.proto +65 -0
  182. protobufs/canvas_generated/messages/events.proto +150 -51
  183. settings.py +27 -11
  184. canvas_sdk/effects/calendar/create_event.py +0 -43
  185. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/entry_points.txt +0 -0
@@ -1,11 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
1
4
  import json
2
- from typing import cast
5
+ import re
6
+ import shutil
7
+ import sys
8
+ import zlib
9
+ from collections.abc import Iterator
10
+ from datetime import UTC, datetime, timedelta
11
+ from typing import Any, TypedDict, cast
3
12
  from urllib.parse import urlparse
4
13
 
14
+ import requests
5
15
  import typer
6
16
  import websocket
7
17
 
8
18
  from canvas_cli.apps.auth.utils import get_default_host, get_or_request_api_token
19
+ from canvas_cli.apps.plugin.plugin import plugin_url
20
+
21
+ DURATION_REGEX = re.compile(
22
+ r"""
23
+ ^\s* # optional leading whitespace
24
+ (?:(?P<d>\d+)d)?\s* # optional days
25
+ (?:(?P<h>\d+)h)?\s* # optional hours
26
+ (?:(?P<m>\d+)m)?\s* # optional minutes
27
+ (?:(?P<s>\d+)s)?\s* # optional seconds
28
+ \s*$ # optional trailing whitespace
29
+ """,
30
+ re.X,
31
+ )
32
+ DEFAULT_PAGE_SIZE = 200
33
+ LAST_CURSOR: list[str] | None = None
34
+
35
+
36
+ def _format_line(hit: dict[str, Any], log_prefix: str = "") -> str:
37
+ """Format a log entry for printing."""
38
+ level = (hit.get("log", {}).get("level")).upper()
39
+ asctime = hit.get("ts")
40
+ message = hit.get("message") or ""
41
+ service = hit.get("service", {}).get("name")
42
+ prefix = f"{log_prefix}" if log_prefix else ""
43
+ error = hit.get("error", {})
44
+
45
+ output = f"{service} {prefix}{level} {asctime} {message}"
46
+
47
+ if error:
48
+ if (stack_trace := error.get("stack_trace")) and service == "plugin-runner":
49
+ output = f"{output}\n{''.join(stack_trace)}".rstrip("\n")
50
+ else:
51
+ output = f"{output}: ({error.get('type', '').split('.')[-1]}: {error.get('message', '')})".strip()
52
+
53
+ return output
54
+
55
+
56
+ def _parse_duration(s: str) -> timedelta:
57
+ """
58
+ Accepts '24h', '2h30m', '1d', '45m', '90s', '1d2h15m10s' etc.
59
+ """
60
+ m = DURATION_REGEX.match(s)
61
+
62
+ if not m:
63
+ raise typer.BadParameter("Use durations like '15m', '2h', '1d2h30m', '45s'.")
64
+
65
+ days = int(m.group("d") or 0)
66
+ hours = int(m.group("h") or 0)
67
+ minutes = int(m.group("m") or 0)
68
+ seconds = int(m.group("s") or 0)
69
+
70
+ return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
71
+
72
+
73
+ def _parse_when(s: str) -> datetime:
74
+ """
75
+ Parses ISO/RFC3339 or 'now'. Naive times treated as local and converted to UTC.
76
+ """
77
+ if s.lower() == "now":
78
+ return datetime.now(UTC)
79
+ try:
80
+ dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
81
+ except ValueError as e:
82
+ raise typer.BadParameter(f"Invalid datetime '{s}': {e}") from None
83
+ if dt.tzinfo is None:
84
+ dt = dt.astimezone() # assume local
85
+ return dt.astimezone(UTC)
86
+
87
+
88
+ def _clear_line() -> None:
89
+ """Clear the current line."""
90
+ try:
91
+ sys.stdout.write("\r\x1b[2K") # CR + ClearLine
92
+ except Exception:
93
+ cols = shutil.get_terminal_size((120, 20)).columns
94
+ sys.stdout.write("\r" + " " * cols + "\r")
95
+ sys.stdout.flush()
96
+
97
+
98
+ def _clear_previous_line() -> None:
99
+ """Move cursor up one line, then clear that line."""
100
+ sys.stdout.write("\x1b[1A")
101
+ _clear_line()
102
+
103
+
104
+ def _ask(prompt: str) -> str:
105
+ """Show prompt, read answer and go back up and clear the original prompt line."""
106
+ sys.stdout.write(prompt)
107
+ sys.stdout.flush()
108
+ try:
109
+ ans = input().strip().lower()
110
+ except EOFError:
111
+ ans = "n"
112
+ _clear_previous_line()
113
+ return ans
114
+
115
+
116
+ def make_resume_token(**kwargs: Any) -> str:
117
+ """Pack filters & cursor into a base64 token."""
118
+ raw = json.dumps(kwargs, separators=(",", ":"), ensure_ascii=False).encode()
119
+ comp = zlib.compress(raw, level=9)
120
+
121
+ return base64.urlsafe_b64encode(comp).decode("ascii").rstrip("=")
122
+
123
+
124
+ def parse_resume_token(token: str) -> dict[str, Any]:
125
+ """Return (params, cursor) from a base64 token."""
126
+ token += "=" * (-len(token) % 4)
127
+ comp = base64.urlsafe_b64decode(token.encode("ascii"))
128
+ raw = zlib.decompress(comp)
129
+ data = json.loads(raw.decode("utf-8"))
130
+
131
+ if not isinstance(data, dict) or "cursor" not in data:
132
+ raise ValueError("Invalid cursor.")
133
+
134
+ return data
135
+
136
+
137
+ class SearchPage(TypedDict, total=False):
138
+ """A page of search results."""
139
+
140
+ hits: list[dict[str, Any]]
141
+ next: dict[str, Any]
142
+
143
+
144
+ def search_logs(
145
+ *,
146
+ token: str,
147
+ host: str,
148
+ source: str | None,
149
+ levels: list[str],
150
+ start_time: str | None,
151
+ end_time: str | None,
152
+ size: int,
153
+ search_after: list[Any] | None,
154
+ query: str | None = None,
155
+ ) -> SearchPage:
156
+ """Fetch logs from Canvas API."""
157
+ url = plugin_url(host, "/logs")
158
+
159
+ params: dict[str, Any] = {
160
+ "size": size,
161
+ }
162
+
163
+ if source:
164
+ params["source"] = source
165
+ if levels:
166
+ params["level"] = levels
167
+ if start_time:
168
+ params["start_time"] = start_time
169
+ if end_time:
170
+ params["end_time"] = end_time
171
+ if query:
172
+ params["query"] = query
173
+ if search_after:
174
+ params["search_after"] = json.dumps(search_after)
175
+
176
+ r = requests.get(
177
+ url,
178
+ headers={"Authorization": f"Bearer {token}"},
179
+ params=params,
180
+ timeout=30,
181
+ )
182
+ r.raise_for_status()
183
+ data = r.json()
184
+
185
+ return {
186
+ "hits": data.get("hits", []),
187
+ "next": data.get("next", {}),
188
+ }
189
+
190
+
191
+ def iter_history(
192
+ *,
193
+ token: str,
194
+ host: str,
195
+ source: str | None,
196
+ levels: list[str],
197
+ start_time: str | None,
198
+ end_time: str | None,
199
+ page_size: int,
200
+ limit: int | None,
201
+ query: str | None = None,
202
+ cursor: list[Any] | None = None,
203
+ interactive: bool = False,
204
+ fetch_all: bool = False,
205
+ ) -> Iterator[dict[str, Any]]:
206
+ """
207
+ Iterate historical hits with search_after paging.
208
+
209
+ Rules:
210
+ - Default: fetch one page only.
211
+ - --limit: fetch up to N docs across pages.
212
+ - --interactive: prompt after each page.
213
+ - --all: fetch until exhausted.
214
+ """
215
+ global LAST_CURSOR
216
+
217
+ if interactive or fetch_all or limit:
218
+ remaining = limit if limit is not None else float("inf")
219
+ else:
220
+ remaining = page_size
221
+
222
+ next_cursor = cursor
223
+ while remaining > 0:
224
+ page = search_logs(
225
+ host=host,
226
+ token=token,
227
+ source=source,
228
+ levels=levels,
229
+ start_time=start_time,
230
+ end_time=end_time,
231
+ size=min(page_size, int(remaining) if remaining != float("inf") else page_size),
232
+ search_after=next_cursor,
233
+ query=query,
234
+ )
235
+ hits = page.get("hits", []) if page else []
236
+ next_cursor = (page.get("next") or {}).get("search_after")
237
+
238
+ if not hits:
239
+ break
240
+
241
+ for h in hits:
242
+ if remaining <= 0:
243
+ break
244
+ yield h
245
+ remaining -= 1
246
+
247
+ if remaining <= 0:
248
+ break
249
+ if not next_cursor:
250
+ break
251
+
252
+ if interactive:
253
+ try:
254
+ ans = _ask("Load more? [Y/n] ")
255
+ except EOFError:
256
+ ans = "n"
257
+ if ans not in ("", "y", "yes"):
258
+ break
259
+ else:
260
+ # default to a single page when no limit/all/interactive was requested
261
+ if not fetch_all and limit is None:
262
+ break
263
+
264
+ LAST_CURSOR = next_cursor
9
265
 
10
266
 
11
267
  def _on_message(ws: websocket.WebSocket, message: str) -> None:
@@ -32,34 +288,142 @@ def _on_open(ws: websocket.WebSocket) -> None:
32
288
 
33
289
  def logs(
34
290
  host: str | None = typer.Option(
35
- callback=get_default_host, help="Canvas instance to connect to", default=None
291
+ callback=get_default_host,
292
+ help="Canvas instance to connect to",
293
+ default=None,
294
+ ),
295
+ since: str | None = typer.Option(
296
+ None,
297
+ help="Lookback window (e.g. '24h', '2h30m'). Mutually exclusive with --start/--end.",
298
+ ),
299
+ start: str | None = typer.Option(None, help="Start time (ISO/RFC3339) or 'now'."),
300
+ end: str | None = typer.Option(
301
+ None, help="End time (ISO/RFC3339) or 'now'. Defaults to now if start is provided."
302
+ ),
303
+ no_follow: bool = typer.Option(
304
+ False, "--no-follow", help="Historical only; do not stream live logs."
305
+ ),
306
+ level: list[str] = typer.Option([], help="Repeatable. --level ERROR --level WARN"),
307
+ source: str | None = typer.Option(None, help="Filter by source/service."),
308
+ page_size: int = typer.Option(DEFAULT_PAGE_SIZE, help="Fetch size per page (historical)."),
309
+ limit: int | None = typer.Option(None, help="Max historical logs to print."),
310
+ all_: bool = typer.Option(False, "--all", help="Fetch all pages until exhausted (historical)."),
311
+ interactive: bool = typer.Option(
312
+ False, "--interactive", help="After each page, prompt to load more."
313
+ ),
314
+ cursor_opt: str | None = typer.Option(
315
+ None, "--cursor", help="Resume token from a previous run."
36
316
  ),
37
317
  ) -> None:
38
- """Listens and prints log streams from the instance."""
318
+ """
319
+ Listens and prints log streams; optionally fetches historical logs first.
320
+ """
39
321
  if not host:
40
322
  raise typer.BadParameter("Please specify a host or add one to the configuration file")
41
323
 
42
324
  token = get_or_request_api_token(host)
43
325
 
44
- # Resolve the instance name from the Canvas host URL (e.g., extract
45
- # 'example' from 'https://example.canvasmedical.com/')
46
- hostname = cast(str, urlparse(host).hostname)
47
- instance = hostname.removesuffix(".canvasmedical.com")
326
+ if since and (start or end):
327
+ raise typer.BadParameter("--since cannot be combined with --start/--end.")
328
+ if all_ and (limit is not None):
329
+ raise typer.BadParameter("--all cannot be combined with --limit.")
330
+ if cursor_opt and any([since, start, end, level, source]):
331
+ raise typer.BadParameter(
332
+ "--cursor cannot be combined with filters. "
333
+ "Use --cursor alone to resume, or start a new query without --cursor."
334
+ )
335
+ if limit is not None and limit <= 0:
336
+ raise typer.BadParameter("--limit must be a positive integer.")
48
337
 
49
- print(
50
- "Connecting to the log stream. Please be patient as there may be a delay before log messages appear."
51
- )
52
- websocket_uri = f"wss://logs.console.canvasmedical.com/{instance}?token={token}"
338
+ start_time = end_time = None
53
339
 
54
- try:
55
- ws = websocket.WebSocketApp(
56
- websocket_uri,
57
- on_message=_on_message,
58
- on_error=_on_error,
59
- on_close=_on_close,
60
- )
61
- ws.on_open = _on_open
62
- ws.run_forever()
340
+ if since:
341
+ lte = datetime.now(UTC)
342
+ gte = lte - _parse_duration(since)
343
+ start_time, end_time = gte.isoformat(), lte.isoformat()
344
+ elif start or end:
345
+ gte = _parse_when(start) if start else None # type: ignore[assignment]
346
+ lte = _parse_when(end) if end else datetime.now(UTC)
347
+ if gte and lte and lte < gte:
348
+ raise typer.BadParameter("End must be after start.")
349
+ start_time = gte.isoformat() if gte else None
350
+ end_time = lte.isoformat() if lte else None
351
+
352
+ # Historical first (if any filtering provided)
353
+ historical_requested = bool(since or start or end or no_follow or cursor_opt)
354
+ if historical_requested:
355
+ cursor = {}
356
+ if cursor_opt:
357
+ try:
358
+ cursor = parse_resume_token(cursor_opt)
359
+ if not isinstance(cursor, dict):
360
+ raise ValueError("cursor must be an encoded base64 JSON dict")
361
+ except Exception as e:
362
+ raise typer.BadParameter(f"Invalid --cursor: {e}") from None
363
+
364
+ args = {
365
+ "source": cursor.get("source", source),
366
+ "levels": cursor.get("levels", [lv.upper() for lv in level]),
367
+ "start_time": cursor.get("start", start_time),
368
+ "end_time": cursor.get("end", end_time),
369
+ "cursor": cursor.get("cursor"),
370
+ }
371
+
372
+ for hit in iter_history(
373
+ token=token,
374
+ host=host,
375
+ fetch_all=all_,
376
+ interactive=interactive and sys.stdout.isatty(),
377
+ page_size=page_size,
378
+ limit=limit,
379
+ **args,
380
+ ):
381
+ print(_format_line(hit))
382
+
383
+ # If there may be more, print a resume hint (stateless paging)
384
+ next_cursor = LAST_CURSOR
385
+ if next_cursor and no_follow and (interactive or (limit is None and not all_)):
386
+ cursor_str = make_resume_token(**{**args, "cursor": next_cursor})
387
+
388
+ parts = [
389
+ "canvas logs",
390
+ " --no-follow",
391
+ ]
392
+
393
+ if page_size != DEFAULT_PAGE_SIZE:
394
+ parts.append(f" --page-size {page_size}")
395
+ if limit:
396
+ parts.append(f" --limit {limit}")
397
+
398
+ parts.append(f" --cursor {cursor_str}")
399
+
400
+ cmd = " \\\n ".join(parts)
401
+
402
+ typer.echo(f"\nMore available. To load the next page, run:\n {cmd}")
403
+
404
+ # Live stream unless --no-follow
405
+ if not no_follow:
406
+ if not historical_requested:
407
+ print(
408
+ "Connecting to the log stream. Please be patient as there may be a delay before log messages appear."
409
+ )
410
+ else:
411
+ print("")
412
+
413
+ # Resolve the instance name from the Canvas host URL (e.g., extract
414
+ # 'example' from 'https://example.canvasmedical.com/')
415
+ hostname = cast(str, urlparse(host).hostname)
416
+ instance = hostname.removesuffix(".canvasmedical.com")
417
+ websocket_uri = f"wss://logs.console.canvasmedical.com/{instance}?token={token}"
63
418
 
64
- except KeyboardInterrupt:
65
- raise typer.Exit(0) from None
419
+ try:
420
+ ws = websocket.WebSocketApp(
421
+ websocket_uri,
422
+ on_message=_on_message,
423
+ on_error=_on_error,
424
+ on_close=_on_close,
425
+ )
426
+ ws.on_open = _on_open
427
+ ws.run_forever()
428
+ except KeyboardInterrupt:
429
+ raise typer.Exit(0) from None
canvas_cli/main.py CHANGED
@@ -22,7 +22,9 @@ app.command(short_help="Enable a plugin from a Canvas instance")(plugin.enable)
22
22
  app.command(short_help="Disable a plugin from a Canvas instance")(plugin.disable)
23
23
  app.command(short_help="List all plugins from a Canvas instance")(plugin.list)
24
24
  app.command(short_help="Validate the Canvas Manifest json file")(plugin.validate_manifest)
25
- app.command(short_help="Listen and print log streams from a Canvas instance")(logs_command)
25
+ app.command(
26
+ short_help="Listen and print log streams or fetches historical logs from a Canvas instance."
27
+ )(logs_command)
26
28
  app.command(
27
29
  short_help="Send an event fixture to your locally running plugin-runner process, and print any resultant effects."
28
30
  )(emit)
@@ -1,21 +1,63 @@
1
1
  # To run the tests, use the command `pytest` in the terminal or uv run pytest.
2
2
  # Each test is wrapped inside a transaction that is rolled back at the end of the test.
3
3
  # If you want to modify which files are used for testing, check the [tool.pytest.ini_options] section in pyproject.toml.
4
+ # For more information on testing Canvas plugins, see: https://docs.canvasmedical.com/sdk/testing-utils/
4
5
 
6
+ from unittest.mock import Mock
5
7
 
8
+ import pytest
9
+ from canvas_sdk.effects import EffectType
10
+ from canvas_sdk.events import EventType
6
11
  from canvas_sdk.test_utils.factories import PatientFactory
7
12
  from canvas_sdk.v1.data.discount import Discount
8
13
 
14
+ from {{ cookiecutter.__package_name }}.protocols.my_protocol import Protocol
9
15
 
10
- # You can use a factory to create a patient instance for testing purposes.
11
- def test_factory() -> None:
16
+
17
+ # Test the protocol's compute method with mocked event data
18
+ def test_protocol_responds_to_assess_command() -> None:
19
+ """Test that the protocol responds correctly to ASSESS_COMMAND__CONDITION_SELECTED events."""
20
+ # Create a mock event with the expected structure
21
+ mock_event = Mock()
22
+ mock_event.type = EventType.ASSESS_COMMAND__CONDITION_SELECTED
23
+ mock_event.context = {
24
+ "note": {
25
+ "uuid": "test-note-uuid-123",
26
+ }
27
+ }
28
+
29
+ # Instantiate the protocol with the mock event
30
+ protocol = Protocol(event=mock_event)
31
+
32
+ # Call compute and get the effects
33
+ effects = protocol.compute()
34
+
35
+ # Assert that effects were returned
36
+ assert len(effects) == 1
37
+
38
+ # Assert the effect has the correct type
39
+ assert effects[0].type == EffectType.LOG
40
+
41
+ # Assert the effect contains expected data
42
+ assert "test-note-uuid-123" in effects[0].payload
43
+ assert protocol.NARRATIVE_STRING in effects[0].payload
44
+
45
+
46
+ # Test that the protocol class has the correct event type configured
47
+ def test_protocol_event_configuration() -> None:
48
+ """Test that the protocol is configured to respond to the correct event type."""
49
+ assert EventType.Name(EventType.ASSESS_COMMAND__CONDITION_SELECTED) == Protocol.RESPONDS_TO
50
+
51
+
52
+ # Example: You can use a factory to create a patient instance for testing purposes.
53
+ def test_factory_example() -> None:
12
54
  """Test that a patient can be created using the PatientFactory."""
13
55
  patient = PatientFactory.create()
14
56
  assert patient.id is not None
15
57
 
16
58
 
17
- # if a factory is not available, you can create an instance manually with the data model directly.
18
- def test_model() -> None:
59
+ # Example: If a factory is not available, you can create an instance manually with the data model directly.
60
+ def test_model_example() -> None:
19
61
  """Test that a Discount instance can be created."""
20
62
  Discount.objects.create(name="10%", adjustment_group="30", adjustment_code="CO", discount=0.10)
21
63
  assert Discount.objects.first().pk is not None
@@ -2,7 +2,7 @@ import functools
2
2
  import json
3
3
  from collections.abc import Callable
4
4
  from pathlib import Path
5
- from typing import Any, TypeVar, cast
5
+ from typing import Any, TypeVar
6
6
 
7
7
  import typer
8
8
 
@@ -47,22 +47,22 @@ class CLIContext:
47
47
  _token_expiration_date: str | None = None
48
48
 
49
49
  @staticmethod
50
- def persistent(fn: F | None = None, **options: Any) -> Callable[[F], F] | F:
50
+ def persistent(fn: Callable[..., Any] | None = None, **options: Any) -> Callable[..., Any]:
51
51
  """A decorator to store a config value in the file everytime it's changed."""
52
52
 
53
- def _decorator(fn: F) -> F:
53
+ def _decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
54
54
  @functools.wraps(fn)
55
55
  def wrapper(self: "CLIContext", *args: Any, **kwargs: Any) -> Any:
56
- fn(self, *args, **kwargs)
57
- value = args[0]
58
-
59
- print(f"Storing {fn.__name__}={value} in the config file")
60
-
61
- self._config_file[fn.__name__] = value
62
- with open(self._config_file_path, "w") as f:
63
- json.dump(self._config_file, f)
64
-
65
- return cast(F, wrapper)
56
+ result = fn(self, *args, **kwargs)
57
+ if args: # Only store if there are arguments (setter case)
58
+ value = args[0]
59
+ print(f"Storing {fn.__name__}={value} in the config file")
60
+ self._config_file[fn.__name__] = str(value)
61
+ with open(self._config_file_path, "w") as f:
62
+ json.dump(self._config_file, f)
63
+ return result
64
+
65
+ return wrapper
66
66
 
67
67
  return _decorator(fn) if fn else _decorator
68
68
 
@@ -11,7 +11,7 @@ manifest_schema = {
11
11
  "components": {
12
12
  "type": "object",
13
13
  "properties": {
14
- "commands": {"$ref": "#/$defs/component"},
14
+ "commands": {"$ref": "#/$defs/commands"},
15
15
  "protocols": {"$ref": "#/$defs/component"},
16
16
  "content": {"$ref": "#/$defs/component"},
17
17
  "effects": {"$ref": "#/$defs/component"},
@@ -153,5 +153,30 @@ manifest_schema = {
153
153
  "additionalProperties": False,
154
154
  },
155
155
  },
156
+ "commands": {
157
+ "type": "array",
158
+ "items": {
159
+ "type": "object",
160
+ "properties": {
161
+ "name": {"type": "string"},
162
+ "label": {"type": "string"},
163
+ "schema_key": {"type": "string"},
164
+ "section": {
165
+ "type": "string",
166
+ "enum": [
167
+ "subjective",
168
+ "objective",
169
+ "assessment",
170
+ "plan",
171
+ "procedures",
172
+ "history",
173
+ "internal",
174
+ ],
175
+ },
176
+ },
177
+ "required": ["name", "schema_key"],
178
+ "additionalProperties": False,
179
+ },
180
+ },
156
181
  },
157
182
  }