Coverage for src/data_api/domain/courses/services.py: 100%
44 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""Normalized, independently cached course snapshots for each published semester."""
3from pydantic import TypeAdapter
5from data_api.core.exceptions import DataNotAvailableException
6from data_api.data.manager import nthudata
7from data_api.data.nthudata import (
8 DatasetState,
9 FetchFailure,
10 Freshness,
11 JsonData,
12 NTHUDataManager,
13 Snapshot,
14)
15from data_api.domain.courses.models import Conditions
16from data_api.models.courses import CourseCatalog, CourseData, CourseListName, SemesterCode
18LATEST_PATH = "courses/latest.json"
19CATALOG_PATH = "courses/sources.json"
22class UnknownSemester(Exception):
23 """A well-formed semester absent from a verified published catalog."""
26def prepare_courses(raw: JsonData, semester: str | None = None) -> list[CourseData]:
27 if not isinstance(raw, list):
28 raise FetchFailure("payload_type")
29 courses = TypeAdapter(list[CourseData]).validate_python(raw, strict=True)
30 ids = [course.id for course in courses]
31 if len(ids) != len(set(ids)) or (
32 semester is not None and any(course.semester_code != semester for course in courses)
33 ):
34 raise FetchFailure("validation")
35 return courses
38def prepare_catalog(raw: JsonData) -> list[str]:
39 return CourseCatalog.model_validate(raw, strict=True).semesters
42class CoursesService:
43 def __init__(self, manager: NTHUDataManager = nthudata) -> None:
44 self.manager = manager
45 self.state = manager.register(LATEST_PATH, prepare_courses)
46 self.catalog_state = manager.register(CATALOG_PATH, prepare_catalog)
47 self._semesters: dict[str, DatasetState[list[CourseData]]] = {}
49 async def get_semesters(self) -> Snapshot[list[str]]:
50 return await self.manager.get_snapshot(CATALOG_PATH, self.catalog_state)
52 async def get_snapshot(self, semester: str | None = None) -> Snapshot[list[CourseData]]:
53 if semester is None:
54 return await self.manager.get_snapshot(LATEST_PATH, self.state)
55 semester = TypeAdapter(SemesterCode).validate_python(semester, strict=True)
56 catalog = await self.get_semesters()
57 if semester not in catalog.data:
58 if self.catalog_state.freshness != Freshness.CURRENT:
59 raise DataNotAvailableException("Semester catalog temporarily unavailable.")
60 raise UnknownSemester("Semester is not published.")
61 path = f"courses/semesters/{semester}.json"
62 if semester not in self._semesters:
63 self._semesters[semester] = self.manager.register(
64 path, lambda raw: prepare_courses(raw, semester)
65 )
66 return await self.manager.get_snapshot(path, self._semesters[semester])
68 @staticmethod
69 def query(
70 courses: list[CourseData],
71 conditions: Conditions,
72 course_type: CourseListName | None = None,
73 ) -> list[CourseData]:
74 return [
75 course
76 for course in courses
77 if conditions.accept(course)
78 and (
79 course_type is None
80 or (
81 course_type == CourseListName.microcredits
82 and course.credits is not None
83 and not course.credits.is_integer()
84 )
85 or (course_type == CourseListName.xclass and "X-Class" in course.note)
86 )
87 ]
90courses_service = CoursesService()