canvas-pull 2.4.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.
@@ -0,0 +1,3 @@
1
+ """Sync files from your active Canvas LMS courses."""
2
+
3
+ __version__ = "2.4.0"
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
canvas_pull/api.py ADDED
@@ -0,0 +1,187 @@
1
+ """Thin Canvas REST API client: retries, pagination, streaming downloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import random
7
+ import threading
8
+ import time
9
+ from collections.abc import Iterator
10
+ from contextlib import nullcontext
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from . import __version__
17
+
18
+ RETRY_STATUS = {429, 500, 502, 503, 504}
19
+
20
+
21
+ class CanvasError(Exception):
22
+ def __init__(self, message: str, status: int | None = None) -> None:
23
+ super().__init__(message)
24
+ self.status = status
25
+
26
+
27
+ class AuthError(CanvasError):
28
+ """The token is missing, invalid or expired."""
29
+
30
+
31
+ class ForbiddenError(CanvasError):
32
+ """The token is fine but this resource is hidden or missing (e.g. Files tab disabled)."""
33
+
34
+
35
+ class CanvasClient:
36
+ def __init__(
37
+ self,
38
+ base_url: str,
39
+ token: str,
40
+ *,
41
+ transport: httpx.BaseTransport | None = None,
42
+ timeout: float = 30.0,
43
+ max_retries: int = 4,
44
+ backoff: float = 1.0,
45
+ max_concurrency: int = 8,
46
+ ) -> None:
47
+ self.base_url = base_url.rstrip("/")
48
+ self.max_retries = max_retries
49
+ self.backoff = backoff
50
+ # Callers may use many threads; this caps how many API calls are in flight at
51
+ # once, which keeps us well inside Canvas's rate limit. Downloads are not capped
52
+ # here (the download pool size limits them).
53
+ self._slots = threading.BoundedSemaphore(max(1, max_concurrency))
54
+ # httpx drops the Authorization header when a redirect leaves this host,
55
+ # so file downloads redirected to S3 / inst-fs do not leak the token.
56
+ self._http = httpx.Client(
57
+ base_url=f"{self.base_url}/api/v1/",
58
+ headers={
59
+ "Authorization": f"Bearer {token}",
60
+ "User-Agent": f"canvas-pull/{__version__}",
61
+ },
62
+ timeout=httpx.Timeout(timeout, connect=10.0),
63
+ follow_redirects=True,
64
+ transport=transport,
65
+ limits=httpx.Limits(max_connections=64, max_keepalive_connections=32),
66
+ )
67
+
68
+ def __enter__(self) -> CanvasClient:
69
+ return self
70
+
71
+ def __exit__(self, *exc: object) -> None:
72
+ self.close()
73
+
74
+ def close(self) -> None:
75
+ self._http.close()
76
+
77
+ # -- low level -----------------------------------------------------------
78
+
79
+ def _sleep(self, attempt: int, retry_after: str | None) -> None:
80
+ try:
81
+ delay = float(retry_after) if retry_after else 0.0
82
+ except ValueError:
83
+ delay = 0.0
84
+ delay = max(delay, self.backoff * (2**attempt)) + random.uniform(0, self.backoff / 2)
85
+ time.sleep(min(delay, 60.0))
86
+
87
+ @staticmethod
88
+ def _is_throttled(resp: httpx.Response) -> bool:
89
+ # Canvas signals throttling with 403 + "Rate Limit Exceeded".
90
+ return resp.status_code == 403 and "rate limit exceeded" in resp.text.lower()
91
+
92
+ @staticmethod
93
+ def _error_message(resp: httpx.Response) -> str:
94
+ try:
95
+ body = resp.json()
96
+ except ValueError:
97
+ return f"HTTP {resp.status_code}"
98
+ if isinstance(body, dict):
99
+ errors = body.get("errors")
100
+ if isinstance(errors, list) and errors and isinstance(errors[0], dict):
101
+ return str(errors[0].get("message", errors[0]))
102
+ if errors:
103
+ return str(errors)
104
+ if body.get("message"):
105
+ return str(body["message"])
106
+ return f"HTTP {resp.status_code}"
107
+
108
+ def _send(self, url: str, *, params: Any = None, stream: bool = False) -> httpx.Response:
109
+ attempt = 0
110
+ while True:
111
+ try:
112
+ request = self._http.build_request("GET", url, params=params)
113
+ with nullcontext() if stream else self._slots:
114
+ resp = self._http.send(request, stream=stream)
115
+ except httpx.TransportError as exc:
116
+ if attempt >= self.max_retries:
117
+ raise CanvasError(f"Network error: {exc}") from exc
118
+ self._sleep(attempt, None)
119
+ attempt += 1
120
+ continue
121
+
122
+ if resp.is_success:
123
+ return resp
124
+
125
+ resp.read()
126
+ resp.close()
127
+ retryable = resp.status_code in RETRY_STATUS or self._is_throttled(resp)
128
+ if retryable and attempt < self.max_retries:
129
+ self._sleep(attempt, resp.headers.get("Retry-After"))
130
+ attempt += 1
131
+ continue
132
+
133
+ message = self._error_message(resp)
134
+ if resp.status_code == 401:
135
+ # Canvas also uses 401 for "user not authorized to perform that action";
136
+ # only a bad token comes with WWW-Authenticate / "Invalid access token".
137
+ if "www-authenticate" in resp.headers or "access token" in message.lower():
138
+ raise AuthError(
139
+ "Access token rejected. It may have expired: create a new one "
140
+ "in Canvas (Account → Settings) and run `canvas-pull init`.",
141
+ 401,
142
+ )
143
+ raise ForbiddenError(message, 401)
144
+ if resp.status_code in (403, 404):
145
+ raise ForbiddenError(message, resp.status_code)
146
+ raise CanvasError(f"{message} ({resp.request.url})", resp.status_code)
147
+
148
+ # -- public --------------------------------------------------------------
149
+
150
+ def get(self, path: str, params: Any = None) -> Any:
151
+ return self._send(path, params=params).json()
152
+
153
+ def paginate(self, path: str, params: dict[str, Any] | None = None) -> Iterator[dict]:
154
+ """Yield every item of a list endpoint, following Link: rel="next"."""
155
+ url: str | None = path
156
+ query: dict[str, Any] | None = {"per_page": 100, **(params or {})}
157
+ while url:
158
+ resp = self._send(url, params=query)
159
+ data = resp.json()
160
+ if not isinstance(data, list):
161
+ raise CanvasError(f"Expected a list from {path}, got {type(data).__name__}")
162
+ yield from data
163
+ url = resp.links.get("next", {}).get("url")
164
+ query = None # the next URL already carries the query string
165
+
166
+ def download(self, url: str, dest: Path) -> tuple[int, str]:
167
+ """Stream `url` into `dest`. Returns (size, sha256)."""
168
+ dest.parent.mkdir(parents=True, exist_ok=True)
169
+ attempt = 0
170
+ while True:
171
+ digest = hashlib.sha256()
172
+ size = 0
173
+ resp = self._send(url, stream=True)
174
+ try:
175
+ with open(dest, "wb") as fh:
176
+ for chunk in resp.iter_bytes(1 << 16):
177
+ fh.write(chunk)
178
+ digest.update(chunk)
179
+ size += len(chunk)
180
+ return size, digest.hexdigest()
181
+ except httpx.TransportError as exc:
182
+ if attempt >= self.max_retries:
183
+ raise CanvasError(f"Download interrupted: {exc}") from exc
184
+ self._sleep(attempt, None)
185
+ attempt += 1
186
+ finally:
187
+ resp.close()