myprepcourse 0.1.0__py3-none-any.whl
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.
- MyPrepCourse/MPCSession.py +308 -0
- MyPrepCourse/MyPrepCourse.py +1844 -0
- MyPrepCourse/__init__.py +112 -0
- MyPrepCourse/exceptions.py +15 -0
- MyPrepCourse/logging_utils.py +36 -0
- MyPrepCourse/py.typed +0 -0
- MyPrepCourse/types/__init__.py +116 -0
- MyPrepCourse/types/base.py +118 -0
- MyPrepCourse/types/classrooms.py +68 -0
- MyPrepCourse/types/entities.py +182 -0
- MyPrepCourse/types/enums.py +23 -0
- MyPrepCourse/types/responses.py +89 -0
- MyPrepCourse/types/scores.py +211 -0
- MyPrepCourse/types/students.py +121 -0
- MyPrepCourse/types/tests.py +360 -0
- MyPrepCourse/utils/__init__.py +1 -0
- MyPrepCourse/utils/students.py +176 -0
- myprepcourse-0.1.0.dist-info/METADATA +518 -0
- myprepcourse-0.1.0.dist-info/RECORD +21 -0
- myprepcourse-0.1.0.dist-info/WHEEL +4 -0
- myprepcourse-0.1.0.dist-info/licenses/LICENSE +7 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
import requests
|
|
9
|
+
from dateutil import parser as dateparser
|
|
10
|
+
|
|
11
|
+
from .exceptions import AuthenticationError
|
|
12
|
+
from .logging_utils import get_logger
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.myprepcourse.com"
|
|
15
|
+
DEFAULT_USER_AGENT = (
|
|
16
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
17
|
+
"(KHTML, like Gecko) Chrome/110.0.5481.78 Safari/537.36"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MPCSession:
|
|
22
|
+
"""Low-level session wrapper for making authenticated API requests."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
email: str | None = None,
|
|
27
|
+
password: str | None = None,
|
|
28
|
+
*,
|
|
29
|
+
token: str | None = None,
|
|
30
|
+
token_expiry: datetime | None = None,
|
|
31
|
+
base_url: str | None = None,
|
|
32
|
+
origin: str | None = None,
|
|
33
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
34
|
+
course_id: str | None = None,
|
|
35
|
+
headers: Mapping[str, str] | None = None,
|
|
36
|
+
timeout: int = 30,
|
|
37
|
+
retry_after_default: int = 30,
|
|
38
|
+
max_retries: int = 3,
|
|
39
|
+
logger: logging.Logger | None = None,
|
|
40
|
+
):
|
|
41
|
+
"""Initialize a session for API requests.
|
|
42
|
+
|
|
43
|
+
:param email: Account email address for authentication.
|
|
44
|
+
:param password: Account password for authentication.
|
|
45
|
+
:param token: Existing bearer token to use instead of logging in.
|
|
46
|
+
:param token_expiry: Optional token expiry timestamp.
|
|
47
|
+
:param base_url: Base URL for the API. Falls back to the
|
|
48
|
+
``MYPREPCOURSE_BASE_URL`` environment variable, then to
|
|
49
|
+
``DEFAULT_BASE_URL``.
|
|
50
|
+
:param origin: Tenant origin URL to send as the Origin header. Falls
|
|
51
|
+
back to the ``MYPREPCOURSE_ORIGIN`` environment variable.
|
|
52
|
+
:param user_agent: User agent header to include with requests.
|
|
53
|
+
:param course_id: Optional course ID header to set.
|
|
54
|
+
:param headers: Additional headers to include with requests.
|
|
55
|
+
:param timeout: Request timeout in seconds.
|
|
56
|
+
:param retry_after_default: Retry delay in seconds for rate limiting.
|
|
57
|
+
:param max_retries: Maximum number of retries for rate-limited requests.
|
|
58
|
+
:param logger: Optional logger for request logging.
|
|
59
|
+
:raises ValueError: When no origin is provided by argument or environment.
|
|
60
|
+
"""
|
|
61
|
+
self.logger = logger or get_logger("session")
|
|
62
|
+
# The MyPrepCourse API expects a double-slash path structure.
|
|
63
|
+
resolved_base_url = (
|
|
64
|
+
base_url or os.environ.get("MYPREPCOURSE_BASE_URL") or DEFAULT_BASE_URL
|
|
65
|
+
)
|
|
66
|
+
self.base_url = resolved_base_url + "//api/"
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
self.retry_after_default = retry_after_default
|
|
69
|
+
self.max_retries = max_retries
|
|
70
|
+
|
|
71
|
+
self.token = token
|
|
72
|
+
self.token_expiry = token_expiry
|
|
73
|
+
self.recent_login_response = None
|
|
74
|
+
self.user = None
|
|
75
|
+
self.origin = origin or os.environ.get("MYPREPCOURSE_ORIGIN")
|
|
76
|
+
if not self.origin:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Origin URL is required (e.g., 'https://yourorg.myprepcourse.com'). "
|
|
79
|
+
"Pass origin= or set MYPREPCOURSE_ORIGIN."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# The MyPrepCourse API validates browser-like headers and rejects
|
|
83
|
+
# requests without them, so we include standard browser headers.
|
|
84
|
+
self.headers = {
|
|
85
|
+
"Origin": self.origin,
|
|
86
|
+
"User-Agent": user_agent,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"Access-Control-Allow-Origin": "*",
|
|
89
|
+
"Accept": "application/json",
|
|
90
|
+
"Sec-Ch-Ua-Platform": "Windows",
|
|
91
|
+
"Sec-Fetch-Site": "same-site",
|
|
92
|
+
"Sec-Fetch-Mode": "cors",
|
|
93
|
+
"Sec-Fetch-Dest": "empty",
|
|
94
|
+
"Referrer": f"{self.origin.rstrip('/')}/",
|
|
95
|
+
}
|
|
96
|
+
if course_id:
|
|
97
|
+
self.headers["X-Course-Id"] = course_id
|
|
98
|
+
if headers:
|
|
99
|
+
self.headers.update(headers)
|
|
100
|
+
|
|
101
|
+
self.email = email or os.environ.get("MYPREPCOURSE_EMAIL")
|
|
102
|
+
self.password = password or os.environ.get("MYPREPCOURSE_PASSWORD")
|
|
103
|
+
|
|
104
|
+
self.session = requests.Session()
|
|
105
|
+
self.session.headers.update(self.headers)
|
|
106
|
+
|
|
107
|
+
if self.token:
|
|
108
|
+
self.update_header("Authorization", f"Bearer {self.token}")
|
|
109
|
+
|
|
110
|
+
def update_header(self, header: str, value: str) -> None:
|
|
111
|
+
"""Update a single header value for the session."""
|
|
112
|
+
self.headers[header] = value
|
|
113
|
+
self.session.headers.update(self.headers)
|
|
114
|
+
|
|
115
|
+
def set_credentials(self, email: str, password: str) -> None:
|
|
116
|
+
"""Update stored credentials for authentication."""
|
|
117
|
+
self.email = email
|
|
118
|
+
self.password = password
|
|
119
|
+
|
|
120
|
+
def set_token(self, token: str, token_expiry: datetime | None = None) -> None:
|
|
121
|
+
"""Update the bearer token and optional expiry."""
|
|
122
|
+
self.token = token
|
|
123
|
+
if token_expiry is not None:
|
|
124
|
+
self.token_expiry = token_expiry
|
|
125
|
+
self.update_header("Authorization", f"Bearer {token}")
|
|
126
|
+
|
|
127
|
+
def login(self) -> str:
|
|
128
|
+
"""Authenticate and refresh the bearer token.
|
|
129
|
+
|
|
130
|
+
:raises AuthenticationError: If no credentials are set, or the API rejects them.
|
|
131
|
+
:raises requests.RequestException: If the HTTP request fails.
|
|
132
|
+
"""
|
|
133
|
+
self.logger.info("Logging in")
|
|
134
|
+
if self.email and self.password:
|
|
135
|
+
login_data = {
|
|
136
|
+
"email": self.email,
|
|
137
|
+
"password": self.password,
|
|
138
|
+
}
|
|
139
|
+
login_url = f"{self.base_url}login"
|
|
140
|
+
self.session.headers.update(self.headers)
|
|
141
|
+
self.recent_login_response = self.session.post(
|
|
142
|
+
login_url,
|
|
143
|
+
json=login_data,
|
|
144
|
+
timeout=self.timeout,
|
|
145
|
+
)
|
|
146
|
+
response = self.recent_login_response
|
|
147
|
+
self.logger.debug(
|
|
148
|
+
"Login response <%s>: %s",
|
|
149
|
+
response.status_code,
|
|
150
|
+
response.content,
|
|
151
|
+
)
|
|
152
|
+
if response.status_code == 200:
|
|
153
|
+
self.logger.info("Login valid. Updating authorization information")
|
|
154
|
+
content = response.json()
|
|
155
|
+
self.token = content["token"]
|
|
156
|
+
self.token_expiry = dateparser.parse(content["expires_at"])
|
|
157
|
+
self.update_header("Authorization", f"Bearer {self.token}")
|
|
158
|
+
return self.token
|
|
159
|
+
raise AuthenticationError(
|
|
160
|
+
f"Login failed with status {response.status_code}: {response.text}"
|
|
161
|
+
)
|
|
162
|
+
raise AuthenticationError("Email and password are required to login.")
|
|
163
|
+
|
|
164
|
+
def logout(self) -> requests.Response:
|
|
165
|
+
"""Log out the current session.
|
|
166
|
+
|
|
167
|
+
:raises requests.RequestException: If the HTTP request fails.
|
|
168
|
+
"""
|
|
169
|
+
response = self.session.post(
|
|
170
|
+
f"{self.base_url}logout",
|
|
171
|
+
timeout=self.timeout,
|
|
172
|
+
)
|
|
173
|
+
return response
|
|
174
|
+
|
|
175
|
+
def check_token(self) -> None:
|
|
176
|
+
"""Ensure a valid token is available, re-authenticating if necessary.
|
|
177
|
+
|
|
178
|
+
:raises AuthenticationError: If the token expired and re-authentication fails.
|
|
179
|
+
"""
|
|
180
|
+
if not self.token:
|
|
181
|
+
self.logger.debug("No token set; attempting login.")
|
|
182
|
+
self.login()
|
|
183
|
+
return
|
|
184
|
+
if self.token_expiry:
|
|
185
|
+
expiry = self.token_expiry
|
|
186
|
+
if expiry.tzinfo is None:
|
|
187
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
188
|
+
if datetime.now(tz=timezone.utc) >= expiry:
|
|
189
|
+
self.logger.info("Token expired; re-authenticating.")
|
|
190
|
+
self.login()
|
|
191
|
+
|
|
192
|
+
def _request(self, method: str, url: str, data: Mapping[str, Any] | None = None) -> requests.Response:
|
|
193
|
+
"""Make an authenticated request with retry handling."""
|
|
194
|
+
self.logger.debug("%s %s: %s", method, url, data)
|
|
195
|
+
self.check_token()
|
|
196
|
+
response = self.session.request(
|
|
197
|
+
method,
|
|
198
|
+
f"{self.base_url}{url}",
|
|
199
|
+
json=data,
|
|
200
|
+
timeout=self.timeout,
|
|
201
|
+
)
|
|
202
|
+
if response.status_code == 401:
|
|
203
|
+
self.logger.info("Unauthorized, re-authenticating")
|
|
204
|
+
self.login()
|
|
205
|
+
response = self.session.request(
|
|
206
|
+
method,
|
|
207
|
+
f"{self.base_url}{url}",
|
|
208
|
+
json=data,
|
|
209
|
+
timeout=self.timeout,
|
|
210
|
+
)
|
|
211
|
+
for attempt in range(self.max_retries):
|
|
212
|
+
if response.status_code != 429:
|
|
213
|
+
break
|
|
214
|
+
retry_after = response.headers.get("Retry-After")
|
|
215
|
+
base_wait = int(retry_after) if retry_after else self.retry_after_default
|
|
216
|
+
wait_time = min(base_wait * (2 ** attempt), 120)
|
|
217
|
+
self.logger.warning(
|
|
218
|
+
"Rate limited (attempt %s/%s), waiting %s seconds",
|
|
219
|
+
attempt + 1, self.max_retries, wait_time,
|
|
220
|
+
)
|
|
221
|
+
time.sleep(wait_time)
|
|
222
|
+
response = self.session.request(
|
|
223
|
+
method,
|
|
224
|
+
f"{self.base_url}{url}",
|
|
225
|
+
json=data,
|
|
226
|
+
timeout=self.timeout,
|
|
227
|
+
)
|
|
228
|
+
return response
|
|
229
|
+
|
|
230
|
+
def get(self, url: str = "") -> requests.Response:
|
|
231
|
+
"""
|
|
232
|
+
Performs a GET request against the MyPrepCourse API, ensuring valid authentication.
|
|
233
|
+
|
|
234
|
+
:param url: The relative URL to call on the API.
|
|
235
|
+
:returns: The HTTP response object from the GET request.
|
|
236
|
+
:raises AuthenticationError: If re-authentication is required and fails.
|
|
237
|
+
:raises requests.RequestException: If the HTTP request fails (connection
|
|
238
|
+
error, timeout, or similar).
|
|
239
|
+
"""
|
|
240
|
+
return self._request("GET", url)
|
|
241
|
+
|
|
242
|
+
def get_all(self, url: str = "", per_page: int | None = 25) -> list[Mapping[str, Any]]:
|
|
243
|
+
"""
|
|
244
|
+
Performs GET requests to retrieve all paginated results from the MyPrepCourse API.
|
|
245
|
+
|
|
246
|
+
:param url: The relative URL to call on the API.
|
|
247
|
+
:param per_page: The number of items to retrieve per page. Defaults to 25.
|
|
248
|
+
:returns: A list of all items retrieved from the paginated GET requests.
|
|
249
|
+
:raises AuthenticationError: If re-authentication is required and fails.
|
|
250
|
+
:raises requests.RequestException: If the HTTP request fails (connection
|
|
251
|
+
error, timeout, or similar).
|
|
252
|
+
"""
|
|
253
|
+
all_items = []
|
|
254
|
+
page = 1
|
|
255
|
+
|
|
256
|
+
while True:
|
|
257
|
+
response = self.get(url + f"?page={page}&per_page={per_page}")
|
|
258
|
+
if not response.ok:
|
|
259
|
+
self.logger.error("Failed to retrieve data: %s from page %s", response.text, page)
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
data = response.json().get('data', [])
|
|
263
|
+
all_items.extend(data)
|
|
264
|
+
|
|
265
|
+
if page == response.json().get('meta', {}).get('last_page', page):
|
|
266
|
+
break
|
|
267
|
+
page += 1
|
|
268
|
+
|
|
269
|
+
return all_items
|
|
270
|
+
|
|
271
|
+
def post(self, url: str = "", data: Mapping[str, Any] | None = None) -> requests.Response:
|
|
272
|
+
"""
|
|
273
|
+
Performs a POST request against the MyPrepCourse API, ensuring valid authentication.
|
|
274
|
+
|
|
275
|
+
:param url: The relative URL to call on the API.
|
|
276
|
+
:param data: The data to include in the POST request.
|
|
277
|
+
:returns: The HTTP response object from the POST request.
|
|
278
|
+
:raises AuthenticationError: If re-authentication is required and fails.
|
|
279
|
+
:raises requests.RequestException: If the HTTP request fails (connection
|
|
280
|
+
error, timeout, or similar).
|
|
281
|
+
"""
|
|
282
|
+
return self._request("POST", url, data)
|
|
283
|
+
|
|
284
|
+
def patch(self, url: str = "", data: Mapping[str, Any] | None = None) -> requests.Response:
|
|
285
|
+
"""
|
|
286
|
+
Performs a PATCH request against the MyPrepCourse API, ensuring valid authentication.
|
|
287
|
+
|
|
288
|
+
:param url: The relative URL to call on the API.
|
|
289
|
+
:param data: The data to include in the PATCH request.
|
|
290
|
+
:returns: The HTTP response object from the PATCH request.
|
|
291
|
+
:raises AuthenticationError: If re-authentication is required and fails.
|
|
292
|
+
:raises requests.RequestException: If the HTTP request fails (connection
|
|
293
|
+
error, timeout, or similar).
|
|
294
|
+
"""
|
|
295
|
+
return self._request("PATCH", url, data)
|
|
296
|
+
|
|
297
|
+
def delete(self, url: str = "", data: Mapping[str, Any] | None = None) -> requests.Response:
|
|
298
|
+
"""
|
|
299
|
+
Performs a DELETE request against the MyPrepCourse API, ensuring valid authentication.
|
|
300
|
+
|
|
301
|
+
:param url: The relative URL to call on the API.
|
|
302
|
+
:param data: The data to include in the DELETE request.
|
|
303
|
+
:returns: The HTTP response object from the DELETE request.
|
|
304
|
+
:raises AuthenticationError: If re-authentication is required and fails.
|
|
305
|
+
:raises requests.RequestException: If the HTTP request fails (connection
|
|
306
|
+
error, timeout, or similar).
|
|
307
|
+
"""
|
|
308
|
+
return self._request("DELETE", url, data)
|