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.
@@ -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)