Compare commits

...

15 Commits

Author SHA1 Message Date
c9a2e1f456 Rewrote login, fixed timing bug 2025-03-01 17:48:19 +01:00
0ed744dff8 More "error handling" 2025-02-12 21:26:25 +01:00
03a1fd1b33 Stupid packaging mistake 2025-02-12 21:26:15 +01:00
813519f642 ujpdated requirements 2025-02-12 18:34:45 +01:00
edabec3e7c Fixed Issue #17 by adding a browser pop-up dialog 2025-02-12 18:30:09 +01:00
194daf89eb
Merge pull request #16 from Code-For-Groningen/lol
Update README.md
2024-12-03 17:27:17 +01:00
172e2a0ebf
Update README.md 2024-12-03 17:26:57 +01:00
213a4d5471 Problematic line fix 2024-12-02 18:29:13 +01:00
8d0164aa42 Fixed problematic line 2024-12-02 18:27:48 +01:00
5ba67e3b51 Replaced | syntax with Optional (Python types <3) 2024-12-02 18:27:33 +01:00
96488ac69c reformatted to make pylint happy
TODO: get rid of the ignore comments - just fix it
2024-12-02 18:21:54 +01:00
060e5df43e
Update pylint.yml 2024-12-02 18:03:53 +01:00
ad3d95a074
Update python-publish.yml 2024-12-02 17:33:17 +01:00
9740e37b64
Create python-publish.yml 2024-12-02 17:27:33 +01:00
569ac0c048
Create pylint.yml 2024-12-02 17:27:09 +01:00
12 changed files with 278 additions and 91 deletions

