37 Commits

Author SHA1 Message Date
82a072ee14 Updated api to reflect new changes 2024-11-18 20:17:26 +01:00
3d9ba063ce Fixed accidental format break 2024-11-18 20:17:09 +01:00
82bafa1925 Removed concrete deprecation warning 2024-11-18 20:05:53 +01:00
52d9d86260 Used API instead of scraping to find courses from year 2024-11-18 20:05:12 +01:00
da4705b56a Used the API for year logic 2024-11-18 20:04:02 +01:00
f6e6bc28d2 Prettified submission code 2024-11-18 20:03:31 +01:00
2fa3bfbad8 Moved all downloading logic, submitting and status/tc table parsing into base Group class
Also created 2 methods to get files and test cases without downloading
2024-11-18 20:03:08 +01:00
9f99df54d8 Moved most logic to base Group class 2024-11-18 19:58:17 +01:00
514fcd2438 Moved disable warnings to __init__ 2024-11-18 19:57:48 +01:00
812f384c3d Course group creation logic updated 2024-11-18 19:57:37 +01:00
34bf29d274 Made create_group_from_url a static class method 2024-11-18 16:02:34 +01:00
f3d9a987f6 Removed circular type delcarations
Python is crazy
2024-11-18 15:55:43 +01:00
cb6ec3cc1c Updated deprecation messages 2024-11-18 15:51:59 +01:00
0ff3f28978 Added methods to create an exercise group from a link 2024-11-18 15:45:19 +01:00
de66e9762e Added support for deprecated methods 2024-11-18 15:30:35 +01:00
1a950c0eb2 Update README 2024-11-17 22:19:50 +01:00
a3a9f5dfee Updated index 2024-11-17 22:06:06 +01:00
461b506be5 Added requirements 2024-11-17 21:57:33 +01:00
a0aeca87b1 Updated changelog and doc with new password storage method 2024-11-17 21:57:07 +01:00
17f0bd237a Solved #11 by using system's keyring 2024-11-17 21:53:02 +01:00
2378be4d42 Fixed slight bug with unused method 2024-11-17 21:52:11 +01:00
6a63abf7ae Expose only Themis on package-level 2024-11-17 19:50:19 +01:00
019e2c181e Updated docs ot include changelog and reflect upon recent changes 2024-11-17 19:50:05 +01:00
135420d488 Naming fix
All methods are prefixed with get_
2024-11-17 19:36:28 +01:00
aab574cdb0 Refactor to use base class group for ExerciseGroup and Course to avoid repeated logic 2024-11-17 19:35:16 +01:00
9d92db4644 Updated link to docs 2024-09-22 16:15:13 +02:00
2c2d39c6dd Fixed stupid docs mistake 2024-06-03 22:55:36 +02:00
54e086c93a Slight update in docs. Released version 1.0.21. 2024-04-21 20:37:07 +02:00
667a388da4 Updated files. getting ready for publishing. Some issues 2024-04-21 02:08:58 +02:00
2daee84d4f Fixed mistakes in docs. Elaborated. 2024-04-21 00:29:49 +02:00
cd5e9b2c8d Added submissions. Updated docs. 2024-04-20 21:35:09 +02:00
de6262a4bb removed class map, as it doesn't correspond to current development. 2024-04-10 19:14:35 +02:00
8325a30af8 Added link to badges. 2024-04-10 18:54:38 +02:00
0f92d05bec Added badges :) 2024-04-10 18:53:14 +02:00
2785ec86e9 Updated docs to reflect upon code changes 2024-04-10 18:48:21 +02:00
78aade7c8c Conformed to naming conventions. Pepped the SHIT out of the code. Will reflect in documentation now. 2024-04-10 18:43:03 +02:00
11864cae6b Updated docs and included getGroup(by name). Privated some attributes that aren't necessary to be public. 2024-04-09 16:18:54 +02:00
28 changed files with 881 additions and 716 deletions

4
.gitignore vendored
View File

@ -1,6 +1,9 @@
# Config - Testing
config.py
tests/
pathfinding/
test.py
setup.py
#Doc env
.docs_env
@ -329,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

View File

