Compare commits

..

No commits in common. "main" and "v1.1.1" have entirely different histories.
main ... v1.1.1

18 changed files with 581 additions and 803 deletions

View File

@ -1,25 +0,0 @@
name: Pylint
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint
pip install .
- name: Analyzing the code with pylint
run: |
pylint temmies

View File

@ -1,58 +0,0 @@
name: Upload Python Package
on:
release:
types: [published]
permissions:
contents: read
jobs:
release-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install setuptools wheel twine
- name: Build release distributions
run: |
python setup.py bdist_wheel
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: release-dists
path: dist/
pypi-publish:
runs-on: ubuntu-latest
needs:
- release-build
permissions:
id-token: write
environment:
name: pypi
steps:
- name: Retrieve release distributions
uses: actions/download-artifact@v4
with:
name: release-dists
path: dist/
- name: Publish release distributions to PyPI
env:
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
python -m pip install twine
twine upload dist/*

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ config.py
tests/
pathfinding/
test.py
setup.py
#Doc env
.docs_env

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="docs/img/temmie.png" width= 200px/>
<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>
@ -15,7 +15,7 @@ A python library which interacts with [Themis](https://themis.housing.rug.nl/).
* [x] Submission status
## Docs
- [Click here](http://temmies.confest.im/).
- [here](http://temmies.confest.im/).
## Possible continuations
* Discord bot

View File

@ -17,8 +17,8 @@ On the first run, you will be prompted for your password. Then, on the next run(
#### `login()`
Logs in to Themis. Runs automatically when the class is initialized.
#### `get_year(year_path)`
Returns an instance of a [`Year`](#year) for the academic year specified by `year_path`.
#### `get_year(start, end)`
Returns an instance of a [`Year`](#year) (academic year) between `start` and `end`.
```python
year = themis.get_year(2023, 2024)
@ -30,7 +30,7 @@ Returns a list of `Year` instances corresponding to all years visible to the use
```python
years = themis.all_years()
```
<sub> I don't see why you would need this, but it's here. </sub>
----
## `Year`
@ -41,20 +41,13 @@ year = themis.get_year(2023, 2024)
```
### Methods
#### `get_course(course_title)`
Returns an instance of a [`Course`](#course) with the title `course_title`.
#### `get_course(name)`
Returns an instance of a [`Course`](#course) with the name `name`.
```python
pf = year.get_course("Programming Fundamentals (for CS)")
```
#### `get_course_by_tag(course_tag)`
Returns an instance of a [`Course`](#course) using the course identifier `course_tag`.
```python
ai_course = year.get_course_by_tag("adinc-ai")
```
#### `all_courses()`
Returns a list of `Course` instances corresponding to all courses visible to the user in a given `Year`.
@ -73,96 +66,160 @@ assignments = pf.get_groups()
### Methods
#### `get_groups(full=False)`
Returns a list of `ExerciseGroup` or `Group` instances corresponding to all items 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 item. If `full=True`, it will traverse the whole course.
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
ai_groups = ai_course.get_groups(full=True)
exercise = ai_groups[7].exercises[1]
exercise.submit(["solution.py"], silent=False)
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` or `Group` with the name `name`. The default argument is `full=False`, which will only return the (name, link) of the group. If `full=True`, it will traverse the whole group.
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")
```
#### `create_group(item_data)`
Creates and returns a `Group` or `ExerciseGroup` instance based on `item_data`.
```python
group = course.create_group(item_data)
```
----
## `Group`
Represents an item in Themis, which can be either a folder (non-submittable) or an assignment (submittable).
### Methods
#### `get_items()`
Returns all items (groups and assignments) under this group.
```python
items = week1.get_items()
```
#### `get_item_by_title(title)`
Returns a single item by its title (case-insensitive).
```python
item = week1.get_item_by_title("Exercise 2")
```
#### `get_status(text=False)`
Retrieves the status of the group. When `text=True`, returns the status as strings. Otherwise, returns submission objects or strings.
```python
status = group.get_status()
leading_submission = status["leading"]
```
#### `download_files(path=".")`
Downloads all files available for this group to a directory `path`. Defaults to the current directory.
```python
group.download_files()
```
#### `download_tcs(path=".")`
Downloads all test cases for this group to a directory `path`. Defaults to the current directory.
```python
group.download_tcs()
```
#### `submit(files, judge=True, wait=True, silent=True)`
Submits the files to the group. Default arguments are `judge=True`, `wait=True`, and `silent=True`.
```python
group.submit(["solution.py"], silent=False)
```
----
## `ExerciseGroup`
Represents a submittable exercise. Inherits from `Group`.
Setting the `full` flag to `True` will traverse the whole group.
### Additional Methods
#### `submit(files)`
Submits files to the exercise. Raises an error if the item is not submittable.
- 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:
```
- Course Name
- Week 1
- Exercise 1
- Exercise 2
- Part 1
- Part 2
- Week 2
- Exercise 1
- Exercise 2
```
And we want to get to `Part 2` of `Week 1`'s `Exercise 2`. We would do this:
```python
exercise.submit(["solution.py"])
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
#### `download_files(path=".")`
Downloads all files in the exercise group to a directory `path`. Defaults to the current directory.
```python
assignment.download_files()
```
#### `download_tcs(path=".")`
Downloads all test cases in the exercise group to a directory `path`. Defaults to the current directory.
```python
assignment.download_tcs()
```
#### `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
# 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`
Represents a submission for a specific exercise.
### Usage
```python
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()["leading"]
```
### Methods
#### `get_test_cases()`
@ -170,13 +227,33 @@ 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 detailed information about the submission.
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()`
@ -184,4 +261,13 @@ 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')
]
```
----

View File

@ -11,11 +11,4 @@
#### **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)
### **Version 1.2.0**
#### **Codebase**
- Moved all methods related to downloading files (including test cases) to `Group`.
- Created `get_test_cases` and `get_files` methods in `Group`.
- We are now using the [API](https://themis.housing.rug.nl/api/navigation/2023-2024) (which mysteriously appeared) to get the year/course structure.
- Using system keyring to store passwords (Issue #11)

BIN
docs/img/rugemmie.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@ -1,5 +1,5 @@
# Temmies!
<center>![Temmie](img/temmie.png)</center>
<center>![Temmie](img/rugemmie.gif)</center>
## What is this?
@ -24,7 +24,7 @@ from temmies.themis import Themis
themis = Themis("s-number") # You will be prompted for your password
# Get a year
year = themis.get_year("2023-2024")
year = themis.get_year(2023, 2024)
# Get a course
course = year.get_course("Programming Fundamentals (for CS)")

View File

@ -1,4 +1,3 @@
attrs==25.1.0
beautifulsoup4==4.12.3
bs4==0.0.2
certifi==2024.8.30
@ -16,6 +15,5 @@ more-itertools==10.5.0
pycparser==2.22
requests==2.32.3
SecretStorage==3.3.3
selenium==4.28.1
soupsieve==2.6
urllib3==2.2.3

View File

@ -1,32 +0,0 @@
from setuptools import find_packages, setup
with open("README.md", "r") as f:
l_description = f.read()
setup(
name="temmies",
version="1.2.124",
packages=find_packages(),
description="A wrapper for the Themis website",
long_description=l_description,
long_description_content_type="text/markdown",
url="https://github.com/Code-For-Groningen/temmies",
author="Boyan K.",
author_email="boyan@confest.im",
license="GPLv3",
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Programming Language :: Python :: 3.9",
],
install_requires=[
"urllib3",
"requests",
"lxml",
"beautifulsoup4",
"keyring",
"selenium",
],
python_requires=">=3.9",
)

View File

@ -1,8 +1,3 @@
"""
Entry point for the temmies package.
"""
import urllib3
from .themis import Themis
__all__ = ["Themis"]
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

View File

@ -1,40 +1,28 @@
"""
Represents a course.
A course is a group that contains exercises or other groups.
"""
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.
Represents a course in a given academic year.
"""
def __init__(self, session, course_path: str, title: str, parent):
super().__init__(session, course_path, title, parent)
self.course_path = course_path # e.g., '/2023-2024/adinc-ai'
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.title})"
return f"Course {self.name} in year {self._parent.year}"
def create_group(self, item_data):
"""
Create a subgroup (Group or ExerciseGroup) based on item data.
"""
if item_data.get("submitable", False):
return ExerciseGroup(
self.session,
item_data["path"],
item_data["title"],
self,
item_data.get("submitable", False),
def __course_available(self, response):
if "Something went wrong" in response.text:
raise CourseUnavailable(
message="'Something went wrong'. Course most likely not found."
)
return Group(
self.session,
item_data["path"],
item_data["title"],
self,
item_data.get("submitable", False),
)
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

@ -1,31 +1,182 @@
"""
Represents a submittable exercise.
"""
from bs4 import BeautifulSoup
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 submittable exercise.
Represents a group of exercises or a single exercise.
"""
# pylint: disable=too-many-arguments, too-many-positional-arguments
def __init__(self, session, path: str, title: str, parent, submitable: bool = True):
super().__init__(session, path, title, parent, submitable=submitable)
self.submit_url = f"{self.base_url}/api/submit{self.path}"
self.__find_name()
def __find_name(self):
"""
Find the name of the exercise group.
"""
if self.title == "":
response = self.session.get(self.base_url + self.path)
soup = BeautifulSoup(response.text, "lxml")
title_elements = soup.find_all("a", class_="fill accent large")
if title_elements:
self.title = title_elements[-1].get_text(strip=True)
else:
self.title = self.path.split("/")[-1]
def __str__(self):
return f"ExerciseGroup({self.title})"
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

View File

@ -1,95 +1,98 @@
"""
Abstract-ish Group class for Themis API.
"""
# temmies/group.py
import os
from typing import Optional, Union, Dict
from json import loads
from time import sleep
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:
"""
Represents an item in Themis.
Can be either a folder (non-submittable) or an assignment (submittable).
Base class for Course and ExerciseGroup.
"""
# pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments
def __init__(self, session, path: str, title: str, parent=None, submitable: bool = False):
self.session = session
self.path = path # e.g., '/2023-2024/adinc-ai/labs'
self.title = title
self.parent = parent
self.submitable = submitable
self.base_url = "https://themis.housing.rug.nl"
self.api_url = f"{self.base_url}/api/navigation{self.path}"
self.classes = []
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 []
# Adjust URL construction to include '/course' when accessing HTML pages
if not self.path.startswith('/course/'):
group_url = f"{self.base_url}/course{self.path}"
else:
group_url = f"{self.base_url}{self.path}"
def __str__(self):
return f"Group {self.name}"
# Fetch the page and parse it
response = self.session.get(group_url)
if response.status_code != 200:
raise ConnectionError(f"Failed to retrieve page for '{self.title}'.")
self._raw = BeautifulSoup(response.text, "lxml")
def get_items(self) -> list:
def get_groups(self, full: bool = False):
"""
Get all items (groups and assignments) under this group.
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)
items = []
groups = []
for x in entries:
href = x['href']
name = x.text.strip()
classes = x.get('class', [])
submitable = "ass-submitable" in classes
item = Group(
session=self.session,
path=href,
title=name,
group = self.create_group(
url=f"https://themis.housing.rug.nl{href}",
name=name,
session=self._session,
parent=self,
submitable=submitable
full=full,
classes=classes
)
items.append(item)
return items
groups.append(group)
return groups
def get_item_by_title(self, title: str):
def get_group(self, name: str, full: bool = False):
"""
Get a single item by its title, case-insensitive.
Get a single group by name.
"""
items = self.get_items()
for item in items:
if (item.title.lower() == title.lower()) or (item.path.split("/")[-1] == title):
return item
raise ValueError(f"Item '{title}' not found under {self.title}.")
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 get_status(self, text: bool = False) -> Union[Dict[str, Union[str, 'Submission']], None]:
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 ValueError(
"Status information is not available for this group.")
raise IllegalAction("Status information is not available for this group.")
status_url = f"{self.base_url}{status_link['href']}"
response = self.session.get(status_url)
if response.status_code != 200:
raise ConnectionError(
f"Failed to retrieve status page for '{self.title}'.")
soup = BeautifulSoup(response.text, "lxml")
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:
@ -97,12 +100,17 @@ class Group:
return self.__parse_status_section(section, text)
def __parse_status_section(
self, section: BeautifulSoup,
text: bool
) -> Dict[str, Union[str, 'Submission']]:
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",
@ -121,237 +129,15 @@ class Group:
continue
# Normalize key
raw_key = " ".join(key_element.get_text(
separator=" ").strip().replace(":", "").lower().split())
# Use mapped key if available
key = key_mapping.get(raw_key, raw_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:
href = link["href"]
# Construct full URL
if href.startswith("/"):
submission_url = href
elif href.startswith("http"):
submission_url = href.replace(
"https://themis.housing.rug.nl", "")
else:
print(f"Invalid href '{href}' found in status page.")
continue # Skip this entry if href is invalid
# Instantiate Submission with submission_url and session
submission = Submission(submission_url, self.session)
parsed[key] = submission
submission_url = link["href"]
parsed[key] = Submission(submission_url, self._session)
else:
parsed[key] = value_element.get_text(separator=" ").strip()
return parsed
def get_test_cases(self) -> list[Dict[str, str]]:
"""
Get all test cases for this assignment.
"""
if not self.submitable:
raise ValueError(
f"No test cases for non-submittable item '{self.title}'.")
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"):
link = case.find("a")
if link:
tcs.append({
'title': link.text.strip(),
'path': link['href']
})
return tcs
def download_tcs(self, path=".") -> list[str]:
"""
Download all test cases for this assignment.
"""
test_cases = self.get_test_cases()
downloaded = []
for tc in test_cases:
url = f"{self.base_url}{tc['path']}"
print(f"Downloading {tc['title']}")
response = self.session.get(url)
if response.status_code == 200:
tc_filename = os.path.join(path, tc['title'])
with open(tc_filename, 'wb') as f:
f.write(response.content)
downloaded.append(tc_filename)
else:
print(f"Failed to download test case '{tc['title']}'")
return downloaded
def get_files(self) -> list[Dict[str, str]]:
"""
Get all downloadable files for this assignment.
"""
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")
files = []
for line in cfg_lines:
key = line.find("span", class_="cfg-key")
if key and "Downloads" in key.text.strip():
vals = line.find_all("span", class_="cfg-val")
for val in vals:
links = val.find_all("a")
for link in links:
files.append({
'title': link.text.strip(),
'path': link['href']
})
return files
def download_files(self, path=".") -> list[str]:
"""
Download all files available for this assignment.
"""
files = self.get_files()
downloaded = []
for file in files:
print(f"Downloading file '{file['title']}'")
url = f"{self.base_url}{file['path']}"
response = self.session.get(url)
if response.status_code == 200:
file_filename = os.path.join(path, file['title'])
with open(file_filename, 'wb') as f:
f.write(response.content)
downloaded.append(file_filename)
else:
print(f"Failed to download file '{file['title']}'")
return downloaded
# pylint: disable=too-many-locals
def submit(
self,
files: list[str],
judge: bool = True,
wait: bool = True,
silent: bool = True
) -> Optional[dict]:
"""
Submit files to this assignment.
Returns a dictionary of test case results or None if wait is False.
"""
if not self.submitable:
raise ValueError(
f"Cannot submit to non-submittable item '{self.title}'.")
form = self._raw.find("form")
if not form:
raise ValueError("Submission form not found.")
url = f"{self.base_url}{form['action']}"
file_types = loads(form.get("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.title}")
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.
Wait until all queued status-icons disappear before parsing.
"""
cases = soup.find_all("tr", class_="sub-casetop")
fail_pass = {}
any_queued = False
for case in cases:
name = case.find("td", class_="sub-casename").text.strip()
status = case.find("td", class_="status-icon")
status_classes = status.get("class", [])
if "queued" in status_classes:
any_queued = True
break
if "pending" in status_classes:
sleep(1)
return self.__wait_for_result(url, verbose, __printed)
statuses = {
"Passed": ("", True),
"Wrong output": ("", False),
"No status": ("🐛", None),
"error": ("🐛", None),
}
found = False
for key, (symbol, value) in statuses.items():
if key.lower() in status.text.lower():
found = True
case_number = int(name)
if verbose and case_number not in __printed:
print(f"Case {case_number}: {symbol}")
fail_pass[case_number] = value
break
if not found:
case_number = int(name)
fail_pass[case_number] = None
if verbose and case_number not in __printed:
print(f"{case_number}: Unrecognized status: {status.text.strip()}")
__printed.append(case_number)
# Polling (fix, use ws)
if any_queued:
sleep(1)
return self.__wait_for_result(url, verbose, __printed)
return fail_pass
def __str__(self):
return f"Group({self.title}, submitable={self.submitable})"

View File

@ -1,20 +1,17 @@
# submission.py
"""
File to define the Submission class
File to define the submission class
"""
from typing import Optional
from bs4 import BeautifulSoup
class Submission:
"""
Submission class
Methods:
get_test_cases: Get a dict of test cases status
get_info: Submission information (in details)
get_files: Get a list of uploaded files (as names)
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
@ -27,17 +24,23 @@ class Submission:
"""Clean text"""
clean = text.replace("\t", "").replace("\n", "")
if value:
return clean.strip()
return clean.replace(" ", "_").replace(":", "").lower().strip()
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"):
@ -45,60 +48,37 @@ class Submission:
status = entry.find(
"td", class_=lambda x: x and "status-icon" in x.split()
).text
results[name.strip()] = self.__clean(status)
results[name] = self.__clean(status)
return results
def get_info(self) -> Optional[dict[str, str]]:
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"):
h4 = div.find("h4", class_=lambda x: x and "info" in x.split())
if h4 and "Details" in h4.text:
info = div.find("div", class_="cfg-container")
info_lines = info.find_all("div", class_="cfg-line")
self.__info = {
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(a.text), a["href"])
for a in line.find("span", class_="cfg-val").find_all("a")
]
)
for line in info_lines
}
return self.__info
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) -> Optional[list[str]]:
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)
# Deprecated methods
def info(self):
"""
Deprecated method. Use get_info instead.
"""
print("This method is deprecated and will be deleted soon. Use get_info instead.")
return self.get_info()
def test_cases(self):
"""
Deprecated method. Use get_test_cases instead.
"""
print("This method is deprecated and will be deleted in soon. Use get_test_cases instead.")
return self.get_test_cases()
def files(self):
"""
Deprecated method. Use get_files instead.
"""
print("This method is deprecated and will be deleted in soon. Use get_files instead.")
return self.get_files()

View File

@ -1,75 +1,51 @@
"""
Main class for the Themis API using the new JSON endpoints.
Main class for the Themis API
"""
from requests import Session
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException
from json import dumps
from .year import Year
import getpass
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:
"""
Main class for interacting with Themis.
- login: Login to Themis
- get_year: Get a year object
- all_years: Get all years
login: Login to Themis
get_year: Get a year object
all_years: Get all years
"""
def __init__(self, cookies: dict = None, user=None):
"""
Initialize Themis object, logging in with the given user.
def __init__(self, user: str):
self.user = user
self.password = self.__get_password()
self.session = self.login(user, self.password)
Args:
user (str): Username to login with.
Attributes:
user (str): Username.
password (str): Password, retrieved from keyring.
base_url (str): Base URL of the Themis website.
session (requests.Session): Authenticated session.
"""
self.base_url = "https://themis.housing.rug.nl"
self.session = self._setup_agent()
self.user, self.password = None, None
# Old login logic
if user:
self.user = user
self.password = self._get_password()
# Reusing session logic
if not cookies:
self.session = self.login(self.session)
else:
self.session.cookies.update(cookies)
if not self.check_session():
self.session = self.login(self.session)
def _get_password(self) -> str:
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)
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)
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 _setup_agent(self) -> Session:
session = Session()
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) "
@ -77,94 +53,56 @@ class Themis:
"Chromium/80.0.3987.160 Chrome/80.0.3987.163 Safari/537.36"
)
session.headers.update({"User-Agent": user_agent})
headers = {"user-agent": user_agent}
return session
data = {"user": user, "password": passwd, "null": None}
def check_session(self) -> bool:
"""
Check if the session is still valid.
"""
# look at the /login and find a pre tag
login_url = f"{self.base_url}/login"
response = self.session.get(login_url)
return "pre" in response.text
def login(self, session: Session) -> Session:
"""
Login to Themis by spawning a selenium browser
"""
login_url = f"{self.base_url}/login"
driver = webdriver.Chrome()
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")
driver.get(login_url)
# 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()
wait = WebDriverWait(driver, 60)
# Login
r = s.post(url, data=data, headers=headers)
try:
wait.until(EC.url_contains("signon.rug.nl/nidp/saml2/sso"))
current_url = driver.current_url
# If on the sign-on page fill in the credentials
if "signon.rug.nl/nidp/saml2/sso" in current_url:
user_field = wait.until(EC.presence_of_element_located((By.NAME, "Ecom_User_ID")))
pass_field = wait.until(EC.presence_of_element_located((By.NAME, "Ecom_Password")))
# 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)
if self.user and not user_field.get_attribute("value"):
user_field.clear()
user_field.send_keys(self.user)
if self.password and not pass_field.get_attribute("value"):
pass_field.clear()
pass_field.send_keys(self.password)
# THIS IS LIKELY TO BREAK AT SOME POINT
wait.until(EC.text_to_be_present_in_element((By.TAG_NAME, "body"), "Cannot GET"))
except TimeoutException:
print("Timeout waiting for login/2FA page to load.")
except (NoSuchElementException, StaleElementReferenceException) as e:
print(f"Encountered an error: {e}")
finally:
# security
self.password = "I-HAVE-BEEN-REMOVED"
cookies = driver.get_cookies()
driver.quit()
return s
# Add all cookies to the session.
for cookie in cookies:
session.cookies.set(name=cookie["name"], value=cookie["value"])
return session
def get_session_cookies(self):
def get_year(self, start: int, end: int) -> Year:
"""
Get the session cookies in json
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 dumps(self.session.cookies.get_dict())
return Year(self.session, start, end)
def get_year(self, start_year: int = None, end_year: int = None) -> Year:
def all_years(self) -> list[Year]:
"""
Gets a Year object using the year path (e.g., 2023, 2024).
get_years(self, start: int, end: int) -> list[Year]
Gets all visible years
"""
year_path = f"{start_year}-{end_year}"
return Year(self.session, year_path)
def all_years(self) -> list:
"""
Gets all visible years as Year objects.
"""
navigation_url = f"{self.base_url}/api/navigation/"
response = self.session.get(navigation_url)
if response.status_code != 200:
raise ConnectionError("Failed to retrieve years from Themis API.")
years_data = response.json()
# 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 year_info in years_data:
if year_info.get("visible", False):
year_path = year_info["path"].strip("/")
years.append(Year(self.session, year_path))
return 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

View File

@ -1,75 +1,52 @@
"""
This module defines the Year class for managing academic year courses.
"""
from bs4 import BeautifulSoup
from .course import Course
from .exceptions.course_unavailable import CourseUnavailable
class Year:
"""
Represents an academic year.
"""
def __init__(self, session, year_path: str):
self.session = session
self.year_path = year_path # e.g., '2023-2024'
self.base_url = "https://themis.housing.rug.nl"
self.api_url = f"{self.base_url}/api/navigation/{self.year_path}"
def all_courses(self) -> list:
"""
Gets all visible courses in this year.
"""
response = self.session.get(self.api_url)
if response.status_code != 200:
raise ConnectionError(f"Failed to retrieve courses for {self.year_path}.")
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
courses_data = response.json()
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 course_info in courses_data:
if course_info.get("visible", False):
course_path = course_info["path"]
course_title = course_info["title"]
courses.append(Course(self.session, course_path, course_title, self))
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, course_title: str) -> Course:
def get_course(self, name: str) -> Course:
"""
Gets a course by its title.
Gets a course by name.
"""
all_courses = self.all_courses()
for course in all_courses:
if course.title == course_title:
return course
raise ValueError(f"Course '{course_title}' not found in year {self.year_path}.")
def get_course_by_tag(self, course_tag: str) -> Course:
"""
Gets a course by its tag (course identifier).
Constructs the course URL using the year and course tag.
"""
course_path = f"/{self.year_path}/{course_tag}"
course_url = f"{self.base_url}/course{course_path}"
response = self.session.get(course_url)
if response.status_code != 200:
raise ConnectionError(
f"Failed to retrieve course '{course_tag}' for year {self.year_path}."
)
soup = BeautifulSoup(response.text, "lxml")
title_elements = soup.find_all("a", class_="fill accent large")
title_element = title_elements[-1] if title_elements else None
if title_element:
course_title = title_element.get_text(strip=True)
else:
raise ValueError(
f"Could not retrieve course title for tag '{course_tag}' in year {self.year_path}."
)
return Course(self.session, course_path, course_title, self)
def __str__(self):
return f"Year({self.year_path})"
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)