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.
- nusmods_api-0.1.0/PKG-INFO +232 -0
- nusmods_api-0.1.0/README.md +222 -0
- nusmods_api-0.1.0/pyproject.toml +21 -0
- nusmods_api-0.1.0/pyproject.toml.orig +26 -0
- nusmods_api-0.1.0/src/nusmods_api/__init__.py +35 -0
- nusmods_api-0.1.0/src/nusmods_api/cache.py +65 -0
- nusmods_api-0.1.0/src/nusmods_api/client.py +394 -0
- nusmods_api-0.1.0/src/nusmods_api/exceptions.py +40 -0
- nusmods_api-0.1.0/src/nusmods_api/models.py +180 -0
- nusmods_api-0.1.0/src/nusmods_api/py.typed +0 -0
|
@@ -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
|