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,308 @@
1
+ """Find courses and list every downloadable file in them.
2
+
3
+ Files are gathered from several places in a course, in priority order. When the same
4
+ file shows up in more than one place it keeps the path from the first source, so a file
5
+ in the Files tab never moves just because an assignment also links to it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import unicodedata
12
+ from collections.abc import Callable, Iterable, Iterator, Sequence
13
+ from concurrent.futures import ThreadPoolExecutor, as_completed
14
+ from dataclasses import dataclass, field
15
+ from datetime import UTC, datetime
16
+ from pathlib import PurePosixPath
17
+
18
+ from .api import CanvasClient, CanvasError, ForbiddenError
19
+ from .models import CanvasFile, Course, Folder
20
+
21
+ SOURCES: tuple[str, ...] = (
22
+ "files",
23
+ "modules",
24
+ "syllabus",
25
+ "assignments",
26
+ "pages",
27
+ "announcements",
28
+ "discussions",
29
+ )
30
+
31
+ # Only the Files tab by default; the others are opt-in through the `sources` setting.
32
+ DEFAULT_SOURCES: tuple[str, ...] = ("files",)
33
+
34
+ _UNSAFE = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
35
+ _WINDOWS_RESERVED = {"CON", "PRN", "AUX", "NUL"} | {
36
+ f"{p}{i}" for p in ("COM", "LPT") for i in range(1, 10)
37
+ }
38
+ # Matches /files/123 and /courses/45/files/123 in links, images and API endpoints.
39
+ _FILE_LINK = re.compile(r"/(?:courses/(\d+)/)?files/(\d+)")
40
+
41
+
42
+ def sanitize(name: str, max_len: int = 150) -> str:
43
+ """Make a Canvas name safe as a single path component on macOS/Linux/Windows."""
44
+ name = unicodedata.normalize("NFC", name)
45
+ name = _UNSAFE.sub("_", name).strip().rstrip(". ")
46
+ if name.split(".", 1)[0].upper() in _WINDOWS_RESERVED:
47
+ name = "_" + name
48
+ if len(name) > max_len:
49
+ stem, dot, ext = name.rpartition(".")
50
+ if dot and 0 < len(ext) <= 10:
51
+ name = stem[: max_len - len(ext) - 1].rstrip() + "." + ext
52
+ else:
53
+ name = name[:max_len].rstrip()
54
+ return name or "_"
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class RemoteFile:
59
+ file: CanvasFile
60
+ course: Course
61
+ path: str # POSIX path relative to the save directory
62
+ source: str = "files"
63
+
64
+ @property
65
+ def locked(self) -> bool:
66
+ return self.file.locked_for_user or not self.file.url
67
+
68
+
69
+ @dataclass
70
+ class CourseScan:
71
+ course: Course
72
+ files: list[RemoteFile] = field(default_factory=list)
73
+ files_tab_hidden: bool = False
74
+ error: str | None = None
75
+
76
+ def count(self, source: str) -> int:
77
+ return sum(1 for f in self.files if f.source == source)
78
+
79
+
80
+ # -- courses ------------------------------------------------------------------
81
+
82
+
83
+ def list_courses(
84
+ client: CanvasClient,
85
+ *,
86
+ include_past: bool = False,
87
+ include_completed: bool = False,
88
+ now: datetime | None = None,
89
+ ) -> list[Course]:
90
+ """Active courses (minus ones whose term ended), optionally plus completed ones."""
91
+ now = now or datetime.now(UTC)
92
+ courses: dict[int, Course] = {}
93
+ states = ["active"] + (["completed"] if include_completed else [])
94
+ for state in states:
95
+ params = {"enrollment_state": state, "include[]": ["term"]}
96
+ for raw in client.paginate("courses", params):
97
+ course = Course.model_validate(raw)
98
+ if course.access_restricted_by_date or course.id in courses:
99
+ continue
100
+ if state == "completed":
101
+ course = course.model_copy(update={"completed": True})
102
+ elif not include_past and not include_completed:
103
+ end = (course.term.end_at if course.term else None) or course.end_at
104
+ if end and end < now:
105
+ continue
106
+ courses[course.id] = course
107
+ return sorted(courses.values(), key=lambda c: c.label.lower())
108
+
109
+
110
+ def course_dirnames(courses: Iterable[Course]) -> dict[int, str]:
111
+ """One folder name per course; disambiguates courses that share a name."""
112
+ courses = list(courses)
113
+ base = {c.id: sanitize(c.label) for c in courses}
114
+ counts: dict[str, int] = {}
115
+ for name in base.values():
116
+ counts[name.lower()] = counts.get(name.lower(), 0) + 1
117
+ return {
118
+ cid: (f"{name} ({cid})" if counts[name.lower()] > 1 else name) for cid, name in base.items()
119
+ }
120
+
121
+
122
+ # -- sources ------------------------------------------------------------------
123
+ # Each source yields (file, folder parts) or (file id, owning course id, folder parts)
124
+ # when only a link is known; links are resolved later, after deduplication.
125
+
126
+ Found = tuple[CanvasFile, list[str]]
127
+ Link = tuple[int, int, list[str]]
128
+
129
+
130
+ def file_links(html: str | None, course_id: int) -> list[tuple[int, int]]:
131
+ """(file id, course id) pairs linked from a piece of Canvas HTML, in order, unique."""
132
+ out: dict[int, int] = {}
133
+ for cid, fid in _FILE_LINK.findall(html or ""):
134
+ out.setdefault(int(fid), int(cid) if cid else course_id)
135
+ return list(out.items())
136
+
137
+
138
+ def _files_tab(client: CanvasClient, course: Course) -> Iterator[Found]:
139
+ # The folder list and the file list are independent: fetch them at the same time.
140
+ with ThreadPoolExecutor(max_workers=1) as pool:
141
+ folders_future = pool.submit(lambda: list(client.paginate(f"courses/{course.id}/folders")))
142
+ files_raw = list(client.paginate(f"courses/{course.id}/files"))
143
+ folders = {
144
+ f.id: [sanitize(p) for p in PurePosixPath(f.full_name).parts[1:]]
145
+ for f in (Folder.model_validate(r) for r in folders_future.result())
146
+ }
147
+ for raw in files_raw:
148
+ f = CanvasFile.model_validate(raw)
149
+ yield f, folders.get(f.folder_id or -1, [])
150
+
151
+
152
+ def _modules(client: CanvasClient, course: Course) -> Iterator[Link]:
153
+ # Ask for items inline; Canvas leaves them out for very large modules, and only those
154
+ # need a request of their own.
155
+ params = {"include[]": ["items"]}
156
+ for module in client.paginate(f"courses/{course.id}/modules", params):
157
+ folder = ["Modules", sanitize(module.get("name") or f"Module {module.get('id')}")]
158
+ items = module.get("items")
159
+ if items is None:
160
+ items = client.paginate(f"courses/{course.id}/modules/{module['id']}/items")
161
+ for item in items:
162
+ if item.get("type") == "File" and item.get("content_id"):
163
+ yield int(item["content_id"]), course.id, folder
164
+
165
+
166
+ def _syllabus(client: CanvasClient, course: Course) -> Iterator[Link]:
167
+ raw = client.get(f"courses/{course.id}", {"include[]": ["syllabus_body"]})
168
+ for fid, cid in file_links(raw.get("syllabus_body"), course.id):
169
+ yield fid, cid, ["Syllabus"]
170
+
171
+
172
+ def _html_items(
173
+ client: CanvasClient, course: Course, path: str, params: dict, root: str, title: str, body: str
174
+ ) -> Iterator[Found | Link]:
175
+ for item in client.paginate(path, params):
176
+ folder = [root, sanitize(str(item.get(title) or item.get("id")))]
177
+ for att in item.get("attachments") or []:
178
+ if isinstance(att, dict) and att.get("id"):
179
+ yield CanvasFile.model_validate(att), folder
180
+ for fid, cid in file_links(item.get(body), course.id):
181
+ yield fid, cid, folder
182
+
183
+
184
+ def _assignments(client: CanvasClient, course: Course):
185
+ path = f"courses/{course.id}/assignments"
186
+ return _html_items(client, course, path, {}, "Assignments", "name", "description")
187
+
188
+
189
+ def _pages(client: CanvasClient, course: Course):
190
+ params = {"include[]": ["body"], "published": "true"}
191
+ return _html_items(
192
+ client, course, f"courses/{course.id}/pages", params, "Pages", "title", "body"
193
+ )
194
+
195
+
196
+ def _announcements(client: CanvasClient, course: Course):
197
+ path = f"courses/{course.id}/discussion_topics"
198
+ params = {"only_announcements": "true"}
199
+ return _html_items(client, course, path, params, "Announcements", "title", "message")
200
+
201
+
202
+ def _discussions(client: CanvasClient, course: Course):
203
+ path = f"courses/{course.id}/discussion_topics"
204
+ return _html_items(client, course, path, {}, "Discussions", "title", "message")
205
+
206
+
207
+ _SCANNERS = {
208
+ "files": _files_tab,
209
+ "modules": _modules,
210
+ "syllabus": _syllabus,
211
+ "assignments": _assignments,
212
+ "pages": _pages,
213
+ "announcements": _announcements,
214
+ "discussions": _discussions,
215
+ }
216
+
217
+
218
+ def _resolve(client: CanvasClient, fid: int, cid: int) -> CanvasFile | None:
219
+ for path in (f"courses/{cid}/files/{fid}", f"files/{fid}"):
220
+ try:
221
+ return CanvasFile.model_validate(client.get(path))
222
+ except ForbiddenError:
223
+ continue
224
+ return None
225
+
226
+
227
+ def scan_courses(
228
+ client: CanvasClient,
229
+ courses: Sequence[Course],
230
+ dirnames: dict[int, str],
231
+ sources: Sequence[str] = DEFAULT_SOURCES,
232
+ *,
233
+ workers: int = 6,
234
+ on_done: Callable[[CourseScan], None] | None = None,
235
+ ) -> list[CourseScan]:
236
+ """Scan several courses at once. Results come back in the order of `courses`."""
237
+ results: dict[int, CourseScan] = {}
238
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
239
+ futures = {pool.submit(scan_course, client, c, dirnames[c.id], sources): c for c in courses}
240
+ for future in as_completed(futures):
241
+ scan = future.result() # scan_course turns Canvas errors into scan.error
242
+ results[scan.course.id] = scan
243
+ if on_done:
244
+ on_done(scan)
245
+ return [results[c.id] for c in courses]
246
+
247
+
248
+ def _assign_paths(
249
+ found: list[tuple[CanvasFile, list[str], str]], course: Course, root: str
250
+ ) -> list[RemoteFile]:
251
+ """Unique, case-insensitively distinct paths. Older files keep the plain name."""
252
+ taken: set[str] = set()
253
+ out: list[RemoteFile] = []
254
+ for f, parts, source in sorted(found, key=lambda e: e[0].id):
255
+ name = sanitize(f.display_name or f.filename or str(f.id))
256
+ path = str(PurePosixPath(root, *parts, name))
257
+ if path.lower() in taken:
258
+ stem, dot, ext = name.rpartition(".")
259
+ name = f"{stem} ({f.id}).{ext}" if dot and stem else f"{name} ({f.id})"
260
+ path = str(PurePosixPath(root, *parts, name))
261
+ taken.add(path.lower())
262
+ out.append(RemoteFile(file=f, course=course, path=path, source=source))
263
+ return out
264
+
265
+
266
+ def scan_course(
267
+ client: CanvasClient, course: Course, dirname: str, sources: Sequence[str] = DEFAULT_SOURCES
268
+ ) -> CourseScan:
269
+ """List a course's files from every enabled source.
270
+
271
+ When the Files tab is enabled but hidden or empty, Modules is scanned as a fallback
272
+ even if it is not enabled, so such courses still get their files.
273
+ A hidden tab (401/403/404) just skips that source. Any other error marks the whole
274
+ scan as failed, so no file of this course is reported as deleted.
275
+ """
276
+ scan = CourseScan(course=course)
277
+ files: dict[int, tuple[CanvasFile, list[str], str]] = {}
278
+ links: dict[int, tuple[int, list[str], str]] = {}
279
+ try:
280
+ for source in SOURCES:
281
+ fallback = (
282
+ source == "modules" and "files" in sources and (scan.files_tab_hidden or not files)
283
+ )
284
+ if source not in sources and not fallback:
285
+ continue
286
+ try:
287
+ for entry in _SCANNERS[source](client, course):
288
+ if isinstance(entry[0], CanvasFile):
289
+ f, parts = entry # type: ignore[misc]
290
+ if f.id not in files and f.id not in links:
291
+ files[f.id] = (f, parts, source)
292
+ else:
293
+ fid, cid, parts = entry # type: ignore[misc]
294
+ if fid not in files and fid not in links:
295
+ links[fid] = (cid, parts, source)
296
+ except ForbiddenError:
297
+ if source == "files":
298
+ scan.files_tab_hidden = True
299
+ if links:
300
+ with ThreadPoolExecutor(max_workers=8) as pool:
301
+ resolved = pool.map(lambda kv: _resolve(client, kv[0], kv[1][0]), links.items())
302
+ for (fid, (_cid, parts, source)), f in zip(links.items(), resolved, strict=True):
303
+ if f is not None:
304
+ files[fid] = (f, parts, source)
305
+ scan.files = _assign_paths(list(files.values()), course, dirname)
306
+ except CanvasError as exc:
307
+ scan.error = str(exc)
308
+ return scan
canvas_pull/config.py ADDED
@@ -0,0 +1,211 @@
1
+ """Load and save settings.
2
+
3
+ Sources, lowest to highest priority:
4
+ 1. ~/.config/canvas-pull/config.toml (written by `canvas-pull init`)
5
+ 2. a `.env` file in the current directory
6
+ 3. environment variables
7
+
8
+ The token is read from those sources first, then from the system keychain.
9
+ Legacy keys from v1 (WEBSITE, ACCESS_TOKEN, SAVE_DIR) are still accepted.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import tomllib
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from platformdirs import user_config_path
21
+
22
+ from .collector import DEFAULT_SOURCES, SOURCES
23
+
24
+ APP_NAME = "canvas-pull"
25
+ KEYRING_SERVICE = "canvas-pull"
26
+ DEFAULT_SAVE_DIR = "~/Canvas"
27
+
28
+ # setting name -> accepted environment / .env keys (first match wins)
29
+ ENV_KEYS: dict[str, tuple[str, ...]] = {
30
+ "website": ("CANVAS_URL", "WEBSITE"),
31
+ "token": ("CANVAS_TOKEN", "ACCESS_TOKEN"),
32
+ "save_dir": ("CANVAS_SAVE_DIR", "SAVE_DIR"),
33
+ }
34
+
35
+
36
+ class ConfigError(Exception):
37
+ pass
38
+
39
+
40
+ @dataclass
41
+ class Config:
42
+ website: str
43
+ save_dir: Path
44
+ token: str = ""
45
+ concurrency: int = 4
46
+ scan_concurrency: int = 8
47
+ keep_history: bool = True
48
+ git: bool = False
49
+ exclude: list[str] = field(default_factory=list)
50
+ include_completed: bool = False
51
+ sources: list[str] = field(default_factory=lambda: list(DEFAULT_SOURCES))
52
+
53
+
54
+ def default_config_file() -> Path:
55
+ return user_config_path(APP_NAME) / "config.toml"
56
+
57
+
58
+ def normalize_website(value: str) -> str:
59
+ """'oc.sjtu.edu.cn/' -> 'https://oc.sjtu.edu.cn'"""
60
+ url = value.strip().rstrip("/")
61
+ if not url:
62
+ raise ConfigError("Canvas URL is empty.")
63
+ if "://" not in url:
64
+ url = "https://" + url
65
+ if url.endswith("/api/v1"):
66
+ url = url[: -len("/api/v1")]
67
+ return url
68
+
69
+
70
+ def parse_dotenv(path: Path) -> dict[str, str]:
71
+ values: dict[str, str] = {}
72
+ if not path.is_file():
73
+ return values
74
+ for raw in path.read_text(encoding="utf-8").splitlines():
75
+ line = raw.strip()
76
+ if not line or line.startswith("#") or "=" not in line:
77
+ continue
78
+ key, _, value = line.partition("=")
79
+ key = key.strip().removeprefix("export ").strip()
80
+ value = value.strip()
81
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
82
+ value = value[1:-1]
83
+ values[key] = value
84
+ return values
85
+
86
+
87
+ def _from_env(source: Mapping[str, str]) -> dict[str, str]:
88
+ out: dict[str, str] = {}
89
+ for name, keys in ENV_KEYS.items():
90
+ for key in keys:
91
+ if source.get(key):
92
+ out[name] = source[key]
93
+ break
94
+ return out
95
+
96
+
97
+ def _keyring_get(website: str) -> str:
98
+ try:
99
+ import keyring
100
+
101
+ return keyring.get_password(KEYRING_SERVICE, website) or ""
102
+ except Exception: # no backend, locked keychain, etc.
103
+ return ""
104
+
105
+
106
+ def load_config(
107
+ *,
108
+ require_token: bool = True,
109
+ config_file: Path | None = None,
110
+ cwd: Path | None = None,
111
+ environ: Mapping[str, str] | None = None,
112
+ use_keyring: bool = True,
113
+ ) -> Config:
114
+ config_file = config_file or default_config_file()
115
+ cwd = cwd or Path.cwd()
116
+ environ = os.environ if environ is None else environ
117
+
118
+ data: dict = {}
119
+ if config_file.is_file():
120
+ try:
121
+ data = tomllib.loads(config_file.read_text(encoding="utf-8"))
122
+ except tomllib.TOMLDecodeError as exc:
123
+ raise ConfigError(f"Cannot parse {config_file}: {exc}") from exc
124
+ data.update(_from_env(parse_dotenv(cwd / ".env")))
125
+ data.update(_from_env(environ))
126
+
127
+ if not data.get("website"):
128
+ raise ConfigError("No Canvas URL configured. Run `canvas-pull init` first.")
129
+ website = normalize_website(str(data["website"]))
130
+
131
+ save_dir = Path(str(data.get("save_dir") or DEFAULT_SAVE_DIR)).expanduser()
132
+ if not save_dir.is_absolute():
133
+ save_dir = (cwd / save_dir).resolve()
134
+
135
+ token = str(data.get("token") or "")
136
+ if not token and use_keyring:
137
+ token = _keyring_get(website)
138
+ if require_token and not token:
139
+ raise ConfigError("No access token found. Run `canvas-pull init`, or set CANVAS_TOKEN.")
140
+
141
+ try:
142
+ return Config(
143
+ website=website,
144
+ save_dir=save_dir,
145
+ token=token,
146
+ concurrency=max(1, int(data.get("concurrency", 4))),
147
+ scan_concurrency=max(1, min(16, int(data.get("scan_concurrency", 8)))),
148
+ keep_history=bool(data.get("keep_history", True)),
149
+ git=bool(data.get("git", False)),
150
+ exclude=[str(x) for x in data.get("exclude", [])],
151
+ include_completed=bool(data.get("include_completed", False)),
152
+ sources=_sources(data.get("sources", list(DEFAULT_SOURCES))),
153
+ )
154
+ except (TypeError, ValueError) as exc:
155
+ raise ConfigError(f"Invalid value in {config_file}: {exc}") from exc
156
+
157
+
158
+ def _sources(value: object) -> list[str]:
159
+ if not isinstance(value, list):
160
+ raise ConfigError(f"`sources` must be a list, e.g. {list(SOURCES)}")
161
+ unknown = [s for s in value if s not in SOURCES]
162
+ if unknown:
163
+ raise ConfigError(f"Unknown source(s) {unknown}. Choose from {list(SOURCES)}.")
164
+ return [str(s) for s in value]
165
+
166
+
167
+ def _toml_value(value: object) -> str:
168
+ if isinstance(value, bool):
169
+ return "true" if value else "false"
170
+ if isinstance(value, int):
171
+ return str(value)
172
+ if isinstance(value, list):
173
+ return "[" + ", ".join(_toml_value(v) for v in value) + "]"
174
+ escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
175
+ return f'"{escaped}"'
176
+
177
+
178
+ def save_config(cfg: Config, *, config_file: Path | None = None) -> str:
179
+ """Write settings; store the token in the keychain when possible.
180
+
181
+ Returns where the token ended up: "keychain" or the config file path.
182
+ """
183
+ config_file = config_file or default_config_file()
184
+ config_file.parent.mkdir(parents=True, exist_ok=True)
185
+
186
+ values: dict[str, object] = {
187
+ "website": cfg.website,
188
+ "save_dir": str(cfg.save_dir),
189
+ "concurrency": cfg.concurrency,
190
+ "scan_concurrency": cfg.scan_concurrency,
191
+ "keep_history": cfg.keep_history,
192
+ "git": cfg.git,
193
+ "exclude": cfg.exclude,
194
+ "include_completed": cfg.include_completed,
195
+ "sources": cfg.sources,
196
+ }
197
+ stored_in = "keychain"
198
+ try:
199
+ import keyring
200
+
201
+ keyring.set_password(KEYRING_SERVICE, cfg.website, cfg.token)
202
+ except Exception:
203
+ values["token"] = cfg.token
204
+ stored_in = str(config_file)
205
+
206
+ lines = ["# canvas-pull settings. Edit freely; run `canvas-pull config` to check."]
207
+ lines += [f"{k} = {_toml_value(v)}" for k, v in values.items()]
208
+ config_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
209
+ if "token" in values:
210
+ config_file.chmod(0o600)
211
+ return stored_in
canvas_pull/gitops.py ADDED
@@ -0,0 +1,52 @@
1
+ """Optional: commit each sync in the save directory so `git log` shows history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ from collections import Counter
8
+ from pathlib import Path
9
+
10
+ from .manifest import STATE_DIR
11
+ from .sync import Change
12
+
13
+ GITIGNORE = f"{STATE_DIR}/tmp/\n{STATE_DIR}/history/\n.DS_Store\n"
14
+
15
+
16
+ class GitError(Exception):
17
+ pass
18
+
19
+
20
+ def _git(save_dir: Path, *args: str) -> subprocess.CompletedProcess[str]:
21
+ proc = subprocess.run(
22
+ ["git", "-C", str(save_dir), *args], capture_output=True, text=True, check=False
23
+ )
24
+ if proc.returncode != 0:
25
+ raise GitError(proc.stderr.strip() or f"git {' '.join(args)} failed")
26
+ return proc
27
+
28
+
29
+ def commit_sync(save_dir: Path, changes: list[Change]) -> str | None:
30
+ """Commit everything in save_dir. Returns the commit summary, or None if nothing changed."""
31
+ if shutil.which("git") is None:
32
+ raise GitError("git is not installed")
33
+ if not (save_dir / ".git").exists():
34
+ _git(save_dir, "init", "-q")
35
+ ignore = save_dir / ".gitignore"
36
+ if not ignore.exists():
37
+ ignore.write_text(GITIGNORE, encoding="utf-8")
38
+
39
+ _git(save_dir, "add", "-A")
40
+ if not _git(save_dir, "status", "--porcelain").stdout.strip():
41
+ return None
42
+
43
+ counts = Counter(str(c.kind) for c in changes)
44
+ summary = "sync: " + (", ".join(f"{n} {k}" for k, n in sorted(counts.items())) or "update")
45
+ body = "\n".join(
46
+ f"{c.kind}: {c.old_path} -> {c.path}"
47
+ if c.old_path and c.old_path != c.path
48
+ else f"{c.kind}: {c.path}"
49
+ for c in changes
50
+ )
51
+ _git(save_dir, "commit", "-q", "-m", summary, "-m", body or "metadata update")
52
+ return summary