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.
- canvas_pull/__init__.py +3 -0
- canvas_pull/__main__.py +3 -0
- canvas_pull/api.py +187 -0
- canvas_pull/cli.py +531 -0
- canvas_pull/collector.py +308 -0
- canvas_pull/config.py +211 -0
- canvas_pull/gitops.py +52 -0
- canvas_pull/manifest.py +132 -0
- canvas_pull/models.py +61 -0
- canvas_pull/notify.py +46 -0
- canvas_pull/schedule.py +215 -0
- canvas_pull/summary.py +72 -0
- canvas_pull/sync.py +231 -0
- canvas_pull-2.4.0.dist-info/METADATA +280 -0
- canvas_pull-2.4.0.dist-info/RECORD +18 -0
- canvas_pull-2.4.0.dist-info/WHEEL +4 -0
- canvas_pull-2.4.0.dist-info/entry_points.txt +2 -0
- canvas_pull-2.4.0.dist-info/licenses/LICENSE +21 -0
canvas_pull/__init__.py
ADDED
canvas_pull/__main__.py
ADDED
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()
|