Compare commits

...

6 Commits

Author SHA1 Message Date
b819305704 Update README to show temmie again. 2024-04-06 16:34:31 +02:00
1016e56e70 I messed up the license lol. 2024-04-06 16:24:45 +02:00
b1eface45d Updated docs 2024-04-06 16:22:32 +02:00
b8a6e05ea0 Changed RTD config 2024-04-06 16:17:52 +02:00
c0056a27d1 Migrated to mkdocs for documentation. Slight clean up of code. 2024-04-06 16:16:23 +02:00
cff77bcc95 Working. Testing out doc writing. 2024-04-06 15:38:52 +02:00
23 changed files with 338 additions and 367 deletions

5
.gitignore vendored
View File

@ -1,7 +1,10 @@
# Config # Config - Testing
config.py config.py
baller.py baller.py
#Doc env
.docs_env
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@ -3,12 +3,10 @@ version: 2
build: build:
os: ubuntu-22.04 os: ubuntu-22.04
tools: tools:
python: "3.12" python: "3.8"
sphinx:
configuration: docs/conf.py
python: mkdocs:
install: configuration: mkdocs.yml
- requirements: docs/requirements.txt

View File

@ -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)

View File

@ -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)

View File

@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="images/rugemmie.gif" /> <img src="docs/img/rugemmie.gif" />
</p> </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. Uses bs4. I'll try to end development on a somewhat working state.

10
docs/about.md Normal file
View 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.

141
docs/api.md Normal file
View File

