From cd5e9b2c8d04af7ea42c484ee52ac9feb6343c22 Mon Sep 17 00:00:00 2001 From: Boyan <36108495+confestim@users.noreply.github.com> Date: Sat, 20 Apr 2024 21:35:09 +0200 Subject: [PATCH] Added submissions. Updated docs. --- docs/api.md | 78 ++++++++++++++++++++++++++++++ src/exercise_group.py | 109 +++++++++++++++++++++++++++++++++++------- src/submission.py | 85 ++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 16 deletions(-) create mode 100644 src/submission.py diff --git a/docs/api.md b/docs/api.md index efaf396..31a7183 100644 --- a/docs/api.md +++ b/docs/api.md @@ -186,5 +186,83 @@ Submits the files to the exercise group. Default arguments are `judge=True`, `wa ``` +#### `get_status(section=None, text=False)` +Parses the status of the exercise group(from a given section). If `section` is not `None`, it will return the status of the section. Don't set `section` if you don't know what you're doing. + +When `text` is set to `True`, it will return the status as a dictionary of strings. Otherwise, it will return a tuple in the form `(dict(str:str), dict(str:Submission))`. Refer to the [Submission](#submission) class for more information. + +```python + pf = year.get_course("Programming Fundamentals (for CS)") + pf_as = pf.get_group("Lab Session 2") + + # Get exercise + exercise = pf_as.get_group("Recurrence") + + # Get status + status = exercise.get_status() + print(status) + + >>> ( + >>> { # Information [0] + >>> 'assignment': 'Recurrence' + >>> 'group': 'Y.N. Here' + >>> 'status': 'passed: Passed all test cases' + >>> 'grade': '2.00' + >>> 'total': '2' + >>> 'output limit': '1' + >>> 'passed': '1' + >>> 'leading': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1' + >>> 'best': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1' + >>> 'latest': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1' + >>> 'first pass': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1' + >>> 'last pass': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1' + >>> 'visible': 'Yes' + >>> } + >>> { # Submission instances [1] + >>> 'leading': + >>> 'best': + >>> 'latest': + >>> 'first_pass': + >>> 'last_pass': + >>> } + >>>) +``` + +#### `get_all_statuses(text=False) +Does the same as `get_status`, but for all visible status sections. + + +## `Submission` +### Usage +```python +submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"] + +``` + +### Methods +#### `test_cases()` +Returns a list of `TestCase` instances corresponding to all test cases in the submission. + +```python + submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"] + submission.test_cases() +``` + +#### `info()` +Returns a dictionary of information about the submission. + +```python + submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"] + submission.info() +``` + +#### `files()` +Returns a list of files in the form `(name, link)`. + +```python + submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"] + submission.files() +``` + diff --git a/src/exercise_group.py b/src/exercise_group.py index 5f37e21..e9ee768 100644 --- a/src/exercise_group.py +++ b/src/exercise_group.py @@ -8,23 +8,29 @@ from json import loads from time import sleep from bs4 import BeautifulSoup from exceptions.illegal_action import IllegalAction - +from submission import Submission 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 + Methods: + `submit`: submit to an exercise + `get_group`: get a group by name + `download_tcs`: download test cases + `download_files`: download files + + `find_status`: get status for an exercise by name + `get_all_statuses`: get all available statuses(useful for multiple exercises) + `get_status(idx=0)`: get the available statuses for the exercise. Set the idx if you want to get a specific submission. + Attributes: + + `am_exercise`: returns bool which tells you if the instance is an exercise + `folders`: folders in the folder + `exercises`: exercises in the folder + `test_cases`: test cases in the exercise(if it is an exercise) + `files`: files in the exercise/folder """ - def __init__(self, url: str, soup, session, full: bool): + def __init__(self, url: str, soup:BeautifulSoup, session, full: bool): self.url = url self.name = soup.text self.__prev_raw = soup @@ -144,7 +150,7 @@ class ExerciseGroup: ] # Get by name - def get_group( + def get_group( # <- 🗿 self, name: str, full: bool = False, link: str = None ) -> "ExerciseGroup": """ @@ -236,7 +242,6 @@ class ExerciseGroup: url = "https://themis.housing.rug.nl" + form["action"] file_types = loads(form["data-suffixes"]) - if isinstance(files, str): temp = [] temp.append(files) @@ -251,12 +256,12 @@ class ExerciseGroup: found_type = file_types[t] break if not found_type: - raise IllegalAction(message="Illegal filetype for this assignment.") + print("WARNING: File type not recognized") with open(file, "rb") as f: packaged_files.append((found_type, (file, f.read()))) - data = {"judgenow": "true" if judge else "false", "judgeLanguage": found_type} + data = {"judgenow": "true" if judge else "false", "judgeLanguage": found_type if found_type else "none"} if not silent: print(f"Submitting to {self.name}") @@ -268,3 +273,75 @@ class ExerciseGroup: return resp.url if "@submissions" in resp.url else None return self.__wait_for_result(resp.url, not silent, []) + + def __status_sections(self) -> list[BeautifulSoup]: + r = self.__session.get("https://themis.housing.rug.nl" + self.__raw.find("a", text="Status")["href"]) + + soup = BeautifulSoup(r.text, "html.parser") + sections = soup.find_all('section', class_=lambda class_: class_ and 'status' in class_.split()) + + return sections + + def __parse_section(self, section:BeautifulSoup, text) -> dict[str, Submission] | dict[str, str]: + # The section has a heading and a body. We only care about the body + body = section.find("div", class_="sec-body") # Find the body of the section + body = body.find("div", class_="subsec-container") # Find the subsec-container + body = body.find("div", class_="cfg-container") + + # Parse the cfg-container + parsed = {} + + # Submission instances go here + submissions = {} + + cfg_lines = body.find_all("div", class_="cfg-line") + for line in cfg_lines: + key = line.find("span", class_="cfg-key").text.strip().split("\n")[0].replace(":", "").lower() + value = line.find("span", class_="cfg-val").text.strip() + + # If there is a span with class tip in the key, it means that the value is a link to a submission + if tip := line.find("span", class_="tip"): + value = line.find("a")["href"] + if not text: + submissions[key.split("\n")[0].lower().replace(" ", "_")] = Submission(value, self.__session) + parsed[key] = value + + if text: + return parsed + + return (parsed, submissions) + + # I assume that the user would usually request submissions for an assignment, + # so I will add a default parameter to the method. + + def get_status(self, section:list[BeautifulSoup]=None, text:bool=False) -> dict[str, Submission] | dict[str, str]: + """Get the available submissions for the exercise. + Set text to True to get the text representation of the submission.""" + if not section: + section = self.__status_sections() + + try: + section = section[0] # When looking at a single exercise, there is only one status section + except IndexError as exc: + raise IllegalAction("Invalid status") from exc + + return self.__parse_section(section, text) + + def get_all_statuses(self, text:bool=False) -> list[dict[str, str]] | list[dict[str, Submission]]: + """ Parses every visible status section. """ + + # This is useless for singular exercises, but if you want the submissions for multiple exercises, you can use this. + statuses = [] + for section in self.__status_sections(): + if parse := self.__parse_section(section, text): + # Find name of the exercise + name = section.find("h3").text.replace("Status: ", "").replace("\n", "").replace("\t", "") + statuses.append((name,parse)) + return statuses + + def find_status(self, name:str, text:bool=False) -> dict[str, Submission] | dict[str, str] | None: + """ Find a status block for an exercise by name. """ + # Find a section which has h3 with the name + for section in self.__status_sections(): + if section.find("h3").text.replace("Status: ", "").replace("\n", "").replace("\t", "") == name: + return self.__parse_section(section, text) \ No newline at end of file diff --git a/src/submission.py b/src/submission.py new file mode 100644 index 0000000..507caed --- /dev/null +++ b/src/submission.py @@ -0,0 +1,85 @@ +""" +File to define the submission class +""" + +from bs4 import BeautifulSoup + + +class Submission: + """ + Submission class + + Methods: + test_cases: Get a dict of test cases status + info: Submission information (in details) + files: Get a list of uploaded files(as names) + """ + def __init__(self, url: str, session): + self.url = "https://themis.housing.rug.nl" + url + self.__session = session + self.__request = self.__session.get(self.url) + self.__raw = BeautifulSoup(self.__request.text, "lxml") + self.__info = None + + def __clean(self, text: str, value: bool = False) -> str: + """Clean text""" + clean = text.replace("\t", "").replace("\n", "") + if value: + return clean + return clean.replace(" ", "_").replace(":", "").lower() + + def test_cases(self) -> dict[str, str]: + """Get a dict of test cases status""" + # In the submission page, the test cases are in a div with class "sub-cases subsec round shade" + # print(self.__raw.prettify()) + cases = self.__raw.find("div", class_=lambda x: x and "sub-cases" in x.split()) + if not cases: + return {} + + # The test cases are in a table in a div with class "cfg-container" + cases = cases.find("div", class_="cfg-container") + cases = cases.find("table") + # For each test case, there is a tr with class sub-casetop, which contains 2 tds: + # * a td with class "sub-case name" which is a name + # * a td with a variable class, which is the status text + + results = {} + for entry in cases.find_all("tr", class_="sub-casetop"): + name = entry.find("td", class_="sub-casename").text + status = entry.find( + "td", class_=lambda x: x and "status-icon" in x.split() + ).text + results[name] = self.__clean(status) + + return results + + def info(self) -> dict[str, str] | None: + """Submission information (in details)""" + # in div with class subsec round shade where there is an h4 with class info + # The info is in a div with class "cfg-container" + if self.__info: + return self.__info + + for div in self.__raw.find_all("div", class_="subsec round shade"): + if h4 := div.find("h4", class_=lambda x: x and "info" in x.split()): + if "Details" in h4.text: + # The information is in divs with class "cfg-line" + # With key in span with class "cfg-key" and value in span with class "cfg-value" + info = div.find("div", class_="cfg-container") + info = info.find_all("div", class_="cfg-line") + return { + self.__clean( + key := line.find("span", class_="cfg-key").text + ): + self.__clean(line.find("span", class_="cfg-val").text, value=True) if "Files" not in key else + ([(self.__clean(x.text), x["href"]) for x in line.find("span", class_="cfg-val").find_all("a")]) + for line in info + } + return None + + def files(self) -> list[str] | None: + """Get a list of uploaded files in the format [(name, url)]""" + if not self.__info: + self.__info = self.info() + + return self.__info.get("files", None)