canvas 0.1.4__py3-none-any.whl → 0.1.5__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 (70) hide show
  1. canvas-0.1.5.dist-info/METADATA +176 -0
  2. canvas-0.1.5.dist-info/RECORD +66 -0
  3. {canvas-0.1.4.dist-info → canvas-0.1.5.dist-info}/WHEEL +1 -1
  4. canvas-0.1.5.dist-info/entry_points.txt +3 -0
  5. canvas_cli/apps/__init__.py +0 -0
  6. canvas_cli/apps/auth/__init__.py +3 -0
  7. canvas_cli/apps/auth/tests.py +142 -0
  8. canvas_cli/apps/auth/utils.py +163 -0
  9. canvas_cli/apps/logs/__init__.py +3 -0
  10. canvas_cli/apps/logs/logs.py +59 -0
  11. canvas_cli/apps/plugin/__init__.py +9 -0
  12. canvas_cli/apps/plugin/plugin.py +286 -0
  13. canvas_cli/apps/plugin/tests.py +32 -0
  14. canvas_cli/conftest.py +28 -0
  15. canvas_cli/main.py +78 -0
  16. canvas_cli/templates/plugins/default/cookiecutter.json +4 -0
  17. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json +29 -0
  18. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/README.md +12 -0
  19. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/__init__.py +0 -0
  20. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +55 -0
  21. canvas_cli/tests.py +11 -0
  22. canvas_cli/utils/__init__.py +0 -0
  23. canvas_cli/utils/context/__init__.py +3 -0
  24. canvas_cli/utils/context/context.py +172 -0
  25. canvas_cli/utils/context/tests.py +130 -0
  26. canvas_cli/utils/print/__init__.py +3 -0
  27. canvas_cli/utils/print/print.py +60 -0
  28. canvas_cli/utils/print/tests.py +70 -0
  29. canvas_cli/utils/urls/__init__.py +3 -0
  30. canvas_cli/utils/urls/tests.py +12 -0
  31. canvas_cli/utils/urls/urls.py +27 -0
  32. canvas_cli/utils/validators/__init__.py +3 -0
  33. canvas_cli/utils/validators/manifest_schema.py +80 -0
  34. canvas_cli/utils/validators/tests.py +36 -0
  35. canvas_cli/utils/validators/validators.py +40 -0
  36. canvas_sdk/__init__.py +0 -0
  37. canvas_sdk/commands/__init__.py +27 -0
  38. canvas_sdk/commands/base.py +118 -0
  39. canvas_sdk/commands/commands/assess.py +48 -0
  40. canvas_sdk/commands/commands/diagnose.py +44 -0
  41. canvas_sdk/commands/commands/goal.py +48 -0
  42. canvas_sdk/commands/commands/history_present_illness.py +15 -0
  43. canvas_sdk/commands/commands/medication_statement.py +28 -0
  44. canvas_sdk/commands/commands/plan.py +15 -0
  45. canvas_sdk/commands/commands/prescribe.py +48 -0
  46. canvas_sdk/commands/commands/questionnaire.py +17 -0
  47. canvas_sdk/commands/commands/reason_for_visit.py +36 -0
  48. canvas_sdk/commands/commands/stop_medication.py +18 -0
  49. canvas_sdk/commands/commands/update_goal.py +48 -0
  50. canvas_sdk/commands/constants.py +9 -0
  51. canvas_sdk/commands/tests/test_utils.py +195 -0
  52. canvas_sdk/commands/tests/tests.py +407 -0
  53. canvas_sdk/data/__init__.py +0 -0
  54. canvas_sdk/effects/__init__.py +1 -0
  55. canvas_sdk/effects/banner_alert/banner_alert.py +37 -0
  56. canvas_sdk/effects/banner_alert/constants.py +19 -0
  57. canvas_sdk/effects/base.py +30 -0
  58. canvas_sdk/events/__init__.py +1 -0
  59. canvas_sdk/protocols/__init__.py +1 -0
  60. canvas_sdk/protocols/base.py +12 -0
  61. canvas_sdk/tests/__init__.py +0 -0
  62. canvas_sdk/utils/__init__.py +3 -0
  63. canvas_sdk/utils/http.py +72 -0
  64. canvas_sdk/utils/tests.py +63 -0
  65. canvas_sdk/views/__init__.py +0 -0
  66. canvas/main.py +0 -19
  67. canvas-0.1.4.dist-info/METADATA +0 -285
  68. canvas-0.1.4.dist-info/RECORD +0 -6
  69. canvas-0.1.4.dist-info/entry_points.txt +0 -3
  70. {canvas → canvas_cli}/__init__.py +0 -0
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.1
2
+ Name: canvas
3
+ Version: 0.1.5
4
+ Summary: SDK to customize event-driven actions in your Canvas instance
5
+ License: MIT
6
+ Author: Canvas Team
7
+ Author-email: engineering@canvasmedical.com
8
+ Requires-Python: >=3.11,<3.13
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Dist: cookiecutter
14
+ Requires-Dist: grpcio (>=1.60.1,<2.0.0)
15
+ Requires-Dist: ipython (>=8.21.0,<9.0.0)
16
+ Requires-Dist: jsonschema (>=4.21.1,<5.0.0)
17
+ Requires-Dist: keyring
18
+ Requires-Dist: pydantic (>=2.6.1,<3.0.0)
19
+ Requires-Dist: python-dotenv (>=1.0.1,<2.0.0)
20
+ Requires-Dist: redis (>=5.0.4,<6.0.0)
21
+ Requires-Dist: requests
22
+ Requires-Dist: restrictedpython (>=7.1,<8.0)
23
+ Requires-Dist: statsd (>=4.0.1,<5.0.0)
24
+ Requires-Dist: typer[all]
25
+ Requires-Dist: websocket-client (>=1.7.0,<2.0.0)
26
+ Description-Content-Type: text/markdown
27
+
28
+ ### Getting Started
29
+
30
+ Create a file `~/.canvas/credentials.ini` and add the client_id and client_secret credentials for each of your Canvas instances. You can define your default host with `is_default=true`. If no default is explicitly defined, the Canvas CLI will use the first instance in the file as the default for each of the CLI commands.
31
+
32
+ **Example:**
33
+
34
+ ```
35
+ [my-canvas-instance]
36
+ client_id=myclientid
37
+ client_secret=myclientsecret
38
+
39
+ [my-dev-canvas-instance]
40
+ client_id=devclientid
41
+ client_secret=devclientsecret
42
+ is_default=true
43
+
44
+ [localhost]
45
+ client_id=localclientid
46
+ client_secret=localclientsecret
47
+ ```
48
+
49
+ Next, you're ready to install canvas.
50
+
51
+ `pip install canvas`
52
+
53
+ **Usage**:
54
+
55
+ ```console
56
+ $ canvas [OPTIONS] COMMAND [ARGS]...
57
+ ```
58
+
59
+ **Options**:
60
+
61
+ - `--no-ansi`: Disable colorized output
62
+ - `--version`
63
+ - `--verbose`: Show extra output
64
+ - `--install-completion`: Install completion for the current shell.
65
+ - `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
66
+ - `--help`: Show this message and exit.
67
+
68
+ **Commands**:
69
+
70
+ - `init`: Create a new plugin
71
+ - `install`: Install a plugin into a Canvas instance
72
+ - `uninstall`: Uninstall a plugin from a Canvas instance
73
+ - `list`: List all plugins from a Canvas instance
74
+ - `validate-manifest`: Validate the Canvas Manifest json file
75
+ - `logs`: Listen and print log streams from a Canvas instance
76
+
77
+ ## `canvas init`
78
+
79
+ Create a new plugin.
80
+
81
+ **Usage**:
82
+
83
+ ```console
84
+ $ canvas init [OPTIONS]
85
+ ```
86
+
87
+ **Options**:
88
+
89
+ - `--help`: Show this message and exit.
90
+
91
+ ## `canvas install`
92
+
93
+ Install a plugin into a Canvas instance.
94
+
95
+ **Usage**:
96
+
97
+ ```console
98
+ $ canvas install [OPTIONS] PLUGIN_NAME
99
+ ```
100
+
101
+ **Arguments**:
102
+
103
+ - `PLUGIN_NAME`: Path to plugin to install [required]
104
+
105
+ **Options**:
106
+
107
+ - `--host TEXT`: Canvas instance to connect to
108
+ - `--help`: Show this message and exit.
109
+
110
+ ## `canvas uninstall`
111
+
112
+ Uninstall a plugin from a Canvas instance..
113
+
114
+ **Usage**:
115
+
116
+ ```console
117
+ $ canvas uninstall [OPTIONS] NAME
118
+ ```
119
+
120
+ **Arguments**:
121
+
122
+ - `NAME`: Plugin name to delete [required]
123
+
124
+ **Options**:
125
+
126
+ - `--host TEXT`: Canvas instance to connect to
127
+ - `--help`: Show this message and exit.
128
+
129
+ ## `canvas list`
130
+
131
+ List all plugins from a Canvas instance.
132
+
133
+ **Usage**:
134
+
135
+ ```console
136
+ $ canvas list [OPTIONS]
137
+ ```
138
+
139
+ **Options**:
140
+
141
+ - `--host TEXT`: Canvas instance to connect to
142
+ - `--help`: Show this message and exit.
143
+
144
+ ## `canvas validate-manifest`
145
+
146
+ Validate the Canvas Manifest json file.
147
+
148
+ **Usage**:
149
+
150
+ ```console
151
+ $ canvas validate-manifest [OPTIONS] PACKAGE
152
+ ```
153
+
154
+ **Arguments**:
155
+
156
+ - `PLUGIN_NAME`: Path to plugin to install [required]
157
+
158
+ **Options**:
159
+
160
+ - `--help`: Show this message and exit.
161
+
162
+ ## `canvas logs`
163
+
164
+ Listens and prints log streams from the instance.
165
+
166
+ **Usage**:
167
+
168
+ ```console
169
+ $ canvas logs [OPTIONS]
170
+ ```
171
+
172
+ **Options**:
173
+
174
+ - `--host TEXT`: Canvas instance to connect to
175
+ - `--help`: Show this message and exit.
176
+
@@ -0,0 +1,66 @@
1
+ canvas_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ canvas_cli/apps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ canvas_cli/apps/auth/__init__.py,sha256=gIwJ2qWvRlLqbiRkudrGqTKV-orlb8OTkG487qoRda4,105
4
+ canvas_cli/apps/auth/tests.py,sha256=WK7hSLTK95gEweMRaj3RdPC-qSwjnuulxMUg6D0bY_k,4528
5
+ canvas_cli/apps/auth/utils.py,sha256=IH5oZB3pdlb4_FRfCZKkNTncx_kdKagpiBqlhtM8h2U,5434
6
+ canvas_cli/apps/logs/__init__.py,sha256=ehY9SRb6nBw81xZF50yyBlUZJtNR2VeVSNI5sFuWJ7o,64
7
+ canvas_cli/apps/logs/logs.py,sha256=R4BSqRiSmuLBGCjprDHQL5lh9rBL7Ksc79cCNQW6jMY,1825
8
+ canvas_cli/apps/plugin/__init__.py,sha256=72Gcu2T9GsRLPbBApS9V0ZKSzehEE3hqgU_pie2T50Y,190
9
+ canvas_cli/apps/plugin/plugin.py,sha256=3nvrZknuMO1ROS1C0Lk6o1GNPcXhWXnP1UkJJ3OsZ2E,9360
10
+ canvas_cli/apps/plugin/tests.py,sha256=_8fHTGlQermt4AVs20nsTYcs2bDRNEqr9CGE29pD6YQ,1035
11
+ canvas_cli/conftest.py,sha256=ZnVUIT4K4VZ92eSLTS38W6N878SVzOEfGPxsp6Rls70,959
12
+ canvas_cli/main.py,sha256=rk2xlrQacq59ELoeO_YDMUBHFOVqlWpnV19PC0iAX4k,2527
13
+ canvas_cli/templates/plugins/default/cookiecutter.json,sha256=dWEB3wJ8U4bko8jX26PgLLg_jgWlafLTNqsGnY1PUcg,124
14
+ canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json,sha256=QOUpv31Wx_eSVms13-C-L7jZ4ulKFNpokshcScKUEmo,787
15
+ canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/README.md,sha256=7QdF2JWlWwq6Us9LzkO9XWJU4IU7Q6RD_w8ImcS-hrI,347
16
+ canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py,sha256=V8knH-2KHdMHMxksVEjJHxr85uFf0ipUSXBZ20r4bxM,2202
18
+ canvas_cli/tests.py,sha256=LgiHeVHF5lxlH_pNT962uk-5D-JqNhhuu1LhSz90nK8,285
19
+ canvas_cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ canvas_cli/utils/context/__init__.py,sha256=HhYvI-hydP0mV18nJiU7uo5gk0yN7EYNgouxieoGDOE,102
21
+ canvas_cli/utils/context/context.py,sha256=zJ-UnjztG3B1fE3tAc7xej1pPFNq6wGNB8pEOTLHzqo,5714
22
+ canvas_cli/utils/context/tests.py,sha256=aNmAggwvjKV-1WrgIvp66M46EX2QCbbHmity3bSCBTg,4211
23
+ canvas_cli/utils/print/__init__.py,sha256=zkRiQCUhPIB3wNGeJ1Hwfd12zJyvbRRiWqequJORvdE,88
24
+ canvas_cli/utils/print/print.py,sha256=1lYglwBefZf0xONN-6PiWxe0ruNU31CTnzDJIhx_VAk,1829
25
+ canvas_cli/utils/print/tests.py,sha256=4kqp_uMpUUjSXokD_Hxs2aMyx50_JzGABAQoNC4XTrM,2376
26
+ canvas_cli/utils/urls/__init__.py,sha256=08hlrQhQ1pKBjlIRaC0j53IkgK723jfK8-j3djvR0ko,81
27
+ canvas_cli/utils/urls/tests.py,sha256=opXDF2i3lXTdsKJ7ywIRzWDDzZ5KAO0JbGIR3hbJdoE,407
28
+ canvas_cli/utils/urls/urls.py,sha256=KwWTh5ERrEsZEvdBrZpZB71xtyWkDuglpXUbycWmBOo,798
29
+ canvas_cli/utils/validators/__init__.py,sha256=rBvSR2O1hWkNAnUBdcr-zUkmqT796_A61b9pnvEhwrM,113
30
+ canvas_cli/utils/validators/manifest_schema.py,sha256=SIMpr_vTV8dtkO9cjsRnZZtRm5tgGkPR6QewTG8CztI,3117
31
+ canvas_cli/utils/validators/tests.py,sha256=cZHLSx7oteOfLOoU1tXGvw88n6itcvUT2B3ZBg-bmEY,1206
32
+ canvas_cli/utils/validators/validators.py,sha256=RKDKxIvZ1IuCPVuhQ1KHdpwu7aewAqT9ZX-nA-LZDdg,1614
33
+ canvas_sdk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
+ canvas_sdk/commands/__init__.py,sha256=4JTiljkBJ2Da2mU3_VAJ5dcpxg0X3QAocYJBBMJRpGc,1116
35
+ canvas_sdk/commands/base.py,sha256=qVgZMBFwdHw6Y66TN68QNlrewr2RqYSpiD1RIPUqUyk,4141
36
+ canvas_sdk/commands/commands/assess.py,sha256=uQgLwDp_9I8fjy3reA4g2RSvGY0Ee8UijfwpsvrMRS4,1636
37
+ canvas_sdk/commands/commands/diagnose.py,sha256=3bg4ScNzTKJa7sXv3xH9VKQSF_srX_G3m-Xpe-VKzzQ,1382
38
+ canvas_sdk/commands/commands/goal.py,sha256=WE5q8oFRtQmclqdCydG4WYW4hoodOvd9dEiSGkJfGqs,1527
39
+ canvas_sdk/commands/commands/history_present_illness.py,sha256=TQ-upptwYy-EKAegqT5jyU7csQpOZeqzp8VfBZTO0mw,366
40
+ canvas_sdk/commands/commands/medication_statement.py,sha256=Ww-WuMbvosfY7Uv8sTFUjPaBGxUoA-ptdcw6Q569DeY,937
41
+ canvas_sdk/commands/commands/plan.py,sha256=Q-e4wqTL6zafG7tOk_9R0mi5pDGqVXKxgbFjsB8CS4Q,350
42
+ canvas_sdk/commands/commands/prescribe.py,sha256=4EQHRhTtW_FHJDOaUgMu8lSgK8JKtUcNrOUs8hbTSdw,1574
43
+ canvas_sdk/commands/commands/questionnaire.py,sha256=XmwpANCHRV_kV2B_X7xqAJ39yX7bn9iT8XHuVoFfLFk,553
44
+ canvas_sdk/commands/commands/reason_for_visit.py,sha256=Zs2WKybbMWvimoZc4ehGxqXNWvX7c0snAe34adP2A4E,1252
45
+ canvas_sdk/commands/commands/stop_medication.py,sha256=5AIPEBBqT1mhpQOcCPh3FJfVq487Qtq3fNrWXqNxqhM,627
46
+ canvas_sdk/commands/commands/update_goal.py,sha256=yiOiHiSwfOzFUAz61C7i6WIJ2o-OUmjmXzm33GcIC3E,1499
47
+ canvas_sdk/commands/constants.py,sha256=LVAuWvxDuDzsW74JUWIzy_lMLvte0_Grlldr-YCjgBw,176
48
+ canvas_sdk/commands/tests/test_utils.py,sha256=gdW9e82-Z9Xm3LyYVa7Pd7sGreL-gtP854iC1UIhOEc,6287
49
+ canvas_sdk/commands/tests/tests.py,sha256=3ZIsIi0GmXV3jm8QmEhLn_FwL8qpW6yt5jjq9FQwHPA,13863
50
+ canvas_sdk/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
+ canvas_sdk/effects/__init__.py,sha256=w3-PimW34Li9b8l_aWCyM7X8-jxMIM3e0ttXg44CV2k,62
52
+ canvas_sdk/effects/banner_alert/banner_alert.py,sha256=Vb23JpwAvfH5GoZpGcHzuWyNQ68cfLXOJjRgbPBaB_M,1013
53
+ canvas_sdk/effects/banner_alert/constants.py,sha256=kfd113dGYDm-g_B5iZb47VNpJjioGqQZSNRoR_rsktw,436
54
+ canvas_sdk/effects/base.py,sha256=h67VmsgMq_Dvr01w8UEf204UqiGjomYrMf_InHzibm4,688
55
+ canvas_sdk/events/__init__.py,sha256=oh1hgaoXbWmPBUUvMb_F0n0Chf5L42ZkKIvvLf-Olzk,74
56
+ canvas_sdk/protocols/__init__.py,sha256=GKtNMsJ5XaNOJUz9adE2_pONLeZqkZIrd9hXAU-jzvU,51
57
+ canvas_sdk/protocols/base.py,sha256=A7K1IcCFU44M7ygwn2iD2LsM4xDlITxRZsDbDWRQPnE,260
58
+ canvas_sdk/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
+ canvas_sdk/utils/__init__.py,sha256=sFuhqcWvXb2-33FOuXZgWuUeC5jZL2MDoqjGoQZGwd8,60
60
+ canvas_sdk/utils/http.py,sha256=YOgUFp7FOFhbDwkGhCiD3JLZ342oNZR-qncMxv2qSjw,2159
61
+ canvas_sdk/utils/tests.py,sha256=t3MScbIfzXkQttMIvj0dRzJlFVS8LFU8WgWRrChM-H0,1931
62
+ canvas_sdk/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
+ canvas-0.1.5.dist-info/METADATA,sha256=b5qC30REXgMjT6HLvuNyYoBI4f4TBULVOXlrSNgcR-Q,3776
64
+ canvas-0.1.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
65
+ canvas-0.1.5.dist-info/entry_points.txt,sha256=VSmSo1IZ3aEfL7enmLmlWSraS_IIkoXNVeyXzgRxFiY,46
66
+ canvas-0.1.5.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.8.1
2
+ Generator: poetry-core 1.9.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ canvas=canvas_cli.main:app
3
+
File without changes
@@ -0,0 +1,3 @@
1
+ from canvas_cli.apps.auth.utils import get_or_request_api_token
2
+
3
+ __all__ = ("get_or_request_api_token",)
@@ -0,0 +1,142 @@
1
+ from typing import Any
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pytest
5
+
6
+ from canvas_cli.apps.auth import get_or_request_api_token
7
+
8
+
9
+ @pytest.fixture
10
+ def valid_token_response() -> Any:
11
+ class TokenResponse:
12
+ status_code = 200
13
+
14
+ def json(self) -> dict:
15
+ return {"access_token": "a-valid-api-token", "expires_in": 3600}
16
+
17
+ return TokenResponse()
18
+
19
+
20
+ @pytest.fixture
21
+ def error_token_response() -> Any:
22
+ class TokenResponse:
23
+ status_code = 500
24
+
25
+ return TokenResponse()
26
+
27
+
28
+ @pytest.fixture
29
+ def expired_token_response() -> Any:
30
+ class TokenResponse:
31
+ status_code = 200
32
+
33
+ def json(self) -> dict:
34
+ return {"access_token": "a-valid-api-token", "expires_in": -1}
35
+
36
+ return TokenResponse()
37
+
38
+
39
+ @patch("keyring.get_password")
40
+ @patch("requests.Session.post")
41
+ @patch("canvas_cli.apps.auth.utils.is_token_valid")
42
+ def test_get_or_request_api_token_uses_stored_token(
43
+ mock_is_token_valid: MagicMock,
44
+ mock_post: MagicMock,
45
+ mock_get_password: MagicMock,
46
+ valid_token_response: Any,
47
+ ) -> None:
48
+ mock_is_token_valid.return_value = True
49
+ mock_get_password.return_value = "a-stored-valid-token"
50
+ mock_post.return_value = valid_token_response
51
+
52
+ token = get_or_request_api_token("http://localhost:8000")
53
+
54
+ assert token == "a-stored-valid-token"
55
+ mock_post.assert_not_called()
56
+
57
+
58
+ @patch("keyring.set_password")
59
+ @patch("keyring.get_password")
60
+ @patch("requests.Session.post")
61
+ @patch("canvas_cli.apps.auth.utils.get_api_client_credentials")
62
+ def test_get_or_request_api_token_requests_token_if_none_stored(
63
+ mock_client_credentials: MagicMock,
64
+ mock_post: MagicMock,
65
+ mock_get_password: MagicMock,
66
+ mock_set_password: MagicMock,
67
+ valid_token_response: Any,
68
+ ) -> None:
69
+ mock_client_credentials.return_value = "client_id=id&client_secret=secret"
70
+ mock_get_password.return_value = None
71
+ mock_post.return_value = valid_token_response
72
+
73
+ token = get_or_request_api_token("http://localhost:8000")
74
+
75
+ assert token == "a-valid-api-token"
76
+ mock_post.assert_called_once_with(
77
+ "http://localhost:8000/auth/token/",
78
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
79
+ json=None,
80
+ data="grant_type=client_credentials&scope=system/Plugins.*&client_id=id&client_secret=secret",
81
+ )
82
+ mock_set_password.assert_called_with(
83
+ "canvas_cli.apps.auth.utils",
84
+ username="http://localhost:8000|token",
85
+ password="a-valid-api-token",
86
+ )
87
+
88
+
89
+ @patch("keyring.get_password")
90
+ @patch("requests.Session.post")
91
+ @patch("canvas_cli.apps.auth.utils.get_api_client_credentials")
92
+ def test_get_or_request_api_token_raises_exception_if_error_token_response(
93
+ mock_client_credentials: MagicMock,
94
+ mock_post: MagicMock,
95
+ mock_get_password: MagicMock,
96
+ error_token_response: Any,
97
+ ) -> None:
98
+ mock_client_credentials.return_value = "client_id=id&client_secret=secret"
99
+ mock_get_password.return_value = None
100
+ mock_post.return_value = error_token_response
101
+
102
+ with pytest.raises(Exception) as e:
103
+ get_or_request_api_token("http://localhost:8000")
104
+
105
+ assert "Unable to get a valid access token from the given host 'http://localhost:8000'" in repr(
106
+ e
107
+ )
108
+
109
+ mock_post.assert_called_once_with(
110
+ "http://localhost:8000/auth/token/",
111
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
112
+ json=None,
113
+ data="grant_type=client_credentials&scope=system/Plugins.*&client_id=id&client_secret=secret",
114
+ )
115
+
116
+
117
+ @patch("keyring.get_password")
118
+ @patch("requests.Session.post")
119
+ @patch("canvas_cli.apps.auth.utils.get_api_client_credentials")
120
+ def test_get_or_request_api_token_raises_exception_if_expired_token(
121
+ mock_client_credentials: MagicMock,
122
+ mock_post: MagicMock,
123
+ mock_get_password: MagicMock,
124
+ expired_token_response: Any,
125
+ ) -> None:
126
+ mock_client_credentials.return_value = "client_id=id&client_secret=secret"
127
+ mock_get_password.return_value = None
128
+ mock_post.return_value = expired_token_response
129
+
130
+ with pytest.raises(Exception) as e:
131
+ get_or_request_api_token("http://localhost:8000")
132
+
133
+ assert (
134
+ "A valid token could not be acquired from the given host 'http://localhost:8000'" in repr(e)
135
+ )
136
+
137
+ mock_post.assert_called_once_with(
138
+ "http://localhost:8000/auth/token/",
139
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
140
+ json=None,
141
+ data="grant_type=client_credentials&scope=system/Plugins.*&client_id=id&client_secret=secret",
142
+ )
@@ -0,0 +1,163 @@
1
+ import configparser
2
+ from datetime import datetime, timedelta
3
+ from pathlib import Path
4
+ from urllib.parse import urlparse
5
+
6
+ import keyring
7
+ import requests
8
+
9
+ from canvas_sdk.utils import Http
10
+
11
+ # Keyring namespace we'll use
12
+ KEYRING_SERVICE = __name__
13
+
14
+ CONFIG_PATH = Path.home() / ".canvas" / "credentials.ini"
15
+
16
+ LOCALHOST = "http://localhost:8000"
17
+
18
+
19
+ def get_password(username: str) -> str | None:
20
+ """Return the stored password for username, or None."""
21
+ return keyring.get_password(KEYRING_SERVICE, username)
22
+
23
+
24
+ def set_password(username: str, password: str) -> None:
25
+ """Set the password for the given username."""
26
+ keyring.set_password(KEYRING_SERVICE, username=username, password=password)
27
+
28
+
29
+ def delete_password(username: str) -> None:
30
+ """Delete the password for the given username."""
31
+ keyring.delete_password(KEYRING_SERVICE, username=username)
32
+
33
+
34
+ def get_config() -> configparser.ConfigParser:
35
+ """Reads the config file and returns a ConfigParser object."""
36
+ config = configparser.ConfigParser()
37
+ if not config.read(CONFIG_PATH):
38
+ raise Exception(
39
+ f"""Please add your configuration file at '{CONFIG_PATH}' with the following format:
40
+
41
+ [my-canvas-subdomain]
42
+ client_id=myclientid
43
+ client_secret=myclientsecret
44
+
45
+ [my-dev-canvas-subdomain]
46
+ client_id=devclientid
47
+ client_secret=devclientsecret
48
+ is_default=true
49
+
50
+ [localhost]
51
+ client_id=localclientid
52
+ client_secret=localclientsecret
53
+ """
54
+ )
55
+ return config
56
+
57
+
58
+ def read_config(host: str, property: str) -> str:
59
+ """Reads the config file and returns the property for a given section."""
60
+ config = get_config()
61
+ if host not in config:
62
+ raise Exception(f"'{host}' is not found in the configuration file at '{CONFIG_PATH}'")
63
+ return config.get(host, property)
64
+
65
+
66
+ def get_api_client_credentials(host: str) -> str:
67
+ """Either return the given api_key, or fetch it from the keyring."""
68
+ hostname = urlparse(host).hostname
69
+
70
+ if not hostname:
71
+ raise ValueError("Could not parse hostname from URL")
72
+
73
+ instance = hostname.removesuffix(".canvasmedical.com")
74
+
75
+ client_id = read_config(instance, "client_id")
76
+ client_secret = read_config(instance, "client_secret")
77
+
78
+ return f"client_id={client_id}&client_secret={client_secret}"
79
+
80
+
81
+ def get_default_host(host: str | None = None) -> str:
82
+ """Return the explicitly stated default host, or first if none is indicated."""
83
+ if host:
84
+ if "://" in host:
85
+ return host
86
+
87
+ if "localhost" in host:
88
+ return LOCALHOST
89
+
90
+ return f"https://{host}.canvasmedical.com"
91
+
92
+ config = get_config()
93
+ if not (hosts := config.sections()):
94
+ raise Exception(f"No hosts found in the configuration file at '{CONFIG_PATH}'")
95
+
96
+ first_default_host = next(
97
+ (host for host in hosts if config.getboolean(host, "is_default", fallback=False) is True),
98
+ hosts[0],
99
+ )
100
+ if first_default_host == "localhost":
101
+ return LOCALHOST
102
+
103
+ return f"https://{first_default_host}.canvasmedical.com"
104
+
105
+
106
+ def request_api_token(host: str, api_client_credentials: str) -> dict:
107
+ """Request an api token using the provided client_id and client_secret."""
108
+ grant_type = "grant_type=client_credentials"
109
+ scope = "scope=system/Plugins.*"
110
+
111
+ http = Http()
112
+ token_response = http.post(
113
+ f"{host}/auth/token/",
114
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
115
+ data=f"{grant_type}&{scope}&{api_client_credentials}",
116
+ )
117
+ if token_response.status_code != requests.codes.ok:
118
+ raise Exception(f"Unable to get a valid access token from the given host '{host}'")
119
+ return token_response.json()
120
+
121
+
122
+ def is_token_valid(host_token_key: str, expiration_date: datetime | None = None) -> bool:
123
+ """True if the token has not expired yet."""
124
+ token_exp_date_key = f"{host_token_key}|exp_date"
125
+
126
+ if expiration_date:
127
+ if expiration_date <= datetime.now():
128
+ return False
129
+ set_password(token_exp_date_key, expiration_date.isoformat())
130
+ return True
131
+
132
+ stored_expiration_date = get_password(token_exp_date_key)
133
+ return (
134
+ stored_expiration_date is not None
135
+ and datetime.fromisoformat(stored_expiration_date) > datetime.now()
136
+ )
137
+
138
+
139
+ def get_or_request_api_token(host: str | None = None) -> str:
140
+ """Returns an existing stored token if it has not expired, or requests a new one."""
141
+ if not (host := get_default_host(host)):
142
+ raise Exception(
143
+ f"Please specify a host or add one to the configuration file at '{CONFIG_PATH}'"
144
+ )
145
+
146
+ host_token_key = f"{host}|token"
147
+ token = get_password(host_token_key)
148
+
149
+ if token and is_token_valid(host_token_key):
150
+ return token
151
+
152
+ api_client_credentials = get_api_client_credentials(host)
153
+
154
+ if not (token_response := request_api_token(host, api_client_credentials)):
155
+ raise Exception(f"A token could not be acquired from the given host '{host}'")
156
+
157
+ token_expiration_date = datetime.now() + timedelta(seconds=token_response["expires_in"])
158
+ if not is_token_valid(host_token_key, token_expiration_date):
159
+ raise Exception(f"A valid token could not be acquired from the given host '{host}'")
160
+
161
+ new_token = token_response["access_token"]
162
+ set_password(host_token_key, new_token)
163
+ return new_token
@@ -0,0 +1,3 @@
1
+ from canvas_cli.apps.logs.logs import logs
2
+
3
+ __all__ = ("logs",)
@@ -0,0 +1,59 @@
1
+ from typing import Optional
2
+ from urllib.parse import urlparse
3
+
4
+ import typer
5
+ import websocket
6
+
7
+ from canvas_cli.apps.auth.utils import get_default_host, get_or_request_api_token
8
+ from canvas_cli.utils.print import print
9
+
10
+
11
+ def _on_message(ws: websocket.WebSocketApp, message: str) -> None:
12
+ print.json(message)
13
+
14
+
15
+ def _on_error(ws: websocket.WebSocketApp, error: str) -> None:
16
+ print.json(f"Error: {error}", success=False)
17
+
18
+
19
+ def _on_close(ws: websocket.WebSocketApp, close_status_code: str, close_msg: str) -> None:
20
+ print.json(f"Connection closed with status code {close_status_code}: {close_msg}")
21
+
22
+
23
+ def _on_open(ws: websocket.WebSocketApp) -> None:
24
+ print.json("Connected to the logging service")
25
+
26
+
27
+ def logs(
28
+ host: Optional[str] = typer.Option(
29
+ callback=get_default_host, help="Canvas instance to connect to", default=None
30
+ )
31
+ ) -> None:
32
+ """Listens and prints log streams from the instance."""
33
+ if not host:
34
+ raise typer.BadParameter("Please specify a host or add one to the configuration file")
35
+
36
+ token = get_or_request_api_token(host)
37
+
38
+ # Resolve the instance name from the Canvas host URL (e.g., extract
39
+ # 'example' from 'https://example.canvasmedical.com/')
40
+ hostname = urlparse(host).hostname
41
+ instance = hostname.removesuffix(".canvasmedical.com")
42
+
43
+ print.json(
44
+ f"Connecting to the log stream. Please be patient as there may be a delay before log messages appear."
45
+ )
46
+ websocket_uri = f"wss://logs.console.canvasmedical.com/{instance}?token={token}"
47
+
48
+ try:
49
+ ws = websocket.WebSocketApp(
50
+ websocket_uri,
51
+ on_message=_on_message,
52
+ on_error=_on_error,
53
+ on_close=_on_close,
54
+ )
55
+ ws.on_open = _on_open
56
+ ws.run_forever()
57
+
58
+ except KeyboardInterrupt:
59
+ raise typer.Exit(0)
@@ -0,0 +1,9 @@
1
+ from canvas_cli.apps.plugin.plugin import (
2
+ init,
3
+ install,
4
+ list,
5
+ uninstall,
6
+ validate_manifest,
7
+ )
8
+
9
+ __all__ = ("uninstall", "init", "validate_manifest", "install", "list")