@ -0,0 +1,141 @@
# 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.
#### `getYear(start, end)`
Returns an instance of a [`Year`](#year)(academic year) between `start` and `end`.
```python
year = themis.getYear(2023, 2024)
```
#### `allYears()`
Returns a list of `Year` instances corresponding to all years visible to the user.
```python
years = themis.allYears()
```
<sub> I don't see why you would need this, but it's here. </sub>
----
## `Year`
### Usage
```python
year = themis.getYear(2023, 2024)
```
### Methods
#### `getCourse(courseName)`
Returns an instance of a [`Course`](#course) with the name `courseName`.
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
```
#### `allCourses()`
Returns a list of `Course` instances corresponding to all courses visible to the user in a given `Year`.
```python
courses = year.allCourses()
```
----
## `Course`
### Usage
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
print(pf.info) # <- course info attribute
assignments = pf.getExerciseGroups()
```
### Methods
#### `getExerciseGroups()`
Returns a list of `ExerciseGroup` instances corresponding to all exercise groups visible to the user in a given `Course`.
```python
assignments = pf.getExerciseGroups()
```
## `ExerciseGroup`
When this class is initialized, it will automatically fetch the exercise's info, files and test cases(it might be slow, because it indexes the entire course, which I will fix at some point).
* Both folders and exercises are represented as `ExerciseGroup` instances.
* Folders will have the `amExercise` attribute set to `False`.
* Folders can have the `downloadFiles` method called on them.
* Exercises can have the `submit`, `downloadFiles` and `downloadTCs` method called on them.
### Usage
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
assignments = pf.getExerciseGroups()
assignment = assignments[0]
print(assignment.amExercise) # <- Exercise or folder attribute
print(assignment.files) # <- Downloadable files attribute
print(assignment.testCases) # <- Test cases attribute
print(assignment.folders) # <- If the group contains folders, they will be here
print(assignment.exercises) # <- If the group contains exercises, they will be here
```
### 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.getCourse("Programming Fundamentals (for CS)")
assignments = pf.getExerciseGroups()
week1 = assignments[0].folders[0]
exercise2 = week1.exercises[1]
part2 = exercise2.folders[1]
```
### Methods
#### `downloadFiles(path=".")`
Downloads all files in the exercise group to a directory `path`. Defaults to the current directory.
```python
assignment.downloadFiles()
```
#### `downloadTCs(path=".")`
Downloads all test cases in the exercise group to a directory `path`. Defaults to the current directory.
```python
assignment.downloadTCs()
```
#### `submit(files)`
Submits the files to the exercise group. (This is not implemented yet)
```python
assignment.submit(["file1.py", "file2.py"])
```

View File

@ -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']

View File

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 190 KiB

40
docs/index.md Normal file
View File

@ -0,0 +1,40 @@
# Temmies!
<center>![Temmie](img/rugemmie.gif)</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
import temmies
# Log in
themis = temmies.Themis("s-number", "password")
# Get a year
year = themis.getYear(2023, 2024)
# Get a course
pf = year.getCourse("Programming Fundamentals (for CS)")
# Get an assignment
assignment = pf.getExerciseGroups()
# Download the files
assignment.downloadFiles()
```

View File

@ -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`

View File

@ -1 +0,0 @@
sphinx-rtd-theme==1.3.0

6
mkdocs.yml Normal file
View File

@ -0,0 +1,6 @@
site_name: Temmies
nav:
- Temmies: index.md
- API Reference: api.md
- About: about.md
theme: readthedocs

View File

@ -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)

View File

@ -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

View File

@ -3,19 +3,15 @@ from bs4 import BeautifulSoup
from requests import Session from requests import Session
from ExerciseGroup import ExerciseGroup from ExerciseGroup import ExerciseGroup
import re import re
from Base import Base
from exceptions.CourseUnavailable import CourseUnavailable from exceptions.CourseUnavailable import CourseUnavailable
# PROBLEM: This implementation is bad due to inconsistencies in the website class Course:
# 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 # Extend the Base class init
def __init__(self, url:str, name:str, session:Session, parent): def __init__(self, url:str, name:str, session:Session, parent):
super().__init__(url, name, session, parent) self.url = url
self.name = name
self.session = session
self.parent = parent
self.assignments = [] self.assignments = []
self.__courseAvailable(self.session.get(self.url)) self.__courseAvailable(self.session.get(self.url))
@ -26,7 +22,7 @@ class Course(Base):
# Check if we got an error # Check if we got an error
# print(self.url) # print(self.url)
if "Something went wrong" in r.text: if "Something went wrong" in r.text:
raise CourseUnavailable() raise CourseUnavailable(message="'Something went wrong'. Course most likely not found. ")
@property @property
def info(self): def info(self):
@ -42,4 +38,11 @@ class Course(Base):
soup = BeautifulSoup(r.text, 'lxml') soup = BeautifulSoup(r.text, 'lxml')
section = soup.find('div', class_="ass-children") section = soup.find('div', class_="ass-children")
entries = section.find_all('a', href=True) 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] return [
ExerciseGroup(
f"https://themis.housing.rug.nl{x['href']}",
x,
self.session,
self,
)
for x in entries]

View File

@ -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]

View File

@ -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

View File

