canvas 0.1.4__tar.gz → 0.1.6__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.

Files changed (69) hide show
  1. canvas-0.1.6/PKG-INFO +176 -0
  2. canvas-0.1.6/README.md +148 -0
  3. canvas-0.1.6/canvas_cli/apps/__init__.py +0 -0
  4. canvas-0.1.6/canvas_cli/apps/auth/__init__.py +3 -0
  5. canvas-0.1.6/canvas_cli/apps/auth/tests.py +142 -0
  6. canvas-0.1.6/canvas_cli/apps/auth/utils.py +163 -0
  7. canvas-0.1.6/canvas_cli/apps/logs/__init__.py +3 -0
  8. canvas-0.1.6/canvas_cli/apps/logs/logs.py +59 -0
  9. canvas-0.1.6/canvas_cli/apps/plugin/__init__.py +9 -0
  10. canvas-0.1.6/canvas_cli/apps/plugin/plugin.py +286 -0
  11. canvas-0.1.6/canvas_cli/apps/plugin/tests.py +32 -0
  12. canvas-0.1.6/canvas_cli/conftest.py +28 -0
  13. canvas-0.1.6/canvas_cli/main.py +78 -0
  14. canvas-0.1.6/canvas_cli/templates/plugins/default/cookiecutter.json +4 -0
  15. canvas-0.1.6/canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/CANVAS_MANIFEST.json +29 -0
  16. canvas-0.1.6/canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/README.md +12 -0
  17. canvas-0.1.6/canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/__init__.py +0 -0
  18. canvas-0.1.6/canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/protocols/my_protocol.py +55 -0
  19. canvas-0.1.6/canvas_cli/tests.py +11 -0
  20. canvas-0.1.6/canvas_cli/utils/__init__.py +0 -0
  21. canvas-0.1.6/canvas_cli/utils/context/__init__.py +3 -0
  22. canvas-0.1.6/canvas_cli/utils/context/context.py +172 -0
  23. canvas-0.1.6/canvas_cli/utils/context/tests.py +130 -0
  24. canvas-0.1.6/canvas_cli/utils/print/__init__.py +3 -0
  25. canvas-0.1.6/canvas_cli/utils/print/print.py +60 -0
  26. canvas-0.1.6/canvas_cli/utils/print/tests.py +70 -0
  27. canvas-0.1.6/canvas_cli/utils/urls/__init__.py +3 -0
  28. canvas-0.1.6/canvas_cli/utils/urls/tests.py +12 -0
  29. canvas-0.1.6/canvas_cli/utils/urls/urls.py +27 -0
  30. canvas-0.1.6/canvas_cli/utils/validators/__init__.py +3 -0
  31. canvas-0.1.6/canvas_cli/utils/validators/manifest_schema.py +80 -0
  32. canvas-0.1.6/canvas_cli/utils/validators/tests.py +36 -0
  33. canvas-0.1.6/canvas_cli/utils/validators/validators.py +40 -0
  34. canvas-0.1.6/canvas_sdk/__init__.py +0 -0
  35. canvas-0.1.6/canvas_sdk/commands/__init__.py +27 -0
  36. canvas-0.1.6/canvas_sdk/commands/base.py +118 -0
  37. canvas-0.1.6/canvas_sdk/commands/commands/assess.py +48 -0
  38. canvas-0.1.6/canvas_sdk/commands/commands/diagnose.py +44 -0
  39. canvas-0.1.6/canvas_sdk/commands/commands/goal.py +48 -0
  40. canvas-0.1.6/canvas_sdk/commands/commands/history_present_illness.py +15 -0
  41. canvas-0.1.6/canvas_sdk/commands/commands/medication_statement.py +28 -0
  42. canvas-0.1.6/canvas_sdk/commands/commands/plan.py +15 -0
  43. canvas-0.1.6/canvas_sdk/commands/commands/prescribe.py +48 -0
  44. canvas-0.1.6/canvas_sdk/commands/commands/questionnaire.py +17 -0
  45. canvas-0.1.6/canvas_sdk/commands/commands/reason_for_visit.py +36 -0
  46. canvas-0.1.6/canvas_sdk/commands/commands/stop_medication.py +18 -0
  47. canvas-0.1.6/canvas_sdk/commands/commands/update_goal.py +48 -0
  48. canvas-0.1.6/canvas_sdk/commands/constants.py +9 -0
  49. canvas-0.1.6/canvas_sdk/commands/tests/test_utils.py +195 -0
  50. canvas-0.1.6/canvas_sdk/commands/tests/tests.py +407 -0
  51. canvas-0.1.6/canvas_sdk/data/__init__.py +0 -0
  52. canvas-0.1.6/canvas_sdk/effects/__init__.py +1 -0
  53. canvas-0.1.6/canvas_sdk/effects/banner_alert/banner_alert.py +37 -0
  54. canvas-0.1.6/canvas_sdk/effects/banner_alert/constants.py +19 -0
  55. canvas-0.1.6/canvas_sdk/effects/base.py +30 -0
  56. canvas-0.1.6/canvas_sdk/events/__init__.py +1 -0
  57. canvas-0.1.6/canvas_sdk/protocols/__init__.py +1 -0
  58. canvas-0.1.6/canvas_sdk/protocols/base.py +12 -0
  59. canvas-0.1.6/canvas_sdk/tests/__init__.py +0 -0
  60. canvas-0.1.6/canvas_sdk/utils/__init__.py +3 -0
  61. canvas-0.1.6/canvas_sdk/utils/http.py +72 -0
  62. canvas-0.1.6/canvas_sdk/utils/tests.py +63 -0
  63. canvas-0.1.6/canvas_sdk/views/__init__.py +0 -0
  64. canvas-0.1.6/pyproject.toml +96 -0
  65. canvas-0.1.4/PKG-INFO +0 -285
  66. canvas-0.1.4/README.md +0 -268
  67. canvas-0.1.4/canvas/main.py +0 -19
  68. canvas-0.1.4/pyproject.toml +0 -65
  69. {canvas-0.1.4/canvas → canvas-0.1.6/canvas_cli}/__init__.py +0 -0
canvas-0.1.6/PKG-INFO ADDED
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.1
2
+ Name: canvas
3
+ Version: 0.1.6
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.6/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,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")