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