mirror of
https://github.com/Code-For-Groningen/temmies.git
synced 2025-03-15 15:10:15 +01:00
Compare commits
11 Commits
6a781ad238
...
213a4d5471
Author | SHA1 | Date | |
---|---|---|---|
213a4d5471 | |||
8d0164aa42 | |||
5ba67e3b51 | |||
96488ac69c | |||
060e5df43e | |||
ad3d95a074 | |||
9740e37b64 | |||
569ac0c048 | |||
aa7b91de0d | |||
5c3e884a8b | |||
c14f87aecc |
25
.github/workflows/pylint.yml
vendored
Normal file
25
.github/workflows/pylint.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
name: Pylint
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v3
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pylint
|
||||||
|
pip install .
|
||||||
|
- name: Analyzing the code with pylint
|
||||||
|
run: |
|
||||||
|
pylint temmies
|
||||||
|
|
58
.github/workflows/python-publish.yml
vendored
Normal file
58
.github/workflows/python-publish.yml
vendored
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
name: Upload Python Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install setuptools wheel twine
|
||||||
|
|
||||||
|
- name: Build release distributions
|
||||||
|
run: |
|
||||||
|
python setup.py bdist_wheel
|
||||||
|
|
||||||
|
- name: Upload distributions
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-dists
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
pypi-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- release-build
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
environment:
|
||||||
|
name: pypi
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Retrieve release distributions
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-dists
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Publish release distributions to PyPI
|
||||||
|
env:
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
python -m pip install twine
|
||||||
|
twine upload dist/*
|
3
setup.py
3
setup.py
@ -5,7 +5,7 @@ with open("README.md", "r") as f:
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="temmies",
|
name="temmies",
|
||||||
version="1.2.1",
|
version="1.2.121",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
description="A wrapper for the Themis website",
|
description="A wrapper for the Themis website",
|
||||||
long_description=l_description,
|
long_description=l_description,
|
||||||
@ -21,6 +21,7 @@ setup(
|
|||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
],
|
],
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
"urllib3",
|
||||||
"requests",
|
"requests",
|
||||||
"lxml",
|
"lxml",
|
||||||
"beautifulsoup4",
|
"beautifulsoup4",
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
from .themis import Themis
|
"""
|
||||||
|
Entry point for the temmies package.
|
||||||
|
"""
|
||||||
import urllib3
|
import urllib3
|
||||||
|
from .themis import Themis
|
||||||
|
|
||||||
__all__ = ["Themis"]
|
__all__ = ["Themis"]
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
from .group import Group
|
"""
|
||||||
|
Represents a course.
|
||||||
|
A course is a group that contains exercises or other groups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .group import Group
|
||||||
|
from .exercise_group import ExerciseGroup
|
||||||
|
|
||||||
class Course(Group):
|
class Course(Group):
|
||||||
"""
|
"""
|
||||||
@ -25,11 +30,11 @@ class Course(Group):
|
|||||||
self,
|
self,
|
||||||
item_data.get("submitable", False),
|
item_data.get("submitable", False),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
return Group(
|
return Group(
|
||||||
self.session,
|
self.session,
|
||||||
item_data["path"],
|
item_data["path"],
|
||||||
item_data["title"],
|
item_data["title"],
|
||||||
self,
|
self,
|
||||||
item_data.get("submitable", False),
|
item_data.get("submitable", False),
|
||||||
)
|
)
|
||||||
|
@ -1,23 +1,24 @@
|
|||||||
from .group import Group
|
"""
|
||||||
from .submission import Submission
|
Represents a submittable exercise.
|
||||||
|
"""
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from .group import Group
|
||||||
|
|
||||||
class ExerciseGroup(Group):
|
class ExerciseGroup(Group):
|
||||||
"""
|
"""
|
||||||
Represents a submittable exercise.
|
Represents a submittable exercise.
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
def __init__(self, session, path: str, title: str, parent, submitable: bool = True):
|
def __init__(self, session, path: str, title: str, parent, submitable: bool = True):
|
||||||
super().__init__(session, path, title, parent, submitable=submitable)
|
super().__init__(session, path, title, parent, submitable=submitable)
|
||||||
self.submit_url = f"{self.base_url}/api/submit{self.path}"
|
self.submit_url = f"{self.base_url}/api/submit{self.path}"
|
||||||
self.__find_name()
|
self.__find_name()
|
||||||
|
|
||||||
def __find_name(self):
|
def __find_name(self):
|
||||||
"""
|
"""
|
||||||
Find the name of the exercise group.
|
Find the name of the exercise group.
|
||||||
"""
|
"""
|
||||||
if self.title == "":
|
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)
|
response = self.session.get(self.base_url + self.path)
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
title_elements = soup.find_all("a", class_="fill accent large")
|
title_elements = soup.find_all("a", class_="fill accent large")
|
||||||
@ -26,26 +27,5 @@ class ExerciseGroup(Group):
|
|||||||
else:
|
else:
|
||||||
self.title = self.path.split("/")[-1]
|
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):
|
def __str__(self):
|
||||||
return f"ExerciseGroup({self.title})"
|
return f"ExerciseGroup({self.title})"
|
||||||
|
@ -1,15 +1,22 @@
|
|||||||
from bs4 import BeautifulSoup
|
"""
|
||||||
from requests import Session
|
Abstract-ish Group class for Themis API.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Optional, Union, Dict
|
from typing import Optional, Union, Dict
|
||||||
from .exceptions.illegal_action import IllegalAction
|
from json import loads
|
||||||
|
from time import sleep
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from .submission import Submission
|
from .submission import Submission
|
||||||
|
|
||||||
|
|
||||||
class Group:
|
class Group:
|
||||||
"""
|
"""
|
||||||
Represents an item in Themis, which can be either a folder (non-submittable) or an assignment (submittable).
|
Represents an item in Themis.
|
||||||
|
Can be either a folder (non-submittable) or an assignment (submittable).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-positional-arguments
|
||||||
def __init__(self, session, path: str, title: str, parent=None, submitable: bool = False):
|
def __init__(self, session, path: str, title: str, parent=None, submitable: bool = False):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.path = path # e.g., '/2023-2024/adinc-ai/labs'
|
self.path = path # e.g., '/2023-2024/adinc-ai/labs'
|
||||||
@ -29,10 +36,9 @@ class Group:
|
|||||||
# Fetch the page and parse it
|
# Fetch the page and parse it
|
||||||
response = self.session.get(group_url)
|
response = self.session.get(group_url)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise ConnectionError(f"Failed to retrieve page for '{self.title}'. Tried {group_url}")
|
raise ConnectionError(f"Failed to retrieve page for '{self.title}'.")
|
||||||
self._raw = BeautifulSoup(response.text, "lxml")
|
self._raw = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
|
|
||||||
def get_items(self) -> list:
|
def get_items(self) -> list:
|
||||||
"""
|
"""
|
||||||
Get all items (groups and assignments) under this group.
|
Get all items (groups and assignments) under this group.
|
||||||
@ -68,19 +74,20 @@ class Group:
|
|||||||
return item
|
return item
|
||||||
raise ValueError(f"Item '{title}' not found under {self.title}.")
|
raise ValueError(f"Item '{title}' not found under {self.title}.")
|
||||||
|
|
||||||
|
|
||||||
def get_status(self, text: bool = False) -> Union[Dict[str, Union[str, 'Submission']], None]:
|
def get_status(self, text: bool = False) -> Union[Dict[str, Union[str, 'Submission']], None]:
|
||||||
"""
|
"""
|
||||||
Get the status of the current group, if available.
|
Get the status of the current group, if available.
|
||||||
"""
|
"""
|
||||||
status_link = self._raw.find("a", text="Status")
|
status_link = self._raw.find("a", text="Status")
|
||||||
if not status_link:
|
if not status_link:
|
||||||
raise ValueError("Status information is not available for this group.")
|
raise ValueError(
|
||||||
|
"Status information is not available for this group.")
|
||||||
|
|
||||||
status_url = f"{self.base_url}{status_link['href']}"
|
status_url = f"{self.base_url}{status_link['href']}"
|
||||||
response = self.session.get(status_url)
|
response = self.session.get(status_url)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise ConnectionError(f"Failed to retrieve status page for '{self.title}'.")
|
raise ConnectionError(
|
||||||
|
f"Failed to retrieve status page for '{self.title}'.")
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
section = soup.find("div", class_="cfg-container")
|
section = soup.find("div", class_="cfg-container")
|
||||||
@ -90,7 +97,10 @@ class Group:
|
|||||||
|
|
||||||
return self.__parse_status_section(section, text)
|
return self.__parse_status_section(section, text)
|
||||||
|
|
||||||
def __parse_status_section(self, section: BeautifulSoup, text: bool) -> Dict[str, Union[str, 'Submission']]:
|
def __parse_status_section(
|
||||||
|
self, section: BeautifulSoup,
|
||||||
|
text: bool
|
||||||
|
) -> Dict[str, Union[str, 'Submission']]:
|
||||||
"""
|
"""
|
||||||
Parse the status section of the group and clean up keys.
|
Parse the status section of the group and clean up keys.
|
||||||
"""
|
"""
|
||||||
@ -111,8 +121,10 @@ class Group:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Normalize key
|
# Normalize key
|
||||||
raw_key = " ".join(key_element.get_text(separator=" ").strip().replace(":", "").lower().split())
|
raw_key = " ".join(key_element.get_text(
|
||||||
key = key_mapping.get(raw_key, raw_key) # Use mapped key if available
|
separator=" ").strip().replace(":", "").lower().split())
|
||||||
|
# Use mapped key if available
|
||||||
|
key = key_mapping.get(raw_key, raw_key)
|
||||||
|
|
||||||
# Process value
|
# Process value
|
||||||
link = value_element.find("a", href=True)
|
link = value_element.find("a", href=True)
|
||||||
@ -122,7 +134,8 @@ class Group:
|
|||||||
if href.startswith("/"):
|
if href.startswith("/"):
|
||||||
submission_url = href
|
submission_url = href
|
||||||
elif href.startswith("http"):
|
elif href.startswith("http"):
|
||||||
submission_url = href.replace("https://themis.housing.rug.nl", "")
|
submission_url = href.replace(
|
||||||
|
"https://themis.housing.rug.nl", "")
|
||||||
else:
|
else:
|
||||||
print(f"Invalid href '{href}' found in status page.")
|
print(f"Invalid href '{href}' found in status page.")
|
||||||
continue # Skip this entry if href is invalid
|
continue # Skip this entry if href is invalid
|
||||||
@ -135,13 +148,13 @@ class Group:
|
|||||||
|
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def get_test_cases(self) -> list[Dict[str, str]]:
|
def get_test_cases(self) -> list[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Get all test cases for this assignment.
|
Get all test cases for this assignment.
|
||||||
"""
|
"""
|
||||||
if not self.submitable:
|
if not self.submitable:
|
||||||
raise ValueError(f"No test cases for non-submittable item '{self.title}'.")
|
raise ValueError(
|
||||||
|
f"No test cases for non-submittable item '{self.title}'.")
|
||||||
|
|
||||||
sections = self._raw.find_all("div", class_="subsec round shade")
|
sections = self._raw.find_all("div", class_="subsec round shade")
|
||||||
tcs = []
|
tcs = []
|
||||||
@ -180,7 +193,8 @@ class Group:
|
|||||||
"""
|
"""
|
||||||
Get all downloadable files for this assignment.
|
Get all downloadable files for this assignment.
|
||||||
"""
|
"""
|
||||||
details = self._raw.find("div", id=lambda x: x and x.startswith("details"))
|
details = self._raw.find(
|
||||||
|
"div", id=lambda x: x and x.startswith("details"))
|
||||||
if not details:
|
if not details:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -219,13 +233,21 @@ class Group:
|
|||||||
print(f"Failed to download file '{file['title']}'")
|
print(f"Failed to download file '{file['title']}'")
|
||||||
return downloaded
|
return downloaded
|
||||||
|
|
||||||
def submit(self, files: list[str], judge: bool = True, wait: bool = True, silent: bool = True) -> Optional[dict]:
|
# pylint: disable=too-many-locals
|
||||||
|
def submit(
|
||||||
|
self,
|
||||||
|
files: list[str],
|
||||||
|
judge: bool = True,
|
||||||
|
wait: bool = True,
|
||||||
|
silent: bool = True
|
||||||
|
) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
Submit files to this assignment.
|
Submit files to this assignment.
|
||||||
Returns a dictionary of test case results or None if wait is False.
|
Returns a dictionary of test case results or None if wait is False.
|
||||||
"""
|
"""
|
||||||
if not self.submitable:
|
if not self.submitable:
|
||||||
raise ValueError(f"Cannot submit to non-submittable item '{self.title}'.")
|
raise ValueError(
|
||||||
|
f"Cannot submit to non-submittable item '{self.title}'.")
|
||||||
|
|
||||||
form = self._raw.find("form")
|
form = self._raw.find("form")
|
||||||
if not form:
|
if not form:
|
||||||
@ -280,14 +302,22 @@ class Group:
|
|||||||
def __parse_table(self, soup: BeautifulSoup, url: str, verbose: bool, __printed: list) -> dict:
|
def __parse_table(self, soup: BeautifulSoup, url: str, verbose: bool, __printed: list) -> dict:
|
||||||
"""
|
"""
|
||||||
Parse the results table from the submission result page.
|
Parse the results table from the submission result page.
|
||||||
|
Wait until all queued status-icons disappear before parsing.
|
||||||
"""
|
"""
|
||||||
cases = soup.find_all("tr", class_="sub-casetop")
|
cases = soup.find_all("tr", class_="sub-casetop")
|
||||||
fail_pass = {}
|
fail_pass = {}
|
||||||
|
any_queued = False
|
||||||
|
|
||||||
for case in cases:
|
for case in cases:
|
||||||
name = case.find("td", class_="sub-casename").text
|
name = case.find("td", class_="sub-casename").text.strip()
|
||||||
status = case.find("td", class_="status-icon")
|
status = case.find("td", class_="status-icon")
|
||||||
|
|
||||||
if "pending" in status.get("class"):
|
status_classes = status.get("class", [])
|
||||||
|
if "queued" in status_classes:
|
||||||
|
any_queued = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if "pending" in status_classes:
|
||||||
sleep(1)
|
sleep(1)
|
||||||
return self.__wait_for_result(url, verbose, __printed)
|
return self.__wait_for_result(url, verbose, __printed)
|
||||||
|
|
||||||
@ -299,19 +329,28 @@ class Group:
|
|||||||
}
|
}
|
||||||
|
|
||||||
found = False
|
found = False
|
||||||
for k, v in statuses.items():
|
for key, (symbol, value) in statuses.items():
|
||||||
if k in status.text:
|
if key.lower() in status.text.lower():
|
||||||
found = True
|
found = True
|
||||||
if verbose and int(name) not in __printed:
|
case_number = int(name)
|
||||||
print(f"{name}: {v[0]}")
|
if verbose and case_number not in __printed:
|
||||||
fail_pass[int(name)] = v[1]
|
print(f"Case {case_number}: {symbol}")
|
||||||
|
fail_pass[case_number] = value
|
||||||
break
|
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))
|
if not found:
|
||||||
|
case_number = int(name)
|
||||||
|
fail_pass[case_number] = None
|
||||||
|
if verbose and case_number not in __printed:
|
||||||
|
print(f"{case_number}: Unrecognized status: {status.text.strip()}")
|
||||||
|
|
||||||
|
__printed.append(case_number)
|
||||||
|
|
||||||
|
# Polling (fix, use ws)
|
||||||
|
if any_queued:
|
||||||
|
sleep(1)
|
||||||
|
return self.__wait_for_result(url, verbose, __printed)
|
||||||
|
|
||||||
return fail_pass
|
return fail_pass
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
File to define the Submission class
|
File to define the Submission class
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
class Submission:
|
class Submission:
|
||||||
@ -48,7 +49,7 @@ class Submission:
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def get_info(self) -> dict[str, str] | None:
|
def get_info(self) -> Optional[dict[str, str]]:
|
||||||
"""Submission information (in details)"""
|
"""Submission information (in details)"""
|
||||||
if self.__info:
|
if self.__info:
|
||||||
return self.__info
|
return self.__info
|
||||||
@ -74,7 +75,7 @@ class Submission:
|
|||||||
return self.__info
|
return self.__info
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_files(self) -> list[str] | None:
|
def get_files(self) -> Optional[list[str]]:
|
||||||
"""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.get_info()
|
self.__info = self.get_info()
|
||||||
@ -82,13 +83,22 @@ class Submission:
|
|||||||
|
|
||||||
# Deprecated methods
|
# Deprecated methods
|
||||||
def info(self):
|
def info(self):
|
||||||
|
"""
|
||||||
|
Deprecated method. Use get_info instead.
|
||||||
|
"""
|
||||||
print("This method is deprecated and will be deleted soon. Use get_info instead.")
|
print("This method is deprecated and will be deleted soon. Use get_info instead.")
|
||||||
return self.get_info()
|
return self.get_info()
|
||||||
|
|
||||||
def test_cases(self):
|
def test_cases(self):
|
||||||
|
"""
|
||||||
|
Deprecated method. Use get_test_cases instead.
|
||||||
|
"""
|
||||||
print("This method is deprecated and will be deleted in soon. Use get_test_cases instead.")
|
print("This method is deprecated and will be deleted in soon. Use get_test_cases instead.")
|
||||||
return self.get_test_cases()
|
return self.get_test_cases()
|
||||||
|
|
||||||
def files(self):
|
def files(self):
|
||||||
|
"""
|
||||||
|
Deprecated method. Use get_files instead.
|
||||||
|
"""
|
||||||
print("This method is deprecated and will be deleted in soon. Use get_files instead.")
|
print("This method is deprecated and will be deleted in soon. Use get_files instead.")
|
||||||
return self.get_files()
|
return self.get_files()
|
||||||
|
@ -2,12 +2,12 @@
|
|||||||
Main class for the Themis API using the new JSON endpoints.
|
Main class for the Themis API using the new JSON endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import keyring
|
|
||||||
import getpass
|
import getpass
|
||||||
|
import keyring
|
||||||
from requests import Session
|
from requests import Session
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from .year import Year
|
from .year import Year
|
||||||
from .exceptions.illegal_action import IllegalAction
|
|
||||||
|
|
||||||
class Themis:
|
class Themis:
|
||||||
"""
|
"""
|
||||||
@ -34,6 +34,7 @@ class Themis:
|
|||||||
self.password = self.__get_password()
|
self.password = self.__get_password()
|
||||||
self.base_url = "https://themis.housing.rug.nl"
|
self.base_url = "https://themis.housing.rug.nl"
|
||||||
self.session = self.login(self.user, self.password)
|
self.session = self.login(self.user, self.password)
|
||||||
|
|
||||||
def __get_password(self) -> str:
|
def __get_password(self) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve the password from the keyring, prompting the user if not found.
|
Retrieve the password from the keyring, prompting the user if not found.
|
||||||
@ -41,7 +42,8 @@ class Themis:
|
|||||||
password = keyring.get_password(f"{self.user}-temmies", self.user)
|
password = keyring.get_password(f"{self.user}-temmies", self.user)
|
||||||
if not password:
|
if not password:
|
||||||
print(f"Password for user '{self.user}' not found in keyring.")
|
print(f"Password for user '{self.user}' not found in keyring.")
|
||||||
password = getpass.getpass(prompt=f"Enter password for {self.user}: ")
|
password = getpass.getpass(
|
||||||
|
prompt=f"Enter password for {self.user}: ")
|
||||||
keyring.set_password(f"{self.user}-temmies", self.user, password)
|
keyring.set_password(f"{self.user}-temmies", self.user, password)
|
||||||
print("Password saved securely in keyring.")
|
print("Password saved securely in keyring.")
|
||||||
return password
|
return password
|
||||||
@ -85,7 +87,8 @@ class Themis:
|
|||||||
passwd = getpass.getpass(prompt="Enter password: ")
|
passwd = getpass.getpass(prompt="Enter password: ")
|
||||||
keyring.set_password(f'{self.user}-temmies', self.user, passwd)
|
keyring.set_password(f'{self.user}-temmies', self.user, passwd)
|
||||||
return self.login(user, passwd)
|
return self.login(user, passwd)
|
||||||
elif "Welcome, logged in as" not in response.text:
|
|
||||||
|
if "Welcome, logged in as" not in response.text:
|
||||||
raise ValueError("Login failed for an unknown reason.")
|
raise ValueError("Login failed for an unknown reason.")
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
from .course import Course
|
"""
|
||||||
|
This module defines the Year class for managing academic year courses.
|
||||||
|
"""
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from .course import Course
|
||||||
|
|
||||||
|
|
||||||
class Year:
|
class Year:
|
||||||
"""
|
"""
|
||||||
Represents an academic year.
|
Represents an academic year.
|
||||||
@ -37,8 +43,6 @@ class Year:
|
|||||||
return course
|
return course
|
||||||
raise ValueError(f"Course '{course_title}' not found in year {self.year_path}.")
|
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:
|
def get_course_by_tag(self, course_tag: str) -> Course:
|
||||||
"""
|
"""
|
||||||
Gets a course by its tag (course identifier).
|
Gets a course by its tag (course identifier).
|
||||||
@ -49,18 +53,21 @@ class Year:
|
|||||||
|
|
||||||
response = self.session.get(course_url)
|
response = self.session.get(course_url)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise ConnectionError(f"Failed to retrieve course with tag '{course_tag}' for year {self.year_path}. Tried {course_url}")
|
raise ConnectionError(
|
||||||
|
f"Failed to retrieve course '{course_tag}' for year {self.year_path}."
|
||||||
|
)
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
title_elements = soup.find_all("a", class_="fill accent large")
|
title_elements = soup.find_all("a", class_="fill accent large")
|
||||||
if title_elements:
|
title_element = title_elements[-1] if title_elements else None
|
||||||
title_element = title_elements[-1]
|
|
||||||
|
|
||||||
if title_element:
|
if title_element:
|
||||||
course_title = title_element.get_text(strip=True)
|
course_title = title_element.get_text(strip=True)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Could not retrieve course title for tag '{course_tag}' in year {self.year_path}.")
|
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)
|
return Course(self.session, course_path, course_title, self)
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user