@ -1,26 +1,26 @@
<p align="center">
<img src="docs/img/rugemmie.gif" />
<img src="https://github.com/Code-For-Groningen/temmies/blob/v1.1.0/docs/img/rugemmie.gif" />
</p>
<p align="center">
<a href="https://temmies.confest.im"><img alt="Read the Docs" src="https://img.shields.io/readthedocs/temmies"></a>
<img alt="GitHub" src="https://img.shields.io/github/license/Code-For-Groningen/temmies">
</p>
A python library which interacts with themis. Uses bs4. I'll try to end development on a somewhat working state.
A python library which interacts with [Themis](https://themis.housing.rug.nl/). Uses bs4. I'll try to end development on a somewhat working state.
## Intended Features
* [x] Log in
* [x] Submit
* [x] Bulk download of test cases and files
* [ ] Submission status
* [ ] Classes, methods and attributes described in the map below
* [x] Submission status
## Docs
[here](http://temmies.rtfd.io/).
## Class map
![map](images/roadmap.png)
- [here](http://temmies.confest.im/).
## Possible continuations
* [ ] Discord bot
* [ ] CLI program
* Discord bot
* [CLI program](https://github.com/Code-For-Groningen/temmies-cli)
## Thanks to
* [Glitchcat](https://glitchcat.github.io/themis-api/), cool docs bro.
* [Mo](https://github.com/Stylo2k), damn bruh you actually work for Themis
* [Mo](https://github.com/Stylo2k), damn bruh you actually work for Themis

View File

@ -1,33 +1,35 @@
# Classes
---
## `Themis`
Creates the initial connection to Themis.
### Usage
```python
from temmies.Themis import Themis
from temmies.themis import Themis
themis = Themis("s-number", "password")
themis = Themis("s-number")
```
On the first run, you will be prompted for your password. Then, on the next run(s), you will be able to log in automatically, as the password is stored in the system keyring. If you want to delete it [click here](https://www.google.com/search?hl=en&q=delete%20a%20password%20from%20keyring).
### Methods
#### `login()`
Logs in to Themis. Runs automatically when the class is initialized.
#### `getYear(start, end)`
Returns an instance of a [`Year`](#year)(academic year) between `start` and `end`.
#### `get_year(year_path)`
Returns an instance of a [`Year`](#year) for the academic year specified by `year_path`.
```python
year = themis.getYear(2023, 2024)
year = themis.get_year(2023, 2024)
```
#### `allYears()`
#### `all_years()`
Returns a list of `Year` instances corresponding to all years visible to the user.
```python
years = themis.allYears()
years = themis.all_years()
```
<sub> I don't see why you would need this, but it's here. </sub>
----
@ -35,22 +37,29 @@ years = themis.allYears()
### Usage
```python
year = themis.getYear(2023, 2024)
year = themis.get_year(2023, 2024)
```
### Methods
#### `getCourse(courseName)`
Returns an instance of a [`Course`](#course) with the name `courseName`.
#### `get_course(course_title)`
Returns an instance of a [`Course`](#course) with the title `course_title`.
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
pf = year.get_course("Programming Fundamentals (for CS)")
```
#### `allCourses()`
#### `get_course_by_tag(course_tag)`
Returns an instance of a [`Course`](#course) using the course identifier `course_tag`.
```python
ai_course = year.get_course_by_tag("adinc-ai")
```
#### `all_courses()`
Returns a list of `Course` instances corresponding to all courses visible to the user in a given `Year`.
```python
courses = year.allCourses()
courses = year.all_courses()
```
----
@ -58,97 +67,121 @@ courses = year.allCourses()
## `Course`
### Usage
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
print(pf.info) # <- course info attribute
assignments = pf.getExerciseGroups()
pf = year.get_course("Programming Fundamentals (for CS)")
assignments = pf.get_groups()
```
### Methods
#### `getExerciseGroups()`
Returns a list of `ExerciseGroup` instances corresponding to all exercise groups visible to the user in a given `Course`.
#### `get_groups(full=False)`
Returns a list of `ExerciseGroup` or `Group` instances corresponding to all items visible to the user in a given `Course`. The default argument is `full=False`, which will only return the top-level (name, link) of each item. If `full=True`, it will traverse the whole course.
```python
assignments = pf.getExerciseGroups()
ai_groups = ai_course.get_groups(full=True)
exercise = ai_groups[7].exercises[1]
exercise.submit(["solution.py"], silent=False)
```
#### `get_group(name, full=False)`
Returns an instance of an `ExerciseGroup` or `Group` with the name `name`. The default argument is `full=False`, which will only return the (name, link) of the group. If `full=True`, it will traverse the whole group.
```python
week1 = pf.get_group("Week 1")
```
#### `create_group(item_data)`
Creates and returns a `Group` or `ExerciseGroup` instance based on `item_data`.
```python
group = course.create_group(item_data)
```
----
## `Group`
Represents an item in Themis, which can be either a folder (non-submittable) or an assignment (submittable).
### Methods
#### `get_items()`
Returns all items (groups and assignments) under this group.
```python
items = week1.get_items()
```
#### `get_item_by_title(title)`
Returns a single item by its title (case-insensitive).
```python
item = week1.get_item_by_title("Exercise 2")
```
#### `get_status(text=False)`
Retrieves the status of the group. When `text=True`, returns the status as strings. Otherwise, returns submission objects or strings.
```python
status = group.get_status()
leading_submission = status["leading"]
```
#### `download_files(path=".")`
Downloads all files available for this group to a directory `path`. Defaults to the current directory.
```python
group.download_files()
```
#### `download_tcs(path=".")`
Downloads all test cases for this group to a directory `path`. Defaults to the current directory.
```python
group.download_tcs()
```
#### `submit(files, judge=True, wait=True, silent=True)`
Submits the files to the group. Default arguments are `judge=True`, `wait=True`, and `silent=True`.
```python
group.submit(["solution.py"], silent=False)
```
----
## `ExerciseGroup`
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).
Represents a submittable exercise. Inherits from `Group`.
* 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:
### Additional Methods
#### `submit(files)`
Submits files to the exercise. Raises an error if the item is not submittable.
```python
pf = year.getCourse("Programming Fundamentals (for CS)")
assignments = pf.getExerciseGroups()
week1 = assignments[0].folders[0]
exercise2 = week1.exercises[1]
part2 = exercise2.folders[1]
exercise.submit(["solution.py"])
```
----
## `Submission`
Represents a submission for a specific exercise.
### Methods
#### `downloadFiles(path=".")`
Downloads all files in the exercise group to a directory `path`. Defaults to the current directory.
#### `get_test_cases()`
Returns a dictionary of test cases and their statuses.
```python
assignment.downloadFiles()
test_cases = submission.get_test_cases()
```
#### `downloadTCs(path=".")`
Downloads all test cases in the exercise group to a directory `path`. Defaults to the current directory.
#### `get_info()`
Returns detailed information about the submission.
```python
assignment.downloadTCs()
info = submission.get_info()
```
#### `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.
#### `get_files()`
Returns a list of uploaded files in the format `(name, URL)`.
```python
suitcase[7].exercises[1].submit("suitcase.py", silent=False)
>>> 1:
>>> 2:
>>> 3:
>>> 4:
>>> 5:
>>> 6:
>>> 7:
>>> 8:
>>> 9:
>>> 10:
files = submission.get_files()
```

21
docs/changelog.md Normal file
View File

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

View File

@ -18,22 +18,22 @@ pip install temmies
## Example Usage
```python
import temmies
from temmies.themis import Themis
# Log in
themis = temmies.Themis("s-number", "password")
themis = Themis("s-number") # You will be prompted for your password
# Get a year
year = themis.getYear(2023, 2024)
year = themis.get_year("2023-2024")
# Get a course
pf = year.getCourse("Programming Fundamentals (for CS)")
course = year.get_course("Programming Fundamentals (for CS)")
# Get an assignment
assignment = pf.getExerciseGroups()
assignment = course.get_group("Assignment 1")
# Download the files
assignment.downloadFiles()
# Submit 2 files
assignment.submit(["among.c", "us.py"])
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

View File

@ -3,4 +3,5 @@ nav:
- Temmies: index.md
- API Reference: api.md
- About: about.md
- Change Log: changelog.md
theme: readthedocs

19
requirements.txt Normal file
View File

@ -0,0 +1,19 @@
beautifulsoup4==4.12.3
bs4==0.0.2
certifi==2024.8.30
cffi==1.17.1
charset-normalizer==3.4.0
cryptography==43.0.3
idna==3.10
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.1.0
jeepney==0.8.0
keyring==25.5.0
lxml==5.3.0
more-itertools==10.5.0
pycparser==2.22
requests==2.32.3
SecretStorage==3.3.3
soupsieve==2.6
urllib3==2.2.3

View File

@ -1,48 +0,0 @@
# Class to handle courses
from bs4 import BeautifulSoup
from requests import Session
from ExerciseGroup import ExerciseGroup
import re
from exceptions.CourseUnavailable import CourseUnavailable
class Course:
# Extend the Base class init
def __init__(self, url:str, name:str, session:Session, parent):
self.url = url
self.name = name
self.session = session
self.parent = 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(message="'Something went wrong'. Course most likely not found. ")
@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,
self.session,
self,
)
for x in entries]

View File

@ -1,217 +0,0 @@
from bs4 import BeautifulSoup
from exceptions.IllegalAction import IllegalAction
import re
from json import loads
from time import sleep
class ExerciseGroup:
def __init__(self, url: str, soup, session, parent):
self.url = url
self.name = soup.text
self.__raw = soup
self.session = session
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):
return f"ExerciseGroup {self.name} in folder {self.parent.name}"
@property
def amExercise(self):
return "ass-submitable" in self.__raw["class"]
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
@property
def exercises(self) -> list:
if self.amExercise:
return self
section = self.soup.find("div", class_="ass-children")
try:
submittables = section.find_all("a", class_="ass-submitable")
except AttributeError:
return None
return [
ExerciseGroup(
f"https://themis.housing.rug.nl{x['href']}", x, self.session, self
)
for x in submittables
]
@property
def folders(self) -> list:
section = self.soup.find("div", class_="ass-children")
try:
folders = section.find_all("a", class_="ass-group")
except AttributeError:
return None
return [
ExerciseGroup(f"https://themis.housing.rug.nl{x['href']}", x, self.session, self)
for x in folders
]
# Account for judge
def __raceCondition(self, soup, url:str, verbose:bool):
self.session.get(url.replace("submission", "judge"))
return self.__waitForResult(url, verbose, [])
def __parseTable(self, soup, url:str, verbose:bool, __printed:list):
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.__raceCondition(soup,url,verbose)
# queued status-icon
if "queued" in status.get("class"):
sleep(1) # <- 🗿
return self.__waitForResult(url, verbose, __printed)
if "Passed" in status.text:
fail_pass[int(name)] = True
if int(name) not in __printed:
print(f"{name}: ✅")
elif "Wrong output" in status.text:
fail_pass[int(name)] = False
if int(name) not in __printed:
print(f"{name}: ❌")
elif ("No status" or "error") in status.text:
fail_pass[int(name)] = None
if int(name) not in __printed:
print(f"{name}:🐛")
__printed.append(int(name))
i += 1
return fail_pass
def __waitForResult(self, url:str, verbose:bool, __printed:list):
# This waits for result and returns a bundled info package
r = self.session.get(url)
soup = BeautifulSoup(r.text, "lxml")
return self.__parseTable(soup, url, verbose, __printed)
# Submit
def submit(self, files: list, judge=True, wait=True, silent=True):
# Find the form with submit and store the action as url
# Store then the data-suffixes as file_types - dictionary
form = self.soup.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
# Package the files up into files[]
# DEBUG: Uncomment for better clarity
# print("Submitting files:")
# [print(f) for f in files]
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:
raise IllegalAction(message="Illegal filetype for this assignment.")
packaged_files.append((f"files[]", (file, open(file, "rb"), "text/x-csrc")))
data = {"judgenow": "true" if judge else "false", "judgeLanguage": found_type}
resp = self.session.post(url, files=packaged_files, data=data)
# Close each file
i = 0
for f in packaged_files:
f[1][1].close()
if not wait:
return resp.url if "@submissions" in resp.url else None
return self.__waitForResult(resp.url, not silent, [])

View File

@ -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
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 Exception(f"Login for user {user} failed")
return s
def getYear(self, start:int, end:int):
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

View File

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

View File

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

View File

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

View File

@ -1,25 +0,0 @@
from Themis import Themis
def main():
# Debug
themis = Themis("s5230837","Bobit0Drog@231")
year = themis.getYear(2023, 2024)
# pf = year.getCourse("Programming Fundamentals (for CS)")
# pf = pf.getExerciseGroups()
# print(pf[1].exercises[1].submit("main.c")) # <- this should throw error
# no_folders = year.getCourse("Computer Architecture")
# ca_ass = no_folders.getExerciseGroups()
ai = year.getCourse("Imperative Programming (for AI)")
ai = ai.getExerciseGroups()
print(ai[7].exercises[1].submit("suitcase.py", silent=False))
ads = year.getCourse("Algorithms and Data Structures for CS")
ads = ads.getExerciseGroups()
# print(ads[0].folders)
print(ads[0].folders[5].folders[0].exercises[0].submit(["texteditor.c", "texteditor.h"], silent=False))
# for ass in ca_ass:
# print(ass.exercises)
if __name__ == "__main__":
main()

View File

@ -1,28 +0,0 @@
def suitcase(maxVolume, sizes, values, n):
dp = [[0 for _ in range(maxVolume + 1)] for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, maxVolume + 1):
if sizes[i - 1] <= j:
dp[i][j] = max(values[i - 1] + dp[i - 1][j - sizes[i - 1]], dp[i - 1][j])
else:
dp[i][j] = dp[i - 1][j]
return dp[n][maxVolume]
def main():
n, maxVolume = map(int, input().split())
sizes = []
values = []
for _ in range(n):
item, size, value = input().split()
sizes.append(int(size))
values.append(int(value))
maxSatisfaction = suitcase(maxVolume, sizes, values, n)
print(maxSatisfaction)
if __name__ == "__main__":
main()

View File

@ -1,159 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "EditOperation.h"
#include "texteditor.h"
#include "LibStack.h"
TextEditor* createTextEditor(void) {
TextEditor *editor = malloc(sizeof(*editor));
// Don't forget to initialize the data structure(s) here
editor->text = malloc(10 * sizeof(*editor->text));
editor->length = 0;
editor->capacity = 10;
return editor;
}
// Think this is correct
void insertCharacter(TextEditor* editor, int pos, char character) {
// Implement the insert operation
if (editor->length == editor->capacity) {
editor->text = realloc(editor->text, 2 * editor->capacity * sizeof(*editor->text));
editor->capacity *= 2;
}
// Shift all characters to the right
for (int i = editor->length; i > pos; i--) {
editor->text[i] = editor->text[i - 1];
}
editor->text[pos] = character;
editor->length++;
}
// This too
void deleteCharacter(TextEditor* editor, int pos) {
// Implement the delete operation
if (editor->length == 0) {
return;
}
// Shift all characters to the left
for (int i = pos; i < editor->length - 1; i++) {
editor->text[i] = editor->text[i + 1];
}
editor->length--;
}
// The issue lies within the mem allocation of the stacks
void undo(TextEditor* editor, Stack* undoStack, Stack* redoStack) {
// Optional for the bonus exercise
if (isEmptyStack(*undoStack)) {
return;
}
EditOperation operation = pop(undoStack);
if (operation.type == INSERT) {
deleteCharacter(editor, operation.position);
} else {
insertCharacter(editor, operation.position, operation.character);
}
push(operation, redoStack);
}
void redo(TextEditor* editor, Stack* undoStack, Stack* redoStack) {
// Optional for the bonus exercise
if (isEmptyStack(*redoStack)) {
return;
}
EditOperation operation = pop(redoStack);
if (operation.type == INSERT) {
insertCharacter(editor, operation.position, operation.character);
} else {
deleteCharacter(editor, operation.position);
}
push(operation, undoStack);
}
void destroyTextEditor(TextEditor* editor) {
// Free the memory allocated for the data structure(s)
free(editor->text);
free(editor);
}
void printText(TextEditor* editor) {
// Handle empty case
if (editor->length == 0) {
printf("EMPTY\n");
return;
}
// Print the text stored in the editor
for (int i = 0; i < editor->length; i++) {
printf("%c", editor->text[i]);
}
printf("\n");
}
int main(int argc, char *argv[]) {
TextEditor* editor = createTextEditor();
char command;
int pos;
char character;
// Initialize stacks
Stack undoStack;
Stack redoStack;
undoStack = newStack(1);
redoStack = newStack(1);
while(1) {
scanf(" %c", &command);
switch (command) {
// Insert a character at a given position
case 'i':
scanf("%d %c", &pos, &character);
insertCharacter(editor, pos, character);
EditOperation operation = {INSERT, character, pos};
// Stack operations
doubleStackSize(&undoStack);
push(operation, &undoStack);
break;
// Delete a character at a given position
case 'd':
scanf("%d", &pos);
character = editor->text[pos];
deleteCharacter(editor, pos);
EditOperation operation1 = {DELETE, character, pos};
doubleStackSize(&undoStack);
push(operation1, &undoStack);
break;
// Undo the last operation
case 'u':
undo(editor, &undoStack, &redoStack);
break;
// Redo the last operation
case 'r':
redo(editor, &undoStack, &redoStack);
break;
// Print and quit
case 'q':
printText(editor);
destroyTextEditor(editor);
freeStack(undoStack);
freeStack(redoStack);
return 0;
// Unknown command
default:
printf("Unknown command.\n");
break;
}
}
return 0;
}

View File

@ -1,14 +0,0 @@
#ifndef TEXTEDITOR_H
#define TEXTEDITOR_H
#include "LibStack.h"
typedef struct TextEditor {
// Store the data structure in here
char *text;
int length;
int capacity;
} TextEditor;
#endif

5
temmies/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from .themis import Themis
import urllib3
__all__ = ["Themis"]
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

35
temmies/course.py Normal file
View File

@ -0,0 +1,35 @@
from .group import Group
class Course(Group):
"""
Represents a course.
"""
def __init__(self, session, course_path: str, title: str, parent):
super().__init__(session, course_path, title, parent)
self.course_path = course_path # e.g., '/2023-2024/adinc-ai'
def __str__(self):
return f"Course({self.title})"
def create_group(self, item_data):
"""
Create a subgroup (Group or ExerciseGroup) based on item data.
"""
if item_data.get("submitable", False):
return ExerciseGroup(
self.session,
item_data["path"],
item_data["title"],
self,
item_data.get("submitable", False),
)
else:
return Group(
self.session,
item_data["path"],
item_data["title"],
self,
item_data.get("submitable", False),
)

View File

View 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}")

View 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}")

51
temmies/exercise_group.py Normal file
View File

@ -0,0 +1,51 @@
from .group import Group
from .submission import Submission
from bs4 import BeautifulSoup
class ExerciseGroup(Group):
"""
Represents a submittable exercise.
"""
def __init__(self, session, path: str, title: str, parent, submitable: bool = True):
super().__init__(session, path, title, parent, submitable=submitable)
self.submit_url = f"{self.base_url}/api/submit{self.path}"
self.__find_name()
def __find_name(self):
"""
Find the name of the exercise group.
"""
if self.title == "":
# Find using beautiful soup (it is the last a with class 'fill accent large')
response = self.session.get(self.base_url + self.path)
soup = BeautifulSoup(response.text, "lxml")
title_elements = soup.find_all("a", class_="fill accent large")
if title_elements:
self.title = title_elements[-1].get_text(strip=True)
else:
self.title = self.path.split("/")[-1]
def submit(self, files: list[str]) -> Submission:
"""
Submit files to this exercise.
"""
if not self.submitable:
raise ValueError(f"Cannot submit to non-submittable item '{self.title}'.")
# Prepare the files and data for submission
files_payload = {}
for idx, file_path in enumerate(files):
file_key = f"file{idx}"
with open(file_path, "rb") as f:
files_payload[file_key] = (file_path, f.read())
response = self.session.post(self.submit_url, files=files_payload)
if response.status_code != 200:
raise ConnectionError(f"Failed to submit to '{self.title}'.")
submission_data = response.json()
return Submission(self.session, submission_data)
def __str__(self):
return f"ExerciseGroup({self.title})"

318
temmies/group.py Normal file
View File

@ -0,0 +1,318 @@
from bs4 import BeautifulSoup
from requests import Session
import os
from typing import Optional, Union, Dict
from .exceptions.illegal_action import IllegalAction
from .submission import Submission
class Group:
"""
Represents an item in Themis, which can be either a folder (non-submittable) or an assignment (submittable).
"""
def __init__(self, session, path: str, title: str, parent=None, submitable: bool = False):
self.session = session
self.path = path # e.g., '/2023-2024/adinc-ai/labs'
self.title = title
self.parent = parent
self.submitable = submitable
self.base_url = "https://themis.housing.rug.nl"
self.api_url = f"{self.base_url}/api/navigation{self.path}"
self.classes = []
# Adjust URL construction to include '/course' when accessing HTML pages
if not self.path.startswith('/course/'):
group_url = f"{self.base_url}/course{self.path}"
else:
group_url = f"{self.base_url}{self.path}"
# Fetch the page and parse it
response = self.session.get(group_url)
if response.status_code != 200:
raise ConnectionError(f"Failed to retrieve page for '{self.title}'. Tried {group_url}")
self._raw = BeautifulSoup(response.text, "lxml")
def get_items(self) -> list:
"""
Get all items (groups and assignments) under this group.
"""
section = self._raw.find("div", class_="ass-children")
if not section:
return []
entries = section.find_all("a", href=True)
items = []
for x in entries:
href = x['href']
name = x.text.strip()
classes = x.get('class', [])
submitable = "ass-submitable" in classes
item = Group(
session=self.session,
path=href,
title=name,
parent=self,
submitable=submitable
)
items.append(item)
return items
def get_item_by_title(self, title: str):
"""
Get a single item by its title, case-insensitive.
"""
items = self.get_items()
for item in items:
if (item.title.lower() == title.lower()) or (item.path.split("/")[-1] == title):
return item
raise ValueError(f"Item '{title}' not found under {self.title}.")
def get_status(self, text: bool = False) -> Union[Dict[str, Union[str, 'Submission']], None]:
"""
Get the status of the current group, if available.
"""
status_link = self._raw.find("a", text="Status")
if not status_link:
raise ValueError("Status information is not available for this group.")
status_url = f"{self.base_url}{status_link['href']}"
response = self.session.get(status_url)
if response.status_code != 200:
raise ConnectionError(f"Failed to retrieve status page for '{self.title}'.")
soup = BeautifulSoup(response.text, "lxml")
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.
"""
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:
href = link["href"]
# Construct full URL
if href.startswith("/"):
submission_url = href
elif href.startswith("http"):
submission_url = href.replace("https://themis.housing.rug.nl", "")
else:
print(f"Invalid href '{href}' found in status page.")
continue # Skip this entry if href is invalid
# Instantiate Submission with submission_url and session
submission = Submission(submission_url, self.session)
parsed[key] = submission
else:
parsed[key] = value_element.get_text(separator=" ").strip()
return parsed
def get_test_cases(self) -> list[Dict[str, str]]:
"""
Get all test cases for this assignment.
"""
if not self.submitable:
raise ValueError(f"No test cases for non-submittable item '{self.title}'.")
sections = self._raw.find_all("div", class_="subsec round shade")
tcs = []
for div in sections:
res = div.find("h4", class_="info")
if res and "Test cases" in res.text:
for case in div.find_all("div", class_="cfg-line"):
link = case.find("a")
if link:
tcs.append({
'title': link.text.strip(),
'path': link['href']
})
return tcs
def download_tcs(self, path=".") -> list[str]:
"""
Download all test cases for this assignment.
"""
test_cases = self.get_test_cases()
downloaded = []
for tc in test_cases:
url = f"{self.base_url}{tc['path']}"
print(f"Downloading {tc['title']}")
response = self.session.get(url)
if response.status_code == 200:
tc_filename = os.path.join(path, tc['title'])
with open(tc_filename, 'wb') as f:
f.write(response.content)
downloaded.append(tc_filename)
else:
print(f"Failed to download test case '{tc['title']}'")
return downloaded
def get_files(self) -> list[Dict[str, str]]:
"""
Get all downloadable files for this assignment.
"""
details = self._raw.find("div", id=lambda x: x and x.startswith("details"))
if not details:
return []
cfg_lines = details.find_all("div", class_="cfg-line")
files = []
for line in cfg_lines:
key = line.find("span", class_="cfg-key")
if key and "Downloads" in key.text.strip():
vals = line.find_all("span", class_="cfg-val")
for val in vals:
links = val.find_all("a")
for link in links:
files.append({
'title': link.text.strip(),
'path': link['href']
})
return files
def download_files(self, path=".") -> list[str]:
"""
Download all files available for this assignment.
"""
files = self.get_files()
downloaded = []
for file in files:
print(f"Downloading file '{file['title']}'")
url = f"{self.base_url}{file['path']}"
response = self.session.get(url)
if response.status_code == 200:
file_filename = os.path.join(path, file['title'])
with open(file_filename, 'wb') as f:
f.write(response.content)
downloaded.append(file_filename)
else:
print(f"Failed to download file '{file['title']}'")
return downloaded
def submit(self, files: list[str], judge: bool = True, wait: bool = True, silent: bool = True) -> Optional[dict]:
"""
Submit files to this assignment.
Returns a dictionary of test case results or None if wait is False.
"""
if not self.submitable:
raise ValueError(f"Cannot submit to non-submittable item '{self.title}'.")
form = self._raw.find("form")
if not form:
raise ValueError("Submission form not found.")
url = f"{self.base_url}{form['action']}"
file_types = loads(form.get("data-suffixes", "{}"))
if isinstance(files, str):
files = [files]
packaged_files = []
data = {}
found_type = ""
for file in files:
for suffix, lang in file_types.items():
if file.endswith(suffix):
found_type = lang
break
if not found_type:
print("WARNING: File type not recognized")
with open(file, "rb") as f:
packaged_files.append((found_type, (file, f.read())))
data = {
"judgenow": "true" if judge else "false",
"judgeLanguage": found_type if found_type else "none"
}
if not silent:
print(f"Submitting to {self.title}")
for file in files:
print(f"{file}")
resp = self.session.post(url, files=packaged_files, data=data)
if not wait or not judge:
return resp.url if "@submissions" in resp.url else None
return self.__wait_for_result(resp.url, not silent, [])
def __wait_for_result(self, url: str, verbose: bool, __printed: list) -> dict:
"""
Wait for the submission result and return the test case results.
"""
r = self.session.get(url)
soup = BeautifulSoup(r.text, "lxml")
return self.__parse_table(soup, url, verbose, __printed)
def __parse_table(self, soup: BeautifulSoup, url: str, verbose: bool, __printed: list) -> dict:
"""
Parse the results table from the submission result page.
"""
cases = soup.find_all("tr", class_="sub-casetop")
fail_pass = {}
for case in cases:
name = case.find("td", class_="sub-casename").text
status = case.find("td", class_="status-icon")
if "pending" in status.get("class"):
sleep(1)
return self.__wait_for_result(url, verbose, __printed)
statuses = {
"Passed": ("", True),
"Wrong output": ("", False),
"No status": ("🐛", None),
"error": ("🐛", None),
}
found = False
for k, v in statuses.items():
if k in status.text:
found = True
if verbose and int(name) not in __printed:
print(f"{name}: {v[0]}")
fail_pass[int(name)] = v[1]
break
if not found:
fail_pass[int(name)] = None
if verbose and int(name) not in __printed:
print(f"{name}: Unrecognized status: {status.text}")
__printed.append(int(name))
return fail_pass
def __str__(self):
return f"Group({self.title}, submitable={self.submitable})"

94
temmies/submission.py Normal file
View File

@ -0,0 +1,94 @@
# submission.py
"""
File to define the Submission class
"""
from bs4 import BeautifulSoup
class Submission:
"""
Submission class
Methods:
get_test_cases: Get a dict of test cases status
get_info: Submission information (in details)
get_files: Get a list of uploaded files (as names)
"""
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.strip()
return clean.replace(" ", "_").replace(":", "").lower().strip()
def get_test_cases(self) -> dict[str, str]:
"""Get a dict of test cases status"""
cases = self.__raw.find("div", class_=lambda x: x and "sub-cases" in x.split())
if not cases:
return {}
cases = cases.find("div", class_="cfg-container")
cases = cases.find("table")
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.strip()] = self.__clean(status)
return results
def get_info(self) -> dict[str, str] | None:
"""Submission information (in details)"""
if self.__info:
return self.__info
for div in self.__raw.find_all("div", class_="subsec round shade"):
h4 = div.find("h4", class_=lambda x: x and "info" in x.split())
if h4 and "Details" in h4.text:
info = div.find("div", class_="cfg-container")
info_lines = info.find_all("div", class_="cfg-line")
self.__info = {
self.__clean(
key := line.find("span", class_="cfg-key").text
): (
self.__clean(line.find("span", class_="cfg-val").text, value=True)
if "Files" not in key
else [
(self.__clean(a.text), a["href"])
for a in line.find("span", class_="cfg-val").find_all("a")
]
)
for line in info_lines
}
return self.__info
return None
def get_files(self) -> list[str] | None:
"""Get a list of uploaded files in the format [(name, url)]"""
if not self.__info:
self.__info = self.get_info()
return self.__info.get("files", None)
# Deprecated methods
def info(self):
print("This method is deprecated and will be deleted soon. Use get_info instead.")
return self.get_info()
def test_cases(self):
print("This method is deprecated and will be deleted in soon. Use get_test_cases instead.")
return self.get_test_cases()
def files(self):
print("This method is deprecated and will be deleted in soon. Use get_files instead.")
return self.get_files()

116
temmies/themis.py Normal file
View File

@ -0,0 +1,116 @@
"""
Main class for the Themis API using the new JSON endpoints.
"""
import keyring
import getpass
from requests import Session
from bs4 import BeautifulSoup
from .year import Year
from .exceptions.illegal_action import IllegalAction
class Themis:
"""
Main class for interacting with Themis.
- login: Login to Themis
- get_year: Get a year object
- all_years: Get all years
"""
def __init__(self, user: str):
"""
Initialize Themis object, logging in with the given user.
Args:
user (str): Username to login with.
Attributes:
user (str): Username.
password (str): Password, retrieved from keyring.
base_url (str): Base URL of the Themis website.
session (requests.Session): Authenticated session.
"""
self.user = user
self.password = self.__get_password()
self.base_url = "https://themis.housing.rug.nl"
self.session = self.login(self.user, self.password)
def __get_password(self) -> str:
"""
Retrieve the password from the keyring, prompting the user if not found.
"""
password = keyring.get_password(f"{self.user}-temmies", self.user)
if not password:
print(f"Password for user '{self.user}' not found in keyring.")
password = getpass.getpass(prompt=f"Enter password for {self.user}: ")
keyring.set_password(f"{self.user}-temmies", self.user, password)
print("Password saved securely in keyring.")
return password
def login(self, user: str, passwd: str) -> Session:
"""
Login to Themis using the original method, parsing CSRF token from the login page.
"""
session = Session()
login_url = f"{self.base_url}/log/in"
user_agent = (
"Mozilla/5.0 (X11; Linux x86_64) "
"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}
# Get login page to retrieve CSRF token
response = session.get(login_url, headers=headers, verify=False)
if response.status_code != 200:
raise ConnectionError("Failed to connect to Themis login page.")
# Parse CSRF token from login page
soup = BeautifulSoup(response.text, "lxml")
csrf_input = soup.find("input", attrs={"name": "_csrf"})
if not csrf_input or not csrf_input.get("value"):
raise ValueError("Unable to retrieve CSRF token.")
csrf_token = csrf_input["value"]
data["_csrf"] = csrf_token
data["sudo"] = user.lower()
# Attempt login
response = session.post(login_url, data=data, headers=headers)
if "Invalid credentials" in response.text:
# Prompt for password again
print("Invalid credentials. Please try again.")
passwd = getpass.getpass(prompt="Enter password: ")
keyring.set_password(f'{self.user}-temmies', self.user, passwd)
return self.login(user, passwd)
elif "Welcome, logged in as" not in response.text:
raise ValueError("Login failed for an unknown reason.")
return session
def get_year(self, start_year: int = None, end_year: int = None) -> Year:
"""
Gets a Year object using the year path (e.g., 2023, 2024).
"""
year_path = f"{start_year}-{end_year}"
return Year(self.session, year_path)
def all_years(self) -> list:
"""
Gets all visible years as Year objects.
"""
navigation_url = f"{self.base_url}/api/navigation/"
response = self.session.get(navigation_url)
if response.status_code != 200:
raise ConnectionError("Failed to retrieve years from Themis API.")
years_data = response.json()
years = []
for year_info in years_data:
if year_info.get("visible", False):
year_path = year_info["path"].strip("/")
years.append(Year(self.session, year_path))
return years

70
temmies/year.py Normal file
View File

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