mirror of
https://github.com/Code-For-Groningen/temmies.git
synced 2025-07-01 11:34:58 +02:00
Compare commits
27 Commits
6a63abf7ae
...
1.2.111
Author | SHA1 | Date | |
---|---|---|---|
6a781ad238 | |||
a1104522f1 | |||
fb8b5cd454 | |||
1367fd667f | |||
1516ef74be | |||
c37edb59c6 | |||
82a072ee14 | |||
3d9ba063ce | |||
82bafa1925 | |||
52d9d86260 | |||
da4705b56a | |||
f6e6bc28d2 | |||
2fa3bfbad8 | |||
9f99df54d8 | |||
514fcd2438 | |||
812f384c3d | |||
34bf29d274 | |||
f3d9a987f6 | |||
cb6ec3cc1c | |||
0ff3f28978 | |||
de66e9762e | |||
1a950c0eb2 | |||
a3a9f5dfee | |||
461b506be5 | |||
a0aeca87b1 | |||
17f0bd237a | |||
2378be4d42 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,7 +3,6 @@ config.py
|
||||
tests/
|
||||
pathfinding/
|
||||
test.py
|
||||
setup.py
|
||||
|
||||
#Doc env
|
||||
.docs_env
|
||||
|
@ -1,12 +1,12 @@
|
||||
<p align="center">
|
||||
<img src="docs/img/rugemmie.gif" />
|
||||
<img src="docs/img/temmie.png" width= 200px/>
|
||||
</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
|
||||
@ -15,11 +15,11 @@ A python library which interacts with themis. Uses bs4. I'll try to end developm
|
||||
* [x] Submission status
|
||||
|
||||
## Docs
|
||||
[here](http://temmies.confest.im/).
|
||||
- [here](http://temmies.confest.im/).
|
||||
|
||||
## Possible continuations
|
||||
* Discord bot
|
||||
* CLI program
|
||||
* [CLI program](https://github.com/Code-For-Groningen/temmies-cli)
|
||||
|
||||
## Thanks to
|
||||
* [Glitchcat](https://glitchcat.github.io/themis-api/), cool docs bro.
|
||||
|
254
docs/api.md
254
docs/api.md
@ -8,15 +8,17 @@ Creates the initial connection to Themis.
|
||||
```python
|
||||
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.
|
||||
|
||||
#### `get_year(start, end)`
|
||||
Returns an instance of a [`Year`](#year) (academic year) between `start` and `end`.
|
||||
#### `get_year(year_path)`
|
||||
Returns an instance of a [`Year`](#year) for the academic year specified by `year_path`.
|
||||
|
||||
```python
|
||||
year = themis.get_year(2023, 2024)
|
||||
@ -28,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`
|
||||
@ -39,13 +41,20 @@ year = themis.get_year(2023, 2024)
|
||||
```
|
||||
|
||||
### Methods
|
||||
#### `get_course(name)`
|
||||
Returns an instance of a [`Course`](#course) with the name `name`.
|
||||
#### `get_course(course_title)`
|
||||
Returns an instance of a [`Course`](#course) with the title `course_title`.
|
||||
|
||||
```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`.
|
||||
|
||||
@ -64,160 +73,96 @@ assignments = pf.get_groups()
|
||||
|
||||
### Methods
|
||||
#### `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:
|
||||
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.
|
||||
|
||||
```python
|
||||
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)
|
||||
ai_groups = ai_course.get_groups(full=True)
|
||||
exercise = ai_groups[7].exercises[1]
|
||||
exercise.submit(["solution.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.
|
||||
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.
|
||||
|
||||
```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`
|
||||
Setting the `full` flag to `True` will traverse the whole group.
|
||||
Represents a submittable exercise. Inherits from `Group`.
|
||||
|
||||
- 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:
|
||||
### Additional Methods
|
||||
#### `submit(files)`
|
||||
Submits files to the exercise. Raises an error if the item is not submittable.
|
||||
|
||||
```python
|
||||
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())
|
||||
exercise.submit(["solution.py"])
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
## `Submission`
|
||||
### Usage
|
||||
```python
|
||||
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()["leading"]
|
||||
```
|
||||
|
||||
Represents a submission for a specific exercise.
|
||||
|
||||
### Methods
|
||||
#### `get_test_cases()`
|
||||
@ -225,33 +170,13 @@ 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.
|
||||
Returns detailed 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()`
|
||||
@ -259,13 +184,4 @@ 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')
|
||||
]
|
||||
```
|
||||
|
||||
----
|
||||
|
@ -11,4 +11,11 @@
|
||||
#### **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.
|
Binary file not shown.
Before Width: | Height: | Size: 190 KiB |
BIN
docs/img/temmie.png
Normal file
BIN
docs/img/temmie.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
@ -1,5 +1,5 @@
|
||||
# Temmies!
|
||||
<center></center>
|
||||
<center></center>
|
||||
|
||||
|
||||
## What is this?
|
||||
@ -21,10 +21,10 @@ pip install temmies
|
||||
from temmies.themis import Themis
|
||||
|
||||
# Log in
|
||||
themis = Themis("s-number", "password")
|
||||
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)")
|
||||
|
19
requirements.txt
Normal file
19
requirements.txt
Normal 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
|
30
setup.py
Normal file
30
setup.py
Normal file
@ -0,0 +1,30 @@
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
with open("README.md", "r") as f:
|
||||
l_description = f.read()
|
||||
|
||||
setup(
|
||||
name="temmies",
|
||||
version="1.2.1",
|
||||
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=[
|
||||
"requests",
|
||||
"lxml",
|
||||
"beautifulsoup4",
|
||||
"keyring"
|
||||
],
|
||||
python_requires=">=3.9",
|
||||
)
|
@ -1,3 +1,5 @@
|
||||
from .themis import Themis
|
||||
import urllib3
|
||||
|
||||
__all__ = ["Themis"]
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
@ -1,28 +1,35 @@
|
||||
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.
|
||||
Represents a course.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, name: str, session, parent):
|
||||
super().__init__(url, name, session, parent=parent, full=False)
|
||||
self.__course_available(self._request)
|
||||
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 __str__(self):
|
||||
return f"Course {self.name} in year {self._parent.year}"
|
||||
return f"Course({self.title})"
|
||||
|
||||
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, 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),
|
||||
)
|
||||
else:
|
||||
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)
|
||||
|
@ -1,182 +1,51 @@
|
||||
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.
|
||||
Represents a submittable 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 __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 create_group(self, url: str, name: str, session, parent, full: bool, classes=None):
|
||||
def __find_name(self):
|
||||
"""
|
||||
Create an instance of ExerciseGroup for subgroups.
|
||||
Find the name of the exercise group.
|
||||
"""
|
||||
return ExerciseGroup(url, name, session, parent, full, classes)
|
||||
if self.title == "":
|
||||
# Find using beautiful soup (it is the last a with class 'fill accent large')
|
||||
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]
|
||||
|
||||
@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]:
|
||||
def submit(self, files: list[str]) -> Submission:
|
||||
"""
|
||||
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.")
|
||||
if not self.submitable:
|
||||
raise ValueError(f"Cannot submit to non-submittable item '{self.title}'.")
|
||||
|
||||
form = self._raw.find("form")
|
||||
if not form:
|
||||
raise IllegalAction("Submission form not found.")
|
||||
# Prepare the files and data for submission
|
||||
files_payload = {}
|
||||
for idx, file_path in enumerate(files):
|
||||
file_key = f"file{idx}"
|
||||
with open(file_path, "rb") as f:
|
||||
files_payload[file_key] = (file_path, f.read())
|
||||
|
||||
url = "https://themis.housing.rug.nl" + form["action"]
|
||||
file_types = loads(form["data-suffixes"])
|
||||
response = self.session.post(self.submit_url, files=files_payload)
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError(f"Failed to submit to '{self.title}'.")
|
||||
|
||||
if isinstance(files, str):
|
||||
files = [files]
|
||||
submission_data = response.json()
|
||||
return Submission(self.session, submission_data)
|
||||
|
||||
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
|
||||
def __str__(self):
|
||||
return f"ExerciseGroup({self.title})"
|
||||
|
315
temmies/group.py
315
temmies/group.py
@ -1,98 +1,88 @@
|
||||
# temmies/group.py
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from requests import Session
|
||||
import os
|
||||
from typing import Optional, Union, Dict
|
||||
from .exceptions.illegal_action import IllegalAction
|
||||
from .submission import Submission
|
||||
|
||||
|
||||
class Group:
|
||||
"""
|
||||
Base class for Course and ExerciseGroup.
|
||||
Represents an item in Themis, which can be either a folder (non-submittable) or an assignment (submittable).
|
||||
"""
|
||||
|
||||
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 __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 __str__(self):
|
||||
return f"Group {self.name}"
|
||||
# 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 get_groups(self, full: bool = False):
|
||||
# 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}'. Tried {group_url}")
|
||||
self._raw = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
|
||||
def get_items(self) -> list:
|
||||
"""
|
||||
Get all groups (exercises and folders) within this group.
|
||||
Get all items (groups and assignments) under this group.
|
||||
"""
|
||||
section = self._raw.find("div", class_="ass-children")
|
||||
if not section:
|
||||
return []
|
||||
|
||||
entries = section.find_all("a", href=True)
|
||||
groups = []
|
||||
items = []
|
||||
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,
|
||||
submitable = "ass-submitable" in classes
|
||||
item = Group(
|
||||
session=self.session,
|
||||
path=href,
|
||||
title=name,
|
||||
parent=self,
|
||||
full=full,
|
||||
classes=classes
|
||||
submitable=submitable
|
||||
)
|
||||
groups.append(group)
|
||||
return groups
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def get_group(self, name: str, full: bool = False):
|
||||
def get_item_by_title(self, title: str):
|
||||
"""
|
||||
Get a single group by name.
|
||||
Get a single item by its title, case-insensitive.
|
||||
"""
|
||||
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
|
||||
)
|
||||
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}.")
|
||||
|
||||
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]:
|
||||
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.")
|
||||
raise ValueError("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")
|
||||
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")
|
||||
section = soup.find("div", class_="cfg-container")
|
||||
|
||||
if not section:
|
||||
@ -100,17 +90,9 @@ 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",
|
||||
@ -135,9 +117,202 @@ class Group:
|
||||
# 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)
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
def __str__(self):
|
||||
return f"Group({self.title}, submitable={self.submitable})"
|
||||
|
@ -1,5 +1,7 @@
|
||||
# submission.py
|
||||
|
||||
"""
|
||||
File to define the submission class
|
||||
File to define the Submission class
|
||||
"""
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
@ -9,9 +11,9 @@ 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)
|
||||
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)
|
||||
"""
|
||||
def __init__(self, url: str, session):
|
||||
self.url = "https://themis.housing.rug.nl" + url
|
||||
@ -24,23 +26,17 @@ class Submission:
|
||||
"""Clean text"""
|
||||
clean = text.replace("\t", "").replace("\n", "")
|
||||
if value:
|
||||
return clean
|
||||
return clean.replace(" ", "_").replace(":", "").lower()
|
||||
return clean.strip()
|
||||
return clean.replace(" ", "_").replace(":", "").lower().strip()
|
||||
|
||||
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"):
|
||||
@ -48,37 +44,51 @@ class Submission:
|
||||
status = entry.find(
|
||||
"td", class_=lambda x: x and "status-icon" in x.split()
|
||||
).text
|
||||
results[name] = self.__clean(status)
|
||||
results[name.strip()] = 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
|
||||
}
|
||||
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
|
||||
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.info()
|
||||
|
||||
self.__info = self.get_info()
|
||||
return self.__info.get("files", None)
|
||||
|
||||
# Deprecated methods
|
||||
def info(self):
|
||||
print("This method is deprecated and will be deleted soon. Use get_info instead.")
|
||||
return self.get_info()
|
||||
|
||||
def test_cases(self):
|
||||
print("This method is deprecated and will be deleted in soon. Use get_test_cases instead.")
|
||||
return self.get_test_cases()
|
||||
|
||||
def files(self):
|
||||
print("This method is deprecated and will be deleted in soon. Use get_files instead.")
|
||||
return self.get_files()
|
||||
|
@ -1,37 +1,57 @@
|
||||
"""
|
||||
Main class for the Themis API
|
||||
|
||||
Main class for the Themis API using the new JSON endpoints.
|
||||
"""
|
||||
|
||||
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
|
||||
Main class for interacting with Themis.
|
||||
- login: Login to Themis
|
||||
- get_year: Get a year object
|
||||
- all_years: Get all years
|
||||
"""
|
||||
|
||||
def __init__(self, user: str, passwd: str):
|
||||
self.session = self.login(user, passwd)
|
||||
self.years = []
|
||||
self.url = "https://themis.housing.rug.nl/course/"
|
||||
def __init__(self, user: str):
|
||||
"""
|
||||
Initialize Themis object, logging in with the given user.
|
||||
|
||||
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.user = user
|
||||
self.password = self.__get_password()
|
||||
self.base_url = "https://themis.housing.rug.nl"
|
||||
self.session = self.login(self.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
|
||||
Login to Themis using the original method, parsing CSRF token from the login page.
|
||||
"""
|
||||
session = Session()
|
||||
login_url = f"{self.base_url}/log/in"
|
||||
|
||||
user_agent = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
@ -43,48 +63,54 @@ class Themis:
|
||||
|
||||
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 login page to retrieve CSRF token
|
||||
response = session.get(login_url, headers=headers, verify=False)
|
||||
if response.status_code != 200:
|
||||
raise ConnectionError("Failed to connect to Themis login page.")
|
||||
|
||||
# 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()
|
||||
# Parse CSRF token from login page
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
csrf_input = soup.find("input", attrs={"name": "_csrf"})
|
||||
if not csrf_input or not csrf_input.get("value"):
|
||||
raise ValueError("Unable to retrieve CSRF token.")
|
||||
csrf_token = csrf_input["value"]
|
||||
data["_csrf"] = csrf_token
|
||||
data["sudo"] = user.lower()
|
||||
|
||||
# Login
|
||||
r = s.post(url, data=data, headers=headers)
|
||||
# Attempt login
|
||||
response = session.post(login_url, data=data, headers=headers)
|
||||
if "Invalid credentials" in response.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)
|
||||
elif "Welcome, logged in as" not in response.text:
|
||||
raise ValueError("Login failed for an unknown reason.")
|
||||
|
||||
# check if login was successful
|
||||
log_out = "Welcome, logged in as" in r.text
|
||||
if not log_out:
|
||||
raise IllegalAction(message=f"Login for user {user} failed")
|
||||
return session
|
||||
|
||||
return s
|
||||
|
||||
def get_year(self, start: int, end: int) -> Year:
|
||||
def get_year(self, start_year: int = None, end_year: int = None) -> 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)
|
||||
Gets a Year object using the year path (e.g., 2023, 2024).
|
||||
"""
|
||||
return Year(self.session, start, end)
|
||||
year_path = f"{start_year}-{end_year}"
|
||||
|
||||
def all_years(self) -> list[Year]:
|
||||
return Year(self.session, year_path)
|
||||
|
||||
def all_years(self) -> list:
|
||||
"""
|
||||
get_years(self, start: int, end: int) -> list[Year]
|
||||
Gets all visible years
|
||||
Gets all visible years as Year objects.
|
||||
"""
|
||||
# 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")
|
||||
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()
|
||||
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
|
||||
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
|
||||
|
@ -1,52 +1,68 @@
|
||||
from bs4 import BeautifulSoup
|
||||
from .course import Course
|
||||
from .exceptions.course_unavailable import CourseUnavailable
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
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 __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) -> 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 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_data = response.json()
|
||||
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
|
||||
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))
|
||||
return courses
|
||||
|
||||
def get_course(self, name: str) -> Course:
|
||||
def get_course(self, course_title: str) -> Course:
|
||||
"""
|
||||
Gets a course by name.
|
||||
Gets a course by its title.
|
||||
"""
|
||||
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)
|
||||
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}.")
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
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 with tag '{course_tag}' for year {self.year_path}. Tried {course_url}")
|
||||
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
title_elements = soup.find_all("a", class_="fill accent large")
|
||||
if title_elements:
|
||||
title_element = title_elements[-1]
|
||||
|
||||
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})"
|
||||
|
Reference in New Issue
Block a user