canvas-term 0.2.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_cli/__init__.py +3 -0
- canvas_cli/api.py +260 -0
- canvas_cli/cli.py +631 -0
- canvas_cli/config.py +160 -0
- canvas_cli/errors.py +27 -0
- canvas_term-0.2.0.dist-info/METADATA +279 -0
- canvas_term-0.2.0.dist-info/RECORD +11 -0
- canvas_term-0.2.0.dist-info/WHEEL +5 -0
- canvas_term-0.2.0.dist-info/entry_points.txt +2 -0
- canvas_term-0.2.0.dist-info/licenses/LICENSE +21 -0
- canvas_term-0.2.0.dist-info/top_level.txt +1 -0
canvas_cli/__init__.py
ADDED
canvas_cli/api.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""HTTP client for the Canvas REST API.
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
- Attach the Bearer token only to requests for the configured Canvas host.
|
|
5
|
+
- Apply timeouts, retry transient failures, and back off when rate limited.
|
|
6
|
+
- Follow Canvas pagination (Link headers).
|
|
7
|
+
- Turn every failure into a CanvasError with a redacted, actionable message.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from urllib.parse import urljoin, urlparse
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from . import __version__, config
|
|
20
|
+
from .errors import AuthError, CanvasError, RateLimitError
|
|
21
|
+
|
|
22
|
+
TIMEOUT = (10, 60) # (connect, read) seconds
|
|
23
|
+
MAX_RETRIES = 4
|
|
24
|
+
MAX_BACKOFF = 60
|
|
25
|
+
IDEMPOTENT_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
26
|
+
RETRYABLE_STATUS = {500, 502, 503, 504}
|
|
27
|
+
|
|
28
|
+
# Indirection so tests can skip real sleeping.
|
|
29
|
+
_sleep = time.sleep
|
|
30
|
+
|
|
31
|
+
_SECRET_PATTERNS = [
|
|
32
|
+
re.compile(r"(?i)(bearer\s+)[^\s\"',]+"),
|
|
33
|
+
re.compile(r"(?i)(access_token=)[^&\s\"']+"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def redact(text, secrets=()):
|
|
38
|
+
"""Remove known secrets and token-shaped values from text shown to the user."""
|
|
39
|
+
text = str(text)
|
|
40
|
+
for secret in secrets:
|
|
41
|
+
if secret:
|
|
42
|
+
text = text.replace(secret, "[REDACTED]")
|
|
43
|
+
for pattern in _SECRET_PATTERNS:
|
|
44
|
+
text = pattern.sub(r"\1[REDACTED]", text)
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CanvasClient:
|
|
49
|
+
def __init__(self, base_url=None, token=None, session=None):
|
|
50
|
+
self.base_url = base_url or config.get_url()
|
|
51
|
+
self._token = token
|
|
52
|
+
self.session = session or requests.Session()
|
|
53
|
+
self.session.headers["User-Agent"] = f"canvas-term/{__version__}"
|
|
54
|
+
|
|
55
|
+
# --- auth ---
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def token(self):
|
|
59
|
+
if self._token is None:
|
|
60
|
+
self._token = config.get_token()
|
|
61
|
+
if not self._token:
|
|
62
|
+
raise AuthError("No API token configured. Run 'canvas-cli config' first.")
|
|
63
|
+
return self._token
|
|
64
|
+
|
|
65
|
+
def is_canvas_url(self, url):
|
|
66
|
+
"""True if url points at the configured Canvas host over https."""
|
|
67
|
+
target = urlparse(url)
|
|
68
|
+
base = urlparse(self.base_url)
|
|
69
|
+
return target.scheme == "https" and target.netloc.lower() == base.netloc.lower()
|
|
70
|
+
|
|
71
|
+
def _redact(self, text):
|
|
72
|
+
return redact(text, [self._token] if self._token else [])
|
|
73
|
+
|
|
74
|
+
# --- core request loop ---
|
|
75
|
+
|
|
76
|
+
def request(self, method, endpoint, *, retry=None, **kwargs):
|
|
77
|
+
"""Send a request and return the response, or raise CanvasError.
|
|
78
|
+
|
|
79
|
+
`endpoint` may be an API path ("/api/v1/...") or an absolute URL. The
|
|
80
|
+
token is attached only when the URL belongs to the Canvas host.
|
|
81
|
+
|
|
82
|
+
Network errors and 5xx responses are retried only for idempotent
|
|
83
|
+
methods (override with `retry`), so a POST is never sent twice.
|
|
84
|
+
Rate-limited requests are always retried, because Canvas rejects them
|
|
85
|
+
before doing any work.
|
|
86
|
+
"""
|
|
87
|
+
method = method.upper()
|
|
88
|
+
url = (
|
|
89
|
+
urljoin(self.base_url + "/", endpoint.lstrip("/"))
|
|
90
|
+
if "://" not in endpoint
|
|
91
|
+
else endpoint
|
|
92
|
+
)
|
|
93
|
+
retry = method in IDEMPOTENT_METHODS if retry is None else retry
|
|
94
|
+
|
|
95
|
+
headers = dict(kwargs.pop("headers", None) or {})
|
|
96
|
+
if self.is_canvas_url(url):
|
|
97
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
98
|
+
kwargs.setdefault("timeout", TIMEOUT)
|
|
99
|
+
host = urlparse(url).netloc
|
|
100
|
+
|
|
101
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
102
|
+
can_retry = attempt < MAX_RETRIES
|
|
103
|
+
try:
|
|
104
|
+
response = self.session.request(method, url, headers=headers, **kwargs)
|
|
105
|
+
except requests.exceptions.Timeout as e:
|
|
106
|
+
if retry and can_retry:
|
|
107
|
+
self._backoff(attempt)
|
|
108
|
+
continue
|
|
109
|
+
raise CanvasError(
|
|
110
|
+
f"Request to {host} timed out. Canvas may be slow or unreachable; try again."
|
|
111
|
+
) from e
|
|
112
|
+
except requests.exceptions.ConnectionError as e:
|
|
113
|
+
if retry and can_retry:
|
|
114
|
+
self._backoff(attempt)
|
|
115
|
+
continue
|
|
116
|
+
raise CanvasError(
|
|
117
|
+
f"Could not connect to {host}. Check your network connection and "
|
|
118
|
+
"Canvas URL ('canvas-cli config')."
|
|
119
|
+
) from e
|
|
120
|
+
except requests.exceptions.RequestException as e:
|
|
121
|
+
raise CanvasError(f"Network error talking to {host}: {self._redact(e)}") from e
|
|
122
|
+
|
|
123
|
+
if self._is_rate_limited(response):
|
|
124
|
+
if can_retry:
|
|
125
|
+
self._backoff(attempt, response)
|
|
126
|
+
continue
|
|
127
|
+
raise RateLimitError(
|
|
128
|
+
"Canvas is rate limiting requests (too many in a short time). "
|
|
129
|
+
"Wait a minute and try again."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if response.status_code in RETRYABLE_STATUS and retry and can_retry:
|
|
133
|
+
self._backoff(attempt, response)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if response.ok: # includes 3xx when the caller disables redirects
|
|
137
|
+
return response
|
|
138
|
+
raise self._error_for(response)
|
|
139
|
+
|
|
140
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _is_rate_limited(response):
|
|
144
|
+
# Canvas signals throttling with 403 + "Rate Limit Exceeded"; proxies may send 429.
|
|
145
|
+
if response.status_code == 429:
|
|
146
|
+
return True
|
|
147
|
+
return response.status_code == 403 and "rate limit exceeded" in response.text.lower()
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _backoff(attempt, response=None):
|
|
151
|
+
delay = None
|
|
152
|
+
if response is not None:
|
|
153
|
+
retry_after = response.headers.get("Retry-After", "")
|
|
154
|
+
if retry_after.isdigit():
|
|
155
|
+
delay = int(retry_after)
|
|
156
|
+
if delay is None:
|
|
157
|
+
delay = 2**attempt + random.uniform(0, 1) # noqa: S311 - jitter, not crypto
|
|
158
|
+
_sleep(min(delay, MAX_BACKOFF))
|
|
159
|
+
|
|
160
|
+
def _error_for(self, response):
|
|
161
|
+
status = response.status_code
|
|
162
|
+
path = urlparse(response.url or "").path or "request"
|
|
163
|
+
detail = self._redact(_error_detail(response))
|
|
164
|
+
|
|
165
|
+
if status == 401:
|
|
166
|
+
return AuthError(
|
|
167
|
+
"Canvas rejected your API token (401 Unauthorized). It may have expired or "
|
|
168
|
+
"been revoked. Generate a new token in Canvas (Account > Settings > "
|
|
169
|
+
"New Access Token) and run 'canvas-cli config'."
|
|
170
|
+
)
|
|
171
|
+
if status == 403:
|
|
172
|
+
return CanvasError(f"Permission denied (403) for {path}: {detail}")
|
|
173
|
+
if status == 404:
|
|
174
|
+
return CanvasError(
|
|
175
|
+
f"Not found (404): {path}. Check the course/assignment IDs or your aliases."
|
|
176
|
+
)
|
|
177
|
+
return CanvasError(f"Canvas API error {status} for {path}: {detail}")
|
|
178
|
+
|
|
179
|
+
# --- helpers ---
|
|
180
|
+
|
|
181
|
+
def get_json(self, endpoint, **kwargs):
|
|
182
|
+
return parse_json(self.request("GET", endpoint, **kwargs))
|
|
183
|
+
|
|
184
|
+
def get_all(self, endpoint, params=None):
|
|
185
|
+
"""GET every page of a paginated endpoint and return the combined list."""
|
|
186
|
+
params = {"per_page": 100, **(params or {})}
|
|
187
|
+
results = []
|
|
188
|
+
url = endpoint
|
|
189
|
+
while url:
|
|
190
|
+
response = self.request("GET", url, params=params)
|
|
191
|
+
page = parse_json(response)
|
|
192
|
+
if not isinstance(page, list):
|
|
193
|
+
return page
|
|
194
|
+
results.extend(page)
|
|
195
|
+
|
|
196
|
+
next_url = response.links.get("next", {}).get("url")
|
|
197
|
+
# The next link already carries the query string; never follow it off-host.
|
|
198
|
+
url = next_url if next_url and self.is_canvas_url(next_url) else None
|
|
199
|
+
params = None
|
|
200
|
+
return results
|
|
201
|
+
|
|
202
|
+
def download(self, url, dest_path):
|
|
203
|
+
"""Stream a file to dest_path atomically. Auth is sent only to the Canvas host."""
|
|
204
|
+
with self.request("GET", url, stream=True) as response:
|
|
205
|
+
directory = os.path.dirname(dest_path) or "."
|
|
206
|
+
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".partial-")
|
|
207
|
+
try:
|
|
208
|
+
with os.fdopen(fd, "wb") as out:
|
|
209
|
+
for chunk in response.iter_content(chunk_size=64 * 1024):
|
|
210
|
+
out.write(chunk)
|
|
211
|
+
os.replace(tmp_path, dest_path)
|
|
212
|
+
except BaseException:
|
|
213
|
+
if os.path.exists(tmp_path):
|
|
214
|
+
os.remove(tmp_path)
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _error_detail(response):
|
|
219
|
+
"""Extract a short human-readable message from a Canvas error body."""
|
|
220
|
+
try:
|
|
221
|
+
body = response.json()
|
|
222
|
+
except ValueError:
|
|
223
|
+
return (response.text or response.reason or "").strip()[:200]
|
|
224
|
+
if isinstance(body, dict):
|
|
225
|
+
errors = body.get("errors")
|
|
226
|
+
if isinstance(errors, list) and errors:
|
|
227
|
+
return "; ".join(
|
|
228
|
+
str(e.get("message", e)) if isinstance(e, dict) else str(e) for e in errors
|
|
229
|
+
)
|
|
230
|
+
if isinstance(errors, dict):
|
|
231
|
+
return "; ".join(f"{k}: {v}" for k, v in errors.items())
|
|
232
|
+
if body.get("message"):
|
|
233
|
+
return str(body["message"])
|
|
234
|
+
return str(body)[:200]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def parse_json(response):
|
|
238
|
+
try:
|
|
239
|
+
return response.json()
|
|
240
|
+
except ValueError as e:
|
|
241
|
+
raise CanvasError(
|
|
242
|
+
f"Canvas returned an unexpected (non-JSON) response for {urlparse(response.url).path}."
|
|
243
|
+
) from e
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --- Per-invocation client used by the CLI ---
|
|
247
|
+
|
|
248
|
+
_client = None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_client():
|
|
252
|
+
global _client
|
|
253
|
+
if _client is None:
|
|
254
|
+
_client = CanvasClient()
|
|
255
|
+
return _client
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def reset_client():
|
|
259
|
+
global _client
|
|
260
|
+
_client = None
|