25
.github/workflows/pylint.yml vendored Normal file
View 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
View 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/*

View File

@ -15,7 +15,7 @@ A python library which interacts with [Themis](https://themis.housing.rug.nl/).
* [x] Submission status
## Docs
- [here](http://temmies.confest.im/).
- [Click here](http://temmies.confest.im/).
## Possible continuations
* Discord bot

View File

@ -1,3 +1,4 @@
attrs==25.1.0
beautifulsoup4==4.12.3
bs4==0.0.2
certifi==2024.8.30
@ -15,5 +16,6 @@ more-itertools==10.5.0
pycparser==2.22
requests==2.32.3
SecretStorage==3.3.3
selenium==4.28.1
soupsieve==2.6
urllib3==2.2.3

View File

@ -5,7 +5,7 @@ with open("README.md", "r") as f:
setup(
name="temmies",
version="1.2.12",
version="1.2.124",
packages=find_packages(),
description="A wrapper for the Themis website",
long_description=l_description,
@ -21,10 +21,12 @@ setup(
"Programming Language :: Python :: 3.9",
],
install_requires=[
"urllib3",
"requests",
"lxml",
"beautifulsoup4",
"keyring"
"keyring",
"selenium",
],
python_requires=">=3.9",
)

View File

@ -1,5 +1,8 @@
from .themis import Themis
"""
Entry point for the temmies package.
"""
import urllib3
from .themis import Themis
__all__ = ["Themis"]
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

View File

@ -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):
"""
@ -25,11 +30,11 @@ class Course(Group):
self,
item_data.get("submitable", False),
)
else:
return Group(
self.session,
item_data["path"],
item_data["title"],
self,
item_data.get("submitable", False),
)
return Group(
self.session,
item_data["path"],
item_data["title"],
self,
item_data.get("submitable", False),
)

View File

@ -1,12 +1,15 @@
from .group import Group
from .submission import Submission
"""
Represents a submittable exercise.
"""
from bs4 import BeautifulSoup
from .group import Group
class ExerciseGroup(Group):
"""
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):
super().__init__(session, path, title, parent, submitable=submitable)
self.submit_url = f"{self.base_url}/api/submit{self.path}"

View File

@ -1,17 +1,22 @@
from bs4 import BeautifulSoup
from requests import Session
"""
Abstract-ish Group class for Themis API.
"""
import os
from typing import Optional, Union, Dict
from .exceptions.illegal_action import IllegalAction
from .submission import Submission
from json import loads
from time import sleep
from bs4 import BeautifulSoup
from .submission import Submission
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):
self.session = session
self.path = path # e.g., '/2023-2024/adinc-ai/labs'
@ -31,10 +36,9 @@ class Group:
# 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}")
raise ConnectionError(f"Failed to retrieve page for '{self.title}'.")
self._raw = BeautifulSoup(response.text, "lxml")
def get_items(self) -> list:
"""
Get all items (groups and assignments) under this group.
@ -70,19 +74,20 @@ class Group:
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.")
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}'.")
raise ConnectionError(
f"Failed to retrieve status page for '{self.title}'.")
soup = BeautifulSoup(response.text, "lxml")
section = soup.find("div", class_="cfg-container")
@ -92,7 +97,10 @@ class Group:
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.
"""
@ -113,8 +121,10 @@ class Group:
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
raw_key = " ".join(key_element.get_text(
separator=" ").strip().replace(":", "").lower().split())
# Use mapped key if available
key = key_mapping.get(raw_key, raw_key)
# Process value
link = value_element.find("a", href=True)
@ -124,7 +134,8 @@ class Group:
if href.startswith("/"):
submission_url = href
elif href.startswith("http"):
submission_url = href.replace("https://themis.housing.rug.nl", "")
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
@ -137,13 +148,13 @@ class Group:
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}'.")
raise ValueError(
f"No test cases for non-submittable item '{self.title}'.")
sections = self._raw.find_all("div", class_="subsec round shade")
tcs = []
@ -182,7 +193,8 @@ class Group:
"""
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:
return []
@ -221,13 +233,21 @@ class Group:
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]:
# 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.
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}'.")
raise ValueError(
f"Cannot submit to non-submittable item '{self.title}'.")
form = self._raw.find("form")
if not form:
@ -326,14 +346,12 @@ class Group:
__printed.append(case_number)
# Polling
# FIXME: Use ws
# Polling (fix, use ws)
if any_queued:
sleep(1)
return self.__wait_for_result(url, verbose, __printed)
return fail_pass
def __str__(self):
return f"Group({self.title}, submitable={self.submitable})"

View File

@ -4,6 +4,7 @@
File to define the Submission class
"""
from typing import Optional
from bs4 import BeautifulSoup
class Submission:
@ -48,7 +49,7 @@ class Submission:
return results
def get_info(self) -> dict[str, str] | None:
def get_info(self) -> Optional[dict[str, str]]:
"""Submission information (in details)"""
if self.__info:
return self.__info
@ -74,7 +75,7 @@ class Submission:
return self.__info
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)]"""
if not self.__info:
self.__info = self.get_info()
@ -82,13 +83,22 @@ class Submission:
# Deprecated methods
def info(self):
"""
Deprecated method. Use get_info instead.
"""
print("This method is deprecated and will be deleted soon. Use get_info instead.")
return self.get_info()
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.")
return self.get_test_cases()
def files(self):
"""
Deprecated method. Use get_files instead.
"""
print("This method is deprecated and will be deleted in soon. Use get_files instead.")
return self.get_files()

View File

@ -2,12 +2,18 @@
Main class for the Themis API using the new JSON endpoints.
"""
import keyring
import getpass
from requests import Session
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException
from json import dumps
from .year import Year
from .exceptions.illegal_action import IllegalAction
import getpass
import keyring
class Themis:
"""
@ -17,7 +23,7 @@ class Themis:
- all_years: Get all years
"""
def __init__(self, user: str):
def __init__(self, cookies: dict = None, user=None):
"""
Initialize Themis object, logging in with the given user.
@ -30,28 +36,40 @@ class Themis:
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:
self.session = self._setup_agent()
self.user, self.password = None, None
# Old login logic
if user:
self.user = user
self.password = self._get_password()
# Reusing session logic
if not cookies:
self.session = self.login(self.session)
else:
self.session.cookies.update(cookies)
if not self.check_session():
self.session = self.login(self.session)
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}: ")
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.
"""
def _setup_agent(self) -> Session:
session = Session()
login_url = f"{self.base_url}/log/in"
user_agent = (
"Mozilla/5.0 (X11; Linux x86_64) "
@ -59,43 +77,79 @@ class Themis:
"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.")
session.headers.update({"User-Agent": user_agent})
return session
def check_session(self) -> bool:
"""
Check if the session is still valid.
"""
# look at the /login and find a pre tag
login_url = f"{self.base_url}/login"
response = self.session.get(login_url)
return "pre" in response.text
def login(self, session: Session) -> Session:
"""
Login to Themis by spawning a selenium browser
"""
login_url = f"{self.base_url}/login"
driver = webdriver.Chrome()
driver.get(login_url)
wait = WebDriverWait(driver, 60)
try:
wait.until(EC.url_contains("signon.rug.nl/nidp/saml2/sso"))
current_url = driver.current_url
# If on the sign-on page fill in the credentials
if "signon.rug.nl/nidp/saml2/sso" in current_url:
user_field = wait.until(EC.presence_of_element_located((By.NAME, "Ecom_User_ID")))
pass_field = wait.until(EC.presence_of_element_located((By.NAME, "Ecom_Password")))
if self.user and not user_field.get_attribute("value"):
user_field.clear()
user_field.send_keys(self.user)
if self.password and not pass_field.get_attribute("value"):
pass_field.clear()
pass_field.send_keys(self.password)
# THIS IS LIKELY TO BREAK AT SOME POINT
wait.until(EC.text_to_be_present_in_element((By.TAG_NAME, "body"), "Cannot GET"))
except TimeoutException:
print("Timeout waiting for login/2FA page to load.")
except (NoSuchElementException, StaleElementReferenceException) as e:
print(f"Encountered an error: {e}")
finally:
# security
self.password = "I-HAVE-BEEN-REMOVED"
cookies = driver.get_cookies()
driver.quit()
# Add all cookies to the session.
for cookie in cookies:
session.cookies.set(name=cookie["name"], value=cookie["value"])
return session
def get_session_cookies(self):
"""
Get the session cookies in json
"""
return dumps(self.session.cookies.get_dict())
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:

View File

@ -1,5 +1,11 @@
from .course import Course
"""
This module defines the Year class for managing academic year courses.
"""
from bs4 import BeautifulSoup
from .course import Course
class Year:
"""
Represents an academic year.
@ -37,8 +43,6 @@ class Year:
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).
@ -49,18 +53,21 @@ class Year:
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}")
raise ConnectionError(
f"Failed to retrieve course '{course_tag}' for year {self.year_path}."
)
soup = BeautifulSoup(response.text, "lxml")
title_elements = soup.find_all("a", class_="fill accent large")
if title_elements:
title_element = title_elements[-1]
title_element = title_elements[-1] if title_elements else None
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}.")
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)