mirror of
https://github.com/Code-For-Groningen/temmies.git
synced 2025-03-15 15:10:15 +01:00
Conformed to naming conventions. Pepped the SHIT out of the code. Will reflect in documentation now.
This commit is contained in:
parent
11864cae6b
commit
78aade7c8c
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,8 @@
|
|||||||
# Config - Testing
|
# Config - Testing
|
||||||
config.py
|
config.py
|
||||||
tests/
|
tests/
|
||||||
|
pathfinding/
|
||||||
|
test.py
|
||||||
|
|
||||||
#Doc env
|
#Doc env
|
||||||
.docs_env
|
.docs_env
|
||||||
|
@ -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)
|
|
@ -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, [])
|
|
@ -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
|
|
55
src/Year.py
55
src/Year.py
@ -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)
|
|
71
src/course.py
Normal file
71
src/course.py
Normal file
@ -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
|
||||||
|
)
|
@ -1,4 +0,0 @@
|
|||||||
class CourseUnavailable(Exception):
|
|
||||||
def __init__(self, message:str=""):
|
|
||||||
self.message = "Course Error: " + message
|
|
||||||
super().__init__(self.message)
|
|
@ -1,4 +0,0 @@
|
|||||||
class IllegalAction(Exception):
|
|
||||||
def __init__(self, message:str=""):
|
|
||||||
self.message = "Illegal action: " + message
|
|
||||||
super().__init__(self.message)
|
|
6
src/exceptions/course_unavailable.py
Normal file
6
src/exceptions/course_unavailable.py
Normal file
@ -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}")
|
8
src/exceptions/illegal_action.py
Normal file
8
src/exceptions/illegal_action.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Illegal Action Exception
|
||||||
|
"""
|
||||||
|
|
||||||
|
class IllegalAction(Exception):
|
||||||
|
"""Illegal Action Exception"""
|
||||||
|
def __init__(self, message: str = ""):
|
||||||
|
super().__init__(f"Illegal action: {message}")
|
270
src/exercise_group.py
Normal file
270
src/exercise_group.py
Normal file
@ -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, [])
|
90
src/themis.py
Normal file
90
src/themis.py
Normal file
@ -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
|
66
src/year.py
Normal file
66
src/year.py
Normal file
@ -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)
|
Loading…
x
Reference in New Issue
Block a user