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/sync.py ADDED
@@ -0,0 +1,231 @@
1
+ """Compare Canvas with the manifest, then apply the differences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import hashlib
7
+ import os
8
+ import shutil
9
+ from collections.abc import Callable, Iterable
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+
16
+ from .api import CanvasClient, CanvasError
17
+ from .collector import CourseScan, RemoteFile
18
+ from .manifest import STATE_DIR, Entry, Manifest, now_iso
19
+
20
+
21
+ class Kind(StrEnum):
22
+ NEW = "new"
23
+ MODIFIED = "modified"
24
+ MOVED = "moved"
25
+ DELETED = "deleted"
26
+ RESTORED = "restored" # in the manifest but missing on disk
27
+
28
+
29
+ @dataclass
30
+ class Change:
31
+ kind: Kind
32
+ path: str
33
+ course: str
34
+ old_path: str | None = None
35
+ remote: RemoteFile | None = None
36
+ old: Entry | None = None
37
+
38
+ @property
39
+ def needs_download(self) -> bool:
40
+ return self.kind in (Kind.NEW, Kind.MODIFIED, Kind.RESTORED)
41
+
42
+ def to_dict(self) -> dict:
43
+ d = {"kind": str(self.kind), "course": self.course, "path": self.path}
44
+ if self.old_path and self.old_path != self.path:
45
+ d["old_path"] = self.old_path
46
+ return d
47
+
48
+
49
+ @dataclass
50
+ class Plan:
51
+ changes: list[Change] = field(default_factory=list)
52
+ locked: list[RemoteFile] = field(default_factory=list)
53
+
54
+
55
+ @dataclass
56
+ class SyncResult:
57
+ applied: list[Change] = field(default_factory=list)
58
+ failed: list[tuple[Change, str]] = field(default_factory=list)
59
+ unchanged: int = 0 # reported as modified by Canvas but byte-identical
60
+
61
+
62
+ def make_plan(scans: Iterable[CourseScan], manifest: Manifest, save_dir: Path) -> Plan:
63
+ plan = Plan()
64
+ seen: set[int] = set()
65
+ scanned: set[int] = set()
66
+ for scan in scans:
67
+ if scan.error:
68
+ continue # never infer deletions from a course we could not read
69
+ scanned.add(scan.course.id)
70
+ for rf in scan.files:
71
+ seen.add(rf.file.id)
72
+ if rf.locked:
73
+ plan.locked.append(rf)
74
+ continue
75
+ old = manifest.get(rf.file.id)
76
+ label = rf.course.label
77
+ if old is None or old.deleted:
78
+ plan.changes.append(Change(Kind.NEW, rf.path, label, remote=rf))
79
+ elif old.size != rf.file.size or old.stamp != rf.file.stamp:
80
+ plan.changes.append(
81
+ Change(Kind.MODIFIED, rf.path, label, old_path=old.path, remote=rf, old=old)
82
+ )
83
+ elif old.path != rf.path:
84
+ plan.changes.append(
85
+ Change(Kind.MOVED, rf.path, label, old_path=old.path, remote=rf, old=old)
86
+ )
87
+ elif not (save_dir / old.path).is_file():
88
+ plan.changes.append(Change(Kind.RESTORED, rf.path, label, remote=rf, old=old))
89
+
90
+ for entry in manifest.entries.values():
91
+ if not entry.deleted and entry.course_id in scanned and entry.id not in seen:
92
+ plan.changes.append(Change(Kind.DELETED, entry.path, entry.course, old=entry))
93
+ plan.changes.sort(key=lambda c: (c.course.lower(), c.path.lower()))
94
+ return plan
95
+
96
+
97
+ def _sha256(path: Path) -> str:
98
+ digest = hashlib.sha256()
99
+ with open(path, "rb") as fh:
100
+ for chunk in iter(lambda: fh.read(1 << 16), b""):
101
+ digest.update(chunk)
102
+ return digest.hexdigest()
103
+
104
+
105
+ def _entry(rf: RemoteFile, size: int, sha: str) -> Entry:
106
+ return Entry(
107
+ id=rf.file.id,
108
+ course_id=rf.course.id,
109
+ course=rf.course.label,
110
+ path=rf.path,
111
+ size=size,
112
+ stamp=rf.file.stamp,
113
+ sha256=sha,
114
+ synced_at=now_iso(),
115
+ )
116
+
117
+
118
+ class _Applier:
119
+ def __init__(self, save_dir: Path, keep_history: bool) -> None:
120
+ self.save_dir = save_dir
121
+ self.keep_history = keep_history
122
+ self.state = save_dir / STATE_DIR
123
+ self.tmp = self.state / "tmp"
124
+ self.history = self.state / "history" / datetime.now().strftime("%Y%m%d-%H%M%S")
125
+
126
+ def retire(self, rel: str) -> None:
127
+ """Move an outdated local file into history (or drop it when history is off)."""
128
+ src = self.save_dir / rel
129
+ if not src.is_file():
130
+ return
131
+ if self.keep_history:
132
+ dst = self.history / rel
133
+ dst.parent.mkdir(parents=True, exist_ok=True)
134
+ shutil.move(src, dst)
135
+ else:
136
+ src.unlink()
137
+
138
+ def place(self, tmp: Path, rel: str, sha: str) -> None:
139
+ target = self.save_dir / rel
140
+ if target.is_file() and _sha256(target) != sha:
141
+ self.retire(rel) # e.g. a file downloaded by v1, or edited locally
142
+ target.parent.mkdir(parents=True, exist_ok=True)
143
+ os.replace(tmp, target)
144
+
145
+ def move(self, old_rel: str, new_rel: str) -> bool:
146
+ src, dst = self.save_dir / old_rel, self.save_dir / new_rel
147
+ if not src.is_file():
148
+ return False
149
+ if dst.exists():
150
+ self.retire(new_rel)
151
+ dst.parent.mkdir(parents=True, exist_ok=True)
152
+ shutil.move(src, dst)
153
+ return True
154
+
155
+
156
+ def execute(
157
+ plan: Plan,
158
+ *,
159
+ client: CanvasClient,
160
+ manifest: Manifest,
161
+ save_dir: Path,
162
+ keep_history: bool = True,
163
+ concurrency: int = 4,
164
+ on_progress: Callable[[Change], None] | None = None,
165
+ ) -> SyncResult:
166
+ result = SyncResult()
167
+ fs = _Applier(save_dir, keep_history)
168
+ downloads: list[Change] = []
169
+
170
+ for change in plan.changes:
171
+ if change.kind is Kind.DELETED:
172
+ assert change.old
173
+ manifest.mark_deleted(change.old.id) # the local copy is kept on purpose
174
+ result.applied.append(change)
175
+ elif change.kind is Kind.MOVED:
176
+ assert change.remote and change.old and change.old_path
177
+ if fs.move(change.old_path, change.path):
178
+ old = change.old
179
+ manifest.upsert(_entry(change.remote, old.size, old.sha256))
180
+ result.applied.append(change)
181
+ else:
182
+ downloads.append(change)
183
+ else:
184
+ downloads.append(change)
185
+
186
+ if not downloads:
187
+ return result
188
+
189
+ fs.tmp.mkdir(parents=True, exist_ok=True)
190
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
191
+ futures = {
192
+ pool.submit(client.download, c.remote.file.url, fs.tmp / f"{c.remote.file.id}.part"): c
193
+ for c in downloads
194
+ if c.remote
195
+ }
196
+ for future in as_completed(futures):
197
+ change = futures[future]
198
+ rf = change.remote
199
+ assert rf is not None
200
+ tmp = fs.tmp / f"{rf.file.id}.part"
201
+ try:
202
+ size, sha = future.result()
203
+ except CanvasError as exc:
204
+ tmp.unlink(missing_ok=True)
205
+ result.failed.append((change, str(exc)))
206
+ continue
207
+
208
+ old = change.old
209
+ same_bytes = old is not None and old.sha256 == sha
210
+ if change.old_path and change.old_path != change.path:
211
+ if same_bytes:
212
+ (save_dir / change.old_path).unlink(missing_ok=True)
213
+ else:
214
+ fs.retire(change.old_path)
215
+ fs.place(tmp, change.path, sha)
216
+ manifest.upsert(_entry(rf, size, sha))
217
+
218
+ if change.kind is Kind.MODIFIED and same_bytes:
219
+ if change.old_path and change.old_path != change.path:
220
+ change.kind = Kind.MOVED # only the name changed
221
+ result.applied.append(change)
222
+ else:
223
+ result.unchanged += 1
224
+ else:
225
+ result.applied.append(change)
226
+ if on_progress:
227
+ on_progress(change)
228
+
229
+ with contextlib.suppress(OSError):
230
+ fs.tmp.rmdir()
231
+ return result
@@ -0,0 +1,280 @@
1
+ Metadata-Version: 2.5
2
+ Name: canvas-pull
3
+ Version: 2.4.0
4
+ Summary: Sync your Canvas LMS course files: download only what changed and see what instructors added, modified, moved or deleted.
5
+ Project-URL: Homepage, https://github.com/gxxxr-111/canvas-pull
6
+ Project-URL: Repository, https://github.com/gxxxr-111/canvas-pull
7
+ Project-URL: Issues, https://github.com/gxxxr-111/canvas-pull/issues
8
+ Project-URL: Changelog, https://github.com/gxxxr-111/canvas-pull/blob/main/CHANGELOG.md
9
+ Author-email: Mirage_ <gaoxuran111@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: canvas,canvas-lms,cli,download,instructure,lms,students,sync
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: Intended Audience :: End Users/Desktop
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3 :: Only
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Education
26
+ Classifier: Topic :: Utilities
27
+ Requires-Python: >=3.11
28
+ Requires-Dist: httpx>=0.27
29
+ Requires-Dist: keyring>=25
30
+ Requires-Dist: platformdirs>=4.2
31
+ Requires-Dist: pydantic>=2.7
32
+ Requires-Dist: rich>=13.7
33
+ Requires-Dist: typer>=0.12
34
+ Description-Content-Type: text/markdown
35
+
36
+ <h1 align="center">Canvas Pull</h1>
37
+
38
+ <p align="center">
39
+ <b>Keep a local copy of your Canvas course files, and see exactly what changed.</b>
40
+ </p>
41
+
42
+ <p align="center">
43
+ <a href="https://github.com/gxxxr-111/canvas-pull/actions/workflows/ci.yml"><img src="https://github.com/gxxxr-111/canvas-pull/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
44
+ <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/v/canvas-pull" alt="PyPI" /></a>
45
+ <a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/pyversions/canvas-pull" alt="Python versions" /></a>
46
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license" /></a>
47
+ </p>
48
+
49
+ <p align="center">
50
+ <a href="#quick-start">Quick start</a> ·
51
+ <a href="#commands">Commands</a> ·
52
+ <a href="#faq">FAQ</a> ·
53
+ <a href="#中文说明">中文说明</a>
54
+ </p>
55
+
56
+ canvas-pull downloads the files of your Canvas LMS courses with one command. Every later run
57
+ downloads only what changed and tells you which files your instructors **added, modified,
58
+ renamed/moved or deleted**. It can run on its own every morning and notify you.
59
+
60
+ <p align="center">
61
+ <img src="https://raw.githubusercontent.com/gxxxr-111/canvas-pull/main/docs/demo.svg" alt="canvas-pull sync and log --since output" width="760" />
62
+ </p>
63
+
64
+ ## Features
65
+
66
+ - **Incremental sync.** Files are tracked by their Canvas file ID, so unchanged files are never
67
+ downloaded again and renames are detected without re-downloading.
68
+ - **Change report.** `status` previews changes, `sync` applies them, `log` shows past syncs,
69
+ and `log --since 7d` gives a merged weekly summary.
70
+ - **Automatic sync.** `schedule enable` runs a sync every day (or every few hours) and shows a
71
+ desktop notification when something changes.
72
+ - **Old versions kept.** When a file changes, the previous copy is moved to
73
+ `.canvas-pull/history/<timestamp>/`. Files deleted on Canvas stay on your disk.
74
+ - **Files tab by default, more on request.** Only the Files tab is synced by default (with
75
+ Modules as a fallback when a course hides it). Opt in to the syllabus, assignment
76
+ descriptions, pages, announcements and discussions through the `sources` setting.
77
+ - **Current courses, or everything.** Courses whose term has ended are skipped by default;
78
+ `--completed` also archives concluded courses. A completed course is scanned once; later syncs
79
+ skip it (use `--refresh` to scan it again).
80
+ - **Safe token storage.** `init` stores your token in the system keychain, not in a text file.
81
+ - **Optional git.** `sync --git` commits each sync in the save folder.
82
+ - **Read-only and private.** Only reads from Canvas; no telemetry, nothing leaves your machine.
83
+ - **Fast.** Courses are scanned in parallel (a 32-course account scans in about 10 s instead of
84
+ 75 s), with parallel downloads, automatic retries with back-off and full pagination.
85
+
86
+ ## Install
87
+
88
+ ```bash
89
+ uv tool install canvas-pull # or: pipx install canvas-pull
90
+ ```
91
+
92
+ Try it without installing: `uvx canvas-pull --help`. Python 3.11+ on macOS, Linux or Windows.
93
+ To get the latest unreleased code: `uv tool install git+https://github.com/gxxxr-111/canvas-pull`.
94
+
95
+ ## Quick start
96
+
97
+ 1. In Canvas, open **Account → Settings → New Access Token** and copy the token.
98
+ Give it an expiry date. Never share it.
99
+ 2. Run:
100
+
101
+ ```bash
102
+ canvas-pull init # Canvas URL (e.g. oc.sjtu.edu.cn), save folder, token
103
+ canvas-pull courses # check which courses will be synced
104
+ canvas-pull sync
105
+ ```
106
+
107
+ ## Commands
108
+
109
+ | Command | What it does |
110
+ | --- | --- |
111
+ | `canvas-pull init` | Set Canvas URL, save folder and token (token goes to the keychain). |
112
+ | `canvas-pull courses [--all] [--completed]` | List courses that would be synced. |
113
+ | `canvas-pull status [-c COURSE] [--completed]` | Show what changed on Canvas. Downloads nothing. |
114
+ | `canvas-pull sync [-n] [-c COURSE] [--completed] [--refresh] [--git] [--notify]` | Download changes. `-n` is a dry run. `-c` takes a course ID or part of its name and can be repeated. `--refresh` rescans archived completed courses. `--notify` shows a desktop notification. |
115
+ | `canvas-pull log [-n 10]` | Show recent syncs. |
116
+ | `canvas-pull log --since 7d` | Merged summary since `7d`, `24h`, `2w`, `today` or a date. |
117
+ | `canvas-pull schedule enable [--at 08:00 ...] [--every 6h]` | Sync automatically (launchd on macOS, systemd timer on Linux). Default: daily 09:00. |
118
+ | `canvas-pull schedule status \| run \| disable` | Show the schedule and its log, trigger it now, or remove it. |
119
+ | `canvas-pull config` | Show settings in use. |
120
+
121
+ ## Configuration
122
+
123
+ `init` writes a config file (path shown by `canvas-pull config`). You can edit it:
124
+
125
+ ```toml
126
+ website = "https://oc.sjtu.edu.cn"
127
+ save_dir = "/Users/me/Canvas"
128
+ concurrency = 4 # parallel downloads
129
+ scan_concurrency = 8 # parallel API requests while scanning (max 16)
130
+ keep_history = true # keep replaced versions in .canvas-pull/history
131
+ git = false # commit after every sync
132
+ exclude = ["PE", "12345"] # course IDs or name fragments to skip
133
+ include_completed = false # also sync concluded courses
134
+ sources = ["files"] # default; add any of the sources below
135
+ ```
136
+
137
+ Available sources: `files`, `modules`, `syllabus`, `assignments`, `pages`, `announcements`,
138
+ `discussions`. With the default, a course that hides its Files tab (or leaves it empty) falls
139
+ back to Modules automatically. To grab everything:
140
+
141
+ ```toml
142
+ sources = ["files", "modules", "syllabus", "assignments", "pages", "announcements", "discussions"]
143
+ ```
144
+
145
+ Environment variables override the file: `CANVAS_URL`, `CANVAS_TOKEN`, `CANVAS_SAVE_DIR`. A
146
+ `.env` file in the current folder is also read (v1 keys `WEBSITE`, `ACCESS_TOKEN`, `SAVE_DIR`
147
+ still work).
148
+
149
+ When a file appears in several places it keeps the path of the first source in that list, so a
150
+ file in the Files tab stays where it is even if an assignment links to it. Files found elsewhere
151
+ go under `Modules/<module>/`, `Assignments/<name>/`, `Pages/<title>/`, `Announcements/<title>/`,
152
+ `Discussions/<title>/` or `Syllabus/`.
153
+
154
+ ### Automatic sync
155
+
156
+ ```bash
157
+ canvas-pull schedule enable --at 08:00 --at 20:00 # or: --every 6h
158
+ canvas-pull schedule run # try it once now
159
+ canvas-pull schedule status # schedule + last log lines
160
+ ```
161
+
162
+ The job runs `canvas-pull sync --notify` from the folder where you enabled it. A Mac that was
163
+ asleep at the scheduled time catches up when it wakes. Install with `uv tool install` first so
164
+ the job points at a stable path.
165
+
166
+ On Windows, `schedule` is not available yet: create a daily task in Task Scheduler that runs
167
+ `canvas-pull sync`. Desktop notifications are macOS and Linux only for now.
168
+
169
+ ## How change detection works
170
+
171
+ Each synced file is recorded in `<save_dir>/.canvas-pull/manifest.json` with its Canvas ID,
172
+ path, size, last-modified time and SHA-256. On every run canvas-pull compares that record with
173
+ Canvas metadata:
174
+
175
+ | Canvas says | Result |
176
+ | --- | --- |
177
+ | ID not seen before | **new**: download |
178
+ | Size or modified time changed | **modified**: download; if the bytes are identical, nothing is reported |
179
+ | Same content, different name/folder | **moved**: renamed locally, no download |
180
+ | ID gone from a course that was read successfully | **deleted**: reported once; local copy kept |
181
+ | Recorded but missing on disk | **restored**: downloaded again |
182
+
183
+ Content-level diffs of PDF/PPTX/DOCX are out of scope for now.
184
+
185
+ ## Tested schools
186
+
187
+ canvas-pull uses the standard Canvas REST API, so it should work with any Canvas site where
188
+ students can create access tokens.
189
+
190
+ | School | Canvas site | Status |
191
+ | --- | --- | --- |
192
+ | Shanghai Jiao Tong University (上海交通大学) | `oc.sjtu.edu.cn` | ✅ Works |
193
+
194
+ Tried it at your school? Please open a
195
+ [school report](https://github.com/gxxxr-111/canvas-pull/issues/new?template=school_report.yml),
196
+ whether it worked or not.
197
+
198
+ ## FAQ
199
+
200
+ **My school doesn't let students create access tokens.**
201
+ That isn't supported yet. Please file a school report so we know which schools need another
202
+ sign-in method.
203
+
204
+ **What does "N locked file(s) not yet available" mean?**
205
+ The instructor has locked those files (until a date, or behind a module requirement). They are
206
+ downloaded automatically once they unlock.
207
+
208
+ **Where did the old version of a changed file go?**
209
+ To `<save_dir>/.canvas-pull/history/<timestamp>/`, keeping its folder structure. Turn this off
210
+ with `keep_history = false`.
211
+
212
+ **I deleted a downloaded file by accident.**
213
+ Run `canvas-pull sync`; it is downloaded again and reported as *restored*.
214
+
215
+ **Can canvas-pull change anything on Canvas?**
216
+ No. It only sends read (`GET`) requests.
217
+
218
+ **My token expired.**
219
+ Create a new one in Canvas and run `canvas-pull init` again. Scheduled syncs show a notification
220
+ when the token is rejected.
221
+
222
+ **How do I skip a course?**
223
+ Add its ID or part of its name to `exclude` in the config file (`canvas-pull courses` shows IDs).
224
+
225
+ ## Privacy and security
226
+
227
+ - The token is kept in the OS keychain and is only sent to your Canvas site. It is not forwarded
228
+ when a download redirects to file storage.
229
+ - No telemetry and no third-party services.
230
+ - See [SECURITY.md](SECURITY.md) for details and how to report a vulnerability.
231
+
232
+ ## Related projects
233
+
234
+ Other Canvas download tools you may also like:
235
+
236
+ - [skyzh/canvas_grab](https://github.com/skyzh/canvas_grab): sync script with a GUI, popular at SJTU.
237
+ - [perslev/CanvasSync](https://github.com/perslev/CanvasSync): syncs modules, assignments and files.
238
+ - [aik2mlj/canvas-downloader](https://github.com/aik2mlj/canvas-downloader): fast downloader written in Rust.
239
+ - [BrkBuilds/Canvas-Downloader](https://github.com/BrkBuilds/Canvas-Downloader): desktop app for Windows and macOS.
240
+ - [Beebeeoii/lominus](https://github.com/Beebeeoii/lominus): file sync for NUS Canvas.
241
+
242
+ canvas-pull focuses on the command line and automation: it tracks files by ID, reports exactly
243
+ what changed (including renames), keeps old versions, and can run unattended.
244
+
245
+ ## Contributing
246
+
247
+ Bug reports, school reports and pull requests are welcome. See
248
+ [CONTRIBUTING.md](CONTRIBUTING.md). Tests run against an in-memory fake Canvas, so no account is
249
+ needed:
250
+
251
+ ```bash
252
+ uv sync && uv run pytest
253
+ ```
254
+
255
+ Changes are listed in [CHANGELOG.md](CHANGELOG.md). Licensed under [MIT](LICENSE).
256
+
257
+ ---
258
+
259
+ ## 中文说明
260
+
261
+ 一条命令同步所有**正在进行的** Canvas 课程文件。之后每次运行只下载有变化的文件,并列出老师**新增、修改、改名/移动、删除**了哪些文件。
262
+
263
+ ```bash
264
+ uv tool install canvas-pull # 或 pipx install canvas-pull
265
+ canvas-pull init # 填学校 Canvas 域名(如 oc.sjtu.edu.cn)、保存目录、Access Token
266
+ canvas-pull status # 只看变化,不下载
267
+ canvas-pull sync # 下载变化
268
+ canvas-pull log --since 7d # 本周汇总
269
+ canvas-pull schedule enable --at 08:00 # 每天早上自动同步,有变化时弹通知
270
+ ```
271
+
272
+ - Token 在 Canvas 的「账户 → 设置 → 新建访问许可证」生成,`init` 会把它存进系统钥匙串。
273
+ - 文件被修改时,旧版本会移到 `.canvas-pull/history/`;Canvas 上删除的文件本地不会删除。
274
+ - 默认只同步「文件」栏目;课程隐藏了文件栏目时,会自动改从「模块」里找。
275
+ - 想要更多来源(课程大纲、作业、页面、公告、讨论里的附件和链接),在配置文件里设置 `sources`。
276
+ - `--completed` 可以把已结业的课程也一起归档下来。已结业的课只完整扫描一次,之后自动跳过(`--refresh` 强制重扫)。
277
+ - 多门课并行扫描,32 门课大约 10 秒扫完。
278
+ - `sync --git` 会在保存目录里自动提交一次 git,方便用 `git log` 回看。
279
+ - 只读取 Canvas,不会修改任何内容;没有遥测,数据不离开你的电脑。
280
+ - 在你的学校试过了?欢迎提交一个 [School report](https://github.com/gxxxr-111/canvas-pull/issues/new?template=school_report.yml),无论成功与否。
@@ -0,0 +1,18 @@
1
+ canvas_pull/__init__.py,sha256=XptFmUhbj77gETWBSdVD0F3nlhcSZJ4MLC6HY6J9ALs,77
2
+ canvas_pull/__main__.py,sha256=wcCrL4PjG51r5wVKqJhcoJPTLfHW0wNbD31DrUN0MWI,28
3
+ canvas_pull/api.py,sha256=icGbIR4Jl9-kWCKu2K6YDP2ub6Vfn1z7kRBgYmG85XM,7161
4
+ canvas_pull/cli.py,sha256=VCenssAhKlyNb7hSh34ykY83E1BHqFlJN0zBA3kDuMM,19116
5
+ canvas_pull/collector.py,sha256=KQdaKHxEdx6gQAZTC76fpFp5QKXmoNovQ0lpgxO65jU,12113
6
+ canvas_pull/config.py,sha256=uHKPZsB3m9Gg6PHQyfQRXHh-1ecEU6omq6wExb5qCho,6840
7
+ canvas_pull/gitops.py,sha256=qMaXXafP3toKPLKxopM9oMQkE3Va7K4D17GxEqtwXS4,1695
8
+ canvas_pull/manifest.py,sha256=f1f4bRYe0o5PjRl2TBJE_d3p5KRwWkZxT2VcHjX2KYM,4382
9
+ canvas_pull/models.py,sha256=hM6ftQGQjuyOD1asoT26pbL3aCO-_s08XyRrIqPnEyo,1633
10
+ canvas_pull/notify.py,sha256=ssEiKpnNcfbihRPkk67lLgZizybgk3i0u7oTdpGXl9Q,1653
11
+ canvas_pull/schedule.py,sha256=DkweJsSmuECdhbh-Ezd_feQZ0XRThBq8M3p6mB2XsFo,8104
12
+ canvas_pull/summary.py,sha256=d2k23a7NYNFTivbdfmeG5Ic7mJgdJExzIexlIJgpofM,2987
13
+ canvas_pull/sync.py,sha256=ThXJkaPSUQX5QKKhiNCPBk3HfJ_SVc-aMlBjruiWVcA,7782
14
+ canvas_pull-2.4.0.dist-info/METADATA,sha256=B4DOSAwl63QkLes4cyn5MhT-jbtwaN-gAaVEjK64_eg,13429
15
+ canvas_pull-2.4.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
16
+ canvas_pull-2.4.0.dist-info/entry_points.txt,sha256=Lv4aWU21VgoopEZnXiLyhZgPG0trFAPveSj5hl_KWvM,52
17
+ canvas_pull-2.4.0.dist-info/licenses/LICENSE,sha256=ZswdR7v2DeEZVWbyF91PmJ53r2z4erq1L1N1JY6GJEA,1064
18
+ canvas_pull-2.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ canvas-pull = canvas_pull.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mirage_
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.