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/cli.py ADDED
@@ -0,0 +1,531 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from contextlib import nullcontext
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
13
+ from rich.table import Table
14
+
15
+ from . import __version__, notify
16
+ from . import schedule as sched
17
+ from .api import AuthError, CanvasClient, CanvasError
18
+ from .collector import CourseScan, course_dirnames, list_courses, scan_courses
19
+ from .config import (
20
+ DEFAULT_SAVE_DIR,
21
+ Config,
22
+ ConfigError,
23
+ default_config_file,
24
+ load_config,
25
+ normalize_website,
26
+ save_config,
27
+ )
28
+ from .gitops import GitError, commit_sync
29
+ from .manifest import Manifest, SyncLocked, append_log, now_iso, read_log, sync_lock
30
+ from .models import Course
31
+ from .summary import parse_since, summarize
32
+ from .sync import Change, Kind, execute, make_plan
33
+
34
+ app = typer.Typer(
35
+ help="Sync files from your active Canvas courses and see what changed.",
36
+ no_args_is_help=True,
37
+ add_completion=False,
38
+ rich_markup_mode="rich",
39
+ )
40
+ console = Console(highlight=False)
41
+ err = Console(stderr=True, highlight=False)
42
+
43
+ STYLE = {
44
+ Kind.NEW: ("+", "green"),
45
+ Kind.MODIFIED: ("~", "yellow"),
46
+ Kind.MOVED: ("→", "cyan"),
47
+ Kind.DELETED: ("-", "red"),
48
+ Kind.RESTORED: ("↺", "blue"),
49
+ }
50
+
51
+
52
+ def _fail(message: str) -> typer.Exit:
53
+ err.print(f"[red]Error:[/] {message}")
54
+ return typer.Exit(1)
55
+
56
+
57
+ def _pretty(path: Path) -> str:
58
+ """Show paths under the home folder as ~/..."""
59
+ try:
60
+ return "~/" + path.relative_to(Path.home()).as_posix()
61
+ except ValueError:
62
+ return str(path)
63
+
64
+
65
+ def _load(require_token: bool = True) -> Config:
66
+ try:
67
+ return load_config(require_token=require_token)
68
+ except ConfigError as exc:
69
+ raise _fail(str(exc)) from None
70
+
71
+
72
+ def _matches(course: Course, patterns: list[str]) -> bool:
73
+ label = course.label.lower()
74
+ code = (course.course_code or "").lower()
75
+ return any(p == str(course.id) or p.lower() in label or p.lower() in code for p in patterns)
76
+
77
+
78
+ def _version(value: bool) -> None:
79
+ if value:
80
+ console.print(f"canvas-pull {__version__}")
81
+ raise typer.Exit()
82
+
83
+
84
+ @app.callback()
85
+ def main(
86
+ version: Annotated[
87
+ bool, typer.Option("--version", callback=_version, is_eager=True, help="Show version.")
88
+ ] = False,
89
+ ) -> None:
90
+ pass
91
+
92
+
93
+ @app.command()
94
+ def init() -> None:
95
+ """Set your Canvas URL, save folder and access token."""
96
+ try:
97
+ current = load_config(require_token=False)
98
+ except ConfigError:
99
+ current = None
100
+
101
+ console.print(
102
+ "Create a token in Canvas: [bold]Account → Settings → New Access Token[/].\n"
103
+ "The token is stored in your system keychain.\n"
104
+ )
105
+ website = normalize_website(
106
+ typer.prompt("Canvas URL", default=current.website if current else "oc.sjtu.edu.cn")
107
+ )
108
+ save_dir = (
109
+ Path(
110
+ typer.prompt(
111
+ "Save files to", default=str(current.save_dir) if current else DEFAULT_SAVE_DIR
112
+ )
113
+ )
114
+ .expanduser()
115
+ .resolve()
116
+ )
117
+ token = typer.prompt("Access token", hide_input=True).strip()
118
+
119
+ try:
120
+ with CanvasClient(website, token) as client:
121
+ me = client.get("users/self")
122
+ except CanvasError as exc:
123
+ raise _fail(f"Could not sign in to {website}: {exc}") from None
124
+
125
+ cfg = current or Config(website=website, save_dir=save_dir)
126
+ cfg.website, cfg.save_dir, cfg.token = website, save_dir, token
127
+ where = save_config(cfg)
128
+ console.print(f"[green]✓[/] Signed in as [bold]{me.get('name', '?')}[/].")
129
+ console.print(f"Settings: {default_config_file()} · token: {where}")
130
+ console.print("Next: [bold]canvas-pull sync[/]")
131
+
132
+
133
+ @app.command()
134
+ def courses(
135
+ all_: Annotated[bool, typer.Option("--all", help="Include courses whose term ended.")] = False,
136
+ completed: Annotated[
137
+ bool | None,
138
+ typer.Option("--completed/--no-completed", help="Include completed (concluded) courses."),
139
+ ] = None,
140
+ ) -> None:
141
+ """List the courses that would be synced."""
142
+ cfg = _load()
143
+ with_completed = cfg.include_completed if completed is None else completed
144
+ try:
145
+ with _client(cfg) as client:
146
+ found = list_courses(client, include_past=all_, include_completed=with_completed)
147
+ except CanvasError as exc:
148
+ raise _fail(str(exc)) from None
149
+
150
+ table = Table(box=None, header_style="bold")
151
+ table.add_column("ID", justify="right")
152
+ table.add_column("Course")
153
+ table.add_column("Term")
154
+ table.add_column("")
155
+ for c in found:
156
+ notes = ["[dim]completed[/]"] if c.completed else []
157
+ if _matches(c, cfg.exclude):
158
+ notes.append("[dim]excluded[/]")
159
+ term = (c.term.name if c.term else "") or ""
160
+ table.add_row(str(c.id), c.label, term, " ".join(notes))
161
+ console.print(table)
162
+
163
+
164
+ def _client(cfg: Config) -> CanvasClient:
165
+ return CanvasClient(cfg.website, cfg.token, max_concurrency=cfg.scan_concurrency)
166
+
167
+
168
+ def _scan(
169
+ client: CanvasClient,
170
+ cfg: Config,
171
+ manifest: Manifest,
172
+ only: list[str],
173
+ all_courses: bool,
174
+ completed: bool,
175
+ refresh: bool,
176
+ ) -> tuple[list[CourseScan], int]:
177
+ """Scan matching courses in parallel. Returns (scans, number of archived courses skipped)."""
178
+ found = list_courses(client, include_past=all_courses, include_completed=completed)
179
+ found = [c for c in found if not _matches(c, cfg.exclude)]
180
+ if only:
181
+ found = [c for c in found if _matches(c, only)]
182
+ # Folder names come from the full list so skipping courses never renames a folder.
183
+ names = course_dirnames(found)
184
+ todo = [
185
+ c for c in found if refresh or not (c.completed and manifest.is_archived(c.id, cfg.sources))
186
+ ]
187
+ if not todo:
188
+ return [], len(found)
189
+ done = 0
190
+ with console.status(f"Scanning {len(todo)} course(s)…") as status:
191
+
192
+ def progress(scan: CourseScan) -> None:
193
+ nonlocal done
194
+ done += 1
195
+ status.update(f"Scanning [{done}/{len(todo)}] {scan.course.label}")
196
+
197
+ scans = scan_courses(
198
+ client, todo, names, cfg.sources, workers=cfg.scan_concurrency, on_done=progress
199
+ )
200
+ return scans, len(found) - len(todo)
201
+
202
+
203
+ _KIND_ORDER = {Kind.NEW: 0, Kind.MODIFIED: 1, Kind.MOVED: 2, Kind.RESTORED: 3, Kind.DELETED: 4}
204
+
205
+
206
+ def _print_changes(changes: list[Change]) -> None:
207
+ by_course: dict[str, list[Change]] = defaultdict(list)
208
+ ordered = sorted(changes, key=lambda c: (c.course.lower(), _KIND_ORDER[c.kind], c.path.lower()))
209
+ for c in ordered:
210
+ by_course[c.course].append(c)
211
+ for course, items in by_course.items():
212
+ console.print(f"\n[bold]{course}[/]")
213
+ for c in items:
214
+ mark, color = STYLE[c.kind]
215
+ shown = c.path.split("/", 1)[-1]
216
+ if c.old_path and c.old_path != c.path:
217
+ shown = f"{c.old_path.split('/', 1)[-1]} → {shown}"
218
+ console.print(f" [{color}]{mark} {c.kind:<8}[/] {shown}")
219
+
220
+
221
+ def _summary(changes: list[Change]) -> str:
222
+ counts: dict[Kind, int] = defaultdict(int)
223
+ for c in changes:
224
+ counts[c.kind] += 1
225
+ parts = [f"[{STYLE[k][1]}]{n} {k}[/]" for k, n in counts.items()]
226
+ return ", ".join(parts) if parts else "no changes"
227
+
228
+
229
+ def _alert(enabled: bool, message: str) -> None:
230
+ if enabled:
231
+ notify.send(message)
232
+
233
+
234
+ @app.command()
235
+ def sync(
236
+ dry_run: Annotated[
237
+ bool, typer.Option("--dry-run", "-n", help="Only show what would change.")
238
+ ] = False,
239
+ course: Annotated[
240
+ list[str] | None,
241
+ typer.Option("--course", "-c", help="Only these courses (id or part of the name)."),
242
+ ] = None,
243
+ all_courses: Annotated[
244
+ bool, typer.Option("--all-courses", help="Include courses whose term ended.")
245
+ ] = False,
246
+ completed: Annotated[
247
+ bool | None,
248
+ typer.Option("--completed/--no-completed", help="Include completed (concluded) courses."),
249
+ ] = None,
250
+ git: Annotated[
251
+ bool | None, typer.Option("--git/--no-git", help="Commit the result in the save folder.")
252
+ ] = None,
253
+ notify_: Annotated[
254
+ bool,
255
+ typer.Option("--notify", help="Desktop notification when files change or sync fails."),
256
+ ] = False,
257
+ refresh: Annotated[
258
+ bool, typer.Option("--refresh", help="Scan archived completed courses again.")
259
+ ] = False,
260
+ ) -> None:
261
+ """Download new and changed files from your active courses."""
262
+ if not console.is_terminal and not dry_run:
263
+ console.print(f"--- {now_iso()} canvas-pull sync") # separator in scheduled-run logs
264
+ try:
265
+ cfg = load_config()
266
+ except ConfigError as exc:
267
+ _alert(notify_, f"Sync failed: {exc}")
268
+ raise _fail(str(exc)) from None
269
+ use_git = cfg.git if git is None else git
270
+ with_completed = cfg.include_completed if completed is None else completed
271
+ manifest = Manifest.load(cfg.save_dir)
272
+
273
+ try:
274
+ lock = nullcontext() if dry_run else sync_lock(cfg.save_dir)
275
+ with lock, _client(cfg) as client:
276
+ scans, archived = _scan(
277
+ client, cfg, manifest, course or [], all_courses, with_completed, refresh
278
+ )
279
+ if archived:
280
+ console.print(
281
+ f"[dim]Skipped {archived} completed course(s) already archived "
282
+ "(use --refresh to scan them again).[/]"
283
+ )
284
+ if not scans:
285
+ if not archived:
286
+ console.print("No matching active courses.")
287
+ return
288
+ plan = make_plan(scans, manifest, cfg.save_dir)
289
+
290
+ for s in scans:
291
+ if s.error:
292
+ err.print(f"[yellow]![/] Skipped {s.course.label}: {s.error}")
293
+ elif s.files_tab_hidden:
294
+ console.print(
295
+ f"[dim]{s.course.label}: Files tab hidden; used Modules instead.[/]"
296
+ )
297
+
298
+ if dry_run:
299
+ _print_changes(plan.changes)
300
+ console.print(f"\n{_summary(plan.changes)} · {_pretty(cfg.save_dir)}")
301
+ if plan.locked:
302
+ console.print(f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]")
303
+ return
304
+
305
+ total = sum(1 for c in plan.changes if c.needs_download)
306
+ columns = (
307
+ TextColumn("Downloading"),
308
+ BarColumn(),
309
+ MofNCompleteColumn(),
310
+ TimeElapsedColumn(),
311
+ )
312
+ with Progress(*columns, console=console, transient=True) as progress:
313
+ task = progress.add_task("", total=total)
314
+ try:
315
+ result = execute(
316
+ plan,
317
+ client=client,
318
+ manifest=manifest,
319
+ save_dir=cfg.save_dir,
320
+ keep_history=cfg.keep_history,
321
+ concurrency=cfg.concurrency,
322
+ on_progress=lambda _c: progress.advance(task),
323
+ )
324
+ failed_courses = {c.remote.course.id for c, _ in result.failed if c.remote}
325
+ for s in scans:
326
+ if s.course.completed and not s.error and s.course.id not in failed_courses:
327
+ manifest.mark_archived(s.course.id, cfg.sources)
328
+ finally:
329
+ manifest.save()
330
+ except SyncLocked:
331
+ raise _fail("Another sync is already running for this folder.") from None
332
+ except AuthError as exc:
333
+ _alert(notify_, "Sync failed: Canvas rejected your token. Run `canvas-pull init`.")
334
+ raise _fail(str(exc)) from None
335
+ except CanvasError as exc:
336
+ if exc.status is not None: # stay quiet when simply offline
337
+ _alert(notify_, f"Sync failed: {exc}")
338
+ raise _fail(str(exc)) from None
339
+
340
+ _print_changes(result.applied)
341
+ for change, reason in result.failed:
342
+ err.print(f"[red]✗[/] {change.path}: {reason}")
343
+ console.print(f"\n{_summary(result.applied)} · {_pretty(cfg.save_dir)}")
344
+ if plan.locked:
345
+ console.print(f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]")
346
+
347
+ if result.applied:
348
+ record = {"time": now_iso(), "changes": [c.to_dict() for c in result.applied]}
349
+ append_log(cfg.save_dir, record)
350
+ if use_git:
351
+ try:
352
+ summary = commit_sync(cfg.save_dir, result.applied)
353
+ if summary:
354
+ console.print(f"[dim]git: {summary}[/]")
355
+ except GitError as exc:
356
+ err.print(f"[yellow]git commit skipped:[/] {exc}")
357
+ if result.applied or result.failed:
358
+ message = notify.describe(result.applied)
359
+ if result.failed:
360
+ message = f"{message} · {len(result.failed)} failed".lstrip(" ·")
361
+ _alert(notify_, message)
362
+ if result.failed:
363
+ raise typer.Exit(1)
364
+
365
+
366
+ @app.command()
367
+ def status(
368
+ course: Annotated[
369
+ list[str] | None, typer.Option("--course", "-c", help="Only these courses.")
370
+ ] = None,
371
+ completed: Annotated[
372
+ bool | None,
373
+ typer.Option("--completed/--no-completed", help="Include completed (concluded) courses."),
374
+ ] = None,
375
+ refresh: Annotated[
376
+ bool, typer.Option("--refresh", help="Scan archived completed courses again.")
377
+ ] = False,
378
+ ) -> None:
379
+ """Show what changed on Canvas since the last sync (downloads nothing)."""
380
+ sync(
381
+ dry_run=True,
382
+ course=course,
383
+ all_courses=False,
384
+ completed=completed,
385
+ git=False,
386
+ notify_=False,
387
+ refresh=refresh,
388
+ )
389
+
390
+
391
+ @app.command()
392
+ def log(
393
+ limit: Annotated[int, typer.Option("--limit", "-n", help="How many syncs to show.")] = 10,
394
+ since: Annotated[
395
+ str | None,
396
+ typer.Option(
397
+ "--since", "-s", help="Net summary since 7d, 24h, 2w, today or a date (2026-10-01)."
398
+ ),
399
+ ] = None,
400
+ ) -> None:
401
+ """Show recent syncs, or a merged summary with --since (e.g. --since 7d)."""
402
+ cfg = _load(require_token=False)
403
+ if since:
404
+ try:
405
+ start = parse_since(since)
406
+ except ValueError as exc:
407
+ raise _fail(str(exc)) from None
408
+ net = summarize(read_log(cfg.save_dir, None), start)
409
+ local = start.astimezone().strftime("%Y-%m-%d %H:%M")
410
+ if not net:
411
+ console.print(f"No changes since {local}.")
412
+ return
413
+ console.print(f"[bold]Changes since {local}[/]")
414
+ by_course: dict[str, list] = defaultdict(list)
415
+ for n in net:
416
+ by_course[n.course].append(n)
417
+ for course_name, items in by_course.items():
418
+ counts: dict[str, int] = defaultdict(int)
419
+ for n in items:
420
+ counts[n.kind] += 1
421
+ tally = ", ".join(f"{v} {k}" for k, v in counts.items())
422
+ console.print(f"\n[bold]{course_name}[/] [dim]{tally}[/]")
423
+ for n in items:
424
+ mark, color = STYLE[Kind(n.kind)]
425
+ shown = n.path.split("/", 1)[-1]
426
+ if n.old_path:
427
+ shown = f"{n.old_path.split('/', 1)[-1]} → {shown}"
428
+ console.print(f" [{color}]{mark} {n.kind:<8}[/] {shown}")
429
+ return
430
+
431
+ records = read_log(cfg.save_dir, limit)
432
+ if not records:
433
+ console.print("No syncs recorded yet.")
434
+ return
435
+ for rec in reversed(records):
436
+ console.print(f"\n[bold]{rec['time']}[/]")
437
+ for c in rec["changes"]:
438
+ kind = Kind(c["kind"])
439
+ mark, color = STYLE[kind]
440
+ path = c["path"] if "old_path" not in c else f"{c['old_path']} → {c['path']}"
441
+ console.print(f" [{color}]{mark} {kind:<8}[/] {path}")
442
+
443
+
444
+ @app.command("config")
445
+ def show_config() -> None:
446
+ """Show the settings in use (token hidden)."""
447
+ cfg = _load(require_token=False)
448
+ token = "set" if cfg.token else "missing"
449
+ table = Table(box=None, show_header=False)
450
+ for key, value in [
451
+ ("config file", str(default_config_file())),
452
+ ("website", cfg.website),
453
+ ("save_dir", _pretty(cfg.save_dir)),
454
+ ("token", token),
455
+ ("concurrency", str(cfg.concurrency)),
456
+ ("scan_concurrency", str(cfg.scan_concurrency)),
457
+ ("keep_history", str(cfg.keep_history)),
458
+ ("git", str(cfg.git)),
459
+ ("exclude", ", ".join(cfg.exclude) or "-"),
460
+ ("include_completed", str(cfg.include_completed)),
461
+ ("sources", ", ".join(cfg.sources)),
462
+ ]:
463
+ table.add_row(f"[bold]{key}[/]", value)
464
+ console.print(table)
465
+
466
+
467
+ schedule_app = typer.Typer(
468
+ help="Sync automatically on a schedule, with desktop notifications.",
469
+ no_args_is_help=True,
470
+ )
471
+ app.add_typer(schedule_app, name="schedule")
472
+
473
+
474
+ @schedule_app.command("enable")
475
+ def schedule_enable(
476
+ at: Annotated[
477
+ list[str] | None,
478
+ typer.Option("--at", help="Time of day, HH:MM. Repeat for several times. Default 09:00."),
479
+ ] = None,
480
+ every: Annotated[
481
+ str | None, typer.Option("--every", help="Interval instead, e.g. 6h or 30m.")
482
+ ] = None,
483
+ ) -> None:
484
+ """Install (or replace) the automatic sync."""
485
+ _load() # fail now, not silently at 9am
486
+ try:
487
+ spec = sched.parse_spec(at or [], every)
488
+ path = sched.enable(spec, workdir=Path.cwd())
489
+ except sched.ScheduleError as exc:
490
+ raise _fail(str(exc)) from None
491
+ cmd = sched.command()
492
+ console.print(f"[green]✓[/] canvas-pull will sync [bold]{spec.describe()}[/].")
493
+ console.print(f"[dim]Job: {path}\nCommand: {' '.join(cmd)}\nLog: {sched.log_file()}[/]")
494
+ if "/.venv/" in cmd[0]:
495
+ err.print(
496
+ "[yellow]Note:[/] this points at a project virtualenv. For a stable path, install "
497
+ "with `uv tool install canvas-pull` and run `canvas-pull schedule enable` again."
498
+ )
499
+
500
+
501
+ @schedule_app.command("disable")
502
+ def schedule_disable() -> None:
503
+ """Remove the automatic sync."""
504
+ if sched.disable():
505
+ console.print("[green]✓[/] Automatic sync removed.")
506
+ else:
507
+ console.print("No automatic sync was installed.")
508
+
509
+
510
+ @schedule_app.command("status")
511
+ def schedule_status(
512
+ lines: Annotated[int, typer.Option("--lines", "-n", help="Log lines to show.")] = 15,
513
+ ) -> None:
514
+ """Show the schedule and the end of its log."""
515
+ desc = sched.current()
516
+ console.print(f"Schedule: [bold]{desc}[/]" if desc else "No automatic sync installed.")
517
+ log_path = sched.log_file()
518
+ if log_path.is_file():
519
+ tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
520
+ console.print(f"[dim]{log_path}[/]")
521
+ console.print("\n".join(tail), markup=False, highlight=False)
522
+
523
+
524
+ @schedule_app.command("run")
525
+ def schedule_run() -> None:
526
+ """Trigger the scheduled sync once now (output goes to the log)."""
527
+ try:
528
+ sched.run_now()
529
+ except sched.ScheduleError as exc:
530
+ raise _fail(str(exc)) from None
531
+ console.print(f"Started. Check with [bold]canvas-pull schedule status[/] ({sched.log_file()}).")