diff --git a/app/app.py b/app/app.py index 7998c9a..66d0870 100644 --- a/app/app.py +++ b/app/app.py @@ -3,7 +3,6 @@ from __future__ import annotations import argparse -import asyncio import datetime import logging from contextlib import asynccontextmanager @@ -12,7 +11,7 @@ from zoneinfo import ZoneInfo import uvicorn -from fastapi import BackgroundTasks, FastAPI +from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, RedirectResponse @@ -29,55 +28,19 @@ from app.pesu import PESUAcademy IST = ZoneInfo("Asia/Kolkata") -CSRF_TOKEN_REFRESH_INTERVAL_SECONDS = 45 * 60 -CSRF_TOKEN_REFRESH_LOCK = asyncio.Lock() - - -async def _refresh_csrf_token_with_lock() -> None: - """Refresh the CSRF token with a lock.""" - logging.debug("Refreshing unauthenticated CSRF token...") - async with CSRF_TOKEN_REFRESH_LOCK: - await pesu_academy.prefetch_client_with_csrf_token() - logging.info("Unauthenticated CSRF token refreshed successfully.") - - -async def _csrf_token_refresh_loop() -> None: - """Background task to refresh the CSRF token periodically.""" - while True: - try: - logging.debug("Refreshing unauthenticated CSRF token...") - await _refresh_csrf_token_with_lock() - except Exception: - logging.exception("Failed to refresh unauthenticated CSRF token in the background.") - await asyncio.sleep(CSRF_TOKEN_REFRESH_INTERVAL_SECONDS) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: - """Lifespan event handler for startup and shutdown events.""" - # Startup - logging.info("PESUAuth API startup") - - # Prefetch PESUAcademy client for first request - await pesu_academy.prefetch_client_with_csrf_token() - logging.info("Prefetched a new PESUAcademy client with an unauthenticated CSRF token.") - - # Start the periodic CSRF token refresh background task - refresh_task = asyncio.create_task(_csrf_token_refresh_loop()) - logging.info("Started the unauthenticated CSRF token refresh background task.") + """Lifespan event handler for FastAPI startup and shutdown events. + This manages resources or sets up logs/connections at application start + and performs necessary cleanup upon shutdown. + """ + # Startup logging when application finishes initialization + logging.info("PESUAuth API startup") yield - - # Shutdown - refresh_task.cancel() - try: - await refresh_task - except asyncio.CancelledError: - logging.debug("Unauthenticated CSRF token refresh background task cancelled.") - except Exception: - logging.exception("Failed to cancel unauthenticated CSRF token refresh background task.") - - await pesu_academy.close_client() + # Shutdown logging when application process is terminating logging.info("PESUAuth API shutdown.") @@ -102,14 +65,19 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: }, ], ) +# Instantiate the PESUAcademy interface wrapper class pesu_academy = PESUAcademy() @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: - """Handler for request validation errors.""" + """Handler for FastAPI request validation errors (e.g. malformed JSON or missing fields). + + Translates internal validation errors into a clean, client-friendly 400 Bad Request JSON response. + """ logging.exception("Request data could not be validated.") errors = exc.errors() + # Format and join location/message strings for descriptive validation reports message = "; ".join([f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in errors]) return JSONResponse( status_code=400, @@ -188,7 +156,7 @@ async def readme() -> RedirectResponse: responses=authenticate_docs.response_examples, tags=["Authentication"], ) -async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) -> JSONResponse: +async def authenticate(payload: RequestModel) -> JSONResponse: """Authenticate a user using their PESU credentials via the PESU Academy service. Request body parameters: @@ -198,14 +166,14 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) - fields (List[str], optional): Specific profile fields to include in the response. """ current_time = datetime.datetime.now(IST) - # Input has already been validated by the RequestModel + # Extract the payload variables validated by Pydantic's RequestModel username = payload.username password = payload.password profile = payload.profile know_your_class_and_section = payload.know_your_class_and_section fields = payload.fields - # Authenticate the user + # Begin authentication request call through the PESUAcademy API interface authentication_result = {"timestamp": current_time} logging.info(f"Authenticating user={username} with PESU Academy...") authentication_result.update( @@ -217,10 +185,8 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) fields=fields, ), ) - # Prefetch a new client with an unauthenticated CSRF token for the next request - background_tasks.add_task(_refresh_csrf_token_with_lock) - # Validate the response + # Validate response structure using the ResponseModel schema try: authentication_result = ResponseModel.model_validate(authentication_result) logging.info(f"Returning auth result for user={username}: {authentication_result}") @@ -263,7 +229,7 @@ def main() -> None: ) args = parser.parse_args() - # Set up logging configuration + # Setup logging configuration logging_level = logging.DEBUG if args.debug else logging.INFO logging.basicConfig( level=logging_level, @@ -271,7 +237,7 @@ def main() -> None: filemode="w", ) - # Run the app + # Launch application using Uvicorn web server uvicorn.run("app.app:app", host=args.host, port=args.port, reload=args.debug) diff --git a/app/exceptions/authentication.py b/app/exceptions/authentication.py index 62560d8..7d9a1e9 100644 --- a/app/exceptions/authentication.py +++ b/app/exceptions/authentication.py @@ -9,38 +9,3 @@ class AuthenticationError(PESUAcademyError): def __init__(self, message: str = "Invalid username or password, or user does not exist.") -> None: """Initialize the AuthenticationError with a custom message.""" super().__init__(message, status_code=401) - - -class CSRFTokenError(PESUAcademyError): - """Raised when CSRF token is missing or cannot be extracted.""" - - def __init__(self, message: str = "CSRF token could not be extracted from the response.") -> None: - """Initialize the CSRFTokenError with a custom message.""" - super().__init__(message, status_code=502) - - -class ProfileFetchError(PESUAcademyError): - """Raised when profile data could not be fetched from PESU Academy.""" - - def __init__(self, message: str = "Failed to fetch student profile page from PESU Academy.") -> None: - """Initialize the ProfileFetchError with a custom message.""" - super().__init__(message, status_code=502) - - -class ProfileParseError(PESUAcademyError): - """Raised when profile data could not be parsed from PESU Academy.""" - - def __init__(self, message: str = "Failed to parse student profile page from PESU Academy.") -> None: - """Initialize the ProfileParseError with a custom message.""" - super().__init__(message, status_code=422) - - -class KYCASFetchError(PESUAcademyError): - """Raised when "Know Your Class and Section" data could not be fetched from PESU Academy.""" - - def __init__( - self, - message: str = 'Failed to fetch "Know Your Class and Section" data from PESU Academy.', - ) -> None: - """Initialize the "Know Your Class and Section" FetchError with a custom message.""" - super().__init__(message, status_code=502) diff --git a/app/pesu.py b/app/pesu.py index bc70371..a54033d 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -1,22 +1,17 @@ -"""PESUAcademy class that serves as an interface to the PESU Academy website.""" +"""PESUAcademy class that serves as an interface to the PESU Academy Mobile API.""" -import asyncio +from __future__ import annotations + +import json import logging import re -from datetime import datetime from typing import Any, Literal, get_args import httpx -from selectolax.parser import HTMLParser, Node -from app.exceptions.authentication import ( - AuthenticationError, - CSRFTokenError, - KYCASFetchError, - ProfileFetchError, - ProfileParseError, -) +from app.exceptions.authentication import AuthenticationError +# used to to generate list of default field names ProfileField = Literal[ "name", "prn", @@ -34,324 +29,282 @@ "instituteName", ] +""" +Extract semester format (e.g. 'Sem-2') from class name or batch class. +Tries two possible source strings (class_name, then batch_class) and attempts +three regex patterns in order of specificity: + 1.Matches things like "Semester-3" or "Sem3" → extracts 3. + 2.Matches things like "3rd-Sem" → extracts 3. + 3.Falls back to just grabbing any standalone number in the string. +""" + + +def _get_semester_from_class(class_name: str | None, batch_class: str | None) -> str | None: + """Extract semester format (e.g. 'Sem-2') from class name or batch class.""" + for val in (class_name, batch_class): + if not val: + continue + if m := re.search(r"Sem(?:ester)?[-_ ]?(\d+)", val, re.IGNORECASE): + return f"Sem-{m.group(1)}" + if m := re.search(r"(\d+)(?:st|nd|rd|th)?[-_ ]?Sem", val, re.IGNORECASE): + return f"Sem-{m.group(1)}" + if m := re.search(r"\b(\d+)\b", val): + return f"Sem-{m.group(1)}" + return None + + +""" +PESU mobile api only returns B.Tech and and just CSE/ECE/EEE so to fix it we use this mapping. +""" +PROGRAM_MAPPING = { + "B.Tech.": "Bachelor of Technology", + "B.Tech": "Bachelor of Technology", + "M.Tech.": "Master of Technology", + "M.Tech": "Master of Technology", + "B.Arch.": "Bachelor of Architecture", + "B.Arch": "Bachelor of Architecture", + "BBA.": "Bachelor of Business Administration", + "BBA": "Bachelor of Business Administration", + "MBA.": "Master of Business Administration", + "MBA": "Master of Business Administration", + "BCA": "Bachelor of Computer Applications", + "BCA.": "Bachelor of Computer Applications", + "B.Com": "Bachelor of Commerce", + "B.Com.": "Bachelor of Commerce", + "MCA": "Master of Computer Applications", + "MCA.": "Master of Computer Applications", + "B.DES": "Bachelor of Design", + "B.DES.": "Bachelor of Design", +} + +BRANCH_MAPPING = { + "Branch:CSE": "Computer Science and Engineering", + "CSE": "Computer Science and Engineering", + "Branch:ECE": "Electronics and Communication Engineering", + "ECE": "Electronics and Communication Engineering", + "Branch:EEE": "Electrical and Electronics Engineering", + "EEE": "Electrical and Electronics Engineering", + "Branch:ME": "Mechanical Engineering", + "ME": "Mechanical Engineering", + "Branch:BT": "Biotechnology", + "BT": "Biotechnology", + "Branch:CSE(AI-ML)": "Computer Science and Engineering (AI&ML)", + "CSE(AI-ML)": "Computer Science and Engineering (AI&ML)", + "Branch:CSE (AI&ML)": "Computer Science and Engineering (AI&ML)", + "CSE (AI&ML)": "Computer Science and Engineering (AI&ML)", + "Branch:AIML": "Computer Science and Engineering (AI&ML)", + "AIML": "Computer Science and Engineering (AI&ML)", + "Branch:CE": "Civil Engineering", + "CE": "Civil Engineering", + "Branch:CV": "Civil Engineering", + "CV": "Civil Engineering", +} + class PESUAcademy: """Class to interact with the PESU Academy server. - This class provides methods to authenticate users, fetch profile information, and handle CSRF token management. - - Attributes: - DEFAULT_FIELDS (list[str]): The default fields to fetch from the profile page. - PROFILE_PAGE_HEADER_TO_KEY_MAP (dict[str, str]): A mapping of profile page headers to the corresponding keys - in the profile dictionary. - - Methods: - prefetch_client_with_csrf_token: Prefetch a new client with an unauthenticated CSRF token. - close_client: Close the internal client if it exists. - get_profile_information: Get the profile information of the user. - authenticate: Authenticate the user with the provided username and password. + This class provides methods to authenticate users. """ DEFAULT_FIELDS: list[str] = list(get_args(ProfileField)) - PROFILE_PAGE_HEADER_TO_KEY_MAP = { - "Name": "name", - "PESU Id": "prn", - "SRN": "srn", - "Program": "program", - "Branch": "branch", - "Semester": "semester", - "Section": "section", - } - - KYCAS_HEADER_TO_KEY_MAP = { - "PRN": "prn", - "SRN": "srn", - "Name": "name", - "Class": "semester", - "Section": "section", - "Cycle": "cycle", - "Department": "department", - "Branch": "branch", - "Institute Name": "instituteName", - } - def __init__(self) -> None: """Initialize the PESUAcademy class.""" - self._csrf_token: str | None = None - self._client: httpx.AsyncClient | None = None - self._csrf_lock = asyncio.Lock() - - @staticmethod - async def _fetch_new_client_with_csrf_token() -> tuple[httpx.AsyncClient, str]: - """Initialize a fresh client with an unauthenticated CSRF token from PESU Academy.""" - logging.info("Fetching a new client with an unauthenticated CSRF token...") - # Create a new client - client = httpx.AsyncClient(follow_redirects=True, timeout=10.0) - # Fetch the CSRF token - resp = await client.get("https://www.pesuacademy.com/Academy/") - soup = await asyncio.to_thread(HTMLParser, resp.text) - if node := soup.css_first("meta[name='csrf-token']"): - csrf_token = node.attributes["content"] - logging.info(f"Fetched CSRF token: {csrf_token}") - return client, csrf_token - raise CSRFTokenError("CSRF token not found in the pre-authentication response.") - - async def _prefetch_client_with_csrf_token(self) -> None: - """Prefetch a new client with an unauthenticated CSRF token. - - This method is used to prefetch a new client with an unauthenticated CSRF token. - It is used to avoid the overhead of fetching a new client with an unauthenticated CSRF token - for each request. - """ - logging.info("Prefetching a new client with an unauthenticated CSRF token...") - client, token = await self._fetch_new_client_with_csrf_token() - async with self._csrf_lock: - # Close old cached client (if any) to avoid leaks - if self._client is not None: - await self._client.aclose() - # Store the new cached client/token - self._client = client - self._csrf_token = token - logging.info("Cache refreshed with new unauthenticated CSRF token.") - - async def _get_client_with_csrf_token(self) -> tuple[httpx.AsyncClient, str]: - """Get the client with the cached CSRF token. - - This method is used to get the client with the cached CSRF token. - It is used to avoid the overhead of fetching a new client with an unauthenticated CSRF token - for each request. - """ - async with self._csrf_lock: - # If cache is empty (first call), populate it - if not (self._client and self._csrf_token): - ( - self._client, - self._csrf_token, - ) = await self._fetch_new_client_with_csrf_token() - # Hand out the cached client/token for *this* request - client_to_use, token_to_use = self._client, self._csrf_token - # Immediately clear the cache so the next caller doesn't reuse this client/token - self._client = None - self._csrf_token = None - - # Kick off async prefetch for the *next* request (non-blocking) - asyncio.create_task(self._prefetch_client_with_csrf_token()) - # Return a dedicated client/token for this request - return client_to_use, token_to_use - - def _extract_and_update_profile(self, node: Node, idx: int, profile: dict) -> None: - """Extract the profile data from a node and update the profile dictionary. + pass - Args: - node (Node): Pre-parsed node containing the profile information - idx (int): Index of the node - profile (dict): The profile dictionary to update in-place - """ - # Use the selector `label.lbl-title-light` to find the key label - if not (key_node := node.css_first("label.lbl-title-light")) or not (key := key_node.text(strip=True)): - raise ProfileParseError(f"Could not parse key for field at index {idx}.") - # Use the adjacent sibling selector `+` to find value label - if not (value_node := node.css_first("label.lbl-title-light + label")) or not ( - value := value_node.text(strip=True) - ): - raise ProfileParseError(f"Could not parse value for field at index {idx}.") - logging.debug(f"Extracted key: '{key}' with value: '{value}' at index {idx}.") - # If the key is in the map, add it to the profile - if mapped_key := self.PROFILE_PAGE_HEADER_TO_KEY_MAP.get(key): - logging.debug(f"Adding key: '{mapped_key}', value: '{value}' to profile...") - profile[mapped_key] = value - else: - raise ProfileParseError( - f"Unknown key: '{key}' in the profile page. The webpage might have changed.", - ) - - async def prefetch_client_with_csrf_token(self) -> None: - """Public method to prefetch a new client with an unauthenticated CSRF token. - - This method is used to prefetch a new client with an unauthenticated CSRF token. - It is used to avoid the overhead of fetching a new client with an unauthenticated CSRF token - for each request. - """ - await self._prefetch_client_with_csrf_token() - - async def close_client(self) -> None: - """Public method to close the internal client if it exists. - - This method is used to close the internal client if it exists. - It is used to avoid the overhead of closing the client for each request. - """ - if self._client is not None: - await self._client.aclose() - self._client = None - - async def get_profile_information( - self, - client: httpx.AsyncClient, - username: str, - ) -> dict[str, Any]: - """Get the profile information of the user. + """ + Raw JSON response format + { + "MESSAGE": "SUCCESS_Record found Successfully", + "STUDENT_PHOTO": { + "userId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "loginId": "PES2UG25CS026", + "status": "A", + "gender": "Male", + "email": "student@example.com", + "mobile": "9876543210", + "firstName": "JOHN", + "nameAsInSSLC": "JOHN DOE", + "programId": 1, + "branchId": 32, + "instituteName": "PES University" + } + } + """ - Args: - client (httpx.Client): The HTTP client to use for making requests. - username (str): The username of the user, usually their PRN/email/phone number. + async def _fetch_profile_details( + self, client: httpx.AsyncClient, token: str, bearer_token: str, user_id: str + ) -> dict[str, Any] | None: + """Fetch the profile details from the dispatcher endpoint. - Returns: - dict[str, Any]: A dictionary containing the user's profile information. + This returns the actual name (nameAsInSSLC) and the correct PRN (loginId) + which are accurate compared to the general login response. """ - # Fetch the profile data from the student profile page - logging.info(f"Fetching profile data for user={username} from the student profile page...") - profile_url = "https://www.pesuacademy.com/Academy/s/studentProfilePESUAdmin" - query = { - "menuId": "670", - "url": "studentProfilePESUAdmin", - "controllerMode": "6414", - "actionType": "5", - "id": "0", - "selectedData": "0", - "_": str(int(datetime.now().timestamp() * 1000)), + dispatcher_url = "https://www.pesuacademy.com/MAcademy/mobile/dispatcher" + headers = { + "mobileappauthenticationtoken": token, + "authorization": f"Bearer {bearer_token}", + } + files = { + "action": (None, "27"), + "mode": (None, "1"), + "userId": (None, user_id), + "searchUserId": (None, user_id), } - response = await client.get(profile_url, params=query) - # If the status code is not 200, raise an exception because the profile page is not accessible - if response.status_code != 200: - raise ProfileFetchError( - f"Failed to fetch student profile page from PESU Academy for user={username}.", - ) - logging.debug("Student profile page fetched successfully.") - - # Parse the response text - soup = await asyncio.to_thread(HTMLParser, response.text) - # Get the details container and its nodes where the profile information is stored - if ( - not (details_container := soup.css_first("div.elem-info-wrapper")) - or not (details_nodes := details_container.css("div.form-group")) - or len(details_nodes) < 7 - ): - raise ProfileParseError( - f"Failed to parse student profile page from PESU Academy for user={username}." - "The webpage might have changed.", - ) - - # Extract the profile information from the profile page - profile: dict[str, Any] = {} - for i in range(7): - self._extract_and_update_profile(details_nodes[i], i, profile) - - # Get the email and phone number from the profile page - if ( - (email_node := soup.css_first("#updateMail")) - and (email_value := email_node.attributes.get("value")) - and isinstance(email_value, str) - ): - profile["email"] = email_value.strip() - - if ( - (phone_node := soup.css_first("#updateContact")) - and (phone_value := phone_node.attributes.get("value")) - and isinstance(phone_value, str) - ): - profile["phone"] = phone_value.strip() - - # If username starts with PES1, then they are from RR campus, else if it is PES2, then EC campus - if profile.get("prn") and (campus_code_match := re.match(r"PES(\d)", profile["prn"])): - campus_code = campus_code_match.group(1) - profile["campusCode"] = int(campus_code) - if campus_code == "1": - profile["campus"] = "RR" - elif campus_code == "2": - profile["campus"] = "EC" - else: - logging.warning( - f"Unknown campus code: {campus_code} parsed from PRN={profile['prn']} for user={username}", - ) - # Check if we extracted any profile data - if not profile: - raise ProfileParseError(f"No profile data could be extracted for user={username}.") - logging.info(f"Complete profile information retrieved for user={username}: {profile}.") + try: + resp = await client.post(dispatcher_url, headers=headers, files=files) + if resp.status_code != 200: + return None + data = resp.json() + if isinstance(data, str): + data = json.loads(data) + + msg = data.get("MESSAGE") + if msg and "SUCCESS" in msg: + return data.get("STUDENT_PHOTO", {}) + except Exception: + # Silently fallback if any network/parsing issue occurs during profile resolution + pass + return None - return profile + """ + Example of actual raw JSON response returned by the PESU Academy mobile login API: + { + "accessToken": "eyJhbGciOiJSUzI1NiJ9...", + "mobileJsonObject": { + "userId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "userRoleId": "3", + "login": "SUCCESS", + "errorMessage": null, + "name": "JOHN DOE", + "photo": "data:image/jpeg;base64,...", + "phone": "9876543210", + "email": "student@example.com", + "program": "B.Tech.", + "branch": "Branch:CSE", + "className": "Sem-2, Section A", + "sectionName": "Section A", + "loginId": "PES2202501872", + "departmentId": "0", + "usertype": "2" + } + } + """ - async def get_know_your_class_and_section( + def _map_data( # noqa: C901 self, - client: httpx.AsyncClient, - csrf_token: str, + data: dict[str, Any], username: str, + profile: bool, + know_your_class_and_section: bool, + fields: list[str], + field_filtering: bool, + profile_details: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Get the class and section information of the user from the "Know Your Class and Section" endpoint. - - Args: - client (httpx.AsyncClient): The authenticated HTTP client to use for making requests. - csrf_token (str): The authenticated CSRF token. - username (str): The username of the user, usually their SRN or PRN. - - Returns: - dict[str, Any]: A dictionary containing the user's class and section information. + """Map the disparate data from both mobile endpoints into a single unified profile schema. + + The PESU Mobile API is fragmented. The initial login response returns a mix of + basic details, while the dispatcher endpoint provides the actual official records. + This method merges those two sources (represented by `data` and `profile_details`) + into the clean, structured format expected by the frontend clients. + + Key transformations include: + - Resolving the true PRN (Application Number) and SRN (University Roll Number), + which are returned ambiguously by the backend. + - Translating raw program/branch codes (e.g., "Branch:CSE(AI-ML)") into readable strings + ("Computer Science and Engineering (AI&ML)") using internal mappings. + - Deduce campus information (RR vs EC) dynamically based on the prefix of the PRN. + - Selectively returning only the fields requested by the client if `field_filtering` is enabled. """ - logging.info(f'Fetching class and section data for user={username} from "Know Your Class and Section" page...') - kycas_url = "https://www.pesuacademy.com/Academy/a/getStudentClassInfo" - kycas_data = {"controllerMode": "370", "actionType": "174", "loginId": username} - kycas_headers = { - "origin": "https://www.pesuacademy.com", - "referer": "https://www.pesuacademy.com/Academy/", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "x-csrf-token": csrf_token, - "x-requested-with": "XMLHttpRequest", - } - - try: - response = await client.post(kycas_url, data=kycas_data, headers=kycas_headers) - except Exception: - raise KYCASFetchError( - f'Failed to send "Know Your Class and Section" request to PESU Academy for user={username}.', - ) - - if response.status_code != 200: - raise KYCASFetchError( - f'Failed to fetch "Know Your Class and Section" data from PESU Academy for user={username}. ' - f"Received status code {response.status_code}.", - ) - - soup = await asyncio.to_thread(HTMLParser, response.text) - kycas: dict[str, Any] = {} - - table = soup.css_first("table") - if not table: - raise KYCASFetchError( - f'Could not find "Know Your Class and Section" table in the response for user={username}.', - ) - - headers = [th.text(strip=True) for th in table.css("thead th")] - if not headers: - raise KYCASFetchError( - f'Could not find "Know Your Class and Section" table headers in the response for user={username}.', - ) - - row = table.css_first("tbody tr") - if not row: - raise KYCASFetchError( - f'Could not find "Know Your Class and Section" data row in the response for user={username}.', - ) - - cells = [td.text(strip=True) for td in row.css("td")] - - if len(headers) != len(cells): - raise KYCASFetchError( - f'Mismatch between "Know Your Class and Section" table headers ({len(headers)}) ' - f"and cells ({len(cells)}) for user={username}.", - ) - - for header, cell_value in zip(headers, cells): - if mapped_key := self.KYCAS_HEADER_TO_KEY_MAP.get(header): - kycas[mapped_key] = cell_value + # --- PRN and SRN Resolution --- + # The new mobile API endpoints are notoriously confusing with identifiers: + # 1. The login endpoint (`data`) returns the Application Number (PRN) under the + # key `loginId` (e.g., PES2202501872). + # 2. The dispatcher endpoint (`profile_details`) returns the actual University SRN + # under the exact same `loginId` key (e.g., PES2UG25CS026). + # We must carefully map these to our explicit `prn` and `srn` keys to prevent downstream bugs. + prn = data.get("loginId") + srn = (profile_details.get("loginId") if profile_details else None) or data.get("departmentId") or username + campus_code = None + campus = None + + # Deduce campus code and campus name prefix from PRN (e.g. PES1... or PES2...) + if prn and (campus_code_match := re.match(r"PES(\d)", prn)): + campus_code = int(campus_code_match.group(1)) + if campus_code == 1: + campus = "RR" + elif campus_code == 2: + campus = "EC" + + # Extract and normalize semester structure + semester_val = _get_semester_from_class(data.get("className"), data.get("batchClass")) + program_raw = data.get("program") + program = PROGRAM_MAPPING.get(program_raw, program_raw) + branch_raw = data.get("branch") + branch = BRANCH_MAPPING.get(branch_raw, branch_raw) + + # Prepare base authentication response structure + result = {"status": True, "message": "Login successful."} + + # Build and map profile information block if requested + name = (profile_details.get("nameAsInSSLC") if profile_details else None) or data.get("name") + if profile: + profile_dict = { + "name": name, + "prn": prn, + "srn": srn, + "program": program, + "branch": branch, + "semester": semester_val, + "section": data.get("sectionName"), + "email": (profile_details.get("email") if profile_details else None) or data.get("email"), + "phone": (profile_details.get("mobile") if profile_details else None) or data.get("phone"), + "campusCode": campus_code, + "campus": campus, + } + # Filter profile dictionary keys if field filtering is active + if field_filtering: + profile_dict = {k: v for k, v in profile_dict.items() if k in fields} + result["profile"] = profile_dict - if not kycas: - raise KYCASFetchError( - f'No "Know Your Class and Section" data could be extracted for user={username}.', - ) + # Build and map Class and Section block if requested + if know_your_class_and_section: + # Deduce missing information originally provided by the web scraping API + kycas_branch = data.get("branch", "").replace("Branch:", "") + kycas_inst = "PES University (Electronic City)" if campus == "EC" else "PES University (Ring Road Campus)" + + kycas_cycle = "NA" + kycas_dept = branch + if semester_val in ("Sem-1", "Sem-2"): + kycas_dept = f"S & H - PESU ({campus} Campus)" + section_letter = str(data.get("sectionName", "")).replace("Section ", "").strip() + + if section_letter: + is_a_to_n = section_letter[0].upper() <= "N" + if semester_val == "Sem-1": + kycas_cycle = "Chemistry Cycle" if is_a_to_n else "Physics Cycle" + else: # Sem-2 + kycas_cycle = "Physics Cycle" if is_a_to_n else "Chemistry Cycle" + + kycas_dict = { + "prn": prn, + "srn": srn, + "name": name, + "semester": semester_val, + "section": data.get("sectionName"), + "cycle": kycas_cycle, + "department": kycas_dept, + "branch": kycas_branch, + "instituteName": kycas_inst, + } + # Filter class and section dictionary keys if field filtering is active + if field_filtering: + kycas_dict = {k: v for k, v in kycas_dict.items() if k in fields} + result["knowYourClassAndSection"] = kycas_dict - logging.info(f'"Know Your Class and Section" data retrieved for user={username}: {kycas}.') - return kycas + return result async def authenticate( self, @@ -382,82 +335,74 @@ async def authenticate( field_filtering = fields != self.DEFAULT_FIELDS logging.info( - f"Connecting to PESU Academy with user={username}, profile={profile}, fields={fields} ...", + f"Connecting to PESU Academy mobile API with user={username}, profile={profile}, fields={fields} ...", ) - # Get a pre-fetched csrf token and client - client, csrf_token = await self._get_client_with_csrf_token() - logging.debug(f"Using cached CSRF token for user={username}.") - - # Prepare the login data for auth call - data = { - "_csrf": csrf_token, - "j_username": username, - "j_password": password, + # Prepare the payload for mobile login API + login_url = "https://www.pesuacademy.com/MAcademy/mobile/mobilelogin/auth" + files = { + "userName": (None, username), + "password": (None, password), + "j_appId": (None, "YES"), + "instId": (None, "1,6,7,14"), } - logging.debug("Attempting to authenticate user...") # Make a post request to authenticate the user - auth_url = "https://www.pesuacademy.com/Academy/j_spring_security_check" - response = await client.post(auth_url, data=data) - soup = await asyncio.to_thread(HTMLParser, response.text) - logging.debug("Authentication response received.") - - # If class login-form is present, login failed - if soup.css_first("div.login-form"): - # Log the error and return the error message - raise AuthenticationError( - f"Invalid username or password, or user does not exist for user={username}.", - ) - - # If the user is successfully authenticated - logging.info(f"Login successful for user={username}.") - status = True - # Get the newly authenticated csrf token - if csrf_node := soup.css_first("meta[name='csrf-token']"): - csrf_token = csrf_node.attributes.get("content") - logging.debug(f"Authenticated CSRF token: {csrf_token}") - else: - raise CSRFTokenError( - f"CSRF token not found in the post-authentication response for user={username}.", - ) - - result = {"status": status, "message": "Login successful."} - - if profile: - logging.info(f"Profile data requested for user={username}. Fetching profile data...") - # Fetch the profile information - result["profile"] = await self.get_profile_information(client, username) - # Filter the fields if field filtering is enabled - if field_filtering: - result["profile"] = {key: value for key, value in result["profile"].items() if key in fields} - logging.info( - f"Field filtering enabled. Filtered profile data for user={username}: {result['profile']}", + async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client: + try: + response = await client.post(login_url, files=files) + except Exception as e: + raise AuthenticationError(f"Connection failed: {str(e)}") + + # Check if the response status code is 200 + if response.status_code != 200: + raise AuthenticationError( + f"Authentication failed: Server returned status code {response.status_code}", ) - if know_your_class_and_section: - logging.info( - f'"Know Your Class and Section" data requested for user={username}. ' - 'Fetching "Know Your Class and Section" data...', - ) - # Fetch the class and section information - result["knowYourClassAndSection"] = await self.get_know_your_class_and_section( - client, - csrf_token, - username, - ) - # Filter the fields if field filtering is enabled - if field_filtering: - result["knowYourClassAndSection"] = { - key: value for key, value in result["knowYourClassAndSection"].items() if key in fields - } - logging.info( - f'Field filtering enabled. Filtered "Know Your Class and Section" data for user={username}: ' - f"{result['knowYourClassAndSection']}", + # Parse the response JSON + try: + data = response.json() + except Exception: + raise AuthenticationError("Authentication failed: Invalid JSON response from server") + + if isinstance(data, str): + try: + data = json.loads(data) + except Exception: + raise AuthenticationError("Authentication failed: Invalid nested JSON response") + + # Validate the mobileJsonObject structure + mobile_obj = data.get("mobileJsonObject") if isinstance(data, dict) else None + + if not isinstance(mobile_obj, dict) or mobile_obj.get("login") != "SUCCESS": + error_msg = mobile_obj.get("errorMessage") if isinstance(mobile_obj, dict) else None + raise AuthenticationError( + error_msg or f"Invalid username or password, or user does not exist for user={username}." ) - logging.info(f"Authentication process for user={username} completed successfully.") - - # Close the client and return the result - await client.aclose() - return result + # If the user is successfully authenticated + logging.info(f"Login successful for user={username}.") + + # Return early if no profile or class section details are requested + if not profile and not know_your_class_and_section: + return {"status": True, "message": "Login successful."} + + token = response.headers.get("mobileappauthenticationtoken") or "" + bearer_token = data.get("accessToken") or mobile_obj.get("accessToken") or "" + user_id = str(mobile_obj.get("userId") or "") + + profile_details = None + if token and user_id and bearer_token: + profile_details = await self._fetch_profile_details(client, token, bearer_token, user_id) + + # Map the parsed response JSON to return format + return self._map_data( + data=mobile_obj, + username=username, + profile=profile, + know_your_class_and_section=know_your_class_and_section, + fields=fields, + field_filtering=field_filtering, + profile_details=profile_details, + ) diff --git a/pyproject.toml b/pyproject.toml index bf9bf1c..241e6fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "uvicorn>=0.47.0", "httpx>=0.28.1", "pydantic>=2.13.4", - "selectolax>=0.4.9", ] [dependency-groups] diff --git a/requirements.txt b/requirements.txt index a16303e..3b65e8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,15 +34,15 @@ pydantic==2.13.4 # fastapi pydantic-core==2.46.4 # via pydantic -selectolax==0.4.9 - # via pesu-auth (pyproject.toml) starlette==1.0.1 # via fastapi typing-extensions==4.15.0 # via + # anyio # fastapi # pydantic # pydantic-core + # starlette # typing-inspection typing-inspection==0.4.2 # via diff --git a/test_new_endpoint.py b/test_new_endpoint.py new file mode 100644 index 0000000..e918f39 --- /dev/null +++ b/test_new_endpoint.py @@ -0,0 +1,62 @@ +"""Test new endpoint script.""" + +import asyncio +import os + +import httpx +from dotenv import load_dotenv + +load_dotenv() + + +async def test_httpx() -> None: + """Run the http test against PESUAcademy endpoints.""" + url = "https://www.pesuacademy.com/MAcademy/mobile/mobilelogin/auth" + + username = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + + if not username or not password: + print("Error: TEST_EMAIL or TEST_PASSWORD not found in .env file") + return + + files = { + "userName": (None, username), + "password": (None, password), + "j_appId": (None, "YES"), + "instId": (None, "1,6,7,14"), + } + async with httpx.AsyncClient() as client: + resp = await client.post(url, files=files) + print("Login status code:", resp.status_code) + + token = resp.headers.get("mobileappauthenticationtoken") + + try: + data = resp.json() + mobile_obj = data.get("mobileJsonObject", {}) + user_id = mobile_obj.get("userId") + bearer_token = data.get("accessToken") or mobile_obj.get("accessToken") + print("Login success:", mobile_obj.get("login")) + print("Token:", token is not None) + disp_url = "https://www.pesuacademy.com/MAcademy/mobile/dispatcher" + disp_headers = {"authorization": f"Bearer {bearer_token}", "mobileappauthenticationtoken": token} + disp_files = { + "action": (None, "27"), + "mode": (None, "1"), + "userId": (None, user_id), + "searchUserId": (None, user_id), + } + disp_resp = await client.post(disp_url, headers=disp_headers, files=disp_files) + print("Dispatcher status code:", disp_resp.status_code) + disp_data = disp_resp.json() + print("Dispatcher message:", disp_data.get("MESSAGE")) + student = disp_data.get("STUDENT_PHOTO", {}) + print("Name:", student.get("nameAsInSSLC")) + + except Exception as e: + print("Error parsing json:", e) + print(resp.text) + + +asyncio.run(test_httpx()) diff --git a/uv.lock b/uv.lock index 26d88a0..cd8daa0 100644 --- a/uv.lock +++ b/uv.lock @@ -717,7 +717,6 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "pydantic" }, - { name = "selectolax" }, { name = "uvicorn" }, ] @@ -743,7 +742,6 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.136.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.13.4" }, - { name = "selectolax", specifier = ">=0.4.9" }, { name = "uvicorn", specifier = ">=0.47.0" }, ] @@ -1137,50 +1135,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, ] -[[package]] -name = "selectolax" -version = "0.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/46/b2956203e943c6417ff7da9ad9bcb5860d60372de6e05934af1b3f0a4950/selectolax-0.4.9.tar.gz", hash = "sha256:15c3de54f75d0d13726fee140bbfec8805050a3d34d298f2e9c05c9da1b87e7a", size = 4879844, upload-time = "2026-05-15T08:59:12.802Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/a9/ef84d5879bdc25ee830e57d030b559bc9e86e9d8f25f6c315511a39c5a67/selectolax-0.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee28b8f1326e3504a055a8f8a894e1180f5d3522f61391670c8b2d5e768cbc2", size = 2241162, upload-time = "2026-05-15T08:57:55.593Z" }, - { url = "https://files.pythonhosted.org/packages/40/1d/22554d2ba0d818ba07402dbab9561ff3b438fb8272727e7dfd21e6cad926/selectolax-0.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:668e243607d8bc2d130283506333f63b65bfba83bb32a59cf89f634031f02e0d", size = 2292306, upload-time = "2026-05-15T08:57:57.838Z" }, - { url = "https://files.pythonhosted.org/packages/83/5c/8ef75e1ee59df6f640f79fcf7abc2df012a67587ce0890e5be96e06276ca/selectolax-0.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9451274ccdb46f691f90538e2ede26e67d20f1ab17aa25083db4983208451609", size = 2374496, upload-time = "2026-05-15T08:57:59.69Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f2/bd9e07a8e51b8f4ddcfe4b2f6bbd7100d763d784910eebe15e48bfa813db/selectolax-0.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c37ae18495338320064920144185e3a434d780ddc8654a44f2c611b2e1aa291", size = 2421157, upload-time = "2026-05-15T08:58:01.695Z" }, - { url = "https://files.pythonhosted.org/packages/ba/db/8c715ef0e58cf7a38cd91610a45c19cdb78314584881eaf1499198f69095/selectolax-0.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:77fd2ab190a5a13ce1c3f616538f325bd0f51760c924c3244f27897d23f0817a", size = 2378875, upload-time = "2026-05-15T08:58:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/7e/f9/55ac766dcf574e5f590fc0774486f7bd52e6e5c08c364f08cc18626533c1/selectolax-0.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:428bbfc0375607dc07ef9086ff6c7fb8e6af051876409e3fcac7d8c856b0fa91", size = 2441299, upload-time = "2026-05-15T08:58:05.288Z" }, - { url = "https://files.pythonhosted.org/packages/c8/06/d8d1a0bfb17ec697904bd33be72d9d29e322359f167aaf111e3ce9026131/selectolax-0.4.9-cp312-cp312-win32.whl", hash = "sha256:7d1afdd14451d7a0d9e095e28df2af11ea2066821170aaca1d7db0e56a44f5dc", size = 1763258, upload-time = "2026-05-15T08:58:06.935Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f5/0727cae946c6dc387f683357c3a48e3f39443f3f0b076513eb4bc4766d3f/selectolax-0.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:592bba3ea66658b2080a6978839887eb1a890d8244441f64b9baf0ca74ed7918", size = 1859646, upload-time = "2026-05-15T08:58:08.417Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/f6995eedda5f3b591d57ab1cf964de0dca01cf77e11ce8433ca9bafc11bd/selectolax-0.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:9ececc7acdb4d16d217d83a55c41e0f4f74fb0e74858005a542e7bd88090aa0d", size = 1809708, upload-time = "2026-05-15T08:58:09.908Z" }, - { url = "https://files.pythonhosted.org/packages/b3/aa/cf3ef1ae4f9a53f53a0add5dcf7c5b241f56f9a778feb7c13cf65e73331b/selectolax-0.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1ca6686d0199ca3ef2c919421116d9d34f56964e2162a0d8757a4e9a7bdfb4a", size = 2240742, upload-time = "2026-05-15T08:58:11.596Z" }, - { url = "https://files.pythonhosted.org/packages/da/81/16ff9754e720769fe3f723baee9e207b2f7b7cf2612d509a9ae84b357ec5/selectolax-0.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f49b7391544f60cc891587bcc504daed4995be2b02af9c6e3054a564a1b25d0", size = 2291271, upload-time = "2026-05-15T08:58:13.057Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c0/b9dd13726bb13a6d439c1fef14d70345da9d15155f058edf9bb124952cd2/selectolax-0.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6833df2a2f74cd89872a2915f5ba6b9e56e4bed31c0b3e26b593ca086f10d298", size = 2373955, upload-time = "2026-05-15T08:58:14.983Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d6/aad9d222d2599bad16f1ee936ccddec3e5e8299a140ea56af2359ed72631/selectolax-0.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:010ff30cc2266074801d92fa717a3c333cc1cf9c98b699a46fc649a9707acc69", size = 2420661, upload-time = "2026-05-15T08:58:16.437Z" }, - { url = "https://files.pythonhosted.org/packages/50/cc/07b6190d0b20627880c612450d1211ff22cfc9ad1e845882846b0f97be97/selectolax-0.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:395261e997f0d1d9d0232cf9ba7d69f46229e027ad7eb3ad0afba57c8eec7413", size = 2378677, upload-time = "2026-05-15T08:58:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/de/70/a2592179765f948146952fbea67ccf9b8e32f125847bf30632b0a8574af5/selectolax-0.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:19ee1f7de842be9dcdae7c7d27b01c91a0a717a06b136984234bc5b3048fc3d4", size = 2440935, upload-time = "2026-05-15T08:58:20.129Z" }, - { url = "https://files.pythonhosted.org/packages/01/76/4d8ceecdb2265ec3a2e64bef83e8a2d96b7c8d9b3505dc504b02be64c684/selectolax-0.4.9-cp313-cp313-win32.whl", hash = "sha256:2cd98512c3de597a6b5db9bd90d10b35244c4ba7b99c1149874111f453ae82d2", size = 1763205, upload-time = "2026-05-15T08:58:21.663Z" }, - { url = "https://files.pythonhosted.org/packages/28/23/9ac462b19e296a426e7b3fecc2ce65cf3c2db6d405653a96a0a978e1ae44/selectolax-0.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:35097ce93a954a95ac82d930758cfc32c03421c0f173de7185e6381fbd00be28", size = 1861512, upload-time = "2026-05-15T08:58:23.792Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f9/cda79bdea85a231cc1c9aafb0c21fda0d56b056b67b1e571c26a5c39f832/selectolax-0.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:bca470e8f6bf6b4b41f841e87fcc729c9c2fbed20850ef4bb1b7b9055c1f8a55", size = 1809709, upload-time = "2026-05-15T08:58:25.623Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/fe04c5466577bd23433f5ea8de16a3e1aa273b547616a6141591641c109c/selectolax-0.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b65f589b8e613089e77b6f735d956f6690021c66acaba0973df0e59b7fb6ab22", size = 2256543, upload-time = "2026-05-15T08:58:27.333Z" }, - { url = "https://files.pythonhosted.org/packages/53/aa/95211bc61a84d2f42678791654bde6a3526520f423c1450b735ef54bcf8d/selectolax-0.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1a2a70a02960efc7112a99b81031fb60bca557e62f99806dd3a6354e6c888e3", size = 2308274, upload-time = "2026-05-15T08:58:28.961Z" }, - { url = "https://files.pythonhosted.org/packages/57/c7/f1c63a427346f27ca9a94c63f5f6f83a0130e011f7d6e0bd44a9e073cb31/selectolax-0.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85c463dbd4f1a5b9a1219ea7895ed3307231cb4edcf46e60de99dde93d9dc65b", size = 2373791, upload-time = "2026-05-15T08:58:30.462Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e5/a86685d5bf6ae5d8d1068247aae9a64e5b455b2ffe0d7178d1d780fbcbf3/selectolax-0.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:432dca308603fcb39600fcbf9e02d8917596fde2d7b0fd752d437953bbb83ff0", size = 2419455, upload-time = "2026-05-15T08:58:32.07Z" }, - { url = "https://files.pythonhosted.org/packages/3b/01/14b5a9a425438d8a40c97b08d3221dd2315470ac6edf586abe1c49f3f8b0/selectolax-0.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:25da6d4afec67efcb5b9f228a7f318163e65bc7f4956448cfc994b45894a062f", size = 2394963, upload-time = "2026-05-15T08:58:33.748Z" }, - { url = "https://files.pythonhosted.org/packages/90/f2/6b0b7434b850415adbe050e82d20a6f119dd05c912022a9964b4db47b880/selectolax-0.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:718872445d44b58b0e6bc029993ac79396b5588b13d0c12c8b585f5a1a490c2d", size = 2458094, upload-time = "2026-05-15T08:58:35.553Z" }, - { url = "https://files.pythonhosted.org/packages/53/0d/3b36c76e478deb6fdc7deb105e99e68a7eecef716611a3ef6776472e3057/selectolax-0.4.9-cp314-cp314-win32.whl", hash = "sha256:2affc47bbe2faff46410ce4edd82e9e6fe6a7ac7a84b4e572c1b22fc2fb1832f", size = 1874907, upload-time = "2026-05-15T08:58:37.162Z" }, - { url = "https://files.pythonhosted.org/packages/f7/8c/d140ed3bb7ab6254628e8078c6c7f42d6f857436548c5a1851d6d8e1ccf1/selectolax-0.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0077fe3d5324a345839c8c9da7ac41b577e13a08266b4a24e7c25e16fb2b6229", size = 1969835, upload-time = "2026-05-15T08:58:39.017Z" }, - { url = "https://files.pythonhosted.org/packages/23/f6/97f68770f73b9d65b3584e12d588fda3e4ee86dcfdcbfb05de7c4c6ccf0c/selectolax-0.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:f214d7c10112c818506e2aa72b309acee76e23b6ff677e91e5fd12fb60176abd", size = 1920477, upload-time = "2026-05-15T08:58:40.982Z" }, - { url = "https://files.pythonhosted.org/packages/17/38/e0f0bdacc94dc868e4f7b542f459a34bb71330b1351cfe177cfa749abfee/selectolax-0.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fb3eef8d522534b59498e2aff67d30b879ffb70961406fc6359336d846409f24", size = 2271971, upload-time = "2026-05-15T08:58:43.055Z" }, - { url = "https://files.pythonhosted.org/packages/99/b0/6fd60519f100051e58f83c604452c997b0ce4e25c279405bdd80ba1c8ef9/selectolax-0.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dd270f3c008eddd9b93d6875b05fda569b9943e94b5c918ce5d06123330d35ec", size = 2317744, upload-time = "2026-05-15T08:58:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/f1/5e/f95857eab0a0e213f46eec42ae74cfe939b8aae507e23af3fa17e111ba0a/selectolax-0.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fe69c7d4a510574746fae68d00016d4ff10cffdba845dfa2a40296534390a91", size = 2378938, upload-time = "2026-05-15T08:58:45.975Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/92f66653993e9b2006dc37c202dbfa0e49803266dead36301461957e6345/selectolax-0.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8720dce153464c1d8172160a2dd96e97dfae0a64534aa2b54eafbe8371ba85b1", size = 2429671, upload-time = "2026-05-15T08:58:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/2a/f2/4d0e8a5b875ca822121a4f3cba66a652379903e2cff1beaace48fa7004db/selectolax-0.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:44d07709c7ddc49702af5661d717aaeab469121328ce1131e182bbe98020c504", size = 2404125, upload-time = "2026-05-15T08:58:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/16/4e/99b4962e5a4171c32ae6747a8d84d5d549a85e9cc48a1f8200e35415acec/selectolax-0.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:919fbb7ba30e9e172913e029e453be68215122bbb3e7e8cedf524e525c24556b", size = 2466287, upload-time = "2026-05-15T08:58:50.889Z" }, - { url = "https://files.pythonhosted.org/packages/71/f4/8a764ebfd95bc1c30d542f32d8eda19e78baa07e2de8f6c355b11de6ee56/selectolax-0.4.9-cp314-cp314t-win32.whl", hash = "sha256:596513c191ec4771357abc21160414e2a0054f064ba543e33e1a7ebd35c96ea6", size = 1923506, upload-time = "2026-05-15T08:58:52.528Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7c/aa021b3ed7c9fce5f16e0a0b4c8897e7970772ba8688754e5813214017d1/selectolax-0.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:008206609f542cbad133570330e51cb4bb666d532b05111914072673de60f33a", size = 2038829, upload-time = "2026-05-15T08:58:53.904Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/525f6a3560d13199d65f1c9f1816ad307d4ed2e1ad05daba08e024fc5364/selectolax-0.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:7ab014959dcf68a0be71484dc87b8d4002756c979836d1d873ba81302b6bffe0", size = 1943672, upload-time = "2026-05-15T08:58:55.808Z" }, -] - [[package]] name = "six" version = "1.17.0"