├── tests ├── __init__.py ├── upload_files │ ├── text_file.txt │ ├── markdown_file.md │ └── python_file.py ├── test_graders.py ├── integration │ └── test_integration.py ├── test_connection.py ├── test_courses.py ├── test_upload.py ├── test_submission.py ├── conftest.py ├── test_extension.py └── test_edit_assignment.py ├── .python-version ├── src └── gradescopeapi │ ├── py.typed │ ├── api │ ├── __init__.py │ ├── constants.py │ └── api.py │ ├── _config │ ├── __init__.py │ └── config.py │ ├── classes │ ├── __init__.py │ ├── courses.py │ ├── member.py │ ├── connection.py │ ├── _helpers │ │ ├── _login_helpers.py │ │ ├── _course_helpers.py │ │ └── _assignment_helpers.py │ ├── upload.py │ ├── extensions.py │ ├── assignments.py │ └── account.py │ └── __init__.py ├── .vscode └── settings.json ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug-report.md └── workflows │ └── main.yaml ├── .pre-commit-config.yaml ├── docs ├── INSTALL.md ├── CONTRIBUTING.md └── TESTING.md ├── LICENSE.md ├── justfile ├── pyproject.toml ├── .gitignore ├── README.md ├── requirements.txt └── requirements.dev.txt /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.14 2 | -------------------------------------------------------------------------------- /src/gradescopeapi/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gradescopeapi/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gradescopeapi/_config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/upload_files/text_file.txt: -------------------------------------------------------------------------------- 1 | This is a text file 2 | -------------------------------------------------------------------------------- /tests/upload_files/markdown_file.md: -------------------------------------------------------------------------------- 1 | # This is a markdown file 2 | -------------------------------------------------------------------------------- /tests/upload_files/python_file.py: -------------------------------------------------------------------------------- 1 | print("This is a python file") 2 | -------------------------------------------------------------------------------- /src/gradescopeapi/__init__.py: -------------------------------------------------------------------------------- 1 | DEFAULT_GRADESCOPE_BASE_URL = "https://www.gradescope.com" 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests", "-s" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } 8 | -------------------------------------------------------------------------------- /src/gradescopeapi/api/constants.py: -------------------------------------------------------------------------------- 1 | """constants.py 2 | Constants file for FastAPI. Specifies any variable or other object which should remain the same across all environments. 3 | """ 4 | 5 | BASE_URL = "https://www.gradescope.com" 6 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/courses.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Course: 6 | name: str 7 | full_name: str 8 | semester: str 9 | year: str 10 | num_grades_published: str # no longer available from course homepage 11 | num_assignments: str 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | _Provide an overview..._ 3 | 4 | ### Details 5 | _Add more context to describe the changes..._ 6 | 7 | ### Checks 8 | - [ ] Tested changes 9 | - [ ] Attached Logs 10 | 11 | ### Team to Review 12 | _Mention the name of the team to review the PR_ 13 | 14 | ### Reference to the issue 15 | _Mention the issue ID or resources_ 16 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/member.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Member: 6 | full_name: str 7 | first_name: str 8 | last_name: str 9 | sid: str 10 | email: str 11 | role: str 12 | user_id: ( 13 | str | None 14 | ) # used for modifying extensions, only present for 'student' accounts in a course 15 | num_submissions: int 16 | sections: str 17 | course_id: str 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: 2 | - pre-commit 3 | - post-checkout 4 | - post-merge 5 | - post-rewrite 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: trailing-whitespace 11 | - id: end-of-file-fixer 12 | - id: check-yaml 13 | - id: check-added-large-files 14 | # format pyproject.toml file 15 | - repo: https://github.com/kieran-ryan/pyprojectsort 16 | rev: v0.4.0 17 | hooks: 18 | - id: pyprojectsort 19 | # export python requirements 20 | - repo: https://github.com/astral-sh/uv-pre-commit 21 | # uv version. 22 | rev: 0.9.11 23 | hooks: 24 | - id: uv-export 25 | args: ["--output-file", "requirements.txt", "--no-dev", "--locked", "--quiet"] 26 | - id: uv-export 27 | args: ["--output-file", "requirements.dev.txt", "--only-dev", "--locked", "--quiet"] 28 | -------------------------------------------------------------------------------- /tests/test_graders.py: -------------------------------------------------------------------------------- 1 | from gradescopeapi.classes.account import Account 2 | 3 | 4 | def test_get_assignment_graders_non_empty(create_session): 5 | """Test getting graders for a question that has been graded.""" 6 | # create test session 7 | test_session = create_session("instructor") 8 | account = Account(test_session) 9 | 10 | course_id = "753413" 11 | question_id = "49653137" 12 | 13 | graders = account.get_assignment_graders(course_id, question_id) 14 | assert len(graders) > 0, "Should have at least 1 grader" 15 | 16 | 17 | def test_get_assignment_graders_empty(create_session): 18 | """Test getting graders for a question that has not been graded.""" 19 | # create test session 20 | test_session = create_session("instructor") 21 | account = Account(test_session) 22 | 23 | course_id = "753413" 24 | question_id = "49653136" 25 | 26 | graders = account.get_assignment_graders(course_id, question_id) 27 | assert len(graders) == 0, "Should not have any graders" 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: What is the bug about? 4 | title: Priority 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here.... 39 | -------------------------------------------------------------------------------- /docs/INSTALL.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | ## User Guide 4 | 5 | `gradescopeapi` is available on [PyPi](https://pypi.org/project/gradescopeapi/) and can be installed using your favorite Python package manager. 6 | 7 | For example, `pip install gradescopeapi` or `uv add gradescopeapi` 8 | 9 | ## Development Guide 10 | 11 | Clone/Fork the repository. This project currently uses [uv](https://docs.astral.sh/uv/) for project management. 12 | 13 | [Just](https://github.com/casey/just) is also used as a command runner to simplify repeated commands. 14 | 15 | ### Development Steps 16 | 17 | 1. Install [uv](https://docs.astral.sh/uv/#getting-started) 18 | 1. Navigate to project root `cd gradescopeapi` 19 | 1. Create a virtual environment `uv venv` 20 | 1. Activate virtual environment 21 | - (macOS/Linux) `source .venv/bin/activate` 22 | - (Windows) `.venv\Scripts\activate` 23 | 1. Update the project's environment `uv sync --all-extras` 24 | 1. Run `just` to see all available command targets. 25 | - Just is added as a development dependency and should not need separate installation 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 gradescope-api 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/connection.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL 4 | from gradescopeapi.classes._helpers._login_helpers import ( 5 | get_auth_token_init_gradescope_session, 6 | login_set_session_cookies, 7 | ) 8 | from gradescopeapi.classes.account import Account 9 | 10 | 11 | class GSConnection: 12 | def __init__(self, gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL): 13 | self.session = requests.Session() 14 | self.gradescope_base_url = gradescope_base_url 15 | self.logged_in = False 16 | self.account = None 17 | 18 | def login(self, email, password): 19 | # go to homepage to parse hidden authenticity token and to set initial "_gradescope_session" cookie 20 | auth_token = get_auth_token_init_gradescope_session( 21 | self.session, self.gradescope_base_url 22 | ) 23 | 24 | # login and set cookies in session. Result bool on whether login was success 25 | login_success = login_set_session_cookies( 26 | self.session, email, password, auth_token, self.gradescope_base_url 27 | ) 28 | if login_success: 29 | self.logged_in = True 30 | self.account = Account(self.session, self.gradescope_base_url) 31 | else: 32 | raise ValueError("Invalid credentials.") 33 | -------------------------------------------------------------------------------- /src/gradescopeapi/_config/config.py: -------------------------------------------------------------------------------- 1 | """config.py 2 | Configuration file for FastAPI. Specifies the specific objects and data models used in our api 3 | """ 4 | 5 | import io 6 | from datetime import datetime 7 | 8 | from pydantic import BaseModel 9 | 10 | 11 | class UserSession(BaseModel): 12 | user_email: str 13 | session_token: str 14 | 15 | 16 | class LoginRequestModel(BaseModel): 17 | email: str 18 | password: str 19 | 20 | 21 | class CourseID(BaseModel): 22 | course_id: str 23 | 24 | 25 | class AssignmentID(BaseModel): 26 | course_id: str 27 | assignment_id: str 28 | 29 | 30 | class StudentSubmission(BaseModel): 31 | student_email: str 32 | course_id: str 33 | assignment_id: str 34 | 35 | 36 | class ExtensionData(BaseModel): 37 | course_id: str 38 | assignment_id: str 39 | 40 | 41 | class UpdateExtensionData(BaseModel): 42 | course_id: str 43 | assignment_id: str 44 | user_id: str 45 | release_date: datetime | None = None 46 | due_date: datetime | None = None 47 | late_due_date: datetime | None = None 48 | 49 | 50 | class AssignmentDates(BaseModel): 51 | course_id: str 52 | assignment_id: str 53 | release_date: datetime | None = None 54 | due_date: datetime | None = None 55 | late_due_date: datetime | None = None 56 | 57 | 58 | class FileUploadModel(BaseModel, arbitrary_types_allowed=True): 59 | file: io.TextIOWrapper 60 | 61 | 62 | class AssignmentUpload(BaseModel): 63 | course_id: str 64 | assignment_id: str 65 | leaderboard_name: str | None = None 66 | -------------------------------------------------------------------------------- /tests/integration/test_integration.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | 4 | from gradescopeapi.classes.extensions import update_student_extension 5 | from gradescopeapi.classes.account import Account 6 | from gradescopeapi.classes.connection import GSConnection 7 | 8 | 9 | def test_add_or_edit_student_extension(create_connection): 10 | course_id = "753413" 11 | assignment_id = "4330410" 12 | release_date = datetime(2024, 4, 15) 13 | due_date = release_date + timedelta(days=1) 14 | late_due_date = due_date + timedelta(days=1) 15 | 16 | # fetch instructor connection 17 | connection: GSConnection = create_connection("instructor") 18 | assert isinstance(connection, GSConnection) 19 | 20 | # get course members 21 | assert isinstance(connection.account, Account) 22 | members = connection.account.get_course_users(course_id) 23 | 24 | # assert at least 1 student account exists in course 25 | assert members is not None and len(members) > 0 26 | student_members = [member for member in members if member.role.lower() == "student"] 27 | assert len(student_members) > 0 28 | 29 | for student in student_members: 30 | assert student.user_id is not None # students should have a user_id 31 | 32 | # try updating the extension for this user 33 | result = update_student_extension( 34 | connection.session, 35 | course_id, 36 | assignment_id, 37 | student.user_id, 38 | release_date, 39 | due_date, 40 | late_due_date, 41 | ) 42 | assert result, "Failed to update student extension" 43 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | 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. 4 | 5 | ## Bug Reports/Enhancements Requests 6 | 7 | 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. 8 | 9 | ## Making Your Own Changes 10 | 11 | If you want to make your own changes to the gradescopeapi, please follow the following instructions: 12 | 13 | 1. Clone/Fork the `gradescopeapi` repository 14 | 1. Create a `feature` branch 15 | 1. Make your desired changes 16 | 1. Push the changes 17 | 1. Submit a Pull Request 18 | 19 | Please ensure that your changes adhere to the **coding style**. This can be done using `pdm` to run the `ruff` linter: 20 | 21 | ```bash 22 | just format 23 | # and 24 | just lint 25 | ``` 26 | 27 | ## Pull Requests 28 | 29 | If you have existing changes that you want added to gradescopeapi, please create a pull request using [GitHub](https://github.com/nyuoss/gradescope-api/pulls). 30 | 31 | Include in your pull request: 32 | 33 | 1. Summary of the changes you made 34 | 1. Detailed description of the exact changes 35 | 1. Checks made 36 | 1. A reference to the issue (optional) 37 | 38 | 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. 39 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # use PowerShell instead of sh when on Windows: 2 | set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] 3 | 4 | # List all recipes 5 | list: 6 | @just --list 7 | 8 | # Run unit/integration tests 9 | test: 10 | uv run -- pytest tests 11 | _test-cov-generate-report: 12 | uv run -- coverage run --source=gradescopeapi --module pytest tests 13 | _test-cov-generate-html: _test-cov-generate-report 14 | uv run -- coverage html --omit=src/gradescopeapi/api/*,src/gradescopeapi/_config/* 15 | _test-cov-view-html: 16 | uv run -- python -m http.server 9000 --directory htmlcov 17 | _test-cov-open-html: 18 | uv run -- python -m webbrowser -t 'http://localhost:9000' 19 | # Run tests and open coverage report in browser 20 | test-cov: _test-cov-generate-html _test-cov-generate-report _test-cov-open-html _test-cov-view-html 21 | 22 | # Lint src and tests directories 23 | lint: 24 | uv run -- ruff check src tests 25 | # Lint and auto fix src and tests directories 26 | lint-fix: 27 | uv run -- ruff check --fix src tests 28 | 29 | # Format Python and markdown files 30 | format: _format-python _format-markdown 31 | _format-python: 32 | uv run -- ruff format src tests 33 | _format-markdown: 34 | uv run -- mdformat docs README.md 35 | 36 | # Export project and dependencies to separate requirements files 37 | export: _export-requirements _export-requirements-dev 38 | _export-requirements: 39 | uv export --output-file requirements.txt --no-dev --locked --quiet 40 | _export-requirements-dev: 41 | uv export --output-file requirements.dev.txt --only-dev --locked --quiet 42 | 43 | # Upgrade dependencies 44 | upgrade: _upgrade-uv export 45 | _upgrade-uv: 46 | uv lock --upgrade 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatchling", 5 | ] 6 | 7 | [dependency-groups] 8 | dev = [ 9 | "coverage>=7.6.10", 10 | "mdformat-footnote>=0.1.1", 11 | "mdformat-frontmatter>=2.0.8", 12 | "mdformat-gfm-alerts>=1.0.1", 13 | "mdformat>=0.7.21", 14 | "mypy>=1.8.0", 15 | "pre-commit>=3.7.0", 16 | "ruff>=0.4.3", 17 | "rust-just>=1.39.0", 18 | ] 19 | 20 | [project] 21 | authors = [ 22 | { name = "Berry Liu" }, 23 | { name = "Calvin Tian" }, 24 | { name = "Kevin Zheng" }, 25 | { name = "Margaret Jagger" }, 26 | { name = "Susmitha Kusuma" }, 27 | ] 28 | classifiers = [ 29 | "Programming Language :: Python :: 3 :: Only", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | "Programming Language :: Python :: 3.13", 34 | "Programming Language :: Python :: 3.14", 35 | ] 36 | dependencies = [ 37 | "beautifulsoup4>=4.12.3", 38 | "fastapi>=0.111.0", 39 | "pytest>=8.2.0", 40 | "python-dateutil>=2.9.0.post0", 41 | "python-dotenv>=1.0.1", 42 | "requests-toolbelt>=1.0.0", 43 | "requests>=2.31.0", 44 | "tzdata>=2024.2", 45 | ] 46 | description = "Library for programmatically interacting with Gradescope." 47 | maintainers = [ 48 | { name = "Calvin Tian" }, 49 | ] 50 | name = "gradescopeapi" 51 | readme = "README.md" 52 | requires-python = ">=3.10" 53 | version = "1.7.0" 54 | 55 | [project.license] 56 | text = "MIT" 57 | 58 | [project.urls] 59 | Homepage = "https://github.com/nyuoss/gradescope-api" 60 | Issues = "https://github.com/nyuoss/gradescope-api/issues" 61 | Repository = "https://github.com/nyuoss/gradescope-api" 62 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy API and Automate Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | name: continuous-integration 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: 18 | - "3.10" 19 | - "3.11" 20 | - "3.12" 21 | - "3.13" 22 | - "3.14" 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v5 27 | 28 | - name: Install uv and set the python version 29 | uses: astral-sh/setup-uv@v7 30 | with: 31 | enable-cache: true 32 | cache-dependency-glob: "uv.lock" 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: Install the project 36 | run: uv sync --all-extras --dev 37 | 38 | - name: Lint code check 39 | run: uv run -- ruff check src tests 40 | 41 | - name: Format code check 42 | run: uv run -- ruff format --check src tests 43 | 44 | - name: Format docs check 45 | run: uv run -- mdformat --check docs README.md 46 | 47 | - name: Run tests 48 | # For example, using `pytest` 49 | run: uv run pytest tests 50 | env: 51 | GRADESCOPE_CI_STUDENT_EMAIL: ${{ secrets.GRADESCOPE_CI_STUDENT_EMAIL }} 52 | GRADESCOPE_CI_STUDENT_PASSWORD: ${{ secrets.GRADESCOPE_CI_STUDENT_PASSWORD }} 53 | GRADESCOPE_CI_INSTRUCTOR_EMAIL: ${{ secrets.GRADESCOPE_CI_INSTRUCTOR_EMAIL }} 54 | GRADESCOPE_CI_INSTRUCTOR_PASSWORD: ${{ secrets.GRADESCOPE_CI_INSTRUCTOR_PASSWORD }} 55 | GRADESCOPE_CI_TA_EMAIL: ${{ secrets.GRADESCOPE_CI_TA_EMAIL }} 56 | GRADESCOPE_CI_TA_PASSWORD: ${{ secrets.GRADESCOPE_CI_TA_PASSWORD }} 57 | -------------------------------------------------------------------------------- /docs/TESTING.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Tests are performed using real Gradescope accounts to interact with the Gradescope server. As such, valid Gradescope accounts and courses are required to run the tests. 4 | 5 | ## Outside Contributors 6 | 7 | Unfortunately, the tests right now are not structured for use by outside contributors. This is because they rely on test accounts and specific course setups in Gradescope and are not shared publicly. In the future we may look into how to improve the testing experience for outside contributors. 8 | 9 | For now, the best way to test your changes is to create a test script and run it. 10 | 11 | For example: 12 | 13 | ```python 14 | # example.py 15 | 16 | from gradescopeapi.classes.connection import GSConnection 17 | 18 | # create connection and login 19 | connection = GSConnection() 20 | connection.login("email@domain.com", "password") 21 | 22 | """ 23 | Fetching all courses for user 24 | """ 25 | courses = connection.account.get_courses() 26 | for course in courses["instructor"]: 27 | print(course) 28 | for course in courses["student"]: 29 | print(course) 30 | ``` 31 | 32 | ```bash 33 | uv run example.py 34 | ``` 35 | 36 | ## Environment 37 | 38 | Create an `.env` file in the root directory of the project with the following environment variables: 39 | 40 | ``` 41 | GRADESCOPE_CI_STUDENT_EMAIL 42 | GRADESCOPE_CI_STUDENT_PASSWORD 43 | GRADESCOPE_CI_INSTRUCTOR_EMAIL 44 | GRADESCOPE_CI_INSTRUCTOR_PASSWORD 45 | GRADESCOPE_CI_TA_EMAIL 46 | GRADESCOPE_CI_TA_PASSWORD 47 | ``` 48 | 49 | For test cases to pass: 50 | 51 | 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/Reader accounts are expected to be **both**. Tests can also be skipped by using the pytest decorator `@pytest.mark.skip(reason="...")` 52 | 53 | ## Running Tests Locally 54 | 55 | Tests can be run locally using `pytest`. 56 | 57 | ```bash 58 | just test 59 | just test-cov 60 | ``` 61 | 62 | ## Running Tests on CI 63 | 64 | Tests are run automatically using GitHub Actions. 65 | 66 | ## Running CI Locally 67 | 68 | Install [act](https://github.com/nektos/act) 69 | 70 | From the root of the repository, run the following command and pass in the `.env` file for Gradescope test account credentials: 71 | 72 | ```bash 73 | act --secret-file .env 74 | ``` 75 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/_helpers/_login_helpers.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from bs4 import BeautifulSoup 3 | 4 | from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL 5 | 6 | 7 | def get_auth_token_init_gradescope_session( 8 | session: requests.Session, 9 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 10 | ) -> str: 11 | """ 12 | Go to homepage to parse hidden authenticity token and to set initial "_gradescope_session" cookie 13 | """ 14 | # go to homepage and set initial "_gradescope_session" cookie 15 | homepage_resp = session.get(gradescope_base_url) 16 | homepage_soup = BeautifulSoup(homepage_resp.text, "html.parser") 17 | 18 | # Find the authenticity token using CSS selectors 19 | auth_token = homepage_soup.select_one( 20 | 'form[action="/login"] input[name="authenticity_token"]' 21 | )["value"] 22 | return auth_token 23 | 24 | 25 | def login_set_session_cookies( 26 | session: requests.Session, 27 | email: str, 28 | password: str, 29 | auth_token: str, 30 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 31 | ) -> bool: 32 | GS_LOGIN_ENDPOINT = f"{gradescope_base_url}/login" 33 | 34 | # populate params for post request to login endpoint 35 | login_data = { 36 | "utf8": "✓", 37 | "session[email]": email, 38 | "session[password]": password, 39 | "session[remember_me]": 0, 40 | "commit": "Log In", 41 | "session[remember_me_sso]": 0, 42 | "authenticity_token": auth_token, 43 | } 44 | 45 | # login -> Send post request to login endpoint. Sets cookies 46 | login_resp = session.post(GS_LOGIN_ENDPOINT, params=login_data) 47 | 48 | # success marked with cookies set and a 302 redirect to the accounts page 49 | if ( 50 | # login_resp.history returns a list of redirects that occurred while handling a request 51 | len(login_resp.history) != 0 52 | and login_resp.history[0].status_code == requests.codes.found 53 | ): 54 | # update headers with csrf token 55 | # grab x-csrf-token 56 | soup = BeautifulSoup(login_resp.text, "html.parser") 57 | csrf_token = soup.select_one('meta[name="csrf-token"]')["content"] 58 | 59 | # update session headers 60 | session.cookies.update(login_resp.cookies) 61 | session.headers.update({"X-CSRF-Token": csrf_token}) 62 | return True 63 | return False 64 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | from dotenv import load_dotenv 5 | 6 | from gradescopeapi.classes._helpers._login_helpers import ( 7 | get_auth_token_init_gradescope_session, 8 | login_set_session_cookies, 9 | ) 10 | 11 | # load .env file 12 | load_dotenv() 13 | 14 | GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") 15 | GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") 16 | 17 | 18 | def test_get_auth_token_init_gradescope_session(): 19 | # create test session 20 | test_session = requests.Session() 21 | 22 | # call the function 23 | auth_token = get_auth_token_init_gradescope_session(test_session) 24 | 25 | # check cookies 26 | cookies = requests.utils.dict_from_cookiejar(test_session.cookies) 27 | cookie_check = set(cookies.keys()) == {"_gradescope_session"} 28 | assert auth_token and cookie_check 29 | 30 | 31 | def test_login_set_session_cookies_correct_creds(): 32 | # create test session 33 | test_session = requests.Session() 34 | 35 | # assuming test_get_auth_token_init_gradescope_session works 36 | auth_token = get_auth_token_init_gradescope_session(test_session) 37 | 38 | login_check = login_set_session_cookies( 39 | test_session, 40 | GRADESCOPE_CI_STUDENT_EMAIL, 41 | GRADESCOPE_CI_STUDENT_PASSWORD, 42 | auth_token, 43 | ) 44 | 45 | # check cookies 46 | cookies = requests.utils.dict_from_cookiejar(test_session.cookies) 47 | cookie_check = set(cookies.keys()).issuperset( 48 | { 49 | "_gradescope_session", 50 | "signed_token", 51 | "remember_me", 52 | } 53 | ) 54 | assert login_check and cookie_check 55 | 56 | 57 | def test_login_set_session_cookies_incorrect_creds(): 58 | FALSE_PASSWORD = "notthepassword" 59 | 60 | # create test session 61 | test_session = requests.Session() 62 | 63 | # assuming test_get_auth_token_init_gradescope_session works 64 | auth_token = get_auth_token_init_gradescope_session(test_session) 65 | 66 | login_check = not login_set_session_cookies( 67 | test_session, GRADESCOPE_CI_STUDENT_EMAIL, FALSE_PASSWORD, auth_token 68 | ) 69 | 70 | # check cookies 71 | cookies = requests.utils.dict_from_cookiejar(test_session.cookies) 72 | cookie_check = set(cookies.keys()).issuperset({"_gradescope_session"}) 73 | 74 | assert login_check and cookie_check 75 | -------------------------------------------------------------------------------- /tests/test_courses.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | from gradescopeapi.classes.connection import GSConnection 6 | 7 | # load .env file 8 | load_dotenv() 9 | GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") 10 | GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") 11 | GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") 12 | GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") 13 | GRADESCOPE_CI_TA_EMAIL = os.getenv("GRADESCOPE_CI_TA_EMAIL") 14 | GRADESCOPE_CI_TA_PASSWORD = os.getenv("GRADESCOPE_CI_TA_PASSWORD") 15 | 16 | 17 | def get_account(account_type="student"): 18 | """Creates a connection and returns the account for testing""" 19 | connection = GSConnection() 20 | 21 | match account_type.lower(): 22 | case "student": 23 | connection.login( 24 | GRADESCOPE_CI_STUDENT_EMAIL, GRADESCOPE_CI_STUDENT_PASSWORD 25 | ) 26 | case "instructor": 27 | connection.login( 28 | GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD 29 | ) 30 | case "ta": 31 | connection.login(GRADESCOPE_CI_TA_EMAIL, GRADESCOPE_CI_TA_PASSWORD) 32 | case _: 33 | raise ValueError( 34 | "Invalid account type: must be 'student' or 'instructor' or 'ta'" 35 | ) 36 | 37 | return connection.account 38 | 39 | 40 | def test_get_courses_student(): 41 | # fetch student account 42 | account = get_account("student") 43 | 44 | # get student courses 45 | courses = account.get_courses() 46 | 47 | assert courses["instructor"] == {} and courses["student"] != {} 48 | 49 | 50 | def test_get_courses_instructor(): 51 | # fetch instructor account 52 | account = get_account("instructor") 53 | 54 | # get instructor courses 55 | courses = account.get_courses() 56 | 57 | assert courses["instructor"] != {} and courses["student"] == {} 58 | 59 | 60 | def test_get_courses_ta(): 61 | # fetch ta account 62 | account = get_account("ta") 63 | 64 | # get ta courses 65 | courses = account.get_courses() 66 | 67 | assert courses["instructor"] != {} and courses["student"] != {} 68 | 69 | 70 | def test_membership_invalid(): 71 | # fetch instructor account 72 | account = get_account("instructor") 73 | 74 | invalid_course_id = "1111111" 75 | 76 | # get course members 77 | members = account.get_course_users(invalid_course_id) 78 | 79 | assert members is None 80 | 81 | 82 | def test_membership(): 83 | # fetch instructor account 84 | account = get_account("instructor") 85 | 86 | course_id = "753413" 87 | 88 | # get course members 89 | members = account.get_course_users(course_id) 90 | 91 | assert members is not None and len(members) > 0 92 | -------------------------------------------------------------------------------- /tests/test_upload.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | from gradescopeapi.classes.connection import GSConnection 6 | from gradescopeapi.classes.upload import upload_assignment 7 | 8 | # load .env file 9 | load_dotenv() 10 | 11 | GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") 12 | GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") 13 | GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") 14 | GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") 15 | 16 | 17 | def new_session(account_type="student"): 18 | """Creates and returns a session for testing""" 19 | connection = GSConnection() 20 | 21 | match account_type.lower(): 22 | case "student": 23 | connection.login( 24 | GRADESCOPE_CI_STUDENT_EMAIL, GRADESCOPE_CI_STUDENT_PASSWORD 25 | ) 26 | case "instructor": 27 | connection.login( 28 | GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD 29 | ) 30 | case _: 31 | raise ValueError("Invalid account type: must be 'student' or 'instructor'") 32 | 33 | return connection.session 34 | 35 | 36 | def test_valid_upload(): 37 | # create test session 38 | test_session = new_session("student") 39 | 40 | course_id = "753413" 41 | assignment_id = "4455030" 42 | 43 | with ( 44 | open("tests/upload_files/text_file.txt", "rb") as text_file, 45 | open("tests/upload_files/markdown_file.md", "rb") as markdown_file, 46 | open("tests/upload_files/python_file.py", "rb") as python_file, 47 | ): 48 | submission_link = upload_assignment( 49 | test_session, 50 | course_id, 51 | assignment_id, 52 | text_file, 53 | markdown_file, 54 | python_file, 55 | leaderboard_name="test", 56 | ) 57 | 58 | assert submission_link is not None 59 | 60 | 61 | def test_invalid_upload(): 62 | # create test session 63 | test_session = new_session("student") 64 | 65 | course_id = "753413" 66 | invalid_assignment_id = "1111111" 67 | 68 | with ( 69 | open("tests/upload_files/text_file.txt", "rb") as text_file, 70 | open("tests/upload_files/markdown_file.md", "rb") as markdown_file, 71 | open("tests/upload_files/python_file.py", "rb") as python_file, 72 | ): 73 | submission_link = upload_assignment( 74 | test_session, 75 | course_id, 76 | invalid_assignment_id, 77 | text_file, 78 | markdown_file, 79 | python_file, 80 | ) 81 | 82 | assert submission_link is None 83 | 84 | 85 | def test_upload_with_no_files(): 86 | test_session = new_session("student") 87 | course_id = "753413" 88 | assignment_id = "4455030" 89 | # No files are passed 90 | submission_link = upload_assignment(test_session, course_id, assignment_id) 91 | assert submission_link is None, "Should handle missing files gracefully" 92 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/upload.py: -------------------------------------------------------------------------------- 1 | """Functions for uploading assignments to Gradescope.""" 2 | 3 | import io 4 | import mimetypes 5 | import pathlib 6 | 7 | import requests 8 | from bs4 import BeautifulSoup 9 | from requests_toolbelt.multipart.encoder import MultipartEncoder 10 | 11 | from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL 12 | 13 | 14 | def upload_assignment( 15 | session: requests.Session, 16 | course_id: str, 17 | assignment_id: str, 18 | *files: io.TextIOWrapper, 19 | leaderboard_name: str | None = None, 20 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 21 | ) -> str | None: 22 | """Uploads given file objects to the specified assignment on Gradescope. 23 | 24 | Args: 25 | session (requests.Session): The session object to use for making HTTP requests. 26 | course_id (str): The ID of the course on Gradescope. 27 | assignment_id (str): The ID of the assignment on Gradescope. 28 | *files (io.TextIOWrapper): Variable number of file objects to upload. 29 | leaderboard_name (str | None, optional): The name of the leaderboard. Defaults to None. 30 | 31 | Returns: 32 | str | None: Link to submission if successful or None if unsuccessful. 33 | """ 34 | GS_COURSE_ENDPOINT = f"{gradescope_base_url}/courses/{course_id}" 35 | GS_UPLOAD_ENDPOINT = f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/submissions" 36 | 37 | # Get auth token 38 | # TODO: Refactor to helper function since it is needed in multiple places 39 | response = session.get(GS_COURSE_ENDPOINT) 40 | soup = BeautifulSoup(response.text, "html.parser") 41 | auth_token = soup.find("meta", {"name": "csrf-token"})["content"] 42 | 43 | # Format files for upload 44 | form_files = [] 45 | for file in files: 46 | form_files.append( 47 | ( 48 | "submission[files][]", 49 | ( 50 | pathlib.Path(file.name).name, # get the filename from the path 51 | file, 52 | mimetypes.guess_type(file.name)[0], 53 | ), 54 | ) 55 | ) 56 | 57 | # Setup multipart form data 58 | fields = [ 59 | ("utf8", "✓"), 60 | ("authenticity_token", auth_token), 61 | ("submission[method]", "upload"), 62 | *form_files, 63 | ] 64 | if leaderboard_name is not None: 65 | fields.append(("submission[leaderboard_name]", leaderboard_name)) 66 | 67 | multipart = MultipartEncoder(fields=fields) 68 | 69 | headers = { 70 | "Content-Type": multipart.content_type, 71 | "Referer": GS_COURSE_ENDPOINT, 72 | } 73 | response = session.post(GS_UPLOAD_ENDPOINT, data=multipart, headers=headers) 74 | 75 | # Note: Response status code is always 200 even if upload was unsuccessful (e.g. past the due date, 76 | # missing form fields, etc.). The response from the server either redirects to the submission page (url) 77 | # if successful, or redirects to the Course homepage if unsuccessful. 78 | return ( 79 | None 80 | if response.url == GS_COURSE_ENDPOINT or response.url.endswith("submissions") 81 | else response.url 82 | ) 83 | -------------------------------------------------------------------------------- /tests/test_submission.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from dotenv import load_dotenv 5 | 6 | from gradescopeapi.classes.account import Account 7 | from gradescopeapi.classes.assignments import Assignment 8 | 9 | # Load .env file 10 | load_dotenv() 11 | GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") 12 | GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") 13 | GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") 14 | 15 | 16 | def test_get_assignments_instructor(create_session): 17 | """Test fetching assignments with valid course ID as an instructor.""" 18 | account = Account(create_session("instructor")) 19 | course_id = "753413" 20 | assignments = account.get_assignments(course_id) 21 | assert isinstance(assignments, list), "Should return a list of assignments" 22 | assert len(assignments) > 0, "Should contain at least 1 assignment" 23 | assert all(isinstance(a, Assignment) for a in assignments), ( 24 | "All items should be Assignment instances" 25 | ) 26 | 27 | 28 | def test_get_assignments_student(create_session): 29 | """Test fetching assignments with valid course ID as a student.""" 30 | account = Account(create_session("student")) 31 | course_id = "753413" 32 | assignments = account.get_assignments(course_id) 33 | assert isinstance(assignments, list), "Should return a list of assignments" 34 | assert len(assignments) > 0, "Should contain at least 1 assignment" 35 | assert all(isinstance(a, Assignment) for a in assignments), ( 36 | "All items should be Assignment instances" 37 | ) 38 | 39 | 40 | def test_get_assignment_submissions(create_session): 41 | """Test fetching assignment submissions with valid course and assignment IDs.""" 42 | account = Account(create_session("instructor")) 43 | course_id = "753413" 44 | assignment_id = "4330410" 45 | 46 | submissions = account.get_assignment_submissions(course_id, assignment_id) 47 | assert isinstance(submissions, dict), "Should return a dictionary of submissions" 48 | assert len(submissions) > 0, "Should contain at least 1 submission" 49 | assert all(isinstance(links, list) for links in submissions.values()), ( 50 | "Each submission ID should map to a list of links" 51 | ) 52 | 53 | 54 | def test_get_assignment_submission_valid(create_session): 55 | """Test fetching a specific assignment submission.""" 56 | account = Account(create_session("instructor")) 57 | student_email = GRADESCOPE_CI_STUDENT_EMAIL 58 | course_id = "753413" 59 | assignment_id = "4330410" 60 | expected_num_files = 3 61 | 62 | submission = account.get_assignment_submission( 63 | student_email, course_id, assignment_id 64 | ) 65 | assert isinstance(submission, list), "Should return a list of aws links" 66 | assert len(submission) == expected_num_files, "List should contain aws links" 67 | 68 | 69 | def test_get_assignment_submission_no_submission_found(create_session): 70 | """Test case when no submission is found for a given student.""" 71 | account = Account(create_session("instructor")) 72 | student_email = GRADESCOPE_CI_INSTRUCTOR_EMAIL 73 | course_id = "753413" 74 | assignment_id = "5525291" 75 | 76 | with pytest.raises(Exception, match="No submission found"): 77 | account.get_assignment_submission(student_email, course_id, assignment_id) 78 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from dotenv import load_dotenv 5 | 6 | from gradescopeapi.classes.connection import GSConnection 7 | from gradescopeapi.classes.account import Account 8 | import requests 9 | from typing import Callable 10 | 11 | load_dotenv() 12 | 13 | GRADESCOPE_CI_STUDENT_EMAIL = os.getenv("GRADESCOPE_CI_STUDENT_EMAIL") 14 | GRADESCOPE_CI_STUDENT_PASSWORD = os.getenv("GRADESCOPE_CI_STUDENT_PASSWORD") 15 | GRADESCOPE_CI_TA_EMAIL = os.getenv("GRADESCOPE_CI_TA_EMAIL") 16 | GRADESCOPE_CI_TA_PASSWORD = os.getenv("GRADESCOPE_CI_TA_PASSWORD") 17 | GRADESCOPE_CI_INSTRUCTOR_EMAIL = os.getenv("GRADESCOPE_CI_INSTRUCTOR_EMAIL") 18 | GRADESCOPE_CI_INSTRUCTOR_PASSWORD = os.getenv("GRADESCOPE_CI_INSTRUCTOR_PASSWORD") 19 | 20 | 21 | @pytest.fixture 22 | def create_session(): 23 | def _create_session(account_type: str = "student") -> requests.Session: 24 | """Creates and returns a session for testing""" 25 | connection = GSConnection() 26 | 27 | match account_type.lower(): 28 | case "student": 29 | connection.login( 30 | GRADESCOPE_CI_STUDENT_EMAIL, GRADESCOPE_CI_STUDENT_PASSWORD 31 | ) 32 | case "ta": 33 | connection.login(GRADESCOPE_CI_TA_EMAIL, GRADESCOPE_CI_TA_PASSWORD) 34 | case "instructor": 35 | connection.login( 36 | GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD 37 | ) 38 | case _: 39 | raise ValueError( 40 | "Invalid account type: must be 'student', 'ta', or 'instructor'" 41 | ) 42 | 43 | return connection.session 44 | 45 | return _create_session 46 | 47 | 48 | @pytest.fixture 49 | def create_account(): 50 | def _create_account(account_type: str = "student") -> Account: 51 | """Creates and returns an Account for testing""" 52 | connection = GSConnection() 53 | 54 | match account_type.lower(): 55 | case "student": 56 | connection.login( 57 | GRADESCOPE_CI_STUDENT_EMAIL, GRADESCOPE_CI_STUDENT_PASSWORD 58 | ) 59 | case "ta": 60 | connection.login(GRADESCOPE_CI_TA_EMAIL, GRADESCOPE_CI_TA_PASSWORD) 61 | case "instructor": 62 | connection.login( 63 | GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD 64 | ) 65 | case _: 66 | raise ValueError( 67 | "Invalid account type: must be 'student', 'ta', or 'instructor'" 68 | ) 69 | 70 | return connection.account 71 | 72 | return _create_account 73 | 74 | 75 | @pytest.fixture 76 | def create_connection() -> Callable[[str], GSConnection]: 77 | def _create_connection(account_type: str = "student") -> GSConnection: 78 | """Creates and returns an connection for testing""" 79 | connection = GSConnection() 80 | 81 | match account_type.lower(): 82 | case "student": 83 | connection.login( 84 | GRADESCOPE_CI_STUDENT_EMAIL, GRADESCOPE_CI_STUDENT_PASSWORD 85 | ) 86 | case "ta": 87 | connection.login(GRADESCOPE_CI_TA_EMAIL, GRADESCOPE_CI_TA_PASSWORD) 88 | case "instructor": 89 | connection.login( 90 | GRADESCOPE_CI_INSTRUCTOR_EMAIL, GRADESCOPE_CI_INSTRUCTOR_PASSWORD 91 | ) 92 | case _: 93 | raise ValueError( 94 | "Invalid account type: must be 'student', 'ta', or 'instructor'" 95 | ) 96 | 97 | return connection 98 | 99 | return _create_connection 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm-project.org/#use-with-ide 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | 5 | from gradescopeapi.classes.extensions import get_extensions, update_student_extension 6 | 7 | 8 | def test_get_extensions(create_session): 9 | """Test fetching extensions for an assignment.""" 10 | # create test session 11 | test_session = create_session("instructor") 12 | 13 | course_id = "753413" 14 | assignment_id = "4330410" 15 | 16 | extensions = get_extensions(test_session, course_id, assignment_id) 17 | assert len(extensions) > 0, ( 18 | f"Got 0 extensions for course {course_id} and assignment {assignment_id}" 19 | ) 20 | 21 | 22 | def test_valid_change_extension(create_session): 23 | """Test granting a valid extension for a student.""" 24 | # create test session 25 | test_session = create_session("instructor") 26 | 27 | course_id = "753413" 28 | assignment_id = "4330410" 29 | user_id = "6515875" 30 | release_date = datetime(2024, 4, 15) 31 | due_date = release_date + timedelta(days=1) 32 | late_due_date = due_date + timedelta(days=1) 33 | 34 | result = update_student_extension( 35 | test_session, 36 | course_id, 37 | assignment_id, 38 | user_id, 39 | release_date, 40 | due_date, 41 | late_due_date, 42 | ) 43 | assert result, "Failed to update student extension" 44 | 45 | 46 | def test_invalid_change_extension(create_session): 47 | """Test granting an invalid extension for a student due to invalid dates.""" 48 | # create test session 49 | test_session = create_session("instructor") 50 | 51 | course_id = "753413" 52 | assignment_id = "4330410" 53 | user_id = "6515875" 54 | release_date = datetime(2024, 4, 15) 55 | due_date = release_date + timedelta(days=-1) 56 | late_due_date = due_date + timedelta(days=-1) 57 | 58 | with pytest.raises( 59 | ValueError, 60 | match="Dates must be in order: release_date <= due_date <= late_due_date", 61 | ): 62 | update_student_extension( 63 | test_session, 64 | course_id, 65 | assignment_id, 66 | user_id, 67 | release_date, 68 | due_date, 69 | late_due_date, 70 | ) 71 | 72 | 73 | def test_invalid_user_id(create_session): 74 | """Test granting an invalid extension for a student due to invalid user ID.""" 75 | test_session = create_session("instructor") 76 | 77 | course_id = "753413" 78 | assignment_id = "4330410" 79 | invalid_user_id = "9999999" # Assuming this is an invalid ID 80 | 81 | # Attempt to change the extension with an invalid user ID 82 | result = update_student_extension( 83 | test_session, 84 | course_id, 85 | assignment_id, 86 | invalid_user_id, 87 | datetime.now(), 88 | datetime.now() + timedelta(days=1), 89 | datetime.now() + timedelta(days=2), 90 | ) 91 | 92 | # Check the function returns False for non-existent user ID 93 | assert not result, "Function should indicate failure when given an invalid user ID" 94 | 95 | 96 | def test_invalid_assignment_id(create_session): 97 | """Test extension handling with an invalid assignment ID.""" 98 | test_session = create_session("instructor") 99 | course_id = "753413" 100 | invalid_assignment_id = "9999999" 101 | 102 | # Attempt to fetch extensions with an invalid assignment ID 103 | with pytest.raises(RuntimeError, match="Failed to get extensions"): 104 | get_extensions(test_session, course_id, invalid_assignment_id) 105 | 106 | 107 | def test_invalid_course_id(create_session): 108 | """Test extension handling with an invalid course ID.""" 109 | test_session = create_session("instructor") 110 | invalid_course_id = "9999999" 111 | 112 | # Attempt to fetch or modify extensions with an invalid course ID 113 | with pytest.raises(RuntimeError, match="Failed to get extensions"): 114 | get_extensions(test_session, invalid_course_id, "4330410") 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gradescope API 2 | 3 | [![PyPI - Version](https://img.shields.io/pypi/v/gradescopeapi)](https://pypi.org/project/gradescopeapi/) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/gradescopeapi) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/nyuoss/gradescope-api/.github%2Fworkflows%2Fmain.yaml) 4 | 5 | ## Description 6 | 7 | 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. 8 | 9 | For example: 10 | 11 | - Students using this project could automatically query information about their courses and assignments to notify them of upcoming deadlines or new assignments. 12 | - Instructors could use this project bulk edit assignment due dates or sync student extensions with an external system. 13 | 14 | ## Features 15 | 16 | Implemented Features Include: 17 | 18 | - Get all courses for a user 19 | - Get a list of all assignments for a course 20 | - Get all extensions for an assignment in a course 21 | - Add/remove/modify extensions for an assignment in a course 22 | - Add/remove/modify dates for an assignment in a course 23 | - Upload submissions to assignments 24 | - API server to interact with library without Python 25 | 26 | ## Demo 27 | 28 | 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) 29 | 30 | Note that we only demo interacting with the API server, you can alternatively use the Python library directly. 31 | 32 | ## Setup 33 | 34 | To use the project you can install the package from PyPI using pip: 35 | 36 | ```bash 37 | pip install gradescopeapi 38 | ``` 39 | 40 | For additional methods of installation, refer to the [install guide](docs/INSTALL.md) 41 | 42 | ## Usage 43 | 44 | 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. 45 | 46 | ### Option 1: FastAPI 47 | 48 | If you do not want to use Python, you can host the API using the integrated FastAPI server. This way, you can interact with Gradescope using different languages by sending HTTP requests to the API server. 49 | 50 | **Running the API Server Locally** 51 | 52 | To run the API server locally on your machine, open the project repository on your machine that you have cloned/forked, and: 53 | 54 | 1. Navigate to the `src.gradescopeapi.api` directory 55 | 1. Run the command: `uvicorn api:app --reload` to run the server locally 56 | 1. In a web browser, navigate to `localhost:8000/docs`, to see the auto-generated FastAPI docs 57 | 58 | ### Option 2: Python 59 | 60 | Alternatively, you can use Python to use the library directly. We have provided some sample scripts of common tasks one might do: 61 | 62 | ```python 63 | from gradescopeapi.classes.connection import GSConnection 64 | 65 | # create connection and login 66 | connection = GSConnection() 67 | connection.login("email@domain.com", "password") 68 | 69 | """ 70 | Fetching all courses for user 71 | """ 72 | courses = connection.account.get_courses() 73 | for course in courses["instructor"]: 74 | print(course) 75 | for course in courses["student"]: 76 | print(course) 77 | 78 | """ 79 | Getting roster for a course 80 | """ 81 | course_id = "123456" 82 | members = connection.account.get_course_users(course_id) 83 | for member in members: 84 | print(member) 85 | 86 | """ 87 | Getting all assignments for course 88 | """ 89 | assignments = connection.account.get_assignments(course_id) 90 | for assignment in assignments: 91 | print(assignment) 92 | ``` 93 | 94 | For more examples of features not covered here such as changing extensions, uploading files, etc., please refer to the [tests](tests/) directory. 95 | 96 | ## Testing 97 | 98 | For information on how to run your own tests using `gradescopeapi`, refer to [TESTING.md](docs/TESTING.md) 99 | 100 | ## Contributing Guidelines 101 | 102 | Please refer to the [CONTRIBUTING.md](docs/CONTRIBUTING.md) file for more information on how to contribute to the project. 103 | 104 | ## Authors 105 | 106 | - Susmitha Kusuma 107 | - Berry Liu 108 | - Margaret Jagger 109 | - Calvin Tian 110 | - Kevin Zheng 111 | -------------------------------------------------------------------------------- /tests/test_edit_assignment.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from gradescopeapi.classes.assignments import ( 4 | update_assignment_date, 5 | update_assignment_title, 6 | update_autograder_image_name, 7 | InvalidTitleName, 8 | ) 9 | import requests 10 | import uuid 11 | 12 | 13 | def test_valid_change_assignment(create_session): 14 | """Test valid extension for a student.""" 15 | # create test session 16 | test_session = create_session("instructor") 17 | 18 | course_id = "753413" 19 | assignment_id = "4436170" 20 | release_date = datetime(2024, 4, 15) 21 | due_date = release_date + timedelta(days=1) 22 | late_due_date = due_date + timedelta(days=1) 23 | 24 | result = update_assignment_date( 25 | test_session, 26 | course_id, 27 | assignment_id, 28 | release_date, 29 | due_date, 30 | late_due_date, 31 | ) 32 | assert result 33 | 34 | 35 | def test_boundary_date_assignment(create_session): 36 | """Test updating assignment with boundary date values.""" 37 | test_session = create_session("instructor") 38 | 39 | course_id = "753413" 40 | assignment_id = "4436170" 41 | boundary_date = datetime(1900, 1, 1) # Very old date 42 | 43 | result = update_assignment_date( 44 | test_session, 45 | course_id, 46 | assignment_id, 47 | boundary_date, 48 | boundary_date, 49 | boundary_date, 50 | ) 51 | assert result, "Failed to update assignment with boundary dates" 52 | 53 | 54 | def test_update_assignment_date_invalid_session(create_session): 55 | """Test updating assignment with student session.""" 56 | test_session = create_session("student") 57 | 58 | course_id = "753413" 59 | assignment_id = "4436170" 60 | release_date = datetime(2024, 4, 15) 61 | due_date = release_date + timedelta(days=1) 62 | late_due_date = due_date + timedelta(days=1) 63 | 64 | try: 65 | update_assignment_date( 66 | test_session, 67 | course_id, 68 | assignment_id, 69 | release_date, 70 | due_date, 71 | late_due_date, 72 | ) 73 | assert False, "Incorrectly updated assignment title with invalid session" 74 | except requests.exceptions.HTTPError as e: 75 | assert e.response.status_code == 401 # HTTP 401 Not Authorized 76 | 77 | 78 | def test_autograder_valid_image_name(create_session): 79 | """Test updating assignment with valid image name.""" 80 | test_session = create_session("instructor") 81 | 82 | course_id = "753413" 83 | assignment_id = "7193007" 84 | image_name = "gradescope/autograder-base:ubuntu-22.04" 85 | 86 | result = update_autograder_image_name( 87 | test_session, 88 | course_id, 89 | assignment_id, 90 | image_name, 91 | ) 92 | assert result, "Failed to update autograder image name" 93 | 94 | 95 | def test_autograder_invalid_image_name(create_session): 96 | """Test updating assignment with invalid image name.""" 97 | test_session = create_session("instructor") 98 | 99 | course_id = "753413" 100 | assignment_id = "7193007" 101 | image_name = "gradescope/autograders:us-prod-docker_image-123456" 102 | 103 | result = update_autograder_image_name( 104 | test_session, 105 | course_id, 106 | assignment_id, 107 | image_name, 108 | ) 109 | assert not result, "Incorrectly updated to invalid autograder image name" 110 | 111 | 112 | def test_autograder_invalid_session(create_session): 113 | """Test updating assignment with student session.""" 114 | test_session = create_session("student") 115 | 116 | course_id = "753413" 117 | assignment_id = "7193007" 118 | image_name = "gradescope/autograder-base:ubuntu-22.04" 119 | 120 | try: 121 | update_autograder_image_name( 122 | test_session, 123 | course_id, 124 | assignment_id, 125 | image_name, 126 | ) 127 | assert False, "Incorrectly updated assignment with invalid session" 128 | except requests.exceptions.HTTPError as e: 129 | assert e.response.status_code == 401 # HTTP 401 Not Authorized 130 | 131 | 132 | def test_autograder_invalid_assignment_type(create_session): 133 | """Test updating assignment with invalid assignment type.""" 134 | test_session = create_session("instructor") 135 | 136 | course_id = "753413" 137 | assignment_id = "7205866" 138 | image_name = "gradescope/autograder-base:ubuntu-22.04" 139 | 140 | try: 141 | update_autograder_image_name( 142 | test_session, 143 | course_id, 144 | assignment_id, 145 | image_name, 146 | ) 147 | assert False, "Incorrectly updated assignment with invalid assignment" 148 | except requests.exceptions.HTTPError as e: 149 | assert e.response.status_code == 404 # HTTP 404 Not Found 150 | 151 | 152 | def test_update_assignment_title_valid_random_title(create_session): 153 | """Test updating assignment with random name.""" 154 | test_session = create_session("instructor") 155 | 156 | course_id = "753413" 157 | assignment_id = "7332839" 158 | new_assignment_name = f"Test Rename - {uuid.uuid4()}" 159 | 160 | result = update_assignment_title( 161 | test_session, 162 | course_id, 163 | assignment_id, 164 | new_assignment_name, 165 | ) 166 | assert result, "Failed to update assignment name" 167 | 168 | 169 | def test_update_assignment_title_invalid_title_whitespace(create_session): 170 | """Test updating assignment with invalid name containing only whitespace.""" 171 | test_session = create_session("instructor") 172 | 173 | course_id = "753413" 174 | assignment_id = "7193007" 175 | new_assignment_name = " " # whitespace only not allowed 176 | 177 | try: 178 | update_assignment_title( 179 | test_session, 180 | course_id, 181 | assignment_id, 182 | new_assignment_name, 183 | ) 184 | assert False, "Incorrectly updated to invalid assignment name" 185 | except InvalidTitleName: 186 | pass 187 | 188 | 189 | def test_update_assignment_title_invalid_session(create_session): 190 | """Test updating assignment with student session.""" 191 | test_session = create_session("student") 192 | 193 | course_id = "753413" 194 | assignment_id = "7332839" 195 | new_assignment_name = f"Test Rename - {uuid.uuid4()}" 196 | 197 | try: 198 | update_assignment_title( 199 | test_session, 200 | course_id, 201 | assignment_id, 202 | new_assignment_name, 203 | ) 204 | assert False, "Incorrectly updated assignment title with invalid session" 205 | except requests.exceptions.HTTPError as e: 206 | assert e.response.status_code == 401 # HTTP 401 Not Authorized 207 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/_helpers/_course_helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from bs4 import BeautifulSoup 4 | import bs4 5 | 6 | from gradescopeapi.classes.courses import Course 7 | from gradescopeapi.classes.member import Member 8 | 9 | 10 | def get_courses_info(soup: BeautifulSoup) -> dict[str, dict[str, Course]]: 11 | """ 12 | Scrape all course info from the main page of Gradescope. 13 | 14 | Args: 15 | soup (BeautifulSoup): BeautifulSoup object with parsed HTML. 16 | user_type (str): The user type to scrape courses for (Instructor or Student courses). 17 | 18 | Returns: 19 | dict: A dictionary mapping course IDs to Course objects containing all course info. 20 | 21 | For example: 22 | { 23 | "instructor": { 24 | "123456": Course( 25 | name="CS 1134", 26 | full_name="Data Structures and Algorithms", 27 | semester="Fall", 28 | year="2021", 29 | num_grades_published="0", 30 | num_assignments="5" 31 | ) 32 | }, 33 | "student": {} 34 | } 35 | """ 36 | 37 | # initialize dictionary to store all courses 38 | all_courses = {"student": {}, "instructor": {}} 39 | 40 | # find heading for courses 41 | courses = soup.select_one("div#account-show") 42 | 43 | # use "Create Course" button to check if user is a staff user in any course 44 | button = soup.select_one("button.js-createNewCourse") 45 | is_staff = button is not None 46 | 47 | # parse through course sections and add courses to appropriate account type 48 | sectionType = "instructor" if is_staff else "student" 49 | courses = soup.select_one("div#account-show") 50 | sections = courses.find_all() 51 | for section in sections: 52 | # only need to switch to student courses if user is both staff role and student role in different courses 53 | if section.name == "h2" and "pageHeading" in section.get("class", []): 54 | # check if there is a label 55 | if section.text == "Student Courses": 56 | sectionType = "student" 57 | # else: 58 | elif section.name == "div" and "courseList" in section.get("class", []): 59 | for term in section.find_all("div", class_="courseList--term"): 60 | # find first "a" -> course 61 | course = term.find_next("a") 62 | while course is not None: 63 | # fetch course id and create new dictionary for each course 64 | course_id = course["href"].split("/")[-1] 65 | 66 | # fetch short name 67 | course_name = course.find("h3", class_="courseBox--shortname") 68 | short_name = course_name.text 69 | 70 | # fetch full name 71 | course_full_name = course.find("div", class_="courseBox--name") 72 | full_name = course_full_name.text 73 | 74 | # fetch basic course info 75 | time_of_year = term.text.split(" ") 76 | semester = time_of_year[0] 77 | year = time_of_year[1] 78 | num_assignments = course.find( 79 | "div", class_="courseBox--assignments" 80 | ) 81 | num_assignments = num_assignments.text 82 | 83 | # create Course object with all relevant info 84 | course_info = Course( 85 | name=short_name, 86 | full_name=full_name, 87 | semester=semester, 88 | year=year, 89 | num_grades_published=None, # this info is no longer available on the course homepage 90 | num_assignments=num_assignments, 91 | ) 92 | 93 | # store info for this course 94 | all_courses[sectionType][course_id] = course_info 95 | 96 | # find next course, or "a" tag 97 | course = course.find_next_sibling("a") 98 | 99 | return all_courses 100 | 101 | 102 | def get_course_members(soup: BeautifulSoup, course_id: str) -> list[Member]: 103 | """ 104 | Scrape all course members from the membership page of a Gradescope course. 105 | 106 | Args: 107 | soup (BeautifulSoup): BeautifulSoup object with parsed HTML. 108 | course_id (str): The course ID to which the members belong. 109 | 110 | Returns: 111 | List: A list of Member objects containing all course members' info. 112 | 113 | For example: 114 | [ 115 | Member(...), 116 | Member(...) 117 | ] 118 | """ 119 | 120 | # assumed ordering 121 | # name, email, role, sections?, submissions, edit, remove 122 | # if course has sections, section column is added before number of submissions column 123 | headers = soup.find("table", class_="js-rosterTable").find_all("th") 124 | has_sections = any(h.text.startswith("Sections") for h in headers) 125 | num_submissions_column = 4 if has_sections else 3 126 | 127 | member_list = [] 128 | 129 | # maps role id to role name 130 | id_to_role = {"0": "Student", "1": "Instructor", "2": "TA", "3": "Reader"} 131 | 132 | # find all rows with class rosterRow (each row is a member) 133 | roster_rows: bs4.ResultSet[bs4.element.Tag] = soup.find_all( 134 | "tr", class_="rosterRow" 135 | ) 136 | 137 | for row in roster_rows: 138 | # get all table data for each row 139 | cells: bs4.ResultSet[bs4.element.Tag] = row.find_all("td") 140 | 141 | # get data from first cell 142 | cell = cells[0] 143 | 144 | data_button = cell.find("button", class_="rosterCell--editIcon") 145 | 146 | # fetch full name from data-cm attribute in button 147 | data_cm = data_button.get("data-cm") 148 | json_data_cm = json.loads(data_cm) # convert to json 149 | full_name = json_data_cm.get("full_name") 150 | 151 | # fetch LMS related attributes 152 | first_name = json_data_cm.get("first_name") 153 | last_name = json_data_cm.get("last_name") 154 | sid = json_data_cm.get("sid") 155 | 156 | # fetch other attributes: email, role, and section 157 | # from data attributes in button 158 | email = data_button.get("data-email") 159 | role = id_to_role[data_button.get("data-role")] 160 | sections = data_button.get("data-sections") # TODO: check if this is correct 161 | 162 | # fetch user_id, only available on 'student' accounts 163 | # 164 | rosterName_button = cell.find("button", class_="js-rosterName") 165 | user_id = None 166 | if rosterName_button is not None: 167 | # data-url="/courses/753413/gradebook.json?user_id=6515875" 168 | data_url: str = rosterName_button.get("data-url", None) 169 | user_id = data_url.split("user_id=")[-1] 170 | 171 | # fetch number of submissions from table cell 172 | num_submissions = int(cells[num_submissions_column].text) 173 | 174 | # create Member object with all relevant info 175 | member_list.append( 176 | Member( 177 | full_name=full_name, 178 | first_name=first_name, 179 | last_name=last_name, 180 | sid=sid, 181 | email=email, 182 | role=role, 183 | user_id=user_id, 184 | num_submissions=num_submissions, 185 | sections=sections, 186 | course_id=course_id, 187 | ) 188 | ) 189 | 190 | return member_list 191 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/_helpers/_assignment_helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import dateutil.parser 4 | import requests 5 | 6 | from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL 7 | from gradescopeapi.classes.assignments import Assignment 8 | 9 | 10 | class NotAuthorized(Exception): 11 | pass 12 | 13 | 14 | def check_page_auth(session, endpoint): 15 | """ 16 | raises Exception if user not logged in or doesn't have appropriate authorities 17 | Returns response if otherwise good 18 | """ 19 | submissions_resp = session.get(endpoint) 20 | # check if page is valid, raise exception if not 21 | if submissions_resp.status_code == requests.codes.unauthorized: 22 | # check error type 23 | # TODO: how should we handle errors so that our API can read them? 24 | error_msg = [*json.loads(submissions_resp.text).values()][0] 25 | if error_msg == "You are not authorized to access this page.": 26 | raise NotAuthorized("You are not authorized to access this page.") 27 | elif error_msg == "You must be logged in to access this page.": 28 | raise Exception("You must be logged in to access this page.") 29 | elif submissions_resp.status_code == requests.codes.not_found: 30 | raise Exception("Page not Found") 31 | elif submissions_resp.status_code == requests.codes.ok: 32 | return submissions_resp 33 | 34 | 35 | def get_assignments_instructor_view(coursepage_soup): 36 | assignments_list = [] 37 | element_with_props = coursepage_soup.find( 38 | "div", {"data-react-class": "AssignmentsTable"} 39 | ) 40 | if element_with_props: 41 | # Extract the value of the data-react-props attribute 42 | props_str = element_with_props["data-react-props"] 43 | # Parse the JSON data 44 | assignment_json = json.loads(props_str) 45 | 46 | # Extract information for each assignment 47 | for assignment in assignment_json["table_data"]: 48 | # Skip non-assignment data like sections 49 | if assignment.get("type", "") != "assignment": 50 | continue 51 | 52 | assignment_obj = Assignment( 53 | assignment_id=assignment["url"].split("/")[-1], 54 | name=assignment["title"], 55 | release_date=assignment["submission_window"]["release_date"], 56 | due_date=assignment["submission_window"]["due_date"], 57 | late_due_date=assignment["submission_window"].get("hard_due_date"), 58 | submissions_status=None, 59 | grade=None, 60 | max_grade=str(float(assignment["total_points"])), 61 | ) 62 | 63 | # convert to datetime objects 64 | assignment_obj.release_date = ( 65 | dateutil.parser.parse(assignment_obj.release_date) 66 | if assignment_obj.release_date 67 | else assignment_obj.release_date 68 | ) 69 | 70 | assignment_obj.due_date = ( 71 | dateutil.parser.parse(assignment_obj.due_date) 72 | if assignment_obj.due_date 73 | else assignment_obj.due_date 74 | ) 75 | 76 | assignment_obj.late_due_date = ( 77 | dateutil.parser.parse(assignment_obj.late_due_date) 78 | if assignment_obj.late_due_date 79 | else assignment_obj.late_due_date 80 | ) 81 | 82 | # Add the assignment dictionary to the list 83 | assignments_list.append(assignment_obj) 84 | return assignments_list 85 | 86 | 87 | def get_assignments_student_view(coursepage_soup): 88 | # parse into list of lists: Assignments[row_elements[]] 89 | assignment_table = [] 90 | for assignment_row in coursepage_soup.find_all("tr", role="row")[ 91 | 1:-1 92 | ]: # Skip header row and tail row (dropzonePreview--fileNameHeader) 93 | row = [] 94 | for th in assignment_row.find_all("th"): 95 | row.append(th) 96 | for td in assignment_row.find_all("td"): 97 | row.append(td) 98 | assignment_table.append(row) 99 | assignment_info_list = [] 100 | 101 | # Iterate over the list of Tag objects 102 | for assignment in assignment_table: 103 | # Extract assignment ID and name 104 | name = assignment[0].text 105 | # 3 cases: 1. submitted -> href element, 2. not submitted, submittable -> button element, 3. not submitted, cant submit -> only text 106 | assignment_a_href = assignment[0].find("a", href=True) 107 | assignment_button = assignment[0].find("button", class_="js-submitAssignment") 108 | if assignment_a_href: 109 | assignment_id = assignment_a_href["href"].split("/")[4] 110 | elif assignment_button: 111 | assignment_id = assignment_button["data-assignment-id"] 112 | else: 113 | assignment_id = None 114 | 115 | # Extract submission status, grade, max_grade 116 | try: # Points not guaranteed 117 | points = assignment[1].text.split(" / ") 118 | grade = float(points[0]) 119 | max_grade = float(points[1]) 120 | submission_status = "Submitted" 121 | except (IndexError, ValueError): 122 | grade = None 123 | max_grade = None 124 | submission_status = assignment[1].text 125 | 126 | # Extract release date, due date, and late due date 127 | release_date = due_date = late_due_date = None 128 | try: # release date, due date, and late due date not guaranteed to be available 129 | release_obj = assignment[2].find(class_="submissionTimeChart--releaseDate") 130 | release_date = release_obj["datetime"] if release_obj else None 131 | # both due data and late due date have the same class 132 | due_dates_obj = assignment[2].find_all( 133 | class_="submissionTimeChart--dueDate" 134 | ) 135 | if due_dates_obj: 136 | due_date = due_dates_obj[0]["datetime"] if due_dates_obj else None 137 | if len(due_dates_obj) > 1: 138 | late_due_date = ( 139 | due_dates_obj[1]["datetime"] if due_dates_obj else None 140 | ) 141 | except IndexError: 142 | pass 143 | 144 | # convert to datetime objects 145 | release_date = ( 146 | dateutil.parser.parse(release_date) if release_date else release_date 147 | ) 148 | due_date = dateutil.parser.parse(due_date) if due_date else due_date 149 | late_due_date = ( 150 | dateutil.parser.parse(late_due_date) if late_due_date else late_due_date 151 | ) 152 | 153 | # Store the extracted information in a dictionary 154 | assignment_obj = Assignment( 155 | assignment_id=assignment_id, 156 | name=name, 157 | release_date=release_date, 158 | due_date=due_date, 159 | late_due_date=late_due_date, 160 | submissions_status=submission_status, 161 | grade=grade, 162 | max_grade=max_grade, 163 | ) 164 | 165 | # Append the dictionary to the list 166 | assignment_info_list.append(assignment_obj) 167 | 168 | return assignment_info_list 169 | 170 | 171 | def get_submission_files( 172 | session, 173 | course_id, 174 | assignment_id, 175 | submission_id, 176 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 177 | ): 178 | ASSIGNMENT_ENDPOINT = ( 179 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 180 | ) 181 | 182 | file_info_link = f"{ASSIGNMENT_ENDPOINT}/submissions/{submission_id}.json?content=react&only_keys[]=text_files&only_keys[]=file_comments" 183 | file_info_resp = session.get(file_info_link) 184 | if file_info_resp.status_code == requests.codes.ok: 185 | file_info_json = json.loads(file_info_resp.text) 186 | if file_info_json.get("text_files"): 187 | aws_links = [] 188 | for file_data in file_info_json["text_files"]: 189 | aws_links.append(file_data["file"]["url"]) 190 | else: 191 | raise NotImplementedError("Image only submissions not yet supported") 192 | # TODO add support for image questions 193 | return aws_links 194 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/extensions.py: -------------------------------------------------------------------------------- 1 | """Functions for getting, adding, and changing student extensions on assignments. 2 | 3 | This module provides functions for interacting with the Gradescope API to manage student extensions 4 | on assignments. It includes functions to get all extensions for an assignment, update the extension 5 | for a specific student, and remove a student's extension. 6 | 7 | The main functions in this module are: 8 | - `get_extensions`: Retrieves all extensions for a specific assignment. 9 | - `update_student_extension`: Updates the extension for a specific student on an assignment. 10 | - `remove_student_extension`: Removes the extension for a specific student. 11 | """ 12 | 13 | import datetime 14 | import json 15 | import zoneinfo 16 | from dataclasses import dataclass 17 | 18 | import dateutil.parser 19 | import requests 20 | from bs4 import BeautifulSoup 21 | 22 | from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL 23 | 24 | 25 | @dataclass 26 | class Extension: 27 | name: str 28 | release_date: datetime.datetime 29 | due_date: datetime.datetime 30 | late_due_date: datetime.datetime 31 | delete_path: str 32 | 33 | 34 | def get_extensions( 35 | session: requests.Session, 36 | course_id: str, 37 | assignment_id: str, 38 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 39 | ) -> dict: 40 | """Get all extensions for an assignment. 41 | 42 | Args: 43 | session (requests.Session): The session object used for making HTTP requests. 44 | course_id (str): The ID of the course. 45 | assignment_id (str): The ID of the assignment. 46 | 47 | Returns: 48 | dict: A dictionary containing the extensions, where the keys are user IDs and the values are Extension objects. 49 | For example: 50 | 51 | { 52 | "123456": Extension(...), 53 | "654321": Extension(...) 54 | } 55 | 56 | Raises: 57 | RuntimeError: If the request to get extensions fails. 58 | """ 59 | 60 | GS_EXTENSIONS_ENDPOINT = f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/extensions" 61 | GS_EXTENSIONS_TABLE_CSS_CLASSES = ( 62 | "table js-overridesTable" # Table containing extensions 63 | ) 64 | 65 | # get the extensions from the page 66 | response = session.get(GS_EXTENSIONS_ENDPOINT) 67 | 68 | # check if the request was successful 69 | if response.status_code != 200: 70 | raise RuntimeError( 71 | f"Failed to get extensions for assignment {assignment_id}. Status code: {response.status_code}" 72 | ) 73 | 74 | # parse the html response 75 | extensions_soup = BeautifulSoup(response.text, "html.parser") 76 | 77 | extensions_table = extensions_soup.find( 78 | "table", class_=GS_EXTENSIONS_TABLE_CSS_CLASSES 79 | ) 80 | 81 | extensions = {} 82 | 83 | table_body = extensions_table.find("tbody") 84 | for row in table_body.find_all("tr"): 85 | # find relevant data 86 | user_properties = row.find("div", {"data-react-class": "EditExtension"}).get( 87 | "data-react-props" 88 | ) 89 | user_properties = json.loads(user_properties) 90 | 91 | # user id 92 | user_id = str(user_properties["override"]["user_id"]) # TODO: keep as int? 93 | 94 | # timezone 95 | timezone = zoneinfo.ZoneInfo(user_properties["timezone"]["identifier"]) 96 | 97 | # extension properties 98 | extension_info = user_properties["override"]["settings"] 99 | release_date = extension_info.get("release_date", {}).get("value", None) 100 | due_date = extension_info.get("due_date", {}).get("value", None) 101 | late_due_date = extension_info.get("hard_due_date", {}).get("value", None) 102 | 103 | # convert dates to datetime objects 104 | release_date = ( 105 | dateutil.parser.parse(release_date).replace(tzinfo=timezone) 106 | if release_date 107 | else None 108 | ) 109 | due_date = ( 110 | dateutil.parser.parse(due_date).replace(tzinfo=timezone) 111 | if due_date 112 | else None 113 | ) 114 | late_due_date = ( 115 | dateutil.parser.parse(late_due_date).replace(tzinfo=timezone) 116 | if late_due_date 117 | else None 118 | ) 119 | 120 | # delete path 121 | delete_path = user_properties["deletePath"] 122 | 123 | # name 124 | name = user_properties["studentName"] 125 | 126 | # create extension object 127 | extension = Extension( 128 | name=name, 129 | release_date=release_date, 130 | due_date=due_date, 131 | late_due_date=late_due_date, 132 | delete_path=delete_path, 133 | ) 134 | extensions[user_id] = extension 135 | 136 | return extensions 137 | 138 | 139 | def update_student_extension( 140 | session: requests.Session, 141 | course_id: str, 142 | assignment_id: str, 143 | user_id: str, 144 | release_date: datetime.datetime | None = None, 145 | due_date: datetime.datetime | None = None, 146 | late_due_date: datetime.datetime | None = None, 147 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 148 | ) -> bool: 149 | """Updates the extension for a student on an assignment. 150 | 151 | If the user currently has an extension, this will overwrite their 152 | current extension. If the user does not have an extension, this 153 | will add an extension for that user. If a date is None, it will 154 | not be updated. 155 | 156 | Requirements: 157 | release_date <= due_date <= late_due_date 158 | 159 | Notes: 160 | Extensions can go "backwards" too. For example, a user can have 161 | a release date earlier than the normal release date or a due date 162 | before the normal due date. 163 | 164 | Args: 165 | session (requests.Session): The session to use for the request 166 | course_id (str): The course id 167 | assignment_id (str): The assignment id 168 | user_id (str): The user id 169 | release_date (datetime.datetime | None): The release date. If None, it will not be updated 170 | due_date (datetime.datetime | None): The due date. If None, it will not be updated 171 | late_due_date (datetime.datetime | None): The late due date. If None, it will not be updated 172 | 173 | Returns: 174 | bool: True if the extension was successfully updated, False otherwise 175 | 176 | Raises: 177 | ValueError: If no dates are provided 178 | ValueError: If the dates are not in order 179 | """ 180 | 181 | # Check if at least 1 date is set 182 | if release_date is None and due_date is None and late_due_date is None: 183 | raise ValueError("At least one date must be provided") 184 | 185 | # Check if date requirements are met (in order) 186 | dates = [ 187 | date for date in [release_date, due_date, late_due_date] if date is not None 188 | ] 189 | if dates != sorted(dates): 190 | raise ValueError( 191 | "Dates must be in order: release_date <= due_date <= late_due_date" 192 | ) 193 | 194 | def add_to_body(extension_name: str, extension_datetime: datetime.datetime): 195 | """Update JSON body for POST request""" 196 | if extension_datetime is not None: 197 | # convert datetime to UTC 198 | extension_datetime = extension_datetime.astimezone(datetime.timezone.utc) 199 | 200 | # convert datetime to string ISO 8601 format 201 | date_str = extension_datetime.strftime("%Y-%m-%dT%H:%M:%SZ") 202 | 203 | # add to request body 204 | body["override"]["settings"][extension_name] = { 205 | "type": "absolute", 206 | "value": date_str, 207 | } 208 | 209 | # Update release date, due date, and late due date 210 | body = {"override": {"user_id": user_id, "settings": {"visible": True}}} 211 | for extension_name, extension_datetime in [ 212 | ("release_date", release_date), 213 | ("due_date", due_date), 214 | ("hard_due_date", late_due_date), 215 | ]: 216 | if extension_datetime is not None: 217 | add_to_body(extension_name, extension_datetime) 218 | 219 | # send the request 220 | resp = session.post( 221 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/extensions", 222 | json=body, 223 | ) 224 | return resp.status_code == 200 225 | 226 | 227 | def remove_student_extension( 228 | session: requests.Session, 229 | delete_path: str, 230 | ) -> bool: 231 | raise NotImplementedError("Not implemented yet") 232 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/assignments.py: -------------------------------------------------------------------------------- 1 | """Functions for modifying assignment details.""" 2 | 3 | import datetime 4 | from dataclasses import dataclass 5 | 6 | import requests 7 | from bs4 import BeautifulSoup 8 | from requests_toolbelt.multipart.encoder import MultipartEncoder 9 | 10 | from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL 11 | 12 | 13 | class AssignmentUpdateError(Exception): 14 | pass 15 | 16 | 17 | class InvalidTitleName(AssignmentUpdateError): 18 | pass 19 | 20 | 21 | @dataclass 22 | class Assignment: 23 | assignment_id: str 24 | name: str 25 | release_date: datetime.datetime 26 | due_date: datetime.datetime 27 | late_due_date: datetime.datetime 28 | submissions_status: str 29 | grade: str 30 | max_grade: str 31 | 32 | 33 | def update_assignment_date( 34 | session: requests.Session, 35 | course_id: str, 36 | assignment_id: str, 37 | release_date: datetime.datetime | None = None, 38 | due_date: datetime.datetime | None = None, 39 | late_due_date: datetime.datetime | None = None, 40 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 41 | ) -> bool: 42 | """Update the dates of an assignment on Gradescope. 43 | 44 | Args: 45 | session (requests.Session): The session object for making HTTP requests. 46 | course_id (str): The ID of the course. 47 | assignment_id (str): The ID of the assignment. 48 | release_date (datetime.datetime | None, optional): The release date of the assignment. Defaults to None. 49 | due_date (datetime.datetime | None, optional): The due date of the assignment. Defaults to None. 50 | late_due_date (datetime.datetime | None, optional): The late due date of the assignment. Defaults to None. 51 | 52 | Notes: 53 | The timezone for dates used in Gradescope is specific to an institution. For example, for NYU, the timezone is America/New_York. 54 | For datetime objects passed to this function, the timezone should be set to the institution's timezone. 55 | 56 | Raises if session does not have access to configure assignment. 57 | 58 | Returns: 59 | bool: True if the assignment dates were successfully updated, False otherwise. 60 | """ 61 | GS_EDIT_ASSIGNMENT_ENDPOINT = ( 62 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/edit" 63 | ) 64 | GS_POST_ASSIGNMENT_ENDPOINT = ( 65 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 66 | ) 67 | 68 | # Get auth token 69 | response = session.get(GS_EDIT_ASSIGNMENT_ENDPOINT) 70 | response.raise_for_status() 71 | soup = BeautifulSoup(response.text, "html.parser") 72 | auth_token = soup.select_one('input[name="authenticity_token"]')["value"] 73 | 74 | # Setup multipart form data 75 | multipart = MultipartEncoder( 76 | fields={ 77 | "utf8": "✓", 78 | "_method": "patch", 79 | "authenticity_token": auth_token, 80 | "assignment[release_date_string]": ( 81 | release_date.strftime("%Y-%m-%dT%H:%M") if release_date else "" 82 | ), 83 | "assignment[due_date_string]": ( 84 | due_date.strftime("%Y-%m-%dT%H:%M") if due_date else "" 85 | ), 86 | "assignment[allow_late_submissions]": "1" if late_due_date else "0", 87 | "assignment[hard_due_date_string]": ( 88 | late_due_date.strftime("%Y-%m-%dT%H:%M") if late_due_date else "" 89 | ), 90 | "commit": "Save", 91 | } 92 | ) 93 | headers = { 94 | "Content-Type": multipart.content_type, 95 | "Referer": GS_EDIT_ASSIGNMENT_ENDPOINT, 96 | } 97 | 98 | response = session.post( 99 | GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers 100 | ) 101 | response.raise_for_status() 102 | 103 | return response.status_code == 200 104 | 105 | 106 | def update_assignment_title( 107 | session: requests.Session, 108 | course_id: str, 109 | assignment_id: str, 110 | assignment_name: str, 111 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 112 | ) -> bool: 113 | """Update the dates of an assignment on Gradescope. 114 | 115 | Args: 116 | session (requests.Session): The session object for making HTTP requests. 117 | course_id (str): The ID of the course. 118 | assignment_id (str): The ID of the assignment. 119 | assignment_name (str): The name of the assignment to update to. 120 | 121 | Notes: 122 | Assignment name cannot be all whitespace 123 | 124 | Raises if session does not have access to configure assignment. 125 | 126 | Returns: 127 | bool: True if the assignment dates were successfully updated, False otherwise. 128 | """ 129 | GS_EDIT_ASSIGNMENT_ENDPOINT = ( 130 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/edit" 131 | ) 132 | GS_POST_ASSIGNMENT_ENDPOINT = ( 133 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 134 | ) 135 | 136 | # Get auth token 137 | response = session.get(GS_EDIT_ASSIGNMENT_ENDPOINT) 138 | response.raise_for_status() 139 | soup = BeautifulSoup(response.text, "html.parser") 140 | auth_token = soup.select_one('input[name="authenticity_token"]')["value"] 141 | 142 | # Setup multipart form data 143 | multipart = MultipartEncoder( 144 | fields={ 145 | "utf8": "✓", 146 | "_method": "patch", 147 | "authenticity_token": auth_token, 148 | "assignment[title]": assignment_name, 149 | "commit": "Save", 150 | } 151 | ) 152 | headers = { 153 | "Content-Type": multipart.content_type, 154 | "Referer": GS_EDIT_ASSIGNMENT_ENDPOINT, 155 | } 156 | 157 | response = session.post( 158 | GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers 159 | ) 160 | response.raise_for_status() 161 | 162 | soup = BeautifulSoup(response.content, "html.parser") 163 | error = soup.select_one(".form--requiredFieldStar.error") 164 | if error is not None: 165 | if error.parent is not None and error.parent.text.startswith("Title"): 166 | raise InvalidTitleName(f"Assignment title '{assignment_name}' is invalid") 167 | else: 168 | raise AssignmentUpdateError( 169 | "Unknown error occurred trying to update assignment title" 170 | ) 171 | 172 | return response.status_code == 200 173 | 174 | 175 | def update_autograder_image_name( 176 | session: requests.Session, 177 | course_id: str, 178 | assignment_id: str, 179 | image_name: str, 180 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 181 | ) -> bool: 182 | """Update the Docker Hub image name of an assignment on Gradescope. 183 | 184 | Args: 185 | session (requests.Session): The session object for making HTTP requests. 186 | course_id (str): The ID of the course. 187 | assignment_id (str): The ID of the assignment. 188 | image_name (str): The Docker Hub Image Name (user-handle/repo:tag) 189 | 190 | Notes: 191 | In most cases Gradescope does not validate that the image_name provided exists on Docker Hub. Garbage 192 | values may still successfully return OK. You should test your autograder after updating the image name 193 | to ensure it works as expected. 194 | 195 | Example image name: 'gradescope/autograder-base:ubuntu-22.04' 196 | from https://hub.docker.com/layers/gradescope/autograder-base/ubuntu-22.04 197 | 198 | Raises if session does not have access to configure autograder or if assignment does not have an autograder. 199 | 200 | Returns: 201 | bool: True if the image name was successfully updated, False otherwise. 202 | """ 203 | GS_EDIT_AUTOGRADER_ASSIGNMENT_ENDPOINT = f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/configure_autograder" 204 | GS_POST_ASSIGNMENT_ENDPOINT = ( 205 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 206 | ) 207 | 208 | # Get auth token 209 | response = session.get(GS_EDIT_AUTOGRADER_ASSIGNMENT_ENDPOINT) 210 | response.raise_for_status() 211 | soup = BeautifulSoup(response.text, "html.parser") 212 | auth_token = soup.select_one('input[name="authenticity_token"]')["value"] 213 | 214 | # Setup multipart form data 215 | multipart = MultipartEncoder( 216 | fields={ 217 | "utf8": "✓", 218 | "_method": "patch", 219 | "authenticity_token": auth_token, 220 | "source_page": "configure_autograder", 221 | "assignment[image_name]": image_name, 222 | } 223 | ) 224 | headers = { 225 | "Content-Type": multipart.content_type, 226 | "Referer": GS_EDIT_AUTOGRADER_ASSIGNMENT_ENDPOINT, 227 | } 228 | 229 | response = session.post( 230 | GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers 231 | ) 232 | response.raise_for_status() 233 | 234 | soup = BeautifulSoup(response.content, "html.parser") 235 | return response.status_code == 200 and not soup.find( 236 | string="Docker image not found in your current course!" 237 | ) 238 | -------------------------------------------------------------------------------- /src/gradescopeapi/classes/account.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from bs4 import BeautifulSoup 4 | 5 | from gradescopeapi import DEFAULT_GRADESCOPE_BASE_URL 6 | from gradescopeapi.classes._helpers._assignment_helpers import ( 7 | check_page_auth, 8 | get_assignments_instructor_view, 9 | get_assignments_student_view, 10 | get_submission_files, 11 | ) 12 | from gradescopeapi.classes._helpers._course_helpers import ( 13 | get_course_members, 14 | get_courses_info, 15 | ) 16 | from gradescopeapi.classes.assignments import Assignment 17 | from gradescopeapi.classes.member import Member 18 | from gradescopeapi.classes._helpers._assignment_helpers import NotAuthorized 19 | from gradescopeapi.classes.courses import Course 20 | 21 | 22 | class Account: 23 | def __init__( 24 | self, 25 | session, 26 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 27 | ): 28 | self.session = session 29 | self.gradescope_base_url = gradescope_base_url 30 | 31 | def get_courses(self) -> dict[str, dict[str, Course]]: 32 | """ 33 | Get all courses for the user, including both instructor and student courses 34 | 35 | Returns: 36 | dict: A dictionary of dictionaries, where keys are "instructor" and "student" and values are 37 | dictionaries containing all courses, where keys are course IDs and values are Course objects. 38 | 39 | For example: 40 | { 41 | 'instructor': { 42 | "123456": Course(...), 43 | "234567": Course(...) 44 | }, 45 | 'student': { 46 | "654321": Course(...), 47 | "765432": Course(...) 48 | } 49 | } 50 | 51 | Raises: 52 | RuntimeError: If request to account page fails. 53 | """ 54 | 55 | endpoint = f"{self.gradescope_base_url}/account" 56 | 57 | # get main page 58 | response = self.session.get(endpoint) 59 | 60 | if response.status_code != 200: 61 | raise RuntimeError( 62 | f"Failed to access account page on Gradescope. Status code: {response.status_code}" 63 | ) 64 | 65 | soup = BeautifulSoup(response.text, "html.parser") 66 | 67 | # see if user is solely a student or instructor 68 | return get_courses_info(soup) 69 | 70 | def get_course_users(self, course_id: str) -> list[Member]: 71 | """ 72 | Get a list of all users in a course 73 | Returns: 74 | list: A list of users in the course (Member objects) 75 | Raises: 76 | Exceptions: 77 | "One or more invalid parameters": if course_id is null or empty value 78 | "You must be logged in to access this page.": if no user is logged in 79 | """ 80 | 81 | membership_endpoint = ( 82 | f"{self.gradescope_base_url}/courses/{course_id}/memberships" 83 | ) 84 | 85 | # check that course_id is valid (not empty) 86 | if not course_id: 87 | raise Exception("Invalid Course ID") 88 | 89 | session = self.session 90 | 91 | try: 92 | # scrape page 93 | membership_resp = check_page_auth(session, membership_endpoint) 94 | membership_soup = BeautifulSoup(membership_resp.text, "html.parser") 95 | 96 | # get all users in the course 97 | users = get_course_members(membership_soup, course_id) 98 | 99 | return users 100 | except Exception: 101 | return None 102 | 103 | def get_assignments(self, course_id: str) -> list[Assignment]: 104 | """ 105 | Get a list of detailed assignment information for a course 106 | Returns: 107 | list: A list of Assignments 108 | Raises: 109 | Exceptions: 110 | "One or more invalid parameters": if course_id or assignment_id is null or empty value 111 | "You are not authorized to access this page.": if logged in user is unable to access submissions 112 | "You must be logged in to access this page.": if no user is logged in 113 | """ 114 | # check that course_id is valid (not empty) 115 | if not course_id: 116 | raise Exception("Invalid Course ID") 117 | session = self.session 118 | 119 | # scrape page 120 | try: 121 | # this endpoint is only available if the user is a staff of the course 122 | course_endpoint = ( 123 | f"{self.gradescope_base_url}/courses/{course_id}/assignments" 124 | ) 125 | coursepage_resp = check_page_auth(session, course_endpoint) 126 | except NotAuthorized: 127 | # fall back to default course page if the user is a student 128 | course_endpoint = f"{self.gradescope_base_url}/courses/{course_id}" 129 | coursepage_resp = check_page_auth(session, course_endpoint) 130 | coursepage_soup = BeautifulSoup(coursepage_resp.text, "html.parser") 131 | 132 | # two different helper functions to parse assignment info 133 | # webpage html structure differs based on if user if instructor or student 134 | assignment_info_list = get_assignments_instructor_view(coursepage_soup) 135 | if not assignment_info_list: 136 | assignment_info_list = get_assignments_student_view(coursepage_soup) 137 | 138 | return assignment_info_list 139 | 140 | def get_assignment_submissions( 141 | self, course_id: str, assignment_id: str 142 | ) -> dict[str, list[str]]: 143 | """ 144 | Get a list of dicts mapping AWS links for all submissions to each submission id 145 | Returns: 146 | dict: A dictionary of submissions, where the keys are the submission ids and the values are 147 | a list of aws links to the submission pdf 148 | For example: 149 | { 150 | 'submission_id': [ 151 | 'aws_link1.com', 152 | 'aws_link2.com', 153 | ... 154 | ], 155 | ... 156 | } 157 | Raises: 158 | Exceptions: 159 | "One or more invalid parameters": if course_id or assignment_id is null or empty value 160 | "You are not authorized to access this page.": if logged in user is unable to access submissions 161 | "You must be logged in to access this page.": if no user is logged in 162 | "Page not Found": When link is invalid: change in url, invalid course_if or assignment id 163 | "Image only submissions not yet supported": assignment is image submission only, which is not yet supported 164 | NOTE: 165 | 1. Image submissions not supports, need to find an endpoint to retrieve image pdfs 166 | 2. Not recommended for use, since this makes a GET request for every submission -> very slow! 167 | 3. so far only accessible for teachers, not for students to get submissions to an assignment 168 | """ 169 | ASSIGNMENT_ENDPOINT = f"{self.gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 170 | ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{ASSIGNMENT_ENDPOINT}/review_grades" 171 | if not course_id or not assignment_id: 172 | raise Exception("One or more invalid parameters") 173 | session = self.session 174 | submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) 175 | submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") 176 | # select submissions (class of td.table--primaryLink a tag, submission id stored in href link) 177 | submissions_a_tags = submissions_soup.select("td.table--primaryLink a") 178 | submission_ids = [ 179 | a_tag.attrs.get("href").split("/")[-1] for a_tag in submissions_a_tags 180 | ] 181 | submission_links = {} 182 | for submission_id in submission_ids: # doesn't support image submissions yet 183 | aws_links = get_submission_files( 184 | session, course_id, assignment_id, submission_id 185 | ) 186 | submission_links[submission_id] = aws_links 187 | # sleep for 0.1 seconds to avoid sending too many requests to gradescope 188 | time.sleep(0.1) 189 | return submission_links 190 | 191 | def get_assignment_submission( 192 | self, student_email: str, course_id: str, assignment_id: str 193 | ) -> list[str]: 194 | """ 195 | Get a list of aws links to files of the student's most recent submission to an assignment 196 | Returns: 197 | list: A list of aws links as strings 198 | For example: 199 | [ 200 | 'aws_link1.com', 201 | 'aws_link2.com', 202 | ... 203 | ] 204 | Raises: 205 | Exceptions: 206 | "One or more invalid parameters": if course_id or assignment_id is null or empty value 207 | "You are not authorized to access this page.": if logged in user is unable to access submissions 208 | "You must be logged in to access this page.": if no user is logged in 209 | "Page not Found": When link is invalid: change in url, invalid course_if or assignment id 210 | "PDF/Image only submissions not yet supported": assignment is pdf/image submission only, which is not yet supported 211 | "No submission found": When no submission is found for given student_email 212 | NOTE: so far only accessible for teachers, not for students to get their own submission 213 | """ 214 | # fetch submission id 215 | ASSIGNMENT_ENDPOINT = f"{self.gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 216 | ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{ASSIGNMENT_ENDPOINT}/review_grades" 217 | if not (student_email and course_id and assignment_id): 218 | raise Exception("One or more invalid parameters") 219 | session = self.session 220 | submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) 221 | submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") 222 | td_with_email = submissions_soup.find( 223 | "td", string=lambda s: student_email in str(s) 224 | ) 225 | if td_with_email: 226 | # grab submission from previous td 227 | submission_td = td_with_email.find_previous_sibling() 228 | # submission_td will have an anchor element as a child if there is a submission 229 | a_element = submission_td.find("a") 230 | if a_element: 231 | submission_id = a_element.get("href").split("/")[-1] 232 | else: 233 | raise Exception("No submission found") 234 | # call get_submission_files helper function 235 | aws_links = get_submission_files( 236 | session, course_id, assignment_id, submission_id 237 | ) 238 | return aws_links 239 | else: 240 | raise Exception("No submission found") 241 | 242 | def get_assignment_graders(self, course_id: str, question_id: str) -> set[str]: 243 | """ 244 | Get a set of graders for a specific question in an assignment 245 | Returns: 246 | set: A set of graders as strings 247 | For example: 248 | { 249 | 'grader1', 250 | 'grader2', 251 | ... 252 | } 253 | Raises: 254 | Exceptions: 255 | "One or more invalid parameters": if course_id or assignment_id is null or empty value 256 | "You are not authorized to access this page.": if logged in user is unable to access submissions 257 | "You must be logged in to access this page.": if no user is logged in 258 | "Page not Found": When link is invalid: change in url, invalid course_if or assignment id 259 | """ 260 | QUESTION_ENDPOINT = ( 261 | f"{self.gradescope_base_url}/courses/{course_id}/questions/{question_id}" 262 | ) 263 | ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{QUESTION_ENDPOINT}/submissions" 264 | if not course_id or not question_id: 265 | raise Exception("One or more invalid parameters") 266 | session = self.session 267 | submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) 268 | submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") 269 | # select graders (class of td tag, grader name stored in text) 270 | graders = submissions_soup.select("td")[2::3] 271 | grader_names = set( 272 | [grader.text for grader in graders if grader.text] 273 | ) # get non-empty grader names 274 | return grader_names 275 | -------------------------------------------------------------------------------- /src/gradescopeapi/api/api.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from fastapi import Depends, FastAPI, HTTPException, status 4 | 5 | from gradescopeapi._config.config import FileUploadModel, LoginRequestModel 6 | from gradescopeapi.classes.account import Account 7 | from gradescopeapi.classes.assignments import Assignment, update_assignment_date 8 | from gradescopeapi.classes.connection import GSConnection 9 | from gradescopeapi.classes.courses import Course 10 | from gradescopeapi.classes.extensions import get_extensions, update_student_extension 11 | from gradescopeapi.classes.member import Member 12 | from gradescopeapi.classes.upload import upload_assignment 13 | 14 | app = FastAPI() 15 | 16 | # Create instance of GSConnection, to be used where needed 17 | connection = GSConnection() 18 | 19 | 20 | def get_gs_connection(): 21 | """ 22 | Returns the GSConnection instance 23 | 24 | Returns: 25 | connection (GSConnection): an instance of the GSConnection class, 26 | containing the session object used to make HTTP requests, 27 | a boolean defining True/False if the user is logged in, and 28 | the user's Account object. 29 | """ 30 | return connection 31 | 32 | 33 | def get_gs_connection_session(): 34 | """ 35 | Returns session of the the GSConnection instance 36 | 37 | Returns: 38 | connection.session (GSConnection.session): an instance of the GSConnection class' session object used to make HTTP requests 39 | """ 40 | return connection.session 41 | 42 | 43 | def get_account(): 44 | """ 45 | Returns the user's Account object 46 | 47 | Returns: 48 | Account (Account): an instance of the Account class, containing 49 | methods for interacting with the user's courses and assignments. 50 | """ 51 | return Account(session=get_gs_connection_session) 52 | 53 | 54 | # Create instance of GSConnection, to be used where needed 55 | connection = GSConnection() 56 | 57 | account = None 58 | 59 | 60 | @app.get("/") 61 | def root(): 62 | return {"message": "Hello World"} 63 | 64 | 65 | @app.post("/login", name="login") 66 | def login( 67 | login_data: LoginRequestModel, 68 | gs_connection: GSConnection = Depends(get_gs_connection), 69 | ): 70 | """Login to Gradescope, with correct credentials 71 | 72 | Args: 73 | username (str): email address of user attempting to log in 74 | password (str): password of user attempting to log in 75 | 76 | Raises: 77 | HTTPException: If the request to login fails, with a 404 Unauthorized Error status code and the error message "Account not found". 78 | """ 79 | user_email = login_data.email 80 | password = login_data.password 81 | 82 | try: 83 | connection.login(user_email, password) 84 | global account 85 | account = connection.account 86 | return {"message": "Login successful", "status_code": status.HTTP_200_OK} 87 | except ValueError as e: 88 | raise HTTPException(status_code=404, detail=f"Account not found. Error {e}") 89 | 90 | 91 | @app.post("/courses", response_model=dict[str, dict[str, Course]]) 92 | def get_courses(): 93 | """Get all courses for the user 94 | 95 | Args: 96 | account (Account): Account object containing the user's courses 97 | 98 | Returns: 99 | dict: dictionary of dictionaries 100 | 101 | Raises: 102 | HTTPException: If the request to get courses fails, with a 500 Internal Server Error status code and the error message. 103 | """ 104 | try: 105 | course_list = account.get_courses() 106 | return course_list 107 | except RuntimeError as e: 108 | raise HTTPException(status_code=500, detail=str(e)) 109 | 110 | 111 | @app.post("/course_users", response_model=list[Member]) 112 | def get_course_users(course_id: str): 113 | """Get all users for a course. ONLY FOR INSTRUCTORS. 114 | 115 | Args: 116 | course_id (str): The ID of the course. 117 | 118 | Returns: 119 | dict: dictionary of dictionaries 120 | 121 | Raises: 122 | HTTPException: If the request to get courses fails, with a 500 Internal Server Error status code and the error message. 123 | """ 124 | try: 125 | course_list = connection.account.get_course_users(course_id) 126 | print(course_list) 127 | return course_list 128 | except RuntimeError as e: 129 | raise HTTPException(status_code=500, detail=str(e)) 130 | 131 | 132 | @app.post("/assignments", response_model=list[Assignment]) 133 | def get_assignments(course_id: str): 134 | """Get all assignments for a course. ONLY FOR INSTRUCTORS. 135 | list: list of user emails 136 | 137 | Raises: 138 | HTTPException: If the request to get course users fails, with a 500 Internal Server Error status code and the error message. 139 | """ 140 | try: 141 | course_users = connection.account.get_assignments(course_id) 142 | return course_users 143 | except RuntimeError as e: 144 | raise HTTPException( 145 | status_code=500, detail=f"Failed to get course users. Error {e}" 146 | ) 147 | 148 | 149 | @app.post("/assignment_submissions", response_model=dict[str, list[str]]) 150 | def get_assignment_submissions( 151 | course_id: str, 152 | assignment_id: str, 153 | ): 154 | """Get all assignment submissions for an assignment. ONLY FOR INSTRUCTORS. 155 | 156 | Args: 157 | course_id (str): The ID of the course. 158 | assignment_id (str): The ID of the assignment. 159 | 160 | Returns: 161 | list: list of Assignment objects 162 | 163 | Raises: 164 | HTTPException: If the request to get assignments fails, with a 500 Internal Server Error status code and the error message. 165 | """ 166 | try: 167 | assignment_list = connection.account.get_assignment_submissions( 168 | course_id=course_id, assignment_id=assignment_id 169 | ) 170 | return assignment_list 171 | except RuntimeError as e: 172 | raise HTTPException( 173 | status_code=500, detail=f"Failed to get assignments. Error: {e}" 174 | ) 175 | 176 | 177 | @app.post("/single_assignment_submission", response_model=list[str]) 178 | def get_student_assignment_submission( 179 | student_email: str, course_id: str, assignment_id: str 180 | ): 181 | """Get a student's assignment submission. ONLY FOR INSTRUCTORS. 182 | 183 | Args: 184 | student_email (str): The email address of the student. 185 | course_id (str): The ID of the course. 186 | assignment_id (str): The ID of the assignment. 187 | 188 | Returns: 189 | dict: dictionary containing a list of student emails and their corresponding submission IDs 190 | 191 | Raises: 192 | HTTPException: If the request to get assignment submissions fails, with a 500 Internal Server Error status code and the error message. 193 | """ 194 | try: 195 | assignment_submissions = connection.account.get_assignment_submission( 196 | student_email=student_email, 197 | course_id=course_id, 198 | assignment_id=assignment_id, 199 | ) 200 | return assignment_submissions 201 | except RuntimeError as e: 202 | raise HTTPException( 203 | status_code=500, detail=f"Failed to get assignment submissions. Error: {e}" 204 | ) 205 | 206 | 207 | @app.post("/assignments/update_dates") 208 | def update_assignment_dates( 209 | course_id: str, 210 | assignment_id: str, 211 | release_date: datetime, 212 | due_date: datetime, 213 | late_due_date: datetime, 214 | ): 215 | """ 216 | Update the release and due dates for an assignment. ONLY FOR INSTRUCTORS. 217 | 218 | Args: 219 | course_id (str): The ID of the course. 220 | assignment_id (str): The ID of the assignment. 221 | release_date (datetime): The release date of the assignment. 222 | due_date (datetime): The due date of the assignment. 223 | late_due_date (datetime): The late due date of the assignment. 224 | 225 | Notes: 226 | The timezone for dates used in Gradescope is specific to an institution. For example, for NYU, the timezone is America/New_York. 227 | For datetime objects passed to this function, the timezone should be set to the institution's timezone. 228 | 229 | Returns: 230 | dict: A dictionary with a "message" key indicating if the assignment dates were updated successfully. 231 | 232 | Raises: 233 | HTTPException: If the assignment dates update fails, with a 400 Bad Request status code and the error message "Failed to update assignment dates". 234 | """ 235 | try: 236 | print(f"late due date {late_due_date}") 237 | success = update_assignment_date( 238 | session=connection.session, 239 | course_id=course_id, 240 | assignment_id=assignment_id, 241 | release_date=release_date, 242 | due_date=due_date, 243 | late_due_date=late_due_date, 244 | ) 245 | if success: 246 | return { 247 | "message": "Assignment dates updated successfully", 248 | "status_code": status.HTTP_200_OK, 249 | } 250 | else: 251 | raise HTTPException( 252 | status_code=400, detail="Failed to update assignment dates" 253 | ) 254 | except Exception as e: 255 | raise HTTPException(status_code=500, detail=str(e)) 256 | 257 | 258 | @app.post("/assignments/extensions", response_model=dict) 259 | def get_assignment_extensions(course_id: str, assignment_id: str): 260 | """ 261 | Get all extensions for an assignment. 262 | 263 | Args: 264 | course_id (str): The ID of the course. 265 | assignment_id (str): The ID of the assignment. 266 | 267 | Returns: 268 | dict: A dictionary containing the extensions, where the keys are user IDs and the values are Extension objects. 269 | 270 | Raises: 271 | HTTPException: If the request to get extensions fails, with a 500 Internal Server Error status code and the error message. 272 | """ 273 | try: 274 | extensions = get_extensions( 275 | session=connection.session, 276 | course_id=course_id, 277 | assignment_id=assignment_id, 278 | ) 279 | return extensions 280 | except RuntimeError as e: 281 | raise HTTPException(status_code=500, detail=str(e)) 282 | 283 | 284 | @app.post("/assignments/extensions/update") 285 | def update_extension( 286 | course_id: str, 287 | assignment_id: str, 288 | user_id: str, 289 | release_date: datetime, 290 | due_date: datetime, 291 | late_due_date: datetime, 292 | ): 293 | """ 294 | Update the extension for a student on an assignment. ONLY FOR INSTRUCTORS. 295 | 296 | Args: 297 | course_id (str): The ID of the course. 298 | assignment_id (str): The ID of the assignment. 299 | user_id (str): The ID of the student. 300 | release_date (datetime): The release date of the extension. 301 | due_date (datetime): The due date of the extension. 302 | late_due_date (datetime): The late due date of the extension. 303 | 304 | Returns: 305 | dict: A dictionary with a "message" key indicating if the extension was updated successfully. 306 | 307 | Raises: 308 | HTTPException: If the extension update fails, with a 400 Bad Request status code and the error message. 309 | HTTPException: If a ValueError is raised (e.g., invalid date order), with a 400 Bad Request status code and the error message. 310 | HTTPException: If any other exception occurs, with a 500 Internal Server Error status code and the error message. 311 | """ 312 | try: 313 | success = update_student_extension( 314 | session=connection.session, 315 | course_id=course_id, 316 | assignment_id=assignment_id, 317 | user_id=user_id, 318 | release_date=release_date, 319 | due_date=due_date, 320 | late_due_date=late_due_date, 321 | ) 322 | if success: 323 | return { 324 | "message": "Extension updated successfully", 325 | "status_code": status.HTTP_200_OK, 326 | } 327 | else: 328 | raise HTTPException(status_code=400, detail="Failed to update extension") 329 | except ValueError as e: 330 | raise HTTPException(status_code=400, detail=str(e)) 331 | except Exception as e: 332 | raise HTTPException(status_code=500, detail=str(e)) 333 | 334 | 335 | @app.post("/assignments/upload") 336 | def upload_assignment_files( 337 | course_id: str, assignment_id: str, leaderboard_name: str, file: FileUploadModel 338 | ): 339 | """ 340 | Upload files for an assignment. 341 | 342 | NOTE: This function within FastAPI is currently nonfunctional, as we did not 343 | find the datatype for file, which would allow us to upload a file via 344 | Postman. However, this functionality works correctly if a user 345 | runs this as a Python package. 346 | 347 | Args: 348 | course_id (str): The ID of the course on Gradescope. 349 | assignment_id (str): The ID of the assignment on Gradescope. 350 | leaderboard_name (str): The name of the leaderboard. 351 | file (FileUploadModel): The file object to upload. 352 | 353 | Returns: 354 | dict: A dictionary containing the submission link for the uploaded files. 355 | 356 | Raises: 357 | HTTPException: If the upload fails, with a 400 Bad Request status code and the error message "Upload unsuccessful". 358 | HTTPException: If any other exception occurs, with a 500 Internal Server Error status code and the error message. 359 | """ 360 | try: 361 | submission_link = upload_assignment( 362 | session=connection.session, 363 | course_id=course_id, 364 | assignment_id=assignment_id, 365 | files=file, 366 | leaderboard_name=leaderboard_name, 367 | ) 368 | if submission_link: 369 | return {"submission_link": submission_link} 370 | else: 371 | raise HTTPException(status_code=400, detail="Upload unsuccessful") 372 | except Exception as e: 373 | raise HTTPException(status_code=500, detail=str(e)) 374 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --output-file requirements.txt --no-dev --locked 3 | -e . 4 | annotated-doc==0.0.4 \ 5 | --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ 6 | --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 7 | # via fastapi 8 | annotated-types==0.7.0 \ 9 | --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ 10 | --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 11 | # via pydantic 12 | anyio==4.12.0 \ 13 | --hash=sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0 \ 14 | --hash=sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb 15 | # via starlette 16 | beautifulsoup4==4.14.3 \ 17 | --hash=sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb \ 18 | --hash=sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86 19 | # via gradescopeapi 20 | certifi==2025.11.12 \ 21 | --hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b \ 22 | --hash=sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316 23 | # via requests 24 | charset-normalizer==3.4.4 \ 25 | --hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \ 26 | --hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \ 27 | --hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \ 28 | --hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \ 29 | --hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \ 30 | --hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \ 31 | --hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \ 32 | --hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \ 33 | --hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \ 34 | --hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \ 35 | --hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \ 36 | --hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \ 37 | --hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \ 38 | --hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \ 39 | --hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \ 40 | --hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \ 41 | --hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \ 42 | --hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \ 43 | --hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \ 44 | --hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \ 45 | --hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \ 46 | --hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \ 47 | --hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \ 48 | --hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \ 49 | --hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \ 50 | --hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \ 51 | --hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \ 52 | --hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \ 53 | --hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \ 54 | --hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \ 55 | --hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \ 56 | --hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \ 57 | --hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \ 58 | --hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \ 59 | --hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \ 60 | --hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \ 61 | --hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \ 62 | --hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \ 63 | --hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \ 64 | --hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \ 65 | --hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \ 66 | --hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \ 67 | --hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \ 68 | --hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \ 69 | --hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \ 70 | --hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \ 71 | --hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \ 72 | --hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \ 73 | --hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \ 74 | --hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \ 75 | --hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \ 76 | --hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \ 77 | --hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \ 78 | --hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \ 79 | --hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \ 80 | --hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \ 81 | --hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \ 82 | --hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \ 83 | --hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \ 84 | --hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \ 85 | --hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \ 86 | --hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \ 87 | --hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \ 88 | --hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \ 89 | --hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \ 90 | --hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \ 91 | --hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \ 92 | --hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \ 93 | --hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \ 94 | --hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \ 95 | --hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \ 96 | --hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \ 97 | --hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \ 98 | --hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \ 99 | --hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \ 100 | --hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \ 101 | --hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \ 102 | --hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \ 103 | --hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \ 104 | --hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \ 105 | --hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \ 106 | --hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e 107 | # via requests 108 | colorama==0.4.6 ; sys_platform == 'win32' \ 109 | --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ 110 | --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 111 | # via pytest 112 | exceptiongroup==1.3.1 ; python_full_version < '3.11' \ 113 | --hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \ 114 | --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 115 | # via 116 | # anyio 117 | # pytest 118 | fastapi==0.125.0 \ 119 | --hash=sha256:16b532691a33e2c5dee1dac32feb31dc6eb41a3dd4ff29a95f9487cb21c054c0 \ 120 | --hash=sha256:2570ec4f3aecf5cca8f0428aed2398b774fcdfee6c2116f86e80513f2f86a7a1 121 | # via gradescopeapi 122 | idna==3.11 \ 123 | --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \ 124 | --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902 125 | # via 126 | # anyio 127 | # requests 128 | iniconfig==2.3.0 \ 129 | --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ 130 | --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 131 | # via pytest 132 | packaging==25.0 \ 133 | --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ 134 | --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f 135 | # via pytest 136 | pluggy==1.6.0 \ 137 | --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ 138 | --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 139 | # via pytest 140 | pydantic==2.12.5 \ 141 | --hash=sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49 \ 142 | --hash=sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d 143 | # via fastapi 144 | pydantic-core==2.41.5 \ 145 | --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \ 146 | --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \ 147 | --hash=sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84 \ 148 | --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \ 149 | --hash=sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c \ 150 | --hash=sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0 \ 151 | --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \ 152 | --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \ 153 | --hash=sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a \ 154 | --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \ 155 | --hash=sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2 \ 156 | --hash=sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3 \ 157 | --hash=sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815 \ 158 | --hash=sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14 \ 159 | --hash=sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba \ 160 | --hash=sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375 \ 161 | --hash=sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf \ 162 | --hash=sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963 \ 163 | --hash=sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1 \ 164 | --hash=sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808 \ 165 | --hash=sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553 \ 166 | --hash=sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1 \ 167 | --hash=sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2 \ 168 | --hash=sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470 \ 169 | --hash=sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2 \ 170 | --hash=sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b \ 171 | --hash=sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660 \ 172 | --hash=sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c \ 173 | --hash=sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093 \ 174 | --hash=sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594 \ 175 | --hash=sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008 \ 176 | --hash=sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a \ 177 | --hash=sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a \ 178 | --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \ 179 | --hash=sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284 \ 180 | --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \ 181 | --hash=sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869 \ 182 | --hash=sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294 \ 183 | --hash=sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f \ 184 | --hash=sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66 \ 185 | --hash=sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51 \ 186 | --hash=sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc \ 187 | --hash=sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97 \ 188 | --hash=sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a \ 189 | --hash=sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d \ 190 | --hash=sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9 \ 191 | --hash=sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c \ 192 | --hash=sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07 \ 193 | --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \ 194 | --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \ 195 | --hash=sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05 \ 196 | --hash=sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e \ 197 | --hash=sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941 \ 198 | --hash=sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612 \ 199 | --hash=sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b \ 200 | --hash=sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe \ 201 | --hash=sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146 \ 202 | --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \ 203 | --hash=sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd \ 204 | --hash=sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b \ 205 | --hash=sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c \ 206 | --hash=sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a \ 207 | --hash=sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1 \ 208 | --hash=sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf \ 209 | --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \ 210 | --hash=sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2 \ 211 | --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \ 212 | --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \ 213 | --hash=sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3 \ 214 | --hash=sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6 \ 215 | --hash=sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770 \ 216 | --hash=sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc \ 217 | --hash=sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23 \ 218 | --hash=sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26 \ 219 | --hash=sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa \ 220 | --hash=sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8 \ 221 | --hash=sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d \ 222 | --hash=sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3 \ 223 | --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \ 224 | --hash=sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034 \ 225 | --hash=sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9 \ 226 | --hash=sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1 \ 227 | --hash=sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56 \ 228 | --hash=sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b \ 229 | --hash=sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c \ 230 | --hash=sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a \ 231 | --hash=sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e \ 232 | --hash=sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9 \ 233 | --hash=sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5 \ 234 | --hash=sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a \ 235 | --hash=sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556 \ 236 | --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e \ 237 | --hash=sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49 \ 238 | --hash=sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2 \ 239 | --hash=sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9 \ 240 | --hash=sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc \ 241 | --hash=sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb \ 242 | --hash=sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0 \ 243 | --hash=sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8 \ 244 | --hash=sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69 \ 245 | --hash=sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b \ 246 | --hash=sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c \ 247 | --hash=sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75 \ 248 | --hash=sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f \ 249 | --hash=sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad \ 250 | --hash=sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b \ 251 | --hash=sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7 \ 252 | --hash=sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52 253 | # via pydantic 254 | pygments==2.19.2 \ 255 | --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ 256 | --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b 257 | # via pytest 258 | pytest==9.0.2 \ 259 | --hash=sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b \ 260 | --hash=sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11 261 | # via gradescopeapi 262 | python-dateutil==2.9.0.post0 \ 263 | --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ 264 | --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 265 | # via gradescopeapi 266 | python-dotenv==1.2.1 \ 267 | --hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \ 268 | --hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61 269 | # via gradescopeapi 270 | requests==2.32.5 \ 271 | --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ 272 | --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf 273 | # via 274 | # gradescopeapi 275 | # requests-toolbelt 276 | requests-toolbelt==1.0.0 \ 277 | --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ 278 | --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 279 | # via gradescopeapi 280 | six==1.17.0 \ 281 | --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ 282 | --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 283 | # via python-dateutil 284 | soupsieve==2.8.1 \ 285 | --hash=sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350 \ 286 | --hash=sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434 287 | # via beautifulsoup4 288 | starlette==0.50.0 \ 289 | --hash=sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca \ 290 | --hash=sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca 291 | # via fastapi 292 | tomli==2.3.0 ; python_full_version < '3.11' \ 293 | --hash=sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456 \ 294 | --hash=sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845 \ 295 | --hash=sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999 \ 296 | --hash=sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0 \ 297 | --hash=sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878 \ 298 | --hash=sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf \ 299 | --hash=sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3 \ 300 | --hash=sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be \ 301 | --hash=sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52 \ 302 | --hash=sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b \ 303 | --hash=sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67 \ 304 | --hash=sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549 \ 305 | --hash=sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba \ 306 | --hash=sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22 \ 307 | --hash=sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c \ 308 | --hash=sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f \ 309 | --hash=sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6 \ 310 | --hash=sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba \ 311 | --hash=sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45 \ 312 | --hash=sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f \ 313 | --hash=sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77 \ 314 | --hash=sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606 \ 315 | --hash=sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441 \ 316 | --hash=sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0 \ 317 | --hash=sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f \ 318 | --hash=sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530 \ 319 | --hash=sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05 \ 320 | --hash=sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8 \ 321 | --hash=sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005 \ 322 | --hash=sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879 \ 323 | --hash=sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae \ 324 | --hash=sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc \ 325 | --hash=sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b \ 326 | --hash=sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b \ 327 | --hash=sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e \ 328 | --hash=sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf \ 329 | --hash=sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac \ 330 | --hash=sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8 \ 331 | --hash=sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b \ 332 | --hash=sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf \ 333 | --hash=sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463 \ 334 | --hash=sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876 335 | # via pytest 336 | typing-extensions==4.15.0 \ 337 | --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ 338 | --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 339 | # via 340 | # anyio 341 | # beautifulsoup4 342 | # exceptiongroup 343 | # fastapi 344 | # pydantic 345 | # pydantic-core 346 | # starlette 347 | # typing-inspection 348 | typing-inspection==0.4.2 \ 349 | --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ 350 | --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 351 | # via pydantic 352 | tzdata==2025.3 \ 353 | --hash=sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1 \ 354 | --hash=sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7 355 | # via gradescopeapi 356 | urllib3==2.6.2 \ 357 | --hash=sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797 \ 358 | --hash=sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd 359 | # via requests 360 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --output-file requirements.dev.txt --only-dev --locked 3 | cfgv==3.5.0 \ 4 | --hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \ 5 | --hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132 6 | # via pre-commit 7 | coverage==7.13.0 \ 8 | --hash=sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe \ 9 | --hash=sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b \ 10 | --hash=sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070 \ 11 | --hash=sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e \ 12 | --hash=sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053 \ 13 | --hash=sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080 \ 14 | --hash=sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc \ 15 | --hash=sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb \ 16 | --hash=sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf \ 17 | --hash=sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820 \ 18 | --hash=sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b \ 19 | --hash=sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232 \ 20 | --hash=sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657 \ 21 | --hash=sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef \ 22 | --hash=sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd \ 23 | --hash=sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259 \ 24 | --hash=sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833 \ 25 | --hash=sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d \ 26 | --hash=sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f \ 27 | --hash=sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493 \ 28 | --hash=sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8 \ 29 | --hash=sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf \ 30 | --hash=sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9 \ 31 | --hash=sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19 \ 32 | --hash=sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98 \ 33 | --hash=sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f \ 34 | --hash=sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b \ 35 | --hash=sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9 \ 36 | --hash=sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b \ 37 | --hash=sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e \ 38 | --hash=sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc \ 39 | --hash=sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256 \ 40 | --hash=sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8 \ 41 | --hash=sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927 \ 42 | --hash=sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae \ 43 | --hash=sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f \ 44 | --hash=sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe \ 45 | --hash=sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f \ 46 | --hash=sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621 \ 47 | --hash=sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1 \ 48 | --hash=sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137 \ 49 | --hash=sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9 \ 50 | --hash=sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74 \ 51 | --hash=sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46 \ 52 | --hash=sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8 \ 53 | --hash=sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940 \ 54 | --hash=sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39 \ 55 | --hash=sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a \ 56 | --hash=sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d \ 57 | --hash=sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b \ 58 | --hash=sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0 \ 59 | --hash=sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a \ 60 | --hash=sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2 \ 61 | --hash=sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb \ 62 | --hash=sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303 \ 63 | --hash=sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971 \ 64 | --hash=sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030 \ 65 | --hash=sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96 \ 66 | --hash=sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb \ 67 | --hash=sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33 \ 68 | --hash=sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8 \ 69 | --hash=sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904 \ 70 | --hash=sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d \ 71 | --hash=sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28 \ 72 | --hash=sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e \ 73 | --hash=sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e \ 74 | --hash=sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9 \ 75 | --hash=sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74 \ 76 | --hash=sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8 \ 77 | --hash=sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032 \ 78 | --hash=sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57 \ 79 | --hash=sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be \ 80 | --hash=sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936 \ 81 | --hash=sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f \ 82 | --hash=sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c \ 83 | --hash=sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a \ 84 | --hash=sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791 \ 85 | --hash=sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5 \ 86 | --hash=sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e \ 87 | --hash=sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a \ 88 | --hash=sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7 \ 89 | --hash=sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a \ 90 | --hash=sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753 \ 91 | --hash=sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3 \ 92 | --hash=sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6 \ 93 | --hash=sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e \ 94 | --hash=sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071 \ 95 | --hash=sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b \ 96 | --hash=sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511 \ 97 | --hash=sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff \ 98 | --hash=sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7 \ 99 | --hash=sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6 100 | distlib==0.4.0 \ 101 | --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ 102 | --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d 103 | # via virtualenv 104 | filelock==3.20.1 \ 105 | --hash=sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a \ 106 | --hash=sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c 107 | # via virtualenv 108 | identify==2.6.15 \ 109 | --hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \ 110 | --hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf 111 | # via pre-commit 112 | librt==0.7.4 ; platform_python_implementation != 'PyPy' \ 113 | --hash=sha256:022cc673e69283a42621dd453e2407cf1647e77f8bd857d7ad7499901e62376f \ 114 | --hash=sha256:02a69369862099e37d00765583052a99d6a68af7e19b887e1b78fee0146b755a \ 115 | --hash=sha256:037f5cb6fe5abe23f1dc058054d50e9699fcc90d0677eee4e4f74a8677636a1a \ 116 | --hash=sha256:064a286e6ab0b4c900e228ab4fa9cb3811b4b83d3e0cc5cd816b2d0f548cb61c \ 117 | --hash=sha256:078ae52ffb3f036396cc4aed558e5b61faedd504a3c1f62b8ae34bf95ae39d94 \ 118 | --hash=sha256:0e8f864b521f6cfedb314d171630f827efee08f5c3462bcbc2244ab8e1768cd6 \ 119 | --hash=sha256:0f8cac84196d0ffcadf8469d9ded4d4e3a8b1c666095c2a291e22bf58e1e8a9f \ 120 | --hash=sha256:114722f35093da080a333b3834fff04ef43147577ed99dd4db574b03a5f7d170 \ 121 | --hash=sha256:1437c3f72a30c7047f16fd3e972ea58b90172c3c6ca309645c1c68984f05526a \ 122 | --hash=sha256:188b4b1a770f7f95ea035d5bbb9d7367248fc9d12321deef78a269ebf46a5729 \ 123 | --hash=sha256:1b668b1c840183e4e38ed5a99f62fac44c3a3eef16870f7f17cfdfb8b47550ed \ 124 | --hash=sha256:1c4c89fb01157dd0a3bfe9e75cd6253b0a1678922befcd664eca0772a4c6c979 \ 125 | --hash=sha256:1ef704e01cb6ad39ad7af668d51677557ca7e5d377663286f0ee1b6b27c28e5f \ 126 | --hash=sha256:21ea710e96c1e050635700695095962a22ea420d4b3755a25e4909f2172b4ff2 \ 127 | --hash=sha256:25cc40d8eb63f0a7ea4c8f49f524989b9df901969cb860a2bc0e4bad4b8cb8a8 \ 128 | --hash=sha256:2857c875f1edd1feef3c371fbf830a61b632fb4d1e57160bb1e6a3206e6abe67 \ 129 | --hash=sha256:28f990e6821204f516d09dc39966ef8b84556ffd648d5926c9a3f681e8de8906 \ 130 | --hash=sha256:2b3ca211ae8ea540569e9c513da052699b7b06928dcda61247cb4f318122bdb5 \ 131 | --hash=sha256:3485b9bb7dfa66167d5500ffdafdc35415b45f0da06c75eb7df131f3357b174a \ 132 | --hash=sha256:3749ef74c170809e6dee68addec9d2458700a8de703de081c888e92a8b015cf9 \ 133 | --hash=sha256:3871af56c59864d5fd21d1ac001eb2fb3b140d52ba0454720f2e4a19812404ba \ 134 | --hash=sha256:39003fc73f925e684f8521b2dbf34f61a5deb8a20a15dcf53e0d823190ce8848 \ 135 | --hash=sha256:3ca1caedf8331d8ad6027f93b52d68ed8f8009f5c420c246a46fe9d3be06be0f \ 136 | --hash=sha256:419eea245e7ec0fe664eb7e85e7ff97dcdb2513ca4f6b45a8ec4a3346904f95a \ 137 | --hash=sha256:42da201c47c77b6cc91fc17e0e2b330154428d35d6024f3278aa2683e7e2daf2 \ 138 | --hash=sha256:4450c354b89dbb266730893862dbff06006c9ed5b06b6016d529b2bf644fc681 \ 139 | --hash=sha256:4df7c9def4fc619a9c2ab402d73a0c5b53899abe090e0100323b13ccb5a3dd82 \ 140 | --hash=sha256:4f1ee004942eaaed6e06c087d93ebc1c67e9a293e5f6b9b5da558df6bf23dc5d \ 141 | --hash=sha256:52e34c6af84e12921748c8354aa6acf1912ca98ba60cdaa6920e34793f1a0788 \ 142 | --hash=sha256:543c42fa242faae0466fe72d297976f3c710a357a219b1efde3a0539a68a6997 \ 143 | --hash=sha256:618b7459bb392bdf373f2327e477597fff8f9e6a1878fffc1b711c013d1b0da4 \ 144 | --hash=sha256:6bb15ee29d95875ad697d449fe6071b67f730f15a6961913a2b0205015ca0843 \ 145 | --hash=sha256:70969229cb23d9c1a80e14225838d56e464dc71fa34c8342c954fc50e7516dee \ 146 | --hash=sha256:71a56f4671f7ff723451f26a6131754d7c1809e04e22ebfbac1db8c9e6767a20 \ 147 | --hash=sha256:721a7b125a817d60bf4924e1eec2a7867bfcf64cfc333045de1df7a0629e4481 \ 148 | --hash=sha256:76b2ba71265c0102d11458879b4d53ccd0b32b0164d14deb8d2b598a018e502f \ 149 | --hash=sha256:772e18696cf5a64afee908662fbcb1f907460ddc851336ee3a848ef7684c8e1e \ 150 | --hash=sha256:7766b57aeebaf3f1dac14fdd4a75c9a61f2ed56d8ebeefe4189db1cb9d2a3783 \ 151 | --hash=sha256:776dbb9bfa0fc5ce64234b446995d8d9f04badf64f544ca036bd6cff6f0732ce \ 152 | --hash=sha256:77772a4b8b5f77d47d883846928c36d730b6e612a6388c74cba33ad9eb149c11 \ 153 | --hash=sha256:7dd3b5c37e0fb6666c27cf4e2c88ae43da904f2155c4cfc1e5a2fdce3b9fcf92 \ 154 | --hash=sha256:8a461f6456981d8c8e971ff5a55f2e34f4e60871e665d2f5fde23ee74dea4eeb \ 155 | --hash=sha256:95cb80854a355b284c55f79674f6187cc9574df4dc362524e0cce98c89ee8331 \ 156 | --hash=sha256:a4f7339d9e445280f23d63dea842c0c77379c4a47471c538fc8feedab9d8d063 \ 157 | --hash=sha256:a5deebb53d7a4d7e2e758a96befcd8edaaca0633ae71857995a0f16033289e44 \ 158 | --hash=sha256:a9c5de1928c486201b23ed0cc4ac92e6e07be5cd7f3abc57c88a9cf4f0f32108 \ 159 | --hash=sha256:adefe0d48ad35b90b6f361f6ff5a1bd95af80c17d18619c093c60a20e7a5b60c \ 160 | --hash=sha256:b35c63f557653c05b5b1b6559a074dbabe0afee28ee2a05b6c9ba21ad0d16a74 \ 161 | --hash=sha256:b370a77be0a16e1ad0270822c12c21462dc40496e891d3b0caf1617c8cc57e20 \ 162 | --hash=sha256:b4c25312c7f4e6ab35ab16211bdf819e6e4eddcba3b2ea632fb51c9a2a97e105 \ 163 | --hash=sha256:b719c8730c02a606dc0e8413287e8e94ac2d32a51153b300baf1f62347858fba \ 164 | --hash=sha256:bc4aebecc79781a1b77d7d4e7d9fe080385a439e198d993b557b60f9117addaf \ 165 | --hash=sha256:c2a6f1236151e6fe1da289351b5b5bce49651c91554ecc7b70a947bced6fe212 \ 166 | --hash=sha256:c66c2b245926ec15188aead25d395091cb5c9df008d3b3207268cd65557d6286 \ 167 | --hash=sha256:c96cb76f055b33308f6858b9b594618f1b46e147a4d03a4d7f0c449e304b9b95 \ 168 | --hash=sha256:c9cab4b3de1f55e6c30a84c8cee20e4d3b2476f4d547256694a1b0163da4fe32 \ 169 | --hash=sha256:ce58420e25097b2fc201aef9b9f6d65df1eb8438e51154e1a7feb8847e4a55ab \ 170 | --hash=sha256:d05acd46b9a52087bfc50c59dfdf96a2c480a601e8898a44821c7fd676598f74 \ 171 | --hash=sha256:d31acb5886c16ae1711741f22504195af46edec8315fe69b77e477682a87a83e \ 172 | --hash=sha256:d44a1b1ba44cbd2fc3cb77992bef6d6fdb1028849824e1dd5e4d746e1f7f7f0b \ 173 | --hash=sha256:d854c6dc0f689bad7ed452d2a3ecff58029d80612d336a45b62c35e917f42d23 \ 174 | --hash=sha256:dc300cb5a5a01947b1ee8099233156fdccd5001739e5f596ecfbc0dab07b5a3b \ 175 | --hash=sha256:ec72342cc4d62f38b25a94e28b9efefce41839aecdecf5e9627473ed04b7be16 \ 176 | --hash=sha256:ee8d3323d921e0f6919918a97f9b5445a7dfe647270b2629ec1008aa676c0bc0 \ 177 | --hash=sha256:f79bc3595b6ed159a1bf0cdc70ed6ebec393a874565cab7088a219cca14da727 \ 178 | --hash=sha256:f7fa8beef580091c02b4fd26542de046b2abfe0aaefa02e8bcf68acb7618f2b3 179 | # via mypy 180 | markdown-it-py==3.0.0 \ 181 | --hash=sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1 \ 182 | --hash=sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb 183 | # via 184 | # mdformat 185 | # mdit-py-plugins 186 | mdformat==0.7.22 \ 187 | --hash=sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5 \ 188 | --hash=sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea 189 | # via 190 | # mdformat-footnote 191 | # mdformat-frontmatter 192 | # mdformat-gfm-alerts 193 | mdformat-footnote==0.1.2 \ 194 | --hash=sha256:d8e6a7fece0f902f0c7dfbd009c093182b9b523527ae48f57f57506d79ccb4ec \ 195 | --hash=sha256:fe23888fc8ddb68c080bae9787a65bb1e51253f5de2bea2633feffdb095e59cd 196 | mdformat-frontmatter==2.0.8 \ 197 | --hash=sha256:577396695af96ad66dff1ff781284ff3764a10be3ab8659f2ef842ab42264ebb \ 198 | --hash=sha256:c11190ae3f9c91ada78fbd820f5b221631b520484e0b644715aa0f6ed7f097ed 199 | mdformat-gfm-alerts==2.0.0 \ 200 | --hash=sha256:e003422cc003bc6e936d0797553f23201095a1d1e8602c5062296d223f2ae516 \ 201 | --hash=sha256:eb2b3189ad44ae28a6b6b714609dd3a30d6e3b898f02b1e6473d7b08df8bb3c0 202 | mdit-py-plugins==0.5.0 \ 203 | --hash=sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f \ 204 | --hash=sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6 205 | # via 206 | # mdformat-footnote 207 | # mdformat-frontmatter 208 | # mdformat-gfm-alerts 209 | mdurl==0.1.2 \ 210 | --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ 211 | --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba 212 | # via markdown-it-py 213 | mypy==1.19.1 \ 214 | --hash=sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd \ 215 | --hash=sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b \ 216 | --hash=sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1 \ 217 | --hash=sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba \ 218 | --hash=sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b \ 219 | --hash=sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045 \ 220 | --hash=sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac \ 221 | --hash=sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6 \ 222 | --hash=sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a \ 223 | --hash=sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957 \ 224 | --hash=sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042 \ 225 | --hash=sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec \ 226 | --hash=sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718 \ 227 | --hash=sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f \ 228 | --hash=sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331 \ 229 | --hash=sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1 \ 230 | --hash=sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1 \ 231 | --hash=sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13 \ 232 | --hash=sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2 \ 233 | --hash=sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b \ 234 | --hash=sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8 \ 235 | --hash=sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef \ 236 | --hash=sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288 \ 237 | --hash=sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75 \ 238 | --hash=sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74 \ 239 | --hash=sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250 \ 240 | --hash=sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab \ 241 | --hash=sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6 \ 242 | --hash=sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247 \ 243 | --hash=sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925 \ 244 | --hash=sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e \ 245 | --hash=sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e 246 | mypy-extensions==1.1.0 \ 247 | --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ 248 | --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 249 | # via mypy 250 | nodeenv==1.9.1 \ 251 | --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ 252 | --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 253 | # via pre-commit 254 | pathspec==0.12.1 \ 255 | --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ 256 | --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 257 | # via mypy 258 | platformdirs==4.5.1 \ 259 | --hash=sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda \ 260 | --hash=sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31 261 | # via virtualenv 262 | pre-commit==4.5.1 \ 263 | --hash=sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77 \ 264 | --hash=sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61 265 | pyyaml==6.0.3 \ 266 | --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ 267 | --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ 268 | --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ 269 | --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ 270 | --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ 271 | --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ 272 | --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ 273 | --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ 274 | --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ 275 | --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ 276 | --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ 277 | --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ 278 | --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ 279 | --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ 280 | --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ 281 | --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ 282 | --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ 283 | --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ 284 | --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ 285 | --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ 286 | --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ 287 | --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ 288 | --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ 289 | --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ 290 | --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ 291 | --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ 292 | --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ 293 | --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ 294 | --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ 295 | --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ 296 | --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ 297 | --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ 298 | --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ 299 | --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ 300 | --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ 301 | --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ 302 | --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ 303 | --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ 304 | --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ 305 | --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ 306 | --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ 307 | --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ 308 | --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ 309 | --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ 310 | --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ 311 | --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ 312 | --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ 313 | --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ 314 | --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ 315 | --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ 316 | --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ 317 | --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ 318 | --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ 319 | --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ 320 | --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ 321 | --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ 322 | --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 323 | # via pre-commit 324 | ruamel-yaml==0.18.17 \ 325 | --hash=sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c \ 326 | --hash=sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d 327 | # via mdformat-frontmatter 328 | ruamel-yaml-clib==0.2.15 ; python_full_version < '3.15' and platform_python_implementation == 'CPython' \ 329 | --hash=sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490 \ 330 | --hash=sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6 \ 331 | --hash=sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9 \ 332 | --hash=sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d \ 333 | --hash=sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3 \ 334 | --hash=sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c \ 335 | --hash=sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc \ 336 | --hash=sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf \ 337 | --hash=sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a \ 338 | --hash=sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa \ 339 | --hash=sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6 \ 340 | --hash=sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd \ 341 | --hash=sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25 \ 342 | --hash=sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600 \ 343 | --hash=sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf \ 344 | --hash=sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642 \ 345 | --hash=sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614 \ 346 | --hash=sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf \ 347 | --hash=sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000 \ 348 | --hash=sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb \ 349 | --hash=sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690 \ 350 | --hash=sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e \ 351 | --hash=sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137 \ 352 | --hash=sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401 \ 353 | --hash=sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f \ 354 | --hash=sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2 \ 355 | --hash=sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471 \ 356 | --hash=sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed \ 357 | --hash=sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524 \ 358 | --hash=sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60 \ 359 | --hash=sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef \ 360 | --hash=sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043 \ 361 | --hash=sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03 \ 362 | --hash=sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77 \ 363 | --hash=sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d \ 364 | --hash=sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467 \ 365 | --hash=sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e \ 366 | --hash=sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec \ 367 | --hash=sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4 \ 368 | --hash=sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd \ 369 | --hash=sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff \ 370 | --hash=sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c \ 371 | --hash=sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862 \ 372 | --hash=sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922 \ 373 | --hash=sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a \ 374 | --hash=sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d \ 375 | --hash=sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262 \ 376 | --hash=sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144 \ 377 | --hash=sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1 \ 378 | --hash=sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51 \ 379 | --hash=sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f 380 | # via ruamel-yaml 381 | ruff==0.14.10 \ 382 | --hash=sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6 \ 383 | --hash=sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405 \ 384 | --hash=sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a \ 385 | --hash=sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f \ 386 | --hash=sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154 \ 387 | --hash=sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e \ 388 | --hash=sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f \ 389 | --hash=sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830 \ 390 | --hash=sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f \ 391 | --hash=sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77 \ 392 | --hash=sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f \ 393 | --hash=sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49 \ 394 | --hash=sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4 \ 395 | --hash=sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d \ 396 | --hash=sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935 \ 397 | --hash=sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60 \ 398 | --hash=sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d \ 399 | --hash=sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6 \ 400 | --hash=sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d 401 | rust-just==1.45.0 \ 402 | --hash=sha256:00598b650295c97043175f27018c130a231cf15a62892231a42dfa8e7b4d70a2 \ 403 | --hash=sha256:037774833a914e6cf85771454dd623c9173dfa95f6c07033e528b4e484788f0d \ 404 | --hash=sha256:22d5e4a963cd14c4e72c5733933a9478c4fe4b58684ac5c00a3da197b6cdbf70 \ 405 | --hash=sha256:33ba0085850fa0378ab479a4421ae79cf88e0e27589f401a63a26ce0c077ae6e \ 406 | --hash=sha256:3b660701191a2bf413483b9b9d00f1372574e656ab7d0ab3a19c7b2e4321a538 \ 407 | --hash=sha256:51d41861edd4872f430a3f8626ce5946581ab5f2f617767de9ff7f450b9d6498 \ 408 | --hash=sha256:6fbe4634e3f4f7ba1d0b68d251da8e291377e1b75fecc1cf2dd8e89bfa577777 \ 409 | --hash=sha256:76e4bbfbfcd7e0d49cd3952f195188504285d1e04418e1e74cc3180d92babd2b \ 410 | --hash=sha256:84d9d0e74e3e2f182002d9ed908c4dc9dac37bfa4515991df9c96f5824070aff \ 411 | --hash=sha256:a63628432f2b7e214cfb422013ddd7bf436993d8e5406e5bf1426ea8a97c794b \ 412 | --hash=sha256:c9572941d9ee8a93e78973858561e5e01ce5f8e3eb466dbfe7dad226e73862ea \ 413 | --hash=sha256:e17ed4a9d2e1d48ee024047371b71323c72194e4189cd7911184a3d4007cbe89 \ 414 | --hash=sha256:f43123b9ecc122222ac3cae69f2e698cd44afb1b3fdb03e342b56f916295cbd8 \ 415 | --hash=sha256:f55a5ed6507189fb4c0c33821205f96739fab6c8c22c0264345749175bb6c59f \ 416 | --hash=sha256:f5e7737429353aa43685671236994fb13eeac990056f487663d2fdfb77dd369d 417 | tomli==2.3.0 ; python_full_version < '3.11' \ 418 | --hash=sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456 \ 419 | --hash=sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845 \ 420 | --hash=sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999 \ 421 | --hash=sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0 \ 422 | --hash=sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878 \ 423 | --hash=sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf \ 424 | --hash=sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3 \ 425 | --hash=sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be \ 426 | --hash=sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52 \ 427 | --hash=sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b \ 428 | --hash=sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67 \ 429 | --hash=sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549 \ 430 | --hash=sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba \ 431 | --hash=sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22 \ 432 | --hash=sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c \ 433 | --hash=sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f \ 434 | --hash=sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6 \ 435 | --hash=sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba \ 436 | --hash=sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45 \ 437 | --hash=sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f \ 438 | --hash=sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77 \ 439 | --hash=sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606 \ 440 | --hash=sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441 \ 441 | --hash=sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0 \ 442 | --hash=sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f \ 443 | --hash=sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530 \ 444 | --hash=sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05 \ 445 | --hash=sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8 \ 446 | --hash=sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005 \ 447 | --hash=sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879 \ 448 | --hash=sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae \ 449 | --hash=sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc \ 450 | --hash=sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b \ 451 | --hash=sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b \ 452 | --hash=sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e \ 453 | --hash=sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf \ 454 | --hash=sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac \ 455 | --hash=sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8 \ 456 | --hash=sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b \ 457 | --hash=sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf \ 458 | --hash=sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463 \ 459 | --hash=sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876 460 | # via 461 | # mdformat 462 | # mypy 463 | typing-extensions==4.15.0 \ 464 | --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ 465 | --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 466 | # via 467 | # mypy 468 | # virtualenv 469 | virtualenv==20.35.4 \ 470 | --hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \ 471 | --hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b 472 | # via pre-commit 473 | --------------------------------------------------------------------------------