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.
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ntu-cool-cli
3
- Version: 1.3.2
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.2"
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.2
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
- (`【114-1 寒假】毒道之處`, `114 微積分學前自我檢測`, `工程力學一`,
243
- `工程圖學`) plus three entries with `name: null` and
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