@ -1,39 +1,126 @@
from Base import Base from bs4 import BeautifulSoup
from bs4 import BeautifulSoup\ from exceptions.IllegalAction import IllegalAction
import re
class ExerciseGroup(Base): class ExerciseGroup():
# I can't tell if I'm already an exercise :C def __init__(self, url:str, soup, session, parent):
self.url = url
def __init__(self, url:str, name:str, session, parent): self.name = soup.text
super().__init__(url, name, session, parent) self.__raw = soup
self.exercises = self.getExercises() self.session = session
self.folders = self.getFolders() self.parent = parent # This is unnecessary, but I'll keep it for now
self.request = self.session.get(self.url)
self.soup = BeautifulSoup(self.request.text, 'lxml')
def __str__(self): def __str__(self):
return f"ExerciseGroup {self.name} in course {self.parent.name}" return f"ExerciseGroup {self.name} in folder {self.parent.name}"
def getExercises(self) -> list: @property
r = self.session.get(self.url) def amExercise(self):
soup = BeautifulSoup(r.text, 'lxml') return "ass-submitable" in self.__raw['class']
section = soup.find('div', class_="ass-children")
def submit(self):
if not self.amExercise:
raise IllegalAction(message="You are submitting to a folder.")
# Logic for submitting
# Test cases
@property
def testCases(self):
section = self.soup.find_all('div', class_="subsec round shade")
tcs = []
for div in section:
res = div.find("h4", class_="info")
if not res:
continue
if "Test cases" in res.text:
for case in div.find_all("div", class_="cfg-line"):
if link := case.find("a"):
tcs.append(link)
return tcs
return None
def downloadTCs(self, path="."):
# Logic for downloading test cases(if any)
# In a div with class "subsec round shade", where there is an h4 with text "Test cases"
if not self.amExercise:
raise IllegalAction(message="You are downloading test cases from a folder.")
for tc in self.testCases:
url= f"https://themis.housing.rug.nl{tc['href']}"
print(f"Downloading {tc.text}")
# download the files
with open(f"{path}/{tc.text}", "wb") as f:
f.write(self.session.get(url).content)
return self.testCases
# Files
@property
def files(self):
details = self.soup.find('div', id=lambda x: x and x.startswith('details'))
cfg_lines = details.find_all('div', class_='cfg-line')
link_list = []
for line in cfg_lines:
key = line.find('span', class_='cfg-key')
if key and "Downloads" in key.text.strip():
# Extract all links in the cfg-val span
links = line.find_all('span', class_='cfg-val')
for link in links:
a = link.find_all('a')
for a in a:
link_list.append(a)
return link_list if link_list else None
def downloadFiles(self, path="."):
for file in self.files:
print(f"Downloading file {file.text}")
url = f"https://themis.housing.rug.nl{file['href']}"
with open(f"{path}/{file.text}", "wb") as f:
f.write(self.session.get(url).content)
return self.files
# idea exercises and folders are identical, maybe merge them?
@property
def exercises(self) -> list:
if self.amExercise:
return self
section = self.soup.find('div', class_="ass-children")
try: try:
submittables = section.find_all('a', class_="ass-submitable") submittables = section.find_all('a', class_="ass-submitable")
except AttributeError: except AttributeError:
return None return None
return submittables return [
ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}",
x,
self.session,
self)
for x in submittables]
# Returns a list of names of the folders @property
def getFolders(self) -> list: def folders(self) -> list:
r = self.session.get(self.url) section = self.soup.find('div', class_="ass-children")
soup = BeautifulSoup(r.text, 'lxml')
section = soup.find('div', class_="ass-children")
try: try:
folders = section.find_all('a', class_="ass-group") folders = section.find_all('a', class_="ass-group")
except AttributeError: except AttributeError:
return None return None
return [x.text for x in folders] return [
ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}",
def recurse(self, folder:str): x,
print(self.url) session,
self)
for x in folders]

View File

@ -44,7 +44,6 @@ class Themis:
def getYear(self, start:int, end:int): def getYear(self, start:int, end:int):
# Get the current year
return Year(self.session, self, start, end) return Year(self.session, self, start, end)
def allYears(self): def allYears(self):

View File

@ -18,7 +18,7 @@ class Year:
return f"https://themis.housing.rug.nl/course/{self.start}-{self.year}" return f"https://themis.housing.rug.nl/course/{self.start}-{self.year}"
# Method to get the courses of the year # Method to get the courses of the year
def getCourses(self, errors:bool=False) -> list[Course]: def allCourses(self, errors:bool=False) -> list[Course]:
# lis in a big ul # lis in a big ul
r = self.session.get(self.url) r = self.session.get(self.url)
soup = BeautifulSoup(r.text, 'lxml') soup = BeautifulSoup(r.text, 'lxml')

View File

@ -1,4 +1,4 @@
class CourseUnavailable(Exception): class CourseUnavailable(Exception):
def __init__(self, message:str="Error in course"): def __init__(self, message:str=""):
self.message = message self.message = "Course Error: " + message
super().__init__(self.message) super().__init__(self.message)

View File

@ -0,0 +1,4 @@
class IllegalAction(Exception):
def __init__(self, message:str=""):
self.message = "Illegal action: " + message
super().__init__(self.message)