canvas-pull 2.4.0__tar.gz → 2.5.0__tar.gz
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-2.4.0 → canvas_pull-2.5.0}/CHANGELOG.md +20 -1
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/PKG-INFO +56 -3
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/README.md +55 -2
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/pyproject.toml +1 -1
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/__init__.py +1 -1
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/cli.py +314 -73
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_cli.py +104 -1
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/.gitignore +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/LICENSE +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/__main__.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/api.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/collector.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/config.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/gitops.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/manifest.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/models.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/notify.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/schedule.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/summary.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/src/canvas_pull/sync.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/__init__.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/fake_canvas.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_config.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_schedule.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_summary.py +0 -0
- {canvas_pull-2.4.0 → canvas_pull-2.5.0}/tests/test_sync.py +0 -0
|
@@ -6,6 +6,24 @@ uses [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.5.0] - 2026-11-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `--json` on `status`, `sync`, `log`, `courses`, `config` and `init`: one JSON object on stdout,
|
|
13
|
+
also for errors. Each change includes `local_path`.
|
|
14
|
+
- Stable exit codes: 3 not configured, 4 token rejected, 5 partly failed, 6 another sync running
|
|
15
|
+
(1 is still a network/Canvas error, 2 invalid arguments).
|
|
16
|
+
- Non-interactive `init`: `--url`, `--save-dir`, `--token-stdin`, `--no-input`.
|
|
17
|
+
- Agent skill (`skills/canvas-pull/SKILL.md`) and a Claude Code plugin:
|
|
18
|
+
`/plugin install canvas-pull --marketplace gxxxr-111/canvas-pull`.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- A course that can't be scanned now makes `sync`/`status` exit with 5 instead of 0.
|
|
22
|
+
- Token rejected now exits with 4 instead of 1.
|
|
23
|
+
- GitHub Actions are pinned to commit SHAs and updated to their latest versions
|
|
24
|
+
(checkout v7, setup-uv v10, upload-artifact v7, download-artifact v8). CI now also tests the
|
|
25
|
+
build-artifact hand-off used by releases.
|
|
26
|
+
|
|
9
27
|
## [2.4.0] - 2026-11-01
|
|
10
28
|
|
|
11
29
|
First release on PyPI: `uv tool install canvas-pull` or `pipx install canvas-pull`.
|
|
@@ -76,6 +94,7 @@ Complete rewrite as an installable CLI.
|
|
|
76
94
|
|
|
77
95
|
- First version: download all files of enrolled courses.
|
|
78
96
|
|
|
79
|
-
[Unreleased]: https://github.com/gxxxr-111/canvas-pull/compare/v2.
|
|
97
|
+
[Unreleased]: https://github.com/gxxxr-111/canvas-pull/compare/v2.5.0...HEAD
|
|
98
|
+
[2.5.0]: https://github.com/gxxxr-111/canvas-pull/compare/v2.4.0...v2.5.0
|
|
80
99
|
[2.4.0]: https://github.com/gxxxr-111/canvas-pull/compare/v1.0.0...v2.4.0
|
|
81
100
|
[1.0.0]: https://github.com/gxxxr-111/canvas-pull/releases/tag/v1.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: canvas-pull
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Sync your Canvas LMS course files: download only what changed and see what instructors added, modified, moved or deleted.
|
|
5
5
|
Project-URL: Homepage, https://github.com/gxxxr-111/canvas-pull
|
|
6
6
|
Project-URL: Repository, https://github.com/gxxxr-111/canvas-pull
|
|
@@ -41,14 +41,15 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
|
|
42
42
|
<p align="center">
|
|
43
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>
|
|
44
|
+
<a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/v/canvas-pull?cacheSeconds=3600" alt="PyPI" /></a>
|
|
45
|
+
<a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/pyversions/canvas-pull?cacheSeconds=3600" alt="Python versions" /></a>
|
|
46
46
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license" /></a>
|
|
47
47
|
</p>
|
|
48
48
|
|
|
49
49
|
<p align="center">
|
|
50
50
|
<a href="#quick-start">Quick start</a> ·
|
|
51
51
|
<a href="#commands">Commands</a> ·
|
|
52
|
+
<a href="#use-with-ai-agents">AI agents</a> ·
|
|
52
53
|
<a href="#faq">FAQ</a> ·
|
|
53
54
|
<a href="#中文说明">中文说明</a>
|
|
54
55
|
</p>
|
|
@@ -79,6 +80,8 @@ renamed/moved or deleted**. It can run on its own every morning and notify you.
|
|
|
79
80
|
skip it (use `--refresh` to scan it again).
|
|
80
81
|
- **Safe token storage.** `init` stores your token in the system keychain, not in a text file.
|
|
81
82
|
- **Optional git.** `sync --git` commits each sync in the save folder.
|
|
83
|
+
- **Made for scripts and AI agents.** Every command has `--json` output and stable exit codes,
|
|
84
|
+
and there's an agent skill / Claude Code plugin.
|
|
82
85
|
- **Read-only and private.** Only reads from Canvas; no telemetry, nothing leaves your machine.
|
|
83
86
|
- **Fast.** Courses are scanned in parallel (a 32-course account scans in about 10 s instead of
|
|
84
87
|
75 s), with parallel downloads, automatic retries with back-off and full pagination.
|
|
@@ -118,6 +121,50 @@ To get the latest unreleased code: `uv tool install git+https://github.com/gxxxr
|
|
|
118
121
|
| `canvas-pull schedule status \| run \| disable` | Show the schedule and its log, trigger it now, or remove it. |
|
|
119
122
|
| `canvas-pull config` | Show settings in use. |
|
|
120
123
|
|
|
124
|
+
## Use with AI agents
|
|
125
|
+
|
|
126
|
+
canvas-pull is a plain CLI, so AI coding agents (Claude Code, Codex, Cursor, …) can run it
|
|
127
|
+
directly. Ask *"anything new in my courses?"* or *"summarize this week's new slides"* and the
|
|
128
|
+
agent syncs, reads the changed files and answers.
|
|
129
|
+
|
|
130
|
+
**Install the skill** so your agent knows how to use it:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
/plugin install canvas-pull --marketplace gxxxr-111/canvas-pull
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The skill is in [`skills/canvas-pull/SKILL.md`](skills/canvas-pull/SKILL.md). Other agents that
|
|
137
|
+
support Agent Skills can use that file as is.
|
|
138
|
+
|
|
139
|
+
**JSON output.** Add `--json` to `status`, `sync`, `log`, `courses`, `config` or `init`, and stdout
|
|
140
|
+
holds exactly one JSON object, also on errors:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
canvas-pull log --since 7d --json | jq -r '.changes[] | select(.kind=="new") | .local_path'
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Each change has `kind`, `course`, `path`, `local_path` (and `old_path` for moves).
|
|
147
|
+
|
|
148
|
+
**Exit codes**
|
|
149
|
+
|
|
150
|
+
| Code | Meaning |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| 0 | OK |
|
|
153
|
+
| 1 | Network or Canvas error |
|
|
154
|
+
| 2 | Invalid arguments |
|
|
155
|
+
| 3 | Not configured / invalid settings |
|
|
156
|
+
| 4 | Token rejected or expired |
|
|
157
|
+
| 5 | Finished, but some downloads or courses failed |
|
|
158
|
+
| 6 | Another sync is running |
|
|
159
|
+
|
|
160
|
+
**Non-interactive setup** (CI, dotfiles, servers):
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
canvas-pull init --url oc.sjtu.edu.cn --save-dir ~/Canvas --token-stdin < token.txt
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Agents should never handle your token. Run `canvas-pull init` yourself.
|
|
167
|
+
|
|
121
168
|
## Configuration
|
|
122
169
|
|
|
123
170
|
`init` writes a config file (path shown by `canvas-pull config`). You can edit it:
|
|
@@ -219,6 +266,10 @@ No. It only sends read (`GET`) requests.
|
|
|
219
266
|
Create a new one in Canvas and run `canvas-pull init` again. Scheduled syncs show a notification
|
|
220
267
|
when the token is rejected.
|
|
221
268
|
|
|
269
|
+
**Can it put assignment deadlines in my calendar?**
|
|
270
|
+
Canvas already can. Open the Canvas **Calendar**, click **Calendar Feed**, and subscribe to
|
|
271
|
+
that link in Apple Calendar, Google Calendar or Outlook.
|
|
272
|
+
|
|
222
273
|
**How do I skip a course?**
|
|
223
274
|
Add its ID or part of its name to `exclude` in the config file (`canvas-pull courses` shows IDs).
|
|
224
275
|
|
|
@@ -277,4 +328,6 @@ canvas-pull schedule enable --at 08:00 # 每天早上自动同步,有变化
|
|
|
277
328
|
- 多门课并行扫描,32 门课大约 10 秒扫完。
|
|
278
329
|
- `sync --git` 会在保存目录里自动提交一次 git,方便用 `git log` 回看。
|
|
279
330
|
- 只读取 Canvas,不会修改任何内容;没有遥测,数据不离开你的电脑。
|
|
331
|
+
- 所有命令都支持 `--json` 输出和固定的退出码,方便脚本和 AI 助手调用。在 Claude Code 里运行 `/plugin install canvas-pull --marketplace gxxxr-111/canvas-pull` 安装技能后,就可以直接问「我的课程这周有什么更新?帮我总结新课件」。
|
|
332
|
+
- 作业截止日期:在 Canvas 的「日历」页面点「日历订阅源」,把链接加到系统日历即可,无需本工具。
|
|
280
333
|
- 在你的学校试过了?欢迎提交一个 [School report](https://github.com/gxxxr-111/canvas-pull/issues/new?template=school_report.yml),无论成功与否。
|
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<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>
|
|
9
|
-
<a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/v/canvas-pull" alt="PyPI" /></a>
|
|
10
|
-
<a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/pyversions/canvas-pull" alt="Python versions" /></a>
|
|
9
|
+
<a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/v/canvas-pull?cacheSeconds=3600" alt="PyPI" /></a>
|
|
10
|
+
<a href="https://pypi.org/project/canvas-pull/"><img src="https://img.shields.io/pypi/pyversions/canvas-pull?cacheSeconds=3600" alt="Python versions" /></a>
|
|
11
11
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license" /></a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
15
15
|
<a href="#quick-start">Quick start</a> ·
|
|
16
16
|
<a href="#commands">Commands</a> ·
|
|
17
|
+
<a href="#use-with-ai-agents">AI agents</a> ·
|
|
17
18
|
<a href="#faq">FAQ</a> ·
|
|
18
19
|
<a href="#中文说明">中文说明</a>
|
|
19
20
|
</p>
|
|
@@ -44,6 +45,8 @@ renamed/moved or deleted**. It can run on its own every morning and notify you.
|
|
|
44
45
|
skip it (use `--refresh` to scan it again).
|
|
45
46
|
- **Safe token storage.** `init` stores your token in the system keychain, not in a text file.
|
|
46
47
|
- **Optional git.** `sync --git` commits each sync in the save folder.
|
|
48
|
+
- **Made for scripts and AI agents.** Every command has `--json` output and stable exit codes,
|
|
49
|
+
and there's an agent skill / Claude Code plugin.
|
|
47
50
|
- **Read-only and private.** Only reads from Canvas; no telemetry, nothing leaves your machine.
|
|
48
51
|
- **Fast.** Courses are scanned in parallel (a 32-course account scans in about 10 s instead of
|
|
49
52
|
75 s), with parallel downloads, automatic retries with back-off and full pagination.
|
|
@@ -83,6 +86,50 @@ To get the latest unreleased code: `uv tool install git+https://github.com/gxxxr
|
|
|
83
86
|
| `canvas-pull schedule status \| run \| disable` | Show the schedule and its log, trigger it now, or remove it. |
|
|
84
87
|
| `canvas-pull config` | Show settings in use. |
|
|
85
88
|
|
|
89
|
+
## Use with AI agents
|
|
90
|
+
|
|
91
|
+
canvas-pull is a plain CLI, so AI coding agents (Claude Code, Codex, Cursor, …) can run it
|
|
92
|
+
directly. Ask *"anything new in my courses?"* or *"summarize this week's new slides"* and the
|
|
93
|
+
agent syncs, reads the changed files and answers.
|
|
94
|
+
|
|
95
|
+
**Install the skill** so your agent knows how to use it:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
/plugin install canvas-pull --marketplace gxxxr-111/canvas-pull
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The skill is in [`skills/canvas-pull/SKILL.md`](skills/canvas-pull/SKILL.md). Other agents that
|
|
102
|
+
support Agent Skills can use that file as is.
|
|
103
|
+
|
|
104
|
+
**JSON output.** Add `--json` to `status`, `sync`, `log`, `courses`, `config` or `init`, and stdout
|
|
105
|
+
holds exactly one JSON object, also on errors:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
canvas-pull log --since 7d --json | jq -r '.changes[] | select(.kind=="new") | .local_path'
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Each change has `kind`, `course`, `path`, `local_path` (and `old_path` for moves).
|
|
112
|
+
|
|
113
|
+
**Exit codes**
|
|
114
|
+
|
|
115
|
+
| Code | Meaning |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| 0 | OK |
|
|
118
|
+
| 1 | Network or Canvas error |
|
|
119
|
+
| 2 | Invalid arguments |
|
|
120
|
+
| 3 | Not configured / invalid settings |
|
|
121
|
+
| 4 | Token rejected or expired |
|
|
122
|
+
| 5 | Finished, but some downloads or courses failed |
|
|
123
|
+
| 6 | Another sync is running |
|
|
124
|
+
|
|
125
|
+
**Non-interactive setup** (CI, dotfiles, servers):
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
canvas-pull init --url oc.sjtu.edu.cn --save-dir ~/Canvas --token-stdin < token.txt
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Agents should never handle your token. Run `canvas-pull init` yourself.
|
|
132
|
+
|
|
86
133
|
## Configuration
|
|
87
134
|
|
|
88
135
|
`init` writes a config file (path shown by `canvas-pull config`). You can edit it:
|
|
@@ -184,6 +231,10 @@ No. It only sends read (`GET`) requests.
|
|
|
184
231
|
Create a new one in Canvas and run `canvas-pull init` again. Scheduled syncs show a notification
|
|
185
232
|
when the token is rejected.
|
|
186
233
|
|
|
234
|
+
**Can it put assignment deadlines in my calendar?**
|
|
235
|
+
Canvas already can. Open the Canvas **Calendar**, click **Calendar Feed**, and subscribe to
|
|
236
|
+
that link in Apple Calendar, Google Calendar or Outlook.
|
|
237
|
+
|
|
187
238
|
**How do I skip a course?**
|
|
188
239
|
Add its ID or part of its name to `exclude` in the config file (`canvas-pull courses` shows IDs).
|
|
189
240
|
|
|
@@ -242,4 +293,6 @@ canvas-pull schedule enable --at 08:00 # 每天早上自动同步,有变化
|
|
|
242
293
|
- 多门课并行扫描,32 门课大约 10 秒扫完。
|
|
243
294
|
- `sync --git` 会在保存目录里自动提交一次 git,方便用 `git log` 回看。
|
|
244
295
|
- 只读取 Canvas,不会修改任何内容;没有遥测,数据不离开你的电脑。
|
|
296
|
+
- 所有命令都支持 `--json` 输出和固定的退出码,方便脚本和 AI 助手调用。在 Claude Code 里运行 `/plugin install canvas-pull --marketplace gxxxr-111/canvas-pull` 安装技能后,就可以直接问「我的课程这周有什么更新?帮我总结新课件」。
|
|
297
|
+
- 作业截止日期:在 Canvas 的「日历」页面点「日历订阅源」,把链接加到系统日历即可,无需本工具。
|
|
245
298
|
- 在你的学校试过了?欢迎提交一个 [School report](https://github.com/gxxxr-111/canvas-pull/issues/new?template=school_report.yml),无论成功与否。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "canvas-pull"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.5.0"
|
|
4
4
|
description = "Sync your Canvas LMS course files: download only what changed and see what instructors added, modified, moved or deleted."
|
|
5
5
|
authors = [{ name = "Mirage_", email = "gaoxuran111@gmail.com" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
"""Command-line interface.
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
Every data command accepts --json: stdout then carries exactly one JSON object (also on
|
|
4
|
+
errors), and human-oriented output is switched off. Exit codes are stable; see `ExitCode`.
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
from __future__ import annotations
|
|
4
8
|
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
5
12
|
from collections import defaultdict
|
|
6
13
|
from contextlib import nullcontext
|
|
14
|
+
from enum import IntEnum
|
|
7
15
|
from pathlib import Path
|
|
8
|
-
from typing import Annotated
|
|
16
|
+
from typing import Annotated, Any
|
|
9
17
|
|
|
10
18
|
import typer
|
|
11
19
|
from rich.console import Console
|
|
@@ -15,7 +23,7 @@ from rich.table import Table
|
|
|
15
23
|
from . import __version__, notify
|
|
16
24
|
from . import schedule as sched
|
|
17
25
|
from .api import AuthError, CanvasClient, CanvasError
|
|
18
|
-
from .collector import CourseScan, course_dirnames, list_courses, scan_courses
|
|
26
|
+
from .collector import CourseScan, RemoteFile, course_dirnames, list_courses, scan_courses
|
|
19
27
|
from .config import (
|
|
20
28
|
DEFAULT_SAVE_DIR,
|
|
21
29
|
Config,
|
|
@@ -40,6 +48,31 @@ app = typer.Typer(
|
|
|
40
48
|
console = Console(highlight=False)
|
|
41
49
|
err = Console(stderr=True, highlight=False)
|
|
42
50
|
|
|
51
|
+
|
|
52
|
+
class ExitCode(IntEnum):
|
|
53
|
+
OK = 0
|
|
54
|
+
ERROR = 1 # network or Canvas error
|
|
55
|
+
USAGE = 2 # bad command-line arguments (Typer's own code)
|
|
56
|
+
CONFIG = 3 # not configured, or invalid settings
|
|
57
|
+
AUTH = 4 # Canvas rejected the token (invalid or expired)
|
|
58
|
+
PARTIAL = 5 # finished, but some downloads or courses failed
|
|
59
|
+
BUSY = 6 # another sync is running on the same folder
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_ERROR_TYPES = {
|
|
63
|
+
ExitCode.ERROR: "canvas",
|
|
64
|
+
ExitCode.CONFIG: "config",
|
|
65
|
+
ExitCode.AUTH: "auth",
|
|
66
|
+
ExitCode.BUSY: "busy",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
JsonOpt = Annotated[
|
|
70
|
+
bool,
|
|
71
|
+
typer.Option("--json", help="Print one JSON object on stdout (for scripts and AI agents)."),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
_json_mode = False
|
|
75
|
+
|
|
43
76
|
STYLE = {
|
|
44
77
|
Kind.NEW: ("+", "green"),
|
|
45
78
|
Kind.MODIFIED: ("~", "yellow"),
|
|
@@ -49,9 +82,25 @@ STYLE = {
|
|
|
49
82
|
}
|
|
50
83
|
|
|
51
84
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
85
|
+
def _start(json_: bool) -> None:
|
|
86
|
+
"""Called first by every command: in JSON mode, silence human output."""
|
|
87
|
+
global _json_mode
|
|
88
|
+
_json_mode = json_
|
|
89
|
+
console.quiet = json_
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _emit(data: dict[str, Any]) -> None:
|
|
93
|
+
sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
94
|
+
sys.stdout.flush()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fail(message: str, code: ExitCode = ExitCode.ERROR) -> typer.Exit:
|
|
98
|
+
if _json_mode:
|
|
99
|
+
kind = _ERROR_TYPES.get(code, "error")
|
|
100
|
+
_emit({"ok": False, "error": {"type": kind, "message": message, "exit_code": int(code)}})
|
|
101
|
+
else:
|
|
102
|
+
err.print(f"[red]Error:[/] {message}")
|
|
103
|
+
return typer.Exit(int(code))
|
|
55
104
|
|
|
56
105
|
|
|
57
106
|
def _pretty(path: Path) -> str:
|
|
@@ -66,7 +115,12 @@ def _load(require_token: bool = True) -> Config:
|
|
|
66
115
|
try:
|
|
67
116
|
return load_config(require_token=require_token)
|
|
68
117
|
except ConfigError as exc:
|
|
69
|
-
raise _fail(str(exc)) from None
|
|
118
|
+
raise _fail(str(exc), ExitCode.CONFIG) from None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _canvas_failure(exc: CanvasError) -> typer.Exit:
|
|
122
|
+
code = ExitCode.AUTH if isinstance(exc, AuthError) else ExitCode.ERROR
|
|
123
|
+
return _fail(str(exc), code)
|
|
70
124
|
|
|
71
125
|
|
|
72
126
|
def _matches(course: Course, patterns: list[str]) -> bool:
|
|
@@ -91,40 +145,85 @@ def main(
|
|
|
91
145
|
|
|
92
146
|
|
|
93
147
|
@app.command()
|
|
94
|
-
def init(
|
|
95
|
-
|
|
148
|
+
def init(
|
|
149
|
+
url: Annotated[
|
|
150
|
+
str | None, typer.Option("--url", help="Canvas site, e.g. oc.sjtu.edu.cn.")
|
|
151
|
+
] = None,
|
|
152
|
+
save_dir_opt: Annotated[
|
|
153
|
+
Path | None, typer.Option("--save-dir", help="Folder to save files in.")
|
|
154
|
+
] = None,
|
|
155
|
+
token_stdin: Annotated[
|
|
156
|
+
bool, typer.Option("--token-stdin", help="Read the access token from stdin.")
|
|
157
|
+
] = False,
|
|
158
|
+
no_input: Annotated[
|
|
159
|
+
bool, typer.Option("--no-input", help="Never prompt; fail if something is missing.")
|
|
160
|
+
] = False,
|
|
161
|
+
json_: JsonOpt = False,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Set your Canvas URL, save folder and access token.
|
|
164
|
+
|
|
165
|
+
Non-interactive: canvas-pull init --url oc.sjtu.edu.cn --save-dir ~/Canvas --token-stdin
|
|
166
|
+
(the token can also come from CANVAS_TOKEN).
|
|
167
|
+
"""
|
|
168
|
+
_start(json_)
|
|
169
|
+
interactive = not (no_input or json_ or token_stdin)
|
|
96
170
|
try:
|
|
97
171
|
current = load_config(require_token=False)
|
|
98
172
|
except ConfigError:
|
|
99
173
|
current = None
|
|
100
174
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
typer.prompt(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
175
|
+
def ask(label: str, default: str | None, hide: bool = False) -> str:
|
|
176
|
+
if not interactive:
|
|
177
|
+
if default:
|
|
178
|
+
return default
|
|
179
|
+
raise _fail(f"Missing {label}; pass it as an option.", ExitCode.CONFIG)
|
|
180
|
+
return typer.prompt(label, default=default, hide_input=hide)
|
|
181
|
+
|
|
182
|
+
if interactive:
|
|
183
|
+
console.print(
|
|
184
|
+
"Create a token in Canvas: [bold]Account → Settings → New Access Token[/].\n"
|
|
185
|
+
"The token is stored in your system keychain.\n"
|
|
113
186
|
)
|
|
114
|
-
|
|
115
|
-
.
|
|
187
|
+
website_raw = url or ask(
|
|
188
|
+
"Canvas URL", current.website if current else ("oc.sjtu.edu.cn" if interactive else None)
|
|
116
189
|
)
|
|
117
|
-
|
|
190
|
+
try:
|
|
191
|
+
website = normalize_website(website_raw)
|
|
192
|
+
except ConfigError as exc:
|
|
193
|
+
raise _fail(str(exc), ExitCode.CONFIG) from None
|
|
194
|
+
default_dir = str(current.save_dir) if current else DEFAULT_SAVE_DIR
|
|
195
|
+
save_dir = Path(save_dir_opt or ask("Save files to", default_dir)).expanduser().resolve()
|
|
196
|
+
|
|
197
|
+
if token_stdin:
|
|
198
|
+
token = sys.stdin.readline().strip()
|
|
199
|
+
else:
|
|
200
|
+
token = os.environ.get("CANVAS_TOKEN", "") or ask("Access token", None, hide=True)
|
|
201
|
+
token = token.strip()
|
|
202
|
+
if not token:
|
|
203
|
+
raise _fail("No access token given.", ExitCode.CONFIG)
|
|
118
204
|
|
|
119
205
|
try:
|
|
120
206
|
with CanvasClient(website, token) as client:
|
|
121
207
|
me = client.get("users/self")
|
|
122
208
|
except CanvasError as exc:
|
|
123
|
-
|
|
209
|
+
code = ExitCode.AUTH if isinstance(exc, AuthError) else ExitCode.ERROR
|
|
210
|
+
raise _fail(f"Could not sign in to {website}: {exc}", code) from None
|
|
124
211
|
|
|
125
212
|
cfg = current or Config(website=website, save_dir=save_dir)
|
|
126
213
|
cfg.website, cfg.save_dir, cfg.token = website, save_dir, token
|
|
127
214
|
where = save_config(cfg)
|
|
215
|
+
if json_:
|
|
216
|
+
_emit(
|
|
217
|
+
{
|
|
218
|
+
"ok": True,
|
|
219
|
+
"user": me.get("name"),
|
|
220
|
+
"website": website,
|
|
221
|
+
"save_dir": str(save_dir),
|
|
222
|
+
"config_file": str(default_config_file()),
|
|
223
|
+
"token_stored_in": where,
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
return
|
|
128
227
|
console.print(f"[green]✓[/] Signed in as [bold]{me.get('name', '?')}[/].")
|
|
129
228
|
console.print(f"Settings: {default_config_file()} · token: {where}")
|
|
130
229
|
console.print("Next: [bold]canvas-pull sync[/]")
|
|
@@ -137,16 +236,36 @@ def courses(
|
|
|
137
236
|
bool | None,
|
|
138
237
|
typer.Option("--completed/--no-completed", help="Include completed (concluded) courses."),
|
|
139
238
|
] = None,
|
|
239
|
+
json_: JsonOpt = False,
|
|
140
240
|
) -> None:
|
|
141
241
|
"""List the courses that would be synced."""
|
|
242
|
+
_start(json_)
|
|
142
243
|
cfg = _load()
|
|
143
244
|
with_completed = cfg.include_completed if completed is None else completed
|
|
144
245
|
try:
|
|
145
246
|
with _client(cfg) as client:
|
|
146
247
|
found = list_courses(client, include_past=all_, include_completed=with_completed)
|
|
147
248
|
except CanvasError as exc:
|
|
148
|
-
raise
|
|
149
|
-
|
|
249
|
+
raise _canvas_failure(exc) from None
|
|
250
|
+
|
|
251
|
+
if json_:
|
|
252
|
+
_emit(
|
|
253
|
+
{
|
|
254
|
+
"ok": True,
|
|
255
|
+
"courses": [
|
|
256
|
+
{
|
|
257
|
+
"id": c.id,
|
|
258
|
+
"name": c.label,
|
|
259
|
+
"code": c.course_code,
|
|
260
|
+
"term": c.term.name if c.term else None,
|
|
261
|
+
"completed": c.completed,
|
|
262
|
+
"excluded": _matches(c, cfg.exclude),
|
|
263
|
+
}
|
|
264
|
+
for c in found
|
|
265
|
+
],
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
return
|
|
150
269
|
table = Table(box=None, header_style="bold")
|
|
151
270
|
table.add_column("ID", justify="right")
|
|
152
271
|
table.add_column("Course")
|
|
@@ -203,10 +322,13 @@ def _scan(
|
|
|
203
322
|
_KIND_ORDER = {Kind.NEW: 0, Kind.MODIFIED: 1, Kind.MOVED: 2, Kind.RESTORED: 3, Kind.DELETED: 4}
|
|
204
323
|
|
|
205
324
|
|
|
325
|
+
def _sorted(changes: list[Change]) -> list[Change]:
|
|
326
|
+
return sorted(changes, key=lambda c: (c.course.lower(), _KIND_ORDER[c.kind], c.path.lower()))
|
|
327
|
+
|
|
328
|
+
|
|
206
329
|
def _print_changes(changes: list[Change]) -> None:
|
|
207
330
|
by_course: dict[str, list[Change]] = defaultdict(list)
|
|
208
|
-
|
|
209
|
-
for c in ordered:
|
|
331
|
+
for c in _sorted(changes):
|
|
210
332
|
by_course[c.course].append(c)
|
|
211
333
|
for course, items in by_course.items():
|
|
212
334
|
console.print(f"\n[bold]{course}[/]")
|
|
@@ -226,6 +348,43 @@ def _summary(changes: list[Change]) -> str:
|
|
|
226
348
|
return ", ".join(parts) if parts else "no changes"
|
|
227
349
|
|
|
228
350
|
|
|
351
|
+
def _counts(changes: list[Change]) -> dict[str, int]:
|
|
352
|
+
counts: dict[str, int] = defaultdict(int)
|
|
353
|
+
for c in changes:
|
|
354
|
+
counts[str(c.kind)] += 1
|
|
355
|
+
return dict(counts)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _change_json(c: Change, save_dir: Path) -> dict[str, Any]:
|
|
359
|
+
d = c.to_dict()
|
|
360
|
+
d["local_path"] = str(save_dir / c.path)
|
|
361
|
+
return d
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _sync_json(
|
|
365
|
+
*,
|
|
366
|
+
dry_run: bool,
|
|
367
|
+
save_dir: Path,
|
|
368
|
+
changes: list[Change],
|
|
369
|
+
failed: list[tuple[Change, str]] = (), # type: ignore[assignment]
|
|
370
|
+
locked: list[RemoteFile] = (), # type: ignore[assignment]
|
|
371
|
+
scans: list[CourseScan] = (), # type: ignore[assignment]
|
|
372
|
+
archived: int = 0,
|
|
373
|
+
) -> dict[str, Any]:
|
|
374
|
+
skipped = [{"course": s.course.label, "error": s.error} for s in scans if s.error]
|
|
375
|
+
return {
|
|
376
|
+
"ok": not failed and not skipped,
|
|
377
|
+
"dry_run": dry_run,
|
|
378
|
+
"save_dir": str(save_dir),
|
|
379
|
+
"summary": _counts(changes),
|
|
380
|
+
"changes": [_change_json(c, save_dir) for c in _sorted(changes)],
|
|
381
|
+
"failed": [{"course": c.course, "path": c.path, "error": e} for c, e in failed],
|
|
382
|
+
"locked": [{"course": rf.course.label, "path": rf.path} for rf in locked],
|
|
383
|
+
"skipped_courses": skipped,
|
|
384
|
+
"archived_courses_skipped": archived,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
229
388
|
def _alert(enabled: bool, message: str) -> None:
|
|
230
389
|
if enabled:
|
|
231
390
|
notify.send(message)
|
|
@@ -257,15 +416,17 @@ def sync(
|
|
|
257
416
|
refresh: Annotated[
|
|
258
417
|
bool, typer.Option("--refresh", help="Scan archived completed courses again.")
|
|
259
418
|
] = False,
|
|
419
|
+
json_: JsonOpt = False,
|
|
260
420
|
) -> None:
|
|
261
421
|
"""Download new and changed files from your active courses."""
|
|
262
|
-
|
|
422
|
+
_start(json_)
|
|
423
|
+
if not console.is_terminal and not dry_run and not json_:
|
|
263
424
|
console.print(f"--- {now_iso()} canvas-pull sync") # separator in scheduled-run logs
|
|
264
425
|
try:
|
|
265
426
|
cfg = load_config()
|
|
266
427
|
except ConfigError as exc:
|
|
267
428
|
_alert(notify_, f"Sync failed: {exc}")
|
|
268
|
-
raise _fail(str(exc)) from None
|
|
429
|
+
raise _fail(str(exc), ExitCode.CONFIG) from None
|
|
269
430
|
use_git = cfg.git if git is None else git
|
|
270
431
|
with_completed = cfg.include_completed if completed is None else completed
|
|
271
432
|
manifest = Manifest.load(cfg.save_dir)
|
|
@@ -284,22 +445,46 @@ def sync(
|
|
|
284
445
|
if not scans:
|
|
285
446
|
if not archived:
|
|
286
447
|
console.print("No matching active courses.")
|
|
448
|
+
if json_:
|
|
449
|
+
_emit(
|
|
450
|
+
_sync_json(
|
|
451
|
+
dry_run=dry_run, save_dir=cfg.save_dir, changes=[], archived=archived
|
|
452
|
+
)
|
|
453
|
+
)
|
|
287
454
|
return
|
|
288
455
|
plan = make_plan(scans, manifest, cfg.save_dir)
|
|
289
456
|
|
|
290
457
|
for s in scans:
|
|
291
458
|
if s.error:
|
|
292
|
-
|
|
459
|
+
if not json_:
|
|
460
|
+
err.print(f"[yellow]![/] Skipped {s.course.label}: {s.error}")
|
|
293
461
|
elif s.files_tab_hidden:
|
|
294
462
|
console.print(
|
|
295
463
|
f"[dim]{s.course.label}: Files tab hidden; used Modules instead.[/]"
|
|
296
464
|
)
|
|
465
|
+
scan_failed = any(s.error for s in scans)
|
|
297
466
|
|
|
298
467
|
if dry_run:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
468
|
+
if json_:
|
|
469
|
+
_emit(
|
|
470
|
+
_sync_json(
|
|
471
|
+
dry_run=True,
|
|
472
|
+
save_dir=cfg.save_dir,
|
|
473
|
+
changes=plan.changes,
|
|
474
|
+
locked=plan.locked,
|
|
475
|
+
scans=scans,
|
|
476
|
+
archived=archived,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
else:
|
|
480
|
+
_print_changes(plan.changes)
|
|
481
|
+
console.print(f"\n{_summary(plan.changes)} · {_pretty(cfg.save_dir)}")
|
|
482
|
+
if plan.locked:
|
|
483
|
+
console.print(
|
|
484
|
+
f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]"
|
|
485
|
+
)
|
|
486
|
+
if scan_failed:
|
|
487
|
+
raise typer.Exit(int(ExitCode.PARTIAL))
|
|
303
488
|
return
|
|
304
489
|
|
|
305
490
|
total = sum(1 for c in plan.changes if c.needs_download)
|
|
@@ -328,39 +513,54 @@ def sync(
|
|
|
328
513
|
finally:
|
|
329
514
|
manifest.save()
|
|
330
515
|
except SyncLocked:
|
|
331
|
-
raise _fail("Another sync is already running for this folder.") from None
|
|
516
|
+
raise _fail("Another sync is already running for this folder.", ExitCode.BUSY) from None
|
|
332
517
|
except AuthError as exc:
|
|
333
518
|
_alert(notify_, "Sync failed: Canvas rejected your token. Run `canvas-pull init`.")
|
|
334
|
-
raise
|
|
519
|
+
raise _canvas_failure(exc) from None
|
|
335
520
|
except CanvasError as exc:
|
|
336
521
|
if exc.status is not None: # stay quiet when simply offline
|
|
337
522
|
_alert(notify_, f"Sync failed: {exc}")
|
|
338
|
-
raise
|
|
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.[/]")
|
|
523
|
+
raise _canvas_failure(exc) from None
|
|
346
524
|
|
|
347
525
|
if result.applied:
|
|
348
526
|
record = {"time": now_iso(), "changes": [c.to_dict() for c in result.applied]}
|
|
349
527
|
append_log(cfg.save_dir, record)
|
|
528
|
+
git_summary = None
|
|
350
529
|
if use_git:
|
|
351
530
|
try:
|
|
352
|
-
|
|
353
|
-
if summary:
|
|
354
|
-
console.print(f"[dim]git: {summary}[/]")
|
|
531
|
+
git_summary = commit_sync(cfg.save_dir, result.applied)
|
|
355
532
|
except GitError as exc:
|
|
356
|
-
|
|
533
|
+
if not json_:
|
|
534
|
+
err.print(f"[yellow]git commit skipped:[/] {exc}")
|
|
357
535
|
if result.applied or result.failed:
|
|
358
536
|
message = notify.describe(result.applied)
|
|
359
537
|
if result.failed:
|
|
360
538
|
message = f"{message} · {len(result.failed)} failed".lstrip(" ·")
|
|
361
539
|
_alert(notify_, message)
|
|
362
|
-
|
|
363
|
-
|
|
540
|
+
|
|
541
|
+
if json_:
|
|
542
|
+
data = _sync_json(
|
|
543
|
+
dry_run=False,
|
|
544
|
+
save_dir=cfg.save_dir,
|
|
545
|
+
changes=result.applied,
|
|
546
|
+
failed=result.failed,
|
|
547
|
+
locked=plan.locked,
|
|
548
|
+
scans=scans,
|
|
549
|
+
archived=archived,
|
|
550
|
+
)
|
|
551
|
+
data["git_commit"] = git_summary
|
|
552
|
+
_emit(data)
|
|
553
|
+
else:
|
|
554
|
+
_print_changes(result.applied)
|
|
555
|
+
for change, reason in result.failed:
|
|
556
|
+
err.print(f"[red]✗[/] {change.path}: {reason}")
|
|
557
|
+
console.print(f"\n{_summary(result.applied)} · {_pretty(cfg.save_dir)}")
|
|
558
|
+
if plan.locked:
|
|
559
|
+
console.print(f"[dim]{len(plan.locked)} locked file(s) not yet available.[/]")
|
|
560
|
+
if git_summary:
|
|
561
|
+
console.print(f"[dim]git: {git_summary}[/]")
|
|
562
|
+
if result.failed or scan_failed:
|
|
563
|
+
raise typer.Exit(int(ExitCode.PARTIAL))
|
|
364
564
|
|
|
365
565
|
|
|
366
566
|
@app.command()
|
|
@@ -375,6 +575,7 @@ def status(
|
|
|
375
575
|
refresh: Annotated[
|
|
376
576
|
bool, typer.Option("--refresh", help="Scan archived completed courses again.")
|
|
377
577
|
] = False,
|
|
578
|
+
json_: JsonOpt = False,
|
|
378
579
|
) -> None:
|
|
379
580
|
"""Show what changed on Canvas since the last sync (downloads nothing)."""
|
|
380
581
|
sync(
|
|
@@ -385,6 +586,7 @@ def status(
|
|
|
385
586
|
git=False,
|
|
386
587
|
notify_=False,
|
|
387
588
|
refresh=refresh,
|
|
589
|
+
json_=json_,
|
|
388
590
|
)
|
|
389
591
|
|
|
390
592
|
|
|
@@ -397,15 +599,41 @@ def log(
|
|
|
397
599
|
"--since", "-s", help="Net summary since 7d, 24h, 2w, today or a date (2026-10-01)."
|
|
398
600
|
),
|
|
399
601
|
] = None,
|
|
602
|
+
json_: JsonOpt = False,
|
|
400
603
|
) -> None:
|
|
401
604
|
"""Show recent syncs, or a merged summary with --since (e.g. --since 7d)."""
|
|
605
|
+
_start(json_)
|
|
402
606
|
cfg = _load(require_token=False)
|
|
403
607
|
if since:
|
|
404
608
|
try:
|
|
405
609
|
start = parse_since(since)
|
|
406
610
|
except ValueError as exc:
|
|
407
|
-
raise _fail(str(exc)) from None
|
|
611
|
+
raise _fail(str(exc), ExitCode.USAGE) from None
|
|
408
612
|
net = summarize(read_log(cfg.save_dir, None), start)
|
|
613
|
+
if json_:
|
|
614
|
+
counts: dict[str, int] = defaultdict(int)
|
|
615
|
+
for n in net:
|
|
616
|
+
counts[n.kind] += 1
|
|
617
|
+
_emit(
|
|
618
|
+
{
|
|
619
|
+
"ok": True,
|
|
620
|
+
"since": start.isoformat(),
|
|
621
|
+
"save_dir": str(cfg.save_dir),
|
|
622
|
+
"summary": dict(counts),
|
|
623
|
+
"changes": [
|
|
624
|
+
{
|
|
625
|
+
"kind": n.kind,
|
|
626
|
+
"course": n.course,
|
|
627
|
+
"path": n.path,
|
|
628
|
+
**({"old_path": n.old_path} if n.old_path else {}),
|
|
629
|
+
"time": n.time,
|
|
630
|
+
"local_path": str(cfg.save_dir / n.path),
|
|
631
|
+
}
|
|
632
|
+
for n in net
|
|
633
|
+
],
|
|
634
|
+
}
|
|
635
|
+
)
|
|
636
|
+
return
|
|
409
637
|
local = start.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
410
638
|
if not net:
|
|
411
639
|
console.print(f"No changes since {local}.")
|
|
@@ -415,10 +643,10 @@ def log(
|
|
|
415
643
|
for n in net:
|
|
416
644
|
by_course[n.course].append(n)
|
|
417
645
|
for course_name, items in by_course.items():
|
|
418
|
-
|
|
646
|
+
tally_counts: dict[str, int] = defaultdict(int)
|
|
419
647
|
for n in items:
|
|
420
|
-
|
|
421
|
-
tally = ", ".join(f"{v} {k}" for k, v in
|
|
648
|
+
tally_counts[n.kind] += 1
|
|
649
|
+
tally = ", ".join(f"{v} {k}" for k, v in tally_counts.items())
|
|
422
650
|
console.print(f"\n[bold]{course_name}[/] [dim]{tally}[/]")
|
|
423
651
|
for n in items:
|
|
424
652
|
mark, color = STYLE[Kind(n.kind)]
|
|
@@ -429,6 +657,9 @@ def log(
|
|
|
429
657
|
return
|
|
430
658
|
|
|
431
659
|
records = read_log(cfg.save_dir, limit)
|
|
660
|
+
if json_:
|
|
661
|
+
_emit({"ok": True, "save_dir": str(cfg.save_dir), "syncs": list(reversed(records))})
|
|
662
|
+
return
|
|
432
663
|
if not records:
|
|
433
664
|
console.print("No syncs recorded yet.")
|
|
434
665
|
return
|
|
@@ -442,25 +673,34 @@ def log(
|
|
|
442
673
|
|
|
443
674
|
|
|
444
675
|
@app.command("config")
|
|
445
|
-
def show_config() -> None:
|
|
676
|
+
def show_config(json_: JsonOpt = False) -> None:
|
|
446
677
|
"""Show the settings in use (token hidden)."""
|
|
678
|
+
_start(json_)
|
|
447
679
|
cfg = _load(require_token=False)
|
|
448
|
-
|
|
680
|
+
values: dict[str, Any] = {
|
|
681
|
+
"config_file": str(default_config_file()),
|
|
682
|
+
"website": cfg.website,
|
|
683
|
+
"save_dir": str(cfg.save_dir),
|
|
684
|
+
"token": "set" if cfg.token else "missing",
|
|
685
|
+
"concurrency": cfg.concurrency,
|
|
686
|
+
"scan_concurrency": cfg.scan_concurrency,
|
|
687
|
+
"keep_history": cfg.keep_history,
|
|
688
|
+
"git": cfg.git,
|
|
689
|
+
"exclude": cfg.exclude,
|
|
690
|
+
"include_completed": cfg.include_completed,
|
|
691
|
+
"sources": cfg.sources,
|
|
692
|
+
}
|
|
693
|
+
if json_:
|
|
694
|
+
_emit({"ok": True, **values})
|
|
695
|
+
return
|
|
449
696
|
table = Table(box=None, show_header=False)
|
|
450
|
-
for key, value in
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
(
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
("
|
|
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)
|
|
697
|
+
for key, value in values.items():
|
|
698
|
+
if key == "save_dir":
|
|
699
|
+
value = _pretty(cfg.save_dir)
|
|
700
|
+
elif isinstance(value, list):
|
|
701
|
+
value = ", ".join(value) or "-"
|
|
702
|
+
label = "config file" if key == "config_file" else key
|
|
703
|
+
table.add_row(f"[bold]{label}[/]", str(value))
|
|
464
704
|
console.print(table)
|
|
465
705
|
|
|
466
706
|
|
|
@@ -482,12 +722,13 @@ def schedule_enable(
|
|
|
482
722
|
] = None,
|
|
483
723
|
) -> None:
|
|
484
724
|
"""Install (or replace) the automatic sync."""
|
|
725
|
+
_start(False)
|
|
485
726
|
_load() # fail now, not silently at 9am
|
|
486
727
|
try:
|
|
487
728
|
spec = sched.parse_spec(at or [], every)
|
|
488
729
|
path = sched.enable(spec, workdir=Path.cwd())
|
|
489
730
|
except sched.ScheduleError as exc:
|
|
490
|
-
raise _fail(str(exc)) from None
|
|
731
|
+
raise _fail(str(exc), ExitCode.CONFIG) from None
|
|
491
732
|
cmd = sched.command()
|
|
492
733
|
console.print(f"[green]✓[/] canvas-pull will sync [bold]{spec.describe()}[/].")
|
|
493
734
|
console.print(f"[dim]Job: {path}\nCommand: {' '.join(cmd)}\nLog: {sched.log_file()}[/]")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import shutil
|
|
2
3
|
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
3
5
|
|
|
4
6
|
import pytest
|
|
5
7
|
from typer.testing import CliRunner
|
|
@@ -74,7 +76,7 @@ def test_git_commit(env, monkeypatch):
|
|
|
74
76
|
def test_bad_token_message(env, monkeypatch):
|
|
75
77
|
monkeypatch.setenv("CANVAS_TOKEN", "expired")
|
|
76
78
|
r = runner.invoke(cli.app, ["sync"])
|
|
77
|
-
assert r.exit_code ==
|
|
79
|
+
assert r.exit_code == 4 and "token" in r.output.lower()
|
|
78
80
|
|
|
79
81
|
|
|
80
82
|
def test_notify_on_changes_and_on_bad_token(env, monkeypatch):
|
|
@@ -139,3 +141,104 @@ def _loader(cfg_file):
|
|
|
139
141
|
from canvas_pull.config import load_config
|
|
140
142
|
|
|
141
143
|
return lambda **kw: load_config(config_file=cfg_file, use_keyring=False, **kw)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _json(result):
|
|
147
|
+
import json
|
|
148
|
+
|
|
149
|
+
return json.loads(result.stdout)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_json_status_and_sync(env):
|
|
153
|
+
fake, out = env
|
|
154
|
+
r = runner.invoke(cli.app, ["status", "--json"])
|
|
155
|
+
assert r.exit_code == 0, r.output
|
|
156
|
+
data = _json(r)
|
|
157
|
+
assert data["ok"] and data["dry_run"] and data["summary"] == {"new": 1}
|
|
158
|
+
assert data["changes"][0]["path"] == "Algorithms/hw1.pdf"
|
|
159
|
+
|
|
160
|
+
r = runner.invoke(cli.app, ["sync", "--json"])
|
|
161
|
+
data = _json(r)
|
|
162
|
+
assert data["ok"] and not data["dry_run"]
|
|
163
|
+
change = data["changes"][0]
|
|
164
|
+
assert change["kind"] == "new" and Path(change["local_path"]).read_bytes() == b"one"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_json_log_courses_config(env):
|
|
168
|
+
fake, _ = env
|
|
169
|
+
runner.invoke(cli.app, ["sync"])
|
|
170
|
+
assert _json(runner.invoke(cli.app, ["log", "--since", "7d", "--json"]))["summary"] == {
|
|
171
|
+
"new": 1
|
|
172
|
+
}
|
|
173
|
+
assert len(_json(runner.invoke(cli.app, ["log", "--json"]))["syncs"]) == 1
|
|
174
|
+
courses = _json(runner.invoke(cli.app, ["courses", "--json"]))["courses"]
|
|
175
|
+
assert courses == [
|
|
176
|
+
{
|
|
177
|
+
"id": 1,
|
|
178
|
+
"name": "Algorithms",
|
|
179
|
+
"code": None,
|
|
180
|
+
"term": "Fall",
|
|
181
|
+
"completed": False,
|
|
182
|
+
"excluded": False,
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
cfg = _json(runner.invoke(cli.app, ["config", "--json"]))
|
|
186
|
+
assert cfg["token"] == "set" and "good-token" not in json.dumps(cfg)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_json_errors_and_exit_codes(env, monkeypatch):
|
|
190
|
+
monkeypatch.setenv("CANVAS_TOKEN", "expired")
|
|
191
|
+
r = runner.invoke(cli.app, ["status", "--json"])
|
|
192
|
+
assert r.exit_code == 4
|
|
193
|
+
assert _json(r)["error"]["type"] == "auth"
|
|
194
|
+
|
|
195
|
+
r = runner.invoke(cli.app, ["log", "--since", "soon", "--json"])
|
|
196
|
+
assert r.exit_code == 2
|
|
197
|
+
|
|
198
|
+
monkeypatch.delenv("CANVAS_URL")
|
|
199
|
+
r = runner.invoke(cli.app, ["courses", "--json"])
|
|
200
|
+
assert r.exit_code == 3 and _json(r)["error"]["type"] == "config"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_partial_failure_exit_code(env):
|
|
204
|
+
fake, _ = env
|
|
205
|
+
fake.flaky["/api/v1/courses/1/folders"] = 99 # this course can't be scanned
|
|
206
|
+
r = runner.invoke(cli.app, ["sync", "--json"])
|
|
207
|
+
assert r.exit_code == 5
|
|
208
|
+
data = _json(r)
|
|
209
|
+
assert not data["ok"] and data["skipped_courses"][0]["course"] == "Algorithms"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_busy_exit_code(env):
|
|
213
|
+
from canvas_pull.manifest import sync_lock
|
|
214
|
+
|
|
215
|
+
_, out = env
|
|
216
|
+
with sync_lock(out):
|
|
217
|
+
r = runner.invoke(cli.app, ["sync", "--json"])
|
|
218
|
+
assert r.exit_code == 6 and _json(r)["error"]["type"] == "busy"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_init_non_interactive(env, monkeypatch, tmp_path):
|
|
222
|
+
import keyring
|
|
223
|
+
|
|
224
|
+
store = {}
|
|
225
|
+
monkeypatch.setattr(keyring, "set_password", lambda s, u, p: store.__setitem__(u, p))
|
|
226
|
+
cfg_file = tmp_path / "written.toml"
|
|
227
|
+
monkeypatch.setattr(config, "default_config_file", lambda: cfg_file)
|
|
228
|
+
monkeypatch.setattr(cli, "default_config_file", lambda: cfg_file)
|
|
229
|
+
monkeypatch.delenv("CANVAS_TOKEN")
|
|
230
|
+
r = runner.invoke(
|
|
231
|
+
cli.app,
|
|
232
|
+
["init", "--url", BASE, "--save-dir", str(tmp_path / "c"), "--token-stdin", "--json"],
|
|
233
|
+
input="good-token\n",
|
|
234
|
+
)
|
|
235
|
+
assert r.exit_code == 0, r.output
|
|
236
|
+
data = _json(r)
|
|
237
|
+
assert data["user"] == "Student" and data["token_stored_in"] == "keychain"
|
|
238
|
+
assert store == {BASE: "good-token"} and "good-token" not in cfg_file.read_text()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_init_no_input_reports_missing(env, monkeypatch):
|
|
242
|
+
monkeypatch.delenv("CANVAS_URL")
|
|
243
|
+
r = runner.invoke(cli.app, ["init", "--no-input", "--json"])
|
|
244
|
+
assert r.exit_code == 3 and "Canvas URL" in _json(r)["error"]["message"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|