4 Commits

12 changed files with 289 additions and 29 deletions

2
.gitignore vendored
View File

@ -3,6 +3,7 @@ config.py
tests/ tests/
pathfinding/ pathfinding/
test.py test.py
setup.py
#Doc env #Doc env
.docs_env .docs_env
@ -331,3 +332,4 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # 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. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
project-hierarchy.txt

View File

@ -12,14 +12,14 @@ A python library which interacts with themis. Uses bs4. I'll try to end developm
* [x] Log in * [x] Log in
* [x] Submit * [x] Submit
* [x] Bulk download of test cases and files * [x] Bulk download of test cases and files
* [ ] Submission status * [x] Submission status
## Docs ## Docs
[here](http://temmies.rtfd.io/). [here](http://temmies.rtfd.io/).
## Possible continuations ## Possible continuations
* [ ] Discord bot * Discord bot
* [ ] CLI program * CLI program
## Thanks to ## Thanks to
* [Glitchcat](https://glitchcat.github.io/themis-api/), cool docs bro. * [Glitchcat](https://glitchcat.github.io/themis-api/), cool docs bro.

View File

@ -5,7 +5,7 @@ Creates the initial connection to Themis.
### Usage ### Usage
```python ```python
from temmies.Themis import Themis from temmies.themis import Themis
themis = Themis("s-number", "password") themis = Themis("s-number", "password")
``` ```
@ -58,7 +58,6 @@ courses = year.all_courses()
## `Course` ## `Course`
### Usage ### Usage
```python ```python
pf = year.get_course("Programming Fundamentals (for CS)") pf = year.get_course("Programming Fundamentals (for CS)")
print(pf.info) # <- course info attribute print(pf.info) # <- course info attribute
assignments = pf.get_groups() assignments = pf.get_groups()
@ -186,5 +185,101 @@ 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': <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')]
```

1
temmies/__init__.py Normal file
View File

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

View File

@ -4,9 +4,10 @@ Houses the Course class which is used to represent a course in a year.
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from requests import Session from requests import Session
from exercise_group import ExerciseGroup
from exceptions.course_unavailable import CourseUnavailable from .exercise_group import ExerciseGroup
from exceptions.illegal_action import IllegalAction from .exceptions.course_unavailable import CourseUnavailable
from .exceptions.illegal_action import IllegalAction
class Course: class Course:

View File

View File

@ -7,24 +7,30 @@ Represents a group of exercises or a single exercise.
from json import loads from json import loads
from time import sleep from time import sleep
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from exceptions.illegal_action import IllegalAction from .exceptions.illegal_action import IllegalAction
from .submission import Submission
class ExerciseGroup: class ExerciseGroup:
""" """
am_exercise: returns bool which tells you if the instance is an exercise Methods:
submit: submit to an exercise `submit`: submit to an exercise
get_group: get a group by name `get_group`: get a group by name
get_groups: get all groups `download_tcs`: download test cases
folders: folders in the folder `download_files`: download files
exercises: exercises in the folder
test_cases: test cases in the exercise(if it is an exercise) `find_status`: get status for an exercise by name
download_tcs: download test cases `get_all_statuses`: get all available statuses(useful for multiple exercises)
files: files in the exercise/folder `get_status(idx=0)`: get the available statuses for the exercise. Set the idx if you want to get a specific submission.
download_files: download files 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.url = url
self.name = soup.text self.name = soup.text
self.__prev_raw = soup self.__prev_raw = soup
@ -144,7 +150,7 @@ class ExerciseGroup:
] ]
# Get by name # Get by name
def get_group( def get_group( # <- 🗿
self, name: str, full: bool = False, link: str = None self, name: str, full: bool = False, link: str = None
) -> "ExerciseGroup": ) -> "ExerciseGroup":
""" """
@ -236,7 +242,6 @@ class ExerciseGroup:
url = "https://themis.housing.rug.nl" + form["action"] url = "https://themis.housing.rug.nl" + form["action"]
file_types = loads(form["data-suffixes"]) file_types = loads(form["data-suffixes"])
if isinstance(files, str): if isinstance(files, str):
temp = [] temp = []
temp.append(files) temp.append(files)
@ -251,12 +256,12 @@ class ExerciseGroup:
found_type = file_types[t] found_type = file_types[t]
break break
if not found_type: if not found_type:
raise IllegalAction(message="Illegal filetype for this assignment.") print("WARNING: File type not recognized")
with open(file, "rb") as f: with open(file, "rb") as f:
packaged_files.append((found_type, (file, f.read()))) 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: if not silent:
print(f"Submitting to {self.name}") print(f"Submitting to {self.name}")
@ -268,3 +273,75 @@ class ExerciseGroup:
return resp.url if "@submissions" in resp.url else None return resp.url if "@submissions" in resp.url else None
return self.__wait_for_result(resp.url, not silent, []) 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)

View File

@ -6,8 +6,8 @@ Main class for the Themis API
import urllib3 import urllib3
from requests import Session from requests import Session
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from year import Year from .year import Year
from exceptions.illegal_action import IllegalAction from .exceptions.illegal_action import IllegalAction
# Disable warnings # Disable warnings

View File

@ -5,8 +5,8 @@ Class which represents an academic year.
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from requests import Session from requests import Session
from course import Course from .course import Course
from exceptions.course_unavailable import CourseUnavailable from .exceptions.course_unavailable import CourseUnavailable
# Works # Works