From 79630758f179afc1e1c74b0fd821a5b86412e6df Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Mon, 13 May 2024 00:17:54 -0400 Subject: [PATCH 01/10] Format and lint files --- .gitignore | 2 + src/gradescopeapi/_config/config.py | 2 +- src/gradescopeapi/api/api.py | 15 +---- .../classes/_helpers/_course_helpers.py | 1 - src/gradescopeapi/classes/account.py | 2 +- tests/test_edit_assignment.py | 3 +- tests/test_extension.py | 1 - tests/test_submission.py | 61 +++++++++++++------ tests/test_upload.py | 3 +- 9 files changed, 51 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 3a8816c..f7cf7a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +wip/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/src/gradescopeapi/_config/config.py b/src/gradescopeapi/_config/config.py index 683d6f9..f45d2e9 100644 --- a/src/gradescopeapi/_config/config.py +++ b/src/gradescopeapi/_config/config.py @@ -4,7 +4,7 @@ from datetime import datetime import io -from typing import List, Dict, Optional +from typing import Optional from pydantic import BaseModel diff --git a/src/gradescopeapi/api/api.py b/src/gradescopeapi/api/api.py index 63c587f..8660ec0 100644 --- a/src/gradescopeapi/api/api.py +++ b/src/gradescopeapi/api/api.py @@ -1,18 +1,7 @@ from datetime import datetime -import io -from fastapi import Depends, FastAPI, HTTPException, status, UploadFile, File +from fastapi import Depends, FastAPI, HTTPException, status from typing import Dict, List -import requests -import os -import io -from fastapi import Depends, FastAPI, HTTPException, status, UploadFile, File -from typing import Dict, List -import requests -import os -from gradescopeapi._config.config import ( - LoginRequestModel, - FileUploadModel -) +from gradescopeapi._config.config import LoginRequestModel, FileUploadModel from gradescopeapi.classes.account import Account from gradescopeapi.classes.assignments import Assignment, update_assignment_date from gradescopeapi.classes.connection import GSConnection diff --git a/src/gradescopeapi/classes/_helpers/_course_helpers.py b/src/gradescopeapi/classes/_helpers/_course_helpers.py index deae268..4318df1 100644 --- a/src/gradescopeapi/classes/_helpers/_course_helpers.py +++ b/src/gradescopeapi/classes/_helpers/_course_helpers.py @@ -1,5 +1,4 @@ from bs4 import BeautifulSoup -import requests from gradescopeapi.classes.courses import Course from gradescopeapi.classes.member import Member import json diff --git a/src/gradescopeapi/classes/account.py b/src/gradescopeapi/classes/account.py index a73af36..62330fe 100644 --- a/src/gradescopeapi/classes/account.py +++ b/src/gradescopeapi/classes/account.py @@ -110,7 +110,7 @@ def get_course_users(self, course_id: str) -> List[Member]: users = get_course_members(membership_soup, course_id) return users - except Exception as e: + except Exception: return None def get_assignments(self, course_id: str) -> List[Assignment]: diff --git a/tests/test_edit_assignment.py b/tests/test_edit_assignment.py index d54f8a0..a1952c8 100644 --- a/tests/test_edit_assignment.py +++ b/tests/test_edit_assignment.py @@ -24,10 +24,11 @@ def test_valid_change_assignment(create_session): ) assert result + def test_boundary_date_assignment(create_session): """Test updating assignment with boundary date values.""" test_session = create_session("instructor") - + course_id = "753413" assignment_id = "4436170" boundary_date = datetime(1900, 1, 1) # Very old date diff --git a/tests/test_extension.py b/tests/test_extension.py index a149fa8..1ff7eac 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -111,4 +111,3 @@ def test_invalid_course_id(create_session): # Attempt to fetch or modify extensions with an invalid course ID with pytest.raises(RuntimeError, match="Failed to get extensions"): get_extensions(test_session, invalid_course_id, "4330410") - \ No newline at end of file diff --git a/tests/test_submission.py b/tests/test_submission.py index 2b609af..05f607d 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -1,7 +1,7 @@ import os from dotenv import load_dotenv import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch from gradescopeapi.classes.connection import GSConnection from gradescopeapi.classes.assignments import Assignment @@ -12,37 +12,52 @@ GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") + def get_account(account_type="instructor"): """Creates a connection and returns the account for testing""" connection = GSConnection() if account_type == "instructor": - connection.login(GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD) + connection.login( + GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD + ) else: raise ValueError("Invalid account type: must be 'instructor'") return connection.account + def test_get_assignments(): """Test fetching assignments with valid course ID.""" account = get_account("instructor") - course_id = "753413" + course_id = "753413" assignments = account.get_assignments(course_id) assert isinstance(assignments, list), "Should return a list of assignments" - assert all(isinstance(a, Assignment) for a in assignments), "All items should be Assignment instances" + assert all( + isinstance(a, Assignment) for a in assignments + ), "All items should be Assignment instances" + def test_get_assignment_submissions(): """Test fetching assignment submissions with valid course and assignment IDs.""" account = get_account("instructor") course_id = "753413" assignment_id = "4436170" - expected_submissions = { - 'submission_id': ['aws_link1.com', 'aws_link2.com'] - } - - with patch('gradescopeapi.classes.account.Account.get_assignment_submissions', return_value=expected_submissions): + expected_submissions = {"submission_id": ["aws_link1.com", "aws_link2.com"]} + + with patch( + "gradescopeapi.classes.account.Account.get_assignment_submissions", + return_value=expected_submissions, + ): submissions = account.get_assignment_submissions(course_id, assignment_id) - assert isinstance(submissions, dict), "Should return a dictionary of submissions" - assert 'submission_id' in submissions, "Dictionary should contain submission IDs" - assert all(isinstance(links, list) for links in submissions.values()), "Each submission ID should map to a list of links" + assert isinstance( + submissions, dict + ), "Should return a dictionary of submissions" + assert ( + "submission_id" in submissions + ), "Dictionary should contain submission IDs" + assert all( + isinstance(links, list) for links in submissions.values() + ), "Each submission ID should map to a list of links" + def test_get_assignment_submission_valid(): """Test fetching a specific assignment submission.""" @@ -50,21 +65,29 @@ def test_get_assignment_submission_valid(): student_email = GRADESCOPE_CI_STUDENT_EMAIL course_id = "753413" assignment_id = "4436170" - expected_links = ['aws_link1.com', 'aws_link2.com'] - - with patch('gradescopeapi.classes.account.Account.get_assignment_submission', return_value=expected_links): - submission = account.get_assignment_submission(student_email, course_id, assignment_id) + expected_links = ["aws_link1.com", "aws_link2.com"] + + with patch( + "gradescopeapi.classes.account.Account.get_assignment_submission", + return_value=expected_links, + ): + submission = account.get_assignment_submission( + student_email, course_id, assignment_id + ) assert isinstance(submission, list), "Should return a list of aws links" assert len(submission) == 2, "List should contain aws links" + def test_get_assignment_submission_no_submission_found(): """Test case when no submission is found for a given student.""" account = get_account("instructor") student_email = GRADESCOPE_CI_INSTRUCTOR_EMAIL course_id = "753413" assignment_id = "101010" - - with patch('gradescopeapi.classes.account.Account.get_assignment_submission', side_effect=Exception("No submission found")): + + with patch( + "gradescopeapi.classes.account.Account.get_assignment_submission", + side_effect=Exception("No submission found"), + ): with pytest.raises(Exception, match="No submission found"): account.get_assignment_submission(student_email, course_id, assignment_id) - diff --git a/tests/test_upload.py b/tests/test_upload.py index cc876f5..62273db 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -81,6 +81,7 @@ def test_invalid_upload(): assert submission_link is None + def test_upload_with_no_files(): test_session = new_session("student") course_id = "753413" @@ -88,5 +89,3 @@ def test_upload_with_no_files(): # No files are passed submission_link = upload_assignment(test_session, course_id, assignment_id) assert submission_link is None, "Should handle missing files gracefully" - - \ No newline at end of file From 02fce6cb5f7a67044393169e2ba8f0ee7f58a824 Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Mon, 13 May 2024 01:42:22 -0400 Subject: [PATCH 02/10] Remove classes --- src/gradescopeapi/__main__.py | 3 + src/gradescopeapi/api/api.py | 6 +- src/gradescopeapi/classes/_data_model.py | 38 +++ .../classes/_helpers/_assignment_helpers.py | 2 +- .../classes/_helpers/_course_helpers.py | 3 +- src/gradescopeapi/classes/account.py | 241 ------------------ src/gradescopeapi/classes/assignments.py | 153 ++++++++++- src/gradescopeapi/classes/connection.py | 47 ++-- src/gradescopeapi/classes/courses.py | 109 +++++++- src/gradescopeapi/classes/member.py | 15 -- tests/__init__.py | 0 tests/conftest.py | 22 +- tests/test_courses.py | 71 ++---- tests/test_submission.py | 119 ++++----- tests/test_upload.py | 58 +---- .../markdown_file.md | 0 .../python_file.py | 0 .../text_file.txt | 0 18 files changed, 411 insertions(+), 476 deletions(-) create mode 100644 src/gradescopeapi/classes/_data_model.py delete mode 100644 src/gradescopeapi/classes/account.py delete mode 100644 src/gradescopeapi/classes/member.py delete mode 100644 tests/__init__.py rename tests/{upload_files => test_upload_files}/markdown_file.md (100%) rename tests/{upload_files => test_upload_files}/python_file.py (100%) rename tests/{upload_files => test_upload_files}/text_file.txt (100%) diff --git a/src/gradescopeapi/__main__.py b/src/gradescopeapi/__main__.py index cd9ac48..60f6005 100644 --- a/src/gradescopeapi/__main__.py +++ b/src/gradescopeapi/__main__.py @@ -1,3 +1,6 @@ +# Start fastapi server + + def main(): pass diff --git a/src/gradescopeapi/api/api.py b/src/gradescopeapi/api/api.py index 8660ec0..5c3084d 100644 --- a/src/gradescopeapi/api/api.py +++ b/src/gradescopeapi/api/api.py @@ -2,13 +2,11 @@ from fastapi import Depends, FastAPI, HTTPException, status from typing import Dict, List from gradescopeapi._config.config import LoginRequestModel, FileUploadModel -from gradescopeapi.classes.account import Account -from gradescopeapi.classes.assignments import Assignment, update_assignment_date +from gradescopeapi.classes.assignments import update_assignment_date from gradescopeapi.classes.connection import GSConnection from gradescopeapi.classes.extensions import get_extensions, update_student_extension from gradescopeapi.classes.upload import upload_assignment -from gradescopeapi.classes.courses import Course -from gradescopeapi.classes.member import Member +from gradescopeapi.classes._data_model import Course, Member, Assignment app = FastAPI() diff --git a/src/gradescopeapi/classes/_data_model.py b/src/gradescopeapi/classes/_data_model.py new file mode 100644 index 0000000..c1369a4 --- /dev/null +++ b/src/gradescopeapi/classes/_data_model.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +import datetime + + +@dataclass +class Course: + name: str + full_name: str + semester: str + year: str + num_grades_published: str + num_assignments: str + + +@dataclass +class Assignment: + assignment_id: str + name: str + release_date: datetime.datetime + due_date: datetime.datetime + late_due_date: datetime.datetime | None + submissions_status: str | None + grade: str | None # change to int? + max_grade: str | None + + +@dataclass +class Member: + full_name: str + first_name: str + last_name: str + sid: str + email: str + role: str + id: str + num_submissions: int + sections: str + course_id: str diff --git a/src/gradescopeapi/classes/_helpers/_assignment_helpers.py b/src/gradescopeapi/classes/_helpers/_assignment_helpers.py index 315b93a..783f579 100644 --- a/src/gradescopeapi/classes/_helpers/_assignment_helpers.py +++ b/src/gradescopeapi/classes/_helpers/_assignment_helpers.py @@ -2,7 +2,7 @@ import json from datetime import datetime -from gradescopeapi.classes.assignments import Assignment +from gradescopeapi.classes._data_model import Assignment def check_page_auth(session, endpoint): diff --git a/src/gradescopeapi/classes/_helpers/_course_helpers.py b/src/gradescopeapi/classes/_helpers/_course_helpers.py index 4318df1..8630d69 100644 --- a/src/gradescopeapi/classes/_helpers/_course_helpers.py +++ b/src/gradescopeapi/classes/_helpers/_course_helpers.py @@ -1,6 +1,5 @@ from bs4 import BeautifulSoup -from gradescopeapi.classes.courses import Course -from gradescopeapi.classes.member import Member +from gradescopeapi.classes._data_model import Course, Member import json diff --git a/src/gradescopeapi/classes/account.py b/src/gradescopeapi/classes/account.py deleted file mode 100644 index 62330fe..0000000 --- a/src/gradescopeapi/classes/account.py +++ /dev/null @@ -1,241 +0,0 @@ -from bs4 import BeautifulSoup -from typing import List, Dict -import time - -from gradescopeapi.classes._helpers._course_helpers import ( - get_courses_info, - get_course_members, -) -from gradescopeapi.classes._helpers._assignment_helpers import ( - check_page_auth, - get_assignments_instructor_view, - get_assignments_student_view, - get_submission_files, -) -from gradescopeapi.classes.assignments import Assignment -from gradescopeapi.classes.member import Member - - -class Account: - def __init__(self, session): - self.session = session - - def get_courses(self) -> dict: - """ - Get all courses for the user, including both instructor and student courses - - Returns: - dict: A dictionary of dictionaries, where keys are "instructor" and "student" and values are - dictionaries containing all courses, where keys are course IDs and values are Course objects. - - For example: - { - 'instructor': { - "123456": Course(...), - "234567": Course(...) - }, - 'student': { - "654321": Course(...), - "765432": Course(...) - } - } - - Raises: - RuntimeError: If request to account page fails. - """ - - endpoint = "https://www.gradescope.com/account" - - # get main page - response = self.session.get(endpoint) - - if response.status_code != 200: - raise RuntimeError( - "Failed to access account page on Gradescope. Status code: {response.status_code}" - ) - - soup = BeautifulSoup(response.text, "html.parser") - - # see if user is solely a student or instructor - user_courses, is_instructor = get_courses_info(soup, "Your Courses") - - # if the user is indeed solely a student or instructor - # return the appropriate set of courses - if user_courses: - if is_instructor: - return {"instructor": user_courses, "student": {}} - else: - return {"instructor": {}, "student": user_courses} - - # if user is both a student and instructor, get both sets of courses - courses = {"instructor": {}, "student": {}} - - # get instructor courses - instructor_courses, _ = get_courses_info(soup, "Instructor Courses") - courses["instructor"] = instructor_courses - - # get student courses - student_courses, _ = get_courses_info(soup, "Student Courses") - courses["student"] = student_courses - - return courses - - def get_course_users(self, course_id: str) -> List[Member]: - """ - Get a list of all users in a course - Returns: - list: A list of users in the course (Member objects) - Raises: - Exceptions: - "One or more invalid parameters": if course_id is null or empty value - "You must be logged in to access this page.": if no user is logged in - """ - - membership_endpoint = ( - f"https://www.gradescope.com/courses/{course_id}/memberships" - ) - - # check that course_id is valid (not empty) - if not course_id: - raise Exception("Invalid Course ID") - - session = self.session - - try: - # scrape page - membership_resp = check_page_auth(session, membership_endpoint) - membership_soup = BeautifulSoup(membership_resp.text, "html.parser") - - # get all users in the course - users = get_course_members(membership_soup, course_id) - - return users - except Exception: - return None - - def get_assignments(self, course_id: str) -> List[Assignment]: - """ - Get a list of detailed assignment information for a course - Returns: - list: A list of Assignments - Raises: - Exceptions: - "One or more invalid parameters": if course_id or assignment_id is null or empty value - "You are not authorized to access this page.": if logged in user is unable to access submissions - "You must be logged in to access this page.": if no user is logged in - """ - course_endpoint = f"https://www.gradescope.com/courses/{course_id}" - # check that course_id is valid (not empty) - if not course_id: - raise Exception("Invalid Course ID") - session = self.session - # scrape page - coursepage_resp = check_page_auth(session, course_endpoint) - coursepage_soup = BeautifulSoup(coursepage_resp.text, "html.parser") - - # two different helper functions to parse assignment info - # webpage html structure differs based on if user if instructor or student - assignment_info_list = get_assignments_instructor_view(coursepage_soup) - if not assignment_info_list: - assignment_info_list = get_assignments_student_view(coursepage_soup) - - return assignment_info_list - - def get_assignment_submissions( - self, course_id: str, assignment_id: str - ) -> Dict[str, List[str]]: - """ - Get a list of dicts mapping AWS links for all submissions to each submission id - Returns: - dict: A dictionary of submissions, where the keys are the submission ids and the values are - a list of aws links to the submission pdf - For example: - { - 'submission_id': [ - 'aws_link1.com', - 'aws_link2.com', - ... - ], - ... - } - Raises: - Exceptions: - "One or more invalid parameters": if course_id or assignment_id is null or empty value - "You are not authorized to access this page.": if logged in user is unable to access submissions - "You must be logged in to access this page.": if no user is logged in - "Page not Found": When link is invalid: change in url, invalid course_if or assignment id - "Image only submissions not yet supported": assignment is image submission only, which is not yet supported - NOTE: - 1. Image submissions not supports, need to find an endpoint to retrieve image pdfs - 2. Not recommended for use, since this makes a GET request for every submission -> very slow! - 3. so far only accessible for teachers, not for students to get submissions to an assignment - """ - ASSIGNMENT_ENDPOINT = f"https://www.gradescope.com/courses/{course_id}/assignments/{assignment_id}" - ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{ASSIGNMENT_ENDPOINT}/review_grades" - if not course_id or not assignment_id: - raise Exception("One or more invalid parameters") - session = self.session - submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) - submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") - # select submissions (class of td.table--primaryLink a tag, submission id stored in href link) - submissions_a_tags = submissions_soup.select("td.table--primaryLink a") - submission_ids = [ - a_tag.attrs.get("href").split("/")[-1] for a_tag in submissions_a_tags - ] - submission_links = {} - for submission_id in submission_ids: # doesn't support image submissions yet - aws_links = get_submission_files( - session, course_id, assignment_id, submission_id - ) - submission_links[submission_id] = aws_links - # sleep for 0.1 seconds to avoid sending too many requests to gradescope - time.sleep(0.1) - return submission_links - - def get_assignment_submission( - self, student_email: str, course_id: str, assignment_id: str - ) -> List[str]: - """ - Get a list of aws links to pdfs of the student's most recent submission to an assignment - Returns: - list: A list of aws links as strings - For example: - [ - 'aws_link1.com', - 'aws_link2.com', - ... - ] - Raises: - Exceptions: - "One or more invalid parameters": if course_id or assignment_id is null or empty value - "You are not authorized to access this page.": if logged in user is unable to access submissions - "You must be logged in to access this page.": if no user is logged in - "Page not Found": When link is invalid: change in url, invalid course_if or assignment id - "Image only submissions not yet supported": assignment is image submission only, which is not yet supported - NOTE: so far only accessible for teachers, not for students to get their own submission - """ - # fetch submission id - ASSIGNMENT_ENDPOINT = f"https://www.gradescope.com/courses/{course_id}/assignments/{assignment_id}" - ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{ASSIGNMENT_ENDPOINT}/review_grades" - if not (student_email and course_id and assignment_id): - raise Exception("One or more invalid parameters") - session = self.session - submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) - submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") - td_with_email = submissions_soup.find( - "td", string=lambda s: student_email in str(s) - ) - if td_with_email: - # grab submission from previous td - submission_td = td_with_email.find_previous_sibling() - # submission_td will have an anchor element as a child if there is a submission - a_element = submission_td.find("a") - if a_element: - submission_id = a_element.get("href").split("/")[-1] - else: - raise Exception("No submission found") - # call get_submission_files helper function - aws_links = get_submission_files( - session, course_id, assignment_id, submission_id - ) - return aws_links diff --git a/src/gradescopeapi/classes/assignments.py b/src/gradescopeapi/classes/assignments.py index a2559fe..85ca749 100644 --- a/src/gradescopeapi/classes/assignments.py +++ b/src/gradescopeapi/classes/assignments.py @@ -4,19 +4,15 @@ from bs4 import BeautifulSoup import datetime from requests_toolbelt.multipart.encoder import MultipartEncoder -from dataclasses import dataclass +from gradescopeapi.classes._helpers._assignment_helpers import ( + check_page_auth, + get_assignments_instructor_view, + get_assignments_student_view, + get_submission_files, +) +import time - -@dataclass -class Assignment: - assignment_id: str - name: str - release_date: datetime.datetime - due_date: datetime.datetime - late_due_date: datetime.datetime - submissions_status: str - grade: str - max_grade: str +from gradescopeapi.classes._data_model import Assignment def update_assignment_date( @@ -83,3 +79,136 @@ def update_assignment_date( ) return response.status_code == 200 + + +def get_assignments(session: requests.Session, course_id: str) -> list[Assignment]: + """ + Get a list of detailed assignment information for a course + Returns: + list: A list of Assignments + Raises: + Exceptions: + "One or more invalid parameters": if course_id or assignment_id is null or empty value + "You are not authorized to access this page.": if logged in user is unable to access submissions + "You must be logged in to access this page.": if no user is logged in + """ + course_endpoint = f"https://www.gradescope.com/courses/{course_id}" + # check that course_id is valid (not empty) + if not course_id: + raise Exception("Invalid Course ID") + session = session + + # scrape page + coursepage_resp = check_page_auth(session, course_endpoint) + coursepage_soup = BeautifulSoup(coursepage_resp.text, "html.parser") + + # two different helper functions to parse assignment info + # webpage html structure differs based on if user if instructor or student + assignment_info_list = get_assignments_instructor_view(coursepage_soup) + if not assignment_info_list: + assignment_info_list = get_assignments_student_view(coursepage_soup) + + return assignment_info_list + + +def get_assignment_submissions( + session, course_id: str, assignment_id: str +) -> dict[str, list[str]]: + """ + Get a list of dicts mapping AWS links for all submissions to each submission id + Returns: + dict: A dictionary of submissions, where the keys are the submission ids and the values are + a list of aws links to the submission pdf + For example: + { + 'submission_id': [ + 'aws_link1.com', + 'aws_link2.com', + ... + ], + ... + } + Raises: + Exceptions: + "One or more invalid parameters": if course_id or assignment_id is null or empty value + "You are not authorized to access this page.": if logged in user is unable to access submissions + "You must be logged in to access this page.": if no user is logged in + "Page not Found": When link is invalid: change in url, invalid course_if or assignment id + "Image only submissions not yet supported": assignment is image submission only, which is not yet supported + NOTE: + 1. Image submissions not supports, need to find an endpoint to retrieve image pdfs + 2. Not recommended for use, since this makes a GET request for every submission -> very slow! + 3. so far only accessible for teachers, not for students to get submissions to an assignment + """ + ASSIGNMENT_ENDPOINT = ( + f"https://www.gradescope.com/courses/{course_id}/assignments/{assignment_id}" + ) + ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{ASSIGNMENT_ENDPOINT}/review_grades" + if not course_id or not assignment_id: + raise Exception("One or more invalid parameters") + session = session + submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) + submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") + # select submissions (class of td.table--primaryLink a tag, submission id stored in href link) + submissions_a_tags = submissions_soup.select("td.table--primaryLink a") + submission_ids = [ + a_tag.attrs.get("href").split("/")[-1] for a_tag in submissions_a_tags + ] + submission_links = {} + for submission_id in submission_ids: # doesn't support image submissions yet + aws_links = get_submission_files( + session, course_id, assignment_id, submission_id + ) + submission_links[submission_id] = aws_links + # sleep for 0.1 seconds to avoid sending too many requests to gradescope + time.sleep(0.1) + return submission_links + + +def get_assignment_submission( + session, student_email: str, course_id: str, assignment_id: str +) -> list[str]: + """ + Get a list of aws links to pdfs of the student's most recent submission to an assignment + Returns: + list: A list of aws links as strings + For example: + [ + 'aws_link1.com', + 'aws_link2.com', + ... + ] + Raises: + Exceptions: + "One or more invalid parameters": if course_id or assignment_id is null or empty value + "You are not authorized to access this page.": if logged in user is unable to access submissions + "You must be logged in to access this page.": if no user is logged in + "Page not Found": When link is invalid: change in url, invalid course_if or assignment id + "Image only submissions not yet supported": assignment is image submission only, which is not yet supported + NOTE: so far only accessible for teachers, not for students to get their own submission + """ + # fetch submission id + ASSIGNMENT_ENDPOINT = ( + f"https://www.gradescope.com/courses/{course_id}/assignments/{assignment_id}" + ) + ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{ASSIGNMENT_ENDPOINT}/review_grades" + if not (student_email and course_id and assignment_id): + raise Exception("One or more invalid parameters") + session = session + submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) + submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") + td_with_email = submissions_soup.find( + "td", string=lambda s: student_email in str(s) + ) + if td_with_email: + # grab submission from previous td + submission_td = td_with_email.find_previous_sibling() + # submission_td will have an anchor element as a child if there is a submission + a_element = submission_td.find("a") + if a_element: + submission_id = a_element.get("href").split("/")[-1] + else: + raise Exception("No submission found") + # call get_submission_files helper function + aws_links = get_submission_files(session, course_id, assignment_id, submission_id) + return aws_links diff --git a/src/gradescopeapi/classes/connection.py b/src/gradescopeapi/classes/connection.py index 35e1727..6abf788 100644 --- a/src/gradescopeapi/classes/connection.py +++ b/src/gradescopeapi/classes/connection.py @@ -5,25 +5,28 @@ login_set_session_cookies, ) -from gradescopeapi.classes.account import Account - - -class GSConnection: - def __init__(self): - self.session = requests.Session() - self.logged_in = False - self.account = None - - def login(self, email, password): - # go to homepage to parse hidden authenticity token and to set initial "_gradescope_session" cookie - auth_token = get_auth_token_init_gradescope_session(self.session) - - # login and set cookies in session. Result bool on whether login was success - login_success = login_set_session_cookies( - self.session, email, password, auth_token - ) - if login_success: - self.logged_in = True - self.account = Account(self.session) - else: - raise ValueError("Invalid credentials.") + +def login(email: str, password: str) -> requests.Session: + """Logs into Gradescope and returns the session. + + Args: + email (str): The email of the user. + password (str): The password of the user. + + Returns: + requests.Session: The session object. + + Raises: + ValueError: If the login credentials are invalid. + """ + session = requests.Session() + + # go to homepage to parse hidden authenticity token and to set initial "_gradescope_session" cookie + auth_token = get_auth_token_init_gradescope_session(session) + + # login and set cookies in session. Result bool on whether login was success + login_success = login_set_session_cookies(session, email, password, auth_token) + if not login_success: + raise ValueError("Invalid credentials.") + + return session diff --git a/src/gradescopeapi/classes/courses.py b/src/gradescopeapi/classes/courses.py index fb6b634..807fb9f 100644 --- a/src/gradescopeapi/classes/courses.py +++ b/src/gradescopeapi/classes/courses.py @@ -1,11 +1,102 @@ -from dataclasses import dataclass +from bs4 import BeautifulSoup +from gradescopeapi.classes._helpers._course_helpers import ( + get_courses_info, + get_course_members, +) +from gradescopeapi.classes._helpers._assignment_helpers import ( + check_page_auth, +) +from gradescopeapi.classes._data_model import Member -@dataclass -class Course: - name: str - full_name: str - semester: str - year: str - num_grades_published: str - num_assignments: str +import requests + + +def get_courses(session: requests.Session) -> dict: + """Gets all courses for the user, including both instructor and student courses + + Returns: + dict: A dictionary of dictionaries, where keys are "instructor" and "student" and values are + dictionaries containing all courses, where keys are course IDs and values are Course objects. + + For example: + { + 'instructor': { + "123456": Course(...), + "234567": Course(...) + }, + 'student': { + "654321": Course(...), + "765432": Course(...) + } + } + + Raises: + RuntimeError: If request to account page fails. + """ + + endpoint = "https://www.gradescope.com/account" + + # get main page + response = session.get(endpoint) + + if response.status_code != 200: + raise RuntimeError( + "Failed to access account page on Gradescope. Status code: {response.status_code}" + ) + + soup = BeautifulSoup(response.text, "html.parser") + + # see if user is solely a student or instructor + user_courses, is_instructor = get_courses_info(soup, "Your Courses") + + # if the user is indeed solely a student or instructor + # return the appropriate set of courses + if user_courses: + if is_instructor: + return {"instructor": user_courses, "student": {}} + else: + return {"instructor": {}, "student": user_courses} + + # if user is both a student and instructor, get both sets of courses + courses = {"instructor": {}, "student": {}} + + # get instructor courses + instructor_courses, _ = get_courses_info(soup, "Instructor Courses") + courses["instructor"] = instructor_courses + + # get student courses + student_courses, _ = get_courses_info(soup, "Student Courses") + courses["student"] = student_courses + + return courses + + +def get_course_users(session: requests.Session, course_id: str) -> list[Member]: + """ + Get a list of all users in a course + Returns: + list: A list of users in the course (Member objects) + Raises: + Exceptions: + "One or more invalid parameters": if course_id is null or empty value + "You must be logged in to access this page.": if no user is logged in + """ + + membership_endpoint = f"https://www.gradescope.com/courses/{course_id}/memberships" + + # check that course_id is valid (not empty) + if not course_id: + raise Exception("Invalid Course ID") + + try: + # scrape page + membership_resp = check_page_auth(session, membership_endpoint) + membership_soup = BeautifulSoup(membership_resp.text, "html.parser") + + # get all users in the course + users = get_course_members(membership_soup, course_id) + + return users + except Exception: + return None diff --git a/src/gradescopeapi/classes/member.py b/src/gradescopeapi/classes/member.py deleted file mode 100644 index 93c6e07..0000000 --- a/src/gradescopeapi/classes/member.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass - - -@dataclass -class Member: - full_name: str - first_name: str - last_name: str - sid: str - email: str - role: str - id: str - num_submissions: int - sections: str - course_id: str diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py index 4fb3e72..476ecbf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,36 +1,44 @@ import pytest -from gradescopeapi.classes.connection import GSConnection +from gradescopeapi.classes.connection import login from dotenv import load_dotenv import os load_dotenv() +""" +Student: enrolled in courses only as a student +Instructor: enrolled in courses only as an instructor +TA: enrolled in different courses as both a student and instructor +""" GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") +GRADESCOPE_CI_TA_EMAIL = os.getenv("GRADESCOPE_CI_TA_EMAIL") +GRADESCOPE_CI_TA_PASSWORD = os.getenv("GRADESCOPE_CI_TA_PASSWORD") @pytest.fixture def create_session(): def _create_session(account_type: str = "student"): """Creates and returns a session for testing""" - connection = GSConnection() match account_type.lower(): case "student": - connection.login( + return login( GRADESCOPE_CI_STUDENT_EMAIL, GRADESCOPE_CI_STUDENT_PASSWORD ) case "instructor": - connection.login( + return login( GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD ) + case "ta": + return login( + GRADESCOPE_CI_TA_EMAIL, GRADESCOPE_CI_TA_PASSWORD + ) case _: raise ValueError( - "Invalid account type: must be 'student' or 'instructor'" + "Invalid account type: must be 'student' or 'instructor' or 'ta'" ) - return connection.session - return _create_session diff --git a/tests/test_courses.py b/tests/test_courses.py index eff67f4..3b42632 100644 --- a/tests/test_courses.py +++ b/tests/test_courses.py @@ -1,90 +1,55 @@ -import os -from dotenv import load_dotenv - -from gradescopeapi.classes.connection import GSConnection - -# load .env file -load_dotenv() -GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") -GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") -GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") -GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") -GRADESCOPE_CI_TA_EMAIL = os.getenv("GRADESCOPE_CI_TA_EMAIL") -GRADESCOPE_CI_TA_PASSWORD = os.getenv("GRADESCOPE_CI_TA_PASSWORD") - - -def get_account(account_type="student"): - """Creates a connection and returns the account for testing""" - connection = GSConnection() - - match account_type.lower(): - case "student": - connection.login( - GRADESCOPE_CI_STUDENT_EMAIL, GRADESCOPE_CI_STUDENT_PASSWORD - ) - case "instructor": - connection.login( - GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD - ) - case "ta": - connection.login(GRADESCOPE_CI_TA_EMAIL, GRADESCOPE_CI_TA_PASSWORD) - case _: - raise ValueError( - "Invalid account type: must be 'student' or 'instructor' or 'ta'" - ) - - return connection.account - - -def test_get_courses_student(): +from gradescopeapi.classes.courses import get_courses, get_course_users + + +def test_get_courses_student(create_session): # fetch student account - account = get_account("student") + session = create_session("student") # get student courses - courses = account.get_courses() + courses = get_courses(session) assert courses["instructor"] == {} and courses["student"] != {} -def test_get_courses_instructor(): +def test_get_courses_instructor(create_session): # fetch instructor account - account = get_account("instructor") + session = create_session("instructor") # get instructor courses - courses = account.get_courses() + courses = get_courses(session) assert courses["instructor"] != {} and courses["student"] == {} -def test_get_courses_ta(): +def test_get_courses_ta(create_session): # fetch ta account - account = get_account("ta") + session = create_session("ta") # get ta courses - courses = account.get_courses() + courses = get_courses(session) assert courses["instructor"] != {} and courses["student"] != {} -def test_membership_invalid(): +def test_membership_invalid(create_session): # fetch instructor account - account = get_account("instructor") + session = create_session("instructor") invalid_course_id = "1111111" # get course members - members = account.get_course_users(invalid_course_id) + members = get_course_users(session, invalid_course_id) assert members is None -def test_membership(): +def test_membership(create_session): # fetch instructor account - account = get_account("instructor") + session = create_session("instructor") course_id = "753413" # get course members - members = account.get_course_users(course_id) + members = get_course_users(session, course_id) assert members is not None and len(members) > 0 diff --git a/tests/test_submission.py b/tests/test_submission.py index 05f607d..9c5fe36 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -3,8 +3,9 @@ import pytest from unittest.mock import patch -from gradescopeapi.classes.connection import GSConnection -from gradescopeapi.classes.assignments import Assignment +from gradescopeapi.classes._data_model import Assignment + +from gradescopeapi.classes.assignments import get_assignments # Load .env file load_dotenv() @@ -13,81 +14,69 @@ GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") -def get_account(account_type="instructor"): - """Creates a connection and returns the account for testing""" - connection = GSConnection() - if account_type == "instructor": - connection.login( - GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD - ) - else: - raise ValueError("Invalid account type: must be 'instructor'") - return connection.account - - -def test_get_assignments(): +def test_get_assignments(create_session): """Test fetching assignments with valid course ID.""" - account = get_account("instructor") + session = create_session("instructor") course_id = "753413" - assignments = account.get_assignments(course_id) + assignments = get_assignments(session, course_id) assert isinstance(assignments, list), "Should return a list of assignments" assert all( isinstance(a, Assignment) for a in assignments ), "All items should be Assignment instances" -def test_get_assignment_submissions(): - """Test fetching assignment submissions with valid course and assignment IDs.""" - account = get_account("instructor") - course_id = "753413" - assignment_id = "4436170" - expected_submissions = {"submission_id": ["aws_link1.com", "aws_link2.com"]} +# def test_get_assignment_submissions(create_session): +# """Test fetching assignment submissions with valid course and assignment IDs.""" +# session = create_session("instructor") +# course_id = "753413" +# assignment_id = "4436170" +# expected_submissions = {"submission_id": ["aws_link1.com", "aws_link2.com"]} - with patch( - "gradescopeapi.classes.account.Account.get_assignment_submissions", - return_value=expected_submissions, - ): - submissions = account.get_assignment_submissions(course_id, assignment_id) - assert isinstance( - submissions, dict - ), "Should return a dictionary of submissions" - assert ( - "submission_id" in submissions - ), "Dictionary should contain submission IDs" - assert all( - isinstance(links, list) for links in submissions.values() - ), "Each submission ID should map to a list of links" +# with patch( +# "gradescopeapi.classes.account.Account.get_assignment_submissions", +# return_value=expected_submissions, +# ): +# submissions = account.get_assignment_submissions(course_id, assignment_id) +# assert isinstance( +# submissions, dict +# ), "Should return a dictionary of submissions" +# assert ( +# "submission_id" in submissions +# ), "Dictionary should contain submission IDs" +# assert all( +# isinstance(links, list) for links in submissions.values() +# ), "Each submission ID should map to a list of links" -def test_get_assignment_submission_valid(): - """Test fetching a specific assignment submission.""" - account = get_account("instructor") - student_email = GRADESCOPE_CI_STUDENT_EMAIL - course_id = "753413" - assignment_id = "4436170" - expected_links = ["aws_link1.com", "aws_link2.com"] +# def test_get_assignment_submission_valid(): +# """Test fetching a specific assignment submission.""" +# account = get_account("instructor") +# student_email = GRADESCOPE_CI_STUDENT_EMAIL +# course_id = "753413" +# assignment_id = "4436170" +# expected_links = ["aws_link1.com", "aws_link2.com"] - with patch( - "gradescopeapi.classes.account.Account.get_assignment_submission", - return_value=expected_links, - ): - submission = account.get_assignment_submission( - student_email, course_id, assignment_id - ) - assert isinstance(submission, list), "Should return a list of aws links" - assert len(submission) == 2, "List should contain aws links" +# with patch( +# "gradescopeapi.classes.account.Account.get_assignment_submission", +# return_value=expected_links, +# ): +# submission = account.get_assignment_submission( +# student_email, course_id, assignment_id +# ) +# assert isinstance(submission, list), "Should return a list of aws links" +# assert len(submission) == 2, "List should contain aws links" -def test_get_assignment_submission_no_submission_found(): - """Test case when no submission is found for a given student.""" - account = get_account("instructor") - student_email = GRADESCOPE_CI_INSTRUCTOR_EMAIL - course_id = "753413" - assignment_id = "101010" +# def test_get_assignment_submission_no_submission_found(): +# """Test case when no submission is found for a given student.""" +# account = get_account("instructor") +# student_email = GRADESCOPE_CI_INSTRUCTOR_EMAIL +# course_id = "753413" +# assignment_id = "101010" - with patch( - "gradescopeapi.classes.account.Account.get_assignment_submission", - side_effect=Exception("No submission found"), - ): - with pytest.raises(Exception, match="No submission found"): - account.get_assignment_submission(student_email, course_id, assignment_id) +# with patch( +# "gradescopeapi.classes.account.Account.get_assignment_submission", +# side_effect=Exception("No submission found"), +# ): +# with pytest.raises(Exception, match="No submission found"): +# account.get_assignment_submission(student_email, course_id, assignment_id) diff --git a/tests/test_upload.py b/tests/test_upload.py index 62273db..ec9170a 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -1,49 +1,17 @@ -import os -from dotenv import load_dotenv - -from gradescopeapi.classes.connection import GSConnection - from gradescopeapi.classes.upload import upload_assignment -# load .env file -load_dotenv() - -GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") -GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") -GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") -GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") - - -def new_session(account_type="student"): - """Creates and returns a session for testing""" - connection = GSConnection() - - match account_type.lower(): - case "student": - connection.login( - GRADESCOPE_CI_STUDENT_EMAIL, GRADESCOPE_CI_STUDENT_PASSWORD - ) - case "instructor": - connection.login( - GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD - ) - case _: - raise ValueError("Invalid account type: must be 'student' or 'instructor'") - - return connection.session - -def test_valid_upload(): +def test_valid_upload(create_session): # create test session - test_session = new_session("student") + test_session = create_session("student") course_id = "753413" assignment_id = "4455030" with ( - open("tests/upload_files/text_file.txt", "rb") as text_file, - open("tests/upload_files/markdown_file.md", "rb") as markdown_file, - open("tests/upload_files/python_file.py", "rb") as python_file, + open("tests/test_upload_files/text_file.txt", "rb") as text_file, + open("tests/test_upload_files/markdown_file.md", "rb") as markdown_file, + open("tests/test_upload_files/python_file.py", "rb") as python_file, ): submission_link = upload_assignment( test_session, @@ -55,20 +23,20 @@ def test_valid_upload(): leaderboard_name="test", ) - assert submission_link is not None + assert submission_link is not None, "Failed to upload assignment. Double check due dates on Gradescope to ensure the assignment is still open." -def test_invalid_upload(): +def test_invalid_upload(create_session): # create test session - test_session = new_session("student") + test_session = create_session("student") course_id = "753413" invalid_assignment_id = "1111111" with ( - open("tests/upload_files/text_file.txt", "rb") as text_file, - open("tests/upload_files/markdown_file.md", "rb") as markdown_file, - open("tests/upload_files/python_file.py", "rb") as python_file, + open("tests/test_upload_files/text_file.txt", "rb") as text_file, + open("tests/test_upload_files/markdown_file.md", "rb") as markdown_file, + open("tests/test_upload_files/python_file.py", "rb") as python_file, ): submission_link = upload_assignment( test_session, @@ -82,8 +50,8 @@ def test_invalid_upload(): assert submission_link is None -def test_upload_with_no_files(): - test_session = new_session("student") +def test_upload_with_no_files(create_session): + test_session = create_session("student") course_id = "753413" assignment_id = "4455030" # No files are passed diff --git a/tests/upload_files/markdown_file.md b/tests/test_upload_files/markdown_file.md similarity index 100% rename from tests/upload_files/markdown_file.md rename to tests/test_upload_files/markdown_file.md diff --git a/tests/upload_files/python_file.py b/tests/test_upload_files/python_file.py similarity index 100% rename from tests/upload_files/python_file.py rename to tests/test_upload_files/python_file.py diff --git a/tests/upload_files/text_file.txt b/tests/test_upload_files/text_file.txt similarity index 100% rename from tests/upload_files/text_file.txt rename to tests/test_upload_files/text_file.txt From c84b1a0868630b520f92476ed6b2d8646a2d752a Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Sun, 19 May 2024 15:51:09 -0400 Subject: [PATCH 03/10] Refactor tests so they are skipped if credentials are missing --- tests/conftest.py | 28 +++++++++------------------- tests/custom_skips.py | 31 +++++++++++++++++++++++++++++++ tests/test_connection.py | 4 ++++ tests/test_courses.py | 6 ++++++ tests/test_edit_assignment.py | 3 +++ tests/test_extension.py | 7 +++++++ tests/test_submission.py | 12 ++---------- tests/test_upload.py | 8 +++++++- 8 files changed, 69 insertions(+), 30 deletions(-) create mode 100644 tests/custom_skips.py diff --git a/tests/conftest.py b/tests/conftest.py index 476ecbf..9fb18f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,21 +1,13 @@ import pytest from gradescopeapi.classes.connection import login -from dotenv import load_dotenv -import os - -load_dotenv() - -""" -Student: enrolled in courses only as a student -Instructor: enrolled in courses only as an instructor -TA: enrolled in different courses as both a student and instructor -""" -GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") -GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") -GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") -GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") -GRADESCOPE_CI_TA_EMAIL = os.getenv("GRADESCOPE_CI_TA_EMAIL") -GRADESCOPE_CI_TA_PASSWORD = os.getenv("GRADESCOPE_CI_TA_PASSWORD") +from custom_skips import ( + GRADESCOPE_CI_STUDENT_EMAIL, + GRADESCOPE_CI_STUDENT_PASSWORD, + GRADESCOPE_CI_INSTRUCTOR_EMAIL, + GRADESCOPE_CI_INSTRUCTOR_PASSWORD, + GRADESCOPE_CI_TA_EMAIL, + GRADESCOPE_CI_TA_PASSWORD, +) @pytest.fixture @@ -33,9 +25,7 @@ def _create_session(account_type: str = "student"): GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD ) case "ta": - return login( - GRADESCOPE_CI_TA_EMAIL, GRADESCOPE_CI_TA_PASSWORD - ) + return login(GRADESCOPE_CI_TA_EMAIL, GRADESCOPE_CI_TA_PASSWORD) case _: raise ValueError( "Invalid account type: must be 'student' or 'instructor' or 'ta'" diff --git a/tests/custom_skips.py b/tests/custom_skips.py new file mode 100644 index 0000000..9c5d6f6 --- /dev/null +++ b/tests/custom_skips.py @@ -0,0 +1,31 @@ +import pytest +import os +from dotenv import load_dotenv + +load_dotenv() + +""" +Student: enrolled in courses only as a student +Instructor: enrolled in courses only as an instructor +TA: enrolled in different courses as both a student and instructor +""" + +GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") +GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") +GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") +GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") +GRADESCOPE_CI_TA_EMAIL = os.getenv("GRADESCOPE_CI_TA_EMAIL") +GRADESCOPE_CI_TA_PASSWORD = os.getenv("GRADESCOPE_CI_TA_PASSWORD") + +instructor = pytest.mark.skipif( + GRADESCOPE_CI_INSTRUCTOR_EMAIL is None or GRADESCOPE_CI_INSTRUCTOR_PASSWORD is None, + reason="Instructor credentials not provided in environment variables", +) +student = pytest.mark.skipif( + GRADESCOPE_CI_STUDENT_EMAIL is None or GRADESCOPE_CI_STUDENT_PASSWORD is None, + reason="Student credentials not provided in environment variables", +) +ta = pytest.mark.skipif( + GRADESCOPE_CI_TA_EMAIL is None or GRADESCOPE_CI_TA_PASSWORD is None, + reason="TA credentials not provided in environment variables", +) diff --git a/tests/test_connection.py b/tests/test_connection.py index be363b2..8926a61 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -7,6 +7,8 @@ get_auth_token_init_gradescope_session, ) +from custom_skips import student + # load .env file load_dotenv() @@ -14,6 +16,7 @@ GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") +@student def test_get_auth_token_init_gradescope_session(): # create test session test_session = requests.Session() @@ -27,6 +30,7 @@ def test_get_auth_token_init_gradescope_session(): assert auth_token and cookie_check +@student def test_login_set_session_cookies_correct_creds(): # create test session test_session = requests.Session() diff --git a/tests/test_courses.py b/tests/test_courses.py index 3b42632..e72630f 100644 --- a/tests/test_courses.py +++ b/tests/test_courses.py @@ -1,6 +1,8 @@ from gradescopeapi.classes.courses import get_courses, get_course_users +from custom_skips import instructor, student, ta +@student def test_get_courses_student(create_session): # fetch student account session = create_session("student") @@ -11,6 +13,7 @@ def test_get_courses_student(create_session): assert courses["instructor"] == {} and courses["student"] != {} +@instructor def test_get_courses_instructor(create_session): # fetch instructor account session = create_session("instructor") @@ -21,6 +24,7 @@ def test_get_courses_instructor(create_session): assert courses["instructor"] != {} and courses["student"] == {} +@ta def test_get_courses_ta(create_session): # fetch ta account session = create_session("ta") @@ -31,6 +35,7 @@ def test_get_courses_ta(create_session): assert courses["instructor"] != {} and courses["student"] != {} +@instructor def test_membership_invalid(create_session): # fetch instructor account session = create_session("instructor") @@ -43,6 +48,7 @@ def test_membership_invalid(create_session): assert members is None +@instructor def test_membership(create_session): # fetch instructor account session = create_session("instructor") diff --git a/tests/test_edit_assignment.py b/tests/test_edit_assignment.py index a1952c8..59f4397 100644 --- a/tests/test_edit_assignment.py +++ b/tests/test_edit_assignment.py @@ -1,8 +1,10 @@ from datetime import datetime, timedelta from gradescopeapi.classes.assignments import update_assignment_date +from custom_skips import instructor +@instructor def test_valid_change_assignment(create_session): """Test valid extension for a student.""" # create test session @@ -25,6 +27,7 @@ def test_valid_change_assignment(create_session): assert result +@instructor def test_boundary_date_assignment(create_session): """Test updating assignment with boundary date values.""" test_session = create_session("instructor") diff --git a/tests/test_extension.py b/tests/test_extension.py index 1ff7eac..bb02bca 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -2,8 +2,10 @@ from datetime import datetime, timedelta from gradescopeapi.classes.extensions import get_extensions, update_student_extension +from custom_skips import instructor +@instructor def test_get_extensions(create_session): """Test fetching extensions for an assignment.""" # create test session @@ -18,6 +20,7 @@ def test_get_extensions(create_session): ), f"Got 0 extensions for course {course_id} and assignment {assignment_id}" +@instructor def test_valid_change_extension(create_session): """Test granting a valid extension for a student.""" # create test session @@ -42,6 +45,7 @@ def test_valid_change_extension(create_session): assert result, "Failed to update student extension" +@instructor def test_invalid_change_extension(create_session): """Test granting an invalid extension for a student due to invalid dates.""" # create test session @@ -69,6 +73,7 @@ def test_invalid_change_extension(create_session): ) +@instructor def test_invalid_user_id(create_session): """Test granting an invalid extension for a student due to invalid user ID.""" test_session = create_session("instructor") @@ -92,6 +97,7 @@ def test_invalid_user_id(create_session): assert not result, "Function should indicate failure when given an invalid user ID" +@instructor def test_invalid_assignment_id(create_session): """Test extension handling with an invalid assignment ID.""" test_session = create_session("instructor") @@ -103,6 +109,7 @@ def test_invalid_assignment_id(create_session): get_extensions(test_session, course_id, invalid_assignment_id) +@instructor def test_invalid_course_id(create_session): """Test extension handling with an invalid course ID.""" test_session = create_session("instructor") diff --git a/tests/test_submission.py b/tests/test_submission.py index 9c5fe36..8fa8c6e 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -1,19 +1,11 @@ -import os -from dotenv import load_dotenv -import pytest -from unittest.mock import patch - from gradescopeapi.classes._data_model import Assignment from gradescopeapi.classes.assignments import get_assignments -# Load .env file -load_dotenv() -GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") -GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") -GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") +from custom_skips import instructor +@instructor def test_get_assignments(create_session): """Test fetching assignments with valid course ID.""" session = create_session("instructor") diff --git a/tests/test_upload.py b/tests/test_upload.py index ec9170a..b1c1e60 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -1,6 +1,8 @@ from gradescopeapi.classes.upload import upload_assignment +from custom_skips import student +@student def test_valid_upload(create_session): # create test session test_session = create_session("student") @@ -23,9 +25,12 @@ def test_valid_upload(create_session): leaderboard_name="test", ) - assert submission_link is not None, "Failed to upload assignment. Double check due dates on Gradescope to ensure the assignment is still open." + assert ( + submission_link is not None + ), "Failed to upload assignment. Double check due dates on Gradescope to ensure the assignment is still open." +@student def test_invalid_upload(create_session): # create test session test_session = create_session("student") @@ -50,6 +55,7 @@ def test_invalid_upload(create_session): assert submission_link is None +@student def test_upload_with_no_files(create_session): test_session = create_session("student") course_id = "753413" From 2a9bc2efa5335dbebcfc65fbdd22f07aab8e75cf Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Sun, 19 May 2024 21:38:00 -0400 Subject: [PATCH 04/10] Sort imports --- src/gradescopeapi/classes/_data_model.py | 2 +- .../classes/_helpers/_assignment_helpers.py | 2 +- .../classes/_helpers/_course_helpers.py | 3 ++- .../classes/_helpers/_login_helpers.py | 2 +- src/gradescopeapi/classes/assignments.py | 9 +++++---- src/gradescopeapi/classes/courses.py | 13 ++++++------- src/gradescopeapi/classes/extensions.py | 7 ++++--- src/gradescopeapi/classes/upload.py | 7 ++++--- tests/test_connection.py | 9 ++++----- tests/test_courses.py | 2 +- tests/test_edit_assignment.py | 2 +- tests/test_extension.py | 4 ++-- tests/test_submission.py | 4 +--- tests/test_upload.py | 2 +- 14 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/gradescopeapi/classes/_data_model.py b/src/gradescopeapi/classes/_data_model.py index c1369a4..6a1b586 100644 --- a/src/gradescopeapi/classes/_data_model.py +++ b/src/gradescopeapi/classes/_data_model.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass import datetime +from dataclasses import dataclass @dataclass diff --git a/src/gradescopeapi/classes/_helpers/_assignment_helpers.py b/src/gradescopeapi/classes/_helpers/_assignment_helpers.py index 783f579..e6ebe91 100644 --- a/src/gradescopeapi/classes/_helpers/_assignment_helpers.py +++ b/src/gradescopeapi/classes/_helpers/_assignment_helpers.py @@ -1,7 +1,7 @@ -import requests import json from datetime import datetime +import requests from gradescopeapi.classes._data_model import Assignment diff --git a/src/gradescopeapi/classes/_helpers/_course_helpers.py b/src/gradescopeapi/classes/_helpers/_course_helpers.py index 8630d69..59990d2 100644 --- a/src/gradescopeapi/classes/_helpers/_course_helpers.py +++ b/src/gradescopeapi/classes/_helpers/_course_helpers.py @@ -1,6 +1,7 @@ +import json + from bs4 import BeautifulSoup from gradescopeapi.classes._data_model import Course, Member -import json def get_courses_info( diff --git a/src/gradescopeapi/classes/_helpers/_login_helpers.py b/src/gradescopeapi/classes/_helpers/_login_helpers.py index 425cf70..21d663f 100644 --- a/src/gradescopeapi/classes/_helpers/_login_helpers.py +++ b/src/gradescopeapi/classes/_helpers/_login_helpers.py @@ -1,5 +1,5 @@ -from bs4 import BeautifulSoup import requests +from bs4 import BeautifulSoup def get_auth_token_init_gradescope_session(session: requests.Session) -> str: diff --git a/src/gradescopeapi/classes/assignments.py b/src/gradescopeapi/classes/assignments.py index 85ca749..cf45c49 100644 --- a/src/gradescopeapi/classes/assignments.py +++ b/src/gradescopeapi/classes/assignments.py @@ -1,18 +1,19 @@ """Functions for modifying assignment details.""" +import datetime +import time + import requests from bs4 import BeautifulSoup -import datetime from requests_toolbelt.multipart.encoder import MultipartEncoder + +from gradescopeapi.classes._data_model import Assignment from gradescopeapi.classes._helpers._assignment_helpers import ( check_page_auth, get_assignments_instructor_view, get_assignments_student_view, get_submission_files, ) -import time - -from gradescopeapi.classes._data_model import Assignment def update_assignment_date( diff --git a/src/gradescopeapi/classes/courses.py b/src/gradescopeapi/classes/courses.py index 807fb9f..5cc28ce 100644 --- a/src/gradescopeapi/classes/courses.py +++ b/src/gradescopeapi/classes/courses.py @@ -1,15 +1,14 @@ +import requests from bs4 import BeautifulSoup -from gradescopeapi.classes._helpers._course_helpers import ( - get_courses_info, - get_course_members, -) +from gradescopeapi.classes._data_model import Member from gradescopeapi.classes._helpers._assignment_helpers import ( check_page_auth, ) -from gradescopeapi.classes._data_model import Member - -import requests +from gradescopeapi.classes._helpers._course_helpers import ( + get_course_members, + get_courses_info, +) def get_courses(session: requests.Session) -> dict: diff --git a/src/gradescopeapi/classes/extensions.py b/src/gradescopeapi/classes/extensions.py index 724e506..ee8dfe1 100644 --- a/src/gradescopeapi/classes/extensions.py +++ b/src/gradescopeapi/classes/extensions.py @@ -10,12 +10,13 @@ - `remove_student_extension`: Removes the extension for a specific student. """ -import requests -from bs4 import BeautifulSoup -from dataclasses import dataclass import datetime import json +from dataclasses import dataclass + +import requests import zoneinfo +from bs4 import BeautifulSoup @dataclass diff --git a/src/gradescopeapi/classes/upload.py b/src/gradescopeapi/classes/upload.py index 618eb27..7a82b22 100644 --- a/src/gradescopeapi/classes/upload.py +++ b/src/gradescopeapi/classes/upload.py @@ -1,11 +1,12 @@ """Functions for uploading assignments to Gradescope.""" +import io +import mimetypes +import pathlib + import requests from bs4 import BeautifulSoup from requests_toolbelt.multipart.encoder import MultipartEncoder -import mimetypes -import io -import pathlib def upload_assignment( diff --git a/tests/test_connection.py b/tests/test_connection.py index 8926a61..851ecb1 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,14 +1,13 @@ -import requests import os -from dotenv import load_dotenv +import requests +from custom_skips import student +from dotenv import load_dotenv from gradescopeapi.classes._helpers._login_helpers import ( - login_set_session_cookies, get_auth_token_init_gradescope_session, + login_set_session_cookies, ) -from custom_skips import student - # load .env file load_dotenv() diff --git a/tests/test_courses.py b/tests/test_courses.py index e72630f..0c835db 100644 --- a/tests/test_courses.py +++ b/tests/test_courses.py @@ -1,5 +1,5 @@ -from gradescopeapi.classes.courses import get_courses, get_course_users from custom_skips import instructor, student, ta +from gradescopeapi.classes.courses import get_course_users, get_courses @student diff --git a/tests/test_edit_assignment.py b/tests/test_edit_assignment.py index 59f4397..7b49ccb 100644 --- a/tests/test_edit_assignment.py +++ b/tests/test_edit_assignment.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta -from gradescopeapi.classes.assignments import update_assignment_date from custom_skips import instructor +from gradescopeapi.classes.assignments import update_assignment_date @instructor diff --git a/tests/test_extension.py b/tests/test_extension.py index bb02bca..7192bf2 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,8 +1,8 @@ -import pytest from datetime import datetime, timedelta -from gradescopeapi.classes.extensions import get_extensions, update_student_extension +import pytest from custom_skips import instructor +from gradescopeapi.classes.extensions import get_extensions, update_student_extension @instructor diff --git a/tests/test_submission.py b/tests/test_submission.py index 8fa8c6e..2560fb8 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -1,9 +1,7 @@ +from custom_skips import instructor from gradescopeapi.classes._data_model import Assignment - from gradescopeapi.classes.assignments import get_assignments -from custom_skips import instructor - @instructor def test_get_assignments(create_session): diff --git a/tests/test_upload.py b/tests/test_upload.py index b1c1e60..ad03798 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -1,5 +1,5 @@ -from gradescopeapi.classes.upload import upload_assignment from custom_skips import student +from gradescopeapi.classes.upload import upload_assignment @student From 6e87c13a7ef45e00eca55b07040a673d30fe484b Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Mon, 20 May 2024 00:04:59 -0400 Subject: [PATCH 05/10] Refactor API to support multiple sessions --- .vscode/launch.json | 16 ++ pdm.lock | 109 +++++++- pyproject.toml | 8 +- requirements.txt | 4 + src/gradescopeapi/_config/config.py | 3 +- src/gradescopeapi/api/api.py | 370 ------------------------- src/gradescopeapi/api/app.py | 13 + src/gradescopeapi/api/constants.py | 5 - src/gradescopeapi/api/models.py | 0 src/gradescopeapi/api/routes/auth.py | 58 ++++ src/gradescopeapi/api/routes/course.py | 61 ++++ tests/conftest.py | 6 +- tests/custom_skips.py | 3 +- tests/test_api.py | 0 14 files changed, 272 insertions(+), 384 deletions(-) create mode 100644 .vscode/launch.json delete mode 100644 src/gradescopeapi/api/api.py create mode 100644 src/gradescopeapi/api/app.py delete mode 100644 src/gradescopeapi/api/constants.py create mode 100644 src/gradescopeapi/api/models.py create mode 100644 src/gradescopeapi/api/routes/auth.py create mode 100644 src/gradescopeapi/api/routes/course.py create mode 100644 tests/test_api.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..506bf2f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "configurations": [ + { + "name": "Python Debugger: FastAPI", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "args": [ + "app:app", + "--reload" + ], + "jinja": true, + "cwd": "${workspaceFolder}/src/gradescopeapi/api" + } + ] +} diff --git a/pdm.lock b/pdm.lock index 1285dde..1f2b17c 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:a368aab1401e9b4ae3822bf0547bba364fddb9cf020686b2ffadef997ea7a9a2" +content_hash = "sha256:7f005d1b13c641041487d00ba95dc50a0ce3a6614d4f003e784ccf796cb235d6" [[package]] name = "annotated-types" @@ -58,6 +58,30 @@ files = [ {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] +[[package]] +name = "cffi" +version = "1.16.0" +requires_python = ">=3.8" +summary = "Foreign Function Interface for Python calling C code." +groups = ["default"] +marker = "platform_python_implementation != \"PyPy\"" +dependencies = [ + "pycparser", +] +files = [ + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -121,6 +145,50 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "42.0.7" +requires_python = ">=3.7" +summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +groups = ["default"] +dependencies = [ + "cffi>=1.12; platform_python_implementation != \"PyPy\"", +] +files = [ + {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a987f840718078212fdf4504d0fd4c6effe34a7e4740378e59d47696e8dfb477"}, + {file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bd13b5e9b543532453de08bcdc3cc7cebec6f9883e886fd20a92f26940fd3e7a"}, + {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a79165431551042cc9d1d90e6145d5d0d3ab0f2d66326c201d9b0e7f5bf43604"}, + {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47787a5e3649008a1102d3df55424e86606c9bae6fb77ac59afe06d234605f8"}, + {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:02c0eee2d7133bdbbc5e24441258d5d2244beb31da5ed19fbb80315f4bbbff55"}, + {file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e44507bf8d14b36b8389b226665d597bc0f18ea035d75b4e53c7b1ea84583cc"}, + {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f8b25fa616d8b846aef64b15c606bb0828dbc35faf90566eb139aa9cff67af2"}, + {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:93a3209f6bb2b33e725ed08ee0991b92976dfdcf4e8b38646540674fc7508e13"}, + {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6b8f1881dac458c34778d0a424ae5769de30544fc678eac51c1c8bb2183e9da"}, + {file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3de9a45d3b2b7d8088c3fbf1ed4395dfeff79d07842217b38df14ef09ce1d8d7"}, + {file = "cryptography-42.0.7-cp37-abi3-win32.whl", hash = "sha256:789caea816c6704f63f6241a519bfa347f72fbd67ba28d04636b7c6b7da94b0b"}, + {file = "cryptography-42.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:8cb8ce7c3347fcf9446f201dc30e2d5a3c898d009126010cbd1f443f28b52678"}, + {file = "cryptography-42.0.7-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:a3a5ac8b56fe37f3125e5b72b61dcde43283e5370827f5233893d461b7360cd4"}, + {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:779245e13b9a6638df14641d029add5dc17edbef6ec915688f3acb9e720a5858"}, + {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d563795db98b4cd57742a78a288cdbdc9daedac29f2239793071fe114f13785"}, + {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:31adb7d06fe4383226c3e963471f6837742889b3c4caa55aac20ad951bc8ffda"}, + {file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:efd0bf5205240182e0f13bcaea41be4fdf5c22c5129fc7ced4a0282ac86998c9"}, + {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a9bc127cdc4ecf87a5ea22a2556cab6c7eda2923f84e4f3cc588e8470ce4e42e"}, + {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3577d029bc3f4827dd5bf8bf7710cac13527b470bbf1820a3f394adb38ed7d5f"}, + {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e47577f9b18723fa294b0ea9a17d5e53a227867a0a4904a1a076d1646d45ca1"}, + {file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1a58839984d9cb34c855197043eaae2c187d930ca6d644612843b4fe8513c886"}, + {file = "cryptography-42.0.7-cp39-abi3-win32.whl", hash = "sha256:e6b79d0adb01aae87e8a44c2b64bc3f3fe59515280e00fb6d57a7267a2583cda"}, + {file = "cryptography-42.0.7-cp39-abi3-win_amd64.whl", hash = "sha256:16268d46086bb8ad5bf0a2b5544d8a9ed87a0e33f5e77dd3c3301e63d941a83b"}, + {file = "cryptography-42.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2954fccea107026512b15afb4aa664a5640cd0af630e2ee3962f2602693f0c82"}, + {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:362e7197754c231797ec45ee081f3088a27a47c6c01eff2ac83f60f85a50fe60"}, + {file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f698edacf9c9e0371112792558d2f705b5645076cc0aaae02f816a0171770fd"}, + {file = "cryptography-42.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5482e789294854c28237bba77c4c83be698be740e31a3ae5e879ee5444166582"}, + {file = "cryptography-42.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e9b2a6309f14c0497f348d08a065d52f3020656f675819fc405fb63bbcd26562"}, + {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d8e3098721b84392ee45af2dd554c947c32cc52f862b6a3ae982dbb90f577f14"}, + {file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c65f96dad14f8528a447414125e1fc8feb2ad5a272b8f68477abbcc1ea7d94b9"}, + {file = "cryptography-42.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36017400817987670037fbb0324d71489b6ead6231c9604f8fc1f7d008087c68"}, + {file = "cryptography-42.0.7.tar.gz", hash = "sha256:ecbfbc00bf55888edda9868a4cf927205de8499e7fabe6c050322298382953f2"}, +] + [[package]] name = "distlib" version = "0.3.8" @@ -476,6 +544,18 @@ files = [ {file = "pre_commit-3.7.0.tar.gz", hash = "sha256:e209d61b8acdcf742404408531f0c37d49d2c734fd7cff2d6076083d191cb060"}, ] +[[package]] +name = "pycparser" +version = "2.22" +requires_python = ">=3.8" +summary = "C parser in Python" +groups = ["default"] +marker = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.7.1" @@ -545,6 +625,33 @@ files = [ {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, ] +[[package]] +name = "pyjwt" +version = "2.8.0" +requires_python = ">=3.7" +summary = "JSON Web Token implementation in Python" +groups = ["default"] +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + +[[package]] +name = "pyjwt" +version = "2.8.0" +extras = ["crypto"] +requires_python = ">=3.7" +summary = "JSON Web Token implementation in Python" +groups = ["default"] +dependencies = [ + "cryptography>=3.4.0", + "pyjwt==2.8.0", +] +files = [ + {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"}, + {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"}, +] + [[package]] name = "pytest" version = "8.2.0" diff --git a/pyproject.toml b/pyproject.toml index 2628d16..e25479d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ dependencies = [ "beautifulsoup4>=4.12.3", "fastapi>=0.111.0", + "pyjwt[crypto]>=2.8.0", "pytest>=8.2.0", "python-dotenv>=1.0.1", "requests-toolbelt>=1.0.0", @@ -51,10 +52,11 @@ dev = [ ] [tool.pdm.scripts] +dev = "fastapi dev src/gradescopeapi/api/api.py" export = "pdm export -f requirements -o requirements.txt --without-hashes" format = "ruff format src tests" format-test = "ruff format --check src tests" -lint = "ruff check src tests" -lint-fix = "ruff check --fix src tests" -start = "python -m gradescopeapi" +lint = "ruff check --extend-select I src tests" +lint-fix = "ruff check --extend-select I --fix src tests" +start = "fastapi run src/gradescopeapi/api/api.py" test = "pytest tests" diff --git a/requirements.txt b/requirements.txt index da57cd8..7293039 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,10 +5,12 @@ annotated-types==0.6.0 anyio==4.3.0 beautifulsoup4==4.12.3 certifi==2024.2.2 +cffi==1.16.0; platform_python_implementation != "PyPy" cfgv==3.4.0 charset-normalizer==3.3.2 click==8.1.7 colorama==0.4.6; sys_platform == "win32" or platform_system == "Windows" +cryptography==42.0.7 distlib==0.3.8 dnspython==2.6.1 email-validator==2.1.1 @@ -34,9 +36,11 @@ packaging==23.2 platformdirs==4.2.0 pluggy==1.5.0 pre-commit==3.7.0 +pycparser==2.22; platform_python_implementation != "PyPy" pydantic==2.7.1 pydantic-core==2.18.2 pygments==2.18.0 +pyjwt==2.8.0 pytest==8.2.0 python-dotenv==1.0.1 python-multipart==0.0.9 diff --git a/src/gradescopeapi/_config/config.py b/src/gradescopeapi/_config/config.py index f45d2e9..68d0aec 100644 --- a/src/gradescopeapi/_config/config.py +++ b/src/gradescopeapi/_config/config.py @@ -2,9 +2,10 @@ Configuration file for FastAPI. Specifies the specific objects and data models used in our api """ -from datetime import datetime import io +from datetime import datetime from typing import Optional + from pydantic import BaseModel diff --git a/src/gradescopeapi/api/api.py b/src/gradescopeapi/api/api.py deleted file mode 100644 index 5c3084d..0000000 --- a/src/gradescopeapi/api/api.py +++ /dev/null @@ -1,370 +0,0 @@ -from datetime import datetime -from fastapi import Depends, FastAPI, HTTPException, status -from typing import Dict, List -from gradescopeapi._config.config import LoginRequestModel, FileUploadModel -from gradescopeapi.classes.assignments import update_assignment_date -from gradescopeapi.classes.connection import GSConnection -from gradescopeapi.classes.extensions import get_extensions, update_student_extension -from gradescopeapi.classes.upload import upload_assignment -from gradescopeapi.classes._data_model import Course, Member, Assignment - -app = FastAPI() - -# Create instance of GSConnection, to be used where needed -connection = GSConnection() - - -def get_gs_connection(): - """ - Returns the GSConnection instance - - Returns: - connection (GSConnection): an instance of the GSConnection class, - containing the session object used to make HTTP requests, - a boolean defining True/False if the user is logged in, and - the user's Account object. - """ - return connection - - -def get_gs_connection_session(): - """ - Returns session of the the GSConnection instance - - Returns: - connection.session (GSConnection.session): an instance of the GSConnection class' session object used to make HTTP requests - """ - return connection.session - - -def get_account(): - """ - Returns the user's Account object - - Returns: - Account (Account): an instance of the Account class, containing - methods for interacting with the user's courses and assignments. - """ - return Account(session=get_gs_connection_session) - - -# Create instance of GSConnection, to be used where needed -connection = GSConnection() - -account = None - - -@app.get("/") -def root(): - return {"message": "Hello World"} - - -@app.post("/login", name="login") -def login( - login_data: LoginRequestModel, - gs_connection: GSConnection = Depends(get_gs_connection), -): - """Login to Gradescope, with correct credentials - - Args: - username (str): email address of user attempting to log in - password (str): password of user attempting to log in - - Raises: - HTTPException: If the request to login fails, with a 404 Unauthorized Error status code and the error message "Account not found". - """ - user_email = login_data.email - password = login_data.password - - try: - connection.login(user_email, password) - global account - account = connection.account - return {"message": "Login successful", "status_code": status.HTTP_200_OK} - except ValueError as e: - raise HTTPException(status_code=404, detail=f"Account not found. Error {e}") - - -@app.post("/courses", response_model=Dict[str, Dict[str, Course]]) -def get_courses(): - """Get all courses for the user - - Args: - account (Account): Account object containing the user's courses - - Returns: - dict: dictionary of dictionaries - - Raises: - HTTPException: If the request to get courses fails, with a 500 Internal Server Error status code and the error message. - """ - try: - course_list = account.get_courses() - return course_list - except RuntimeError as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/course_users", response_model=List[Member]) -def get_course_users(course_id: str): - """Get all users for a course. ONLY FOR INSTRUCTORS. - - Args: - course_id (str): The ID of the course. - - Returns: - dict: dictionary of dictionaries - - Raises: - HTTPException: If the request to get courses fails, with a 500 Internal Server Error status code and the error message. - """ - try: - course_list = connection.account.get_course_users(course_id) - print(course_list) - return course_list - except RuntimeError as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/assignments", response_model=List[Assignment]) -def get_assignments(course_id: str): - """Get all assignments for a course. ONLY FOR INSTRUCTORS. - list: list of user emails - - Raises: - HTTPException: If the request to get course users fails, with a 500 Internal Server Error status code and the error message. - """ - try: - course_users = connection.account.get_assignments(course_id) - return course_users - except RuntimeError as e: - raise HTTPException( - status_code=500, detail=f"Failed to get course users. Error {e}" - ) - - -@app.post("/assignment_submissions", response_model=Dict[str, List[str]]) -def get_assignment_submissions( - course_id: str, - assignment_id: str, -): - """Get all assignment submissions for an assignment. ONLY FOR INSTRUCTORS. - - Args: - course_id (str): The ID of the course. - assignment_id (str): The ID of the assignment. - - Returns: - list: list of Assignment objects - - Raises: - HTTPException: If the request to get assignments fails, with a 500 Internal Server Error status code and the error message. - """ - try: - assignment_list = connection.account.get_assignment_submissions( - course_id=course_id, assignment_id=assignment_id - ) - return assignment_list - except RuntimeError as e: - raise HTTPException( - status_code=500, detail=f"Failed to get assignments. Error: {e}" - ) - - -@app.post("/single_assignment_submission", response_model=List[str]) -def get_student_assignment_submission( - student_email: str, course_id: str, assignment_id: str -): - """Get a student's assignment submission. ONLY FOR INSTRUCTORS. - - Args: - student_email (str): The email address of the student. - course_id (str): The ID of the course. - assignment_id (str): The ID of the assignment. - - Returns: - dict: dictionary containing a list of student emails and their corresponding submission IDs - - Raises: - HTTPException: If the request to get assignment submissions fails, with a 500 Internal Server Error status code and the error message. - """ - try: - assignment_submissions = connection.account.get_assignment_submission( - student_email=student_email, - course_id=course_id, - assignment_id=assignment_id, - ) - return assignment_submissions - except RuntimeError as e: - raise HTTPException( - status_code=500, detail=f"Failed to get assignment submissions. Error: {e}" - ) - - -@app.post("/assignments/update_dates") -def update_assignment_dates( - course_id: str, - assignment_id: str, - release_date: datetime, - due_date: datetime, - late_due_date: datetime, -): - """ - Update the release and due dates for an assignment. ONLY FOR INSTRUCTORS. - - Args: - course_id (str): The ID of the course. - assignment_id (str): The ID of the assignment. - release_date (datetime): The release date of the assignment. - due_date (datetime): The due date of the assignment. - late_due_date (datetime): The late due date of the assignment. - - Notes: - The timezone for dates used in Gradescope is specific to an institution. For example, for NYU, the timezone is America/New_York. - For datetime objects passed to this function, the timezone should be set to the institution's timezone. - - Returns: - dict: A dictionary with a "message" key indicating if the assignment dates were updated successfully. - - Raises: - HTTPException: If the assignment dates update fails, with a 400 Bad Request status code and the error message "Failed to update assignment dates". - """ - try: - print(f"late due date {late_due_date}") - success = update_assignment_date( - session=connection.session, - course_id=course_id, - assignment_id=assignment_id, - release_date=release_date, - due_date=due_date, - late_due_date=late_due_date, - ) - if success: - return { - "message": "Assignment dates updated successfully", - "status_code": status.HTTP_200_OK, - } - else: - raise HTTPException( - status_code=400, detail="Failed to update assignment dates" - ) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/assignments/extensions", response_model=dict) -def get_assignment_extensions(course_id: str, assignment_id: str): - """ - Get all extensions for an assignment. - - Args: - course_id (str): The ID of the course. - assignment_id (str): The ID of the assignment. - - Returns: - dict: A dictionary containing the extensions, where the keys are user IDs and the values are Extension objects. - - Raises: - HTTPException: If the request to get extensions fails, with a 500 Internal Server Error status code and the error message. - """ - try: - extensions = get_extensions( - session=connection.session, - course_id=course_id, - assignment_id=assignment_id, - ) - return extensions - except RuntimeError as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/assignments/extensions/update") -def update_extension( - course_id: str, - assignment_id: str, - user_id: str, - release_date: datetime, - due_date: datetime, - late_due_date: datetime, -): - """ - Update the extension for a student on an assignment. ONLY FOR INSTRUCTORS. - - Args: - course_id (str): The ID of the course. - assignment_id (str): The ID of the assignment. - user_id (str): The ID of the student. - release_date (datetime): The release date of the extension. - due_date (datetime): The due date of the extension. - late_due_date (datetime): The late due date of the extension. - - Returns: - dict: A dictionary with a "message" key indicating if the extension was updated successfully. - - Raises: - HTTPException: If the extension update fails, with a 400 Bad Request status code and the error message. - HTTPException: If a ValueError is raised (e.g., invalid date order), with a 400 Bad Request status code and the error message. - HTTPException: If any other exception occurs, with a 500 Internal Server Error status code and the error message. - """ - try: - success = update_student_extension( - session=connection.session, - course_id=course_id, - assignment_id=assignment_id, - user_id=user_id, - release_date=release_date, - due_date=due_date, - late_due_date=late_due_date, - ) - if success: - return { - "message": "Extension updated successfully", - "status_code": status.HTTP_200_OK, - } - else: - raise HTTPException(status_code=400, detail="Failed to update extension") - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@app.post("/assignments/upload") -def upload_assignment_files( - course_id: str, assignment_id: str, leaderboard_name: str, file: FileUploadModel -): - """ - Upload files for an assignment. - - NOTE: This function within FastAPI is currently nonfunctional, as we did not - find the datatype for file, which would allow us to upload a file via - Postman. However, this functionality works correctly if a user - runs this as a Python package. - - Args: - course_id (str): The ID of the course on Gradescope. - assignment_id (str): The ID of the assignment on Gradescope. - leaderboard_name (str): The name of the leaderboard. - file (FileUploadModel): The file object to upload. - - Returns: - dict: A dictionary containing the submission link for the uploaded files. - - Raises: - HTTPException: If the upload fails, with a 400 Bad Request status code and the error message "Upload unsuccessful". - HTTPException: If any other exception occurs, with a 500 Internal Server Error status code and the error message. - """ - try: - submission_link = upload_assignment( - session=connection.session, - course_id=course_id, - assignment_id=assignment_id, - files=file, - leaderboard_name=leaderboard_name, - ) - if submission_link: - return {"submission_link": submission_link} - else: - raise HTTPException(status_code=400, detail="Upload unsuccessful") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/gradescopeapi/api/app.py b/src/gradescopeapi/api/app.py new file mode 100644 index 0000000..393ee56 --- /dev/null +++ b/src/gradescopeapi/api/app.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from gradescopeapi.api.routes import auth, course + +app = FastAPI() +app.include_router(auth.router) +app.include_router(course.router) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/gradescopeapi/api/constants.py b/src/gradescopeapi/api/constants.py deleted file mode 100644 index 57b7fc9..0000000 --- a/src/gradescopeapi/api/constants.py +++ /dev/null @@ -1,5 +0,0 @@ -"""constants.py -Constants file for FastAPI. Specifies any variable or other object which should remain the same across all environments. -""" - -BASE_URL = "https://www.gradescope.com" diff --git a/src/gradescopeapi/api/models.py b/src/gradescopeapi/api/models.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gradescopeapi/api/routes/auth.py b/src/gradescopeapi/api/routes/auth.py new file mode 100644 index 0000000..b9275ce --- /dev/null +++ b/src/gradescopeapi/api/routes/auth.py @@ -0,0 +1,58 @@ +from typing import Annotated +from uuid import UUID, uuid4 + +import gradescopeapi.classes.connection +import requests +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm + +router = APIRouter() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +# TODO: Clear sessions after a certain amount of time +sessions = {} + + +def check_session(session_id: UUID | None): + if session_id not in sessions or session_id is None: + raise HTTPException(status_code=401, detail=f"Invalid session ID: {session_id}") + return sessions[session_id] + + +async def get_current_session(token: Annotated[UUID, Depends(oauth2_scheme)]): + session = sessions.get(token, None) + if session is None: + raise HTTPException( + status_code=401, detail=f"Invalid session ID{token=} {session=}" + ) + return session + + +@router.post("/token") +def login(credentials: Annotated[OAuth2PasswordRequestForm, Depends()]): + """ + Logs in the user using the provided credentials. + + Args: + credentials (Credentials): An object containing the user's email and password. + + Returns: + dict or str: If the login is successful, returns a dictionary with a session ID. + If the login fails due to invalid credentials, returns the string "Invalid credentials". + """ + try: + session = gradescopeapi.classes.connection.login( + credentials.username, credentials.password + ) + session_id = str(uuid4()) + sessions[session_id] = session + + return {"access_token": session_id, "token_type": "bearer"} + except ValueError as e: + return HTTPException(status_code=401, detail=f"Invalid credentials. Error {e}") + + +@router.get("/") +def read_root(session: Annotated[requests.Session, Depends(get_current_session)]): + return "Successfully logged in" diff --git a/src/gradescopeapi/api/routes/course.py b/src/gradescopeapi/api/routes/course.py new file mode 100644 index 0000000..ee31577 --- /dev/null +++ b/src/gradescopeapi/api/routes/course.py @@ -0,0 +1,61 @@ +from typing import Annotated + +import gradescopeapi.classes.assignments +import gradescopeapi.classes.courses +from fastapi import APIRouter, Depends, HTTPException +from gradescopeapi.api.routes.auth import get_current_session + +router = APIRouter( + prefix="/courses", + tags=["courses"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/") +def get_courses(session: Annotated[str, Depends(get_current_session)]): + return gradescopeapi.classes.courses.get_courses(session) + + +@router.get("/{course_id}") +def get_course(course_id: str, session: Annotated[str, Depends(get_current_session)]): + return HTTPException(status_code=404, detail="Not implemented") + + +@router.get("/{course_id}/members") +def get_course_members( + course_id: str, session: Annotated[str, Depends(get_current_session)] +): + return gradescopeapi.classes.courses.get_course_users(session, course_id) + + +@router.get("/{course_id}/assignments") +def get_all_assignments( + course_id: str, session: Annotated[str, Depends(get_current_session)] +): + return gradescopeapi.classes.assignments.get_assignments(session, course_id) + + +@router.get("/{course_id}/assignments/{assignment_id}") +def get_single_assignment( + course_id: str, + assignment_id: str, + session: Annotated[str, Depends(get_current_session)], +): + raise HTTPException(status_code=404, detail="Not implemented") + + +@router.get("/{course_id}/assignments/{assignment_id}/submissions") +def get_assingment_all_submissions( + course_id: str, + assignment_id: str, + session: Annotated[str, Depends(get_current_session)], +): + return gradescopeapi.classes.assignments.get_assignment_submissions( + session, course_id, assignment_id + ) + + +@router.get("/{course_id}/assignments/{assignment_id}/submissions/{user_id}") +def get_assignment_single_submission(course_id: str, assignment_id: str, user_id: str): + raise HTTPException(status_code=404, detail="Not implemented") diff --git a/tests/conftest.py b/tests/conftest.py index 9fb18f6..9e99a8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,13 @@ import pytest -from gradescopeapi.classes.connection import login from custom_skips import ( - GRADESCOPE_CI_STUDENT_EMAIL, - GRADESCOPE_CI_STUDENT_PASSWORD, GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD, + GRADESCOPE_CI_STUDENT_EMAIL, + GRADESCOPE_CI_STUDENT_PASSWORD, GRADESCOPE_CI_TA_EMAIL, GRADESCOPE_CI_TA_PASSWORD, ) +from gradescopeapi.classes.connection import login @pytest.fixture diff --git a/tests/custom_skips.py b/tests/custom_skips.py index 9c5d6f6..3514b06 100644 --- a/tests/custom_skips.py +++ b/tests/custom_skips.py @@ -1,5 +1,6 @@ -import pytest import os + +import pytest from dotenv import load_dotenv load_dotenv() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..e69de29 From 2d4ba585868992cf6fcf12646ff8d5c7c557d8d9 Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Tue, 28 May 2024 00:08:49 -0400 Subject: [PATCH 06/10] Add markdown formatter --- .github/ISSUE_TEMPLATE/bug-report.md | 28 ++++++++------ .github/ISSUE_TEMPLATE/feature_request.md | 7 ++-- .github/pull_request_template.md | 9 ++++- .pre-commit-config.yaml | 28 +++++++------- README.md | 46 +++++++++++------------ docs/CONTRIBUTING.md | 42 +++++++++------------ docs/INSTALL.md | 22 ++++------- docs/TESTING.md | 4 +- pdm.lock | 20 ++++++++-- pyproject.toml | 19 +++++++++- requirements.txt | 1 + tests/test_upload_files/markdown_file.md | 2 +- 12 files changed, 126 insertions(+), 102 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index a49e9c6..5d50c0e 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,21 +1,23 @@ ---- +______________________________________________________________________ + name: Bug Report about: What is the bug about? title: Priority labels: bug assignees: '' ---- +______________________________________________________________________ **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +1. Click on '....' +1. Scroll down to '....' +1. See error **Expected behavior** A clear and concise description of what you expected to happen. @@ -24,15 +26,17 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: \[e.g. iOS\] +- Browser \[e.g. chrome, safari\] +- Version \[e.g. 22\] **Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] + +- Device: \[e.g. iPhone6\] +- OS: \[e.g. iOS8.1\] +- Browser \[e.g. stock browser, safari\] +- Version \[e.g. 22\] **Additional context** Add any other context about the problem here.... diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 11fc491..84fe8d3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,14 +1,15 @@ ---- +______________________________________________________________________ + name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' ---- +______________________________________________________________________ **Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +A clear and concise description of what the problem is. Ex. I'm always frustrated when \[...\] **Describe the solution you'd like** A clear and concise description of what you want to happen. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0ccf5b0..c38a914 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,15 +1,20 @@ ### Summary + _Provide an overview..._ ### Details + _Add more context to describe the changes..._ ### Checks -- [ ] Tested changes -- [ ] Attached Logs + +- \[ \] Tested changes +- \[ \] Attached Logs ### Team to Review + _Mention the name of the team to review the PR_ ### Reference to the issue + _Mention the issue ID or resources_ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7cdff0e..2e37468 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,21 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.2.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files -# export python requirements -- repo: https://github.com/pdm-project/pdm + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + # export python requirements + - repo: https://github.com/pdm-project/pdm rev: 2.15.1 # a PDM release exposing the hook hooks: - - id: pdm-export + - id: pdm-export # command arguments, e.g.: - args: ['-o', 'requirements.txt', '--without-hashes'] + args: ["-o", "requirements.txt", "--without-hashes"] files: ^pdm.lock$ -# format pyproject.toml file -- repo: https://github.com/kieran-ryan/pyprojectsort - rev: v0.3.0 - hooks: - - id: pyprojectsort + # format pyproject.toml file + - repo: https://github.com/kieran-ryan/pyprojectsort + rev: v0.3.0 + hooks: + - id: pyprojectsort diff --git a/README.md b/README.md index ef5347e..107d073 100644 --- a/README.md +++ b/README.md @@ -6,27 +6,26 @@ This *unofficial* project serves as a library for programmatically interacting w For example: -* Students using this project could automatically query information about their courses and assignments to notify them of upcoming deadlines or new assignments. -* Instructors could use this project bulk edit assignment due dates or sync student extensions with an external system. +- Students using this project could automatically query information about their courses and assignments to notify them of upcoming deadlines or new assignments. +- Instructors could use this project bulk edit assignment due dates or sync student extensions with an external system. ## Features Implemented Features Include: -* Get all courses for a user -* Get a list of all assignments for a course -* Get all extensions for an assignment in a course -* Add/remove/modify extensions for an assignment in a course -* Add/remove/modify dates for an assignment in a course -* Upload submissions to assignments -* API server to interact with library without Python +- Get all courses for a user +- Get a list of all assignments for a course +- Get all extensions for an assignment in a course +- Add/remove/modify extensions for an assignment in a course +- Add/remove/modify dates for an assignment in a course +- Upload submissions to assignments +- API server to interact with library without Python +## Demo -## Demo To get a feel for how the API works, we have provided a demo video of the features in-use: [link](https://youtu.be/eK9m4nVjU1A?si=6GTevv23Vym0Mu8V) -Note that we only demo interacting with the API server, you can alternatively use the Python library directly. - +Note that we only demo interacting with the API server, you can alternatively use the Python library directly. ## Setup @@ -39,6 +38,7 @@ pip install gradescopeapi For additional methods of installation, refer to the [install guide](docs/INSTALL.md) ## Usage + The project is designed to be simple and easy to use. As such, we have provided users with two different options for using this project. ### Option 1: FastAPI @@ -50,24 +50,22 @@ If you do not want to use Python, you can host the API using the integrated Fast To run the API server locally on your machine, open the project repository on your machine that you have cloned/forked, and: 1. Navigate to the `src.gradescopeapi.api` directory -2. Run the command: `uvicorn api:app --reload` to run the server locally -3. In a web browser, navigate to `localhost:8000/docs`, to see the auto-generated FastAPI docs - +1. Run the command: `uvicorn api:app --reload` to run the server locally +1. In a web browser, navigate to `localhost:8000/docs`, to see the auto-generated FastAPI docs ### Option 2: Python + Alternatively, you can use Python to use the library directly. We have provided some sample scripts of common tasks one might do: ```python -from gradescopeapi.classes.connection import GSConnection +from gradescopeapi.classes.connection import login +from gradescopeapi.classes.courses import get_all_courses -# create connection and login -connection = GSConnection() -connection.login("email@domain.com", "password") +# Login to Gradescope +session = login("email", "password") -""" -Fetching all courses for user -""" -courses = connection.account.get_courses() +# Fetch all courses for the user +courses = get_courses(session) for course in courses["instructor"]: print(course) for course in courses["student"]: @@ -93,7 +91,7 @@ For more examples of features not covered here such as changing extensions, uplo ## Testing -For information on how to run your own tests using ```gradescopeapi```, refer to [TESTING.md](docs/TESTING.md) +For information on how to run your own tests using `gradescopeapi` refer to [TESTING.md](docs/TESTING.md) ## Contributing Guidelines diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 7a1c2e2..1c408a9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,26 +1,24 @@ # Contributing Guidelines We welcome any potential contributions to the gradescopeapi project! Please -use this page as a reference if you want to help improve the project by submitting feedback, changes, etc. +use this page as a reference if you want to help improve the project by submitting feedback, changes, etc. - -## Bug Reports/Enhancements Requests +## Bug Reports/Enhancements Requests We use [GitHub Issues](https://github.com/nyuoss/gradescope-api/issues) to track any potential bugs along with requests for features to be implemented. Please follow the provided guide for creating your bug report/feature request -and we will respond to it as soon as possible. - +and we will respond to it as soon as possible. ## Making Your Own Changes If you want to make your own changes to the gradescopeapi, please follow the following instructions: -1. Clone/Fork the ```gradescopeapi``` repository -2. Create a ```feature``` branch -3. Make your desired changes -4. Push the changes -5. Submit a Pull Request +1. Clone/Fork the `gradescopeapi` repository +1. Create a `feature` branch +1. Make your desired changes +1. Push the changes +1. Submit a Pull Request -Ensure that your changes adhere to the **coding style**. This can be done using ```pdm``` to run the ```ruff``` linter: +Ensure that your changes adhere to the **coding style**. This can be done using `pdm` to run the `ruff` linter: ```bash pdm format @@ -29,21 +27,15 @@ pdm lint ``` ## Pull Requests -If you have existing chnages that you want added to gradescopeapi, please create a pull request using [GitHub](https://github.com/nyuoss/gradescope-api/pulls). - -Include in your pull request: -1. Summary of the changes you made -2. Detailed description of the exact changes -3. Checks made -4. Members of the team to review the request -5. A reference to the issue (optional) - -Once you have submitted your pull requests, a team member will review the changes and provide feedback as necessary. If your changes are approved, they will be merged with the ```main``` branch. - - - - +If you have existing chnages that you want added to gradescopeapi, please create a pull request using [GitHub](https://github.com/nyuoss/gradescope-api/pulls). +Include in your pull request: +1. Summary of the changes you made +1. Detailed description of the exact changes +1. Checks made +1. Members of the team to review the request +1. A reference to the issue (optional) +Once you have submitted your pull requests, a team member will review the changes and provide feedback as necessary. If your changes are approved, they will be merged with the `main` branch. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 328b681..426931f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,21 +1,22 @@ ## Setup -Clone/Fork the repository. This project currently uses [PDM](https://pdm-project.org/en/latest/) for dependency management. +Clone/Fork the repository. This project currently uses [PDM](https://pdm-project.org/en/latest/) for dependency management. ### Instructions: + Run at root of repository: + 1. Initialize repository using `pdm install` -2. Update dependencies using `pdm update` -3. Activate virtual environment using `pdm venv activate` +1. Update dependencies using `pdm update` +1. Activate virtual environment using `pdm venv activate` If you do not plan to make any changes to the dependencies, you can use `pip` to install the dependencies instead. + ```bash # pip pip install -r requirements.txt ``` - - ### Scripts Additional scripts are also available and can be seen using `pdm run --list` @@ -25,12 +26,5 @@ Additional scripts are also available and can be seen using `pdm run --list` - `lint` - Run static analysis - `lint-fix` - Run static analysis and fix code - `format` - Format code -- `format-test` - Dry run for code formatting -- `export` - export pdm dependencies to ```requirements.txt``` - - - - - - - +- `format-check` - Dry run for code formatting +- `export` - export pdm dependencies to `requirements.txt` diff --git a/docs/TESTING.md b/docs/TESTING.md index d9d843e..9e07e9a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -17,7 +17,7 @@ GRADESCOPE_CI_TA_PASSWORD For test cases to pass: -Student accounts are expected to be accounts that are **only** enrolled as students in courses. Similarly, instructor accounts are expected to **only** be instructors for courses. TA accounts are expected to be **both**. Tests can also be skipped by using the pytest decorator ```@pytest.mark.skip(reason="...")``` +Student accounts are expected to be accounts that are **only** enrolled as students in courses. Similarly, instructor accounts are expected to **only** be instructors for courses. TA accounts are expected to be **both**. Tests can also be skipped by using the pytest decorator `@pytest.mark.skip(reason="...")` ### Running Tests Locally @@ -41,4 +41,4 @@ From the root of the repository, run the following command and pass in the `.env ```bash act --secret-file .env -``` \ No newline at end of file +``` diff --git a/pdm.lock b/pdm.lock index 1f2b17c..a325a57 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:7f005d1b13c641041487d00ba95dc50a0ce3a6614d4f003e784ccf796cb235d6" +content_hash = "sha256:9c67afffeba2a637afa241780383b3efabaccc7e765bd484e8ef488e0d6607c4" [[package]] name = "annotated-types" @@ -389,7 +389,7 @@ name = "markdown-it-py" version = "3.0.0" requires_python = ">=3.8" summary = "Python port of markdown-it. Markdown parsing, done right!" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "mdurl~=0.1", ] @@ -418,12 +418,26 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] +[[package]] +name = "mdformat" +version = "0.7.17" +requires_python = ">=3.8" +summary = "CommonMark compliant Markdown formatter" +groups = ["dev"] +dependencies = [ + "markdown-it-py<4.0.0,>=1.0.0", +] +files = [ + {file = "mdformat-0.7.17-py3-none-any.whl", hash = "sha256:91ffc5e203f5814a6ad17515c77767fd2737fc12ffd8b58b7bb1d8b9aa6effaa"}, + {file = "mdformat-0.7.17.tar.gz", hash = "sha256:a9dbb1838d43bb1e6f03bd5dca9412c552544a9bc42d6abb5dc32adfe8ae7c0d"}, +] + [[package]] name = "mdurl" version = "0.1.2" requires_python = ">=3.7" summary = "Markdown URL utilities" -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, diff --git a/pyproject.toml b/pyproject.toml index e25479d..231fa62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ distribution = true [tool.pdm.dev-dependencies] dev = [ + "mdformat>=0.7.17", "mypy>=1.8.0", "pre-commit>=3.7.0", "ruff>=0.4.3", @@ -54,9 +55,23 @@ dev = [ [tool.pdm.scripts] dev = "fastapi dev src/gradescopeapi/api/api.py" export = "pdm export -f requirements -o requirements.txt --without-hashes" -format = "ruff format src tests" -format-test = "ruff format --check src tests" +format-markdown = "mdformat ." +format-markdown-check = "mdformat --check ." +format-python = "ruff format src tests" +format-python-check = "ruff format --check src tests" lint = "ruff check --extend-select I src tests" lint-fix = "ruff check --extend-select I --fix src tests" start = "fastapi run src/gradescopeapi/api/api.py" test = "pytest tests" + +[tool.pdm.scripts.format] +composite = [ + "format-markdown", + "format-python", +] + +[tool.pdm.scripts.format-check] +composite = [ + "format-markdown-check", + "format-python-check", +] diff --git a/requirements.txt b/requirements.txt index 7293039..243973c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,7 @@ iniconfig==2.0.0 jinja2==3.1.4 markdown-it-py==3.0.0 markupsafe==2.1.5 +mdformat==0.7.17 mdurl==0.1.2 mypy==1.10.0 mypy-extensions==1.0.0 diff --git a/tests/test_upload_files/markdown_file.md b/tests/test_upload_files/markdown_file.md index 86f8c9b..d285c92 100644 --- a/tests/test_upload_files/markdown_file.md +++ b/tests/test_upload_files/markdown_file.md @@ -1 +1 @@ -# This is a markdown file +# This is a markdown file From 39b9dd7d937fa1af5db672e274899b33dbfc59e6 Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Tue, 28 May 2024 01:06:13 -0400 Subject: [PATCH 07/10] Add status badge --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 107d073..7aa92bf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Gradescope API +[![PyPI version](https://img.shields.io/pypi/v/gradescopeapi)](https://pypi.org/project/gradescopeapi/) ![Test and lint workflow](https://github.com/nyuoss/gradescope-api/actions/workflows/main.yaml/badge.svg) + ## Description This *unofficial* project serves as a library for programmatically interacting with [Gradescope](https://www.gradescope.com/). The primary purpose of this project is to provide students and instructors tools for interacting with Gradescope without having to use the web interface. From a9ecc8246b4114ce04bcf986f541541d7560e845 Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Tue, 28 May 2024 01:09:59 -0400 Subject: [PATCH 08/10] Readd linting and fix file name in script --- .github/workflows/main.yaml | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 79275f5..8e81d9f 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -38,11 +38,11 @@ jobs: - name: Format code run: pdm format - # - name: Lint code - # run: pdm lint + - name: Lint code + run: pdm lint - - name: Start project - run: pdm run start + # - name: Start project + # run: pdm run start - name: Run Tests run: pdm test diff --git a/pyproject.toml b/pyproject.toml index 231fa62..26d8552 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ format-python = "ruff format src tests" format-python-check = "ruff format --check src tests" lint = "ruff check --extend-select I src tests" lint-fix = "ruff check --extend-select I --fix src tests" -start = "fastapi run src/gradescopeapi/api/api.py" +start = "fastapi run src/gradescopeapi/api/app.py" test = "pytest tests" [tool.pdm.scripts.format] From fae8972562fc91dc3a33cc2d4679c61683638239 Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Tue, 28 May 2024 01:28:33 -0400 Subject: [PATCH 09/10] Add yaml formatter --- .github/workflows/main.yaml | 20 ++------- .pre-commit-config.yaml | 5 ++- pdm.lock | 81 ++++++++++++++++++++++++++++++++++--- pyproject.toml | 5 +++ requirements.txt | 5 +++ 5 files changed, 91 insertions(+), 25 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 8e81d9f..903f2f3 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,49 +1,35 @@ +--- name: Deploy API and Automate Tests - on: push: - branches: - - main + branches: [main] pull_request: - branches: - - main - + branches: [main] jobs: deploy-and-test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: 3.12 - - name: Setup PDM uses: pdm-project/setup-pdm@v4 - - name: Install PDM dependencies and initialize project repository run: pdm install - - name: Activate Virtual Environment run: pdm venv activate - - name: Install pip and other dependencies run: | python -m pip install --upgrade pip python -m pip install types-beautifulsoup4 types-requests pip install -r requirements.txt - - name: Format code run: pdm format - - name: Lint code run: pdm lint - - # - name: Start project - # run: pdm run start - - name: Run Tests run: pdm test env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2e37468..3762010 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,4 @@ +--- repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.2.0 @@ -8,11 +9,11 @@ repos: - id: check-added-large-files # export python requirements - repo: https://github.com/pdm-project/pdm - rev: 2.15.1 # a PDM release exposing the hook + rev: 2.15.1 # a PDM release exposing the hook hooks: - id: pdm-export # command arguments, e.g.: - args: ["-o", "requirements.txt", "--without-hashes"] + args: [-o, requirements.txt, --without-hashes] files: ^pdm.lock$ # format pyproject.toml file - repo: https://github.com/kieran-ryan/pyprojectsort diff --git a/pdm.lock b/pdm.lock index a325a57..3d441b8 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,14 +5,14 @@ groups = ["default", "dev"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:9c67afffeba2a637afa241780383b3efabaccc7e765bd484e8ef488e0d6607c4" +content_hash = "sha256:de37b582d249e18fec5605a036b3fab3682866a00bebe371bcbd57c6f5e28e8c" [[package]] name = "annotated-types" version = "0.6.0" requires_python = ">=3.8" summary = "Reusable constraint types to use with typing.Annotated" -groups = ["default"] +groups = ["default", "dev"] files = [ {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, @@ -124,7 +124,7 @@ name = "click" version = "8.1.7" requires_python = ">=3.7" summary = "Composable command line interface toolkit" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "colorama; platform_system == \"Windows\"", ] @@ -138,7 +138,7 @@ name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["default"] +groups = ["default", "dev"] marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -199,6 +199,17 @@ files = [ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] +[[package]] +name = "distro" +version = "1.9.0" +requires_python = ">=3.6" +summary = "Distro - an OS platform information API" +groups = ["dev"] +files = [ + {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, + {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, +] + [[package]] name = "dnspython" version = "2.6.1" @@ -384,6 +395,22 @@ files = [ {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, ] +[[package]] +name = "maison" +version = "1.4.3" +requires_python = ">=3.7.1,<4.0.0" +summary = "Read settings from config files" +groups = ["dev"] +dependencies = [ + "click<9.0.0,>=8.0.1", + "pydantic<3.0.0,>=2.5.3", + "toml<0.11.0,>=0.10.2", +] +files = [ + {file = "maison-1.4.3-py3-none-any.whl", hash = "sha256:a36208d0befb3bd8aa3b002ac198ce6f6e61efe568b195132640f4032eff46ac"}, + {file = "maison-1.4.3.tar.gz", hash = "sha256:766222ce82ae27138256c4af9d0bc6b3226288349601e095dcc567884cf0ce36"}, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -575,7 +602,7 @@ name = "pydantic" version = "2.7.1" requires_python = ">=3.8" summary = "Data validation using Python type hints" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "annotated-types>=0.4.0", "pydantic-core==2.18.2", @@ -591,7 +618,7 @@ name = "pydantic-core" version = "2.18.2" requires_python = ">=3.8" summary = "Core functionality for Pydantic validation and serialization" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "typing-extensions!=4.7.0,>=4.6.0", ] @@ -794,6 +821,21 @@ files = [ {file = "ruff-0.4.3.tar.gz", hash = "sha256:ff0a3ef2e3c4b6d133fbedcf9586abfbe38d076041f2dc18ffb2c7e0485d5a07"}, ] +[[package]] +name = "ruyaml" +version = "0.91.0" +requires_python = ">=3.6" +summary = "ruyaml is a fork of ruamel.yaml" +groups = ["dev"] +dependencies = [ + "distro>=1.3.0", + "setuptools>=39.0", +] +files = [ + {file = "ruyaml-0.91.0-py3-none-any.whl", hash = "sha256:50e0ee3389c77ad340e209472e0effd41ae0275246df00cdad0a067532171755"}, + {file = "ruyaml-0.91.0.tar.gz", hash = "sha256:6ce9de9f4d082d696d3bde264664d1bcdca8f5a9dff9d1a1f1a127969ab871ab"}, +] + [[package]] name = "setuptools" version = "69.5.1" @@ -852,6 +894,17 @@ files = [ {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, ] +[[package]] +name = "toml" +version = "0.10.2" +requires_python = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +summary = "Python Library for Tom's Obvious, Minimal Language" +groups = ["dev"] +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + [[package]] name = "typer" version = "0.12.3" @@ -1069,3 +1122,19 @@ files = [ {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] + +[[package]] +name = "yamlfix" +version = "1.16.0" +requires_python = ">=3.8" +summary = "A simple opionated yaml formatter that keeps your comments!" +groups = ["dev"] +dependencies = [ + "click>=8.1.3", + "maison>=1.4.0", + "ruyaml>=0.91.0", +] +files = [ + {file = "yamlfix-1.16.0-py3-none-any.whl", hash = "sha256:d92bf8a6d5b6f186bd9d643d633549a1c2424555cb8d176a5d38bce3e678b2b0"}, + {file = "yamlfix-1.16.0.tar.gz", hash = "sha256:72f7990e5b2b4459ef3249df4724dacbd85ce7b87f4ea3503d8a72c48574cc32"}, +] diff --git a/pyproject.toml b/pyproject.toml index 26d8552..f5137f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dev = [ "mypy>=1.8.0", "pre-commit>=3.7.0", "ruff>=0.4.3", + "yamlfix>=1.16.0", ] [tool.pdm.scripts] @@ -59,6 +60,8 @@ format-markdown = "mdformat ." format-markdown-check = "mdformat --check ." format-python = "ruff format src tests" format-python-check = "ruff format --check src tests" +format-yaml = "yamlfix ." +format-yaml-check = "yamlfix --check ." lint = "ruff check --extend-select I src tests" lint-fix = "ruff check --extend-select I --fix src tests" start = "fastapi run src/gradescopeapi/api/app.py" @@ -68,10 +71,12 @@ test = "pytest tests" composite = [ "format-markdown", "format-python", + "format-yaml", ] [tool.pdm.scripts.format-check] composite = [ "format-markdown-check", "format-python-check", + "format-yaml-check", ] diff --git a/requirements.txt b/requirements.txt index 243973c..1b8fbdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ click==8.1.7 colorama==0.4.6; sys_platform == "win32" or platform_system == "Windows" cryptography==42.0.7 distlib==0.3.8 +distro==1.9.0 dnspython==2.6.1 email-validator==2.1.1 fastapi==0.111.0 @@ -25,6 +26,7 @@ identify==2.5.36 idna==3.7 iniconfig==2.0.0 jinja2==3.1.4 +maison==1.4.3 markdown-it-py==3.0.0 markupsafe==2.1.5 mdformat==0.7.17 @@ -50,11 +52,13 @@ requests==2.31.0 requests-toolbelt==1.0.0 rich==13.7.1 ruff==0.4.3 +ruyaml==0.91.0 setuptools==69.5.1 shellingham==1.5.4 sniffio==1.3.1 soupsieve==2.5 starlette==0.37.2 +toml==0.10.2 typer==0.12.3 typing-extensions==4.9.0 ujson==5.9.0 @@ -64,3 +68,4 @@ uvloop==0.19.0; (sys_platform != "cygwin" and sys_platform != "win32") and platf virtualenv==20.26.1 watchfiles==0.21.0 websockets==12.0 +yamlfix==1.16.0 From 231b35518cdf405c9d08a584a9a4f2da64478cca Mon Sep 17 00:00:00 2001 From: Calvin <48587962+calvinatian@users.noreply.github.com> Date: Tue, 28 May 2024 02:13:37 -0400 Subject: [PATCH 10/10] Add CLI support --- pyproject.toml | 3 +++ src/gradescopeapi/api/app.py | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f5137f8..66856c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ readme = "README.md" requires-python = ">=3.12" version = "1.0.0" +[project.gui-scripts] +gradescopeapi = "gradescopeapi.api.app:main" + [project.license] text = "MIT" diff --git a/src/gradescopeapi/api/app.py b/src/gradescopeapi/api/app.py index 393ee56..87e8739 100644 --- a/src/gradescopeapi/api/app.py +++ b/src/gradescopeapi/api/app.py @@ -6,8 +6,10 @@ app.include_router(auth.router) app.include_router(course.router) - -if __name__ == "__main__": +def main(): import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) + +if __name__ == "__main__": + main()