mirror of
https://github.com/Code-For-Groningen/temmies.git
synced 2025-03-15 23:10:15 +01:00
Compare commits
No commits in common. "6a63abf7aecebe2925f87fdb6fc6c3581d6b18c2" and "9d92db4644c6f2c941e64142a6b6655558c240e1" have entirely different histories.
6a63abf7ae
...
9d92db4644
234
docs/api.md
234
docs/api.md
@ -1,6 +1,5 @@
|
|||||||
# Classes
|
# Classes
|
||||||
---
|
---
|
||||||
|
|
||||||
## `Themis`
|
## `Themis`
|
||||||
Creates the initial connection to Themis.
|
Creates the initial connection to Themis.
|
||||||
|
|
||||||
@ -16,7 +15,7 @@ themis = Themis("s-number", "password")
|
|||||||
Logs in to Themis. Runs automatically when the class is initialized.
|
Logs in to Themis. Runs automatically when the class is initialized.
|
||||||
|
|
||||||
#### `get_year(start, end)`
|
#### `get_year(start, end)`
|
||||||
Returns an instance of a [`Year`](#year) (academic year) between `start` and `end`.
|
Returns an instance of a [`Year`](#year)(academic year) between `start` and `end`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
year = themis.get_year(2023, 2024)
|
year = themis.get_year(2023, 2024)
|
||||||
@ -29,6 +28,7 @@ Returns a list of `Year` instances corresponding to all years visible to the use
|
|||||||
years = themis.all_years()
|
years = themis.all_years()
|
||||||
```
|
```
|
||||||
<sub> I don't see why you would need this, but it's here. </sub>
|
<sub> I don't see why you would need this, but it's here. </sub>
|
||||||
|
|
||||||
----
|
----
|
||||||
|
|
||||||
## `Year`
|
## `Year`
|
||||||
@ -59,47 +59,48 @@ courses = year.all_courses()
|
|||||||
### Usage
|
### Usage
|
||||||
```python
|
```python
|
||||||
pf = year.get_course("Programming Fundamentals (for CS)")
|
pf = year.get_course("Programming Fundamentals (for CS)")
|
||||||
|
print(pf.info) # <- course info attribute
|
||||||
assignments = pf.get_groups()
|
assignments = pf.get_groups()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Methods
|
### Methods
|
||||||
#### `get_groups(full=False)`
|
#### `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.
|
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.
|
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:
|
When you have fully traversed the course, you can access everything via indices and the `exercises` and `folders` attributes of the `ExerciseGroup` instances:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
ai_group = ai_course.get_groups(full=True)
|
ai_group = ai_course.get_groups(full=True)
|
||||||
exercise = ai_group[7].exercises[1] # Week 11 -> Suitcase packing
|
exercise = ai_group[7].exercises[1] # Week 11 -> Suitcase packing
|
||||||
exercise.submit(["suitcase.py"], silent=False)
|
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:
|
This is equivalent to the case in which we don't traverse the full course using `get_group` like so:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
ai_group = ai_course.get_group("Week 11")
|
ai_group = ai_course.get_group("Week 11")
|
||||||
exercise = ai_group.get_group("Suitcase packing")
|
exercise = ai_group.get_group("Suitcase packing")
|
||||||
exercise.submit(["suitcase.py"], silent=False)
|
exercise.submit("suitcase.py", silent=False)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `get_group(name, full=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` 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
|
```python
|
||||||
week1 = pf.get_group("Week 1")
|
week1 = pf.get_group("Week 1")
|
||||||
```
|
```
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
## `ExerciseGroup`
|
## `ExerciseGroup`
|
||||||
Setting the `full` flag to `True` will traverse the whole group.
|
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.
|
||||||
|
|
||||||
- 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
|
### Example of folder traversal
|
||||||
Let's say we have a folder structure like this:
|
Let's say we have a folder structure like this:
|
||||||
@ -119,153 +120,166 @@ And we want to get to `Part 2` of `Week 1`'s `Exercise 2`. We would do this:
|
|||||||
```python
|
```python
|
||||||
pf = year.get_course("Programming Fundamentals (for CS)")
|
pf = year.get_course("Programming Fundamentals (for CS)")
|
||||||
assignments = pf.get_groups()
|
assignments = pf.get_groups()
|
||||||
week1 = assignments[0] # Week 1
|
week1 = assignments[0] # Week 1
|
||||||
exercise2 = week1.folders[1] # Exercise 2
|
exercise2 = week1.folders[1] # Exercise 2
|
||||||
part2 = exercise2.exercises[1] # Part 2
|
part2 = exercise2.exercises[1] # Part 2
|
||||||
|
|
||||||
# Or, if you don't want to traverse the whole course:
|
# Or, if you dont want to traverse the whole course:
|
||||||
week1 = pf.get_group("Week 1")
|
week1 = pf.get_group("Week 1")
|
||||||
exercise2 = week1.get_group("Exercise 2")
|
exercise2 = week1.get_group("Exercise 2")
|
||||||
part2 = exercise2.get_group("Part 2")
|
part2 = exercise2.get_group("Part 2")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Methods
|
### Methods
|
||||||
#### `download_files(path=".")`
|
#### `download_files(path=".")`
|
||||||
Downloads all files in the exercise group to a directory `path`. Defaults to the current directory.
|
Downloads all files in the exercise group to a directory `path`. Defaults to the current directory.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
assignment.download_files()
|
assignment.download_files()
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `download_tcs(path=".")`
|
#### `download_tcs(path=".")`
|
||||||
Downloads all test cases in the exercise group to a directory `path`. Defaults to the current directory.
|
Downloads all test cases in the exercise group to a directory `path`. Defaults to the current directory.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
assignment.download_tcs()
|
assignment.download_tcs()
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `get_group(name, full=False)`
|
#### 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.
|
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
|
```python
|
||||||
# Week 1 -> Exercise 2 -> Part 2
|
# Week 1 -> Exercise 2 -> Part 2
|
||||||
week1 = pf.get_group("Week 1")
|
week1 = pf.get_groups("Week 1")
|
||||||
exercise2 = week1.get_group("Exercise 2")
|
exercise2 = week1.get_group("Exercise 2")
|
||||||
part2 = exercise2.get_group("Part 2")
|
part2 = exercise2.get_group("Part 2")
|
||||||
|
|
||||||
# This is equivalent to (but faster than):
|
# This is equivalent to(but faster than):
|
||||||
week1 = pf.get_groups(full=True)[0]
|
week1 = pf.get_groups("Week 1", full=True)
|
||||||
exercise2 = week1.folders[1]
|
exercise2 = week1[1]
|
||||||
part2 = exercise2.exercises[1]
|
part2 = exercise2[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.
|
#### `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
|
```python
|
||||||
suitcase = ai_course.get_group("Week 11").get_group("Suitcase packing")
|
suitcase = ai.get_group("Week 11")
|
||||||
suitcase.submit(["suitcase.py"], silent=False)
|
suitcase[7].exercises[1].submit("suitcase.py", silent=False)
|
||||||
|
|
||||||
# Output:
|
# Or
|
||||||
# Submitting to Suitcase packing
|
ai.get_group("Week 11").get_group("Suitcase packing").submit("suitcase.py", silent=False)
|
||||||
# • suitcase.py
|
|
||||||
# 1: ✅
|
>>> 1: ✅
|
||||||
# 2: ✅
|
>>> 2: ✅
|
||||||
# 3: ✅
|
>>> 3: ✅
|
||||||
# ...
|
>>> 4: ✅
|
||||||
|
>>> 5: ✅
|
||||||
|
>>> 6: ✅
|
||||||
|
>>> 7: ✅
|
||||||
|
>>> 8: ✅
|
||||||
|
>>> 9: ✅
|
||||||
|
>>> 10: ✅
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `get_status(text=False)`
|
#### `get_status(section=None, 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.
|
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
|
```python
|
||||||
pf = year.get_course("Programming Fundamentals (for CS)")
|
pf = year.get_course("Programming Fundamentals (for CS)")
|
||||||
exercise = pf.get_group("Lab Session 2").get_group("Recurrence")
|
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 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:
|
#### `get_all_statuses(text=False)
|
||||||
|
Does the same as `get_status`, but for all visible status sections.
|
||||||
|
|
||||||
```python
|
|
||||||
leading_submission = status["leading"]
|
|
||||||
print(leading_submission.get_files())
|
|
||||||
```
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
## `Submission`
|
## `Submission`
|
||||||
### Usage
|
### Usage
|
||||||
```python
|
```python
|
||||||
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()["leading"]
|
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
|
### Methods
|
||||||
#### `get_test_cases()`
|
#### `test_cases()`
|
||||||
Returns a dictionary of test cases and their statuses.
|
Returns a list of `TestCase` instances corresponding to all test cases in the submission.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
test_cases = submission.get_test_cases()
|
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"]
|
||||||
print(test_cases)
|
submission.test_cases()
|
||||||
|
>>> {'1': 'passed', '2': 'passed', '3': 'passed', '4': 'passed', '5': 'passed', '6': 'passed', '7': 'passed', '8': 'passed', '9': 'passed', '10': 'passed'}
|
||||||
|
|
||||||
# Output:
|
|
||||||
{'1': 'passed', '2': 'passed', '3': 'passed', '4': 'passed', '5': 'passed', '6': 'passed', '7': 'passed', '8': 'passed', '9': 'passed', '10': 'passed'}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `get_info()`
|
#### `info()`
|
||||||
Returns a dictionary of information about the submission.
|
Returns a dictionary of information about the submission.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
info = submission.get_info()
|
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"]
|
||||||
print(info)
|
submission.info()
|
||||||
|
|
||||||
# Output:
|
>>> {
|
||||||
{
|
>>>'assignment': 'Part 1',
|
||||||
'assignment': 'Part 1',
|
>>>'group': 'Y.N. Here',
|
||||||
'group': 'Y.N. Here',
|
>>>'uploaded_by': 'Y.N. Here s1234567',
|
||||||
'uploaded_by': 'Y.N. Here s1234567',
|
>>>'created_on': 'Wed Sep 13 2023 12:51:37 GMT+02002023-09-13T10:51:37.338Z',
|
||||||
'created_on': 'Wed Sep 13 2023 12:51:37 GMT+0200',
|
>>>'submitted_on': 'Wed Sep 13 2023 12:51:37 GMT+02002023-09-13T10:51:37.344Z',
|
||||||
'submitted_on': 'Wed Sep 13 2023 12:51:37 GMT+0200',
|
>>>'status': 'passed: Passed all test cases',
|
||||||
'status': 'passed: Passed all test cases',
|
>>>'files': [('recurrence.c',
|
||||||
'files': [
|
>>>'/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/source/recurrence.c'),
|
||||||
('recurrence.c', '/file/.../recurrence.c'),
|
>>>('compile.log',
|
||||||
('compile.log', '/file/.../compile.log')
|
>>>'/file/2023-2024/progfun/lab2/recurrence/%40submissions/s1234567/s1234567-1/output/compile.log')],
|
||||||
],
|
>>>'language': 'c'
|
||||||
'language': 'c'
|
>>> }
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `get_files()`
|
#### `files()`
|
||||||
Returns a list of uploaded files in the format `(name, URL)`.
|
Returns a list of files in the form `(name, link)`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
files = submission.get_files()
|
submission = pf.get_group("Week 1").get_group("Exercise 1").get_group("Part 1").get_status()[1]["leading"]
|
||||||
print(files)
|
submission.files()
|
||||||
|
|
||||||
# Output:
|
>>> [('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')]
|
||||||
[
|
|
||||||
('recurrence.c', '/file/.../recurrence.c'),
|
|
||||||
('compile.log', '/file/.../compile.log')
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
----
|
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
|
|
||||||
## **Changelog**
|
|
||||||
|
|
||||||
### **Version 1.1.0**
|
|
||||||
|
|
||||||
#### **Documentation**
|
|
||||||
- Fixed method signatures to align with actual functionality.
|
|
||||||
- Updated `get_status` to properly handle `Submission` instances.
|
|
||||||
- Ensured all class and method examples are consistent with the codebase.
|
|
||||||
|
|
||||||
#### **Codebase**
|
|
||||||
- Prepended `get_` to all methods in `Submission`
|
|
||||||
- Created base `Group` from which `Course` and `ExerciseGroup` inherit.
|
|
||||||
-
|
|
@ -3,5 +3,4 @@ nav:
|
|||||||
- Temmies: index.md
|
- Temmies: index.md
|
||||||
- API Reference: api.md
|
- API Reference: api.md
|
||||||
- About: about.md
|
- About: about.md
|
||||||
- Change Log: changelog.md
|
|
||||||
theme: readthedocs
|
theme: readthedocs
|
@ -1,3 +1 @@
|
|||||||
from .themis import Themis
|
from .themis import Themis
|
||||||
|
|
||||||
__all__ = ["Themis"]
|
|
@ -1,28 +1,72 @@
|
|||||||
from .group import Group
|
"""
|
||||||
from .exercise_group import ExerciseGroup
|
Houses the Course class which is used to represent a course in a year.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from requests import Session
|
from requests import Session
|
||||||
|
|
||||||
|
from .exercise_group import ExerciseGroup
|
||||||
from .exceptions.course_unavailable import CourseUnavailable
|
from .exceptions.course_unavailable import CourseUnavailable
|
||||||
|
from .exceptions.illegal_action import IllegalAction
|
||||||
|
|
||||||
class Course(Group):
|
|
||||||
|
class Course:
|
||||||
"""
|
"""
|
||||||
Represents a course in a given academic year.
|
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, parent):
|
def __init__(self, url: str, name: str, session: Session, parent):
|
||||||
super().__init__(url, name, session, parent=parent, full=False)
|
self.url = url
|
||||||
self.__course_available(self._request)
|
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):
|
def __str__(self):
|
||||||
return f"Course {self.name} in year {self._parent.year}"
|
return f"Course {self.name} in year {self.__parent.year}"
|
||||||
|
|
||||||
def __course_available(self, response):
|
def __course_available(self, r):
|
||||||
if "Something went wrong" in response.text:
|
# Check if we got an error
|
||||||
|
# print(self.url)
|
||||||
|
if "Something went wrong" in r.text:
|
||||||
raise CourseUnavailable(
|
raise CourseUnavailable(
|
||||||
message="'Something went wrong'. Course most likely not found."
|
message="'Something went wrong'. Course most likely not found. "
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_group(self, url: str, name: str, session: Session, parent, full: bool, classes=None):
|
def get_groups(self, full: bool = False) -> list[ExerciseGroup]:
|
||||||
"""
|
"""
|
||||||
Create an instance of ExerciseGroup for subgroups within a Course.
|
get_groups(full: bool = False) -> list[ExerciseGroup]
|
||||||
|
Get all groups in a course. Set full to True to get all subgroups.
|
||||||
"""
|
"""
|
||||||
return ExerciseGroup(url, name, session, parent, full, classes)
|
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
|
||||||
|
)
|
||||||
|
@ -1,39 +1,59 @@
|
|||||||
from .group import Group
|
"""
|
||||||
from .exceptions.illegal_action import IllegalAction
|
Houses the ExerciseGroup class.
|
||||||
from .submission import Submission
|
Represents a group of exercises or a single exercise.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
from json import loads
|
from json import loads
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import Optional
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from .exceptions.illegal_action import IllegalAction
|
||||||
|
from .submission import Submission
|
||||||
|
|
||||||
class ExerciseGroup(Group):
|
class ExerciseGroup:
|
||||||
"""
|
"""
|
||||||
Represents a group of exercises or a single exercise.
|
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, name: str, session, parent=None, full: bool = False, classes=None):
|
def __init__(self, url: str, soup:BeautifulSoup, session, full: bool):
|
||||||
super().__init__(url, name, session, parent=parent, full=full, classes=classes)
|
self.url = url
|
||||||
self.am_exercise = "ass-submitable" in self.classes
|
self.name = soup.text
|
||||||
|
self.__prev_raw = soup
|
||||||
def create_group(self, url: str, name: str, session, parent, full: bool, classes=None):
|
self.__session = session
|
||||||
"""
|
self.__request = self.__session.get(self.url)
|
||||||
Create an instance of ExerciseGroup for subgroups.
|
self.__raw = BeautifulSoup(self.__request.text, "lxml")
|
||||||
"""
|
self.__full = full
|
||||||
return ExerciseGroup(url, name, session, parent, full, classes)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def test_cases(self) -> list[str]:
|
def am_exercise(self) -> bool:
|
||||||
"""
|
return "ass-submitable" in self.__prev_raw["class"]
|
||||||
Get all test cases for this exercise.
|
|
||||||
"""
|
|
||||||
if not self.am_exercise:
|
|
||||||
return []
|
|
||||||
|
|
||||||
sections = self._raw.find_all("div", class_="subsec round shade")
|
# Test cases
|
||||||
|
@property
|
||||||
|
def test_cases(self) -> list[str]:
|
||||||
|
section = self.__raw.find_all("div", class_="subsec round shade")
|
||||||
tcs = []
|
tcs = []
|
||||||
for div in sections:
|
for div in section:
|
||||||
res = div.find("h4", class_="info")
|
res = div.find("h4", class_="info")
|
||||||
if res and "Test cases" in res.text:
|
if not res:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "Test cases" in res.text:
|
||||||
for case in div.find_all("div", class_="cfg-line"):
|
for case in div.find_all("div", class_="cfg-line"):
|
||||||
if link := case.find("a"):
|
if link := case.find("a"):
|
||||||
tcs.append(link)
|
tcs.append(link)
|
||||||
@ -41,121 +61,143 @@ class ExerciseGroup(Group):
|
|||||||
|
|
||||||
def download_tcs(self, path=".") -> list[str]:
|
def download_tcs(self, path=".") -> list[str]:
|
||||||
"""
|
"""
|
||||||
Download all test cases for this exercise.
|
download_tcs(path=".") -> list[str]
|
||||||
|
Downloads every test case available from a given exercise. `path` defaults to '.'.
|
||||||
"""
|
"""
|
||||||
if not self.am_exercise:
|
if not self.am_exercise:
|
||||||
raise IllegalAction("You are downloading test cases from a folder.")
|
raise IllegalAction(message="You are downloading test cases from a folder.")
|
||||||
|
|
||||||
for tc in self.test_cases:
|
for tc in self.test_cases:
|
||||||
url = f"https://themis.housing.rug.nl{tc['href']}"
|
url = f"https://themis.housing.rug.nl{tc['href']}"
|
||||||
|
|
||||||
print(f"Downloading {tc.text}")
|
print(f"Downloading {tc.text}")
|
||||||
|
# download the files
|
||||||
with open(f"{path}/{tc.text}", "wb") as f:
|
with open(f"{path}/{tc.text}", "wb") as f:
|
||||||
f.write(self._session.get(url).content)
|
f.write(self.__session.get(url).content)
|
||||||
|
|
||||||
return self.test_cases
|
return self.test_cases
|
||||||
|
|
||||||
|
# Files
|
||||||
@property
|
@property
|
||||||
def files(self) -> list[str]:
|
def files(self) -> list[str]:
|
||||||
"""
|
details = self.__raw.find("div", id=lambda x: x and x.startswith("details"))
|
||||||
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")
|
cfg_lines = details.find_all("div", class_="cfg-line")
|
||||||
|
|
||||||
link_list = []
|
link_list = []
|
||||||
|
|
||||||
for line in cfg_lines:
|
for line in cfg_lines:
|
||||||
key = line.find("span", class_="cfg-key")
|
key = line.find("span", class_="cfg-key")
|
||||||
|
|
||||||
if key and "Downloads" in key.text.strip():
|
if key and "Downloads" in key.text.strip():
|
||||||
|
# Extract all links in the cfg-val span
|
||||||
links = line.find_all("span", class_="cfg-val")
|
links = line.find_all("span", class_="cfg-val")
|
||||||
for link in links:
|
for link in links:
|
||||||
a_tags = link.find_all("a")
|
a = link.find_all("a")
|
||||||
for a in a_tags:
|
for i in a:
|
||||||
link_list.append(a)
|
link_list.append(i)
|
||||||
|
|
||||||
return link_list
|
return link_list
|
||||||
|
|
||||||
def download_files(self, path=".") -> list[str]:
|
def download_files(self, path=".") -> list[str]:
|
||||||
"""
|
"""
|
||||||
Download all files available for this exercise or group.
|
download_files(path=".") -> list[str]
|
||||||
|
Downloads every file available from a given exercise/folder. `path` defaults to '.'.
|
||||||
"""
|
"""
|
||||||
for file in self.files:
|
for file in self.files:
|
||||||
print(f"Downloading file {file.text}")
|
print(f"Downloading file {file.text}")
|
||||||
url = f"https://themis.housing.rug.nl{file['href']}"
|
url = f"https://themis.housing.rug.nl{file['href']}"
|
||||||
with open(f"{path}/{file.text}", "wb") as f:
|
with open(f"{path}/{file.text}", "wb") as f:
|
||||||
f.write(self._session.get(url).content)
|
f.write(self.__session.get(url).content)
|
||||||
return self.files
|
return self.files
|
||||||
|
|
||||||
def submit(self, files: list[str], judge: bool = True, wait: bool = True, silent: bool = True) -> Optional[dict]:
|
@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":
|
||||||
"""
|
"""
|
||||||
Submit files to this exercise.
|
get_group(name:str, full:bool=False, link:str=None) -> ExerciseGroup | list[ExerciseGroup]
|
||||||
Returns a dictionary of test case results or None if wait is False.
|
Get a single group by name.
|
||||||
|
Set `full` to True to get all subgroups as well.
|
||||||
|
Set `link` to directly fetch a group.
|
||||||
"""
|
"""
|
||||||
if not self.am_exercise:
|
if link:
|
||||||
raise IllegalAction("You cannot submit to this assignment.")
|
return ExerciseGroup(link, self.__prev_raw, self.__session, full)
|
||||||
|
|
||||||
form = self._raw.find("form")
|
group = self.__raw.find("a", text=name)
|
||||||
if not form:
|
if not group:
|
||||||
raise IllegalAction("Submission form not found.")
|
raise IllegalAction(message=f"No such group found: {name}")
|
||||||
|
|
||||||
url = "https://themis.housing.rug.nl" + form["action"]
|
return ExerciseGroup(
|
||||||
file_types = loads(form["data-suffixes"])
|
f"https://themis.housing.rug.nl{group['href']}", group, self.__session, full
|
||||||
|
)
|
||||||
|
|
||||||
if isinstance(files, str):
|
# Wait for result
|
||||||
files = [files]
|
def __wait_for_result(self, url: str, verbose: bool, __printed: list) -> None:
|
||||||
|
# This waits for result and returns a bundled info package
|
||||||
packaged_files = []
|
r = self.__session.get(url)
|
||||||
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")
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
return self.__parse_table(soup, url, verbose, __printed)
|
return self.__parse_table(soup, url, verbose, __printed)
|
||||||
|
|
||||||
def __parse_table(self, soup: BeautifulSoup, url: str, verbose: bool, __printed: list) -> dict:
|
# Account for judge
|
||||||
"""
|
def __race_condition(self, url: str, verbose: bool) -> None:
|
||||||
Parse the results table from the submission result page.
|
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")
|
cases = soup.find_all("tr", class_="sub-casetop")
|
||||||
fail_pass = {}
|
fail_pass = {}
|
||||||
|
i = 1
|
||||||
for case in cases:
|
for case in cases:
|
||||||
name = case.find("td", class_="sub-casename").text
|
name = case.find("td", class_="sub-casename").text
|
||||||
status = case.find("td", class_="status-icon")
|
status = case.find("td", class_="status-icon")
|
||||||
|
|
||||||
if "pending" in status.get("class"):
|
if "pending" in status.get("class"):
|
||||||
sleep(1)
|
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)
|
return self.__wait_for_result(url, verbose, __printed)
|
||||||
|
|
||||||
statuses = {
|
statuses = {
|
||||||
@ -165,6 +207,7 @@ class ExerciseGroup(Group):
|
|||||||
"error": ("🐛", None),
|
"error": ("🐛", None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Printing and storing
|
||||||
found = False
|
found = False
|
||||||
for k, v in statuses.items():
|
for k, v in statuses.items():
|
||||||
if k in status.text:
|
if k in status.text:
|
||||||
@ -179,4 +222,126 @@ class ExerciseGroup(Group):
|
|||||||
print(f"{name}: Unrecognized status: {status.text}")
|
print(f"{name}: Unrecognized status: {status.text}")
|
||||||
|
|
||||||
__printed.append(int(name))
|
__printed.append(int(name))
|
||||||
|
i += 1
|
||||||
return fail_pass
|
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)
|
143
temmies/group.py
143
temmies/group.py
@ -1,143 +0,0 @@
|
|||||||
# temmies/group.py
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from requests import Session
|
|
||||||
from typing import Optional, Union, Dict
|
|
||||||
from .exceptions.illegal_action import IllegalAction
|
|
||||||
from .submission import Submission
|
|
||||||
|
|
||||||
|
|
||||||
class Group:
|
|
||||||
"""
|
|
||||||
Base class for Course and ExerciseGroup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, url: str, name: str, session: Session, parent=None, full: bool = False, classes=None):
|
|
||||||
self.url = url
|
|
||||||
self.name = name
|
|
||||||
self._session = session
|
|
||||||
self._parent = parent
|
|
||||||
self._full = full
|
|
||||||
self._request = self._session.get(self.url)
|
|
||||||
self._raw = BeautifulSoup(self._request.text, "lxml")
|
|
||||||
self.classes = classes or []
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Group {self.name}"
|
|
||||||
|
|
||||||
def get_groups(self, full: bool = False):
|
|
||||||
"""
|
|
||||||
Get all groups (exercises and folders) within this group.
|
|
||||||
"""
|
|
||||||
section = self._raw.find("div", class_="ass-children")
|
|
||||||
if not section:
|
|
||||||
return []
|
|
||||||
|
|
||||||
entries = section.find_all("a", href=True)
|
|
||||||
groups = []
|
|
||||||
for x in entries:
|
|
||||||
href = x['href']
|
|
||||||
name = x.text.strip()
|
|
||||||
classes = x.get('class', [])
|
|
||||||
group = self.create_group(
|
|
||||||
url=f"https://themis.housing.rug.nl{href}",
|
|
||||||
name=name,
|
|
||||||
session=self._session,
|
|
||||||
parent=self,
|
|
||||||
full=full,
|
|
||||||
classes=classes
|
|
||||||
)
|
|
||||||
groups.append(group)
|
|
||||||
return groups
|
|
||||||
|
|
||||||
def get_group(self, name: str, full: bool = False):
|
|
||||||
"""
|
|
||||||
Get a single group by name.
|
|
||||||
"""
|
|
||||||
group_link = self._raw.find("a", text=name)
|
|
||||||
if not group_link:
|
|
||||||
raise IllegalAction(f"No such group found: {name}")
|
|
||||||
href = group_link['href']
|
|
||||||
classes = group_link.get('class', [])
|
|
||||||
return self.create_group(
|
|
||||||
url=f"https://themis.housing.rug.nl{href}",
|
|
||||||
name=name,
|
|
||||||
session=self._session,
|
|
||||||
parent=self,
|
|
||||||
full=full,
|
|
||||||
classes=classes
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_group(self, url: str, name: str, session: Session, parent, full: bool, classes=None):
|
|
||||||
"""
|
|
||||||
Factory method to create a group. Subclasses must implement this.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError("Subclasses must implement create_group")
|
|
||||||
|
|
||||||
def get_status(self, text: bool = False) -> Union[Dict[str, Union[str, Submission]], None]:
|
|
||||||
"""
|
|
||||||
Get the status of the current group, if available.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text (bool): If True, returns text representation of the status.
|
|
||||||
Otherwise, creates `Submission` objects for applicable fields.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Union[str, Submission]] | None: The status data for the group,
|
|
||||||
with `Submission` objects for links.
|
|
||||||
"""
|
|
||||||
status_link = self._raw.find("a", text="Status")
|
|
||||||
if not status_link:
|
|
||||||
raise IllegalAction("Status information is not available for this group.")
|
|
||||||
|
|
||||||
status_url = f"https://themis.housing.rug.nl{status_link['href']}"
|
|
||||||
r = self._session.get(status_url)
|
|
||||||
soup = BeautifulSoup(r.text, "lxml")
|
|
||||||
section = soup.find("div", class_="cfg-container")
|
|
||||||
|
|
||||||
if not section:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self.__parse_status_section(section, text)
|
|
||||||
|
|
||||||
def __parse_status_section(self, section: BeautifulSoup, text: bool) -> Dict[str, Union[str, Submission]]:
|
|
||||||
"""
|
|
||||||
Parse the status section of the group and clean up keys.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
section (BeautifulSoup): The HTML section containing the status information.
|
|
||||||
text (bool): Whether to return text representation.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Union[str, Submission]]: Parsed and cleaned status information,
|
|
||||||
with `Submission` objects for links.
|
|
||||||
"""
|
|
||||||
key_mapping = {
|
|
||||||
"leading the submission that counts towards the grade": "leading",
|
|
||||||
"best the latest submission with the best result": "best",
|
|
||||||
"latest the most recent submission": "latest",
|
|
||||||
"first pass the first submission that passed": "first_pass",
|
|
||||||
"last pass the last submission to pass before the deadline": "last_pass",
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed = {}
|
|
||||||
cfg_lines = section.find_all("div", class_="cfg-line")
|
|
||||||
for line in cfg_lines:
|
|
||||||
key_element = line.find("span", class_="cfg-key")
|
|
||||||
value_element = line.find("span", class_="cfg-val")
|
|
||||||
if not key_element or not value_element:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Normalize key
|
|
||||||
raw_key = " ".join(key_element.get_text(separator=" ").strip().replace(":", "").lower().split())
|
|
||||||
key = key_mapping.get(raw_key, raw_key) # Use mapped key if available
|
|
||||||
|
|
||||||
# Process value
|
|
||||||
link = value_element.find("a", href=True)
|
|
||||||
if link and not text:
|
|
||||||
submission_url = link["href"]
|
|
||||||
parsed[key] = Submission(submission_url, self._session)
|
|
||||||
else:
|
|
||||||
parsed[key] = value_element.get_text(separator=" ").strip()
|
|
||||||
|
|
||||||
return parsed
|
|
@ -27,7 +27,7 @@ class Submission:
|
|||||||
return clean
|
return clean
|
||||||
return clean.replace(" ", "_").replace(":", "").lower()
|
return clean.replace(" ", "_").replace(":", "").lower()
|
||||||
|
|
||||||
def get_test_cases(self) -> dict[str, str]:
|
def test_cases(self) -> dict[str, str]:
|
||||||
"""Get a dict of test cases status"""
|
"""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"
|
# In the submission page, the test cases are in a div with class "sub-cases subsec round shade"
|
||||||
# print(self.__raw.prettify())
|
# print(self.__raw.prettify())
|
||||||
@ -52,7 +52,7 @@ class Submission:
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def get_info(self) -> dict[str, str] | None:
|
def info(self) -> dict[str, str] | None:
|
||||||
"""Submission information (in details)"""
|
"""Submission information (in details)"""
|
||||||
# in div with class subsec round shade where there is an h4 with class info
|
# 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"
|
# The info is in a div with class "cfg-container"
|
||||||
@ -76,7 +76,7 @@ class Submission:
|
|||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_files(self) -> list[str] | None:
|
def files(self) -> list[str] | None:
|
||||||
"""Get a list of uploaded files in the format [(name, url)]"""
|
"""Get a list of uploaded files in the format [(name, url)]"""
|
||||||
if not self.__info:
|
if not self.__info:
|
||||||
self.__info = self.info()
|
self.__info = self.info()
|
||||||
|
@ -1,52 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Class which represents an academic year.
|
||||||
|
"""
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from requests import Session
|
||||||
|
|
||||||
from .course import Course
|
from .course import Course
|
||||||
from .exceptions.course_unavailable import CourseUnavailable
|
from .exceptions.course_unavailable import CourseUnavailable
|
||||||
|
|
||||||
|
|
||||||
|
# Works
|
||||||
class Year:
|
class Year:
|
||||||
"""
|
"""
|
||||||
Represents an academic year.
|
all_courses: Get all visible courses in a year
|
||||||
|
get_course: Get a course by name
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session, start_year: int, end_year: int):
|
def __init__(self, session: Session, start_year: int, end_year: int):
|
||||||
self.start = start_year
|
self.start = start_year
|
||||||
self.year = end_year
|
self.year = end_year
|
||||||
self.url = f"https://themis.housing.rug.nl/course/{self.start}-{self.year}"
|
self.url = f"https://themis.housing.rug.nl/course/{self.start}-{self.year}"
|
||||||
self._session = session
|
self.__session = session
|
||||||
|
|
||||||
|
# Method to get the courses of the year
|
||||||
def all_courses(self, errors: bool = True) -> list[Course]:
|
def all_courses(self, errors: bool = True) -> list[Course]:
|
||||||
"""
|
"""
|
||||||
|
all_courses(self, errors: bool = False) -> list[Course]
|
||||||
Gets all visible courses in a year.
|
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)
|
r = self.__session.get(self.url)
|
||||||
soup = BeautifulSoup(r.text, "lxml")
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
lis = soup.find_all("li", class_="large")
|
lis = soup.find_all("li", class_="large")
|
||||||
courses = []
|
courses = []
|
||||||
for li in lis:
|
for li in lis:
|
||||||
try:
|
try:
|
||||||
suffix = li.a["href"].replace(f"course/{self.start}-{self.year}", "")
|
suffix = li.a["href"].replace(f"course/{self.start}-{self.year}", "")
|
||||||
course_url = self.url + suffix
|
|
||||||
course_name = li.a.text.strip()
|
|
||||||
courses.append(
|
courses.append(
|
||||||
Course(course_url, course_name, self._session, self)
|
Course(self.url + suffix, li.a.text, self.__session, self)
|
||||||
)
|
)
|
||||||
except CourseUnavailable as exc:
|
except CourseUnavailable as exc:
|
||||||
if errors:
|
if errors:
|
||||||
raise CourseUnavailable(
|
raise CourseUnavailable(
|
||||||
message=f"Course {li.a.text} in year {self.start}-{self.year} unavailable"
|
message=f"Course {li.a.text} in year {self.start}-{self.year} unavailable"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
print("Error with course", li.a.text)
|
print("Error with course", li.a.text)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return courses
|
return courses
|
||||||
|
|
||||||
def get_course(self, name: str) -> Course:
|
def get_course(self, name: str) -> Course:
|
||||||
"""
|
"""
|
||||||
|
get_course(self, name: str) -> Course
|
||||||
Gets a course by name.
|
Gets a course by name.
|
||||||
"""
|
"""
|
||||||
r = self._session.get(self.url)
|
# Get the course
|
||||||
|
r = self.__session.get(self.url)
|
||||||
soup = BeautifulSoup(r.text, "lxml")
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
course_link = soup.find("a", text=name)
|
# Search by name
|
||||||
if not course_link:
|
course = self.url + soup.find("a", text=name)["href"].replace(
|
||||||
raise CourseUnavailable(f"No such course found: {name}")
|
f"course/{self.start}-{self.year}", ""
|
||||||
suffix = course_link["href"].replace(f"course/{self.start}-{self.year}", "")
|
)
|
||||||
course_url = self.url + suffix
|
# Get the url and transform it into a course object
|
||||||
return Course(course_url, name, self._session, self)
|
return Course(url=course, name=name, session=self.__session, parent=self)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user