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.
Files changed (37) hide show
  1. {get_class_material-0.2.3 → get_class_material-0.2.4}/PKG-INFO +1 -1
  2. {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/PKG-INFO +1 -1
  3. {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/SOURCES.txt +1 -0
  4. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/doctor.py +114 -0
  5. {get_class_material-0.2.3 → get_class_material-0.2.4}/pyproject.toml +1 -1
  6. get_class_material-0.2.4/tests/test_doctor.py +93 -0
  7. {get_class_material-0.2.3 → get_class_material-0.2.4}/LICENSE +0 -0
  8. {get_class_material-0.2.3 → get_class_material-0.2.4}/README.md +0 -0
  9. {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/dependency_links.txt +0 -0
  10. {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/entry_points.txt +0 -0
  11. {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/requires.txt +0 -0
  12. {get_class_material-0.2.3 → get_class_material-0.2.4}/get_class_material.egg-info/top_level.txt +0 -0
  13. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/__init__.py +0 -0
  14. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/__main__.py +0 -0
  15. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/announcements.py +0 -0
  16. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/browser_session.py +0 -0
  17. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/canvas_client.py +0 -0
  18. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/cli.py +0 -0
  19. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/cool_video.py +0 -0
  20. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/course_pipeline.py +0 -0
  21. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/i18n.py +0 -0
  22. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/media_naming.py +0 -0
  23. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/pages.py +0 -0
  24. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/session_client.py +0 -0
  25. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/storage.py +0 -0
  26. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/sync.py +0 -0
  27. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/text_extract.py +0 -0
  28. {get_class_material-0.2.3 → get_class_material-0.2.4}/ntu_cool_materials/youtube_cookies.py +0 -0
  29. {get_class_material-0.2.3 → get_class_material-0.2.4}/setup.cfg +0 -0
  30. {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_announcements.py +0 -0
  31. {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_browser_session.py +0 -0
  32. {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_canvas_client.py +0 -0
  33. {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_cool_video.py +0 -0
  34. {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_media_naming.py +0 -0
  35. {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_session_client.py +0 -0
  36. {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_storage.py +0 -0
  37. {get_class_material-0.2.3 → get_class_material-0.2.4}/tests/test_youtube_cookies.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: get-class-material
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: One-command bulk downloader for NTU COOL (Canvas) course materials — PDFs, lecture videos, and Pages.
5
5
  Author-email: jabir <jabir95tsai@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: get-class-material
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: One-command bulk downloader for NTU COOL (Canvas) course materials — PDFs, lecture videos, and Pages.
5
5
  Author-email: jabir <jabir95tsai@gmail.com>
6
6
  License-Expression: MIT
@@ -28,6 +28,7 @@ tests/test_announcements.py
28
28
  tests/test_browser_session.py
29
29
  tests/test_canvas_client.py
30
30
  tests/test_cool_video.py
31
+ tests/test_doctor.py
31
32
  tests/test_media_naming.py
32
33
  tests/test_session_client.py
33
34
  tests/test_storage.py
@@ -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.3"
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()