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,132 @@
1
+ """Local record of what has been synced, keyed by Canvas file id.
2
+
3
+ Stored as JSON in <save_dir>/.canvas-pull/ so it is readable and diffs well in git.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from dataclasses import asdict, dataclass
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ STATE_DIR = ".canvas-pull"
17
+ MANIFEST_VERSION = 1
18
+
19
+
20
+ def now_iso() -> str:
21
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
22
+
23
+
24
+ @dataclass
25
+ class Entry:
26
+ id: int
27
+ course_id: int
28
+ course: str
29
+ path: str
30
+ size: int
31
+ stamp: str | None
32
+ sha256: str
33
+ synced_at: str
34
+ deleted: bool = False
35
+
36
+
37
+ class Manifest:
38
+ def __init__(
39
+ self,
40
+ path: Path,
41
+ entries: dict[int, Entry] | None = None,
42
+ archived: dict[int, list[str]] | None = None,
43
+ ) -> None:
44
+ self.path = path
45
+ self.entries: dict[int, Entry] = entries or {}
46
+ # Completed courses already synced in full: course id -> sources used then.
47
+ self.archived: dict[int, list[str]] = archived or {}
48
+
49
+ @classmethod
50
+ def load(cls, save_dir: Path) -> Manifest:
51
+ path = save_dir / STATE_DIR / "manifest.json"
52
+ if not path.is_file():
53
+ return cls(path)
54
+ data = json.loads(path.read_text(encoding="utf-8"))
55
+ entries = {e["id"]: Entry(**e) for e in data.get("files", [])}
56
+ archived = {int(k): list(v) for k, v in data.get("archived_courses", {}).items()}
57
+ return cls(path, entries, archived)
58
+
59
+ def is_archived(self, course_id: int, sources: list[str]) -> bool:
60
+ """True if this completed course was fully synced with (at least) these sources."""
61
+ done = self.archived.get(course_id)
62
+ return done is not None and set(sources) <= set(done)
63
+
64
+ def mark_archived(self, course_id: int, sources: list[str]) -> None:
65
+ self.archived[course_id] = sorted(sources)
66
+
67
+ def get(self, file_id: int) -> Entry | None:
68
+ return self.entries.get(file_id)
69
+
70
+ def upsert(self, entry: Entry) -> None:
71
+ self.entries[entry.id] = entry
72
+
73
+ def mark_deleted(self, file_id: int) -> None:
74
+ if file_id in self.entries:
75
+ self.entries[file_id].deleted = True
76
+
77
+ def save(self) -> None:
78
+ self.path.parent.mkdir(parents=True, exist_ok=True)
79
+ data = {
80
+ "version": MANIFEST_VERSION,
81
+ "files": [asdict(e) for e in sorted(self.entries.values(), key=lambda e: e.id)],
82
+ "archived_courses": {str(k): v for k, v in sorted(self.archived.items())},
83
+ }
84
+ tmp = self.path.with_suffix(".json.tmp")
85
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
86
+ os.replace(tmp, self.path)
87
+
88
+
89
+ def append_log(save_dir: Path, record: dict) -> None:
90
+ path = save_dir / STATE_DIR / "log.jsonl"
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ with open(path, "a", encoding="utf-8") as fh:
93
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
94
+
95
+
96
+ def read_log(save_dir: Path, limit: int | None = 10) -> list[dict]:
97
+ """Sync records, oldest first; the last `limit` of them (all if None)."""
98
+ path = save_dir / STATE_DIR / "log.jsonl"
99
+ if not path.is_file():
100
+ return []
101
+ lines = [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
102
+ return [json.loads(line) for line in (lines if limit is None else lines[-limit:])]
103
+
104
+
105
+ class SyncLocked(Exception):
106
+ """Another canvas-pull sync is already running on this save folder."""
107
+
108
+
109
+ @contextmanager
110
+ def sync_lock(save_dir: Path) -> Iterator[None]:
111
+ """Exclusive, non-blocking lock on the save folder (flock on Unix, msvcrt on Windows)."""
112
+ path = save_dir / STATE_DIR / "lock"
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ with open(path, "a+") as fh:
115
+ try:
116
+ if os.name == "nt":
117
+ import msvcrt
118
+
119
+ fh.seek(0)
120
+ msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
121
+ else:
122
+ import fcntl
123
+
124
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
125
+ except OSError:
126
+ raise SyncLocked from None
127
+ try:
128
+ yield
129
+ finally:
130
+ if os.name == "nt":
131
+ fh.seek(0)
132
+ msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
canvas_pull/models.py ADDED
@@ -0,0 +1,61 @@
1
+ """Canvas API objects (only the fields canvas-pull uses)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class _Model(BaseModel):
11
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
12
+
13
+
14
+ class Term(_Model):
15
+ id: int | None = None
16
+ name: str | None = None
17
+ start_at: datetime | None = None
18
+ end_at: datetime | None = None
19
+
20
+
21
+ class Course(_Model):
22
+ id: int
23
+ name: str | None = None
24
+ course_code: str | None = None
25
+ term: Term | None = None
26
+ start_at: datetime | None = None
27
+ end_at: datetime | None = None
28
+ # Courses outside their access dates come back with only id + this flag.
29
+ access_restricted_by_date: bool = False
30
+ # Set by canvas-pull (not Canvas) for courses from completed enrollments.
31
+ completed: bool = False
32
+
33
+ @property
34
+ def label(self) -> str:
35
+ return self.name or self.course_code or f"Course {self.id}"
36
+
37
+
38
+ class Folder(_Model):
39
+ id: int
40
+ full_name: str
41
+ name: str = ""
42
+ parent_folder_id: int | None = None
43
+
44
+
45
+ class CanvasFile(_Model):
46
+ id: int
47
+ display_name: str
48
+ filename: str = ""
49
+ folder_id: int | None = None
50
+ size: int = 0
51
+ content_type: str = Field(default="", alias="content-type")
52
+ url: str = ""
53
+ updated_at: datetime | None = None
54
+ modified_at: datetime | None = None
55
+ locked_for_user: bool = False
56
+
57
+ @property
58
+ def stamp(self) -> str | None:
59
+ """Timestamp of the last content change (modified_at ignores renames)."""
60
+ ts = self.modified_at or self.updated_at
61
+ return ts.isoformat() if ts else None
canvas_pull/notify.py ADDED
@@ -0,0 +1,46 @@
1
+ """Desktop notifications (macOS Notification Center, or notify-send on Linux)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from collections import Counter, defaultdict
9
+ from collections.abc import Iterable
10
+
11
+ from .sync import Change
12
+
13
+ TITLE = "Canvas Pull"
14
+
15
+
16
+ def send(message: str, title: str = TITLE) -> bool:
17
+ """Show a notification. Returns False when no notifier is available."""
18
+ try:
19
+ if sys.platform == "darwin":
20
+ # Pass text as argv so quotes in file names cannot break the script.
21
+ script = [
22
+ "-e", "on run argv",
23
+ "-e", "display notification (item 2 of argv) with title (item 1 of argv)",
24
+ "-e", "end run",
25
+ ] # fmt: skip
26
+ cmd = ["osascript", *script, title, message]
27
+ elif shutil.which("notify-send"):
28
+ cmd = ["notify-send", "--app-name=canvas-pull", title, message]
29
+ else:
30
+ return False
31
+ return subprocess.run(cmd, capture_output=True, timeout=10, check=False).returncode == 0
32
+ except (OSError, subprocess.SubprocessError):
33
+ return False
34
+
35
+
36
+ def describe(changes: Iterable[Change], limit: int = 220) -> str:
37
+ """'Signals: 2 new, 1 modified · OS: 1 deleted' (truncated)."""
38
+ per_course: dict[str, Counter[str]] = defaultdict(Counter)
39
+ for c in changes:
40
+ per_course[c.course][str(c.kind)] += 1
41
+ parts = [
42
+ f"{course}: " + ", ".join(f"{n} {k}" for k, n in counts.items())
43
+ for course, counts in per_course.items()
44
+ ]
45
+ text = " · ".join(parts)
46
+ return text if len(text) <= limit else text[: limit - 1] + "…"
@@ -0,0 +1,215 @@
1
+ """Run `canvas-pull sync --notify` on a schedule.
2
+
3
+ macOS: a launchd user agent in ~/Library/LaunchAgents.
4
+ Linux: a systemd user timer in ~/.config/systemd/user.
5
+ A Mac that was asleep at the scheduled time runs the missed sync when it wakes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import plistlib
12
+ import re
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ from collections.abc import Callable, Sequence
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ LABEL = "io.github.gxxxr-111.canvas-pull"
21
+ UNIT = "canvas-pull"
22
+ DEFAULT_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
23
+
24
+ Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
25
+
26
+
27
+ class ScheduleError(Exception):
28
+ pass
29
+
30
+
31
+ def _run(cmd: Sequence[str]) -> subprocess.CompletedProcess[str]:
32
+ return subprocess.run(list(cmd), capture_output=True, text=True, check=False)
33
+
34
+
35
+ @dataclass
36
+ class Spec:
37
+ times: list[tuple[int, int]] # (hour, minute) every day
38
+ every: int | None = None # seconds; used instead of times
39
+
40
+ def describe(self) -> str:
41
+ if self.every:
42
+ mins = self.every // 60
43
+ return f"every {mins // 60}h" if mins % 60 == 0 else f"every {mins}m"
44
+ return "daily at " + ", ".join(f"{h:02d}:{m:02d}" for h, m in self.times)
45
+
46
+
47
+ def parse_spec(at: Sequence[str], every: str | None) -> Spec:
48
+ if every and at:
49
+ raise ScheduleError("Use either --at or --every, not both.")
50
+ if every:
51
+ m = re.fullmatch(r"\s*(\d+)\s*([mh])\s*", every.lower())
52
+ if not m:
53
+ raise ScheduleError("--every looks like 30m or 6h.")
54
+ seconds = int(m[1]) * (60 if m[2] == "m" else 3600)
55
+ if seconds < 15 * 60:
56
+ raise ScheduleError("Please sync at most every 15 minutes.")
57
+ return Spec(times=[], every=seconds)
58
+ times = []
59
+ for value in at or ["09:00"]:
60
+ m = re.fullmatch(r"\s*(\d{1,2}):(\d{2})\s*", value)
61
+ if not m or int(m[1]) > 23 or int(m[2]) > 59:
62
+ raise ScheduleError(f"Invalid time {value!r}; use HH:MM, e.g. 08:30.")
63
+ times.append((int(m[1]), int(m[2])))
64
+ return Spec(times=sorted(set(times)))
65
+
66
+
67
+ def command() -> list[str]:
68
+ """How the scheduler should start canvas-pull: the same install that is running now."""
69
+ argv0 = Path(sys.argv[0])
70
+ if argv0.name == "canvas-pull" and argv0.exists():
71
+ return [str(argv0.resolve()), "sync", "--notify"]
72
+ return [sys.executable, "-m", "canvas_pull", "sync", "--notify"]
73
+
74
+
75
+ def log_file() -> Path:
76
+ if sys.platform == "darwin":
77
+ return Path.home() / "Library/Logs/canvas-pull.log"
78
+ state = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
79
+ return state / "canvas-pull/canvas-pull.log"
80
+
81
+
82
+ # -- macOS ---------------------------------------------------------------------
83
+
84
+
85
+ def plist_path() -> Path:
86
+ return Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
87
+
88
+
89
+ def build_plist(spec: Spec, cmd: list[str], workdir: Path, log: Path) -> dict:
90
+ plist: dict = {
91
+ "Label": LABEL,
92
+ "ProgramArguments": cmd,
93
+ "WorkingDirectory": str(workdir),
94
+ "StandardOutPath": str(log),
95
+ "StandardErrorPath": str(log),
96
+ "EnvironmentVariables": {"PATH": DEFAULT_PATH, "PYTHONUNBUFFERED": "1"},
97
+ "ProcessType": "Background",
98
+ "RunAtLoad": False,
99
+ }
100
+ if spec.every:
101
+ plist["StartInterval"] = spec.every
102
+ else:
103
+ plist["StartCalendarInterval"] = [{"Hour": h, "Minute": m} for h, m in spec.times]
104
+ return plist
105
+
106
+
107
+ # -- Linux ---------------------------------------------------------------------
108
+
109
+
110
+ def systemd_dir() -> Path:
111
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
112
+ return base / "systemd/user"
113
+
114
+
115
+ def build_systemd(spec: Spec, cmd: list[str], workdir: Path, log: Path) -> tuple[str, str]:
116
+ service = (
117
+ "[Unit]\nDescription=Sync Canvas course files\n"
118
+ "Wants=network-online.target\nAfter=network-online.target\n\n"
119
+ "[Service]\nType=oneshot\n"
120
+ f"WorkingDirectory={workdir}\n"
121
+ f"ExecStart={shlex.join(cmd)}\n"
122
+ f"StandardOutput=append:{log}\nStandardError=append:{log}\n"
123
+ )
124
+ if spec.every:
125
+ when = f"OnBootSec=5min\nOnUnitActiveSec={spec.every}s\n"
126
+ else:
127
+ when = "".join(f"OnCalendar=*-*-* {h:02d}:{m:02d}:00\n" for h, m in spec.times)
128
+ timer = (
129
+ f"[Unit]\nDescription=Run canvas-pull ({spec.describe()})\n\n"
130
+ f"[Timer]\n{when}Persistent=true\n\n[Install]\nWantedBy=timers.target\n"
131
+ )
132
+ return service, timer
133
+
134
+
135
+ # -- public --------------------------------------------------------------------
136
+
137
+
138
+ def _check(proc: subprocess.CompletedProcess[str], what: str) -> None:
139
+ if proc.returncode != 0:
140
+ raise ScheduleError(f"{what} failed: {(proc.stderr or proc.stdout).strip()}")
141
+
142
+
143
+ def enable(spec: Spec, *, workdir: Path, run: Runner = _run) -> Path:
144
+ """Install (or replace) the schedule. Returns the file written."""
145
+ cmd, log = command(), log_file()
146
+ log.parent.mkdir(parents=True, exist_ok=True)
147
+ if sys.platform == "darwin":
148
+ path = plist_path()
149
+ path.parent.mkdir(parents=True, exist_ok=True)
150
+ domain = f"gui/{os.getuid()}"
151
+ run(["launchctl", "bootout", domain, str(path)]) # ignore "not loaded"
152
+ path.write_bytes(plistlib.dumps(build_plist(spec, cmd, workdir, log)))
153
+ _check(run(["launchctl", "bootstrap", domain, str(path)]), "launchctl bootstrap")
154
+ return path
155
+ if sys.platform.startswith("linux"):
156
+ d = systemd_dir()
157
+ d.mkdir(parents=True, exist_ok=True)
158
+ service, timer = build_systemd(spec, cmd, workdir, log)
159
+ (d / f"{UNIT}.service").write_text(service, encoding="utf-8")
160
+ (d / f"{UNIT}.timer").write_text(timer, encoding="utf-8")
161
+ _check(run(["systemctl", "--user", "daemon-reload"]), "systemctl daemon-reload")
162
+ _check(run(["systemctl", "--user", "enable", "--now", f"{UNIT}.timer"]), "systemctl enable")
163
+ return d / f"{UNIT}.timer"
164
+ raise ScheduleError(
165
+ "Scheduling is supported on macOS and Linux. On Windows use Task Scheduler."
166
+ )
167
+
168
+
169
+ def disable(*, run: Runner = _run) -> bool:
170
+ """Remove the schedule. Returns False if none was installed."""
171
+ if sys.platform == "darwin":
172
+ path = plist_path()
173
+ if not path.exists():
174
+ return False
175
+ run(["launchctl", "bootout", f"gui/{os.getuid()}", str(path)])
176
+ path.unlink()
177
+ return True
178
+ if sys.platform.startswith("linux"):
179
+ d = systemd_dir()
180
+ timer = d / f"{UNIT}.timer"
181
+ if not timer.exists():
182
+ return False
183
+ run(["systemctl", "--user", "disable", "--now", f"{UNIT}.timer"])
184
+ timer.unlink()
185
+ (d / f"{UNIT}.service").unlink(missing_ok=True)
186
+ run(["systemctl", "--user", "daemon-reload"])
187
+ return True
188
+ return False
189
+
190
+
191
+ def current() -> str | None:
192
+ """Human-readable description of the installed schedule, or None."""
193
+ if sys.platform == "darwin" and plist_path().exists():
194
+ data = plistlib.loads(plist_path().read_bytes())
195
+ if "StartInterval" in data:
196
+ return Spec(times=[], every=int(data["StartInterval"])).describe()
197
+ cal = data.get("StartCalendarInterval", [])
198
+ cal = cal if isinstance(cal, list) else [cal]
199
+ return Spec(times=[(c.get("Hour", 0), c.get("Minute", 0)) for c in cal]).describe()
200
+ timer = systemd_dir() / f"{UNIT}.timer"
201
+ if sys.platform.startswith("linux") and timer.exists():
202
+ m = re.search(r"Description=Run canvas-pull \((.*)\)", timer.read_text())
203
+ return m[1] if m else "installed"
204
+ return None
205
+
206
+
207
+ def run_now(*, run: Runner = _run) -> None:
208
+ """Trigger the scheduled job once, as the scheduler would."""
209
+ if sys.platform == "darwin":
210
+ target = f"gui/{os.getuid()}/{LABEL}"
211
+ _check(run(["launchctl", "kickstart", target]), "launchctl kickstart")
212
+ elif sys.platform.startswith("linux"):
213
+ _check(run(["systemctl", "--user", "start", f"{UNIT}.service"]), "systemctl start")
214
+ else:
215
+ raise ScheduleError("No schedule support on this platform.")
canvas_pull/summary.py ADDED
@@ -0,0 +1,72 @@
1
+ """Merge many sync records into one net summary ("what changed this week")."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime, timedelta
8
+
9
+ _UNITS = {"h": "hours", "d": "days", "w": "weeks"}
10
+
11
+
12
+ def parse_since(value: str, now: datetime | None = None) -> datetime:
13
+ """'7d', '24h', '2w', 'today' or a date like '2026-10-01' -> aware datetime."""
14
+ now = now or datetime.now(UTC)
15
+ text = value.strip().lower()
16
+ if text == "today":
17
+ local = datetime.now().astimezone()
18
+ return local.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(UTC)
19
+ m = re.fullmatch(r"(\d+)\s*([hdw])", text)
20
+ if m:
21
+ return now - timedelta(**{_UNITS[m[2]]: int(m[1])})
22
+ try:
23
+ dt = datetime.fromisoformat(value.strip())
24
+ except ValueError:
25
+ raise ValueError(f"Can't read {value!r}; try 7d, 24h, 2w, today or 2026-10-01.") from None
26
+ return (
27
+ dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class NetChange:
33
+ kind: str # new | modified | moved | deleted
34
+ course: str
35
+ path: str
36
+ old_path: str | None = None
37
+ time: str = ""
38
+
39
+
40
+ def summarize(records: list[dict], since: datetime) -> list[NetChange]:
41
+ """Net effect of all changes after `since`, oldest record first in `records`.
42
+
43
+ new→modified stays new, new→deleted disappears, a moved file keeps its first path.
44
+ """
45
+ state: dict[str, NetChange] = {}
46
+ for rec in records:
47
+ if datetime.fromisoformat(rec["time"]) < since:
48
+ continue
49
+ for c in rec["changes"]:
50
+ kind, path, course, t = c["kind"], c["path"], c["course"], rec["time"]
51
+ if kind == "restored": # a local event, not a Canvas change
52
+ continue
53
+ if kind == "new":
54
+ state[path] = NetChange("new", course, path, time=t)
55
+ continue
56
+ old = c.get("old_path") or path
57
+ prev = state.pop(old, None)
58
+ if kind == "deleted":
59
+ if not (prev and prev.kind == "new"): # added then removed: no net change
60
+ state[path] = NetChange("deleted", course, path, time=t)
61
+ continue
62
+ # modified or moved
63
+ origin = prev.old_path if prev and prev.old_path else old
64
+ origin = origin if origin != path else None
65
+ if prev and prev.kind == "new":
66
+ state[path] = NetChange("new", course, path, time=t)
67
+ elif kind == "modified" or (prev and prev.kind == "modified"):
68
+ state[path] = NetChange("modified", course, path, origin, t)
69
+ elif origin: # moved, and not back to where it started
70
+ state[path] = NetChange("moved", course, path, origin, t)
71
+ order = {"new": 0, "modified": 1, "moved": 2, "deleted": 3}
72
+ return sorted(state.values(), key=lambda n: (n.course.lower(), order[n.kind], n.path.lower()))