diff --git a/setup.py b/setup.py index 11ee8e5..80cb732 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open("README.md", "r") as f: setup( name="temmies", - version="1.2.12", + version="1.2.121", packages=find_packages(), description="A wrapper for the Themis website", long_description=l_description, @@ -21,6 +21,7 @@ setup( "Programming Language :: Python :: 3.9", ], install_requires=[ + "urllib3", "requests", "lxml", "beautifulsoup4", diff --git a/temmies/__init__.py b/temmies/__init__.py index 0c82371..c64834e 100644 --- a/temmies/__init__.py +++ b/temmies/__init__.py @@ -1,5 +1,8 @@ -from .themis import Themis +""" +Entry point for the temmies package. +""" import urllib3 +from .themis import Themis __all__ = ["Themis"] urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) diff --git a/temmies/course.py b/temmies/course.py index 21f2163..12f1fd0 100644 --- a/temmies/course.py +++ b/temmies/course.py @@ -1,5 +1,10 @@ -from .group import Group +""" +Represents a course. +A course is a group that contains exercises or other groups. +""" +from .group import Group +from .exercise_group import ExerciseGroup class Course(Group): """ @@ -25,11 +30,11 @@ class Course(Group): self, item_data.get("submitable", False), ) - else: - return Group( - self.session, - item_data["path"], - item_data["title"], - self, - item_data.get("submitable", False), - ) + + return Group( + self.session, + item_data["path"], + item_data["title"], + self, + item_data.get("submitable", False), + ) diff --git a/temmies/exercise_group.py b/temmies/exercise_group.py index dd97f46..bab2a60 100644 --- a/temmies/exercise_group.py +++ b/temmies/exercise_group.py @@ -1,12 +1,15 @@ -from .group import Group -from .submission import Submission +""" +Represents a submittable exercise. +""" + from bs4 import BeautifulSoup +from .group import Group class ExerciseGroup(Group): """ Represents a submittable exercise. """ - + # pylint: disable=too-many-arguments, too-many-positional-arguments def __init__(self, session, path: str, title: str, parent, submitable: bool = True): super().__init__(session, path, title, parent, submitable=submitable) self.submit_url = f"{self.base_url}/api/submit{self.path}" diff --git a/temmies/group.py b/temmies/group.py index aaa3568..e32fe5e 100644 --- a/temmies/group.py +++ b/temmies/group.py @@ -1,17 +1,22 @@ -from bs4 import BeautifulSoup -from requests import Session +""" +Abstract-ish Group class for Themis API. +""" + import os from typing import Optional, Union, Dict -from .exceptions.illegal_action import IllegalAction -from .submission import Submission from json import loads from time import sleep +from bs4 import BeautifulSoup +from .submission import Submission + class Group: """ - Represents an item in Themis, which can be either a folder (non-submittable) or an assignment (submittable). + Represents an item in Themis. + Can be either a folder (non-submittable) or an assignment (submittable). """ + # pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments def __init__(self, session, path: str, title: str, parent=None, submitable: bool = False): self.session = session self.path = path # e.g., '/2023-2024/adinc-ai/labs' @@ -31,10 +36,10 @@ class Group: # Fetch the page and parse it response = self.session.get(group_url) if response.status_code != 200: - raise ConnectionError(f"Failed to retrieve page for '{self.title}'. Tried {group_url}") + raise ConnectionError(f"Failed to retrieve page for '{ + self.title}'. Tried {group_url}") self._raw = BeautifulSoup(response.text, "lxml") - def get_items(self) -> list: """ Get all items (groups and assignments) under this group. @@ -70,19 +75,20 @@ class Group: return item raise ValueError(f"Item '{title}' not found under {self.title}.") - def get_status(self, text: bool = False) -> Union[Dict[str, Union[str, 'Submission']], None]: """ Get the status of the current group, if available. """ status_link = self._raw.find("a", text="Status") if not status_link: - raise ValueError("Status information is not available for this group.") + raise ValueError( + "Status information is not available for this group.") status_url = f"{self.base_url}{status_link['href']}" response = self.session.get(status_url) if response.status_code != 200: - raise ConnectionError(f"Failed to retrieve status page for '{self.title}'.") + raise ConnectionError( + f"Failed to retrieve status page for '{self.title}'.") soup = BeautifulSoup(response.text, "lxml") section = soup.find("div", class_="cfg-container") @@ -92,7 +98,10 @@ class Group: return self.__parse_status_section(section, text) - def __parse_status_section(self, section: BeautifulSoup, text: bool) -> Dict[str, Union[str, 'Submission']]: + def __parse_status_section( + self, section: BeautifulSoup, + text: bool + ) -> Dict[str, Union[str, 'Submission']]: """ Parse the status section of the group and clean up keys. """ @@ -113,8 +122,10 @@ class Group: continue # Normalize key - raw_key = " ".join(key_element.get_text(separator=" ").strip().replace(":", "").lower().split()) - key = key_mapping.get(raw_key, raw_key) # Use mapped key if available + raw_key = " ".join(key_element.get_text( + separator=" ").strip().replace(":", "").lower().split()) + # Use mapped key if available + key = key_mapping.get(raw_key, raw_key) # Process value link = value_element.find("a", href=True) @@ -124,7 +135,8 @@ class Group: if href.startswith("/"): submission_url = href elif href.startswith("http"): - submission_url = href.replace("https://themis.housing.rug.nl", "") + submission_url = href.replace( + "https://themis.housing.rug.nl", "") else: print(f"Invalid href '{href}' found in status page.") continue # Skip this entry if href is invalid @@ -137,13 +149,13 @@ class Group: return parsed - def get_test_cases(self) -> list[Dict[str, str]]: """ Get all test cases for this assignment. """ if not self.submitable: - raise ValueError(f"No test cases for non-submittable item '{self.title}'.") + raise ValueError( + f"No test cases for non-submittable item '{self.title}'.") sections = self._raw.find_all("div", class_="subsec round shade") tcs = [] @@ -182,7 +194,8 @@ class Group: """ Get all downloadable files for this assignment. """ - details = self._raw.find("div", id=lambda x: x and x.startswith("details")) + details = self._raw.find( + "div", id=lambda x: x and x.startswith("details")) if not details: return [] @@ -221,13 +234,21 @@ class Group: print(f"Failed to download file '{file['title']}'") return downloaded - def submit(self, files: list[str], judge: bool = True, wait: bool = True, silent: bool = True) -> Optional[dict]: + # pylint: disable=too-many-locals + def submit( + self, + files: list[str], + judge: bool = True, + wait: bool = True, + silent: bool = True + ) -> Optional[dict]: """ Submit files to this assignment. Returns a dictionary of test case results or None if wait is False. """ if not self.submitable: - raise ValueError(f"Cannot submit to non-submittable item '{self.title}'.") + raise ValueError( + f"Cannot submit to non-submittable item '{self.title}'.") form = self._raw.find("form") if not form: @@ -322,18 +343,17 @@ class Group: case_number = int(name) fail_pass[case_number] = None if verbose and case_number not in __printed: - print(f"{case_number}: Unrecognized status: {status.text.strip()}") + print(f"{case_number}: Unrecognized status: { + status.text.strip()}") __printed.append(case_number) - # Polling - # FIXME: Use ws + # Polling (fix, use ws) if any_queued: sleep(1) return self.__wait_for_result(url, verbose, __printed) return fail_pass - def __str__(self): return f"Group({self.title}, submitable={self.submitable})" diff --git a/temmies/submission.py b/temmies/submission.py index 562659d..fd05431 100644 --- a/temmies/submission.py +++ b/temmies/submission.py @@ -82,13 +82,22 @@ class Submission: # Deprecated methods def info(self): + """ + Deprecated method. Use get_info instead. + """ print("This method is deprecated and will be deleted soon. Use get_info instead.") return self.get_info() def test_cases(self): + """ + Deprecated method. Use get_test_cases instead. + """ print("This method is deprecated and will be deleted in soon. Use get_test_cases instead.") return self.get_test_cases() def files(self): + """ + Deprecated method. Use get_files instead. + """ print("This method is deprecated and will be deleted in soon. Use get_files instead.") return self.get_files() diff --git a/temmies/themis.py b/temmies/themis.py index 9024998..a1dea86 100644 --- a/temmies/themis.py +++ b/temmies/themis.py @@ -2,12 +2,12 @@ Main class for the Themis API using the new JSON endpoints. """ -import keyring import getpass +import keyring from requests import Session from bs4 import BeautifulSoup from .year import Year -from .exceptions.illegal_action import IllegalAction + class Themis: """ @@ -34,6 +34,7 @@ class Themis: self.password = self.__get_password() self.base_url = "https://themis.housing.rug.nl" self.session = self.login(self.user, self.password) + def __get_password(self) -> str: """ Retrieve the password from the keyring, prompting the user if not found. @@ -41,7 +42,8 @@ class Themis: password = keyring.get_password(f"{self.user}-temmies", self.user) if not password: print(f"Password for user '{self.user}' not found in keyring.") - password = getpass.getpass(prompt=f"Enter password for {self.user}: ") + password = getpass.getpass( + prompt=f"Enter password for {self.user}: ") keyring.set_password(f"{self.user}-temmies", self.user, password) print("Password saved securely in keyring.") return password @@ -85,7 +87,8 @@ class Themis: passwd = getpass.getpass(prompt="Enter password: ") keyring.set_password(f'{self.user}-temmies', self.user, passwd) return self.login(user, passwd) - elif "Welcome, logged in as" not in response.text: + + if "Welcome, logged in as" not in response.text: raise ValueError("Login failed for an unknown reason.") return session @@ -95,7 +98,7 @@ class Themis: Gets a Year object using the year path (e.g., 2023, 2024). """ year_path = f"{start_year}-{end_year}" - + return Year(self.session, year_path) def all_years(self) -> list: diff --git a/temmies/year.py b/temmies/year.py index 841e844..c67f717 100644 --- a/temmies/year.py +++ b/temmies/year.py @@ -1,5 +1,11 @@ -from .course import Course +""" +This module defines the Year class for managing academic year courses. +""" + from bs4 import BeautifulSoup +from .course import Course + + class Year: """ Represents an academic year. @@ -37,8 +43,6 @@ class Year: return course raise ValueError(f"Course '{course_title}' not found in year {self.year_path}.") - from bs4 import BeautifulSoup - def get_course_by_tag(self, course_tag: str) -> Course: """ Gets a course by its tag (course identifier). @@ -49,18 +53,21 @@ class Year: response = self.session.get(course_url) if response.status_code != 200: - raise ConnectionError(f"Failed to retrieve course with tag '{course_tag}' for year {self.year_path}. Tried {course_url}") + raise ConnectionError( + f"Failed to retrieve course '{course_tag}' for year {self.year_path}." + ) soup = BeautifulSoup(response.text, "lxml") title_elements = soup.find_all("a", class_="fill accent large") - if title_elements: - title_element = title_elements[-1] + title_element = title_elements[-1] if title_elements else None if title_element: course_title = title_element.get_text(strip=True) else: - raise ValueError(f"Could not retrieve course title for tag '{course_tag}' in year {self.year_path}.") + raise ValueError( + f"Could not retrieve course title for tag '{course_tag}' in year {self.year_path}." + ) return Course(self.session, course_path, course_title, self)