diff --git a/.gitignore b/.gitignore index acd899d..5de3fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Config - Testing config.py tests/ +pathfinding/ +test.py #Doc env .docs_env diff --git a/src/Course.py b/src/Course.py deleted file mode 100644 index 9a04743..0000000 --- a/src/Course.py +++ /dev/null @@ -1,48 +0,0 @@ -# Class to handle courses -from bs4 import BeautifulSoup -from requests import Session -from ExerciseGroup import ExerciseGroup -import re -from exceptions.CourseUnavailable import CourseUnavailable - -class Course: - # Extend the Base class init - def __init__(self, url:str, name:str, session:Session, parent): - self.url = url - self.name = name - self.__session = session - self.__parent = parent - self.__request = self.__session.get(self.url) - self.__raw = BeautifulSoup(self.__request.text, 'lxml') - - self.__courseAvailable(self.__session.get(self.url)) - - def __str__(self): - return f"Course {self.name} in year {self.__parent.year}" - - def __courseAvailable(self, r): - # Check if we got an error - # print(self.url) - if "Something went wrong" in r.text: - raise CourseUnavailable(message="'Something went wrong'. Course most likely not found. ") - - def getGroups(self, full:bool=False): - section = self.__raw.find('div', class_="ass-children") - entries = section.find_all('a', href=True) - return [ - ExerciseGroup( - f"https://themis.housing.rug.nl{x['href']}", - x, - self.__session, - self, - full - ) - for x in entries] - - # BAD: Repeated code!!!! - def getGroup(self, name:str, full:bool=False): - group = self.__raw.find("a", text=name) - if not group: - raise IllegalAction(message=f"No such group found: {name}") - - return ExerciseGroup(f"https://themis.housing.rug.nl{group['href']}", group, self.__session, self, full) diff --git a/src/ExerciseGroup.py b/src/ExerciseGroup.py deleted file mode 100644 index 9b46afe..0000000 --- a/src/ExerciseGroup.py +++ /dev/null @@ -1,231 +0,0 @@ -from bs4 import BeautifulSoup -from exceptions.IllegalAction import IllegalAction -import re -from json import loads -from time import sleep - - -class ExerciseGroup: - def __init__(self, url: str, soup, session, parent, full:bool): - self.url = url - self.name = soup.text - self.__prev_raw = soup - self.__session = session - self.__request = self.__session.get(self.url) - self.__raw = BeautifulSoup(self.__request.text, "lxml") - self.__full = full - - - @property - def amExercise(self): - return "ass-submitable" in self.__prev_raw["class"] - - def submit(self): - if not self.amExercise: - raise IllegalAction(message="You are submitting to a folder.") - - # Logic for submitting - - # Test cases - @property - def testCases(self): - section = self.__raw.find_all("div", class_="subsec round shade") - tcs = [] - for div in section: - res = div.find("h4", class_="info") - if not res: - continue - - if "Test cases" in res.text: - for case in div.find_all("div", class_="cfg-line"): - if link := case.find("a"): - tcs.append(link) - return tcs - return None - - def downloadTCs(self, path="."): - # Logic for downloading test cases(if any) - # In a div with class "subsec round shade", where there is an h4 with text "Test cases" - if not self.amExercise: - raise IllegalAction(message="You are downloading test cases from a folder.") - - for tc in self.testCases: - url = f"https://themis.housing.rug.nl{tc['href']}" - - print(f"Downloading {tc.text}") - # download the files - with open(f"{path}/{tc.text}", "wb") as f: - f.write(self.__session.get(url).content) - - return self.testCases - - # Files - @property - def files(self): - details = self.__raw.find("div", id=lambda x: x and x.startswith("details")) - - cfg_lines = details.find_all("div", class_="cfg-line") - - link_list = [] - - for line in cfg_lines: - key = line.find("span", class_="cfg-key") - - if key and "Downloads" in key.text.strip(): - # Extract all links in the cfg-val span - links = line.find_all("span", class_="cfg-val") - for link in links: - a = link.find_all("a") - for a in a: - link_list.append(a) - - return link_list if link_list else None - - def downloadFiles(self, path="."): - for file in self.files: - print(f"Downloading file {file.text}") - url = f"https://themis.housing.rug.nl{file['href']}" - with open(f"{path}/{file.text}", "wb") as f: - f.write(self.__session.get(url).content) - return self.files - - @property - def exercises(self) -> list: - if self.amExercise: - return self - - section = self.__raw.find("div", class_="ass-children") - try: - submittables = section.find_all("a", class_="ass-submitable") - except AttributeError: - return None - - if not self.__full: - return [(x.text,x['href']) for x in submittables] - return [ - ExerciseGroup( - f"https://themis.housing.rug.nl{x['href']}", x, self.__session, self, True - ) - for x in submittables - ] - - @property - def folders(self) -> list: - section = self.__raw.find("div", class_="ass-children") - try: - folders = section.find_all("a", class_="ass-group") - except AttributeError: - return None - - if not self.__full: - return [(x.text,x['href']) for x in folders] - - return [ - ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}", x, self.__session, self, True) - for x in folders - ] - - # Get by name - def getGroup(self, name:str, full:bool=False, link:str=None): - if link: - return ExerciseGroup(link, self.__prev_raw, self.__session, self, full) - - group = self.__raw.find("a", text=name) - if not group: - raise IllegalAction(message=f"No such group found: {name}") - - return ExerciseGroup(f"https://themis.housing.rug.nl{group['href']}", group, self.__session, self, full) - - # Account for judge - def __raceCondition(self, soup, url:str, verbose:bool): - self.__session.get(url.replace("submission", "judge")) - return self.__waitForResult(url, verbose, []) - - def __parseTable(self, soup, url:str, verbose:bool, __printed:list): - cases = soup.find_all('tr', class_='sub-casetop') - fail_pass = {} - i = 1 - for case in cases: - name = case.find('td', class_='sub-casename').text - status = case.find('td', class_='status-icon') - - if "pending" in status.get("class"): - return self.__raceCondition(soup,url,verbose) - - # queued status-icon - if "queued" in status.get("class"): - sleep(1) # <- 🗿 - return self.__waitForResult(url, verbose, __printed) - - if "Passed" in status.text: - fail_pass[int(name)] = True - if int(name) not in __printed and verbose == True: - print(f"{name}: ✅") - elif "Wrong output" in status.text: - fail_pass[int(name)] = False - if int(name) not in __printed and verbose == True: - print(f"{name}: ❌") - elif ("No status" or "error") in status.text: - fail_pass[int(name)] = None - if int(name) not in __printed and verbose == True: - print(f"{name}:🐛") - - __printed.append(int(name)) - i += 1 - return fail_pass - - def __waitForResult(self, url:str, verbose:bool, __printed:list): - # This waits for result and returns a bundled info package - r = self.__session.get(url) - soup = BeautifulSoup(r.text, "lxml") - return self.__parseTable(soup, url, verbose, __printed) - - - # Submit - def submit(self, files: list, judge=True, wait=True, silent=True): - - # Find the form with submit and store the action as url - # Store then the data-suffixes as file_types - dictionary - - form = self.__raw.find("form") - if not form: - raise IllegalAction(message="You cannot submit to this assignment.") - - url = "https://themis.housing.rug.nl" + form["action"] - file_types = loads(form["data-suffixes"]) - - if isinstance(files, str): - temp = [] - temp.append(files) - files = temp - - # Package the files up into files[] - # DEBUG: Uncomment for better clarity - # print("Submitting files:") - # [print(f) for f in files] - packaged_files = [] - data = {} - found_type = "" - for file in files: - for t in file_types: - if t in file: - found_type = file_types[t] - break - if not found_type: - raise IllegalAction(message="Illegal filetype for this assignment.") - - packaged_files.append((f"files[]", (file, open(file, "rb"), "text/x-csrc"))) - - data = {"judgenow": "true" if judge else "false", "judgeLanguage": found_type} - - resp = self.__session.post(url, files=packaged_files, data=data) - - # Close each file - i = 0 - for f in packaged_files: - f[1][1].close() - - if not wait: - return resp.url if "@submissions" in resp.url else None - - return self.__waitForResult(resp.url, not silent, []) diff --git a/src/Themis.py b/src/Themis.py deleted file mode 100644 index 7761fef..0000000 --- a/src/Themis.py +++ /dev/null @@ -1,62 +0,0 @@ -from Year import Year -import urllib3 -from requests import Session -from bs4 import BeautifulSoup - -# Disable warnings -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - -class Themis: - def __init__(self,user:str, passwd:str): - self.session = self.login(user,passwd) - self.years = [] - self.url = "https://themis.housing.rug.nl/course/" - - def login(self, user, passwd): - headers = { - "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chromium/80.0.3987.160 Chrome/80.0.3987.163 Safari/537.36" - } - - data = { - "user": user, - "password":passwd, - "null": None - } - - with Session() as s: - url = 'https://themis.housing.rug.nl/log/in' - r = s.get(url,headers=headers,verify=False) - soup = BeautifulSoup(r.text, 'lxml') - - # get the csrf token and add it to payload - csrfToken = soup.find('input',attrs = {'name':'_csrf'})['value'] - data['_csrf'] = csrfToken - data['sudo'] = user.lower() - - # Login - r = s.post(url,data=data,headers = headers) - - # check if login was successful - log_out = "Welcome, logged in as" in r.text - if not log_out: - raise Exception(f"Login for user {user} failed") - - return s - - - def getYear(self, start:int, end:int): - return Year(self.session, self, start, end) - - def allYears(self): - # All of them are in a big ul at the beginning of the page - r = self.session.get(self.url) - soup = BeautifulSoup(r.text, 'lxml') - ul = soup.find('ul', class_='round') - lis = ul.find_all('li', class_='large') - years = [] - for li in lis: - # format: 2019-2020 - year = li.a.text.split("-") - years.append(Year(self.session, self, int(year[0]), int(year[1]))) - - return years # Return a list of year objects \ No newline at end of file diff --git a/src/Year.py b/src/Year.py deleted file mode 100644 index 639c994..0000000 --- a/src/Year.py +++ /dev/null @@ -1,55 +0,0 @@ -# Year class to represent an academic year - -from bs4 import BeautifulSoup -from Course import Course -from requests import Session -from exceptions.CourseUnavailable import CourseUnavailable - -# Works -class Year: - def __init__(self, session:Session, parent, start_year:int, end_year:int): - self.start = start_year - self.year = end_year - self.url = self.__constructUrl() - self.__session = session - - # Method to set the url - def __constructUrl(self): - return f"https://themis.housing.rug.nl/course/{self.start}-{self.year}" - - # Method to get the courses of the year - def allCourses(self, errors:bool=False) -> list[Course]: - # lis in a big ul - r = self.__session.get(self.url) - soup = BeautifulSoup(r.text, 'lxml') - lis = soup.find_all('li', class_='large') - courses = [] - for li in lis: - try: - suffix = (li.a['href'].replace(f"course/{self.start}-{self.year}", "")) - courses.append( - Course( - self.url + suffix, - li.a.text, - self.__session, - self - ) - ) - except CourseUnavailable: - if errors: - raise CourseUnavailable(f"Course {li.a.text} in year {self.start}-{self.year} is not available") - else: - print("error with course", li.a.text) - continue - - - return courses - - def getCourse(self, name:str) -> Course: - # Get the course - r = self.__session.get(self.url) - soup = BeautifulSoup(r.text, 'lxml') - # Search by name - course = self.url + soup.find('a', text=name)['href'].replace(f"course/{self.start}-{self.year}", "") - # Get the url and transform it into a course object - return Course(url=course, name=name, session=self.__session, parent=self) \ No newline at end of file diff --git a/src/course.py b/src/course.py new file mode 100644 index 0000000..db69c99 --- /dev/null +++ b/src/course.py @@ -0,0 +1,71 @@ +""" +Houses the Course class which is used to represent a course in a year. +""" + +from bs4 import BeautifulSoup +from requests import Session +from exercise_group import ExerciseGroup +from exceptions.course_unavailable import CourseUnavailable +from exceptions.illegal_action import IllegalAction + + +class Course: + """ + get_groups: Get all groups in a course. Set full to True to get all subgroups. + get_group: Get a group by name. Set full to True to get all subgroups. + """ + + def __init__(self, url: str, name: str, session: Session, parent): + self.url = url + self.name = name + self.__session = session + self.__parent = parent + self.__request = self.__session.get(self.url) + self.__raw = BeautifulSoup(self.__request.text, "lxml") + + self.__course_available(self.__session.get(self.url)) + + def __str__(self): + return f"Course {self.name} in year {self.__parent.year}" + + def __course_available(self, r): + # Check if we got an error + # print(self.url) + if "Something went wrong" in r.text: + raise CourseUnavailable( + message="'Something went wrong'. Course most likely not found. " + ) + + def get_groups(self, full: bool = False) -> list[ExerciseGroup]: + """ + get_groups(full: bool = False) -> list[ExerciseGroup] + Get all groups in a course. Set full to True to get all subgroups. + """ + section = self.__raw.find("div", class_="ass-children") + entries = section.find_all("a", href=True) + return [ + ExerciseGroup( + f"https://themis.housing.rug.nl{x['href']}", + x, + self.__session, + full + ) + for x in entries + ] + + # BAD: Repeated code!!!! + def get_group(self, name: str, full: bool = False) -> ExerciseGroup: + """ + get_group(name:str, full:bool = False) -> ExerciseGroup + Get a single group by name. Set full to True to get all subgroups as well. + """ + group = self.__raw.find("a", text=name) + if not group: + raise IllegalAction(message=f"No such group found: {name}") + + return ExerciseGroup( + f"https://themis.housing.rug.nl{group['href']}", + group, + self.__session, + full + ) diff --git a/src/exceptions/CourseUnavailable.py b/src/exceptions/CourseUnavailable.py deleted file mode 100644 index 87d83ca..0000000 --- a/src/exceptions/CourseUnavailable.py +++ /dev/null @@ -1,4 +0,0 @@ -class CourseUnavailable(Exception): - def __init__(self, message:str=""): - self.message = "Course Error: " + message - super().__init__(self.message) \ No newline at end of file diff --git a/src/exceptions/IllegalAction.py b/src/exceptions/IllegalAction.py deleted file mode 100644 index fddb072..0000000 --- a/src/exceptions/IllegalAction.py +++ /dev/null @@ -1,4 +0,0 @@ -class IllegalAction(Exception): - def __init__(self, message:str=""): - self.message = "Illegal action: " + message - super().__init__(self.message) \ No newline at end of file diff --git a/src/exceptions/course_unavailable.py b/src/exceptions/course_unavailable.py new file mode 100644 index 0000000..926f370 --- /dev/null +++ b/src/exceptions/course_unavailable.py @@ -0,0 +1,6 @@ +""" This module contains the CourseUnavailable exception. """ + +class CourseUnavailable(Exception): + """CourseUnavailable Exception""" + def __init__(self, message: str = ""): + super().__init__(f"Course unavailable: {message}") diff --git a/src/exceptions/illegal_action.py b/src/exceptions/illegal_action.py new file mode 100644 index 0000000..5456590 --- /dev/null +++ b/src/exceptions/illegal_action.py @@ -0,0 +1,8 @@ +""" +Illegal Action Exception +""" + +class IllegalAction(Exception): + """Illegal Action Exception""" + def __init__(self, message: str = ""): + super().__init__(f"Illegal action: {message}") diff --git a/src/exercise_group.py b/src/exercise_group.py new file mode 100644 index 0000000..5f37e21 --- /dev/null +++ b/src/exercise_group.py @@ -0,0 +1,270 @@ +""" +Houses the ExerciseGroup class. +Represents a group of exercises or a single exercise. + +""" + +from json import loads +from time import sleep +from bs4 import BeautifulSoup +from exceptions.illegal_action import IllegalAction + + +class ExerciseGroup: + """ + am_exercise: returns bool which tells you if the instance is an exercise + submit: submit to an exercise + get_group: get a group by name + get_groups: get all groups + folders: folders in the folder + exercises: exercises in the folder + test_cases: test cases in the exercise(if it is an exercise) + download_tcs: download test cases + files: files in the exercise/folder + download_files: download files + """ + + def __init__(self, url: str, soup, session, full: bool): + self.url = url + self.name = soup.text + self.__prev_raw = soup + self.__session = session + self.__request = self.__session.get(self.url) + self.__raw = BeautifulSoup(self.__request.text, "lxml") + self.__full = full + + @property + def am_exercise(self) -> bool: + return "ass-submitable" in self.__prev_raw["class"] + + # Test cases + @property + def test_cases(self) -> list[str]: + section = self.__raw.find_all("div", class_="subsec round shade") + tcs = [] + for div in section: + res = div.find("h4", class_="info") + if not res: + continue + + if "Test cases" in res.text: + for case in div.find_all("div", class_="cfg-line"): + if link := case.find("a"): + tcs.append(link) + return tcs + + def download_tcs(self, path=".") -> list[str]: + """ + download_tcs(path=".") -> list[str] + Downloads every test case available from a given exercise. `path` defaults to '.'. + """ + if not self.am_exercise: + raise IllegalAction(message="You are downloading test cases from a folder.") + + for tc in self.test_cases: + url = f"https://themis.housing.rug.nl{tc['href']}" + + print(f"Downloading {tc.text}") + # download the files + with open(f"{path}/{tc.text}", "wb") as f: + f.write(self.__session.get(url).content) + + return self.test_cases + + # Files + @property + def files(self) -> list[str]: + details = self.__raw.find("div", id=lambda x: x and x.startswith("details")) + + cfg_lines = details.find_all("div", class_="cfg-line") + + link_list = [] + + for line in cfg_lines: + key = line.find("span", class_="cfg-key") + + if key and "Downloads" in key.text.strip(): + # Extract all links in the cfg-val span + links = line.find_all("span", class_="cfg-val") + for link in links: + a = link.find_all("a") + for i in a: + link_list.append(i) + + return link_list + + def download_files(self, path=".") -> list[str]: + """ + download_files(path=".") -> list[str] + Downloads every file available from a given exercise/folder. `path` defaults to '.'. + """ + for file in self.files: + print(f"Downloading file {file.text}") + url = f"https://themis.housing.rug.nl{file['href']}" + with open(f"{path}/{file.text}", "wb") as f: + f.write(self.__session.get(url).content) + return self.files + + @property + def exercises(self) -> list[str] | list["ExerciseGroup"]: + if self.am_exercise: + return self + + section = self.__raw.find("div", class_="ass-children") + try: + submittables = section.find_all("a", class_="ass-submitable") + except AttributeError: + return [] + + if not self.__full: + return [(x.text, x["href"]) for x in submittables] + return [ + ExerciseGroup( + f"https://themis.housing.rug.nl{x['href']}", x, self.__session, True + ) + for x in submittables + ] + + @property + def folders(self) -> list[str] | list["ExerciseGroup"]: + section = self.__raw.find("div", class_="ass-children") + try: + folders = section.find_all("a", class_="ass-group") + except AttributeError: + return [] + + if not self.__full: + return [(x.text, x["href"]) for x in folders] + + return [ + ExerciseGroup( + f"https://themis.housing.rug.nl{x['href']}", x, self.__session, True + ) + for x in folders + ] + + # Get by name + def get_group( + self, name: str, full: bool = False, link: str = None + ) -> "ExerciseGroup": + """ + get_group(name:str, full:bool=False, link:str=None) -> ExerciseGroup | list[ExerciseGroup] + Get a single group by name. + Set `full` to True to get all subgroups as well. + Set `link` to directly fetch a group. + """ + if link: + return ExerciseGroup(link, self.__prev_raw, self.__session, full) + + group = self.__raw.find("a", text=name) + if not group: + raise IllegalAction(message=f"No such group found: {name}") + + return ExerciseGroup( + f"https://themis.housing.rug.nl{group['href']}", group, self.__session, full + ) + + # Wait for result + def __wait_for_result(self, url: str, verbose: bool, __printed: list) -> None: + # This waits for result and returns a bundled info package + r = self.__session.get(url) + soup = BeautifulSoup(r.text, "lxml") + return self.__parse_table(soup, url, verbose, __printed) + + # Account for judge + def __race_condition(self, url: str, verbose: bool) -> None: + self.__session.get(url.replace("submission", "judge")) + return self.__wait_for_result(url, verbose, []) + + def __parse_table( + self, soup: BeautifulSoup, url: str, verbose: bool, __printed: list + ) -> dict: + cases = soup.find_all("tr", class_="sub-casetop") + fail_pass = {} + i = 1 + for case in cases: + name = case.find("td", class_="sub-casename").text + status = case.find("td", class_="status-icon") + + if "pending" in status.get("class"): + return self.__race_condition(url, verbose) + + # queued status-icon + if "queued" in status.get("class"): + sleep(1) # <- 🗿 + return self.__wait_for_result(url, verbose, __printed) + + statuses = { + "Passed": ("✅", True), + "Wrong output": ("❌", False), + "No status": ("🐛", None), + "error": ("🐛", None), + } + + # Printing and storing + found = False + for k, v in statuses.items(): + if k in status.text: + found = True + if verbose and int(name) not in __printed: + print(f"{name}: {v[0]}") + fail_pass[int(name)] = v[1] + break + if not found: + fail_pass[int(name)] = None + if verbose and int(name) not in __printed: + print(f"{name}: Unrecognized status: {status.text}") + + __printed.append(int(name)) + i += 1 + return fail_pass + + # Submit + def submit( + self, files: list, judge: bool = True, wait: bool = True, silent: bool = True + ) -> dict | None: + """ + submit(files:list, judge:bool=True, wait:bool=True, silent:bool=True) -> dict | None + Submits given files to given exercise. Returns a dictionary of test cases and their status. + Set judge to False to not judge the submission. + Set wait to False to not wait for the result. + Set silent to False to print the results. + """ + form = self.__raw.find("form") + if not form: + raise IllegalAction(message="You cannot submit to this assignment.") + + url = "https://themis.housing.rug.nl" + form["action"] + file_types = loads(form["data-suffixes"]) + + if isinstance(files, str): + temp = [] + temp.append(files) + files = temp + + packaged_files = [] + data = {} + found_type = "" + for file in files: + for t in file_types: + if t in file: + found_type = file_types[t] + break + if not found_type: + raise IllegalAction(message="Illegal filetype for this assignment.") + + with open(file, "rb") as f: + packaged_files.append((found_type, (file, f.read()))) + + data = {"judgenow": "true" if judge else "false", "judgeLanguage": found_type} + + if not silent: + print(f"Submitting to {self.name}") + for file in files: + print(f"• {file}") + resp = self.__session.post(url, files=packaged_files, data=data) + + if not wait or not judge: + return resp.url if "@submissions" in resp.url else None + + return self.__wait_for_result(resp.url, not silent, []) diff --git a/src/themis.py b/src/themis.py new file mode 100644 index 0000000..99707ff --- /dev/null +++ b/src/themis.py @@ -0,0 +1,90 @@ +""" +Main class for the Themis API + +""" + +import urllib3 +from requests import Session +from bs4 import BeautifulSoup +from year import Year +from exceptions.illegal_action import IllegalAction + + +# Disable warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +class Themis: + """ + login: Login to Themis + get_year: Get a year object + all_years: Get all years + """ + + def __init__(self, user: str, passwd: str): + self.session = self.login(user, passwd) + self.years = [] + self.url = "https://themis.housing.rug.nl/course/" + + def login(self, user: str, passwd: str) -> Session: + """ + login(self, user: str, passwd: str) -> Session + Login to Themis + Set user to your student number and passwd to your password + """ + + user_agent = ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chromium/80.0.3987.160 Chrome/80.0.3987.163 Safari/537.36" + ) + + headers = {"user-agent": user_agent} + + data = {"user": user, "password": passwd, "null": None} + + with Session() as s: + url = "https://themis.housing.rug.nl/log/in" + r = s.get(url, headers=headers, verify=False) + soup = BeautifulSoup(r.text, "lxml") + + # get the csrf token and add it to payload + csrf_token = soup.find("input", attrs={"name": "_csrf"})["value"] + data["_csrf"] = csrf_token + data["sudo"] = user.lower() + + # Login + r = s.post(url, data=data, headers=headers) + + # check if login was successful + log_out = "Welcome, logged in as" in r.text + if not log_out: + raise IllegalAction(message=f"Login for user {user} failed") + + return s + + def get_year(self, start: int, end: int) -> Year: + """ + get_year(self, start: int, end: int) -> Year + Gets a year object + Set start to the start year and end to the end year (e.g. 2023-2024) + """ + return Year(self.session, start, end) + + def all_years(self) -> list[Year]: + """ + get_years(self, start: int, end: int) -> list[Year] + Gets all visible years + """ + # All of them are in a big ul at the beginning of the page + r = self.session.get(self.url) + soup = BeautifulSoup(r.text, "lxml") + ul = soup.find("ul", class_="round") + lis = ul.find_all("li", class_="large") + years = [] + for li in lis: + # format: 2019-2020 + year = li.a.text.split("-") + years.append(Year(self.session, int(year[0]), int(year[1]))) + + return years # Return a list of year objects diff --git a/src/year.py b/src/year.py new file mode 100644 index 0000000..cc09604 --- /dev/null +++ b/src/year.py @@ -0,0 +1,66 @@ +""" +Class which represents an academic year. +""" + +from bs4 import BeautifulSoup +from requests import Session + +from course import Course +from exceptions.course_unavailable import CourseUnavailable + + +# Works +class Year: + """ + all_courses: Get all visible courses in a year + get_course: Get a course by name + """ + + def __init__(self, session: Session, start_year: int, end_year: int): + self.start = start_year + self.year = end_year + self.url = f"https://themis.housing.rug.nl/course/{self.start}-{self.year}" + self.__session = session + + # Method to get the courses of the year + def all_courses(self, errors: bool = True) -> list[Course]: + """ + all_courses(self, errors: bool = False) -> list[Course] + Gets all visible courses in a year. + Set errors to False to not raise an error when a course is unavailable. + """ + r = self.__session.get(self.url) + soup = BeautifulSoup(r.text, "lxml") + lis = soup.find_all("li", class_="large") + courses = [] + for li in lis: + try: + suffix = li.a["href"].replace(f"course/{self.start}-{self.year}", "") + courses.append( + Course(self.url + suffix, li.a.text, self.__session, self) + ) + except CourseUnavailable as exc: + if errors: + raise CourseUnavailable( + message=f"Course {li.a.text} in year {self.start}-{self.year} unavailable" + ) from exc + + print("Error with course", li.a.text) + continue + + return courses + + def get_course(self, name: str) -> Course: + """ + get_course(self, name: str) -> Course + Gets a course by name. + """ + # Get the course + r = self.__session.get(self.url) + soup = BeautifulSoup(r.text, "lxml") + # Search by name + course = self.url + soup.find("a", text=name)["href"].replace( + f"course/{self.start}-{self.year}", "" + ) + # Get the url and transform it into a course object + return Course(url=course, name=name, session=self.__session, parent=self)