ntu-cool-cli 1.3.2__tar.gz → 1.3.3__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.
- {ntu_cool_cli-1.3.2/scripts/ntu_cool_cli.egg-info → ntu_cool_cli-1.3.3}/PKG-INFO +2 -1
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/pyproject.toml +8 -1
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3/scripts/ntu_cool_cli.egg-info}/PKG-INFO +2 -1
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/scripts/ntu_fetch.py +2 -2
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/tests/test_ntu_fetch.py +108 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/LICENSE +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/README.md +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/scripts/ntu_cool_cli.egg-info/SOURCES.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/scripts/ntu_cool_cli.egg-info/dependency_links.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/scripts/ntu_cool_cli.egg-info/entry_points.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/scripts/ntu_cool_cli.egg-info/requires.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/scripts/ntu_cool_cli.egg-info/top_level.txt +0 -0
- {ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/setup.cfg +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ntu-cool-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.3
|
|
4
4
|
Summary: Read-only CLI for NTU Cool: course announcements, course files, handouts, and submission status.
|
|
5
5
|
Author-email: Gary Xie <gary.1020.xie@gmail.com>
|
|
6
|
+
Maintainer-email: Gary Xie <gary.1020.xie@gmail.com>
|
|
6
7
|
License-Expression: MIT
|
|
7
8
|
Project-URL: Homepage, https://github.com/Gary1020-byte/ntu-cool-cli
|
|
8
9
|
Project-URL: Repository, https://github.com/Gary1020-byte/ntu-cool-cli
|
|
@@ -4,15 +4,22 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ntu-cool-cli"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.3"
|
|
8
8
|
description = "Read-only CLI for NTU Cool: course announcements, course files, handouts, and submission status."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
|
+
# Both keys, not just `authors`. Older setuptools splits the author table
|
|
14
|
+
# into author/author_email and leaves the PyPI "author" field blank, so the
|
|
15
|
+
# index page showed an email with no name next to it. `maintainer` is the
|
|
16
|
+
# field the index renders for the person responsible for the release.
|
|
13
17
|
authors = [
|
|
14
18
|
{ name = "Gary Xie", email = "gary.1020.xie@gmail.com" },
|
|
15
19
|
]
|
|
20
|
+
maintainers = [
|
|
21
|
+
{ name = "Gary Xie", email = "gary.1020.xie@gmail.com" },
|
|
22
|
+
]
|
|
16
23
|
keywords = [
|
|
17
24
|
"ntu", "cool", "ceiba", "cli", "announcements", "course", "files",
|
|
18
25
|
"homework", "handout", "student", "scraper", "read-only",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ntu-cool-cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.3
|
|
4
4
|
Summary: Read-only CLI for NTU Cool: course announcements, course files, handouts, and submission status.
|
|
5
5
|
Author-email: Gary Xie <gary.1020.xie@gmail.com>
|
|
6
|
+
Maintainer-email: Gary Xie <gary.1020.xie@gmail.com>
|
|
6
7
|
License-Expression: MIT
|
|
7
8
|
Project-URL: Homepage, https://github.com/Gary1020-byte/ntu-cool-cli
|
|
8
9
|
Project-URL: Repository, https://github.com/Gary1020-byte/ntu-cool-cli
|
|
@@ -239,8 +239,8 @@ def fetch_all_courses_historical(s: requests.Session) -> list[dict]:
|
|
|
239
239
|
|
|
240
240
|
This endpoint is a trap. Measured 2026-10-23 on a real account: it answered
|
|
241
241
|
with 10 courses while the current roster had 7. The extras were last term's
|
|
242
|
-
(
|
|
243
|
-
|
|
242
|
+
(a term-bracket course title, a placement self-test, and two engineering
|
|
243
|
+
courses) plus three entries with `name: null` and
|
|
244
244
|
`access_restricted_by_date: true`. **Every one of them carried
|
|
245
245
|
`term: null`**, so there is no term field to filter on — you cannot tell
|
|
246
246
|
a stale course from a current one after the fact.
|
|
@@ -396,6 +396,47 @@ class TestNoPersonalCourseRoster(unittest.TestCase):
|
|
|
396
396
|
ID_RE = re.compile(r"(?<![0-9])[56][0-9]{4}(?![0-9])")
|
|
397
397
|
CODE_RE = re.compile(r"ESOE[0-9]{4}")
|
|
398
398
|
|
|
399
|
+
# --- the rules that were missing when the guard was first written ---
|
|
400
|
+
#
|
|
401
|
+
# The original pair above catches a course id and an ESOE code. It does
|
|
402
|
+
# NOT catch the four things that actually leaked:
|
|
403
|
+
#
|
|
404
|
+
# 1. the six-digit course code as printed on the handout header, which
|
|
405
|
+
# is written with a space in the middle and so misses ID_RE
|
|
406
|
+
# 2. the student id, `B` + eight digits -- a different shape entirely
|
|
407
|
+
# 3. a literal password pasted into an `authenticate(...)` call
|
|
408
|
+
# 4. the course TITLES and the room numbers, which are the identifying
|
|
409
|
+
# part: a roster of four real course names with their exam dates
|
|
410
|
+
# says who this account belongs to as clearly as a course id does
|
|
411
|
+
#
|
|
412
|
+
# Rules 1-3 are hard identifiers and need no context. Rule 4 needs
|
|
413
|
+
# context, because the same words appear in ordinary prose ("the Files
|
|
414
|
+
# tree structure", "a maths course"), so a title only counts when it
|
|
415
|
+
# sits near a course-context marker.
|
|
416
|
+
#
|
|
417
|
+
# Nothing here lists a real course name, student id or password: a
|
|
418
|
+
# denylist of the leaked values would itself be the leak. Every rule is
|
|
419
|
+
# a shape, so it also catches values added after this file was written.
|
|
420
|
+
STUDENT_ID_RE = re.compile(r"(?<![A-Za-z0-9])[BR]\d{8}(?![0-9])")
|
|
421
|
+
COURSE_CODE_RE = re.compile(r"(?<!\d)[56]\d{2}\s?\d{5}(?!\d)")
|
|
422
|
+
PASSWORD_RE = re.compile(r"authenticate\(\s*[\"'][^\"']+[\"']\s*\)")
|
|
423
|
+
# A course title: a Chinese discipline noun, optionally with a leading
|
|
424
|
+
# qualifier and an optional trailing numeral (力學一, 電路一, 數學).
|
|
425
|
+
CN_TITLE_RE = re.compile(
|
|
426
|
+
r"(?<![一-鿿])[一-鿿]{2,4}(?:力學|數學|電路|結構|物理|化學|電子)"
|
|
427
|
+
r"(?:[一二三四五六七八九])?(?![一-鿿])")
|
|
428
|
+
EN_TITLE_RE = re.compile(
|
|
429
|
+
r"\b[A-Z][a-z]+ (?:Mechanics|Circuit Design"
|
|
430
|
+
r"|Electronics|Structures)\b"
|
|
431
|
+
r"|\b[A-Z][a-z]+ [A-Z][a-z]+ Structures\b")
|
|
432
|
+
ROOM_RE = re.compile(r"(?<![0-9])\d{2,3}\s?(?:教室|實驗室)")
|
|
433
|
+
# Words that mean "this line is about a course", not "this line is prose".
|
|
434
|
+
CONTEXT_RE = re.compile(
|
|
435
|
+
r"期中考|期末考|模擬考|小考|課程|課堂|助教|講義|作業|學期|科目"
|
|
436
|
+
r"|\bcourses?\b|\bcourse\b|\broster\b|\bterm\b|\bhomework\b"
|
|
437
|
+
r"|\bHW\d|\bCA\d|ESOE|cool\.ntu|/courses/")
|
|
438
|
+
CONTEXT_LINES = 3
|
|
439
|
+
|
|
399
440
|
def _repo_files(self):
|
|
400
441
|
"""Files that would actually be published: the git-tracked set.
|
|
401
442
|
|
|
@@ -427,6 +468,73 @@ class TestNoPersonalCourseRoster(unittest.TestCase):
|
|
|
427
468
|
self.assertIsNone(
|
|
428
469
|
hit, f"ESOE course code {hit.group(0) if hit else ''!r} in {path}")
|
|
429
470
|
|
|
471
|
+
def test_no_student_ids_anywhere(self):
|
|
472
|
+
"""`B` + eight digits is a student id, and it is not a course id."""
|
|
473
|
+
for path in self._repo_files():
|
|
474
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
475
|
+
hit = self.STUDENT_ID_RE.search(src)
|
|
476
|
+
self.assertIsNone(
|
|
477
|
+
hit, f"student id {hit.group(0) if hit else ''!r} in {path}")
|
|
478
|
+
|
|
479
|
+
def test_no_six_digit_course_codes_anywhere(self):
|
|
480
|
+
"""The handout header carries the course code as six digits with a
|
|
481
|
+
space in the middle, so the five-digit course-id rule never sees it."""
|
|
482
|
+
for path in self._repo_files():
|
|
483
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
484
|
+
hit = self.COURSE_CODE_RE.search(src)
|
|
485
|
+
self.assertIsNone(
|
|
486
|
+
hit, f"course code {hit.group(0) if hit else ''!r} in {path}")
|
|
487
|
+
|
|
488
|
+
def test_no_literal_passwords_anywhere(self):
|
|
489
|
+
"""A password pasted into an `authenticate(...)` call is a leak, even
|
|
490
|
+
when the password is one the course published."""
|
|
491
|
+
for path in self._repo_files():
|
|
492
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
493
|
+
hit = self.PASSWORD_RE.search(src)
|
|
494
|
+
self.assertIsNone(
|
|
495
|
+
hit, f"literal password {hit.group(0) if hit else ''!r} in {path}")
|
|
496
|
+
|
|
497
|
+
def test_no_course_titles_in_course_context(self):
|
|
498
|
+
"""A course title plus an exam date says whose account this is.
|
|
499
|
+
|
|
500
|
+
The same words appear in ordinary prose, so a title only counts when
|
|
501
|
+
a course-context marker sits within a few lines of it. Without the
|
|
502
|
+
context window this rule fires on 'the Files tree structure'."""
|
|
503
|
+
for path in self._repo_files():
|
|
504
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
505
|
+
for i, line in enumerate(lines):
|
|
506
|
+
for rx, label in ((self.CN_TITLE_RE, "course title"),
|
|
507
|
+
(self.EN_TITLE_RE, "course title")):
|
|
508
|
+
hit = rx.search(line)
|
|
509
|
+
if not hit:
|
|
510
|
+
continue
|
|
511
|
+
lo = max(0, i - self.CONTEXT_LINES)
|
|
512
|
+
hi = min(len(lines), i + self.CONTEXT_LINES + 1)
|
|
513
|
+
window = "\n".join(lines[lo:hi])
|
|
514
|
+
if not self.CONTEXT_RE.search(window):
|
|
515
|
+
continue
|
|
516
|
+
self.fail(
|
|
517
|
+
f"{label} {hit.group(0)!r} in {path}:{i + 1} "
|
|
518
|
+
f"near a course-context marker:\n{line.strip()[:160]}")
|
|
519
|
+
|
|
520
|
+
def test_no_room_numbers_in_course_context(self):
|
|
521
|
+
"""A room number is only identifying in a course context; the same
|
|
522
|
+
digits appear in a port number or a code elsewhere."""
|
|
523
|
+
for path in self._repo_files():
|
|
524
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
525
|
+
for i, line in enumerate(lines):
|
|
526
|
+
hit = self.ROOM_RE.search(line)
|
|
527
|
+
if not hit:
|
|
528
|
+
continue
|
|
529
|
+
lo = max(0, i - self.CONTEXT_LINES)
|
|
530
|
+
hi = min(len(lines), i + self.CONTEXT_LINES + 1)
|
|
531
|
+
window = "\n".join(lines[lo:hi])
|
|
532
|
+
if not self.CONTEXT_RE.search(window):
|
|
533
|
+
continue
|
|
534
|
+
self.fail(
|
|
535
|
+
f"room {hit.group(0)!r} in {path}:{i + 1} "
|
|
536
|
+
f"near a course-context marker:\n{line.strip()[:160]}")
|
|
537
|
+
|
|
430
538
|
|
|
431
539
|
class _PagedResponse(_FakeResponse):
|
|
432
540
|
def __init__(self, payload, status=200, next_url=None):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ntu_cool_cli-1.3.2 → ntu_cool_cli-1.3.3}/scripts/ntu_cool_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|