nusmods-api 0.1.0__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.
@@ -0,0 +1,232 @@
1
+ Metadata-Version: 2.3
2
+ Name: nusmods-api
3
+ Version: 0.1.0
4
+ Summary: A Python wrapper for the NUSMods API
5
+ Author: kommade
6
+ Author-email: kommade <khoojarrell@gmail.com>
7
+ Requires-Dist: requests>=2.34.2
8
+ Requires-Python: >=3.15.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # nusmods-api
12
+
13
+ A typed Python wrapper for the [NUSMods API](https://api.nusmods.com/).
14
+
15
+ The client provides access to module lists, detailed module information, venue
16
+ data, and shared timetable URLs from [nusmods.com](https://nusmods.com/).
17
+ Responses are converted into immutable Python dataclasses.
18
+
19
+ ## Requirements
20
+
21
+ - Python 3.15 or newer
22
+ - Internet access when making API requests
23
+
24
+ ## Installation
25
+
26
+ ### With uv
27
+
28
+ ```powershell
29
+ uv add nusmods-api
30
+ ```
31
+
32
+ For local development:
33
+
34
+ ```powershell
35
+ uv sync
36
+ ```
37
+
38
+ ### With pip
39
+
40
+ ```powershell
41
+ pip install nusmods-api
42
+ ```
43
+
44
+ ## Quick start
45
+
46
+ ```python
47
+ from nusmods_api import NUSModsClient
48
+
49
+ client = NUSModsClient(academic_year="2026-2027")
50
+
51
+ modules = client.get_module_list()
52
+ for module in modules[:5]:
53
+ print(module.moduleCode, module.title)
54
+
55
+ module = client.get_module("CS2030S")
56
+ print(module.title)
57
+ print(module.description)
58
+ ```
59
+
60
+ If no academic year is supplied, the client uses the current calendar year and
61
+ the following year. For example, a client created in 2026 uses `2026-2027`.
62
+
63
+ ## Client configuration
64
+
65
+ ```python
66
+ from nusmods_api import NUSModsClient
67
+
68
+ client = NUSModsClient(
69
+ academic_year="2026-2027",
70
+ timeout=20.0,
71
+ cache_directory=".nusmods_cache",
72
+ cache_expiration=86_400,
73
+ )
74
+ ```
75
+
76
+ | Argument | Default | Description |
77
+ | --- | --- | --- |
78
+ | `academic_year` | Current year and next year | Academic year in `YYYY-YYYY` format |
79
+ | `timeout` | `10.0` | HTTP timeout in seconds |
80
+ | `cache_directory` | `.nusmods_cache` | Directory for cached JSON responses |
81
+ | `cache_expiration` | `86400` | Cache lifetime in seconds; use `None` for no expiration |
82
+
83
+ Set `cache_directory=None` to disable caching:
84
+
85
+ ```python
86
+ client = NUSModsClient(cache_directory=None)
87
+ ```
88
+
89
+ ## API usage
90
+
91
+ ### Module list
92
+
93
+ ```python
94
+ modules = client.get_module_list()
95
+
96
+ for module in modules:
97
+ print(module.moduleCode, module.title, module.semesters)
98
+ ```
99
+
100
+ ### Detailed module information
101
+
102
+ ```python
103
+ modules = client.get_module_information()
104
+ module = client.get_module(" cs2030s ")
105
+
106
+ print(module.moduleCode) # CS2030S
107
+ print(module.acadYear)
108
+ print(module.semesterData)
109
+ ```
110
+
111
+ Module codes are stripped and normalized to uppercase by `get_module`.
112
+
113
+ ### Venues
114
+
115
+ ```python
116
+ venues = client.get_venues(semester=1)
117
+ print(venues)
118
+
119
+ venue_information = client.get_venue_information(semester=1)
120
+ for venue, entries in venue_information.items():
121
+ print(venue, entries)
122
+ ```
123
+
124
+ `semester` must be `1` or `2`.
125
+
126
+ ### Shared timetables
127
+
128
+ Pass a NUSMods share URL to `get_shared_timetable`:
129
+
130
+ ```python
131
+ share_url = (
132
+ "https://nusmods.com/timetable/sem-1/share?"
133
+ "CS1231S=TUT:25B,LEC:1&"
134
+ "CS2030S=REC:04,LAB:10B,LEC:1&"
135
+ "GES1034=LEC:1,TUT:E2&"
136
+ "IS1108=TUT:14,LEC:2&"
137
+ "MA1522=LEC:1"
138
+ )
139
+
140
+ timetable = client.get_shared_timetable(share_url)
141
+
142
+ for lesson in timetable.weekly:
143
+ print(
144
+ lesson.moduleCode,
145
+ lesson.lessonType,
146
+ lesson.classNo,
147
+ lesson.day,
148
+ lesson.startTime,
149
+ lesson.endTime,
150
+ lesson.venue,
151
+ )
152
+
153
+ for exam in timetable.exams:
154
+ print(exam.moduleCode, exam.examDate, exam.examDuration)
155
+ ```
156
+
157
+ The client parses the module selections from the URL and retrieves the
158
+ corresponding module data from the NUSMods API. Lesson type codes such as
159
+ `LEC`, `TUT`, `LAB`, and `REC` are mapped to their full names.
160
+
161
+ `Timetable` also provides convenience methods:
162
+
163
+ ```python
164
+ monday = timetable.get_day_schedule("Monday")
165
+ exam = timetable.get_exam_for_module("CS2030S")
166
+ ```
167
+
168
+ ## Data models
169
+
170
+ The package exposes frozen dataclasses for common API response types:
171
+
172
+ - `ModuleCondensed` — module code, title, and offered semesters
173
+ - `Module` — detailed module information
174
+ - `Lesson` — class schedule information
175
+ - `LessonWithModule` — a lesson including its module code
176
+ - `VenueLesson` and `VenueInformation` — venue timetable data
177
+ - `WeekRange` — date-based lesson ranges
178
+ - `Workload` — workload components
179
+ - `PrerequisiteTree` — nested prerequisite expressions
180
+ - `ExamTimetable` — exam information
181
+ - `Timetable` — selected weekly lessons and exams
182
+
183
+ Some model fields retain the NUSMods API naming convention, such as
184
+ `moduleCode`, `classNo`, `startTime`, `lessonType`, and `examDate`.
185
+
186
+ ## Error handling
187
+
188
+ The client raises package-specific exceptions:
189
+
190
+ ```python
191
+ from nusmods_api import (
192
+ NUSModsHTTPError,
193
+ NUSModsInvalidResponseError,
194
+ NUSModsNotFoundError,
195
+ NUSModsRequestError,
196
+ )
197
+
198
+ try:
199
+ module = client.get_module("CS2030S")
200
+ except NUSModsNotFoundError:
201
+ print("The module or academic year was not found")
202
+ except NUSModsHTTPError as error:
203
+ print(error.status_code, error.url)
204
+ except NUSModsInvalidResponseError:
205
+ print("The API returned invalid JSON")
206
+ except NUSModsRequestError:
207
+ print("The API could not be reached")
208
+ ```
209
+
210
+ Invalid client arguments, malformed share URLs, invalid semesters, and
211
+ unmatched lesson selections raise `ValueError`.
212
+
213
+ ## Testing
214
+
215
+ Run the default unit test suite:
216
+
217
+ ```powershell
218
+ uv run pytest
219
+ ```
220
+
221
+ The live integration test makes real requests to the NUSMods API:
222
+
223
+ ```powershell
224
+ uv run pytest tests/test_live_client.py -m live -q
225
+ ```
226
+
227
+ The live test requires internet access and may need its expected timetable
228
+ dates updated when NUSMods publishes a new academic-year dataset.
229
+
230
+ ## License
231
+
232
+ No license has been specified yet.
@@ -0,0 +1,222 @@
1
+ # nusmods-api
2
+
3
+ A typed Python wrapper for the [NUSMods API](https://api.nusmods.com/).
4
+
5
+ The client provides access to module lists, detailed module information, venue
6
+ data, and shared timetable URLs from [nusmods.com](https://nusmods.com/).
7
+ Responses are converted into immutable Python dataclasses.
8
+
9
+ ## Requirements
10
+
11
+ - Python 3.15 or newer
12
+ - Internet access when making API requests
13
+
14
+ ## Installation
15
+
16
+ ### With uv
17
+
18
+ ```powershell
19
+ uv add nusmods-api
20
+ ```
21
+
22
+ For local development:
23
+
24
+ ```powershell
25
+ uv sync
26
+ ```
27
+
28
+ ### With pip
29
+
30
+ ```powershell
31
+ pip install nusmods-api
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ from nusmods_api import NUSModsClient
38
+
39
+ client = NUSModsClient(academic_year="2026-2027")
40
+
41
+ modules = client.get_module_list()
42
+ for module in modules[:5]:
43
+ print(module.moduleCode, module.title)
44
+
45
+ module = client.get_module("CS2030S")
46
+ print(module.title)
47
+ print(module.description)
48
+ ```
49
+
50
+ If no academic year is supplied, the client uses the current calendar year and
51
+ the following year. For example, a client created in 2026 uses `2026-2027`.
52
+
53
+ ## Client configuration
54
+
55
+ ```python
56
+ from nusmods_api import NUSModsClient
57
+
58
+ client = NUSModsClient(
59
+ academic_year="2026-2027",
60
+ timeout=20.0,
61
+ cache_directory=".nusmods_cache",
62
+ cache_expiration=86_400,
63
+ )
64
+ ```
65
+
66
+ | Argument | Default | Description |
67
+ | --- | --- | --- |
68
+ | `academic_year` | Current year and next year | Academic year in `YYYY-YYYY` format |
69
+ | `timeout` | `10.0` | HTTP timeout in seconds |
70
+ | `cache_directory` | `.nusmods_cache` | Directory for cached JSON responses |
71
+ | `cache_expiration` | `86400` | Cache lifetime in seconds; use `None` for no expiration |
72
+
73
+ Set `cache_directory=None` to disable caching:
74
+
75
+ ```python
76
+ client = NUSModsClient(cache_directory=None)
77
+ ```
78
+
79
+ ## API usage
80
+
81
+ ### Module list
82
+
83
+ ```python
84
+ modules = client.get_module_list()
85
+
86
+ for module in modules:
87
+ print(module.moduleCode, module.title, module.semesters)
88
+ ```
89
+
90
+ ### Detailed module information
91
+
92
+ ```python
93
+ modules = client.get_module_information()
94
+ module = client.get_module(" cs2030s ")
95
+
96
+ print(module.moduleCode) # CS2030S
97
+ print(module.acadYear)
98
+ print(module.semesterData)
99
+ ```
100
+
101
+ Module codes are stripped and normalized to uppercase by `get_module`.
102
+
103
+ ### Venues
104
+
105
+ ```python
106
+ venues = client.get_venues(semester=1)
107
+ print(venues)
108
+
109
+ venue_information = client.get_venue_information(semester=1)
110
+ for venue, entries in venue_information.items():
111
+ print(venue, entries)
112
+ ```
113
+
114
+ `semester` must be `1` or `2`.
115
+
116
+ ### Shared timetables
117
+
118
+ Pass a NUSMods share URL to `get_shared_timetable`:
119
+
120
+ ```python
121
+ share_url = (
122
+ "https://nusmods.com/timetable/sem-1/share?"
123
+ "CS1231S=TUT:25B,LEC:1&"
124
+ "CS2030S=REC:04,LAB:10B,LEC:1&"
125
+ "GES1034=LEC:1,TUT:E2&"
126
+ "IS1108=TUT:14,LEC:2&"
127
+ "MA1522=LEC:1"
128
+ )
129
+
130
+ timetable = client.get_shared_timetable(share_url)
131
+
132
+ for lesson in timetable.weekly:
133
+ print(
134
+ lesson.moduleCode,
135
+ lesson.lessonType,
136
+ lesson.classNo,
137
+ lesson.day,
138
+ lesson.startTime,
139
+ lesson.endTime,
140
+ lesson.venue,
141
+ )
142
+
143
+ for exam in timetable.exams:
144
+ print(exam.moduleCode, exam.examDate, exam.examDuration)
145
+ ```
146
+
147
+ The client parses the module selections from the URL and retrieves the
148
+ corresponding module data from the NUSMods API. Lesson type codes such as
149
+ `LEC`, `TUT`, `LAB`, and `REC` are mapped to their full names.
150
+
151
+ `Timetable` also provides convenience methods:
152
+
153
+ ```python
154
+ monday = timetable.get_day_schedule("Monday")
155
+ exam = timetable.get_exam_for_module("CS2030S")
156
+ ```
157
+
158
+ ## Data models
159
+
160
+ The package exposes frozen dataclasses for common API response types:
161
+
162
+ - `ModuleCondensed` — module code, title, and offered semesters
163
+ - `Module` — detailed module information
164
+ - `Lesson` — class schedule information
165
+ - `LessonWithModule` — a lesson including its module code
166
+ - `VenueLesson` and `VenueInformation` — venue timetable data
167
+ - `WeekRange` — date-based lesson ranges
168
+ - `Workload` — workload components
169
+ - `PrerequisiteTree` — nested prerequisite expressions
170
+ - `ExamTimetable` — exam information
171
+ - `Timetable` — selected weekly lessons and exams
172
+
173
+ Some model fields retain the NUSMods API naming convention, such as
174
+ `moduleCode`, `classNo`, `startTime`, `lessonType`, and `examDate`.
175
+
176
+ ## Error handling
177
+
178
+ The client raises package-specific exceptions:
179
+
180
+ ```python
181
+ from nusmods_api import (
182
+ NUSModsHTTPError,
183
+ NUSModsInvalidResponseError,
184
+ NUSModsNotFoundError,
185
+ NUSModsRequestError,
186
+ )
187
+
188
+ try:
189
+ module = client.get_module("CS2030S")
190
+ except NUSModsNotFoundError:
191
+ print("The module or academic year was not found")
192
+ except NUSModsHTTPError as error:
193
+ print(error.status_code, error.url)
194
+ except NUSModsInvalidResponseError:
195
+ print("The API returned invalid JSON")
196
+ except NUSModsRequestError:
197
+ print("The API could not be reached")
198
+ ```
199
+
200
+ Invalid client arguments, malformed share URLs, invalid semesters, and
201
+ unmatched lesson selections raise `ValueError`.
202
+
203
+ ## Testing
204
+
205
+ Run the default unit test suite:
206
+
207
+ ```powershell
208
+ uv run pytest
209
+ ```
210
+
211
+ The live integration test makes real requests to the NUSMods API:
212
+
213
+ ```powershell
214
+ uv run pytest tests/test_live_client.py -m live -q
215
+ ```
216
+
217
+ The live test requires internet access and may need its expected timetable
218
+ dates updated when NUSMods publishes a new academic-year dataset.
219
+
220
+ ## License
221
+
222
+ No license has been specified yet.
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "nusmods-api"
3
+ version = "0.1.0"
4
+ description = "A Python wrapper for the NUSMods API"
5
+ readme = "README.md"
6
+ requires-python = ">=3.15.0"
7
+ dependencies = ["requests>=2.34.2"]
8
+
9
+ [[project.authors]]
10
+ name = "kommade"
11
+ email = "khoojarrell@gmail.com"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.13.5,<0.14.0"]
15
+ build-backend = "uv_build"
16
+
17
+ [dependency-groups]
18
+ dev = ["pytest>=8.0"]
19
+
20
+ [tool.pytest.ini_options]
21
+ markers = ["live: tests that make requests to the live NUSMods API"]
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "nusmods-api"
3
+ version = "0.1.0"
4
+ description = "A Python wrapper for the NUSMods API"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "kommade", email = "khoojarrell@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.15.0"
10
+ dependencies = [
11
+ "requests>=2.34.2",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["uv_build>=0.13.5,<0.14.0"]
16
+ build-backend = "uv_build"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=8.0",
21
+ ]
22
+
23
+ [tool.pytest.ini_options]
24
+ markers = [
25
+ "live: tests that make requests to the live NUSMods API",
26
+ ]
@@ -0,0 +1,35 @@
1
+ from .client import NUSModsClient
2
+ from .exceptions import (
3
+ NUSModsError,
4
+ NUSModsRequestError,
5
+ NUSModsHTTPError,
6
+ NUSModsInvalidResponseError,
7
+ NUSModsNotFoundError,
8
+ )
9
+ from .models import (
10
+ WeekRange,
11
+ Lesson,
12
+ Workload,
13
+ PrerequisiteTree,
14
+ ModuleCondensed,
15
+ Module,
16
+ VenueLesson,
17
+ VenueInformation,
18
+ )
19
+
20
+ __all__ = [
21
+ "NUSModsClient",
22
+ "NUSModsError",
23
+ "NUSModsRequestError",
24
+ "NUSModsHTTPError",
25
+ "NUSModsInvalidResponseError",
26
+ "NUSModsNotFoundError",
27
+ "WeekRange",
28
+ "Lesson",
29
+ "Workload",
30
+ "PrerequisiteTree",
31
+ "ModuleCondensed",
32
+ "Module",
33
+ "VenueLesson",
34
+ "VenueInformation",
35
+ ]
@@ -0,0 +1,65 @@
1
+ """Local cache for NUSMods API data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ class Cache:
12
+ def __init__(
13
+ self,
14
+ directory: str | Path = ".nusmods_cache",
15
+ expiration: int | None = 86_400,
16
+ ) -> None:
17
+ self.directory = Path(directory)
18
+ self.expiration = expiration
19
+ self.directory.mkdir(parents=True, exist_ok=True)
20
+
21
+ def _path_for(self, key: str) -> Path:
22
+ filename = hashlib.sha256(
23
+ key.encode()
24
+ ).hexdigest() + ".json"
25
+
26
+ return self.directory / filename
27
+
28
+ def get(self, key: str) -> Any | None:
29
+ path = self._path_for(key)
30
+ if not path.exists():
31
+ return None
32
+
33
+ if self.expiration is not None and time.time() - path.stat().st_mtime > self.expiration:
34
+ path.unlink()
35
+ return None
36
+
37
+ try:
38
+ return json.loads(path.read_text())
39
+ except (json.JSONDecodeError, OSError):
40
+ return None
41
+
42
+ def set(self, key: str, value: Any) -> None:
43
+ path = self._path_for(key)
44
+ temp_path = path.with_suffix(".tmp")
45
+
46
+ temp_path.write_text(
47
+ json.dumps(value, ensure_ascii=False),
48
+ encoding="utf-8",
49
+ )
50
+ temp_path.replace(path)
51
+
52
+ def delete(self, key: str) -> None:
53
+ path = self._path_for(key)
54
+
55
+ try:
56
+ path.unlink()
57
+ except FileNotFoundError:
58
+ pass
59
+
60
+ def clear(self) -> None:
61
+ for file in self.directory.glob("*.json"):
62
+ try:
63
+ file.unlink()
64
+ except FileNotFoundError:
65
+ pass
@@ -0,0 +1,394 @@
1
+ """Main client for the NUSMods API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ import requests
9
+ from datetime import datetime, date
10
+ from pathlib import Path
11
+ from urllib.parse import parse_qs, urlparse
12
+
13
+ from .cache import Cache
14
+
15
+ from .exceptions import (
16
+ NUSModsRequestError,
17
+ NUSModsHTTPError,
18
+ NUSModsInvalidResponseError,
19
+ NUSModsNotFoundError,
20
+ )
21
+
22
+ from .models import (
23
+ WeekRange,
24
+ Lesson,
25
+ LessonWithModule,
26
+ Workload,
27
+ PrerequisiteTree,
28
+ SemesterData,
29
+ ModuleCondensed,
30
+ Module,
31
+ VenueLesson,
32
+ VenueInformation,
33
+ ExamTimetable,
34
+ Timetable,
35
+ )
36
+
37
+ class NUSModsClient:
38
+ BASE_URL = "https://api.nusmods.com/v2"
39
+
40
+ LESSON_TYPE_NAMES = {
41
+ "LEC": "Lecture",
42
+ "TUT": "Tutorial",
43
+ "LAB": "Laboratory",
44
+ "REC": "Recitation",
45
+ "SEM": "Seminar",
46
+ "SEC": "Sectional Teaching",
47
+ "PRA": "Practical",
48
+ }
49
+
50
+ DAY_ORDER = {
51
+ "Monday": 0,
52
+ "Tuesday": 1,
53
+ "Wednesday": 2,
54
+ "Thursday": 3,
55
+ "Friday": 4,
56
+ "Saturday": 5,
57
+ "Sunday": 6,
58
+ }
59
+
60
+
61
+ def __init__(
62
+ self,
63
+ academic_year: str | None = None,
64
+ timeout: float = 10.0,
65
+ cache_directory: str | Path | None = ".nusmods_cache",
66
+ cache_expiration: int | None = 86_400,
67
+ ) -> None:
68
+ if academic_year is not None:
69
+ if re.fullmatch(r"\d{4}-\d{4}", academic_year) is None:
70
+ raise ValueError("Invalid academic year format. Expected 'YYYY-YYYY'.")
71
+ self.academic_year = academic_year
72
+ else:
73
+ this_year = datetime.now().year
74
+ next_year = this_year + 1
75
+ self.academic_year = f"{this_year}-{next_year}"
76
+ self.timeout = timeout
77
+
78
+ self.session = requests.Session()
79
+ self.session.headers.update({
80
+ "Accept": "application/json",
81
+ "User-Agent": f"nusmods-api-python/0.1.0)",
82
+ })
83
+
84
+ self.cache = Cache(
85
+ directory=cache_directory,
86
+ expiration=cache_expiration,
87
+ ) if cache_directory is not None else None
88
+
89
+ def _build_url(self, endpoint: str) -> str:
90
+ if self.academic_year is None:
91
+ raise ValueError("Academic year is not set")
92
+ return f"{self.BASE_URL}/{self.academic_year}/{endpoint}"
93
+
94
+ def _get(self, path: str) -> Any:
95
+ url = self._build_url(path)
96
+
97
+ if self.cache is not None:
98
+ cached_response = self.cache.get(url)
99
+ if cached_response is not None:
100
+ return cached_response
101
+
102
+ try:
103
+ response = self.session.get(url, timeout=self.timeout)
104
+ except requests.RequestException as e:
105
+ raise NUSModsRequestError(
106
+ f"Could not connect to NUSMods API: {url}"
107
+ ) from e
108
+
109
+ if response.status_code == 404:
110
+ raise NUSModsNotFoundError(url)
111
+
112
+ if not response.ok:
113
+ raise NUSModsHTTPError(
114
+ status_code=response.status_code,
115
+ url=url,
116
+ message=f"NUSMods API returned HTTP {response.status_code}",
117
+ )
118
+ try:
119
+ data = response.json()
120
+ except ValueError as e:
121
+ raise NUSModsInvalidResponseError(
122
+ url=url,
123
+ message="Invalid JSON response",
124
+ ) from e
125
+
126
+ if self.cache is not None:
127
+ self.cache.set(url, data)
128
+
129
+ return data
130
+
131
+ def get_module_list(self) -> list[ModuleCondensed]:
132
+ """Get a list of all modules for the academic year."""
133
+ data = self._get("modules.json")
134
+ return [ModuleCondensed(**module) for module in data]
135
+
136
+ def get_module_information(self) -> list[Module]:
137
+ """Get detailed information for all modules for the academic year."""
138
+ data = self._get("moduleInformation.json")
139
+ return [self._parse_module(module) for module in data]
140
+
141
+ def get_module(self, module_code: str) -> Module:
142
+ """Get detailed information for a specific module."""
143
+ module_code = module_code.strip().upper()
144
+ data = self._get(f"modules/{module_code}.json")
145
+ return self._parse_module(data)
146
+
147
+ def _parse_module(self, data: dict[str, Any]) -> Module:
148
+ module_data = {
149
+ "moduleCode": data["moduleCode"],
150
+ "title": data["title"],
151
+ "semesters": data.get("semesters", []),
152
+ "acadYear": data["acadYear"],
153
+ "preclusion": data.get("preclusion"),
154
+ "description": data.get("description"),
155
+ "department": data.get("department"),
156
+ "faculty": data.get("faculty"),
157
+ "moduleCredit": data.get("moduleCredit"),
158
+ "attributes": data.get("attributes"),
159
+ "prerequisite": data.get("prerequisite"),
160
+ "corequisite": data.get("corequisite"),
161
+ "workload": (
162
+ Workload.from_list(data["workload"])
163
+ if isinstance(data.get("workload"), list)
164
+ else data.get("workload")
165
+ ),
166
+ "fulfill_requirements": data.get("fulfillRequirements"),
167
+ "prerequisite_tree": self._parse_prerequisite_tree(
168
+ data.get("prereqTree")
169
+ ),
170
+ "semesterData": [
171
+ SemesterData(
172
+ semester=semester["semester"],
173
+ examDate=semester.get("examDate"),
174
+ examDuration=semester.get("examDuration"),
175
+ timetable=[
176
+ Lesson(**{
177
+ key: lesson[key]
178
+ for key in (
179
+ "classNo",
180
+ "startTime",
181
+ "endTime",
182
+ "weeks",
183
+ "venue",
184
+ "day",
185
+ "lessonType",
186
+ "size",
187
+ )
188
+ })
189
+ for lesson in semester.get("timetable") or []
190
+ ],
191
+ )
192
+ for semester in data.get("semesterData") or []
193
+ ],
194
+ }
195
+ return Module(**module_data)
196
+
197
+ def _parse_prerequisite_tree(
198
+ self,
199
+ data: dict[str, Any] | None,
200
+ ) -> PrerequisiteTree | None:
201
+ if data is None:
202
+ return None
203
+
204
+ def parse_children(
205
+ values: list[str | dict[str, Any]],
206
+ ) -> list[str | PrerequisiteTree]:
207
+ return list(filter(None, (
208
+ self._parse_prerequisite_tree(value)
209
+ if isinstance(value, dict)
210
+ else value
211
+ for value in values
212
+ )))
213
+
214
+ return PrerequisiteTree(
215
+ and_=parse_children(data["and"]) if "and" in data else None,
216
+ or_=parse_children(data["or"]) if "or" in data else None,
217
+ )
218
+
219
+ def get_venues(self, semester: int) -> list[str]:
220
+ """Get a list of all venues for the academic year."""
221
+ if semester not in (1, 2):
222
+ raise ValueError("Semester must be 1 or 2")
223
+ data = self._get(f"semesters/{semester}/venues.json")
224
+ return data
225
+
226
+ def get_venue_information(self, semester: int) -> dict[str, list[VenueInformation]]:
227
+ """Get timetable information grouped by venue"""
228
+ if semester not in (1, 2):
229
+ raise ValueError("Semester must be 1 or 2")
230
+ data = self._get(f"semesters/{semester}/venues.json")
231
+ venue_info = {}
232
+ for venue in data:
233
+ venue_name = venue["venue"]
234
+ lessons = [VenueInformation(**lesson) for lesson in venue["classes"]]
235
+ venue_info[venue_name] = lessons
236
+ return venue_info
237
+
238
+ def get_shared_timetable(
239
+ self,
240
+ share_url: str,
241
+ ) -> Timetable:
242
+ parsed_url = urlparse(share_url)
243
+
244
+ if parsed_url.netloc not in {
245
+ "nusmods.com",
246
+ "www.nusmods.com",
247
+ }:
248
+ raise ValueError("URL must belong to nusmods.com")
249
+
250
+ semester_match = re.search(
251
+ r"/timetable/sem-(\d+)/share",
252
+ parsed_url.path,
253
+ )
254
+
255
+ if semester_match is None:
256
+ raise ValueError(
257
+ "URL must have the form /timetable/sem-{n}/share"
258
+ )
259
+
260
+ semester = int(semester_match.group(1))
261
+ query = parse_qs(parsed_url.query)
262
+
263
+ if not query:
264
+ raise ValueError("Share URL contains no module selections")
265
+
266
+ weekly_lessons: list[LessonWithModule] = []
267
+ exams: list[ExamTimetable] = []
268
+
269
+ for module_code, selection_groups in query.items():
270
+ module = self.get_module(module_code)
271
+
272
+ semester_data = next(
273
+ (
274
+ data
275
+ for data in module.semesterData or []
276
+ if data.semester == semester
277
+ ),
278
+ None,
279
+ )
280
+
281
+ if semester_data is None:
282
+ raise ValueError(
283
+ f"{module_code} has no data for semester {semester}"
284
+ )
285
+
286
+ selected_lessons = self._select_lessons(
287
+ module_code=module_code,
288
+ lessons=semester_data.timetable or [],
289
+ selection_groups=selection_groups,
290
+ )
291
+
292
+ weekly_lessons.extend(selected_lessons)
293
+
294
+ if semester_data.examDate is not None:
295
+ exams.append(
296
+ ExamTimetable(
297
+ moduleCode=module_code,
298
+ examDate=semester_data.examDate,
299
+ examDuration=semester_data.examDuration,
300
+ )
301
+ )
302
+
303
+ weekly_lessons.sort(key=self._lesson_sort_key)
304
+ exams.sort(key=self._exam_sort_key)
305
+
306
+ return Timetable(
307
+ semester=semester,
308
+ weekly=weekly_lessons,
309
+ exams=exams,
310
+ )
311
+
312
+ def _select_lessons(
313
+ self,
314
+ module_code: str,
315
+ lessons: list[Lesson],
316
+ selection_groups: list[str],
317
+ ) -> list[LessonWithModule]:
318
+ requested: set[tuple[str, str]] = set()
319
+
320
+ for selection_group in selection_groups:
321
+ for selection in selection_group.split(","):
322
+ try:
323
+ lesson_type_code, class_no = selection.split(":", 1)
324
+ except ValueError as error:
325
+ raise ValueError(
326
+ f"Invalid lesson selection for {module_code}: "
327
+ f"{selection!r}"
328
+ ) from error
329
+
330
+ lesson_type = self.LESSON_TYPE_NAMES.get(
331
+ lesson_type_code.upper(),
332
+ lesson_type_code,
333
+ )
334
+
335
+ requested.add((lesson_type, class_no))
336
+
337
+ selected = [
338
+ LessonWithModule(
339
+ moduleCode=module_code,
340
+ classNo=lesson.classNo,
341
+ startTime=lesson.startTime,
342
+ endTime=lesson.endTime,
343
+ weeks=lesson.weeks,
344
+ venue=lesson.venue,
345
+ day=lesson.day,
346
+ lessonType=lesson.lessonType,
347
+ size=lesson.size,
348
+ )
349
+ for lesson in lessons
350
+ if (lesson.lessonType, lesson.classNo) in requested
351
+ ]
352
+
353
+ found = {
354
+ (lesson.lessonType, lesson.classNo)
355
+ for lesson in selected
356
+ }
357
+
358
+ missing = requested - found
359
+
360
+ if missing:
361
+ raise ValueError(
362
+ f"Could not find selected lessons for {module_code}: "
363
+ f"{sorted(missing)}"
364
+ )
365
+
366
+ return selected
367
+
368
+ def _lesson_sort_key(
369
+ self,
370
+ lesson: LessonWithModule,
371
+ ) -> tuple[int, int, str, str]:
372
+ try:
373
+ start_minutes = (
374
+ int(lesson.startTime[:2]) * 60
375
+ + int(lesson.startTime[2:])
376
+ )
377
+ except ValueError:
378
+ start_minutes = 0
379
+
380
+ return (
381
+ self.DAY_ORDER.get(lesson.day, len(self.DAY_ORDER)),
382
+ start_minutes,
383
+ lesson.moduleCode,
384
+ lesson.lessonType,
385
+ )
386
+
387
+ def _exam_sort_key(
388
+ self,
389
+ exam: ExamTimetable,
390
+ ) -> tuple[date, str]:
391
+ return (
392
+ exam.examDate or date.max,
393
+ exam.moduleCode,
394
+ )
@@ -0,0 +1,40 @@
1
+ """Typed exceptions for the NUSMods API."""
2
+
3
+
4
+ class NUSModsError(Exception):
5
+ """Base exception for all errors raised by the NUSMods wrapper."""
6
+
7
+
8
+ class NUSModsRequestError(NUSModsError):
9
+ """Raised when a request cannot be completed."""
10
+
11
+
12
+ class NUSModsHTTPError(NUSModsError):
13
+ """Raised when the API returns an unsuccessful HTTP status code."""
14
+
15
+ def __init__(
16
+ self,
17
+ status_code: int,
18
+ url: str,
19
+ message: str | None = None,
20
+ ) -> None:
21
+ self.status_code = status_code
22
+ self.url = url
23
+ self.message = message or f"NUSMods API returned HTTP {status_code}"
24
+ super().__init__(f"{self.message}: {url}")
25
+
26
+
27
+ class NUSModsNotFoundError(NUSModsHTTPError):
28
+ """Raised when the requested academic-year resource does not exist."""
29
+
30
+ def __init__(self, url: str) -> None:
31
+ super().__init__(404, url, "NUSMods resource not found")
32
+
33
+
34
+ class NUSModsInvalidResponseError(NUSModsError):
35
+ """Raised when the API response cannot be decoded as JSON."""
36
+
37
+ def __init__(self, url: str, message: str = "Invalid JSON response") -> None:
38
+ self.url = url
39
+ self.message = message
40
+ super().__init__(f"{message}: {url}")
@@ -0,0 +1,180 @@
1
+ """Typed models for responses from the NUSMods v2 API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime
7
+ from typing import Any, TypeAlias
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class WeekRange:
12
+ """A date-based lesson range used for special classes."""
13
+
14
+ start: date
15
+ end: date
16
+ week_interval: int | None = None
17
+ weeks: list[int] | None = None
18
+
19
+
20
+ LessonWeeks: TypeAlias = list[int] | WeekRange
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Lesson:
25
+ classNo: str
26
+ startTime: str
27
+ endTime: str
28
+ weeks: LessonWeeks
29
+ venue: str
30
+ day: str
31
+ lessonType: str
32
+ size: int
33
+
34
+ def __post_init__(self) -> None:
35
+ if not isinstance(self.weeks, (list, WeekRange)):
36
+ object.__setattr__(self, "weeks", parse_weeks(self.weeks))
37
+
38
+ @dataclass(frozen=True)
39
+ class LessonWithModule(Lesson):
40
+ moduleCode: str
41
+
42
+ @dataclass(frozen=True)
43
+ class Workload:
44
+ """Workload for a module.
45
+
46
+ The workload is represented as a list of strings, where each string
47
+ represents a workload component in the format ``<hours>:<component>``.
48
+ For example, ``"2:Lecture"`` means 2 hours of lecture per week.
49
+ """
50
+
51
+ lecture: int | None = None
52
+ tutorial: int | None = None
53
+ lab: int | None = None
54
+ project: int | None = None
55
+ preparation: int | None = None
56
+ string: str | None = None
57
+
58
+ @staticmethod
59
+ def from_string(workload_str: str) -> Workload:
60
+ return Workload(string=workload_str)
61
+
62
+ @staticmethod
63
+ def from_list(workload_list: list[str]) -> Workload:
64
+ if len(workload_list) != 5:
65
+ raise ValueError("Workload list must have exactly 5 elements.")
66
+ order = ["lecture", "tutorial", "lab", "project", "preparation"]
67
+ d = {}
68
+ for i in range(5):
69
+ d[order[i]] = workload_list[i]
70
+ return Workload(**d)
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class PrerequisiteTree:
75
+ """Recursive prerequisite expression.
76
+
77
+ A node normally contains either ``and`` or ``or``. Leaf values are
78
+ module codes represented as strings.
79
+ """
80
+
81
+ and_: list[str | PrerequisiteTree] | None = None
82
+ or_: list[str | PrerequisiteTree] | None = None
83
+
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class SemesterData:
88
+ """Semester data for a module."""
89
+
90
+ semester: int
91
+ examDate: date | None = None
92
+ examDuration: int | None = None
93
+ timetable: list[Lesson] | None = None
94
+
95
+ def __post_init__(self) -> None:
96
+ if self.examDate is not None and not isinstance(self.examDate, date):
97
+ object.__setattr__(self, "examDate", parse_date(self.examDate))
98
+
99
+ @dataclass(frozen=True)
100
+ class ModuleCondensed:
101
+ moduleCode: str
102
+ title: str
103
+ semesters: list[int]
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class Module(ModuleCondensed):
108
+ acadYear: str
109
+ preclusion: str | None = None
110
+ description: str | None = None
111
+ department: str | None = None
112
+ faculty: str | None = None
113
+ workload: Workload | None = None
114
+ moduleCredit: int | None = None
115
+ attributes: dict[str, Any] | None = None
116
+ semesterData: list[SemesterData] | None = None
117
+ prerequisite: str | None = None
118
+ fulfill_requirements: list[str] | None = None
119
+ corequisite: str | None = None
120
+ prerequisite_tree: PrerequisiteTree | None = None
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class VenueLesson(Lesson):
125
+ moduleCode: str = ""
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class VenueInformation:
130
+ """Information about a venue and its lessons."""
131
+
132
+ day: str
133
+ classes: list[VenueLesson]
134
+ availability: dict[str, str] | None = None
135
+
136
+ @dataclass(frozen=True)
137
+ class ExamTimetable:
138
+ """Exam timetable for a module."""
139
+
140
+ moduleCode: str
141
+ examDate: date | None = None
142
+ examDuration: int | None = None
143
+
144
+ @dataclass(frozen=True)
145
+ class Timetable:
146
+ """Timetable for a semester."""
147
+ semester: int
148
+ weekly: list[LessonWithModule]
149
+ exams: list[ExamTimetable]
150
+
151
+ def get_day_schedule(self, day: str) -> list[Lesson]:
152
+ """Get the lessons for a specific day of the week."""
153
+ if day not in {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}:
154
+ raise ValueError("Day must be one of Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday")
155
+ return [lesson for lesson in self.weekly if lesson.day == day]
156
+
157
+ def get_exam_for_module(self, module_code: str) -> ExamTimetable | None:
158
+ """Get the exam timetable for a specific module."""
159
+ for exam in self.exams:
160
+ if exam.moduleCode == module_code:
161
+ return exam
162
+ return None
163
+
164
+ def parse_date(value: str) -> date:
165
+ try:
166
+ return date.fromisoformat(value)
167
+ except ValueError:
168
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).date()
169
+
170
+
171
+ def parse_weeks(value: list[int] | dict[str, Any]) -> LessonWeeks:
172
+ if isinstance(value, list):
173
+ return value
174
+
175
+ return WeekRange(
176
+ start=parse_date(value["start"]),
177
+ end=parse_date(value["end"]),
178
+ weeks=value.get("weeks"),
179
+ week_interval=value.get("weekInterval"),
180
+ )
File without changes