get-class-material 0.2.3__tar.gz → 0.2.4__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.
- {get_class_material-0.2.3 → get_class_material-0.2.4}/PKG-INFO +1 -1
- {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/PKG-INFO +1 -1
- {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/SOURCES.txt +1 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/doctor.py +114 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/pyproject.toml +1 -1
- get_class_material-0.2.4/tests/test_doctor.py +93 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/LICENSE +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/README.md +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/dependency_links.txt +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/entry_points.txt +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/requires.txt +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/top_level.txt +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/__init__.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/__main__.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/announcements.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/browser_session.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/canvas_client.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/cli.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/cool_video.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/course_pipeline.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/i18n.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/media_naming.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/pages.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/session_client.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/storage.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/sync.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/text_extract.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/youtube_cookies.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/setup.cfg +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_announcements.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_browser_session.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_canvas_client.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_cool_video.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_media_naming.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_session_client.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_storage.py +0 -0
- {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_youtube_cookies.py +0 -0
|
@@ -14,10 +14,12 @@ print the right command for the user to run themselves.
|
|
|
14
14
|
"""
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import os
|
|
17
18
|
import platform
|
|
18
19
|
import shutil
|
|
19
20
|
import subprocess
|
|
20
21
|
import sys
|
|
22
|
+
import sysconfig
|
|
21
23
|
import time
|
|
22
24
|
from dataclasses import dataclass
|
|
23
25
|
from pathlib import Path
|
|
@@ -123,6 +125,75 @@ def _install_ffmpeg_auto() -> bool:
|
|
|
123
125
|
return False
|
|
124
126
|
|
|
125
127
|
|
|
128
|
+
def _scripts_dir() -> Path | None:
|
|
129
|
+
"""Where pip drops console-script .exe shims for this Python install."""
|
|
130
|
+
try:
|
|
131
|
+
path = sysconfig.get_path("scripts")
|
|
132
|
+
except Exception:
|
|
133
|
+
return None
|
|
134
|
+
return Path(path) if path else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalized_path_entries() -> list[str]:
|
|
138
|
+
raw = os.environ.get("PATH", "")
|
|
139
|
+
out: list[str] = []
|
|
140
|
+
for entry in raw.split(os.pathsep):
|
|
141
|
+
if not entry:
|
|
142
|
+
continue
|
|
143
|
+
out.append(os.path.normcase(os.path.normpath(entry)))
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _scripts_on_path(scripts_dir: Path) -> bool:
|
|
148
|
+
target = os.path.normcase(os.path.normpath(str(scripts_dir)))
|
|
149
|
+
return target in _normalized_path_entries()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _add_scripts_to_user_path(scripts_dir: Path) -> bool:
|
|
153
|
+
"""Append scripts_dir to the current user's PATH (Windows only).
|
|
154
|
+
|
|
155
|
+
We bypass `setx` for two reasons:
|
|
156
|
+
1. setx truncates PATH at 1024 chars (legacy registry limit).
|
|
157
|
+
2. setx reads from the process's PATH (Machine + User merged), so its
|
|
158
|
+
"write" actually corrupts the User scope with Machine entries.
|
|
159
|
+
`[Environment]::SetEnvironmentVariable(..., 'User')` reads + writes the
|
|
160
|
+
HKCU\\Environment hive cleanly, no length limit, no admin required.
|
|
161
|
+
The change only affects newly-launched processes — the current PowerShell
|
|
162
|
+
won't see it until reopened, which is fine for our messaging.
|
|
163
|
+
"""
|
|
164
|
+
if platform.system() != "Windows":
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
scripts = str(scripts_dir)
|
|
168
|
+
# Single-quote the path inside the PowerShell literal; PowerShell single
|
|
169
|
+
# quotes don't expand $vars and treat backslashes literally, so Windows
|
|
170
|
+
# paths drop in as-is. Embedded apostrophes (rare in usernames) get
|
|
171
|
+
# doubled-up to escape them.
|
|
172
|
+
ps_scripts = scripts.replace("'", "''")
|
|
173
|
+
ps_script = (
|
|
174
|
+
f"$scripts = '{ps_scripts}'; "
|
|
175
|
+
"$user = [Environment]::GetEnvironmentVariable('PATH', 'User'); "
|
|
176
|
+
"if (-not $user) { $user = '' }; "
|
|
177
|
+
"$entries = @($user -split ';' | Where-Object { $_ -ne '' }); "
|
|
178
|
+
"if ($entries -notcontains $scripts) { "
|
|
179
|
+
" if ($user) { [Environment]::SetEnvironmentVariable('PATH', $user.TrimEnd(';') + ';' + $scripts, 'User') } "
|
|
180
|
+
" else { [Environment]::SetEnvironmentVariable('PATH', $scripts, 'User') }; "
|
|
181
|
+
" Write-Output 'added' "
|
|
182
|
+
"} else { Write-Output 'already-present' }"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
ok = _stream_subprocess(
|
|
186
|
+
["powershell", "-NoProfile", "-Command", ps_script],
|
|
187
|
+
f"add {scripts_dir} to user PATH",
|
|
188
|
+
)
|
|
189
|
+
if ok:
|
|
190
|
+
print(t(
|
|
191
|
+
" ⚠ 請關掉這個視窗、重新打開,之後就能直接打 `ntu-cool-gcm` 啟動。",
|
|
192
|
+
" ⚠ Close this window, reopen it, then `ntu-cool-gcm` will work directly.",
|
|
193
|
+
))
|
|
194
|
+
return ok
|
|
195
|
+
|
|
196
|
+
|
|
126
197
|
# ---- checks ----
|
|
127
198
|
|
|
128
199
|
def check_python() -> CheckResult:
|
|
@@ -226,6 +297,43 @@ def check_ffmpeg() -> CheckResult:
|
|
|
226
297
|
)
|
|
227
298
|
|
|
228
299
|
|
|
300
|
+
def check_scripts_on_path() -> CheckResult:
|
|
301
|
+
"""Python's `Scripts\\` dir must be on PATH or pip's entry-point shims
|
|
302
|
+
(ntu-cool-gcm.exe etc.) won't resolve from a fresh shell.
|
|
303
|
+
|
|
304
|
+
NOTE: keep the `name` string fixed (not localized) — it's used as a
|
|
305
|
+
set-membership key by `ensure_ready`'s `recommended_names`. The rest
|
|
306
|
+
of the user-facing strings here can vary by locale.
|
|
307
|
+
"""
|
|
308
|
+
name = "Python Scripts 在 PATH 上"
|
|
309
|
+
if platform.system() != "Windows":
|
|
310
|
+
# On macOS/Linux, pip --user goes to a different dir (~/.local/bin or
|
|
311
|
+
# /opt/homebrew/bin) and the symptoms / fixes are different enough
|
|
312
|
+
# that we leave this to the OS package manager and shell rc files.
|
|
313
|
+
return CheckResult(name=name, ok=True, optional=True, detail="(non-Windows, skipped)")
|
|
314
|
+
|
|
315
|
+
scripts_dir = _scripts_dir()
|
|
316
|
+
if scripts_dir is None:
|
|
317
|
+
return CheckResult(name=name, ok=True, optional=True, detail="(scripts dir unknown)")
|
|
318
|
+
|
|
319
|
+
on_path = _scripts_on_path(scripts_dir)
|
|
320
|
+
if on_path:
|
|
321
|
+
return CheckResult(name=name, ok=True, detail=str(scripts_dir))
|
|
322
|
+
|
|
323
|
+
return CheckResult(
|
|
324
|
+
name=name,
|
|
325
|
+
ok=False,
|
|
326
|
+
optional=True,
|
|
327
|
+
detail=f"{scripts_dir} 不在 PATH 上",
|
|
328
|
+
fix_command=(
|
|
329
|
+
f'powershell -NoProfile -Command "[Environment]::SetEnvironmentVariable('
|
|
330
|
+
f"'PATH', [Environment]::GetEnvironmentVariable('PATH', 'User').TrimEnd(';') + "
|
|
331
|
+
f"';{scripts_dir}', 'User')\""
|
|
332
|
+
),
|
|
333
|
+
auto_install=lambda: _add_scripts_to_user_path(scripts_dir),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
229
337
|
def check_ntu_session(headers_path: Path) -> CheckResult:
|
|
230
338
|
if not headers_path.exists():
|
|
231
339
|
return CheckResult(
|
|
@@ -271,6 +379,7 @@ def _all_checks(headers_path: Path, youtube_cookies_path: Path) -> list[CheckRes
|
|
|
271
379
|
check_yt_dlp(),
|
|
272
380
|
check_node(),
|
|
273
381
|
check_ffmpeg(),
|
|
382
|
+
check_scripts_on_path(),
|
|
274
383
|
check_ntu_session(headers_path),
|
|
275
384
|
check_youtube_cookies(youtube_cookies_path),
|
|
276
385
|
]
|
|
@@ -356,6 +465,11 @@ def ensure_ready(
|
|
|
356
465
|
recommended_names = {
|
|
357
466
|
"Node.js (下載 YouTube 影片用)",
|
|
358
467
|
"ffmpeg (下載 YouTube 影片用)",
|
|
468
|
+
# Not blocking — if Scripts isn't on PATH, the user invoked us via
|
|
469
|
+
# `python -m ntu_cool_materials pick` and the pick already works.
|
|
470
|
+
# The check exists to upgrade that to the shorter `ntu-cool-gcm`
|
|
471
|
+
# for future runs.
|
|
472
|
+
"Python Scripts 在 PATH 上",
|
|
359
473
|
}
|
|
360
474
|
relevant = blocking_names | recommended_names
|
|
361
475
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "get-class-material"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "One-command bulk downloader for NTU COOL (Canvas) course materials — PDFs, lecture videos, and Pages."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Tests for the PATH-detection bits of doctor.py.
|
|
2
|
+
|
|
3
|
+
These avoid actually invoking subprocesses or modifying real PATH — we just
|
|
4
|
+
patch `os.environ["PATH"]` and call the pure helpers.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
from ntu_cool_materials import doctor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ScriptsOnPathTests(unittest.TestCase):
|
|
17
|
+
def test_exact_match(self) -> None:
|
|
18
|
+
scripts = Path(r"C:\Python\Scripts")
|
|
19
|
+
with mock.patch.dict(os.environ, {"PATH": r"C:\Windows;C:\Python\Scripts;C:\Other"}):
|
|
20
|
+
self.assertTrue(doctor._scripts_on_path(scripts))
|
|
21
|
+
|
|
22
|
+
def test_missing(self) -> None:
|
|
23
|
+
scripts = Path(r"C:\Python\Scripts")
|
|
24
|
+
with mock.patch.dict(os.environ, {"PATH": r"C:\Windows;C:\Other"}):
|
|
25
|
+
self.assertFalse(doctor._scripts_on_path(scripts))
|
|
26
|
+
|
|
27
|
+
def test_case_insensitive_on_windows(self) -> None:
|
|
28
|
+
# normcase lowercases on Windows; os.pathsep + paths come back upper-cased
|
|
29
|
+
# from the registry sometimes, so we need to match regardless of case.
|
|
30
|
+
scripts = Path(r"C:\Python\Scripts")
|
|
31
|
+
with mock.patch.dict(os.environ, {"PATH": r"C:\WINDOWS;c:\python\scripts;C:\Other"}):
|
|
32
|
+
if os.name == "nt":
|
|
33
|
+
self.assertTrue(doctor._scripts_on_path(scripts))
|
|
34
|
+
else:
|
|
35
|
+
# POSIX is case-sensitive — the test is only meaningful on Windows.
|
|
36
|
+
self.assertFalse(doctor._scripts_on_path(scripts))
|
|
37
|
+
|
|
38
|
+
def test_trailing_separator_tolerated(self) -> None:
|
|
39
|
+
scripts = Path(r"C:\Python\Scripts")
|
|
40
|
+
# Trailing backslash should normpath away.
|
|
41
|
+
with mock.patch.dict(os.environ, {"PATH": r"C:\Python\Scripts\;C:\Other"}):
|
|
42
|
+
self.assertTrue(doctor._scripts_on_path(scripts))
|
|
43
|
+
|
|
44
|
+
def test_empty_path_entries_ignored(self) -> None:
|
|
45
|
+
scripts = Path(r"C:\Python\Scripts")
|
|
46
|
+
# Empty entries from leading/trailing/double separators should not crash
|
|
47
|
+
# or false-match.
|
|
48
|
+
with mock.patch.dict(os.environ, {"PATH": f";;{scripts};;"}):
|
|
49
|
+
self.assertTrue(doctor._scripts_on_path(scripts))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CheckScriptsOnPathTests(unittest.TestCase):
|
|
53
|
+
def test_non_windows_returns_ok(self) -> None:
|
|
54
|
+
with mock.patch.object(doctor.platform, "system", return_value="Darwin"):
|
|
55
|
+
result = doctor.check_scripts_on_path()
|
|
56
|
+
self.assertTrue(result.ok)
|
|
57
|
+
self.assertTrue(result.optional)
|
|
58
|
+
|
|
59
|
+
def test_windows_on_path(self) -> None:
|
|
60
|
+
fake_scripts = Path(r"C:\Python\Scripts")
|
|
61
|
+
with (
|
|
62
|
+
mock.patch.object(doctor.platform, "system", return_value="Windows"),
|
|
63
|
+
mock.patch.object(doctor, "_scripts_dir", return_value=fake_scripts),
|
|
64
|
+
mock.patch.dict(os.environ, {"PATH": str(fake_scripts) + ";C:\\Other"}),
|
|
65
|
+
):
|
|
66
|
+
result = doctor.check_scripts_on_path()
|
|
67
|
+
self.assertTrue(result.ok)
|
|
68
|
+
self.assertEqual(result.detail, str(fake_scripts))
|
|
69
|
+
|
|
70
|
+
def test_windows_missing_offers_auto_install(self) -> None:
|
|
71
|
+
fake_scripts = Path(r"C:\Python\Scripts")
|
|
72
|
+
with (
|
|
73
|
+
mock.patch.object(doctor.platform, "system", return_value="Windows"),
|
|
74
|
+
mock.patch.object(doctor, "_scripts_dir", return_value=fake_scripts),
|
|
75
|
+
mock.patch.dict(os.environ, {"PATH": r"C:\Windows;C:\Other"}),
|
|
76
|
+
):
|
|
77
|
+
result = doctor.check_scripts_on_path()
|
|
78
|
+
self.assertFalse(result.ok)
|
|
79
|
+
self.assertTrue(result.optional) # not blocking
|
|
80
|
+
self.assertIsNotNone(result.auto_install)
|
|
81
|
+
self.assertIn(str(fake_scripts), result.fix_command)
|
|
82
|
+
|
|
83
|
+
def test_name_is_stable_for_set_membership(self) -> None:
|
|
84
|
+
"""`ensure_ready` matches checks against `recommended_names` by exact
|
|
85
|
+
string. If we ever localize this check's name via t(), the match breaks
|
|
86
|
+
silently. Lock the name in place via a test."""
|
|
87
|
+
with mock.patch.object(doctor.platform, "system", return_value="Linux"):
|
|
88
|
+
result = doctor.check_scripts_on_path()
|
|
89
|
+
self.assertEqual(result.name, "Python Scripts 在 PATH 上")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/requires.txt
RENAMED
|
File without changes
|
{get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/top_level.txt
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|