canvas 0.1.4__tar.gz → 0.1.5__tar.gz
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.
- canvas-0.1.5/PKG-INFO +176 -0
- canvas-0.1.5/README.md +148 -0
- canvas-0.1.5/canvas_cli/apps/__init__.py +0 -0
- canvas-0.1.5/canvas_cli/apps/auth/__init__.py +3 -0
- canvas-0.1.5/canvas_cli/apps/auth/tests.py +142 -0
- canvas-0.1.5/canvas_cli/apps/auth/utils.py +163 -0
- canvas-0.1.5/canvas_cli/apps/logs/__init__.py +3 -0
- canvas-0.1.5/canvas_cli/apps/logs/logs.py +59 -0
- canvas-0.1.5/canvas_cli/apps/plugin/__init__.py +9 -0
- canvas-0.1.5/canvas_cli/apps/plugin/plugin.py +286 -0
- canvas-0.1.5/canvas_cli/apps/plugin/tests.py +32 -0
- canvas-0.1.5/canvas_cli/conftest.py +28 -0
- canvas-0.1.5/canvas_cli/main.py +78 -0
- canvas-0.1.5/canvas_cli/templates/plugins/default/cookiecutter.json +4 -0
- canvas-0.1.5/canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json +29 -0
- canvas-0.1.5/canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/README.md +12 -0
- canvas-0.1.5/canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/__init__.py +0 -0
- canvas-0.1.5/canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +55 -0
- canvas-0.1.5/canvas_cli/tests.py +11 -0
- canvas-0.1.5/canvas_cli/utils/__init__.py +0 -0
- canvas-0.1.5/canvas_cli/utils/context/__init__.py +3 -0
- canvas-0.1.5/canvas_cli/utils/context/context.py +172 -0
- canvas-0.1.5/canvas_cli/utils/context/tests.py +130 -0
- canvas-0.1.5/canvas_cli/utils/print/__init__.py +3 -0
- canvas-0.1.5/canvas_cli/utils/print/print.py +60 -0
- canvas-0.1.5/canvas_cli/utils/print/tests.py +70 -0
- canvas-0.1.5/canvas_cli/utils/urls/__init__.py +3 -0
- canvas-0.1.5/canvas_cli/utils/urls/tests.py +12 -0
- canvas-0.1.5/canvas_cli/utils/urls/urls.py +27 -0
- canvas-0.1.5/canvas_cli/utils/validators/__init__.py +3 -0
- canvas-0.1.5/canvas_cli/utils/validators/manifest_schema.py +80 -0
- canvas-0.1.5/canvas_cli/utils/validators/tests.py +36 -0
- canvas-0.1.5/canvas_cli/utils/validators/validators.py +40 -0
- canvas-0.1.5/canvas_sdk/__init__.py +0 -0
- canvas-0.1.5/canvas_sdk/commands/__init__.py +27 -0
- canvas-0.1.5/canvas_sdk/commands/base.py +118 -0
- canvas-0.1.5/canvas_sdk/commands/commands/assess.py +48 -0
- canvas-0.1.5/canvas_sdk/commands/commands/diagnose.py +44 -0
- canvas-0.1.5/canvas_sdk/commands/commands/goal.py +48 -0
- canvas-0.1.5/canvas_sdk/commands/commands/history_present_illness.py +15 -0
- canvas-0.1.5/canvas_sdk/commands/commands/medication_statement.py +28 -0
- canvas-0.1.5/canvas_sdk/commands/commands/plan.py +15 -0
- canvas-0.1.5/canvas_sdk/commands/commands/prescribe.py +48 -0
- canvas-0.1.5/canvas_sdk/commands/commands/questionnaire.py +17 -0
- canvas-0.1.5/canvas_sdk/commands/commands/reason_for_visit.py +36 -0
- canvas-0.1.5/canvas_sdk/commands/commands/stop_medication.py +18 -0
- canvas-0.1.5/canvas_sdk/commands/commands/update_goal.py +48 -0
- canvas-0.1.5/canvas_sdk/commands/constants.py +9 -0
- canvas-0.1.5/canvas_sdk/commands/tests/test_utils.py +195 -0
- canvas-0.1.5/canvas_sdk/commands/tests/tests.py +407 -0
- canvas-0.1.5/canvas_sdk/data/__init__.py +0 -0
- canvas-0.1.5/canvas_sdk/effects/__init__.py +1 -0
- canvas-0.1.5/canvas_sdk/effects/banner_alert/banner_alert.py +37 -0
- canvas-0.1.5/canvas_sdk/effects/banner_alert/constants.py +19 -0
- canvas-0.1.5/canvas_sdk/effects/base.py +30 -0
- canvas-0.1.5/canvas_sdk/events/__init__.py +1 -0
- canvas-0.1.5/canvas_sdk/protocols/__init__.py +1 -0
- canvas-0.1.5/canvas_sdk/protocols/base.py +12 -0
- canvas-0.1.5/canvas_sdk/tests/__init__.py +0 -0
- canvas-0.1.5/canvas_sdk/utils/__init__.py +3 -0
- canvas-0.1.5/canvas_sdk/utils/http.py +72 -0
- canvas-0.1.5/canvas_sdk/utils/tests.py +63 -0
- canvas-0.1.5/canvas_sdk/views/__init__.py +0 -0
- canvas-0.1.5/pyproject.toml +96 -0
- canvas-0.1.4/PKG-INFO +0 -285
- canvas-0.1.4/README.md +0 -268
- canvas-0.1.4/canvas/main.py +0 -19
- canvas-0.1.4/pyproject.toml +0 -65
- {canvas-0.1.4/canvas → canvas-0.1.5/canvas_cli}/__init__.py +0 -0
canvas-0.1.5/PKG-INFO
ADDED
|
@@ -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
|
+
|
canvas-0.1.5/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
### Getting Started
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
**Example:**
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
[my-canvas-instance]
|
|
9
|
+
client_id=myclientid
|
|
10
|
+
client_secret=myclientsecret
|
|
11
|
+
|
|
12
|
+
[my-dev-canvas-instance]
|
|
13
|
+
client_id=devclientid
|
|
14
|
+
client_secret=devclientsecret
|
|
15
|
+
is_default=true
|
|
16
|
+
|
|
17
|
+
[localhost]
|
|
18
|
+
client_id=localclientid
|
|
19
|
+
client_secret=localclientsecret
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Next, you're ready to install canvas.
|
|
23
|
+
|
|
24
|
+
`pip install canvas`
|
|
25
|
+
|
|
26
|
+
**Usage**:
|
|
27
|
+
|
|
28
|
+
```console
|
|
29
|
+
$ canvas [OPTIONS] COMMAND [ARGS]...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Options**:
|
|
33
|
+
|
|
34
|
+
- `--no-ansi`: Disable colorized output
|
|
35
|
+
- `--version`
|
|
36
|
+
- `--verbose`: Show extra output
|
|
37
|
+
- `--install-completion`: Install completion for the current shell.
|
|
38
|
+
- `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
|
|
39
|
+
- `--help`: Show this message and exit.
|
|
40
|
+
|
|
41
|
+
**Commands**:
|
|
42
|
+
|
|
43
|
+
- `init`: Create a new plugin
|
|
44
|
+
- `install`: Install a plugin into a Canvas instance
|
|
45
|
+
- `uninstall`: Uninstall a plugin from a Canvas instance
|
|
46
|
+
- `list`: List all plugins from a Canvas instance
|
|
47
|
+
- `validate-manifest`: Validate the Canvas Manifest json file
|
|
48
|
+
- `logs`: Listen and print log streams from a Canvas instance
|
|
49
|
+
|
|
50
|
+
## `canvas init`
|
|
51
|
+
|
|
52
|
+
Create a new plugin.
|
|
53
|
+
|
|
54
|
+
**Usage**:
|
|
55
|
+
|
|
56
|
+
```console
|
|
57
|
+
$ canvas init [OPTIONS]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Options**:
|
|
61
|
+
|
|
62
|
+
- `--help`: Show this message and exit.
|
|
63
|
+
|
|
64
|
+
## `canvas install`
|
|
65
|
+
|
|
66
|
+
Install a plugin into a Canvas instance.
|
|
67
|
+
|
|
68
|
+
**Usage**:
|
|
69
|
+
|
|
70
|
+
```console
|
|
71
|
+
$ canvas install [OPTIONS] PLUGIN_NAME
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Arguments**:
|
|
75
|
+
|
|
76
|
+
- `PLUGIN_NAME`: Path to plugin to install [required]
|
|
77
|
+
|
|
78
|
+
**Options**:
|
|
79
|
+
|
|
80
|
+
- `--host TEXT`: Canvas instance to connect to
|
|
81
|
+
- `--help`: Show this message and exit.
|
|
82
|
+
|
|
83
|
+
## `canvas uninstall`
|
|
84
|
+
|
|
85
|
+
Uninstall a plugin from a Canvas instance..
|
|
86
|
+
|
|
87
|
+
**Usage**:
|
|
88
|
+
|
|
89
|
+
```console
|
|
90
|
+
$ canvas uninstall [OPTIONS] NAME
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Arguments**:
|
|
94
|
+
|
|
95
|
+
- `NAME`: Plugin name to delete [required]
|
|
96
|
+
|
|
97
|
+
**Options**:
|
|
98
|
+
|
|
99
|
+
- `--host TEXT`: Canvas instance to connect to
|
|
100
|
+
- `--help`: Show this message and exit.
|
|
101
|
+
|
|
102
|
+
## `canvas list`
|
|
103
|
+
|
|
104
|
+
List all plugins from a Canvas instance.
|
|
105
|
+
|
|
106
|
+
**Usage**:
|
|
107
|
+
|
|
108
|
+
```console
|
|
109
|
+
$ canvas list [OPTIONS]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Options**:
|
|
113
|
+
|
|
114
|
+
- `--host TEXT`: Canvas instance to connect to
|
|
115
|
+
- `--help`: Show this message and exit.
|
|
116
|
+
|
|
117
|
+
## `canvas validate-manifest`
|
|
118
|
+
|
|
119
|
+
Validate the Canvas Manifest json file.
|
|
120
|
+
|
|
121
|
+
**Usage**:
|
|
122
|
+
|
|
123
|
+
```console
|
|
124
|
+
$ canvas validate-manifest [OPTIONS] PACKAGE
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Arguments**:
|
|
128
|
+
|
|
129
|
+
- `PLUGIN_NAME`: Path to plugin to install [required]
|
|
130
|
+
|
|
131
|
+
**Options**:
|
|
132
|
+
|
|
133
|
+
- `--help`: Show this message and exit.
|
|
134
|
+
|
|
135
|
+
## `canvas logs`
|
|
136
|
+
|
|
137
|
+
Listens and prints log streams from the instance.
|
|
138
|
+
|
|
139
|
+
**Usage**:
|
|
140
|
+
|
|
141
|
+
```console
|
|
142
|
+
$ canvas logs [OPTIONS]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Options**:
|
|
146
|
+
|
|
147
|
+
- `--host TEXT`: Canvas instance to connect to
|
|
148
|
+
- `--help`: Show this message and exit.
|
|
File without changes
|
|
@@ -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,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)
|