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 ADDED
@@ -0,0 +1,3 @@
1
+ """canvas-cli: a command-line interface for the Canvas LMS."""
2
+
3
+ __version__ = "0.2.0"
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