25 Commits

Author SHA1 Message Date
1a950c0eb2 Update README 2024-11-17 22:19:50 +01:00
a3a9f5dfee Updated index 2024-11-17 22:06:06 +01:00
461b506be5 Added requirements 2024-11-17 21:57:33 +01:00
a0aeca87b1 Updated changelog and doc with new password storage method 2024-11-17 21:57:07 +01:00
17f0bd237a Solved #11 by using system's keyring 2024-11-17 21:53:02 +01:00
2378be4d42 Fixed slight bug with unused method 2024-11-17 21:52:11 +01:00
6a63abf7ae Expose only Themis on package-level 2024-11-17 19:50:19 +01:00
019e2c181e Updated docs ot include changelog and reflect upon recent changes 2024-11-17 19:50:05 +01:00
135420d488 Naming fix
All methods are prefixed with get_
2024-11-17 19:36:28 +01:00
aab574cdb0 Refactor to use base class group for ExerciseGroup and Course to avoid repeated logic 2024-11-17 19:35:16 +01:00
9d92db4644 Updated link to docs 2024-09-22 16:15:13 +02:00
2c2d39c6dd Fixed stupid docs mistake 2024-06-03 22:55:36 +02:00
54e086c93a Slight update in docs. Released version 1.0.21. 2024-04-21 20:37:07 +02:00
667a388da4 Updated files. getting ready for publishing. Some issues 2024-04-21 02:08:58 +02:00
2daee84d4f Fixed mistakes in docs. Elaborated. 2024-04-21 00:29:49 +02:00
cd5e9b2c8d Added submissions. Updated docs. 2024-04-20 21:35:09 +02:00
de6262a4bb removed class map, as it doesn't correspond to current development. 2024-04-10 19:14:35 +02:00
8325a30af8 Added link to badges. 2024-04-10 18:54:38 +02:00
0f92d05bec Added badges :) 2024-04-10 18:53:14 +02:00
2785ec86e9 Updated docs to reflect upon code changes 2024-04-10 18:48:21 +02:00
78aade7c8c Conformed to naming conventions. Pepped the SHIT out of the code. Will reflect in documentation now. 2024-04-10 18:43:03 +02:00
11864cae6b Updated docs and included getGroup(by name). Privated some attributes that aren't necessary to be public. 2024-04-09 16:18:54 +02:00
27d21ac7c1 Updated readme. 100% coverage on docs. 2024-04-08 22:31:54 +02:00
df8429d811 Fix bug with multiple file upload. Fixed bug with printing the same TC number. Updated docs to reflect on submit implementation. 2024-04-08 22:28:01 +02:00
3c63a64eac Implemented submissions. Works, although in a pretty nasty manner. 2024-04-08 21:40:52 +02:00
24 changed files with 858 additions and 369 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,23 +1,26 @@
<p align="center">
<img src="docs/img/rugemmie.gif" />
<img src="https://github.com/Code-For-Groningen/temmies/blob/v1.1.0/docs/img/rugemmie.gif" />
</p>
<p align="center">
<a href="https://temmies.confest.im"><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.
A python library which interacts with [Themis](https://themis.housing.rug.nl/). 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.confest.im/).
## Possible continuations
* [ ] Discord bot
* [ ] CLI program
* Discord bot
* [CLI program](https://github.com/Code-For-Groningen/temmies-cli)
## Thanks to
* [Glitchcat](https://glitchcat.github.io/themis-api/), cool docs bro.
* [Mo](https://github.com/Stylo2k), damn bruh you actually work for Themis
* [Mo](https://github.com/Stylo2k), damn bruh you actually work for Themis

View File

@ -1,56 +1,58 @@
# Classes
---
## `Themis`
Creates the initial connection to Themis.
### Usage
```python
from temmies.Themis import Themis
from temmies.themis import Themis
themis = Themis("s-number", "password")
themis = Themis("s-number")
```
On the first run, you will be prompted for your password. Then, on the next run(s), you will be able to log in automatically, as the password is stored in the system keyring. If you want to delete it [click here](https://www.google.com/search?hl=en&q=delete%20a%20password%20from%20keyring).
### Methods
#### `login()`
Logs in to Themis. Runs automatically when the class is initialized.
#### `getYear(start, end)`
Returns an instance of a [`Year`](#year)(academic year) between `start` and `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>
----
## `Year`
### 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,41 +60,48 @@ courses = year.allCourses()
## `Course`
### Usage
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
print(pf.info) # <- course info attribute
assignments = pf.getExerciseGroups()
pf = year.get_course("Programming Fundamentals (for CS)")
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`. The default argument is `full=False`, which will only return the top-level (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 whole 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`. The 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 group.
* 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.
### 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
```
- Both folders and exercises are represented as `ExerciseGroup` instances.
- 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` methods called on them.
### Example of folder traversal
Let's say we have a folder structure like this:
@ -110,32 +119,155 @@ 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 don't 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). You can use it even if you've traversed the whole course.
```python
assignment.submit(["file1.py", "file2.py"])
# Week 1 -> Exercise 2 -> Part 2
week1 = pf.get_group("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(full=True)[0]
exercise2 = week1.folders[1]
part2 = exercise2.exercises[1]
```
#### `submit(files, judge=True, wait=True, silent=True)`
Submits the files to the exercise. The default arguments are `judge=True`, `wait=True`, and `silent=True`. Setting `judge=False` will not judge the submission immediately. Setting `wait=False` will not wait for the submission to finish. Turning off `silent` will print the submission status dynamically.
```python
suitcase = ai_course.get_group("Week 11").get_group("Suitcase packing")
suitcase.submit(["suitcase.py"], silent=False)
# Output:
# Submitting to Suitcase packing
# • suitcase.py
# 1: ✅
# 2: ✅
# 3: ✅
# ...
```
#### `get_status(text=False)`
Retrieves the status of the exercise group. When `text` is set to `True`, it will return the status as a dictionary of strings. Otherwise, it will return a dictionary where keys map to either strings or `Submission` objects. Common keys include `'leading'`, `'best'`, `'latest'`, etc.
```python
pf = year.get_course("Programming Fundamentals (for CS)")
exercise = pf.get_group("Lab Session 2").get_group("Recurrence")
# Get status
status = exercise.get_status()
print(status)
# Output:
{
'assignment': 'Recurrence',
'group': 'Y.N. Here',
'status': 'passed: Passed all test cases',
'grade': '2.00',
'total': '2',
'output limit': '1',
'passed': '1',
'leading': <temmies.submission.Submission object at 0x...>,
'best': <temmies.submission.Submission object at 0x...>,
'latest': <temmies.submission.Submission object at 0x...>,
'first_pass': <temmies.submission.Submission object at 0x...>,
'last_pass': <temmies.submission.Submission object at 0x...>,
'visible': 'Yes'
}
```
To access submission details:
```python
leading_submission = status["leading"]
print(leading_submission.get_files())
```
----
## `Submission`
### Usage
```python
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()["leading"]
```
### Methods
#### `get_test_cases()`
Returns a dictionary of test cases and their statuses.
```python
test_cases = submission.get_test_cases()
print(test_cases)
# Output:
{'1': 'passed', '2': 'passed', '3': 'passed', '4': 'passed', '5': 'passed', '6': 'passed', '7': 'passed', '8': 'passed', '9': 'passed', '10': 'passed'}
```
#### `get_info()`
Returns a dictionary of information about the submission.
```python
info = submission.get_info()
print(info)
# Output:
{
'assignment': 'Part 1',
'group': 'Y.N. Here',
'uploaded_by': 'Y.N. Here s1234567',
'created_on': 'Wed Sep 13 2023 12:51:37 GMT+0200',
'submitted_on': 'Wed Sep 13 2023 12:51:37 GMT+0200',
'status': 'passed: Passed all test cases',
'files': [
('recurrence.c', '/file/.../recurrence.c'),
('compile.log', '/file/.../compile.log')
],
'language': 'c'
}
```
#### `get_files()`
Returns a list of uploaded files in the format `(name, URL)`.
```python
files = submission.get_files()
print(files)
# Output:
[
('recurrence.c', '/file/.../recurrence.c'),
('compile.log', '/file/.../compile.log')
]
```
----

14
docs/changelog.md Normal file
View File

@ -0,0 +1,14 @@
## **Changelog**
### **Version 1.1.0**
#### **Documentation**
- Fixed method signatures to align with actual functionality.
- Updated `get_status` to properly handle `Submission` instances.
- Ensured all class and method examples are consistent with the codebase.
#### **Codebase**
- Prepended `get_` to all methods in `Submission`
- Created base `Group` from which `Course` and `ExerciseGroup` inherit.
- Using system keyring to store passwords (Issue #11)

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") # You will be prompted for your 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_group("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

@ -3,4 +3,5 @@ nav:
- Temmies: index.md
- API Reference: api.md
- About: about.md
- Change Log: changelog.md
theme: readthedocs

19
requirements.txt Normal file
View File

@ -0,0 +1,19 @@
beautifulsoup4==4.12.3
bs4==0.0.2
certifi==2024.8.30
cffi==1.17.1
charset-normalizer==3.4.0
cryptography==43.0.3
idna==3.10
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.1.0
jeepney==0.8.0
keyring==25.5.0
lxml==5.3.0
more-itertools==10.5.0
pycparser==2.22
requests==2.32.3
SecretStorage==3.3.3
soupsieve==2.6
urllib3==2.2.3

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)

3
temmies/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .themis import Themis
__all__ = ["Themis"]

28
temmies/course.py Normal file
View File

@ -0,0 +1,28 @@
from .group import Group
from .exercise_group import ExerciseGroup
from requests import Session
from .exceptions.course_unavailable import CourseUnavailable
class Course(Group):
"""
Represents a course in a given academic year.
"""
def __init__(self, url: str, name: str, session, parent):
super().__init__(url, name, session, parent=parent, full=False)
self.__course_available(self._request)
def __str__(self):
return f"Course {self.name} in year {self._parent.year}"
def __course_available(self, response):
if "Something went wrong" in response.text:
raise CourseUnavailable(
message="'Something went wrong'. Course most likely not found."
)
def create_group(self, url: str, name: str, session: Session, parent, full: bool, classes=None):
"""
Create an instance of ExerciseGroup for subgroups within a Course.
"""
return ExerciseGroup(url, name, session, parent, full, classes)

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

182
temmies/exercise_group.py Normal file
View File

@ -0,0 +1,182 @@
from .group import Group
from .exceptions.illegal_action import IllegalAction
from .submission import Submission
from json import loads
from time import sleep
from typing import Optional
from bs4 import BeautifulSoup
class ExerciseGroup(Group):
"""
Represents a group of exercises or a single exercise.
"""
def __init__(self, url: str, name: str, session, parent=None, full: bool = False, classes=None):
super().__init__(url, name, session, parent=parent, full=full, classes=classes)
self.am_exercise = "ass-submitable" in self.classes
def create_group(self, url: str, name: str, session, parent, full: bool, classes=None):
"""
Create an instance of ExerciseGroup for subgroups.
"""
return ExerciseGroup(url, name, session, parent, full, classes)
@property
def test_cases(self) -> list[str]:
"""
Get all test cases for this exercise.
"""
if not self.am_exercise:
return []
sections = self._raw.find_all("div", class_="subsec round shade")
tcs = []
for div in sections:
res = div.find("h4", class_="info")
if res and "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 all test cases for this exercise.
"""
if not self.am_exercise:
raise IllegalAction("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}")
with open(f"{path}/{tc.text}", "wb") as f:
f.write(self._session.get(url).content)
return self.test_cases
@property
def files(self) -> list[str]:
"""
Get all downloadable files for this exercise or group.
"""
details = self._raw.find("div", id=lambda x: x and x.startswith("details"))
if not details:
return []
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():
links = line.find_all("span", class_="cfg-val")
for link in links:
a_tags = link.find_all("a")
for a in a_tags:
link_list.append(a)
return link_list
def download_files(self, path=".") -> list[str]:
"""
Download all files available for this exercise or group.
"""
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
def submit(self, files: list[str], judge: bool = True, wait: bool = True, silent: bool = True) -> Optional[dict]:
"""
Submit files to this exercise.
Returns a dictionary of test case results or None if wait is False.
"""
if not self.am_exercise:
raise IllegalAction("You cannot submit to this assignment.")
form = self._raw.find("form")
if not form:
raise IllegalAction("Submission form not found.")
url = "https://themis.housing.rug.nl" + form["action"]
file_types = loads(form["data-suffixes"])
if isinstance(files, str):
files = [files]
packaged_files = []
data = {}
found_type = ""
for file in files:
for suffix, lang in file_types.items():
if file.endswith(suffix):
found_type = lang
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 __wait_for_result(self, url: str, verbose: bool, __printed: list) -> dict:
"""
Wait for the submission result and return the test case results.
"""
r = self._session.get(url)
soup = BeautifulSoup(r.text, "lxml")
return self.__parse_table(soup, url, verbose, __printed)
def __parse_table(self, soup: BeautifulSoup, url: str, verbose: bool, __printed: list) -> dict:
"""
Parse the results table from the submission result page.
"""
cases = soup.find_all("tr", class_="sub-casetop")
fail_pass = {}
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"):
sleep(1)
return self.__wait_for_result(url, verbose, __printed)
statuses = {
"Passed": ("", True),
"Wrong output": ("", False),
"No status": ("🐛", None),
"error": ("🐛", None),
}
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))
return fail_pass

143
temmies/group.py Normal file
View File

@ -0,0 +1,143 @@
# temmies/group.py
from bs4 import BeautifulSoup
from requests import Session
from typing import Optional, Union, Dict
from .exceptions.illegal_action import IllegalAction
from .submission import Submission
class Group:
"""
Base class for Course and ExerciseGroup.
"""
def __init__(self, url: str, name: str, session: Session, parent=None, full: bool = False, classes=None):
self.url = url
self.name = name
self._session = session
self._parent = parent
self._full = full
self._request = self._session.get(self.url)
self._raw = BeautifulSoup(self._request.text, "lxml")
self.classes = classes or []
def __str__(self):
return f"Group {self.name}"
def get_groups(self, full: bool = False):
"""
Get all groups (exercises and folders) within this group.
"""
section = self._raw.find("div", class_="ass-children")
if not section:
return []
entries = section.find_all("a", href=True)
groups = []
for x in entries:
href = x['href']
name = x.text.strip()
classes = x.get('class', [])
group = self.create_group(
url=f"https://themis.housing.rug.nl{href}",
name=name,
session=self._session,
parent=self,
full=full,
classes=classes
)
groups.append(group)
return groups
def get_group(self, name: str, full: bool = False):
"""
Get a single group by name.
"""
group_link = self._raw.find("a", text=name)
if not group_link:
raise IllegalAction(f"No such group found: {name}")
href = group_link['href']
classes = group_link.get('class', [])
return self.create_group(
url=f"https://themis.housing.rug.nl{href}",
name=name,
session=self._session,
parent=self,
full=full,
classes=classes
)
def create_group(self, url: str, name: str, session: Session, parent, full: bool, classes=None):
"""
Factory method to create a group. Subclasses must implement this.
"""
raise NotImplementedError("Subclasses must implement create_group")
def get_status(self, text: bool = False) -> Union[Dict[str, Union[str, Submission]], None]:
"""
Get the status of the current group, if available.
Args:
text (bool): If True, returns text representation of the status.
Otherwise, creates `Submission` objects for applicable fields.
Returns:
dict[str, Union[str, Submission]] | None: The status data for the group,
with `Submission` objects for links.
"""
status_link = self._raw.find("a", text="Status")
if not status_link:
raise IllegalAction("Status information is not available for this group.")
status_url = f"https://themis.housing.rug.nl{status_link['href']}"
r = self._session.get(status_url)
soup = BeautifulSoup(r.text, "lxml")
section = soup.find("div", class_="cfg-container")
if not section:
return None
return self.__parse_status_section(section, text)
def __parse_status_section(self, section: BeautifulSoup, text: bool) -> Dict[str, Union[str, Submission]]:
"""
Parse the status section of the group and clean up keys.
Args:
section (BeautifulSoup): The HTML section containing the status information.
text (bool): Whether to return text representation.
Returns:
dict[str, Union[str, Submission]]: Parsed and cleaned status information,
with `Submission` objects for links.
"""
key_mapping = {
"leading the submission that counts towards the grade": "leading",
"best the latest submission with the best result": "best",
"latest the most recent submission": "latest",
"first pass the first submission that passed": "first_pass",
"last pass the last submission to pass before the deadline": "last_pass",
}
parsed = {}
cfg_lines = section.find_all("div", class_="cfg-line")
for line in cfg_lines:
key_element = line.find("span", class_="cfg-key")
value_element = line.find("span", class_="cfg-val")
if not key_element or not value_element:
continue
# Normalize key
raw_key = " ".join(key_element.get_text(separator=" ").strip().replace(":", "").lower().split())
key = key_mapping.get(raw_key, raw_key) # Use mapped key if available
# Process value
link = value_element.find("a", href=True)
if link and not text:
submission_url = link["href"]
parsed[key] = Submission(submission_url, self._session)
else:
parsed[key] = value_element.get_text(separator=" ").strip()
return parsed

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 get_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 get_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 get_files(self) -> list[str] | None:
"""Get a list of uploaded files in the format [(name, url)]"""
if not self.__info:
self.__info = self.get_info()
return self.__info.get("files", None)

108
temmies/themis.py Normal file
View File

@ -0,0 +1,108 @@
"""
Main class for the Themis API
"""
import urllib3
import keyring
import getpass
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):
self.user = user
self.password = self.__get_password()
self.session = self.login(user, self.password)
def __get_password(self) -> str:
"""
Retrieve the password from the keyring, prompting the user if not found.
"""
password = keyring.get_password(f'{self.user}-temmies', self.user)
if not password:
print(f"Password for user '{self.user}' not found in keyring.")
password = getpass.getpass(prompt=f"Enter password for {self.user}: ")
keyring.set_password(f'{self.user}-temmies', self.user, password)
print("Password saved securely in keyring.")
return password
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 "Invalid credentials" in r.text:
# Prompt for password again
print("Invalid credentials. Please try again.")
passwd = getpass.getpass(prompt="Enter password: ")
keyring.set_password(f'{self.user}-temmies', self.user, passwd)
return self.login(user, passwd)
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

52
temmies/year.py Normal file
View File

@ -0,0 +1,52 @@
from bs4 import BeautifulSoup
from .course import Course
from .exceptions.course_unavailable import CourseUnavailable
class Year:
"""
Represents an academic year.
"""
def __init__(self, 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
def all_courses(self, errors: bool = True) -> list[Course]:
"""
Gets all visible courses in a year.
"""
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}", "")
course_url = self.url + suffix
course_name = li.a.text.strip()
courses.append(
Course(course_url, course_name, 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:
"""
Gets a course by name.
"""
r = self._session.get(self.url)
soup = BeautifulSoup(r.text, "lxml")
course_link = soup.find("a", text=name)
if not course_link:
raise CourseUnavailable(f"No such course found: {name}")
suffix = course_link["href"].replace(f"course/{self.start}-{self.year}", "")
course_url = self.url + suffix
return Course(course_url, name, self._session, self)