mirror of
https://github.com/Code-For-Groningen/temmies.git
synced 2025-07-04 04:24:58 +02:00
Compare commits
19 Commits
e3d863d7b2
...
v1.0.21
Author | SHA1 | Date | |
---|---|---|---|
54e086c93a
|
|||
667a388da4
|
|||
2daee84d4f
|
|||
cd5e9b2c8d
|
|||
de6262a4bb | |||
8325a30af8 | |||
0f92d05bec | |||
2785ec86e9 | |||
78aade7c8c | |||
11864cae6b | |||
27d21ac7c1 | |||
df8429d811 | |||
3c63a64eac | |||
b819305704 | |||
1016e56e70 | |||
b1eface45d | |||
b8a6e05ea0 | |||
c0056a27d1 | |||
cff77bcc95 |
11
.gitignore
vendored
11
.gitignore
vendored
@ -1,6 +1,12 @@
|
||||
# Config
|
||||
# Config - Testing
|
||||
config.py
|
||||
baller.py
|
||||
tests/
|
||||
pathfinding/
|
||||
test.py
|
||||
setup.py
|
||||
|
||||
#Doc env
|
||||
.docs_env
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@ -326,3 +332,4 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
project-hierarchy.txt
|
||||
|
@ -3,12 +3,10 @@ version: 2
|
||||
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
|
||||
tools:
|
||||
python: "3.12"
|
||||
python: "3.8"
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
mkdocs:
|
||||
configuration: mkdocs.yml
|
30
Folder.py
30
Folder.py
@ -1,30 +0,0 @@
|
||||
# Module to handle each assignment (most difficult part)
|
||||
|
||||
from Base import Base
|
||||
from Exercise import Exercise
|
||||
from requests import Session
|
||||
|
||||
|
||||
class Assignment(Base):
|
||||
def __init__(self, url:str, name:str, session:Session, parent):
|
||||
super().__init__(url, name, session, parent)
|
||||
self.download = Downloadable(name, session, self)
|
||||
|
||||
def __str__(self):
|
||||
return f"Assignment {self.name} in course {self.parent.name}"
|
||||
|
||||
def getExercises(self) -> list[Exercise]:
|
||||
# Find li large
|
||||
ul = self.soup.find('ul', class_='round')
|
||||
|
||||
# Turn each li to an exercise instance
|
||||
return self.liLargeToExercises(ul, self.session, self)
|
||||
|
||||
def getExercise(self, name:str) -> Exercise:
|
||||
# Get the exercise
|
||||
r = self.session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
# Search by name
|
||||
exercise = soup.find('a', text=name)
|
||||
# Get the url and transform it into an exercise object
|
||||
return Exercise(url=exercise['href'], name=name, session=self.session, assignment=self)
|
20
Makefile
20
Makefile
@ -1,20 +0,0 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
19
README.md
19
README.md
@ -1,22 +1,25 @@
|
||||
<p align="center">
|
||||
<img src="images/rugemmie.gif" />
|
||||
<img src="docs/img/rugemmie.gif" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://temmies.readthedocs.io/en/latest/"><img alt="Read the Docs" src="https://img.shields.io/readthedocs/temmies"></a>
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/Code-For-Groningen/temmies">
|
||||
</p>
|
||||
|
||||
A python library which interacts with themis. Uses bs4. I'll try to end development on a somewhat working state.
|
||||
|
||||
## Intended Features
|
||||
* [x] Log in
|
||||
* [ ] Classes, methods and attributes described in the map below
|
||||
* [x] Submit
|
||||
* [x] Bulk download of test cases and files
|
||||
* [x] Submission status
|
||||
|
||||
## Docs
|
||||
[here](http://temmies.rtfd.io/). Heavily WIP.
|
||||
|
||||
## Class map
|
||||

|
||||
[here](http://temmies.rtfd.io/).
|
||||
|
||||
## Possible continuations
|
||||
* [ ] Discord bot
|
||||
* [ ] CLI program
|
||||
* Discord bot
|
||||
* CLI program
|
||||
|
||||
## Thanks to
|
||||
* [Glitchcat](https://glitchcat.github.io/themis-api/), cool docs bro.
|
||||
|
10
docs/about.md
Normal file
10
docs/about.md
Normal file
@ -0,0 +1,10 @@
|
||||
# This project was made with ❤️
|
||||
By [Boyan](https://confest.im) from the student organization [Code for Groningen](https://github.com/Code-For-Groningen/).
|
||||
|
||||
It has **no** affiliation with the [University of Groningen](https://rug.nl).
|
||||
|
||||
## Contact
|
||||
Shoot me an email: boyan(plus)cfg(at)bobokara.com.
|
||||
|
||||
## License
|
||||
This project is licensed under the GPL 3.0 license.
|
285
docs/api.md
Normal file
285
docs/api.md
Normal file
@ -0,0 +1,285 @@
|
||||
# Classes
|
||||
---
|
||||
## `Themis`
|
||||
Creates the initial connection to Themis.
|
||||
|
||||
### Usage
|
||||
```python
|
||||
from temmies.themis import Themis
|
||||
|
||||
themis = Themis("s-number", "password")
|
||||
```
|
||||
|
||||
### 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`.
|
||||
|
||||
```python
|
||||
year = themis.get_year(2023, 2024)
|
||||
```
|
||||
|
||||
#### `all_years()`
|
||||
Returns a list of `Year` instances corresponding to all years visible to the user.
|
||||
|
||||
```python
|
||||
years = themis.all_years()
|
||||
```
|
||||
<sub> I don't see why you would need this, but it's here. </sub>
|
||||
|
||||
----
|
||||
|
||||
## `Year`
|
||||
|
||||
### Usage
|
||||
```python
|
||||
year = themis.get_year(2023, 2024)
|
||||
```
|
||||
|
||||
### Methods
|
||||
#### `get_course(name)`
|
||||
Returns an instance of a [`Course`](#course) with the name `name`.
|
||||
|
||||
```python
|
||||
pf = year.get_course("Programming Fundamentals (for CS)")
|
||||
```
|
||||
|
||||
#### `all_courses()`
|
||||
Returns a list of `Course` instances corresponding to all courses visible to the user in a given `Year`.
|
||||
|
||||
```python
|
||||
courses = year.all_courses()
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
## `Course`
|
||||
### Usage
|
||||
```python
|
||||
pf = year.get_course("Programming Fundamentals (for CS)")
|
||||
print(pf.info) # <- course info attribute
|
||||
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`. Default argument is `full=False`, which will only return the (name, link) of each exercise and folder in the group. If `full=True`, it will traverse the whole course.
|
||||
|
||||
You can traverse the course in both cases, although in different ways.
|
||||
|
||||
When you have fully traversed the course, you can access everything via indices and the `exercises` and `folders` attributes of the `ExerciseGroup` instances:
|
||||
|
||||
```python
|
||||
ai_group = ai_course.get_groups(full=True)
|
||||
exercise = ai_group[7].exercises[1] # Week 11 -> Suitcase packing
|
||||
exercise.submit("suitcase.py", silent=False)```
|
||||
```
|
||||
|
||||
This is equivalent to the case in which we don't traverse the full course using `get_group` like so:
|
||||
|
||||
```python
|
||||
ai_group = ai_course.get_group("Week 11")
|
||||
exercise = ai_group.get_group("Suitcase packing")
|
||||
exercise.submit("suitcase.py", silent=False)
|
||||
```
|
||||
|
||||
### `get_group(name, full=False)`
|
||||
Returns an instance of an `ExerciseGroup` with the name `name`. Default argument is `full=False`, which will only return the (name, link) of each exercise and folder in the group. If `full=True`, it will traverse the whole group.
|
||||
|
||||
```python
|
||||
week1 = pf.get_group("Week 1")
|
||||
```
|
||||
|
||||
## `ExerciseGroup`
|
||||
Setting the `full` flag to `True` will traverse the whole course.
|
||||
|
||||
You can traverse the course in both cases
|
||||
* Both folders and exercises are represented as `ExerciseGroup` instances.
|
||||
* Folders will have the `am_exercise` attribute set to `False`.
|
||||
* Folders can have the `download_files` method called on them.
|
||||
* Exercises can have the `submit`, `download_files` and `download_tcs` method called on them.
|
||||
|
||||
|
||||
### 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
|
||||
pf = year.get_course("Programming Fundamentals (for CS)")
|
||||
assignments = pf.get_groups()
|
||||
week1 = assignments[0] # Week 1
|
||||
exercise2 = week1.folders[1] # Exercise 2
|
||||
part2 = exercise2.exercises[1] # Part 2
|
||||
|
||||
# Or, if you dont want to traverse the whole course:
|
||||
week1 = pf.get_group("Week 1")
|
||||
exercise2 = week1.get_group("Exercise 2")
|
||||
part2 = exercise2.get_group("Part 2")
|
||||
```
|
||||
|
||||
|
||||
### Methods
|
||||
#### `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). Of course, you can use it even if you've traversed the whole course, but that would overcomplicate things.
|
||||
|
||||
```python
|
||||
# Week 1 -> Exercise 2 -> Part 2
|
||||
week1 = pf.get_groups("Week 1")
|
||||
exercise2 = week1.get_group("Exercise 2")
|
||||
part2 = exercise2.get_group("Part 2")
|
||||
|
||||
# This is equivalent to(but faster than):
|
||||
week1 = pf.get_groups("Week 1", full=True)
|
||||
exercise2 = week1[1]
|
||||
part2 = exercise2[1]
|
||||
```
|
||||
|
||||
|
||||
#### `submit(files)`
|
||||
Submits the files to the exercise group. Default arguments are `judge=True`, `wait=True` and `silent=True`. `judge` will judge the submission instantly, and `wait` will wait for the submission to finish. Turning off `silent` will print the submission status dynamically.
|
||||
|
||||
```python
|
||||
suitcase = ai.get_group("Week 11")
|
||||
suitcase[7].exercises[1].submit("suitcase.py", silent=False)
|
||||
|
||||
# Or
|
||||
ai.get_group("Week 11").get_group("Suitcase packing").submit("suitcase.py", silent=False)
|
||||
|
||||
>>> 1: ✅
|
||||
>>> 2: ✅
|
||||
>>> 3: ✅
|
||||
>>> 4: ✅
|
||||
>>> 5: ✅
|
||||
>>> 6: ✅
|
||||
>>> 7: ✅
|
||||
>>> 8: ✅
|
||||
>>> 9: ✅
|
||||
>>> 10: ✅
|
||||
|
||||
```
|
||||
|
||||
#### `get_status(section=None, text=False)`
|
||||
Parses the status of the exercise group(from a given section). If `section` is not `None`, it will return the status of the section. Don't set `section` if you don't know what you're doing.
|
||||
|
||||
When `text` is set to `True`, it will return the status as a dictionary of strings. Otherwise, it will return a tuple in the form `(dict(str:str), dict(str:Submission))`. Refer to the [Submission](#submission) class for more information.
|
||||
|
||||
```python
|
||||
pf = year.get_course("Programming Fundamentals (for CS)")
|
||||
pf_as = pf.get_group("Lab Session 2")
|
||||
|
||||
# Get exercise
|
||||
exercise = pf_as.get_group("Recurrence")
|
||||
|
||||
# Get status
|
||||
status = exercise.get_status()
|
||||
print(status)
|
||||
|
||||
>>> (
|
||||
>>> { # Information [0]
|
||||
>>> 'assignment': 'Recurrence'
|
||||
>>> 'group': 'Y.N. Here'
|
||||
>>> 'status': 'passed: Passed all test cases'
|
||||
>>> 'grade': '2.00'
|
||||
>>> 'total': '2'
|
||||
>>> 'output limit': '1'
|
||||
>>> 'passed': '1'
|
||||
>>> 'leading': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1'
|
||||
>>> 'best': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1'
|
||||
>>> 'latest': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1'
|
||||
>>> 'first pass': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1'
|
||||
>>> 'last pass': '/submission/2023-2024/progfun/lab2/recurrence/@submissions/s1234567/s1234567-1'
|
||||
>>> 'visible': 'Yes'
|
||||
>>> }
|
||||
>>> { # Submission instances [1]
|
||||
>>> 'leading': <submission.Submission object at 0x774ea7a48cd0>
|
||||
>>> 'best': <submission.Submission object at 0x774ea79af910>
|
||||
>>> 'latest': <submission.Submission object at 0x774eaa7d3c10>
|
||||
>>> 'first_pass': <submission.Submission object at 0x774ea77ee810>
|
||||
>>> 'last_pass': <submission.Submission object at 0x774ea755de10>
|
||||
>>> }
|
||||
>>>)
|
||||
|
||||
|
||||
```
|
||||
|
||||
#### `get_all_statuses(text=False)
|
||||
Does the same as `get_status`, but for all visible status sections.
|
||||
|
||||
|
||||
## `Submission`
|
||||
### Usage
|
||||
```python
|
||||
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"] # Week 1 -> Exercise 1 -> Part 1 -> Leading submission
|
||||
|
||||
```
|
||||
|
||||
### Methods
|
||||
#### `test_cases()`
|
||||
Returns a list of `TestCase` instances corresponding to all test cases in the submission.
|
||||
|
||||
```python
|
||||
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"]
|
||||
submission.test_cases()
|
||||
>>> {'1': 'passed', '2': 'passed', '3': 'passed', '4': 'passed', '5': 'passed', '6': 'passed', '7': 'passed', '8': 'passed', '9': 'passed', '10': 'passed'}
|
||||
|
||||
```
|
||||
|
||||
#### `info()`
|
||||
Returns a dictionary of information about the submission.
|
||||
|
||||
```python
|
||||
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"]
|
||||
submission.info()
|
||||
|
||||
>>> {
|
||||
>>>'assignment': 'Part 1',
|
||||
>>>'group': 'Y.N. Here',
|
||||
>>>'uploaded_by': 'Y.N. Here s1234567',
|
||||
>>>'created_on': 'Wed Sep 13 2023 12:51:37 GMT+02002023-09-13T10:51:37.338Z',
|
||||
>>>'submitted_on': 'Wed Sep 13 2023 12:51:37 GMT+02002023-09-13T10:51:37.344Z',
|
||||
>>>'status': 'passed: Passed all test cases',
|
||||
>>>'files': [('recurrence.c',
|
||||
>>>'/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/source/recurrence.c'),
|
||||
>>>('compile.log',
|
||||
>>>'/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/output/compile.log')],
|
||||
>>>'language': 'c'
|
||||
>>> }
|
||||
```
|
||||
|
||||
#### `files()`
|
||||
Returns a list of files in the form `(name, link)`.
|
||||
|
||||
```python
|
||||
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"]
|
||||
submission.files()
|
||||
|
||||
>>> [('recurrence.c', '/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/source/recurrence.c'), ('compile.log', '/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/output/compile.log')]
|
||||
```
|
||||
|
27
docs/conf.py
27
docs/conf.py
@ -1,27 +0,0 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
project = 'temmies-docs'
|
||||
copyright = '2024, Boyan K.'
|
||||
author = 'Boyan K.'
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
extensions = []
|
||||
|
||||
templates_path = ['_templates']
|
||||
exclude_patterns = []
|
||||
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_static_path = ['_static']
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 190 KiB |
40
docs/index.md
Normal file
40
docs/index.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Temmies!
|
||||
<center></center>
|
||||
|
||||
|
||||
## What is this?
|
||||
A python library which interacts with themis. Uses bs4. I'll try to end development on a somewhat working state. [Check out the code](https://github.com/Code-For-Groningen/temmies)
|
||||
|
||||
## Intended Features
|
||||
* Log in
|
||||
* Bulk download of test cases and files~~
|
||||
* Submitting files
|
||||
* Somewhat easy to use API to interact with courses
|
||||
|
||||
## Installation
|
||||
```bash
|
||||
pip install temmies
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
```python
|
||||
from temmies.themis import Themis
|
||||
|
||||
# Log in
|
||||
themis = Themis("s-number", "password")
|
||||
|
||||
# Get a year
|
||||
year = themis.get_year(2023, 2024)
|
||||
|
||||
# Get a course
|
||||
course = year.get_course("Programming Fundamentals (for CS)")
|
||||
|
||||
# Get an assignment
|
||||
assignment = course.get_assignment("Assignment 1")
|
||||
|
||||
# Submit 2 files
|
||||
assignment.submit(["among.c", "us.py"])
|
||||
```
|
||||
|
||||
|
||||
|
@ -1,21 +0,0 @@
|
||||
.. temmies-docs documentation master file, created by
|
||||
sphinx-quickstart on Tue Feb 13 20:53:28 2024.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Temmies!
|
||||
========================================
|
||||
.. image:: https://static.wikia.nocookie.net/undertale/images/7/7b/Temmie_battle_idle.gif
|
||||
:align: center
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
@ -1 +0,0 @@
|
||||
sphinx-rtd-theme==1.3.0
|
Binary file not shown.
Before Width: | Height: | Size: 141 KiB |
6
mkdocs.yml
Normal file
6
mkdocs.yml
Normal file
@ -0,0 +1,6 @@
|
||||
site_name: Temmies
|
||||
nav:
|
||||
- Temmies: index.md
|
||||
- API Reference: api.md
|
||||
- About: about.md
|
||||
theme: readthedocs
|
@ -1,31 +0,0 @@
|
||||
# Module to handle each assignment (most difficult part)
|
||||
|
||||
from Downloadable import Downloadable
|
||||
from Base import Base
|
||||
from Exercise import Exercise
|
||||
from requests import Session
|
||||
|
||||
|
||||
class Assignment(Base):
|
||||
def __init__(self, url:str, name:str, session:Session, parent):
|
||||
super().__init__(url, name, session, parent)
|
||||
self.download = Downloadable(name, session, self)
|
||||
|
||||
def __str__(self):
|
||||
return f"Assignment {self.name} in course {self.parent.name}"
|
||||
|
||||
def getExercises(self) -> list[Exercise]:
|
||||
# Find li large
|
||||
ul = self.soup.find('ul', class_='round')
|
||||
|
||||
# Turn each li to an exercise instance
|
||||
return self.liLargeToExercises(ul, self.session, self)
|
||||
|
||||
def getExercise(self, name:str) -> Exercise:
|
||||
# Get the exercise
|
||||
r = self.session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
# Search by name
|
||||
exercise = soup.find('a', text=name)
|
||||
# Get the url and transform it into an exercise object
|
||||
return Exercise(url=exercise['href'], name=name, session=self.session, assignment=self)
|
72
src/Base.py
72
src/Base.py
@ -1,72 +0,0 @@
|
||||
# Noticed there's a similar pattern in the classes, so I'm going to create a base class for them
|
||||
|
||||
# classes that inherit from Base:
|
||||
# - Course
|
||||
# - Assignment
|
||||
# - Exercise
|
||||
from requests import Session
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
class Base:
|
||||
def __init__(self, url:str, name:str, session:Session, parent):
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.session = session
|
||||
self.parent = parent
|
||||
|
||||
def __parseCfgBlock(self, div:BeautifulSoup) -> dict:
|
||||
# We assume that the div is a submission with class "cfg-container round"
|
||||
# Put each key and value in a dictionary
|
||||
# The key is a span with a class "cfg-key"
|
||||
# The value is a span with a class "cfg-val"
|
||||
|
||||
# Get the key and value spans
|
||||
keys = div.find_all('span', class_="cfg-key")
|
||||
values = div.find_all('span', class_="cfg-val")
|
||||
|
||||
# Create a dictionary
|
||||
submission = {}
|
||||
|
||||
# Put each key and value in the dictionary
|
||||
for i in range(len(keys)):
|
||||
submission[keys[i].text] = values[i].text
|
||||
|
||||
return submission
|
||||
|
||||
|
||||
# TODO: Fix
|
||||
def getDownloadable(self, soup) -> list:
|
||||
# Make sure we only get the ones that have a link
|
||||
# We parse the cfg and check for the key "Downloads"
|
||||
# Check if downloads are available
|
||||
print(soup)
|
||||
cfg = soup.find('div', class_='cfg-container round')
|
||||
print(cfg)
|
||||
cfg = self.__parseCfgBlock(cfg)
|
||||
# Get the downloads
|
||||
downloads = cfg.get("Downloads", None)
|
||||
if downloads == None:
|
||||
return []
|
||||
# Get the links
|
||||
links = downloads.find_all('a')
|
||||
files = []
|
||||
for link in links:
|
||||
files.append(Base(link['href'], link.text, self.session, self))
|
||||
|
||||
return files
|
||||
|
||||
def getSubmissions(self):
|
||||
# We change the url where course becomes stats
|
||||
url = self.url.replace("course", "stats")
|
||||
r = self.session.get(url)
|
||||
|
||||
# Get each div with class "cfg-container round"
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
divs = soup.find_all('div', class_="cfg-container round")
|
||||
|
||||
# The first one is an overview, the next ones are the submissions
|
||||
submissions = []
|
||||
for div in divs[1:]:
|
||||
submissions.append(self.__parseCfgBlock(div))
|
||||
return self.__parseCfgBlock(divs[0]), submissions
|
||||
|
@ -1,45 +0,0 @@
|
||||
# Class to handle courses
|
||||
from bs4 import BeautifulSoup
|
||||
from requests import Session
|
||||
from ExerciseGroup import ExerciseGroup
|
||||
import re
|
||||
from Base import Base
|
||||
from exceptions.CourseUnavailable import CourseUnavailable
|
||||
|
||||
# PROBLEM: This implementation is bad due to inconsistencies in the website
|
||||
# The way we can tell the difference between an assignment and an exercise is by the presence of an a with the class "ass-submitable"
|
||||
# As opposed to folders which contain exercises which are marked with "ass-group"
|
||||
# Therefore, we should take that into consideration and spawn the corresponding Exercise or Assignment class
|
||||
# Naming becomes a bit inconsistent like that as well, as Assignments could be Exercises. Might opt to call the "assignments" "exerciseGroups" or some shit.
|
||||
|
||||
class Course(Base):
|
||||
# Extend the Base class init
|
||||
def __init__(self, url:str, name:str, session:Session, parent):
|
||||
super().__init__(url, name, session, parent)
|
||||
self.assignments = []
|
||||
self.__courseAvailable(self.session.get(self.url))
|
||||
|
||||
def __str__(self):
|
||||
return f"Course {self.name} in year {self.parent.year}"
|
||||
|
||||
def __courseAvailable(self, r):
|
||||
# Check if we got an error
|
||||
# print(self.url)
|
||||
if "Something went wrong" in r.text:
|
||||
raise CourseUnavailable()
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"year": self.parent.year,
|
||||
"url": self.url,
|
||||
"assignments": [x.name for x in self.assignments]
|
||||
}
|
||||
|
||||
def getExerciseGroups(self):
|
||||
r = self.session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
section = soup.find('div', class_="ass-children")
|
||||
entries = section.find_all('a', href=True)
|
||||
return [ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}", x.text, self.session, self) for x in entries]
|
@ -1,45 +0,0 @@
|
||||
# Since we can download files both from the assignment itself and its exercises, this class will handle both
|
||||
|
||||
from requests import Session
|
||||
from bs4 import BeautifulSoup
|
||||
from Base import Base
|
||||
|
||||
class Downloadable(Base):
|
||||
def __init__(self, name, session:Session, parent):
|
||||
self.name = name
|
||||
self.session = session
|
||||
self.parent = parent
|
||||
|
||||
# File handling
|
||||
def __findFile(self, name:str):
|
||||
# Get the file by name
|
||||
for file in self.files:
|
||||
if file.name == name:
|
||||
return file
|
||||
return None
|
||||
|
||||
@property
|
||||
def files(self) -> list:
|
||||
# Create a list of files
|
||||
# They are all links in a span with class "cfg-val"
|
||||
r = self.session.get("https://themis.housing.rug.nl" + self.parent.url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
return self.getDownloadable(soup)
|
||||
|
||||
def download(self, filename:str) -> str:
|
||||
# Download the file
|
||||
if filename == None:
|
||||
raise NameError("No filename provided")
|
||||
|
||||
file = self.__findFile(filename)
|
||||
r = self.session.get(file.url, stream=True)
|
||||
with open(file.name, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return file.name
|
||||
|
||||
def downloadAll(self) -> list[str]:
|
||||
# Download all files
|
||||
return [self.download(file.name) for file in self.files]
|
||||
|
@ -1,73 +0,0 @@
|
||||
from Base import Base
|
||||
from Downloadable import Downloadable
|
||||
from requests import Session
|
||||
|
||||
from time import sleep
|
||||
|
||||
|
||||
class Exercise(Base):
|
||||
def __init__(self, url:str, name:str, session:Session, parent):
|
||||
super().__init__()
|
||||
self.download = Downloadable(url, name, session, self)
|
||||
|
||||
def __str__(self):
|
||||
return f"Exercise {self.name} in assignment {self.parent.name}"
|
||||
|
||||
def getTests(self) -> list[str]:
|
||||
pass
|
||||
|
||||
def submit(self, file:str, comment:str) -> str:
|
||||
# Submit a file
|
||||
# The form is in the page with class "cfg-container round"
|
||||
# The form is a POST request to the url with the file and the comment
|
||||
# The url looks like this: https://themis.housing.rug.nl/submit/{year}/{course}/{assignment}/{exercise}?_csrf={session_csrf}&sudo={username}
|
||||
# The current url looks like: https://themis.housing.rug.nl/course/{year}/{course}/{assignment}/{exercise}
|
||||
# The request should contain the contents of the file
|
||||
|
||||
# Get the url
|
||||
url = self.url.replace("course", "submit")
|
||||
# Get the csrf token
|
||||
csrf = self.session.cookies['_csrf']
|
||||
# Get the username
|
||||
username = self.session.cookies['username']
|
||||
|
||||
# Open the file
|
||||
with open(file, 'rb') as f:
|
||||
# Submit the file
|
||||
# After submission it will 302 to the current submission page
|
||||
r = self.session.post(url, files={'file': f}, data={'comment': comment, '_csrf': csrf, 'sudo': username})
|
||||
|
||||
# Follow the redirect and repeatedly send get requests to the page
|
||||
|
||||
# We have a table which represents the test cases. The program should wait until all the test cases are done
|
||||
# The test case is done when all of the elements in the table are not none
|
||||
# The element which showcases this for each <tr class="sub-casetop">
|
||||
# is the class in there. if it is "queued" it is still running.
|
||||
|
||||
# Get the url
|
||||
url = r.url
|
||||
# Get the page
|
||||
r = self.session.get(url)
|
||||
# Get the soup
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
# Get the table
|
||||
table = soup.find('table')
|
||||
# Get the rows
|
||||
rows = table.find_all('tr', class_='sub-casetop')
|
||||
# Get the status
|
||||
status = [row.find('td', class_='status').text for row in rows]
|
||||
# Wait until all the status are not queued
|
||||
while "queued" in status:
|
||||
# Wait a bit
|
||||
sleep(1)
|
||||
# Get the page
|
||||
r = self.session.get(url)
|
||||
# Get the soup
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
# Get the table
|
||||
table = soup.find('table')
|
||||
# Get the rows
|
||||
rows = table.find_all('tr', class_='sub-casetop')
|
||||
|
||||
|
||||
pass
|
@ -1,39 +0,0 @@
|
||||
from Base import Base
|
||||
from bs4 import BeautifulSoup\
|
||||
|
||||
class ExerciseGroup(Base):
|
||||
# I can't tell if I'm already an exercise :C
|
||||
|
||||
def __init__(self, url:str, name:str, session, parent):
|
||||
super().__init__(url, name, session, parent)
|
||||
self.exercises = self.getExercises()
|
||||
self.folders = self.getFolders()
|
||||
|
||||
def __str__(self):
|
||||
return f"ExerciseGroup {self.name} in course {self.parent.name}"
|
||||
|
||||
def getExercises(self) -> list:
|
||||
r = self.session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
section = soup.find('div', class_="ass-children")
|
||||
try:
|
||||
submittables = section.find_all('a', class_="ass-submitable")
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
return submittables
|
||||
|
||||
# Returns a list of names of the folders
|
||||
def getFolders(self) -> list:
|
||||
r = self.session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
section = soup.find('div', class_="ass-children")
|
||||
try:
|
||||
folders = section.find_all('a', class_="ass-group")
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
return [x.text for x in folders]
|
||||
|
||||
def recurse(self, folder:str):
|
||||
print(self.url)
|
@ -1,62 +0,0 @@
|
||||
from Year import Year
|
||||
import urllib3
|
||||
from requests import Session
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Disable warnings
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
class Themis:
|
||||
def __init__(self,user:str, passwd:str):
|
||||
self.session = self.login(user,passwd)
|
||||
self.years = []
|
||||
self.url = "https://themis.housing.rug.nl/course/"
|
||||
|
||||
def login(self, user, passwd):
|
||||
headers = {
|
||||
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chromium/80.0.3987.160 Chrome/80.0.3987.163 Safari/537.36"
|
||||
}
|
||||
|
||||
data = {
|
||||
"user": user,
|
||||
"password":passwd,
|
||||
"null": None
|
||||
}
|
||||
|
||||
with Session() as s:
|
||||
url = 'https://themis.housing.rug.nl/log/in'
|
||||
r = s.get(url,headers=headers,verify=False)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
|
||||
# get the csrf token and add it to payload
|
||||
csrfToken = soup.find('input',attrs = {'name':'_csrf'})['value']
|
||||
data['_csrf'] = csrfToken
|
||||
|
||||
# Login
|
||||
r = s.post(url,data=data,headers = headers)
|
||||
|
||||
# check if login was successful
|
||||
log_out = "Welcome, logged in as" in r.text
|
||||
if not log_out:
|
||||
raise Exception(f"Login for user {user} failed")
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def getYear(self, start:int, end:int):
|
||||
# Get the current year
|
||||
return Year(self.session, self, start, end)
|
||||
|
||||
def allYears(self):
|
||||
# All of them are in a big ul at the beginning of the page
|
||||
r = self.session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
ul = soup.find('ul', class_='round')
|
||||
lis = ul.find_all('li', class_='large')
|
||||
years = []
|
||||
for li in lis:
|
||||
# format: 2019-2020
|
||||
year = li.a.text.split("-")
|
||||
years.append(Year(self.session, self, int(year[0]), int(year[1])))
|
||||
|
||||
return years # Return a list of year objects
|
55
src/Year.py
55
src/Year.py
@ -1,55 +0,0 @@
|
||||
# Year class to represent an academic year
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from Course import Course
|
||||
from requests import Session
|
||||
from exceptions.CourseUnavailable import CourseUnavailable
|
||||
|
||||
# Works
|
||||
class Year:
|
||||
def __init__(self, session:Session, parent, start_year:int, end_year:int):
|
||||
self.start = start_year
|
||||
self.year = end_year
|
||||
self.session = session
|
||||
self.url = self.__constructUrl()
|
||||
|
||||
# Method to set the url
|
||||
def __constructUrl(self):
|
||||
return f"https://themis.housing.rug.nl/course/{self.start}-{self.year}"
|
||||
|
||||
# Method to get the courses of the year
|
||||
def getCourses(self, errors:bool=False) -> list[Course]:
|
||||
# lis in a big ul
|
||||
r = self.session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
lis = soup.find_all('li', class_='large')
|
||||
courses = []
|
||||
for li in lis:
|
||||
try:
|
||||
suffix = (li.a['href'].replace(f"course/{self.start}-{self.year}", ""))
|
||||
courses.append(
|
||||
Course(
|
||||
self.url + suffix,
|
||||
li.a.text,
|
||||
self.session,
|
||||
self
|
||||
)
|
||||
)
|
||||
except CourseUnavailable:
|
||||
if errors:
|
||||
raise CourseUnavailable(f"Course {li.a.text} in year {self.start}-{self.year} is not available")
|
||||
else:
|
||||
print("error with course", li.a.text)
|
||||
continue
|
||||
|
||||
|
||||
return courses
|
||||
|
||||
def getCourse(self, name:str) -> Course:
|
||||
# Get the course
|
||||
r = self.session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, 'lxml')
|
||||
# Search by name
|
||||
course = self.url + soup.find('a', text=name)['href'].replace(f"course/{self.start}-{self.year}", "")
|
||||
# Get the url and transform it into a course object
|
||||
return Course(url=course, name=name, session=self.session, parent=self)
|
@ -1,4 +0,0 @@
|
||||
class CourseUnavailable(Exception):
|
||||
def __init__(self, message:str="Error in course"):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
1
temmies/__init__.py
Normal file
1
temmies/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .themis import Themis
|
72
temmies/course.py
Normal file
72
temmies/course.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""
|
||||
Houses the Course class which is used to represent a course in a year.
|
||||
"""
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from requests import Session
|
||||
|
||||
from .exercise_group import ExerciseGroup
|
||||
from .exceptions.course_unavailable import CourseUnavailable
|
||||
from .exceptions.illegal_action import IllegalAction
|
||||
|
||||
|
||||
class Course:
|
||||
"""
|
||||
get_groups: Get all groups in a course. Set full to True to get all subgroups.
|
||||
get_group: Get a group by name. Set full to True to get all subgroups.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, name: str, session: Session, parent):
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.__session = session
|
||||
self.__parent = parent
|
||||
self.__request = self.__session.get(self.url)
|
||||
self.__raw = BeautifulSoup(self.__request.text, "lxml")
|
||||
|
||||
self.__course_available(self.__session.get(self.url))
|
||||
|
||||
def __str__(self):
|
||||
return f"Course {self.name} in year {self.__parent.year}"
|
||||
|
||||
def __course_available(self, r):
|
||||
# Check if we got an error
|
||||
# print(self.url)
|
||||
if "Something went wrong" in r.text:
|
||||
raise CourseUnavailable(
|
||||
message="'Something went wrong'. Course most likely not found. "
|
||||
)
|
||||
|
||||
def get_groups(self, full: bool = False) -> list[ExerciseGroup]:
|
||||
"""
|
||||
get_groups(full: bool = False) -> list[ExerciseGroup]
|
||||
Get all groups in a course. Set full to True to get all subgroups.
|
||||
"""
|
||||
section = self.__raw.find("div", class_="ass-children")
|
||||
entries = section.find_all("a", href=True)
|
||||
return [
|
||||
ExerciseGroup(
|
||||
f"https://themis.housing.rug.nl{x['href']}",
|
||||
x,
|
||||
self.__session,
|
||||
full
|
||||
)
|
||||
for x in entries
|
||||
]
|
||||
|
||||
# BAD: Repeated code!!!!
|
||||
def get_group(self, name: str, full: bool = False) -> ExerciseGroup:
|
||||
"""
|
||||
get_group(name:str, full:bool = False) -> ExerciseGroup
|
||||
Get a single group by name. Set full to True to get all subgroups as well.
|
||||
"""
|
||||
group = self.__raw.find("a", text=name)
|
||||
if not group:
|
||||
raise IllegalAction(message=f"No such group found: {name}")
|
||||
|
||||
return ExerciseGroup(
|
||||
f"https://themis.housing.rug.nl{group['href']}",
|
||||
group,
|
||||
self.__session,
|
||||
full
|
||||
)
|
0
temmies/exceptions/__init__.py
Normal file
0
temmies/exceptions/__init__.py
Normal file
6
temmies/exceptions/course_unavailable.py
Normal file
6
temmies/exceptions/course_unavailable.py
Normal file
@ -0,0 +1,6 @@
|
||||
""" This module contains the CourseUnavailable exception. """
|
||||
|
||||
class CourseUnavailable(Exception):
|
||||
"""CourseUnavailable Exception"""
|
||||
def __init__(self, message: str = ""):
|
||||
super().__init__(f"Course unavailable: {message}")
|
8
temmies/exceptions/illegal_action.py
Normal file
8
temmies/exceptions/illegal_action.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""
|
||||
Illegal Action Exception
|
||||
"""
|
||||
|
||||
class IllegalAction(Exception):
|
||||
"""Illegal Action Exception"""
|
||||
def __init__(self, message: str = ""):
|
||||
super().__init__(f"Illegal action: {message}")
|
347
temmies/exercise_group.py
Normal file
347
temmies/exercise_group.py
Normal file
@ -0,0 +1,347 @@
|
||||
"""
|
||||
Houses the ExerciseGroup class.
|
||||
Represents a group of exercises or a single exercise.
|
||||
|
||||
"""
|
||||
|
||||
from json import loads
|
||||
from time import sleep
|
||||
from bs4 import BeautifulSoup
|
||||
from .exceptions.illegal_action import IllegalAction
|
||||
from .submission import Submission
|
||||
|
||||
class ExerciseGroup:
|
||||
"""
|
||||
Methods:
|
||||
`submit`: submit to an exercise
|
||||
`get_group`: get a group by name
|
||||
`download_tcs`: download test cases
|
||||
`download_files`: download files
|
||||
|
||||
`find_status`: get status for an exercise by name
|
||||
`get_all_statuses`: get all available statuses(useful for multiple exercises)
|
||||
`get_status(idx=0)`: get the available statuses for the exercise. Set the idx if you want to get a specific submission.
|
||||
Attributes:
|
||||
|
||||
`am_exercise`: returns bool which tells you if the instance is an exercise
|
||||
`folders`: folders in the folder
|
||||
`exercises`: exercises in the folder
|
||||
`test_cases`: test cases in the exercise(if it is an exercise)
|
||||
`files`: files in the exercise/folder
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, soup:BeautifulSoup, session, full: bool):
|
||||
self.url = url
|
||||
self.name = soup.text
|
||||
self.__prev_raw = soup
|
||||
self.__session = session
|
||||
self.__request = self.__session.get(self.url)
|
||||
self.__raw = BeautifulSoup(self.__request.text, "lxml")
|
||||
self.__full = full
|
||||
|
||||
@property
|
||||
def am_exercise(self) -> bool:
|
||||
return "ass-submitable" in self.__prev_raw["class"]
|
||||
|
||||
# Test cases
|
||||
@property
|
||||
def test_cases(self) -> list[str]:
|
||||
section = self.__raw.find_all("div", class_="subsec round shade")
|
||||
tcs = []
|
||||
for div in section:
|
||||
res = div.find("h4", class_="info")
|
||||
if not res:
|
||||
continue
|
||||
|
||||
if "Test cases" in res.text:
|
||||
for case in div.find_all("div", class_="cfg-line"):
|
||||
if link := case.find("a"):
|
||||
tcs.append(link)
|
||||
return tcs
|
||||
|
||||
def download_tcs(self, path=".") -> list[str]:
|
||||
"""
|
||||
download_tcs(path=".") -> list[str]
|
||||
Downloads every test case available from a given exercise. `path` defaults to '.'.
|
||||
"""
|
||||
if not self.am_exercise:
|
||||
raise IllegalAction(message="You are downloading test cases from a folder.")
|
||||
|
||||
for tc in self.test_cases:
|
||||
url = f"https://themis.housing.rug.nl{tc['href']}"
|
||||
|
||||
print(f"Downloading {tc.text}")
|
||||
# download the files
|
||||
with open(f"{path}/{tc.text}", "wb") as f:
|
||||
f.write(self.__session.get(url).content)
|
||||
|
||||
return self.test_cases
|
||||
|
||||
# Files
|
||||
@property
|
||||
def files(self) -> list[str]:
|
||||
details = self.__raw.find("div", id=lambda x: x and x.startswith("details"))
|
||||
|
||||
cfg_lines = details.find_all("div", class_="cfg-line")
|
||||
|
||||
link_list = []
|
||||
|
||||
for line in cfg_lines:
|
||||
key = line.find("span", class_="cfg-key")
|
||||
|
||||
if key and "Downloads" in key.text.strip():
|
||||
# Extract all links in the cfg-val span
|
||||
links = line.find_all("span", class_="cfg-val")
|
||||
for link in links:
|
||||
a = link.find_all("a")
|
||||
for i in a:
|
||||
link_list.append(i)
|
||||
|
||||
return link_list
|
||||
|
||||
def download_files(self, path=".") -> list[str]:
|
||||
"""
|
||||
download_files(path=".") -> list[str]
|
||||
Downloads every file available from a given exercise/folder. `path` defaults to '.'.
|
||||
"""
|
||||
for file in self.files:
|
||||
print(f"Downloading file {file.text}")
|
||||
url = f"https://themis.housing.rug.nl{file['href']}"
|
||||
with open(f"{path}/{file.text}", "wb") as f:
|
||||
f.write(self.__session.get(url).content)
|
||||
return self.files
|
||||
|
||||
@property
|
||||
def exercises(self) -> list[str] | list["ExerciseGroup"]:
|
||||
if self.am_exercise:
|
||||
return self
|
||||
|
||||
section = self.__raw.find("div", class_="ass-children")
|
||||
try:
|
||||
submittables = section.find_all("a", class_="ass-submitable")
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
if not self.__full:
|
||||
return [(x.text, x["href"]) for x in submittables]
|
||||
return [
|
||||
ExerciseGroup(
|
||||
f"https://themis.housing.rug.nl{x['href']}", x, self.__session, True
|
||||
)
|
||||
for x in submittables
|
||||
]
|
||||
|
||||
@property
|
||||
def folders(self) -> list[str] | list["ExerciseGroup"]:
|
||||
section = self.__raw.find("div", class_="ass-children")
|
||||
try:
|
||||
folders = section.find_all("a", class_="ass-group")
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
if not self.__full:
|
||||
return [(x.text, x["href"]) for x in folders]
|
||||
|
||||
return [
|
||||
ExerciseGroup(
|
||||
f"https://themis.housing.rug.nl{x['href']}", x, self.__session, True
|
||||
)
|
||||
for x in folders
|
||||
]
|
||||
|
||||
# Get by name
|
||||
def get_group( # <- 🗿
|
||||
self, name: str, full: bool = False, link: str = None
|
||||
) -> "ExerciseGroup":
|
||||
"""
|
||||
get_group(name:str, full:bool=False, link:str=None) -> ExerciseGroup | list[ExerciseGroup]
|
||||
Get a single group by name.
|
||||
Set `full` to True to get all subgroups as well.
|
||||
Set `link` to directly fetch a group.
|
||||
"""
|
||||
if link:
|
||||
return ExerciseGroup(link, self.__prev_raw, self.__session, full)
|
||||
|
||||
group = self.__raw.find("a", text=name)
|
||||
if not group:
|
||||
raise IllegalAction(message=f"No such group found: {name}")
|
||||
|
||||
return ExerciseGroup(
|
||||
f"https://themis.housing.rug.nl{group['href']}", group, self.__session, full
|
||||
)
|
||||
|
||||
# Wait for result
|
||||
def __wait_for_result(self, url: str, verbose: bool, __printed: list) -> None:
|
||||
# This waits for result and returns a bundled info package
|
||||
r = self.__session.get(url)
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
return self.__parse_table(soup, url, verbose, __printed)
|
||||
|
||||
# Account for judge
|
||||
def __race_condition(self, url: str, verbose: bool) -> None:
|
||||
self.__session.get(url.replace("submission", "judge"))
|
||||
return self.__wait_for_result(url, verbose, [])
|
||||
|
||||
def __parse_table(
|
||||
self, soup: BeautifulSoup, url: str, verbose: bool, __printed: list
|
||||
) -> dict:
|
||||
cases = soup.find_all("tr", class_="sub-casetop")
|
||||
fail_pass = {}
|
||||
i = 1
|
||||
for case in cases:
|
||||
name = case.find("td", class_="sub-casename").text
|
||||
status = case.find("td", class_="status-icon")
|
||||
|
||||
if "pending" in status.get("class"):
|
||||
return self.__race_condition(url, verbose)
|
||||
|
||||
# queued status-icon
|
||||
if "queued" in status.get("class"):
|
||||
sleep(1) # <- 🗿
|
||||
return self.__wait_for_result(url, verbose, __printed)
|
||||
|
||||
statuses = {
|
||||
"Passed": ("✅", True),
|
||||
"Wrong output": ("❌", False),
|
||||
"No status": ("🐛", None),
|
||||
"error": ("🐛", None),
|
||||
}
|
||||
|
||||
# Printing and storing
|
||||
found = False
|
||||
for k, v in statuses.items():
|
||||
if k in status.text:
|
||||
found = True
|
||||
if verbose and int(name) not in __printed:
|
||||
print(f"{name}: {v[0]}")
|
||||
fail_pass[int(name)] = v[1]
|
||||
break
|
||||
if not found:
|
||||
fail_pass[int(name)] = None
|
||||
if verbose and int(name) not in __printed:
|
||||
print(f"{name}: Unrecognized status: {status.text}")
|
||||
|
||||
__printed.append(int(name))
|
||||
i += 1
|
||||
return fail_pass
|
||||
|
||||
# Submit
|
||||
def submit(
|
||||
self, files: list, judge: bool = True, wait: bool = True, silent: bool = True
|
||||
) -> dict | None:
|
||||
"""
|
||||
submit(files:list, judge:bool=True, wait:bool=True, silent:bool=True) -> dict | None
|
||||
Submits given files to given exercise. Returns a dictionary of test cases and their status.
|
||||
Set judge to False to not judge the submission.
|
||||
Set wait to False to not wait for the result.
|
||||
Set silent to False to print the results.
|
||||
"""
|
||||
form = self.__raw.find("form")
|
||||
if not form:
|
||||
raise IllegalAction(message="You cannot submit to this assignment.")
|
||||
|
||||
url = "https://themis.housing.rug.nl" + form["action"]
|
||||
file_types = loads(form["data-suffixes"])
|
||||
if isinstance(files, str):
|
||||
temp = []
|
||||
temp.append(files)
|
||||
files = temp
|
||||
|
||||
packaged_files = []
|
||||
data = {}
|
||||
found_type = ""
|
||||
for file in files:
|
||||
for t in file_types:
|
||||
if t in file:
|
||||
found_type = file_types[t]
|
||||
break
|
||||
if not found_type:
|
||||
print("WARNING: File type not recognized")
|
||||
|
||||
with open(file, "rb") as f:
|
||||
packaged_files.append((found_type, (file, f.read())))
|
||||
|
||||
data = {"judgenow": "true" if judge else "false", "judgeLanguage": found_type if found_type else "none"}
|
||||
|
||||
if not silent:
|
||||
print(f"Submitting to {self.name}")
|
||||
for file in files:
|
||||
print(f"• {file}")
|
||||
resp = self.__session.post(url, files=packaged_files, data=data)
|
||||
|
||||
if not wait or not judge:
|
||||
return resp.url if "@submissions" in resp.url else None
|
||||
|
||||
return self.__wait_for_result(resp.url, not silent, [])
|
||||
|
||||
def __status_sections(self) -> list[BeautifulSoup]:
|
||||
r = self.__session.get("https://themis.housing.rug.nl" + self.__raw.find("a", text="Status")["href"])
|
||||
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
sections = soup.find_all('section', class_=lambda class_: class_ and 'status' in class_.split())
|
||||
|
||||
return sections
|
||||
|
||||
def __parse_section(self, section:BeautifulSoup, text) -> dict[str, Submission] | dict[str, str]:
|
||||
# The section has a heading and a body. We only care about the body
|
||||
body = section.find("div", class_="sec-body") # Find the body of the section
|
||||
body = body.find("div", class_="subsec-container") # Find the subsec-container
|
||||
body = body.find("div", class_="cfg-container")
|
||||
|
||||
# Parse the cfg-container
|
||||
parsed = {}
|
||||
|
||||
# Submission instances go here
|
||||
submissions = {}
|
||||
|
||||
cfg_lines = body.find_all("div", class_="cfg-line")
|
||||
for line in cfg_lines:
|
||||
key = line.find("span", class_="cfg-key").text.strip().split("\n")[0].replace(":", "").lower()
|
||||
value = line.find("span", class_="cfg-val").text.strip()
|
||||
|
||||
# If there is a span with class tip in the key, it means that the value is a link to a submission
|
||||
if tip := line.find("span", class_="tip"):
|
||||
value = line.find("a")["href"]
|
||||
if not text:
|
||||
submissions[key.split("\n")[0].lower().replace(" ", "_")] = Submission(value, self.__session)
|
||||
parsed[key] = value
|
||||
|
||||
if text:
|
||||
return parsed
|
||||
|
||||
return (parsed, submissions)
|
||||
|
||||
# I assume that the user would usually request submissions for an assignment,
|
||||
# so I will add a default parameter to the method.
|
||||
|
||||
def get_status(self, section:list[BeautifulSoup]=None, text:bool=False) -> dict[str, Submission] | dict[str, str]:
|
||||
"""Get the available submissions for the exercise.
|
||||
Set text to True to get the text representation of the submission."""
|
||||
if not section:
|
||||
section = self.__status_sections()
|
||||
|
||||
try:
|
||||
section = section[0] # When looking at a single exercise, there is only one status section
|
||||
except IndexError as exc:
|
||||
raise IllegalAction("Invalid status") from exc
|
||||
|
||||
return self.__parse_section(section, text)
|
||||
|
||||
def get_all_statuses(self, text:bool=False) -> list[dict[str, str]] | list[dict[str, Submission]]:
|
||||
""" Parses every visible status section. """
|
||||
|
||||
# This is useless for singular exercises, but if you want the submissions for multiple exercises, you can use this.
|
||||
statuses = []
|
||||
for section in self.__status_sections():
|
||||
if parse := self.__parse_section(section, text):
|
||||
# Find name of the exercise
|
||||
name = section.find("h3").text.replace("Status: ", "").replace("\n", "").replace("\t", "")
|
||||
statuses.append((name,parse))
|
||||
return statuses
|
||||
|
||||
def find_status(self, name:str, text:bool=False) -> dict[str, Submission] | dict[str, str] | None:
|
||||
""" Find a status block for an exercise by name. """
|
||||
# Find a section which has h3 with the name
|
||||
for section in self.__status_sections():
|
||||
if section.find("h3").text.replace("Status: ", "").replace("\n", "").replace("\t", "") == name:
|
||||
return self.__parse_section(section, text)
|
84
temmies/submission.py
Normal file
84
temmies/submission.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""
|
||||
File to define the submission class
|
||||
"""
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
class Submission:
|
||||
"""
|
||||
Submission class
|
||||
|
||||
Methods:
|
||||
test_cases: Get a dict of test cases status
|
||||
info: Submission information (in details)
|
||||
files: Get a list of uploaded files(as names)
|
||||
"""
|
||||
def __init__(self, url: str, session):
|
||||
self.url = "https://themis.housing.rug.nl" + url
|
||||
self.__session = session
|
||||
self.__request = self.__session.get(self.url)
|
||||
self.__raw = BeautifulSoup(self.__request.text, "lxml")
|
||||
self.__info = None
|
||||
|
||||
def __clean(self, text: str, value: bool = False) -> str:
|
||||
"""Clean text"""
|
||||
clean = text.replace("\t", "").replace("\n", "")
|
||||
if value:
|
||||
return clean
|
||||
return clean.replace(" ", "_").replace(":", "").lower()
|
||||
|
||||
def test_cases(self) -> dict[str, str]:
|
||||
"""Get a dict of test cases status"""
|
||||
# In the submission page, the test cases are in a div with class "sub-cases subsec round shade"
|
||||
# print(self.__raw.prettify())
|
||||
cases = self.__raw.find("div", class_=lambda x: x and "sub-cases" in x.split())
|
||||
if not cases:
|
||||
return {}
|
||||
|
||||
# The test cases are in a table in a div with class "cfg-container"
|
||||
cases = cases.find("div", class_="cfg-container")
|
||||
cases = cases.find("table")
|
||||
# For each test case, there is a tr with class sub-casetop, which contains 2 tds:
|
||||
# * a td with class "sub-case name" which is a name
|
||||
# * a td with a variable class, which is the status text
|
||||
|
||||
results = {}
|
||||
for entry in cases.find_all("tr", class_="sub-casetop"):
|
||||
name = entry.find("td", class_="sub-casename").text
|
||||
status = entry.find(
|
||||
"td", class_=lambda x: x and "status-icon" in x.split()
|
||||
).text
|
||||
results[name] = self.__clean(status)
|
||||
|
||||
return results
|
||||
|
||||
def info(self) -> dict[str, str] | None:
|
||||
"""Submission information (in details)"""
|
||||
# in div with class subsec round shade where there is an h4 with class info
|
||||
# The info is in a div with class "cfg-container"
|
||||
if self.__info:
|
||||
return self.__info
|
||||
|
||||
for div in self.__raw.find_all("div", class_="subsec round shade"):
|
||||
if h4 := div.find("h4", class_=lambda x: x and "info" in x.split()):
|
||||
if "Details" in h4.text:
|
||||
# The information is in divs with class "cfg-line"
|
||||
# With key in span with class "cfg-key" and value in span with class "cfg-value"
|
||||
info = div.find("div", class_="cfg-container")
|
||||
info = info.find_all("div", class_="cfg-line")
|
||||
return {
|
||||
self.__clean(
|
||||
key := line.find("span", class_="cfg-key").text
|
||||
):
|
||||
self.__clean(line.find("span", class_="cfg-val").text, value=True) if "Files" not in key else
|
||||
([(self.__clean(x.text), x["href"]) for x in line.find("span", class_="cfg-val").find_all("a")])
|
||||
for line in info
|
||||
}
|
||||
return None
|
||||
|
||||
def files(self) -> list[str] | None:
|
||||
"""Get a list of uploaded files in the format [(name, url)]"""
|
||||
if not self.__info:
|
||||
self.__info = self.info()
|
||||
|
||||
return self.__info.get("files", None)
|
90
temmies/themis.py
Normal file
90
temmies/themis.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""
|
||||
Main class for the Themis API
|
||||
|
||||
"""
|
||||
|
||||
import urllib3
|
||||
from requests import Session
|
||||
from bs4 import BeautifulSoup
|
||||
from .year import Year
|
||||
from .exceptions.illegal_action import IllegalAction
|
||||
|
||||
|
||||
# Disable warnings
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
class Themis:
|
||||
"""
|
||||
login: Login to Themis
|
||||
get_year: Get a year object
|
||||
all_years: Get all years
|
||||
"""
|
||||
|
||||
def __init__(self, user: str, passwd: str):
|
||||
self.session = self.login(user, passwd)
|
||||
self.years = []
|
||||
self.url = "https://themis.housing.rug.nl/course/"
|
||||
|
||||
def login(self, user: str, passwd: str) -> Session:
|
||||
"""
|
||||
login(self, user: str, passwd: str) -> Session
|
||||
Login to Themis
|
||||
Set user to your student number and passwd to your password
|
||||
"""
|
||||
|
||||
user_agent = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chromium/80.0.3987.160 Chrome/80.0.3987.163 Safari/537.36"
|
||||
)
|
||||
|
||||
headers = {"user-agent": user_agent}
|
||||
|
||||
data = {"user": user, "password": passwd, "null": None}
|
||||
|
||||
with Session() as s:
|
||||
url = "https://themis.housing.rug.nl/log/in"
|
||||
r = s.get(url, headers=headers, verify=False)
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
|
||||
# get the csrf token and add it to payload
|
||||
csrf_token = soup.find("input", attrs={"name": "_csrf"})["value"]
|
||||
data["_csrf"] = csrf_token
|
||||
data["sudo"] = user.lower()
|
||||
|
||||
# Login
|
||||
r = s.post(url, data=data, headers=headers)
|
||||
|
||||
# check if login was successful
|
||||
log_out = "Welcome, logged in as" in r.text
|
||||
if not log_out:
|
||||
raise IllegalAction(message=f"Login for user {user} failed")
|
||||
|
||||
return s
|
||||
|
||||
def get_year(self, start: int, end: int) -> Year:
|
||||
"""
|
||||
get_year(self, start: int, end: int) -> Year
|
||||
Gets a year object
|
||||
Set start to the start year and end to the end year (e.g. 2023-2024)
|
||||
"""
|
||||
return Year(self.session, start, end)
|
||||
|
||||
def all_years(self) -> list[Year]:
|
||||
"""
|
||||
get_years(self, start: int, end: int) -> list[Year]
|
||||
Gets all visible years
|
||||
"""
|
||||
# All of them are in a big ul at the beginning of the page
|
||||
r = self.session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
ul = soup.find("ul", class_="round")
|
||||
lis = ul.find_all("li", class_="large")
|
||||
years = []
|
||||
for li in lis:
|
||||
# format: 2019-2020
|
||||
year = li.a.text.split("-")
|
||||
years.append(Year(self.session, int(year[0]), int(year[1])))
|
||||
|
||||
return years # Return a list of year objects
|
66
temmies/year.py
Normal file
66
temmies/year.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""
|
||||
Class which represents an academic year.
|
||||
"""
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from requests import Session
|
||||
|
||||
from .course import Course
|
||||
from .exceptions.course_unavailable import CourseUnavailable
|
||||
|
||||
|
||||
# Works
|
||||
class Year:
|
||||
"""
|
||||
all_courses: Get all visible courses in a year
|
||||
get_course: Get a course by name
|
||||
"""
|
||||
|
||||
def __init__(self, session: Session, start_year: int, end_year: int):
|
||||
self.start = start_year
|
||||
self.year = end_year
|
||||
self.url = f"https://themis.housing.rug.nl/course/{self.start}-{self.year}"
|
||||
self.__session = session
|
||||
|
||||
# Method to get the courses of the year
|
||||
def all_courses(self, errors: bool = True) -> list[Course]:
|
||||
"""
|
||||
all_courses(self, errors: bool = False) -> list[Course]
|
||||
Gets all visible courses in a year.
|
||||
Set errors to False to not raise an error when a course is unavailable.
|
||||
"""
|
||||
r = self.__session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
lis = soup.find_all("li", class_="large")
|
||||
courses = []
|
||||
for li in lis:
|
||||
try:
|
||||
suffix = li.a["href"].replace(f"course/{self.start}-{self.year}", "")
|
||||
courses.append(
|
||||
Course(self.url + suffix, li.a.text, self.__session, self)
|
||||
)
|
||||
except CourseUnavailable as exc:
|
||||
if errors:
|
||||
raise CourseUnavailable(
|
||||
message=f"Course {li.a.text} in year {self.start}-{self.year} unavailable"
|
||||
) from exc
|
||||
|
||||
print("Error with course", li.a.text)
|
||||
continue
|
||||
|
||||
return courses
|
||||
|
||||
def get_course(self, name: str) -> Course:
|
||||
"""
|
||||
get_course(self, name: str) -> Course
|
||||
Gets a course by name.
|
||||
"""
|
||||
# Get the course
|
||||
r = self.__session.get(self.url)
|
||||
soup = BeautifulSoup(r.text, "lxml")
|
||||
# Search by name
|
||||
course = self.url + soup.find("a", text=name)["href"].replace(
|
||||
f"course/{self.start}-{self.year}", ""
|
||||
)
|
||||
# Get the url and transform it into a course object
|
||||
return Course(url=course, name=name, session=self.__session, parent=self)
|
Reference in New Issue
Block a user