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.
- {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/METADATA +4 -1
- {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/RECORD +184 -98
- {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/WHEEL +1 -1
- canvas_cli/apps/emit/event_fixtures/UNKNOWN.ndjson +1 -0
- canvas_cli/apps/logs/logs.py +386 -22
- canvas_cli/main.py +3 -1
- canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/tests/test_models.py +46 -4
- canvas_cli/utils/context/context.py +13 -13
- canvas_cli/utils/validators/manifest_schema.py +26 -1
- canvas_generated/messages/effects_pb2.py +5 -5
- canvas_generated/messages/effects_pb2.pyi +108 -2
- canvas_generated/messages/events_pb2.py +6 -6
- canvas_generated/messages/events_pb2.pyi +282 -2
- canvas_sdk/clients/__init__.py +1 -0
- canvas_sdk/clients/llms/__init__.py +17 -0
- canvas_sdk/clients/llms/libraries/__init__.py +11 -0
- canvas_sdk/clients/llms/libraries/llm_anthropic.py +87 -0
- canvas_sdk/clients/llms/libraries/llm_api.py +143 -0
- canvas_sdk/clients/llms/libraries/llm_google.py +92 -0
- canvas_sdk/clients/llms/libraries/llm_openai.py +98 -0
- canvas_sdk/clients/llms/structures/__init__.py +9 -0
- canvas_sdk/clients/llms/structures/llm_response.py +33 -0
- canvas_sdk/clients/llms/structures/llm_tokens.py +53 -0
- canvas_sdk/clients/llms/structures/llm_turn.py +47 -0
- canvas_sdk/clients/llms/structures/settings/__init__.py +13 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings.py +27 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings_anthropic.py +43 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings_gemini.py +40 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings_gpt4.py +40 -0
- canvas_sdk/clients/llms/structures/settings/llm_settings_gpt5.py +48 -0
- canvas_sdk/clients/third_party.py +3 -0
- canvas_sdk/commands/__init__.py +12 -0
- canvas_sdk/commands/base.py +33 -2
- canvas_sdk/commands/commands/adjust_prescription.py +4 -0
- canvas_sdk/commands/commands/custom_command.py +86 -0
- canvas_sdk/commands/commands/family_history.py +17 -1
- canvas_sdk/commands/commands/immunization_statement.py +42 -2
- canvas_sdk/commands/commands/medication_statement.py +16 -1
- canvas_sdk/commands/commands/past_surgical_history.py +16 -1
- canvas_sdk/commands/commands/perform.py +18 -1
- canvas_sdk/commands/commands/prescribe.py +8 -9
- canvas_sdk/commands/commands/refill.py +5 -5
- canvas_sdk/commands/commands/resolve_condition.py +5 -5
- canvas_sdk/commands/commands/review/__init__.py +3 -0
- canvas_sdk/commands/commands/review/base.py +72 -0
- canvas_sdk/commands/commands/review/imaging.py +13 -0
- canvas_sdk/commands/commands/review/lab.py +13 -0
- canvas_sdk/commands/commands/review/referral.py +13 -0
- canvas_sdk/commands/commands/review/uncategorized_document.py +13 -0
- canvas_sdk/commands/validation.py +43 -0
- canvas_sdk/effects/batch_originate.py +22 -0
- canvas_sdk/effects/calendar/__init__.py +13 -3
- canvas_sdk/effects/calendar/{create_calendar.py → calendar.py} +19 -5
- canvas_sdk/effects/calendar/event.py +172 -0
- canvas_sdk/effects/claim_label.py +93 -0
- canvas_sdk/effects/claim_line_item.py +47 -0
- canvas_sdk/effects/claim_queue.py +49 -0
- canvas_sdk/effects/fax/__init__.py +3 -0
- canvas_sdk/effects/fax/base.py +77 -0
- canvas_sdk/effects/fax/note.py +42 -0
- canvas_sdk/effects/metadata.py +15 -1
- canvas_sdk/effects/note/__init__.py +8 -1
- canvas_sdk/effects/note/appointment.py +135 -7
- canvas_sdk/effects/note/base.py +17 -0
- canvas_sdk/effects/note/message.py +22 -14
- canvas_sdk/effects/note/note.py +150 -1
- canvas_sdk/effects/observation/__init__.py +11 -0
- canvas_sdk/effects/observation/base.py +206 -0
- canvas_sdk/effects/patient/__init__.py +2 -0
- canvas_sdk/effects/patient/base.py +8 -0
- canvas_sdk/effects/payment/__init__.py +11 -0
- canvas_sdk/effects/payment/base.py +355 -0
- canvas_sdk/effects/payment/post_claim_payment.py +49 -0
- canvas_sdk/effects/send_contact_verification.py +42 -0
- canvas_sdk/effects/task/__init__.py +2 -1
- canvas_sdk/effects/task/task.py +30 -0
- canvas_sdk/effects/validation/__init__.py +3 -0
- canvas_sdk/effects/validation/base.py +92 -0
- canvas_sdk/events/base.py +15 -0
- canvas_sdk/handlers/application.py +7 -7
- canvas_sdk/handlers/simple_api/api.py +1 -4
- canvas_sdk/handlers/simple_api/websocket.py +1 -4
- canvas_sdk/handlers/utils.py +14 -0
- canvas_sdk/questionnaires/utils.py +1 -0
- canvas_sdk/templates/utils.py +17 -4
- canvas_sdk/test_utils/factories/FACTORY_GUIDE.md +362 -0
- canvas_sdk/test_utils/factories/__init__.py +115 -0
- canvas_sdk/test_utils/factories/calendar.py +24 -0
- canvas_sdk/test_utils/factories/claim.py +81 -0
- canvas_sdk/test_utils/factories/claim_diagnosis_code.py +16 -0
- canvas_sdk/test_utils/factories/coverage.py +17 -0
- canvas_sdk/test_utils/factories/imaging.py +74 -0
- canvas_sdk/test_utils/factories/lab.py +192 -0
- canvas_sdk/test_utils/factories/medication_history.py +75 -0
- canvas_sdk/test_utils/factories/note.py +52 -0
- canvas_sdk/test_utils/factories/organization.py +50 -0
- canvas_sdk/test_utils/factories/practicelocation.py +88 -0
- canvas_sdk/test_utils/factories/referral.py +81 -0
- canvas_sdk/test_utils/factories/staff.py +111 -0
- canvas_sdk/test_utils/factories/task.py +66 -0
- canvas_sdk/test_utils/factories/uncategorized_clinical_document.py +48 -0
- canvas_sdk/utils/metrics.py +4 -1
- canvas_sdk/v1/data/__init__.py +66 -7
- canvas_sdk/v1/data/allergy_intolerance.py +5 -11
- canvas_sdk/v1/data/appointment.py +18 -4
- canvas_sdk/v1/data/assessment.py +2 -12
- canvas_sdk/v1/data/banner_alert.py +2 -4
- canvas_sdk/v1/data/base.py +53 -14
- canvas_sdk/v1/data/billing.py +8 -11
- canvas_sdk/v1/data/calendar.py +64 -0
- canvas_sdk/v1/data/care_team.py +4 -10
- canvas_sdk/v1/data/claim.py +172 -66
- canvas_sdk/v1/data/claim_diagnosis_code.py +19 -0
- canvas_sdk/v1/data/claim_line_item.py +2 -5
- canvas_sdk/v1/data/coding.py +19 -0
- canvas_sdk/v1/data/command.py +2 -4
- canvas_sdk/v1/data/common.py +10 -0
- canvas_sdk/v1/data/compound_medication.py +3 -4
- canvas_sdk/v1/data/condition.py +4 -9
- canvas_sdk/v1/data/coverage.py +66 -26
- canvas_sdk/v1/data/detected_issue.py +20 -20
- canvas_sdk/v1/data/device.py +2 -14
- canvas_sdk/v1/data/discount.py +2 -5
- canvas_sdk/v1/data/encounter.py +44 -0
- canvas_sdk/v1/data/facility.py +1 -0
- canvas_sdk/v1/data/goal.py +2 -14
- canvas_sdk/v1/data/imaging.py +4 -30
- canvas_sdk/v1/data/immunization.py +7 -15
- canvas_sdk/v1/data/lab.py +12 -65
- canvas_sdk/v1/data/line_item_transaction.py +2 -5
- canvas_sdk/v1/data/medication.py +3 -8
- canvas_sdk/v1/data/medication_history.py +142 -0
- canvas_sdk/v1/data/medication_statement.py +41 -0
- canvas_sdk/v1/data/message.py +4 -8
- canvas_sdk/v1/data/note.py +37 -38
- canvas_sdk/v1/data/observation.py +9 -36
- canvas_sdk/v1/data/organization.py +70 -9
- canvas_sdk/v1/data/patient.py +8 -12
- canvas_sdk/v1/data/patient_consent.py +4 -14
- canvas_sdk/v1/data/payment_collection.py +2 -5
- canvas_sdk/v1/data/posting.py +3 -9
- canvas_sdk/v1/data/practicelocation.py +66 -7
- canvas_sdk/v1/data/protocol_override.py +3 -4
- canvas_sdk/v1/data/protocol_result.py +3 -3
- canvas_sdk/v1/data/questionnaire.py +10 -26
- canvas_sdk/v1/data/reason_for_visit.py +2 -6
- canvas_sdk/v1/data/referral.py +41 -17
- canvas_sdk/v1/data/staff.py +34 -26
- canvas_sdk/v1/data/stop_medication_event.py +27 -0
- canvas_sdk/v1/data/task.py +30 -11
- canvas_sdk/v1/data/team.py +2 -4
- canvas_sdk/v1/data/uncategorized_clinical_document.py +84 -0
- canvas_sdk/v1/data/user.py +14 -0
- canvas_sdk/v1/data/utils.py +5 -0
- canvas_sdk/value_set/v2026/__init__.py +1 -0
- canvas_sdk/value_set/v2026/adverse_event.py +157 -0
- canvas_sdk/value_set/v2026/allergy.py +116 -0
- canvas_sdk/value_set/v2026/assessment.py +466 -0
- canvas_sdk/value_set/v2026/communication.py +496 -0
- canvas_sdk/value_set/v2026/condition.py +52934 -0
- canvas_sdk/value_set/v2026/device.py +315 -0
- canvas_sdk/value_set/v2026/diagnostic_study.py +5243 -0
- canvas_sdk/value_set/v2026/encounter.py +2714 -0
- canvas_sdk/value_set/v2026/immunization.py +297 -0
- canvas_sdk/value_set/v2026/individual_characteristic.py +339 -0
- canvas_sdk/value_set/v2026/intervention.py +1703 -0
- canvas_sdk/value_set/v2026/laboratory_test.py +1831 -0
- canvas_sdk/value_set/v2026/medication.py +8218 -0
- canvas_sdk/value_set/v2026/no_qdm_category_assigned.py +26493 -0
- canvas_sdk/value_set/v2026/physical_exam.py +342 -0
- canvas_sdk/value_set/v2026/procedure.py +27869 -0
- canvas_sdk/value_set/v2026/symptom.py +625 -0
- logger/logger.py +30 -31
- logger/logstash.py +282 -0
- logger/pubsub.py +26 -0
- plugin_runner/allowed-module-imports.json +940 -9
- plugin_runner/generate_allowed_imports.py +1 -0
- plugin_runner/installation.py +2 -2
- plugin_runner/plugin_runner.py +21 -24
- plugin_runner/sandbox.py +34 -0
- protobufs/canvas_generated/messages/effects.proto +65 -0
- protobufs/canvas_generated/messages/events.proto +150 -51
- settings.py +27 -11
- canvas_sdk/effects/calendar/create_event.py +0 -43
- {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/entry_points.txt +0 -0
canvas_cli/apps/logs/logs.py
CHANGED
|
@@ -1,11 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
1
4
|
import json
|
|
2
|
-
|
|
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,
|
|
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
|
-
"""
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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(
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
#
|
|
18
|
-
def
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return
|
|
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/
|
|
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
|
}
|