From 0cf1507a5f636c2b000a6780eb5a3c0253d401c7 Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Thu, 14 May 2026 22:20:27 +0530 Subject: [PATCH 01/13] feat: add KYCAS --- app/app.py | 4 +- app/docs/authenticate.py | 50 +++++++++++++ app/exceptions/authentication.py | 8 +++ app/models/__init__.py | 1 + app/models/kycas.py | 66 +++++++++++++++++ app/models/request.py | 7 ++ app/models/response.py | 9 ++- app/pesu.py | 118 +++++++++++++++++++++++++++++++ 8 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 app/models/kycas.py diff --git a/app/app.py b/app/app.py index 91e8ecc..aba6e72 100644 --- a/app/app.py +++ b/app/app.py @@ -194,6 +194,7 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) 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 @@ -204,6 +205,7 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) username=username, password=password, profile=profile, + know_your_class_and_section=know_your_class_and_section, fields=fields, ), ) @@ -214,7 +216,7 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) try: authentication_result = ResponseModel.model_validate(authentication_result) logging.info(f"Returning auth result for user={username}: {authentication_result}") - authentication_result = authentication_result.model_dump(exclude_none=True) + authentication_result = authentication_result.model_dump(exclude_none=True, by_alias=True) authentication_result["timestamp"] = current_time.isoformat() return JSONResponse( status_code=200, diff --git a/app/docs/authenticate.py b/app/docs/authenticate.py index 33d6521..4a7ef4f 100644 --- a/app/docs/authenticate.py +++ b/app/docs/authenticate.py @@ -23,6 +23,16 @@ "profile": True, }, }, + "auth_with_class_and_section": { + "summary": "Authentication with KYCAS", + "description": "Authentication with Know Your Class and Section data", + "value": { + "username": "PES1201800001", + "password": "mySecurePassword123", + "profile": True, + "know_your_class_and_section": True, + }, + }, "phone_auth_selective_fields": { "summary": "Authentication with Selected Fields", "description": "Authentication using username and requesting specific profile data fields", @@ -74,6 +84,38 @@ }, }, }, + "authentication_with_kycas": { + "summary": "Authentication with KYCAS", + "value": { + "status": True, + "message": "Login successful.", + "timestamp": "2024-07-28T22:30:10.103368+05:30", + "profile": { + "name": "John Doe", + "prn": "PESXXYYZZZZZ", + "srn": "PESXXUGYYZZZ", + "program": "Bachelor of Technology", + "branch": "Computer Science and Engineering", + "semester": "2", + "section": "C", + "email": "johndoe@gmail.com", + "phone": "1234567890", + "campus_code": 1, + "campus": "RR", + }, + "know_your_class_and_section": { + "prn": "PESXXYYZZZZZ", + "srn": "PESXXUGYYZZZ", + "name": "John Doe", + "class": "Sem-X", + "section": "Section X", + "cycle": "NA", + "department": "Computer Science and Engineering", + "branch": "CSE", + "institute_name": "PES University", + }, + }, + }, "authentication_with_selected_fields": { "summary": "Authentication with Selected Fields", "value": { @@ -167,6 +209,14 @@ "timestamp": "2024-07-28T22:30:10.103368+05:30", }, }, + "kycas_fetch_error": { + "summary": "KYCAS page fetching failed", + "value": { + "status": False, + "message": "Failed to fetch Know Your Class and Section data from PESU Academy.", + "timestamp": "2024-07-28T22:30:10.103368+05:30", + }, + }, } } }, diff --git a/app/exceptions/authentication.py b/app/exceptions/authentication.py index 03833c8..f03c479 100644 --- a/app/exceptions/authentication.py +++ b/app/exceptions/authentication.py @@ -33,3 +33,11 @@ class ProfileParseError(PESUAcademyError): 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 KYCASFetchError with a custom message.""" + super().__init__(message, status_code=502) diff --git a/app/models/__init__.py b/app/models/__init__.py index c06043f..3791feb 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ """Custom models for the PESUAuth API.""" +from .kycas import KYCASModel as KYCASModel from .profile import ProfileModel as ProfileModel from .request import RequestModel as RequestModel from .response import ResponseModel as ResponseModel diff --git a/app/models/kycas.py b/app/models/kycas.py new file mode 100644 index 0000000..30378b1 --- /dev/null +++ b/app/models/kycas.py @@ -0,0 +1,66 @@ +"""Model representing the Know Your Class and Section data returned after successful authentication.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class KYCASModel(BaseModel): + """Model representing the Know Your Class and Section data.""" + + model_config = ConfigDict(populate_by_name=True) + + prn: str | None = Field( + None, + title="PRN", + description="PRN of the user.", + json_schema_extra={"example": "PESXXYYZZZZZ"}, + ) + srn: str | None = Field( + None, + title="SRN", + description="SRN of the user.", + json_schema_extra={"example": "PESXXUGYYZZZ"}, + ) + name: str | None = Field( + None, + title="Name", + description="Full name of the user.", + json_schema_extra={"example": "John Doe"}, + ) + class_field: str | None = Field( + None, + validation_alias="class", + serialization_alias="class", + title="Class", + description="Class of the user.", + json_schema_extra={"example": "Sem-X"}, + ) + section: str | None = Field( + None, + title="Section", + description="Section the user belongs to.", + json_schema_extra={"example": "Section X"}, + ) + cycle: str | None = Field( + None, + title="Cycle", + description="Cycle of the user.", + json_schema_extra={"example": "NA"}, + ) + department: str | None = Field( + None, + title="Department", + description="Department of the user.", + json_schema_extra={"example": "Computer Science and Engineering"}, + ) + branch: str | None = Field( + None, + title="Branch", + description="Branch short code of the user.", + json_schema_extra={"example": "CSE"}, + ) + institute_name: str | None = Field( + None, + title="Institute Name", + description="Institute name of the user.", + json_schema_extra={"example": "PES University"}, + ) diff --git a/app/models/request.py b/app/models/request.py index eba390c..284b26c 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -33,6 +33,13 @@ class RequestModel(BaseModel): json_schema_extra={"example": True}, ) + know_your_class_and_section: bool = Field( + False, + title="Know Your Class and Section Flag", + description="Whether to fetch the user's class and section information.", + json_schema_extra={"example": True}, + ) + fields: list[Literal[*PESUAcademy.DEFAULT_FIELDS]] | None = Field( None, title="Profile Fields", diff --git a/app/models/response.py b/app/models/response.py index 686e105..5aadc8a 100644 --- a/app/models/response.py +++ b/app/models/response.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field -from app.models import ProfileModel +from app.models import KYCASModel, ProfileModel class ResponseModel(BaseModel): @@ -38,3 +38,10 @@ class ResponseModel(BaseModel): title="User Profile Data", description="The user's profile data returned only if authentication succeeds and profile data was requested.", ) + + know_your_class_and_section: KYCASModel | None = Field( + None, + title="Know Your Class and Section Data", + description="The user's class and section data returned only if authentication succeeds" + " and class/section data was requested.", + ) diff --git a/app/pesu.py b/app/pesu.py index 2f1941a..c346043 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -12,6 +12,7 @@ from app.exceptions.authentication import ( AuthenticationError, CSRFTokenError, + KYCASFetchError, ProfileFetchError, ProfileParseError, ) @@ -46,6 +47,10 @@ class PESUAcademy: "phone", "campus_code", "campus", + "class", + "cycle", + "department", + "institute_name", ] PROFILE_PAGE_HEADER_TO_KEY_MAP = { @@ -58,6 +63,18 @@ class PESUAcademy: "Section": "section", } + KYCAS_HEADER_TO_KEY_MAP = { + "PRN": "prn", + "SRN": "srn", + "Name": "name", + "Class": "class", + "Section": "section", + "Cycle": "cycle", + "Department": "department", + "Branch": "branch", + "Institute Name": "institute_name", + } + def __init__(self) -> None: """Initialize the PESUAcademy class.""" self._csrf_token: str | None = None @@ -254,11 +271,92 @@ async def get_profile_information( return profile + async def get_know_your_class_and_section( + self, + client: httpx.AsyncClient, + csrf_token: str, + username: str, + ) -> dict[str, Any]: + """Get the class and section information of the user from the Know Your Class and Section page. + + 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. + """ + logging.info(f"Fetching class and section data for user={username} from KYCAS 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 KYCAS request to PESU Academy for user={username}.", + ) + + if response.status_code != 200: + raise KYCASFetchError( + f"Failed to fetch KYCAS 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 KYCAS 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 KYCAS table headers in the response for user={username}.", + ) + + row = table.css_first("tbody tr") + if not row: + raise KYCASFetchError( + f"Could not find KYCAS 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 KYCAS table headers ({len(headers)}) 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 + + if not kycas: + raise KYCASFetchError( + f"No KYCAS data could be extracted for user={username}.", + ) + + logging.info(f"KYCAS data retrieved for user={username}: {kycas}.") + return kycas + async def authenticate( self, username: str, password: str, profile: bool = False, + know_your_class_and_section: bool = False, fields: list[str] | None = None, ) -> dict[str, Any]: """Authenticate the user with the provided username and password. @@ -267,6 +365,8 @@ async def authenticate( username (str): The username of the user, usually their PRN/email/phone number. password (str): The password of the user. profile (bool, optional): Whether to fetch the profile information or not. Defaults to False. + know_your_class_and_section (bool, optional): Whether to fetch the class and section + information or not. Defaults to False. fields (Optional[list[str]], optional): The fields to fetch from the profile. Defaults to None, which means all default fields will be fetched. @@ -333,6 +433,24 @@ async def authenticate( f"Field filtering enabled. Filtered profile data for user={username}: {result['profile']}", ) + if know_your_class_and_section: + logging.info(f"KYCAS data requested for user={username}. Fetching KYCAS data...") + # Fetch the class and section information + result["know_your_class_and_section"] = await self.get_know_your_class_and_section( + client, + csrf_token, + username, + ) + # Filter the fields if field filtering is enabled + if field_filtering: + result["know_your_class_and_section"] = { + key: value for key, value in result["know_your_class_and_section"].items() if key in fields + } + logging.info( + f"Field filtering enabled. Filtered KYCAS data for user={username}: " + f"{result['know_your_class_and_section']}", + ) + logging.info(f"Authentication process for user={username} completed successfully.") # Close the client and return the result From c48ae99cd93fa7a2054e8f7fa2964b4fa67bee97 Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Wed, 20 May 2026 20:47:35 +0530 Subject: [PATCH 02/13] fix: expanding and quoting the KYCAS acronym --- app/docs/authenticate.py | 8 ++++---- app/exceptions/authentication.py | 4 ++-- app/models/kycas.py | 10 +++++----- app/models/request.py | 2 +- app/models/response.py | 5 ++--- app/pesu.py | 5 ++--- 6 files changed, 16 insertions(+), 18 deletions(-) diff --git a/app/docs/authenticate.py b/app/docs/authenticate.py index 4a7ef4f..1ed0ad1 100644 --- a/app/docs/authenticate.py +++ b/app/docs/authenticate.py @@ -24,8 +24,8 @@ }, }, "auth_with_class_and_section": { - "summary": "Authentication with KYCAS", - "description": "Authentication with Know Your Class and Section data", + "summary": "Authentication with \"Know Your Class and Section\"", + "description": "Authentication with \"Know Your Class and Section\" data", "value": { "username": "PES1201800001", "password": "mySecurePassword123", @@ -85,7 +85,7 @@ }, }, "authentication_with_kycas": { - "summary": "Authentication with KYCAS", + "summary": "Authentication with \"Know Your Class and Section\"", "value": { "status": True, "message": "Login successful.", @@ -210,7 +210,7 @@ }, }, "kycas_fetch_error": { - "summary": "KYCAS page fetching failed", + "summary": "\"Know Your Class and Section\" page fetching failed", "value": { "status": False, "message": "Failed to fetch Know Your Class and Section data from PESU Academy.", diff --git a/app/exceptions/authentication.py b/app/exceptions/authentication.py index f03c479..a141ef9 100644 --- a/app/exceptions/authentication.py +++ b/app/exceptions/authentication.py @@ -36,8 +36,8 @@ def __init__(self, message: str = "Failed to parse student profile page from PES class KYCASFetchError(PESUAcademyError): - """Raised when Know Your Class and Section data could not be fetched from PESU Academy.""" + """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 KYCASFetchError with a custom message.""" + """Initialize the "Know Your Class and Section" FetchError with a custom message.""" super().__init__(message, status_code=502) diff --git a/app/models/kycas.py b/app/models/kycas.py index 30378b1..71d09d4 100644 --- a/app/models/kycas.py +++ b/app/models/kycas.py @@ -1,10 +1,10 @@ -"""Model representing the Know Your Class and Section data returned after successful authentication.""" +"""Model representing the "Know Your Class and Section" data returned after successful authentication.""" from pydantic import BaseModel, ConfigDict, Field class KYCASModel(BaseModel): - """Model representing the Know Your Class and Section data.""" + """Model representing the "Know Your Class and Section" data.""" model_config = ConfigDict(populate_by_name=True) @@ -31,7 +31,7 @@ class KYCASModel(BaseModel): validation_alias="class", serialization_alias="class", title="Class", - description="Class of the user.", + description="Class the user belongs to.", json_schema_extra={"example": "Sem-X"}, ) section: str | None = Field( @@ -49,7 +49,7 @@ class KYCASModel(BaseModel): department: str | None = Field( None, title="Department", - description="Department of the user.", + description="Department the user belongs to.", json_schema_extra={"example": "Computer Science and Engineering"}, ) branch: str | None = Field( @@ -61,6 +61,6 @@ class KYCASModel(BaseModel): institute_name: str | None = Field( None, title="Institute Name", - description="Institute name of the user.", + description="Institute the user belongs to.", json_schema_extra={"example": "PES University"}, ) diff --git a/app/models/request.py b/app/models/request.py index 284b26c..fb4b960 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -36,7 +36,7 @@ class RequestModel(BaseModel): know_your_class_and_section: bool = Field( False, title="Know Your Class and Section Flag", - description="Whether to fetch the user's class and section information.", + description="Whether to fetch the user's class and section information from the \"Know Your Class and Section\" endpoint.", json_schema_extra={"example": True}, ) diff --git a/app/models/response.py b/app/models/response.py index 5aadc8a..2c46c94 100644 --- a/app/models/response.py +++ b/app/models/response.py @@ -41,7 +41,6 @@ class ResponseModel(BaseModel): know_your_class_and_section: KYCASModel | None = Field( None, - title="Know Your Class and Section Data", - description="The user's class and section data returned only if authentication succeeds" - " and class/section data was requested.", + title="\"Know Your Class and Section\" Data", + description="The user's class and section data from the \"Know Your Class and Section\" endpoint returned only if authentication succeeds.", ) diff --git a/app/pesu.py b/app/pesu.py index c346043..4030530 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -277,7 +277,7 @@ async def get_know_your_class_and_section( csrf_token: str, username: str, ) -> dict[str, Any]: - """Get the class and section information of the user from the Know Your Class and Section page. + """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. @@ -365,8 +365,7 @@ async def authenticate( username (str): The username of the user, usually their PRN/email/phone number. password (str): The password of the user. profile (bool, optional): Whether to fetch the profile information or not. Defaults to False. - know_your_class_and_section (bool, optional): Whether to fetch the class and section - information or not. Defaults to False. + know_your_class_and_section (bool, optional): Whether to fetch from the "Know Your Class and Section" endpoint or not. Defaults to False. fields (Optional[list[str]], optional): The fields to fetch from the profile. Defaults to None, which means all default fields will be fetched. From e9a33beeeb98aae87b52e9cc9f4859bbc70052ad Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Wed, 20 May 2026 20:49:11 +0530 Subject: [PATCH 03/13] fix: formatting --- app/docs/authenticate.py | 8 ++++---- app/models/request.py | 2 +- app/models/response.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/docs/authenticate.py b/app/docs/authenticate.py index 1ed0ad1..730550e 100644 --- a/app/docs/authenticate.py +++ b/app/docs/authenticate.py @@ -24,8 +24,8 @@ }, }, "auth_with_class_and_section": { - "summary": "Authentication with \"Know Your Class and Section\"", - "description": "Authentication with \"Know Your Class and Section\" data", + "summary": 'Authentication with "Know Your Class and Section"', + "description": 'Authentication with "Know Your Class and Section" data', "value": { "username": "PES1201800001", "password": "mySecurePassword123", @@ -85,7 +85,7 @@ }, }, "authentication_with_kycas": { - "summary": "Authentication with \"Know Your Class and Section\"", + "summary": 'Authentication with "Know Your Class and Section"', "value": { "status": True, "message": "Login successful.", @@ -210,7 +210,7 @@ }, }, "kycas_fetch_error": { - "summary": "\"Know Your Class and Section\" page fetching failed", + "summary": '"Know Your Class and Section" page fetching failed', "value": { "status": False, "message": "Failed to fetch Know Your Class and Section data from PESU Academy.", diff --git a/app/models/request.py b/app/models/request.py index fb4b960..8ef39d3 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -36,7 +36,7 @@ class RequestModel(BaseModel): know_your_class_and_section: bool = Field( False, title="Know Your Class and Section Flag", - description="Whether to fetch the user's class and section information from the \"Know Your Class and Section\" endpoint.", + description='Whether to fetch the user\'s class and section information from the "Know Your Class and Section" endpoint.', json_schema_extra={"example": True}, ) diff --git a/app/models/response.py b/app/models/response.py index 2c46c94..57d0890 100644 --- a/app/models/response.py +++ b/app/models/response.py @@ -41,6 +41,6 @@ class ResponseModel(BaseModel): know_your_class_and_section: KYCASModel | None = Field( None, - title="\"Know Your Class and Section\" Data", - description="The user's class and section data from the \"Know Your Class and Section\" endpoint returned only if authentication succeeds.", + title='"Know Your Class and Section" Data', + description='The user\'s class and section data from the "Know Your Class and Section" endpoint returned only if authentication succeeds.', ) From a7ce4670dae38e83cccc63c89717e998cb4f70d3 Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Wed, 20 May 2026 20:55:38 +0530 Subject: [PATCH 04/13] fix: formatting --- app/models/request.py | 5 ++++- app/models/response.py | 5 ++++- app/pesu.py | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/models/request.py b/app/models/request.py index 8ef39d3..f0b1f83 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -36,7 +36,10 @@ class RequestModel(BaseModel): know_your_class_and_section: bool = Field( False, title="Know Your Class and Section Flag", - description='Whether to fetch the user\'s class and section information from the "Know Your Class and Section" endpoint.', + description=( + "Whether to fetch the user's class and section information from the " + '"Know Your Class and Section" endpoint.' + ), json_schema_extra={"example": True}, ) diff --git a/app/models/response.py b/app/models/response.py index 57d0890..e7a5b6a 100644 --- a/app/models/response.py +++ b/app/models/response.py @@ -42,5 +42,8 @@ class ResponseModel(BaseModel): know_your_class_and_section: KYCASModel | None = Field( None, title='"Know Your Class and Section" Data', - description='The user\'s class and section data from the "Know Your Class and Section" endpoint returned only if authentication succeeds.', + description=( + "The user's class and section data from the " + '"Know Your Class and Section" endpoint returned only if authentication succeeds.' + ), ) diff --git a/app/pesu.py b/app/pesu.py index 4030530..0ff1bad 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -365,7 +365,8 @@ async def authenticate( username (str): The username of the user, usually their PRN/email/phone number. password (str): The password of the user. profile (bool, optional): Whether to fetch the profile information or not. Defaults to False. - know_your_class_and_section (bool, optional): Whether to fetch from the "Know Your Class and Section" endpoint or not. Defaults to False. + know_your_class_and_section (bool, optional): Whether to fetch from the + "Know Your Class and Section" endpoint or not. Defaults to False. fields (Optional[list[str]], optional): The fields to fetch from the profile. Defaults to None, which means all default fields will be fetched. From f7100b034b8878b8573acb2d14152e90c4ea0e10 Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Wed, 20 May 2026 23:22:13 +0530 Subject: [PATCH 05/13] fix: class field renamed to semester --- app/app.py | 2 +- app/docs/authenticate.py | 2 +- app/models/kycas.py | 10 ++++------ app/pesu.py | 4 ++-- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/app.py b/app/app.py index aba6e72..5fc5ece 100644 --- a/app/app.py +++ b/app/app.py @@ -216,7 +216,7 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) try: authentication_result = ResponseModel.model_validate(authentication_result) logging.info(f"Returning auth result for user={username}: {authentication_result}") - authentication_result = authentication_result.model_dump(exclude_none=True, by_alias=True) + authentication_result = authentication_result.model_dump(exclude_none=True) authentication_result["timestamp"] = current_time.isoformat() return JSONResponse( status_code=200, diff --git a/app/docs/authenticate.py b/app/docs/authenticate.py index 730550e..f88c6d0 100644 --- a/app/docs/authenticate.py +++ b/app/docs/authenticate.py @@ -107,7 +107,7 @@ "prn": "PESXXYYZZZZZ", "srn": "PESXXUGYYZZZ", "name": "John Doe", - "class": "Sem-X", + "semester": "Sem-X", "section": "Section X", "cycle": "NA", "department": "Computer Science and Engineering", diff --git a/app/models/kycas.py b/app/models/kycas.py index 71d09d4..49cf013 100644 --- a/app/models/kycas.py +++ b/app/models/kycas.py @@ -6,7 +6,7 @@ class KYCASModel(BaseModel): """Model representing the "Know Your Class and Section" data.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(strict=True) prn: str | None = Field( None, @@ -26,12 +26,10 @@ class KYCASModel(BaseModel): description="Full name of the user.", json_schema_extra={"example": "John Doe"}, ) - class_field: str | None = Field( + semester: str | None = Field( None, - validation_alias="class", - serialization_alias="class", - title="Class", - description="Class the user belongs to.", + title="Semester", + description="Semester of the user.", json_schema_extra={"example": "Sem-X"}, ) section: str | None = Field( diff --git a/app/pesu.py b/app/pesu.py index 0ff1bad..0f6ea54 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -47,7 +47,7 @@ class PESUAcademy: "phone", "campus_code", "campus", - "class", + "semester", "cycle", "department", "institute_name", @@ -67,7 +67,7 @@ class PESUAcademy: "PRN": "prn", "SRN": "srn", "Name": "name", - "Class": "class", + "Class": "semester", "Section": "section", "Cycle": "cycle", "Department": "department", From 051e3895573b380fac09c01f3d058ea277ecfbdc Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Thu, 21 May 2026 18:56:46 +0530 Subject: [PATCH 06/13] fix: grammatical errors --- app/docs/authenticate.py | 8 ++++---- app/exceptions/authentication.py | 2 +- app/models/kycas.py | 4 ++-- app/models/profile.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/docs/authenticate.py b/app/docs/authenticate.py index f88c6d0..28dbdea 100644 --- a/app/docs/authenticate.py +++ b/app/docs/authenticate.py @@ -23,8 +23,8 @@ "profile": True, }, }, - "auth_with_class_and_section": { - "summary": 'Authentication with "Know Your Class and Section"', + "auth_with_kycas": { + "summary": 'Authentication with "Know Your Class and Section" endpoint', "description": 'Authentication with "Know Your Class and Section" data', "value": { "username": "PES1201800001", @@ -85,7 +85,7 @@ }, }, "authentication_with_kycas": { - "summary": 'Authentication with "Know Your Class and Section"', + "summary": 'Authentication with "Know Your Class and Section endpoint"', "value": { "status": True, "message": "Login successful.", @@ -210,7 +210,7 @@ }, }, "kycas_fetch_error": { - "summary": '"Know Your Class and Section" page fetching failed', + "summary": '"Know Your Class and Section" endpoint fetching failed', "value": { "status": False, "message": "Failed to fetch Know Your Class and Section data from PESU Academy.", diff --git a/app/exceptions/authentication.py b/app/exceptions/authentication.py index a141ef9..5b6f26f 100644 --- a/app/exceptions/authentication.py +++ b/app/exceptions/authentication.py @@ -38,6 +38,6 @@ def __init__(self, message: str = "Failed to parse student profile page from PES 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: + 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/models/kycas.py b/app/models/kycas.py index 49cf013..333fdb3 100644 --- a/app/models/kycas.py +++ b/app/models/kycas.py @@ -29,7 +29,7 @@ class KYCASModel(BaseModel): semester: str | None = Field( None, title="Semester", - description="Semester of the user.", + description="Semester the user belongs to.", json_schema_extra={"example": "Sem-X"}, ) section: str | None = Field( @@ -41,7 +41,7 @@ class KYCASModel(BaseModel): cycle: str | None = Field( None, title="Cycle", - description="Cycle of the user.", + description="Cycle the user belongs to.", json_schema_extra={"example": "NA"}, ) department: str | None = Field( diff --git a/app/models/profile.py b/app/models/profile.py index 7b47b96..9deedb6 100644 --- a/app/models/profile.py +++ b/app/models/profile.py @@ -43,7 +43,7 @@ class ProfileModel(BaseModel): semester: str | None = Field( None, title="Semester", - description="Current semester of the user.", + description="Current semester the user belongs to.", json_schema_extra={"example": "2"}, ) section: str | None = Field( From 12a858d9d79394d51fe468893d92bb4610a22e5f Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Thu, 21 May 2026 18:59:27 +0530 Subject: [PATCH 07/13] fix: formatting --- app/exceptions/authentication.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/exceptions/authentication.py b/app/exceptions/authentication.py index 5b6f26f..62560d8 100644 --- a/app/exceptions/authentication.py +++ b/app/exceptions/authentication.py @@ -38,6 +38,9 @@ def __init__(self, message: str = "Failed to parse student profile page from PES 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: + 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) From e0939c320d3d0c206cd3ea1f42388961f13e59e9 Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Thu, 21 May 2026 19:37:25 +0530 Subject: [PATCH 08/13] chore: update README --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index bc28d0d..fe20621 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ object, with the user's profile information if requested. | `username` | No | `str` | | The user's SRN or PRN | | `password` | No | `str` | | The user's password | | `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | +| `know_your_class_and_section` | Yes | `boolean` | `False` | Whether to fetch Know Your Class and Section information | | `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | #### Response Object @@ -123,6 +124,7 @@ profile data was requested, the response's `profile` key will store a dictionary |-------------|-----------------|--------------------------------------------------------------------------| | `status` | `boolean` | A flag indicating whether the overall request was successful | | `profile` | `ProfileObject` | A nested map storing the profile information, returned only if requested | +| `know_your_class_and_section` | `KnowYourClassAndSectionObject` | A nested map storing the profile information from PESU's Know Your Class and Section Portal | | `message` | `str` | A message that provides information corresponding to the status | | `timestamp` | `datetime` | A timezone offset timestamp indicating the time of authentication | @@ -145,6 +147,22 @@ If the authentication fails, this field will not be present in the response. | `campus_code` | The integer code of the campus (1 for RR and 2 for EC) | | `campus` | Abbreviation of the user's campus name | +#### KnowYourClassAndSectionObject + +| **Field** | **Description** | +|------------------|----------------------------------------------------------------| +| `prn` | PRN of the user | +| `srn` | SRN of the user | +| `name` | Name of the user | +| `semester` | Current semester that the user is in | +| `section` | Section of the user | +| `cycle` | Physics Cycle or Chemistry Cycle, if the user is in first year | +| `department` | Abbreviation of the branch along with the campus of the user | +| `branch` | Abbreviation of the branch that the user is pursuing | +| `institute_name` | The name of the campus that the user is studying in | +| `error` | The error name and stack trace, if an error occurs | + + ### `/health` This endpoint can be used to check the health of the API. It's useful for monitoring and uptime checks. This endpoint @@ -177,6 +195,7 @@ data = { "username": "your SRN or PRN here", "password": "your password here", "profile": True, # Optional, defaults to False + 'know_your_class_and_section': True, # Optional, defaults to False } response = requests.post("http://localhost:5000/authenticate", json=data) @@ -202,6 +221,17 @@ print(response.json()) "campus": "RR" }, "message": "Login successful.", + "know_your_class_and_section": { + "prn": "PES1201800001", + "srn": "PES1201800001", + "name": "JOHNNY BLAZE", + "semester": "Sem-8", + "section": "Section F", + "cycle": "NA", + "department": "CSE(EC Campus)", + "branch": "CSE", + "institute_name": "PES University (Electronic City)" + }, "timestamp": "2024-07-28 22:30:10.103368+05:30" } ``` From 0f6adee284f55a9f5a77b6239a8a600919b68168 Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Thu, 21 May 2026 21:16:02 +0530 Subject: [PATCH 09/13] feat: Add tests for KYCAS --- .../test_authenticate_functional.py | 86 +++++ tests/integration/test_app_integration.py | 83 ++++ tests/unit/test_app_unit.py | 31 ++ tests/unit/test_kycas_exception.py | 35 ++ tests/unit/test_kycas_model.py | 87 +++++ tests/unit/test_pesu.py | 365 ++++++++++++++++++ tests/unit/test_request_model.py | 39 ++ 7 files changed, 726 insertions(+) create mode 100644 tests/unit/test_kycas_exception.py create mode 100644 tests/unit/test_kycas_model.py diff --git a/tests/functional/test_authenticate_functional.py b/tests/functional/test_authenticate_functional.py index c330cf3..2eb440a 100644 --- a/tests/functional/test_authenticate_functional.py +++ b/tests/functional/test_authenticate_functional.py @@ -166,3 +166,89 @@ async def test_authenticate_invalid_credentials(pesu_academy: PESUAcademy): assert result["status"] is False assert "Invalid username or password" in result["message"] assert "profile" not in result + + +@pytest.mark.secret_required +@pytest.mark.asyncio +async def test_authenticate_with_kycas(pesu_academy: PESUAcademy): + """Test successful authentication with KYCAS data.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + result = await pesu_academy.authenticate( + email, + password, + know_your_class_and_section=True, + fields=None, + ) + assert result["status"] is True + assert "Login successful" in result["message"] + assert "know_your_class_and_section" in result + kycas = result["know_your_class_and_section"] + assert "prn" in kycas or "srn" in kycas + assert "name" in kycas + + +@pytest.mark.secret_required +@pytest.mark.asyncio +async def test_authenticate_with_kycas_and_profile(pesu_academy: PESUAcademy): + """Test authentication requesting both profile and KYCAS data.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + result = await pesu_academy.authenticate( + email, + password, + profile=True, + know_your_class_and_section=True, + fields=None, + ) + assert result["status"] is True + assert "profile" in result + assert "know_your_class_and_section" in result + + +@pytest.mark.secret_required +@pytest.mark.asyncio +async def test_authenticate_with_kycas_field_filtering(pesu_academy: PESUAcademy): + """Test that KYCAS data respects field filtering.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + result = await pesu_academy.authenticate( + email, + password, + know_your_class_and_section=True, + fields=["name", "semester"], + ) + assert result["status"] is True + kycas = result["know_your_class_and_section"] + assert "name" in kycas + assert "semester" in kycas + assert "prn" not in kycas + assert "branch" not in kycas + + +@pytest.mark.secret_required +@pytest.mark.asyncio +async def test_authenticate_without_kycas(pesu_academy: PESUAcademy): + """Test that KYCAS data is NOT returned when not requested.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + result = await pesu_academy.authenticate( + email, + password, + know_your_class_and_section=False, + fields=None, + ) + assert result["status"] is True + assert "know_your_class_and_section" not in result diff --git a/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index 7baca86..2e02d22 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -354,3 +354,86 @@ def test_unhandled_exception_handler(client): data = response.json() assert data["status"] is False assert data["message"] == "Internal Server Error. Please try again later." + + +def test_integration_authenticate_kycas_wrong_type(client): + """Test that non-boolean know_your_class_and_section is rejected.""" + payload = { + "username": "username", + "password": "password", + "know_your_class_and_section": "true", + } + + response = client.post("/authenticate", json=payload) + assert response.status_code == 400 + data = response.json() + assert data["status"] is False + assert "Could not validate request data" in data["message"] + assert "body.know_your_class_and_section: Input should be a valid boolean" in data["message"] + +@pytest.mark.secret_required +def test_integration_authenticate_with_kycas(client): + """Test successful authentication with KYCAS data.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + payload = { + "username": email, + "password": password, + "know_your_class_and_section": True, + } + + response = client.post("/authenticate", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["status"] is True + assert data["message"] == "Login successful." + assert "know_your_class_and_section" in data + kycas = data["know_your_class_and_section"] + assert "prn" in kycas or "srn" in kycas or "name" in kycas + + +@pytest.mark.secret_required +def test_integration_authenticate_with_profile_and_kycas(client): + """Test successful authentication requesting both profile and KYCAS.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + payload = { + "username": email, + "password": password, + "profile": True, + "know_your_class_and_section": True, + } + + response = client.post("/authenticate", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["status"] is True + assert "profile" in data + assert "know_your_class_and_section" in data + + +@pytest.mark.secret_required +def test_integration_authenticate_kycas_without_requesting(client): + """Test that KYCAS data is NOT returned when know_your_class_and_section is False.""" + email = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + assert email is not None, "TEST_EMAIL environment variable not set" + assert password is not None, "TEST_PASSWORD environment variable not set" + + payload = { + "username": email, + "password": password, + "know_your_class_and_section": False, + } + + response = client.post("/authenticate", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["status"] is True + assert data.get("know_your_class_and_section") is None diff --git a/tests/unit/test_app_unit.py b/tests/unit/test_app_unit.py index a9c8502..d05ae8c 100644 --- a/tests/unit/test_app_unit.py +++ b/tests/unit/test_app_unit.py @@ -37,6 +37,37 @@ def test_authenticate_general_exception(mock_authenticate, client): assert "Internal Server Error" in data["message"] +@patch("app.app.pesu_academy.authenticate") +def test_authenticate_passes_kycas_flag(mock_authenticate, client): + mock_authenticate.return_value = { + "status": True, + "message": "Login successful.", + "know_your_class_and_section": { + "srn": "PES2UG21CS310", + "semester": "Sem-8", + "section": "Section F", + }, + } + payload = { + "username": "testuser", + "password": "testpass", + "know_your_class_and_section": True, + } + + response = client.post("/authenticate", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["know_your_class_and_section"]["semester"] == "Sem-8" + mock_authenticate.assert_called_once_with( + username="testuser", + password="testpass", + profile=False, + know_your_class_and_section=True, + fields=None, + ) + + @patch("app.app.argparse.ArgumentParser.parse_args") @patch("app.app.logging.basicConfig") @patch("app.app.uvicorn.run") diff --git a/tests/unit/test_kycas_exception.py b/tests/unit/test_kycas_exception.py new file mode 100644 index 0000000..b26e70e --- /dev/null +++ b/tests/unit/test_kycas_exception.py @@ -0,0 +1,35 @@ +"""Unit tests for the KYCASFetchError exception.""" + +from app.exceptions.authentication import KYCASFetchError +from app.exceptions.base import PESUAcademyError + + +def test_kycas_fetch_error_default_message(): + """Test that the default message is set correctly.""" + error = KYCASFetchError() + assert 'Failed to fetch "Know Your Class and Section" data from PESU Academy.' in str(error) + + +def test_kycas_fetch_error_custom_message(): + """Test that a custom message overrides the default.""" + error = KYCASFetchError("Custom KYCAS error message.") + assert "Custom KYCAS error message." in str(error) + + +def test_kycas_fetch_error_status_code(): + """Test that the status code is 502.""" + error = KYCASFetchError() + assert error.status_code == 502 + + +def test_kycas_fetch_error_inherits_from_pesu_academy_error(): + """Test that KYCASFetchError is a subclass of PESUAcademyError.""" + assert issubclass(KYCASFetchError, PESUAcademyError) + + +def test_kycas_fetch_error_is_exception(): + """Test that KYCASFetchError can be raised and caught.""" + try: + raise KYCASFetchError("test") + except PESUAcademyError as e: + assert "test" in str(e) diff --git a/tests/unit/test_kycas_model.py b/tests/unit/test_kycas_model.py new file mode 100644 index 0000000..f562fcb --- /dev/null +++ b/tests/unit/test_kycas_model.py @@ -0,0 +1,87 @@ +"""Unit tests for the KYCASModel Pydantic model.""" + +import pytest +from pydantic import ValidationError + +from app.models.kycas import KYCASModel + + +def test_kycas_model_all_fields(): + """Test creating KYCASModel with all fields populated.""" + data = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "institute_name": "PES University", + } + model = KYCASModel(**data) + assert model.prn == "PES1201800001" + assert model.srn == "PES1UG19CS001" + assert model.name == "John Doe" + assert model.semester == "Sem-6" + assert model.section == "Section A" + assert model.cycle == "NA" + assert model.department == "CSE(RR Campus)" + assert model.branch == "CSE" + assert model.institute_name == "PES University" + + +def test_kycas_model_all_defaults(): + """Test that all fields default to None.""" + model = KYCASModel() + assert model.prn is None + assert model.srn is None + assert model.name is None + assert model.semester is None + assert model.section is None + assert model.cycle is None + assert model.department is None + assert model.branch is None + assert model.institute_name is None + + +def test_kycas_model_partial_fields(): + """Test creating KYCASModel with only some fields.""" + model = KYCASModel(prn="PES1201800001", name="Jane Doe") + assert model.prn == "PES1201800001" + assert model.name == "Jane Doe" + assert model.srn is None + assert model.semester is None + + +def test_kycas_model_strict_type_enforcement(): + """Test that strict mode rejects non-string types for string fields.""" + with pytest.raises(ValidationError) as exc_info: + KYCASModel(prn=12345) # Should be a string, not int + assert "prn" in str(exc_info.value) + + +def test_kycas_model_serialization(): + """Test that model serializes to dict correctly.""" + data = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "institute_name": "PES University", + } + model = KYCASModel(**data) + dumped = model.model_dump() + assert dumped == data + + +def test_kycas_model_json_serialization(): + """Test that the model can be serialized to JSON.""" + model = KYCASModel(prn="PES1201800001", name="John Doe") + json_str = model.model_dump_json() + assert "PES1201800001" in json_str + assert "John Doe" in json_str diff --git a/tests/unit/test_pesu.py b/tests/unit/test_pesu.py index 33ed4b0..3bf2e28 100644 --- a/tests/unit/test_pesu.py +++ b/tests/unit/test_pesu.py @@ -5,6 +5,7 @@ from app.exceptions.authentication import ( AuthenticationError, CSRFTokenError, + KYCASFetchError, ProfileFetchError, ProfileParseError, ) @@ -489,3 +490,367 @@ def test_default_fields_is_list(): assert "phone" in PESUAcademy.DEFAULT_FIELDS assert "campus_code" in PESUAcademy.DEFAULT_FIELDS assert "campus" in PESUAcademy.DEFAULT_FIELDS + +@pytest.mark.asyncio +async def test_get_kycas_http_exception(pesu): + """Test that KYCASFetchError is raised when the POST request throws an exception.""" + client = AsyncMock() + client.post.side_effect = Exception("Connection error") + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert "Failed to send KYCAS request" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_kycas_non_200_status(pesu): + """Test that KYCASFetchError is raised when the server returns a non-200 status.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 500 + client.post.return_value = mock_response + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert "Received status code 500" in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_no_table(mock_html_parser, pesu): + """Test that KYCASFetchError is raised when no element is found.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + client.post.return_value = mock_response + + mock_soup = MagicMock() + mock_soup.css_first.return_value = None + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert "Could not find KYCAS table" in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_no_headers(mock_html_parser, pesu): + """Test that KYCASFetchError is raised when elements are empty.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "
" + client.post.return_value = mock_response + + mock_table = MagicMock() + mock_table.css.return_value = [] + + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert "Could not find KYCAS table headers" in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_no_data_row(mock_html_parser, pesu): + """Test that KYCASFetchError is raised when there's no .""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "
PRN
" + client.post.return_value = mock_response + + mock_th = MagicMock() + mock_th.text.return_value = "PRN" + + mock_table = MagicMock() + mock_table.css.return_value = [mock_th] + mock_table.css_first.return_value = None + + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert "Could not find KYCAS data row" in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_header_cell_mismatch(mock_html_parser, pesu): + """Test that KYCASFetchError is raised when headers count != cells count.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + client.post.return_value = mock_response + + mock_th1 = MagicMock() + mock_th1.text.return_value = "PRN" + mock_th2 = MagicMock() + mock_th2.text.return_value = "SRN" + + mock_td1 = MagicMock() + mock_td1.text.return_value = "PES1201800001" + + mock_row = MagicMock() + mock_row.css.return_value = [mock_td1] + + mock_table = MagicMock() + mock_table.css.return_value = [mock_th1, mock_th2] + mock_table.css_first.return_value = mock_row + + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert "Mismatch between KYCAS table headers" in str(exc_info.value) + + +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_no_mapped_keys(mock_html_parser, pesu): + """Test that KYCASFetchError is raised when no headers match KYCAS_HEADER_TO_KEY_MAP.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + client.post.return_value = mock_response + + mock_th = MagicMock() + mock_th.text.return_value = "UnknownHeader" + + mock_td = MagicMock() + mock_td.text.return_value = "some_value" + + mock_row = MagicMock() + mock_row.css.return_value = [mock_td] + + mock_table = MagicMock() + mock_table.css.return_value = [mock_th] + mock_table.css_first.return_value = mock_row + + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert "No KYCAS data could be extracted" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_kycas_success(pesu): + """Test the happy path: successfully parsing KYCAS data from a well-formed table.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PRNSRNNameClassSectionCycleDepartmentBranchInstitute Name
PES2202100984PES2UG21CS310Test UserSem-8Section FNACSE(EC Campus)CSEPES University (Electronic City)
+ """ + client.post.return_value = mock_response + + result = await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + + assert result["prn"] == "PES2202100984" + assert result["srn"] == "PES2UG21CS310" + assert result["name"] == "Test User" + assert result["semester"] == "Sem-8" + assert result["section"] == "Section F" + assert result["cycle"] == "NA" + assert result["department"] == "CSE(EC Campus)" + assert result["branch"] == "CSE" + assert result["institute_name"] == "PES University (Electronic City)" + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@pytest.mark.asyncio +async def test_authenticate_success_no_kycas(mock_post, mock_get, pesu): + """Test that KYCAS data is NOT in the result when know_your_class_and_section=False.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + result = await pesu.authenticate("user", "pass", know_your_class_and_section=False) + assert result["status"] is True + assert "know_your_class_and_section" not in result + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") +@pytest.mark.asyncio +async def test_authenticate_with_kycas(mock_get_kycas, mock_post, mock_get, pesu): + """Test that KYCAS data IS in the result when know_your_class_and_section=True.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "institute_name": "PES University", + } + + result = await pesu.authenticate("user", "pass", know_your_class_and_section=True) + + assert result["status"] is True + assert "know_your_class_and_section" in result + assert result["know_your_class_and_section"]["prn"] == "PES1201800001" + assert result["know_your_class_and_section"]["institute_name"] == "PES University" + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") +@pytest.mark.asyncio +async def test_authenticate_with_kycas_field_filtering(mock_get_kycas, mock_post, mock_get, pesu): + """Test that KYCAS data is filtered when field filtering is enabled.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "institute_name": "PES University", + } + + result = await pesu.authenticate( + "user", + "pass", + know_your_class_and_section=True, + fields=["name", "semester"], + ) + + assert result["status"] is True + kycas = result["know_your_class_and_section"] + assert "name" in kycas + assert "semester" in kycas + assert "prn" not in kycas + assert "branch" not in kycas + assert "institute_name" not in kycas + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_profile_information") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") +@pytest.mark.asyncio +async def test_authenticate_with_both_profile_and_kycas( + mock_get_kycas, mock_get_profile, mock_post, mock_get, pesu +): + """Test requesting both profile and KYCAS data simultaneously.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_profile.return_value = { + "name": "John Doe", + "prn": "PES1201800001", + "email": "john@example.com", + } + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "semester": "Sem-6", + "section": "Section A", + } + + result = await pesu.authenticate( + "user", "pass", profile=True, know_your_class_and_section=True + ) + + assert result["status"] is True + assert "profile" in result + assert "know_your_class_and_section" in result + assert result["profile"]["name"] == "John Doe" + assert result["know_your_class_and_section"]["semester"] == "Sem-6" + +def test_kycas_header_to_key_map_is_dict(): + """Test that KYCAS_HEADER_TO_KEY_MAP is a dict with expected keys.""" + kmap = PESUAcademy.KYCAS_HEADER_TO_KEY_MAP + assert isinstance(kmap, dict) + assert "PRN" in kmap + assert "SRN" in kmap + assert "Name" in kmap + assert "Class" in kmap + assert kmap["Class"] == "semester" + assert "Section" in kmap + assert "Cycle" in kmap + assert "Department" in kmap + assert "Branch" in kmap + assert "Institute Name" in kmap + + +def test_default_fields_includes_kycas_relevant_fields(): + """Test that DEFAULT_FIELDS now includes fields relevant to KYCAS filtering.""" + fields = PESUAcademy.DEFAULT_FIELDS + assert "semester" in fields + assert "cycle" in fields + assert "department" in fields + assert "institute_name" in fields diff --git a/tests/unit/test_request_model.py b/tests/unit/test_request_model.py index af0681f..f4baf5a 100644 --- a/tests/unit/test_request_model.py +++ b/tests/unit/test_request_model.py @@ -110,3 +110,42 @@ def test_validate_username_strips_whitespace(): def test_validate_password_strips_whitespace(): model = RequestModel(username="testuser", password=" testpass ") assert model.password == "testpass" + + +def test_validate_know_your_class_and_section_default_false(): + """Test that know_your_class_and_section defaults to False.""" + model = RequestModel(username="testuser", password="testpass") + assert model.know_your_class_and_section is False + + +def test_validate_know_your_class_and_section_true(): + """Test setting know_your_class_and_section to True.""" + model = RequestModel( + username="testuser", + password="testpass", + know_your_class_and_section=True, + ) + assert model.know_your_class_and_section is True + + +def test_validate_know_your_class_and_section_invalid_type(): + """Test that non-boolean types are rejected for know_your_class_and_section.""" + with pytest.raises(ValidationError) as exc_info: + RequestModel( + username="testuser", + password="testpass", + know_your_class_and_section="yes", + ) + assert exc_info.value.errors()[0]["type"] == "bool_type" + assert "Input should be a valid boolean" in str(exc_info.value) + + +def test_validate_know_your_class_and_section_int_rejected(): + """Test that integer types are rejected (strict mode).""" + with pytest.raises(ValidationError) as exc_info: + RequestModel( + username="testuser", + password="testpass", + know_your_class_and_section=1, + ) + assert "Input should be a valid boolean" in str(exc_info.value) From bc77d168441bcd6f2e943f896c83771a3f02ea68 Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Thu, 21 May 2026 21:16:14 +0530 Subject: [PATCH 10/13] fix: grammar --- app/pesu.py | 26 +++++++----- .../test_authenticate_functional.py | 8 ++-- tests/integration/test_app_integration.py | 6 +-- tests/unit/test_kycas_exception.py | 10 ++--- tests/unit/test_kycas_model.py | 8 ++-- tests/unit/test_pesu.py | 40 +++++++++---------- 6 files changed, 51 insertions(+), 47 deletions(-) diff --git a/app/pesu.py b/app/pesu.py index 0f6ea54..96dcf63 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -287,7 +287,7 @@ async def get_know_your_class_and_section( Returns: dict[str, Any]: A dictionary containing the user's class and section information. """ - logging.info(f"Fetching class and section data for user={username} from KYCAS page...") + 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 = { @@ -302,12 +302,12 @@ async def get_know_your_class_and_section( response = await client.post(kycas_url, data=kycas_data, headers=kycas_headers) except Exception: raise KYCASFetchError( - f"Failed to send KYCAS request to PESU Academy for user={username}.", + 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 KYCAS data from PESU Academy for user={username}. " + f'Failed to fetch "Know Your Class and Section" data from PESU Academy for user={username}. ' f"Received status code {response.status_code}.", ) @@ -317,26 +317,27 @@ async def get_know_your_class_and_section( table = soup.css_first("table") if not table: raise KYCASFetchError( - f"Could not find KYCAS table in the response for user={username}.", + 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 KYCAS table headers in the response for user={username}.", + 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 KYCAS data row in the response for user={username}.", + 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 KYCAS table headers ({len(headers)}) and cells ({len(cells)}) for user={username}.", + 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): @@ -345,10 +346,10 @@ async def get_know_your_class_and_section( if not kycas: raise KYCASFetchError( - f"No KYCAS data could be extracted for user={username}.", + f'No "Know Your Class and Section" data could be extracted for user={username}.', ) - logging.info(f"KYCAS data retrieved for user={username}: {kycas}.") + logging.info(f'"Know Your Class and Section" data retrieved for user={username}: {kycas}.') return kycas async def authenticate( @@ -434,7 +435,10 @@ async def authenticate( ) if know_your_class_and_section: - logging.info(f"KYCAS data requested for user={username}. Fetching KYCAS data...") + 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["know_your_class_and_section"] = await self.get_know_your_class_and_section( client, @@ -447,7 +451,7 @@ async def authenticate( key: value for key, value in result["know_your_class_and_section"].items() if key in fields } logging.info( - f"Field filtering enabled. Filtered KYCAS data for user={username}: " + f'Field filtering enabled. Filtered "Know Your Class and Section" data for user={username}: ' f"{result['know_your_class_and_section']}", ) diff --git a/tests/functional/test_authenticate_functional.py b/tests/functional/test_authenticate_functional.py index 2eb440a..12d7b9c 100644 --- a/tests/functional/test_authenticate_functional.py +++ b/tests/functional/test_authenticate_functional.py @@ -171,7 +171,7 @@ async def test_authenticate_invalid_credentials(pesu_academy: PESUAcademy): @pytest.mark.secret_required @pytest.mark.asyncio async def test_authenticate_with_kycas(pesu_academy: PESUAcademy): - """Test successful authentication with KYCAS data.""" + """Test successful authentication with "Know Your Class and Section" data.""" email = os.getenv("TEST_EMAIL") password = os.getenv("TEST_PASSWORD") assert email is not None, "TEST_EMAIL environment variable not set" @@ -194,7 +194,7 @@ async def test_authenticate_with_kycas(pesu_academy: PESUAcademy): @pytest.mark.secret_required @pytest.mark.asyncio async def test_authenticate_with_kycas_and_profile(pesu_academy: PESUAcademy): - """Test authentication requesting both profile and KYCAS data.""" + """Test authentication requesting both profile and "Know Your Class and Section" data.""" email = os.getenv("TEST_EMAIL") password = os.getenv("TEST_PASSWORD") assert email is not None, "TEST_EMAIL environment variable not set" @@ -215,7 +215,7 @@ async def test_authenticate_with_kycas_and_profile(pesu_academy: PESUAcademy): @pytest.mark.secret_required @pytest.mark.asyncio async def test_authenticate_with_kycas_field_filtering(pesu_academy: PESUAcademy): - """Test that KYCAS data respects field filtering.""" + """Test that "Know Your Class and Section" data respects field filtering.""" email = os.getenv("TEST_EMAIL") password = os.getenv("TEST_PASSWORD") assert email is not None, "TEST_EMAIL environment variable not set" @@ -238,7 +238,7 @@ async def test_authenticate_with_kycas_field_filtering(pesu_academy: PESUAcademy @pytest.mark.secret_required @pytest.mark.asyncio async def test_authenticate_without_kycas(pesu_academy: PESUAcademy): - """Test that KYCAS data is NOT returned when not requested.""" + """Test that "Know Your Class and Section" data is NOT returned when not requested.""" email = os.getenv("TEST_EMAIL") password = os.getenv("TEST_PASSWORD") assert email is not None, "TEST_EMAIL environment variable not set" diff --git a/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index 2e02d22..ef5f15e 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -373,7 +373,7 @@ def test_integration_authenticate_kycas_wrong_type(client): @pytest.mark.secret_required def test_integration_authenticate_with_kycas(client): - """Test successful authentication with KYCAS data.""" + """Test successful authentication with "Know Your Class and Section" data.""" email = os.getenv("TEST_EMAIL") password = os.getenv("TEST_PASSWORD") assert email is not None, "TEST_EMAIL environment variable not set" @@ -397,7 +397,7 @@ def test_integration_authenticate_with_kycas(client): @pytest.mark.secret_required def test_integration_authenticate_with_profile_and_kycas(client): - """Test successful authentication requesting both profile and KYCAS.""" + """Test successful authentication requesting both profile and "Know Your Class and Section".""" email = os.getenv("TEST_EMAIL") password = os.getenv("TEST_PASSWORD") assert email is not None, "TEST_EMAIL environment variable not set" @@ -420,7 +420,7 @@ def test_integration_authenticate_with_profile_and_kycas(client): @pytest.mark.secret_required def test_integration_authenticate_kycas_without_requesting(client): - """Test that KYCAS data is NOT returned when know_your_class_and_section is False.""" + """Test that "Know Your Class and Section" data is NOT returned when know_your_class_and_section is False.""" email = os.getenv("TEST_EMAIL") password = os.getenv("TEST_PASSWORD") assert email is not None, "TEST_EMAIL environment variable not set" diff --git a/tests/unit/test_kycas_exception.py b/tests/unit/test_kycas_exception.py index b26e70e..e7a223a 100644 --- a/tests/unit/test_kycas_exception.py +++ b/tests/unit/test_kycas_exception.py @@ -1,4 +1,4 @@ -"""Unit tests for the KYCASFetchError exception.""" +"""Unit tests for the "Know Your Class and Section" fetch error exception.""" from app.exceptions.authentication import KYCASFetchError from app.exceptions.base import PESUAcademyError @@ -12,8 +12,8 @@ def test_kycas_fetch_error_default_message(): def test_kycas_fetch_error_custom_message(): """Test that a custom message overrides the default.""" - error = KYCASFetchError("Custom KYCAS error message.") - assert "Custom KYCAS error message." in str(error) + error = KYCASFetchError('Custom "Know Your Class and Section" error message.') + assert 'Custom "Know Your Class and Section" error message.' in str(error) def test_kycas_fetch_error_status_code(): @@ -23,12 +23,12 @@ def test_kycas_fetch_error_status_code(): def test_kycas_fetch_error_inherits_from_pesu_academy_error(): - """Test that KYCASFetchError is a subclass of PESUAcademyError.""" + """Test that the "Know Your Class and Section" fetch error subclasses PESUAcademyError.""" assert issubclass(KYCASFetchError, PESUAcademyError) def test_kycas_fetch_error_is_exception(): - """Test that KYCASFetchError can be raised and caught.""" + """Test that the "Know Your Class and Section" fetch error can be raised and caught.""" try: raise KYCASFetchError("test") except PESUAcademyError as e: diff --git a/tests/unit/test_kycas_model.py b/tests/unit/test_kycas_model.py index f562fcb..f38e065 100644 --- a/tests/unit/test_kycas_model.py +++ b/tests/unit/test_kycas_model.py @@ -1,4 +1,4 @@ -"""Unit tests for the KYCASModel Pydantic model.""" +"""Unit tests for the "Know Your Class and Section" Pydantic model.""" import pytest from pydantic import ValidationError @@ -7,7 +7,7 @@ def test_kycas_model_all_fields(): - """Test creating KYCASModel with all fields populated.""" + """Test creating the "Know Your Class and Section" model with all fields populated.""" data = { "prn": "PES1201800001", "srn": "PES1UG19CS001", @@ -46,7 +46,7 @@ def test_kycas_model_all_defaults(): def test_kycas_model_partial_fields(): - """Test creating KYCASModel with only some fields.""" + """Test creating the "Know Your Class and Section" model with only some fields.""" model = KYCASModel(prn="PES1201800001", name="Jane Doe") assert model.prn == "PES1201800001" assert model.name == "Jane Doe" @@ -57,7 +57,7 @@ def test_kycas_model_partial_fields(): def test_kycas_model_strict_type_enforcement(): """Test that strict mode rejects non-string types for string fields.""" with pytest.raises(ValidationError) as exc_info: - KYCASModel(prn=12345) # Should be a string, not int + KYCASModel(prn=12345) assert "prn" in str(exc_info.value) diff --git a/tests/unit/test_pesu.py b/tests/unit/test_pesu.py index 3bf2e28..2b3a815 100644 --- a/tests/unit/test_pesu.py +++ b/tests/unit/test_pesu.py @@ -493,18 +493,18 @@ def test_default_fields_is_list(): @pytest.mark.asyncio async def test_get_kycas_http_exception(pesu): - """Test that KYCASFetchError is raised when the POST request throws an exception.""" + """Test that the "Know Your Class and Section" fetch error is raised on request failure.""" client = AsyncMock() client.post.side_effect = Exception("Connection error") with pytest.raises(KYCASFetchError) as exc_info: await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert "Failed to send KYCAS request" in str(exc_info.value) + assert 'Failed to send "Know Your Class and Section" request' in str(exc_info.value) @pytest.mark.asyncio async def test_get_kycas_non_200_status(pesu): - """Test that KYCASFetchError is raised when the server returns a non-200 status.""" + """Test that the "Know Your Class and Section" fetch error is raised on non-200 responses.""" client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 500 @@ -518,7 +518,7 @@ async def test_get_kycas_non_200_status(pesu): @patch("app.pesu.HTMLParser") @pytest.mark.asyncio async def test_get_kycas_no_table(mock_html_parser, pesu): - """Test that KYCASFetchError is raised when no element is found.""" + """Test that the "Know Your Class and Section" fetch error is raised when no table is found.""" client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 @@ -531,13 +531,13 @@ async def test_get_kycas_no_table(mock_html_parser, pesu): with pytest.raises(KYCASFetchError) as exc_info: await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert "Could not find KYCAS table" in str(exc_info.value) + assert 'Could not find "Know Your Class and Section" table' in str(exc_info.value) @patch("app.pesu.HTMLParser") @pytest.mark.asyncio async def test_get_kycas_no_headers(mock_html_parser, pesu): - """Test that KYCASFetchError is raised when elements are empty.""" + """Test that the "Know Your Class and Section" fetch error is raised when headers are empty.""" client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 @@ -553,13 +553,13 @@ async def test_get_kycas_no_headers(mock_html_parser, pesu): with pytest.raises(KYCASFetchError) as exc_info: await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert "Could not find KYCAS table headers" in str(exc_info.value) + assert 'Could not find "Know Your Class and Section" table headers' in str(exc_info.value) @patch("app.pesu.HTMLParser") @pytest.mark.asyncio async def test_get_kycas_no_data_row(mock_html_parser, pesu): - """Test that KYCASFetchError is raised when there's no .""" + """Test that the "Know Your Class and Section" fetch error is raised when no row exists.""" client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 @@ -579,13 +579,13 @@ async def test_get_kycas_no_data_row(mock_html_parser, pesu): with pytest.raises(KYCASFetchError) as exc_info: await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert "Could not find KYCAS data row" in str(exc_info.value) + assert 'Could not find "Know Your Class and Section" data row' in str(exc_info.value) @patch("app.pesu.HTMLParser") @pytest.mark.asyncio async def test_get_kycas_header_cell_mismatch(mock_html_parser, pesu): - """Test that KYCASFetchError is raised when headers count != cells count.""" + """Test that the "Know Your Class and Section" fetch error is raised on malformed rows.""" client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 @@ -613,13 +613,13 @@ async def test_get_kycas_header_cell_mismatch(mock_html_parser, pesu): with pytest.raises(KYCASFetchError) as exc_info: await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert "Mismatch between KYCAS table headers" in str(exc_info.value) + assert 'Mismatch between "Know Your Class and Section" table headers' in str(exc_info.value) @patch("app.pesu.HTMLParser") @pytest.mark.asyncio async def test_get_kycas_no_mapped_keys(mock_html_parser, pesu): - """Test that KYCASFetchError is raised when no headers match KYCAS_HEADER_TO_KEY_MAP.""" + """Test that the "Know Your Class and Section" fetch error is raised on unknown headers.""" client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 @@ -645,12 +645,12 @@ async def test_get_kycas_no_mapped_keys(mock_html_parser, pesu): with pytest.raises(KYCASFetchError) as exc_info: await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert "No KYCAS data could be extracted" in str(exc_info.value) + assert 'No "Know Your Class and Section" data could be extracted' in str(exc_info.value) @pytest.mark.asyncio async def test_get_kycas_success(pesu): - """Test the happy path: successfully parsing KYCAS data from a well-formed table.""" + """Test the happy path: successfully parsing "Know Your Class and Section" data.""" client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 @@ -703,7 +703,7 @@ async def test_get_kycas_success(pesu): @patch("app.pesu.httpx.AsyncClient.post") @pytest.mark.asyncio async def test_authenticate_success_no_kycas(mock_post, mock_get, pesu): - """Test that KYCAS data is NOT in the result when know_your_class_and_section=False.""" + """Test that "Know Your Class and Section" data is NOT returned when not requested.""" mock_get_response = AsyncMock() mock_get_response.text = '' mock_get.return_value = mock_get_response @@ -722,7 +722,7 @@ async def test_authenticate_success_no_kycas(mock_post, mock_get, pesu): @patch("app.pesu.PESUAcademy.get_know_your_class_and_section") @pytest.mark.asyncio async def test_authenticate_with_kycas(mock_get_kycas, mock_post, mock_get, pesu): - """Test that KYCAS data IS in the result when know_your_class_and_section=True.""" + """Test that "Know Your Class and Section" data is returned when requested.""" mock_get_response = AsyncMock() mock_get_response.text = '' mock_get.return_value = mock_get_response @@ -756,7 +756,7 @@ async def test_authenticate_with_kycas(mock_get_kycas, mock_post, mock_get, pesu @patch("app.pesu.PESUAcademy.get_know_your_class_and_section") @pytest.mark.asyncio async def test_authenticate_with_kycas_field_filtering(mock_get_kycas, mock_post, mock_get, pesu): - """Test that KYCAS data is filtered when field filtering is enabled.""" + """Test that "Know Your Class and Section" data is filtered when field filtering is enabled.""" mock_get_response = AsyncMock() mock_get_response.text = '' mock_get.return_value = mock_get_response @@ -801,7 +801,7 @@ async def test_authenticate_with_kycas_field_filtering(mock_get_kycas, mock_post async def test_authenticate_with_both_profile_and_kycas( mock_get_kycas, mock_get_profile, mock_post, mock_get, pesu ): - """Test requesting both profile and KYCAS data simultaneously.""" + """Test requesting both profile and "Know Your Class and Section" data simultaneously.""" mock_get_response = AsyncMock() mock_get_response.text = '' mock_get.return_value = mock_get_response @@ -832,7 +832,7 @@ async def test_authenticate_with_both_profile_and_kycas( assert result["know_your_class_and_section"]["semester"] == "Sem-6" def test_kycas_header_to_key_map_is_dict(): - """Test that KYCAS_HEADER_TO_KEY_MAP is a dict with expected keys.""" + """Test that the "Know Your Class and Section" header map has expected keys.""" kmap = PESUAcademy.KYCAS_HEADER_TO_KEY_MAP assert isinstance(kmap, dict) assert "PRN" in kmap @@ -848,7 +848,7 @@ def test_kycas_header_to_key_map_is_dict(): def test_default_fields_includes_kycas_relevant_fields(): - """Test that DEFAULT_FIELDS now includes fields relevant to KYCAS filtering.""" + """Test that DEFAULT_FIELDS includes fields relevant to "Know Your Class and Section" filtering.""" fields = PESUAcademy.DEFAULT_FIELDS assert "semester" in fields assert "cycle" in fields From 4b7e1d65951db4180a90df0005a7a65f2438bcf6 Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Thu, 21 May 2026 21:26:39 +0530 Subject: [PATCH 11/13] fix: pre-commit errors --- tests/unit/test_app_unit.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_app_unit.py b/tests/unit/test_app_unit.py index d05ae8c..cb4f47a 100644 --- a/tests/unit/test_app_unit.py +++ b/tests/unit/test_app_unit.py @@ -8,7 +8,8 @@ @pytest.fixture def client(): - return TestClient(app, raise_server_exceptions=False) + with TestClient(app, raise_server_exceptions=False) as client: + yield client @patch("app.app.pesu_academy.authenticate") From b9dc020946506573967ce04d132bbd2c6e75e667 Mon Sep 17 00:00:00 2001 From: aditeyabaral Date: Thu, 21 May 2026 20:02:44 -0400 Subject: [PATCH 12/13] chore: fix grammar --- README.md | 8 ++++---- app/models/kycas.py | 2 +- app/models/profile.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fe20621..9117458 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ object, with the user's profile information if requested. | `username` | No | `str` | | The user's SRN or PRN | | `password` | No | `str` | | The user's password | | `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | -| `know_your_class_and_section` | Yes | `boolean` | `False` | Whether to fetch Know Your Class and Section information | +| `know_your_class_and_section` | Yes | `boolean` | `False` | Whether to fetch data from PESU's "Know Your Class and Section" information | | `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | #### Response Object @@ -124,7 +124,7 @@ profile data was requested, the response's `profile` key will store a dictionary |-------------|-----------------|--------------------------------------------------------------------------| | `status` | `boolean` | A flag indicating whether the overall request was successful | | `profile` | `ProfileObject` | A nested map storing the profile information, returned only if requested | -| `know_your_class_and_section` | `KnowYourClassAndSectionObject` | A nested map storing the profile information from PESU's Know Your Class and Section Portal | +| `know_your_class_and_section` | `KnowYourClassAndSectionObject` | A nested map storing the profile information from PESU's "Know Your Class and Section" endpoint | | `message` | `str` | A message that provides information corresponding to the status | | `timestamp` | `datetime` | A timezone offset timestamp indicating the time of authentication | @@ -147,7 +147,7 @@ If the authentication fails, this field will not be present in the response. | `campus_code` | The integer code of the campus (1 for RR and 2 for EC) | | `campus` | Abbreviation of the user's campus name | -#### KnowYourClassAndSectionObject +#### `KnowYourClassAndSectionObject` | **Field** | **Description** | |------------------|----------------------------------------------------------------| @@ -157,7 +157,7 @@ If the authentication fails, this field will not be present in the response. | `semester` | Current semester that the user is in | | `section` | Section of the user | | `cycle` | Physics Cycle or Chemistry Cycle, if the user is in first year | -| `department` | Abbreviation of the branch along with the campus of the user | +| `department` | Abbreviation of the branch along with the campus the user is studying in | | `branch` | Abbreviation of the branch that the user is pursuing | | `institute_name` | The name of the campus that the user is studying in | | `error` | The error name and stack trace, if an error occurs | diff --git a/app/models/kycas.py b/app/models/kycas.py index 333fdb3..4ad283a 100644 --- a/app/models/kycas.py +++ b/app/models/kycas.py @@ -53,7 +53,7 @@ class KYCASModel(BaseModel): branch: str | None = Field( None, title="Branch", - description="Branch short code of the user.", + description="Abbreviation of the branch that the user is pursuing.", json_schema_extra={"example": "CSE"}, ) institute_name: str | None = Field( diff --git a/app/models/profile.py b/app/models/profile.py index 9deedb6..2f59826 100644 --- a/app/models/profile.py +++ b/app/models/profile.py @@ -43,7 +43,7 @@ class ProfileModel(BaseModel): semester: str | None = Field( None, title="Semester", - description="Current semester the user belongs to.", + description="Current semester the user is pursuing.", json_schema_extra={"example": "2"}, ) section: str | None = Field( From 0b67d7f5086e9695a3c89d53ddc24f79214b6d73 Mon Sep 17 00:00:00 2001 From: aditeyabaral Date: Thu, 21 May 2026 20:31:53 -0400 Subject: [PATCH 13/13] chore: add markdown formatter, migrate tests --- .github/CODE_OF_CONDUCT.md | 26 +++--- .github/CONTRIBUTING.md | 139 +++++++++++++++-------------- .github/PULL_REQUEST_TEMPLATE.md | 30 +++---- .pre-commit-config.yaml | 7 ++ README.md | 128 +++++++++++++------------- pyproject.toml | 2 + tests/unit/test_app_unit.py | 31 ------- tests/unit/test_kycas_exception.py | 35 -------- tests/unit/test_kycas_model.py | 87 ------------------ tests/unit/test_pesu.py | 35 +++++++- uv.lock | 75 +++++++++++++++- 11 files changed, 279 insertions(+), 316 deletions(-) delete mode 100644 tests/unit/test_kycas_exception.py delete mode 100644 tests/unit/test_kycas_model.py diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 49e0f44..5dd0f28 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -17,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities @@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within @@ -121,8 +121,8 @@ https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). -[homepage]: https://www.contributor-covenant.org - For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. + +[homepage]: https://www.contributor-covenant.org diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 24669b8..3e0d5ee 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,32 +3,31 @@ Thank you for your interest in contributing to auth! This document provides guidelines and instructions for setting up your development environment and contributing to the project. -
πŸ“š Table of Contents - [🀝 Contributing to auth](#-contributing-to-auth) - [🚧 Getting Started](#-getting-started) - [πŸ› οΈ Development Environment Setup](#-development-environment-setup) - - [Prerequisites](#prerequisites) - - [Setting Up Your Environment](#setting-up-your-environment) - - [Set Up Environment Variables](#set-up-environment-variables) - - [Pre-commit Hooks](#pre-commit-hooks) + - [Prerequisites](#prerequisites) + - [Setting Up Your Environment](#setting-up-your-environment) + - [Set Up Environment Variables](#set-up-environment-variables) + - [Pre-commit Hooks](#pre-commit-hooks) - [🧰 Running the Application](#-running-the-application) - [πŸ§ͺ Testing and Code Quality](#-testing-and-code-quality) - - [Pre-commit Hooks](#pre-commit-hooks-1) - - [Linting & Formatting](#linting--formatting) + - [Pre-commit Hooks](#pre-commit-hooks-1) + - [Linting & Formatting](#linting--formatting) - [πŸ§ͺ Running Tests](#-running-tests) - - [Writing Tests](#writing-tests) + - [Writing Tests](#writing-tests) - [πŸš€ Submitting Changes](#-submitting-changes) - - [πŸ”€ Create a Branch](#-create-a-branch) - - [✏️ Make and Commit Changes](#-make-and-commit-changes) - - [πŸ“€ Push and Open a Pull Request](#-push-and-open-a-pull-request) + - [πŸ”€ Create a Branch](#-create-a-branch) + - [✏️ Make and Commit Changes](#-make-and-commit-changes) + - [πŸ“€ Push and Open a Pull Request](#-push-and-open-a-pull-request) - [❓ Need Help?](#-need-help) - [πŸ” Security](#-security) - [✨ Code Style Guide](#-code-style-guide) - - [βœ… General Guidelines](#-general-guidelines) - - [πŸ“ Docstrings & Comments](#-docstrings--comments) + - [βœ… General Guidelines](#-general-guidelines) + - [πŸ“ Docstrings & Comments](#-docstrings--comments) - [🏷️ GitHub Labels](#-github-labels) - [🧩 Feature Suggestions](#-feature-suggestions) - [πŸ“„ License](#-license) @@ -52,14 +51,14 @@ We maintain two deployment environments: The standard workflow for contributing is as follows: 1. Fork the repository on GitHub and clone it to your local machine. -2. Create a new branch for your feature or bug fix. -3. Make your changes and commit them with clear, descriptive messages. -4. Push your branch to your fork on GitHub. -5. Create a Pull Request (PR) against the repository's `dev` branch (not `main`). -6. Wait for review and feedback from the maintainers, address any comments or suggestions. -7. Once approved, your changes will be merged into the `dev` branch and deployed to staging for testing. -8. After all pre-commit checks pass, deployment to staging is triggered automatically. -9. Production deployment is performed manually by authorized maintainers after successful staging validation. +1. Create a new branch for your feature or bug fix. +1. Make your changes and commit them with clear, descriptive messages. +1. Push your branch to your fork on GitHub. +1. Create a Pull Request (PR) against the repository's `dev` branch (not `main`). +1. Wait for review and feedback from the maintainers, address any comments or suggestions. +1. Once approved, your changes will be merged into the `dev` branch and deployed to staging for testing. +1. After all pre-commit checks pass, deployment to staging is triggered automatically. +1. Production deployment is performed manually by authorized maintainers after successful staging validation. > [!WARNING] > Please note that you will not be able to push directly to either the `dev` or `main` branches of the repository. All @@ -82,12 +81,14 @@ projects. ### Setting Up Your Environment 1. **Create and activate a virtual environment:** + ```bash uv venv --python 3.11 source .venv/bin/activate ``` -2. **Install dependencies:** +1. **Install dependencies:** + ```bash uv sync --all-extras ``` @@ -95,11 +96,12 @@ projects. ### Set Up Environment Variables 1. **Copy the example environment file to create your own:** + ```bash cp .env.example .env ``` -2. **Configure your test credentials:** +1. **Configure your test credentials:** Open the `.env` file and replace all `` placeholders with your actual test user details. Each variable has been documented in the `.env.example` file for clarity. @@ -127,13 +129,13 @@ suite automatically before every commit. The following checks are enforced: -* βœ… `ruff` for linting and formatting (with auto-fix) -* βœ… `blacken-docs` to format code blocks inside Markdown files -* βœ… `pyupgrade` to upgrade syntax to Python 3.9+ -* βœ… `end-of-file-fixer`, `trailing-whitespace`, `check-yaml`, `check-toml`, `requirements-txt-fixer` for formatting -* βœ… `name-tests-test` to enforce test naming conventions -* βœ… `debug-statements` to prevent committed `print()` or `pdb` -* βœ… A local `pytest` hook that runs the full test suite +- βœ… `ruff` for linting and formatting (with auto-fix) +- βœ… `blacken-docs` to format code blocks inside Markdown files +- βœ… `pyupgrade` to upgrade syntax to Python 3.9+ +- βœ… `end-of-file-fixer`, `trailing-whitespace`, `check-yaml`, `check-toml`, `requirements-txt-fixer` for formatting +- βœ… `name-tests-test` to enforce test naming conventions +- βœ… `debug-statements` to prevent committed `print()` or `pdb` +- βœ… A local `pytest` hook that runs the full test suite > [!WARNING] > You will not be able to commit code that fails these checks. @@ -168,10 +170,10 @@ uv run pytest --cov ### Writing Tests -* Write tests for all new features and bug fixes -* Place them in the `tests/` directory -* Name your test files and functions with the `test_` prefix (required by `pytest` and validated by pre-commit) -* Keep test cases small, meaningful, and well-named +- Write tests for all new features and bug fixes +- Place them in the `tests/` directory +- Name your test files and functions with the `test_` prefix (required by `pytest` and validated by pre-commit) +- Keep test cases small, meaningful, and well-named ## πŸš€ Submitting Changes @@ -198,7 +200,7 @@ git commit -m "fix: resolve token expiry issue" Use [Conventional Commits](https://www.conventionalcommits.org/) to keep commit history consistent: | Type | Use for… | -|-------------|------------------------------------------------| +| ----------- | ---------------------------------------------- | | `feat:` | New features | | `fix:` | Bug fixes | | `docs:` | Documentation changes | @@ -210,18 +212,19 @@ Use [Conventional Commits](https://www.conventionalcommits.org/) to keep commit ### πŸ“€ Push and Open a Pull Request 1. Push your branch to your fork: + ```bash git push origin your-feature-name ``` -2. Open a Pull Request (PR) on GitHub targeting the `dev` branch. +1. Open a Pull Request (PR) on GitHub targeting the `dev` branch. -3. In your PR: +1. In your PR: - * Use a clear and descriptive title - * Include a summary of your changes - * Link any related issues using `Closes #issue-number` - * Add screenshots, terminal output, or examples if relevant + - Use a clear and descriptive title + - Include a summary of your changes + - Link any related issues using `Closes #issue-number` + - Add screenshots, terminal output, or examples if relevant After your PR is merged into `dev`, all `pre-commit` checks will run automatically. If they pass, deployment to staging is triggered. The maintainers will review your PR, provide feedback, and may request changes. Once approved, your PR will be merged @@ -233,12 +236,12 @@ production which is manually trigerred by authorized maintainers. If you get stuck or have questions: 1. Check the [README.md](../README.md) for setup and usage info. -2. Review [open issues](https://github.com/pesu-dev/auth/issues) +1. Review [open issues](https://github.com/pesu-dev/auth/issues) or [pull requests](https://github.com/pesu-dev/auth/pulls) to see if someone else encountered the same problem. -3. Reach out to the maintainers on PESU Discord. - - Use the `#pesu-auth` channel for questions related to this repository. - - Search for existing discussions before posting. -4. Open a new issue if you're facing something new or need clarification. +1. Reach out to the maintainers on PESU Discord. + - Use the `#pesu-auth` channel for questions related to this repository. + - Search for existing discussions before posting. +1. Open a new issue if you're facing something new or need clarification. ## πŸ” Security @@ -253,18 +256,18 @@ To keep the codebase clean and maintainable, please follow these conventions: ### βœ… General Guidelines -* Write clean, readable code -* Use meaningful variable and function names -* Avoid large functions; keep logic modular and composable -* Use Python 3.11+ syntax when appropriate (e.g., `match`, `|` union types) -* Keep imports sorted and remove unused ones (handled automatically via `ruff`) +- Write clean, readable code +- Use meaningful variable and function names +- Avoid large functions; keep logic modular and composable +- Use Python 3.11+ syntax when appropriate (e.g., `match`, `|` union types) +- Keep imports sorted and remove unused ones (handled automatically via `ruff`) ### πŸ“ Docstrings & Comments -* Add docstrings to all public functions, classes, and modules -* Use [Google-style docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) (or +- Add docstrings to all public functions, classes, and modules +- Use [Google-style docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) (or consistent alternatives) -* Write comments when logic is non-obvious and avoid restating the code +- Write comments when logic is non-obvious and avoid restating the code Example: @@ -289,52 +292,52 @@ each label means: ### πŸ§‘β€πŸ’» Contribution Level | Label | Description | -|--------------------|---------------------------------------------------------------| +| ------------------ | ------------------------------------------------------------- | | `good first issue` | 🟒 Simple, well-scoped tasks good for first-time contributors | | `help wanted` | 🟑 Maintainers are actively seeking help on this issue | ### 🐞 Bug & Error Handling | Label | Description | -|-------------|-------------------------------------------------------------| +| ----------- | ----------------------------------------------------------- | | `bug` | πŸ”΄ A defect or unexpected behavior in the application | | `invalid` | 🚫 The issue/PR is not valid or based on a misunderstanding | -| `wontfix` | ❌ The issue is acknowledged but will not be fixed | +| `wontfix` | ❌ The issue is acknowledged but will not be fixed | | `duplicate` | πŸ“‘ This issue or PR duplicates an existing one | ### ✨ Feature Development | Label | Description | -|---------------|---------------------------------------------------------| +| ------------- | ------------------------------------------------------- | | `enhancement` | 🟒 A request or proposal for improvement or new feature | | `feature` | 🌟 Work related to adding a new capability | -| `question` | ❓ Request for clarification or discussion | +| `question` | ❓ Request for clarification or discussion | ### πŸ“š Documentation | Label | Description | -|-----------------|------------------------------------------------------| +| --------------- | ---------------------------------------------------- | | `documentation` | πŸ“˜ Updates to README, docstrings, or inline comments | ### πŸ§ͺ Testing & CI/CD | Label | Description | -|-------------------|-------------------------------------------------------------------| +| ----------------- | ----------------------------------------------------------------- | | `tests and ci/cd` | πŸ§ͺ Changes or issues related to testing or continuous integration | ### πŸ”’ Authentication & Core | Label | Description | -|-------------------|-----------------------------------------------------------| +| ----------------- | --------------------------------------------------------- | | `authentication` | πŸ” Login, CSRF, token handling, error flows | | `pesuacademy` | πŸŽ“ PESUAcademy client, authentication, and scraping logic | -| `student profile` | πŸ§‘β€πŸŽ“ HTML parsing & profile field extraction logic | +| `student profile` | πŸ§‘β€πŸŽ“ HTML parsing & profile field extraction logic | ### 🧠 Meta / Organization -| Label | Description | -|--------------|-----------------------------------------------------| -| `api` | βš™οΈ Core FastAPI application and route handlers | +| Label | Description | +| ------------ | -------------------------------------------------- | +| `api` | βš™οΈ Core FastAPI application and route handlers | | `discussion` | πŸ—£οΈ Open-ended conversation about project direction | > [!NOTE] @@ -346,8 +349,8 @@ each label means: If you want to propose a new feature: 1. Check if it already exists in [issues](https://github.com/pesu-dev/auth/issues) -2. Open a new issue using the **"Feature Request"** template if available -3. Clearly explain the use case, proposed solution, and any relevant context +1. Open a new issue using the **"Feature Request"** template if available +1. Clearly explain the use case, proposed solution, and any relevant context ## πŸ“„ License diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b4c6662..9c2b4e9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,12 +12,10 @@ Please provide a concise summary of the changes: - What problem does it solve, or what feature does it add? - Any relevant motivation, background, or context? - > ℹ️ **Fixes / Related Issues** > Fixes: #100 > Related: #001 - ## 🧱 Type of Change > *Please indicate the type of changes introduced in your PR. Anything left unchecked will be assumed to be non-relevant* @@ -35,7 +33,6 @@ Please provide a concise summary of the changes: - [ ] πŸ”’ Security fix – Addresses auth/session/data validation vulnerabilities - [ ] 🧰 Dependency update – Updates libraries in `requirements.txt`, `pyproject.toml` - ## πŸ§ͺ How Has This Been Tested? > *Please indicate how you tested your changes. Completing all the relevant items on this list is mandatory* @@ -51,7 +48,6 @@ Please provide a concise summary of the changes: > - Python: (e.g., `3.12` via `uv`) > - [ ] Docker build tested - ## βœ… Checklist > *Please indicate the work items you have carried out. Completing all the relevant items on this list is mandatory. Anything left unchecked will be assumed to be non-relevant* @@ -70,7 +66,6 @@ Please provide a concise summary of the changes: - [ ] I've tested across multiple environments (if applicable) - [ ] Benchmarks still meet expected performance (`scripts/benchmark/benchmark_requests.py`) - ## πŸ› οΈ Affected API Behaviour > *Please indicate the areas affected by changes introduced in your PR* @@ -78,33 +73,31 @@ Please provide a concise summary of the changes: - [ ] `app/app.py` – Modified `/authenticate` route logic - [ ] `app/pesu.py` – Updated scraping or authentication handling - ### 🧩 Models -* [ ] `app/models/request.py` – Input validation or request schema changes -* [ ] `app/models/response.py` – Authentication response formatting -* [ ] `app/models/profile.py` – Profile extraction logic +- [ ] `app/models/request.py` – Input validation or request schema changes +- [ ] `app/models/response.py` – Authentication response formatting +- [ ] `app/models/profile.py` – Profile extraction logic ### 🐳 DevOps & Config -* [ ] `Dockerfile` – Changes to base image or build process -* [ ] `.github/workflows/*.yaml` – CI/CD pipeline or deployment updates -* [ ] `pyproject.toml` / `requirements.txt` – Dependency version changes -* [ ] `.pre-commit-config.yaml` – Linting or formatting hook changes - +- [ ] `Dockerfile` – Changes to base image or build process +- [ ] `.github/workflows/*.yaml` – CI/CD pipeline or deployment updates +- [ ] `pyproject.toml` / `requirements.txt` – Dependency version changes +- [ ] `.pre-commit-config.yaml` – Linting or formatting hook changes ### πŸ“Š Benchmarks & Analysis -* [ ] `scripts/benchmark_auth.py` – Performance or latency measurement changes -* [ ] `scripts/analyze_benchmark.py` – Benchmark result analysis changes -* [ ] `scripts/run_tests.py` – Custom test runner logic or behavior updates - +- [ ] `scripts/benchmark_auth.py` – Performance or latency measurement changes +- [ ] `scripts/analyze_benchmark.py` – Benchmark result analysis changes +- [ ] `scripts/run_tests.py` – Custom test runner logic or behavior updates ## πŸ“Έ Screenshots / API Demos (if applicable) > *Add any visual evidence that supports your changes. MANDATORY for breaking changes.* > > *Examples:* +> > - *Terminal output from a successful `curl` request (redact sensitive data)* > - *Screenshots of Postman/Bruno results* > - *GIF of the endpoint working in a browser* @@ -115,6 +108,7 @@ Please provide a concise summary of the changes: > *Use this space to add any final context or implementation caveats.* > > *Examples:* +> > - *Edge cases or limitations to be aware of* > - *Follow-up work or tech debt to track* > - *Known compatibility issues (e.g., with certain Python versions)* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e73d136..4e55174 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,13 @@ repos: - id: name-tests-test args: [ '--pytest-test-first' ] + - repo: https://github.com/hukkin/mdformat + rev: 0.7.22 + hooks: + - id: mdformat + additional_dependencies: + - mdformat-gfm + - repo: local hooks: - id: pytest diff --git a/README.md b/README.md index 9117458..f4b364b 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ returns the user's profile information. No personal data is stored. ## PESUAuth LIVE Deployment -* You can access the PESUAuth API endpoints [here](https://pesu-auth.onrender.com/). -* You can view the health status of the API on the [PESUAuth Health Dashboard](https://xzlk85cp.status.cron-job.org/). +- You can access the PESUAuth API endpoints [here](https://pesu-auth.onrender.com/). +- You can view the health status of the API on the [PESUAuth Health Dashboard](https://xzlk85cp.status.cron-job.org/). #### API Status @@ -46,27 +46,30 @@ following commands to start the API. 1. Build the Docker image either from the source code or pull the pre-built image from Docker Hub. - 1. You can build the Docker image from the source code by running the following command in the root directory of - the repository. - ```bash - docker build . --tag pesu-auth - ``` + 1. You can build the Docker image from the source code by running the following command in the root directory of + the repository. - 2. You can also pull the pre-built Docker image - from [Docker Hub](https://hub.docker.com/repository/docker/pesudev/pesu-auth/general) by running the - following command: - ```bash - docker pull pesudev/pesu-auth:latest - ``` + ```bash + docker build . --tag pesu-auth + ``` -2. Run the Docker container - ```bash - docker run --name pesu-auth -d -p 5000:5000 pesu-auth - # If you pulled the pre-built image, use the following command instead: - docker run --name pesu-auth -d -p 5000:5000 pesudev/pesu-auth:latest - ``` + 1. You can also pull the pre-built Docker image + from [Docker Hub](https://hub.docker.com/repository/docker/pesudev/pesu-auth/general) by running the + following command: -3. Access the API at `http://localhost:5000/` + ```bash + docker pull pesudev/pesu-auth:latest + ``` + +1. Run the Docker container + + ```bash + docker run --name pesu-auth -d -p 5000:5000 pesu-auth + # If you pulled the pre-built image, use the following command instead: + docker run --name pesu-auth -d -p 5000:5000 pesudev/pesu-auth:latest + ``` + +1. Access the API at `http://localhost:5000/` ### Running without Docker @@ -75,25 +78,27 @@ installed on your system. We recommend using a package manager like [`uv`](https dependencies. 1. Create a virtual environment using and activate it. Then, install the dependencies using the following commands. - ```bash - uv venv --python=3.11 - source .venv/bin/activate - uv sync - ``` -2. Run the API using the following command. - ```bash - uv run python -m app.app - ``` + ```bash + uv venv --python=3.11 + source .venv/bin/activate + uv sync + ``` -3. Access the API as previously mentioned on `http://localhost:5000/` +1. Run the API using the following command. + + ```bash + uv run python -m app.app + ``` + +1. Access the API as previously mentioned on `http://localhost:5000/` ## How to use the PESUAuth API The API provides multiple endpoints for authentication, documentation, and monitoring. | **Endpoint** | **Method** | **Description** | -|-----------------|------------|--------------------------------------------------------| +| --------------- | ---------- | ------------------------------------------------------ | | `/` | `GET` | Serves the interactive API documentation (Swagger UI). | | `/authenticate` | `POST` | Authenticates a user using their PESU credentials. | | `/health` | `GET` | A health check endpoint to monitor the API's status. | @@ -106,13 +111,13 @@ object, with the user's profile information if requested. #### Request Parameters -| **Parameter** | **Optional** | **Type** | **Default** | **Description** | -|---------------|--------------|-------------|-------------|-------------------------------------------------------------------------------------------------| -| `username` | No | `str` | | The user's SRN or PRN | -| `password` | No | `str` | | The user's password | -| `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | -| `know_your_class_and_section` | Yes | `boolean` | `False` | Whether to fetch data from PESU's "Know Your Class and Section" information | -| `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | +| **Parameter** | **Optional** | **Type** | **Default** | **Description** | +| ----------------------------- | ------------ | ----------- | ----------- | ----------------------------------------------------------------------------------------------- | +| `username` | No | `str` | | The user's SRN or PRN | +| `password` | No | `str` | | The user's password | +| `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | +| `know_your_class_and_section` | Yes | `boolean` | `False` | Whether to fetch data from PESU's "Know Your Class and Section" information | +| `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | #### Response Object @@ -120,13 +125,13 @@ On authentication, it returns the following parameters in a JSON object. If the profile data was requested, the response's `profile` key will store a dictionary with a user's profile information. **On an unsuccessful sign-in, this field will not exist**. -| **Field** | **Type** | **Description** | -|-------------|-----------------|--------------------------------------------------------------------------| -| `status` | `boolean` | A flag indicating whether the overall request was successful | -| `profile` | `ProfileObject` | A nested map storing the profile information, returned only if requested | +| **Field** | **Type** | **Description** | +| ----------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- | +| `status` | `boolean` | A flag indicating whether the overall request was successful | +| `profile` | `ProfileObject` | A nested map storing the profile information, returned only if requested | | `know_your_class_and_section` | `KnowYourClassAndSectionObject` | A nested map storing the profile information from PESU's "Know Your Class and Section" endpoint | -| `message` | `str` | A message that provides information corresponding to the status | -| `timestamp` | `datetime` | A timezone offset timestamp indicating the time of authentication | +| `message` | `str` | A message that provides information corresponding to the status | +| `timestamp` | `datetime` | A timezone offset timestamp indicating the time of authentication | ##### `ProfileObject` @@ -134,7 +139,7 @@ This object contains the user's profile information, which is returned only if t If the authentication fails, this field will not be present in the response. | **Field** | **Description** | -|---------------|--------------------------------------------------------| +| ------------- | ------------------------------------------------------ | | `name` | Name of the user | | `prn` | PRN of the user | | `srn` | SRN of the user | @@ -149,19 +154,18 @@ If the authentication fails, this field will not be present in the response. #### `KnowYourClassAndSectionObject` -| **Field** | **Description** | -|------------------|----------------------------------------------------------------| -| `prn` | PRN of the user | -| `srn` | SRN of the user | -| `name` | Name of the user | -| `semester` | Current semester that the user is in | -| `section` | Section of the user | -| `cycle` | Physics Cycle or Chemistry Cycle, if the user is in first year | -| `department` | Abbreviation of the branch along with the campus the user is studying in | -| `branch` | Abbreviation of the branch that the user is pursuing | -| `institute_name` | The name of the campus that the user is studying in | -| `error` | The error name and stack trace, if an error occurs | - +| **Field** | **Description** | +| ---------------- | ------------------------------------------------------------------------ | +| `prn` | PRN of the user | +| `srn` | SRN of the user | +| `name` | Name of the user | +| `semester` | Current semester that the user is in | +| `section` | Section of the user | +| `cycle` | Physics Cycle or Chemistry Cycle, if the user is in first year | +| `department` | Abbreviation of the branch along with the campus the user is studying in | +| `branch` | Abbreviation of the branch that the user is pursuing | +| `institute_name` | The name of the campus that the user is studying in | +| `error` | The error name and stack trace, if an error occurs | ### `/health` @@ -170,10 +174,10 @@ does not take any request parameters. #### Response Object -| **Field** | **Type** | **Description** | -|-----------|------------|-------------------------------------------------------------------| -| `status` | `str` | `true` if healthy, `false` if there was an error | -| `message` | `str` | "ok" if healthy, error message otherwise | +| **Field** | **Type** | **Description** | +| ----------- | -------- | ----------------------------------------------------------------- | +| `status` | `str` | `true` if healthy, `false` if there was an error | +| `message` | `str` | "ok" if healthy, error message otherwise | | `timestamp` | `string` | A timezone offset timestamp indicating the time of authentication | ### `/readme` diff --git a/pyproject.toml b/pyproject.toml index d4a330b..ec17b38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ [project.optional-dependencies] dev = [ "matplotlib>=3.10.3", + "mdformat>=1.0.0", + "mdformat-gfm>=1.0.0", "numpy>=2.3.1", "pandas>=2.3.1", "pre-commit>=4.2.0", diff --git a/tests/unit/test_app_unit.py b/tests/unit/test_app_unit.py index cb4f47a..6231d06 100644 --- a/tests/unit/test_app_unit.py +++ b/tests/unit/test_app_unit.py @@ -38,37 +38,6 @@ def test_authenticate_general_exception(mock_authenticate, client): assert "Internal Server Error" in data["message"] -@patch("app.app.pesu_academy.authenticate") -def test_authenticate_passes_kycas_flag(mock_authenticate, client): - mock_authenticate.return_value = { - "status": True, - "message": "Login successful.", - "know_your_class_and_section": { - "srn": "PES2UG21CS310", - "semester": "Sem-8", - "section": "Section F", - }, - } - payload = { - "username": "testuser", - "password": "testpass", - "know_your_class_and_section": True, - } - - response = client.post("/authenticate", json=payload) - - assert response.status_code == 200 - data = response.json() - assert data["know_your_class_and_section"]["semester"] == "Sem-8" - mock_authenticate.assert_called_once_with( - username="testuser", - password="testpass", - profile=False, - know_your_class_and_section=True, - fields=None, - ) - - @patch("app.app.argparse.ArgumentParser.parse_args") @patch("app.app.logging.basicConfig") @patch("app.app.uvicorn.run") diff --git a/tests/unit/test_kycas_exception.py b/tests/unit/test_kycas_exception.py deleted file mode 100644 index e7a223a..0000000 --- a/tests/unit/test_kycas_exception.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Unit tests for the "Know Your Class and Section" fetch error exception.""" - -from app.exceptions.authentication import KYCASFetchError -from app.exceptions.base import PESUAcademyError - - -def test_kycas_fetch_error_default_message(): - """Test that the default message is set correctly.""" - error = KYCASFetchError() - assert 'Failed to fetch "Know Your Class and Section" data from PESU Academy.' in str(error) - - -def test_kycas_fetch_error_custom_message(): - """Test that a custom message overrides the default.""" - error = KYCASFetchError('Custom "Know Your Class and Section" error message.') - assert 'Custom "Know Your Class and Section" error message.' in str(error) - - -def test_kycas_fetch_error_status_code(): - """Test that the status code is 502.""" - error = KYCASFetchError() - assert error.status_code == 502 - - -def test_kycas_fetch_error_inherits_from_pesu_academy_error(): - """Test that the "Know Your Class and Section" fetch error subclasses PESUAcademyError.""" - assert issubclass(KYCASFetchError, PESUAcademyError) - - -def test_kycas_fetch_error_is_exception(): - """Test that the "Know Your Class and Section" fetch error can be raised and caught.""" - try: - raise KYCASFetchError("test") - except PESUAcademyError as e: - assert "test" in str(e) diff --git a/tests/unit/test_kycas_model.py b/tests/unit/test_kycas_model.py deleted file mode 100644 index f38e065..0000000 --- a/tests/unit/test_kycas_model.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Unit tests for the "Know Your Class and Section" Pydantic model.""" - -import pytest -from pydantic import ValidationError - -from app.models.kycas import KYCASModel - - -def test_kycas_model_all_fields(): - """Test creating the "Know Your Class and Section" model with all fields populated.""" - data = { - "prn": "PES1201800001", - "srn": "PES1UG19CS001", - "name": "John Doe", - "semester": "Sem-6", - "section": "Section A", - "cycle": "NA", - "department": "CSE(RR Campus)", - "branch": "CSE", - "institute_name": "PES University", - } - model = KYCASModel(**data) - assert model.prn == "PES1201800001" - assert model.srn == "PES1UG19CS001" - assert model.name == "John Doe" - assert model.semester == "Sem-6" - assert model.section == "Section A" - assert model.cycle == "NA" - assert model.department == "CSE(RR Campus)" - assert model.branch == "CSE" - assert model.institute_name == "PES University" - - -def test_kycas_model_all_defaults(): - """Test that all fields default to None.""" - model = KYCASModel() - assert model.prn is None - assert model.srn is None - assert model.name is None - assert model.semester is None - assert model.section is None - assert model.cycle is None - assert model.department is None - assert model.branch is None - assert model.institute_name is None - - -def test_kycas_model_partial_fields(): - """Test creating the "Know Your Class and Section" model with only some fields.""" - model = KYCASModel(prn="PES1201800001", name="Jane Doe") - assert model.prn == "PES1201800001" - assert model.name == "Jane Doe" - assert model.srn is None - assert model.semester is None - - -def test_kycas_model_strict_type_enforcement(): - """Test that strict mode rejects non-string types for string fields.""" - with pytest.raises(ValidationError) as exc_info: - KYCASModel(prn=12345) - assert "prn" in str(exc_info.value) - - -def test_kycas_model_serialization(): - """Test that model serializes to dict correctly.""" - data = { - "prn": "PES1201800001", - "srn": "PES1UG19CS001", - "name": "John Doe", - "semester": "Sem-6", - "section": "Section A", - "cycle": "NA", - "department": "CSE(RR Campus)", - "branch": "CSE", - "institute_name": "PES University", - } - model = KYCASModel(**data) - dumped = model.model_dump() - assert dumped == data - - -def test_kycas_model_json_serialization(): - """Test that the model can be serialized to JSON.""" - model = KYCASModel(prn="PES1201800001", name="John Doe") - json_str = model.model_dump_json() - assert "PES1201800001" in json_str - assert "John Doe" in json_str diff --git a/tests/unit/test_pesu.py b/tests/unit/test_pesu.py index 2b3a815..b284add 100644 --- a/tests/unit/test_pesu.py +++ b/tests/unit/test_pesu.py @@ -396,7 +396,7 @@ async def test_get_profile_information_no_profile_data(mock_get, mock_html_parse @patch("app.pesu.HTMLParser") @patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.PESUAcademy._extract_and_update_profile", new_callable=AsyncMock) +@patch("app.pesu.PESUAcademy._extract_and_update_profile", new_callable=MagicMock) @pytest.mark.asyncio async def test_get_profile_information_empty_profile_triggers_final_parse_error( mock_extract, @@ -699,6 +699,39 @@ async def test_get_kycas_success(pesu): assert result["institute_name"] == "PES University (Electronic City)" +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") +@pytest.mark.asyncio +async def test_authenticate_passes_kycas_flag(mock_get_kycas, mock_post, mock_get, pesu): + """Test that authenticate calls get_know_your_class_and_section when the flag is set.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "institute_name": "PES University", + } + + result = await pesu.authenticate("testuser", "testpass", profile=False, know_your_class_and_section=True) + + assert result["status"] is True + assert result["know_your_class_and_section"]["semester"] == "Sem-6" + mock_get_kycas.assert_called_once() + + @patch("app.pesu.httpx.AsyncClient.get") @patch("app.pesu.httpx.AsyncClient.post") @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 67543d1..3e47d0a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.12'", @@ -434,6 +434,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload-time = "2024-12-24T18:30:40.019Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "matplotlib" version = "3.10.5" @@ -498,6 +510,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/66/cd29ebc7f6c0d2a15d216fb572573e8fc38bd5d6dec3bd9d7d904c0949f7/matplotlib-3.10.5-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c49465bf689c4d59d174d0c7795fb42a21d4244d11d70e52b8011987367ac61", size = 8672192, upload-time = "2025-07-31T18:09:31.407Z" }, ] +[[package]] +name = "mdformat" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" }, +] + +[[package]] +name = "mdformat-gfm" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdformat" }, + { name = "mdit-py-plugins" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", hash = "sha256:d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623", size = 10112, upload-time = "2025-10-16T09:12:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", hash = "sha256:7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005", size = 10970, upload-time = "2025-10-16T09:12:21.276Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -654,6 +714,8 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "matplotlib" }, + { name = "mdformat" }, + { name = "mdformat-gfm" }, { name = "numpy" }, { name = "pandas" }, { name = "pre-commit" }, @@ -671,6 +733,8 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.109.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.10.3" }, + { name = "mdformat", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "mdformat-gfm", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "numpy", marker = "extra == 'dev'", specifier = ">=2.3.1" }, { name = "pandas", marker = "extra == 'dev'", specifier = ">=2.3.1" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.2.0" }, @@ -1214,3 +1278,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca373 wheels = [ { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +]