13 Commits

20 changed files with 886 additions and 359 deletions

6
.gitignore vendored
View File

@ -1,6 +1,9 @@
# Config - Testing
config.py
baller.py
tests/
pathfinding/
test.py
setup.py
#Doc env
.docs_env
@ -329,3 +332,4 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
project-hierarchy.txt

View File

@ -1,22 +1,25 @@
<p align="center">
<img src="docs/img/rugemmie.gif" />
</p>
<p align="center">
<a href="https://temmies.readthedocs.io/en/latest/"><img alt="Read the Docs" src="https://img.shields.io/readthedocs/temmies"></a>
<img alt="GitHub" src="https://img.shields.io/github/license/Code-For-Groningen/temmies">
</p>
A python library which interacts with themis. Uses bs4. I'll try to end development on a somewhat working state.
## Intended Features
* [x] Log in
* [ ] Classes, methods and attributes described in the map below
* [x] Submit
* [x] Bulk download of test cases and files
* [x] Submission status
## Docs
[here](http://temmies.rtfd.io/). Heavily WIP.
## Class map
![map](images/roadmap.png)
[here](http://temmies.rtfd.io/).
## Possible continuations
* [ ] Discord bot
* [ ] CLI program
* Discord bot
* CLI program
## Thanks to
* [Glitchcat](https://glitchcat.github.io/themis-api/), cool docs bro.

View File

@ -5,7 +5,7 @@ Creates the initial connection to Themis.
### Usage
```python
from temmies.Themis import Themis
from temmies.themis import Themis
themis = Themis("s-number", "password")
```
@ -14,18 +14,18 @@ themis = Themis("s-number", "password")
#### `login()`
Logs in to Themis. Runs automatically when the class is initialized.
#### `getYear(start, end)`
#### `get_year(start, end)`
Returns an instance of a [`Year`](#year)(academic year) between `start` and `end`.
```python
year = themis.getYear(2023, 2024)
year = themis.get_year(2023, 2024)
```
#### `allYears()`
#### `all_years()`
Returns a list of `Year` instances corresponding to all years visible to the user.
```python
years = themis.allYears()
years = themis.all_years()
```
<sub> I don't see why you would need this, but it's here. </sub>
@ -35,22 +35,22 @@ years = themis.allYears()
### Usage
```python
year = themis.getYear(2023, 2024)
year = themis.get_year(2023, 2024)
```
### Methods
#### `getCourse(courseName)`
Returns an instance of a [`Course`](#course) with the name `courseName`.
#### `get_course(name)`
Returns an instance of a [`Course`](#course) with the name `name`.
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
pf = year.get_course("Programming Fundamentals (for CS)")
```
#### `allCourses()`
#### `all_courses()`
Returns a list of `Course` instances corresponding to all courses visible to the user in a given `Year`.
```python
courses = year.allCourses()
courses = year.all_courses()
```
----
@ -58,42 +58,50 @@ courses = year.allCourses()
## `Course`
### Usage
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
pf = year.get_course("Programming Fundamentals (for CS)")
print(pf.info) # <- course info attribute
assignments = pf.getExerciseGroups()
assignments = pf.get_groups()
```
### Methods
#### `getExerciseGroups()`
Returns a list of `ExerciseGroup` instances corresponding to all exercise groups visible to the user in a given `Course`.
#### `get_groups(full=False)`
Returns a list of `ExerciseGroup` instances corresponding to all exercise groups visible to the user in a given `Course`. Default argument is `full=False`, which will only return the (name, link) of each exercise and folder in the group. If `full=True`, it will traverse the whole course.
You can traverse the course in both cases, although in different ways.
When you have fully traversed the course, you can access everything via indices and the `exercises` and `folders` attributes of the `ExerciseGroup` instances:
```python
assignments = pf.getExerciseGroups()
ai_group = ai_course.get_groups(full=True)
exercise = ai_group[7].exercises[1] # Week 11 -> Suitcase packing
exercise.submit("suitcase.py", silent=False)```
```
This is equivalent to the case in which we don't traverse the full course using `get_group` like so:
```python
ai_group = ai_course.get_group("Week 11")
exercise = ai_group.get_group("Suitcase packing")
exercise.submit("suitcase.py", silent=False)
```
### `get_group(name, full=False)`
Returns an instance of an `ExerciseGroup` with the name `name`. Default argument is `full=False`, which will only return the (name, link) of each exercise and folder in the group. If `full=True`, it will traverse the whole group.
```python
week1 = pf.get_group("Week 1")
```
## `ExerciseGroup`
When this class is initialized, it will automatically fetch the exercise's info, files and test cases(it might be slow, because it indexes the entire course, which I will fix at some point).
Setting the `full` flag to `True` will traverse the whole course.
You can traverse the course in both cases
* Both folders and exercises are represented as `ExerciseGroup` instances.
* Folders will have the `amExercise` attribute set to `False`.
* Folders can have the `downloadFiles` method called on them.
* Exercises can have the `submit`, `downloadFiles` and `downloadTCs` method called on them.
* Folders will have the `am_exercise` attribute set to `False`.
* Folders can have the `download_files` method called on them.
* Exercises can have the `submit`, `download_files` and `download_tcs` method called on them.
### Usage
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
assignments = pf.getExerciseGroups()
assignment = assignments[0]
print(assignment.amExercise) # <- Exercise or folder attribute
print(assignment.files) # <- Downloadable files attribute
print(assignment.testCases) # <- Test cases attribute
print(assignment.folders) # <- If the group contains folders, they will be here
print(assignment.exercises) # <- If the group contains exercises, they will be here
```
### Example of folder traversal
Let's say we have a folder structure like this:
```
@ -110,32 +118,168 @@ Let's say we have a folder structure like this:
And we want to get to `Part 2` of `Week 1`'s `Exercise 2`. We would do this:
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
assignments = pf.getExerciseGroups()
week1 = assignments[0].folders[0]
exercise2 = week1.exercises[1]
part2 = exercise2.folders[1]
pf = year.get_course("Programming Fundamentals (for CS)")
assignments = pf.get_groups()
week1 = assignments[0] # Week 1
exercise2 = week1.folders[1] # Exercise 2
part2 = exercise2.exercises[1] # Part 2
# Or, if you dont want to traverse the whole course:
week1 = pf.get_group("Week 1")
exercise2 = week1.get_group("Exercise 2")
part2 = exercise2.get_group("Part 2")
```
### Methods
#### `downloadFiles(path=".")`
#### `download_files(path=".")`
Downloads all files in the exercise group to a directory `path`. Defaults to the current directory.
```python
assignment.downloadFiles()
assignment.download_files()
```
#### `downloadTCs(path=".")`
#### `download_tcs(path=".")`
Downloads all test cases in the exercise group to a directory `path`. Defaults to the current directory.
```python
assignment.downloadTCs()
assignment.download_tcs()
```
#### `submit(files)`
Submits the files to the exercise group. (This is not implemented yet)
#### get_group(name, full=False)
This is used when you want to traverse the course dynamically(not recurse through the whole thing). Of course, you can use it even if you've traversed the whole course, but that would overcomplicate things.
```python
assignment.submit(["file1.py", "file2.py"])
# Week 1 -> Exercise 2 -> Part 2
week1 = pf.get_groups("Week 1")
exercise2 = week1.get_group("Exercise 2")
part2 = exercise2.get_group("Part 2")
# This is equivalent to(but faster than):
week1 = pf.get_groups("Week 1", full=True)
exercise2 = week1[1]
part2 = exercise2[1]
```
#### `submit(files)`
Submits the files to the exercise group. Default arguments are `judge=True`, `wait=True` and `silent=True`. `judge` will judge the submission instantly, and `wait` will wait for the submission to finish. Turning off `silent` will print the submission status dynamically.
```python
suitcase = ai.get_group("Week 11")
suitcase[7].exercises[1].submit("suitcase.py", silent=False)
# Or
ai.get_group("Week 11").get_group("Suitcase packing").submit("suitcase.py", silent=False)
>>> 1:
>>> 2:
>>> 3:
>>> 4:
>>> 5:
>>> 6:
>>> 7:
>>> 8:
>>> 9:
>>> 10:
```
#### `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': <submission.Submission object at 0x774ea7a48cd0>
>>> 'best': <submission.Submission object at 0x774ea79af910>
>>> 'latest': <submission.Submission object at 0x774eaa7d3c10>
>>> 'first_pass': <submission.Submission object at 0x774ea77ee810>
>>> 'last_pass': <submission.Submission object at 0x774ea755de10>
>>> }
>>>)
```
#### `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"] # Week 1 -> Exercise 1 -> Part 1 -> Leading submission
```
### 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()
>>> {'1': 'passed', '2': 'passed', '3': 'passed', '4': 'passed', '5': 'passed', '6': 'passed', '7': 'passed', '8': 'passed', '9': 'passed', '10': 'passed'}
```
#### `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()
>>> {
>>>'assignment': 'Part 1',
>>>'group': 'Y.N. Here',
>>>'uploaded_by': 'Y.N. Here s1234567',
>>>'created_on': 'Wed Sep 13 2023 12:51:37 GMT+02002023-09-13T10:51:37.338Z',
>>>'submitted_on': 'Wed Sep 13 2023 12:51:37 GMT+02002023-09-13T10:51:37.344Z',
>>>'status': 'passed: Passed all test cases',
>>>'files': [('recurrence.c',
>>>'/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/source/recurrence.c'),
>>>('compile.log',
>>>'/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/output/compile.log')],
>>>'language': 'c'
>>> }
```
#### `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()
>>> [('recurrence.c', '/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/source/recurrence.c'), ('compile.log', '/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/output/compile.log')]
```

View File

@ -18,22 +18,22 @@ pip install temmies
## Example Usage
```python
import temmies
from temmies.themis import Themis
# Log in
themis = temmies.Themis("s-number", "password")
themis = Themis("s-number", "password")
# Get a year
year = themis.getYear(2023, 2024)
year = themis.get_year(2023, 2024)
# Get a course
pf = year.getCourse("Programming Fundamentals (for CS)")
course = year.get_course("Programming Fundamentals (for CS)")
# Get an assignment
assignment = pf.getExerciseGroups()
assignment = course.get_assignment("Assignment 1")
# Download the files
assignment.downloadFiles()
# Submit 2 files
assignment.submit(["among.c", "us.py"])
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

View File

@ -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.assignments = []
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. ")
@property
def info(self):
return {
"name": self.name,
"year": self.parent.year,
"url": self.url,
"assignments": [x.name for x in self.assignments]
}
def getExerciseGroups(self):
r = self.session.get(self.url)
soup = BeautifulSoup(r.text, 'lxml')
section = soup.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,
)
for x in entries]

View File

@ -1,126 +0,0 @@
from bs4 import BeautifulSoup
from exceptions.IllegalAction import IllegalAction
import re
class ExerciseGroup():
def __init__(self, url:str, soup, session, parent):
self.url = url
self.name = soup.text
self.__raw = soup
self.session = session
self.parent = parent # This is unnecessary, but I'll keep it for now
self.request = self.session.get(self.url)
self.soup = BeautifulSoup(self.request.text, 'lxml')
def __str__(self):
return f"ExerciseGroup {self.name} in folder {self.parent.name}"
@property
def amExercise(self):
return "ass-submitable" in self.__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.soup.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.soup.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
# idea exercises and folders are identical, maybe merge them?
@property
def exercises(self) -> list:
if self.amExercise:
return self
section = self.soup.find('div', class_="ass-children")
try:
submittables = section.find_all('a', class_="ass-submitable")
except AttributeError:
return None
return [
ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}",
x,
self.session,
self)
for x in submittables]
@property
def folders(self) -> list:
section = self.soup.find('div', class_="ass-children")
try:
folders = section.find_all('a', class_="ass-group")
except AttributeError:
return None
return [
ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}",
x,
session,
self)
for x in folders]

View File

@ -1,61 +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
# 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

View File

@ -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.session = session
self.url = self.__constructUrl()
# 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)

View File

@ -1,4 +0,0 @@
class CourseUnavailable(Exception):
def __init__(self, message:str=""):
self.message = "Course Error: " + message
super().__init__(self.message)

View File

@ -1,4 +0,0 @@
class IllegalAction(Exception):
def __init__(self, message:str=""):
self.message = "Illegal action: " + message
super().__init__(self.message)

1
temmies/__init__.py Normal file
View File

@ -0,0 +1 @@
from .themis import Themis

72
temmies/course.py Normal file
View File

@ -0,0 +1,72 @@
"""
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
)

View File

View 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}")

View 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}")

347
temmies/exercise_group.py Normal file
View File

@ -0,0 +1,347 @@
"""
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
from .submission import Submission
class ExerciseGroup:
"""
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:BeautifulSoup, 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:
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 if found_type else "none"}
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, [])
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)

84
temmies/submission.py Normal file
View File

@ -0,0 +1,84 @@
"""
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)

90
temmies/themis.py Normal file
View 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
temmies/year.py Normal file
View 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)