├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ └── main.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── docs ├── CONTRIBUTING.md ├── INSTALL.md └── TESTING.md ├── justfile ├── pyproject.toml ├── requirements.dev.txt ├── requirements.txt ├── src └── gradescopeapi │ ├── __init__.py │ ├── _config │ ├── __init__.py │ └── config.py │ ├── api │ ├── __init__.py │ ├── api.py │ └── constants.py │ ├── classes │ ├── __init__.py │ ├── _helpers │ │ ├── _assignment_helpers.py │ │ ├── _course_helpers.py │ │ └── _login_helpers.py │ ├── account.py │ ├── assignments.py │ ├── connection.py │ ├── courses.py │ ├── extensions.py │ ├── member.py │ └── upload.py │ └── py.typed ├── tests ├── __init__.py ├── conftest.py ├── integration │ └── test_integration.py ├── test_connection.py ├── test_courses.py ├── test_edit_assignment.py ├── test_extension.py ├── test_graders.py ├── test_submission.py ├── test_upload.py └── upload_files │ ├── markdown_file.md │ ├── python_file.py │ └── text_file.txt └── uv.lock /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Install uv and set the python version 28 | uses: astral-sh/setup-uv@v5 29 | with: 30 | # Install a specific version of uv. 31 | version: "0.5.24" 32 | enable-cache: true 33 | cache-dependency-glob: "uv.lock" 34 | python-version: ${{ matrix.python-version }} 35 | 36 | - name: Install the project 37 | run: uv sync --all-extras --dev 38 | 39 | - name: Lint code check 40 | run: uv run -- ruff check src tests 41 | 42 | - name: Format code check 43 | run: uv run -- ruff format --check src tests 44 | 45 | - name: Format docs check 46 | run: uv run -- mdformat --check docs README.md 47 | 48 | - name: Run tests 49 | # For example, using `pytest` 50 | run: uv run pytest tests 51 | env: 52 | GRADESCOPE_CI_STUDENT_EMAIL: ${{ secrets.GRADESCOPE_CI_STUDENT_EMAIL }} 53 | GRADESCOPE_CI_STUDENT_PASSWORD: ${{ secrets.GRADESCOPE_CI_STUDENT_PASSWORD }} 54 | GRADESCOPE_CI_INSTRUCTOR_EMAIL: ${{ secrets.GRADESCOPE_CI_INSTRUCTOR_EMAIL }} 55 | GRADESCOPE_CI_INSTRUCTOR_PASSWORD: ${{ secrets.GRADESCOPE_CI_INSTRUCTOR_PASSWORD }} 56 | GRADESCOPE_CI_TA_EMAIL: ${{ secrets.GRADESCOPE_CI_TA_EMAIL }} 57 | GRADESCOPE_CI_TA_PASSWORD: ${{ secrets.GRADESCOPE_CI_TA_PASSWORD }} 58 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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.5.14 23 | hooks: 24 | - id: uv-export 25 | args: ["--no-dev", "--frozen", "--quiet", "--output-file=requirements.txt"] 26 | - id: uv-export 27 | args: ["--only-dev", "--frozen", "--quiet", "--output-file=requirements.dev.txt"] 28 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ] 35 | dependencies = [ 36 | "beautifulsoup4>=4.12.3", 37 | "fastapi>=0.111.0", 38 | "pytest>=8.2.0", 39 | "python-dateutil>=2.9.0.post0", 40 | "python-dotenv>=1.0.1", 41 | "requests-toolbelt>=1.0.0", 42 | "requests>=2.31.0", 43 | "tzdata>=2024.2", 44 | ] 45 | description = "Library for programmatically interacting with Gradescope." 46 | maintainers = [ 47 | { name = "Calvin Tian" }, 48 | ] 49 | name = "gradescopeapi" 50 | readme = "README.md" 51 | requires-python = ">=3.10" 52 | version = "1.5.0" 53 | 54 | [project.license] 55 | text = "MIT" 56 | 57 | [project.urls] 58 | Homepage = "https://github.com/nyuoss/gradescope-api" 59 | Issues = "https://github.com/nyuoss/gradescope-api/issues" 60 | Repository = "https://github.com/nyuoss/gradescope-api" 61 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --only-dev --frozen --output-file=requirements.dev.txt 3 | cfgv==3.4.0 \ 4 | --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \ 5 | --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560 6 | coverage==7.6.10 \ 7 | --hash=sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9 \ 8 | --hash=sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273 \ 9 | --hash=sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994 \ 10 | --hash=sha256:0f460286cb94036455e703c66988851d970fdfd8acc2a1122ab7f4f904e4029e \ 11 | --hash=sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50 \ 12 | --hash=sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e \ 13 | --hash=sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e \ 14 | --hash=sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c \ 15 | --hash=sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853 \ 16 | --hash=sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8 \ 17 | --hash=sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8 \ 18 | --hash=sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe \ 19 | --hash=sha256:32ee6d8491fcfc82652a37109f69dee9a830e9379166cb73c16d8dc5c2915165 \ 20 | --hash=sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb \ 21 | --hash=sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609 \ 22 | --hash=sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098 \ 23 | --hash=sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd \ 24 | --hash=sha256:4eea95ef275de7abaef630c9b2c002ffbc01918b726a39f5a4353916ec72d2f3 \ 25 | --hash=sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43 \ 26 | --hash=sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d \ 27 | --hash=sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359 \ 28 | --hash=sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78 \ 29 | --hash=sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99 \ 30 | --hash=sha256:675cefc4c06e3b4c876b85bfb7c59c5e2218167bbd4da5075cbe3b5790a28988 \ 31 | --hash=sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2 \ 32 | --hash=sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0 \ 33 | --hash=sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694 \ 34 | --hash=sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377 \ 35 | --hash=sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23 \ 36 | --hash=sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312 \ 37 | --hash=sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf \ 38 | --hash=sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6 \ 39 | --hash=sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b \ 40 | --hash=sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c \ 41 | --hash=sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690 \ 42 | --hash=sha256:a3b204c11e2b2d883946fe1d97f89403aa1811df28ce0447439178cc7463448a \ 43 | --hash=sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f \ 44 | --hash=sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd \ 45 | --hash=sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852 \ 46 | --hash=sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0 \ 47 | --hash=sha256:bc67deb76bc3717f22e765ab3e07ee9c7a5e26b9019ca19a3b063d9f4b874244 \ 48 | --hash=sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078 \ 49 | --hash=sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0 \ 50 | --hash=sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132 \ 51 | --hash=sha256:e2f0280519e42b0a17550072861e0bc8a80a0870de260f9796157d3fca2733c5 \ 52 | --hash=sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247 \ 53 | --hash=sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022 \ 54 | --hash=sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b \ 55 | --hash=sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3 \ 56 | --hash=sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18 \ 57 | --hash=sha256:f4f620668dbc6f5e909a0946a877310fb3d57aea8198bde792aae369ee1c23b5 \ 58 | --hash=sha256:fd34e7b3405f0cc7ab03d54a334c17a9e802897580d964bd8c2001f4b9fd488f 59 | distlib==0.3.9 \ 60 | --hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \ 61 | --hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403 62 | filelock==3.17.0 \ 63 | --hash=sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338 \ 64 | --hash=sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e 65 | identify==2.6.6 \ 66 | --hash=sha256:7bec12768ed44ea4761efb47806f0a41f86e7c0a5fdf5950d4648c90eca7e251 \ 67 | --hash=sha256:cbd1810bce79f8b671ecb20f53ee0ae8e86ae84b557de31d89709dc2a48ba881 68 | markdown-it-py==3.0.0 \ 69 | --hash=sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1 \ 70 | --hash=sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb 71 | mdformat==0.7.21 \ 72 | --hash=sha256:74b9e2bdb5ec1e766babece2b5148958ab268e54b6f4f360a3c4e75e68637149 \ 73 | --hash=sha256:ed81bfab711751d8ce4bf6a7854aeb02a3fdd165be751d4f672e0d949ae54dd9 74 | mdformat-footnote==0.1.1 \ 75 | --hash=sha256:30063aaa0f74c36257c2e80fa0cf00d7c71a5277f27e98109e8765ae8678a95b \ 76 | --hash=sha256:3b85c4c84119f15f0b651df89c99a4f6f119fc46dca6b33f7edf4f09655d1126 77 | mdformat-frontmatter==2.0.8 \ 78 | --hash=sha256:577396695af96ad66dff1ff781284ff3764a10be3ab8659f2ef842ab42264ebb \ 79 | --hash=sha256:c11190ae3f9c91ada78fbd820f5b221631b520484e0b644715aa0f6ed7f097ed 80 | mdformat-gfm-alerts==1.0.1 \ 81 | --hash=sha256:3bc382daf004088bc7b848db22fdf43df4383454a161db5cbbbd0472877b1a53 \ 82 | --hash=sha256:c11f837ac16ac7414331887a2df4b66175b68155077d56887e713661782141de 83 | mdit-py-plugins==0.4.2 \ 84 | --hash=sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636 \ 85 | --hash=sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5 86 | mdurl==0.1.2 \ 87 | --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ 88 | --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba 89 | mypy==1.14.1 \ 90 | --hash=sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd \ 91 | --hash=sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f \ 92 | --hash=sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0 \ 93 | --hash=sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b \ 94 | --hash=sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14 \ 95 | --hash=sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb \ 96 | --hash=sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e \ 97 | --hash=sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f \ 98 | --hash=sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6 \ 99 | --hash=sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107 \ 100 | --hash=sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11 \ 101 | --hash=sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a \ 102 | --hash=sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b \ 103 | --hash=sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d \ 104 | --hash=sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255 \ 105 | --hash=sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae \ 106 | --hash=sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1 \ 107 | --hash=sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8 \ 108 | --hash=sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9 \ 109 | --hash=sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9 \ 110 | --hash=sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1 \ 111 | --hash=sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34 \ 112 | --hash=sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427 \ 113 | --hash=sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1 \ 114 | --hash=sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c \ 115 | --hash=sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89 116 | mypy-extensions==1.0.0 \ 117 | --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ 118 | --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 119 | nodeenv==1.9.1 \ 120 | --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ 121 | --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 122 | platformdirs==4.3.6 \ 123 | --hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \ 124 | --hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb 125 | pre-commit==4.1.0 \ 126 | --hash=sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4 \ 127 | --hash=sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b 128 | pyyaml==6.0.2 \ 129 | --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ 130 | --hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \ 131 | --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ 132 | --hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \ 133 | --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ 134 | --hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \ 135 | --hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \ 136 | --hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \ 137 | --hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \ 138 | --hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \ 139 | --hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \ 140 | --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ 141 | --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ 142 | --hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \ 143 | --hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \ 144 | --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ 145 | --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ 146 | --hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \ 147 | --hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \ 148 | --hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \ 149 | --hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \ 150 | --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ 151 | --hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \ 152 | --hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \ 153 | --hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \ 154 | --hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \ 155 | --hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \ 156 | --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ 157 | --hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \ 158 | --hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \ 159 | --hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \ 160 | --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ 161 | --hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \ 162 | --hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \ 163 | --hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \ 164 | --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ 165 | --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 166 | ruamel-yaml==0.18.10 \ 167 | --hash=sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58 \ 168 | --hash=sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1 169 | ruamel-yaml-clib==0.2.12 ; python_full_version < '3.13' and platform_python_implementation == 'CPython' \ 170 | --hash=sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4 \ 171 | --hash=sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef \ 172 | --hash=sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5 \ 173 | --hash=sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3 \ 174 | --hash=sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632 \ 175 | --hash=sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6 \ 176 | --hash=sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680 \ 177 | --hash=sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf \ 178 | --hash=sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da \ 179 | --hash=sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6 \ 180 | --hash=sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a \ 181 | --hash=sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01 \ 182 | --hash=sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6 \ 183 | --hash=sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f \ 184 | --hash=sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd \ 185 | --hash=sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2 \ 186 | --hash=sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52 \ 187 | --hash=sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd \ 188 | --hash=sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d \ 189 | --hash=sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c \ 190 | --hash=sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6 \ 191 | --hash=sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb \ 192 | --hash=sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a \ 193 | --hash=sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969 \ 194 | --hash=sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28 \ 195 | --hash=sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d \ 196 | --hash=sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e \ 197 | --hash=sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4 \ 198 | --hash=sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31 \ 199 | --hash=sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642 \ 200 | --hash=sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e \ 201 | --hash=sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1 \ 202 | --hash=sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3 \ 203 | --hash=sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475 \ 204 | --hash=sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5 \ 205 | --hash=sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76 \ 206 | --hash=sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df 207 | ruff==0.9.3 \ 208 | --hash=sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2 \ 209 | --hash=sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4 \ 210 | --hash=sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439 \ 211 | --hash=sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730 \ 212 | --hash=sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4 \ 213 | --hash=sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5 \ 214 | --hash=sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624 \ 215 | --hash=sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b \ 216 | --hash=sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a \ 217 | --hash=sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b \ 218 | --hash=sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5 \ 219 | --hash=sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4 \ 220 | --hash=sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c \ 221 | --hash=sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519 \ 222 | --hash=sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1 \ 223 | --hash=sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4 \ 224 | --hash=sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6 \ 225 | --hash=sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c 226 | rust-just==1.39.0 \ 227 | --hash=sha256:0dfcc49a5fa126ba923b58e48921fd117e429660495577a854494c6ced3134c9 \ 228 | --hash=sha256:135a7a65a8641b00a2fe7f3156a97ab7052e4830a922a71e67ca4e38ccd54cd2 \ 229 | --hash=sha256:247d0b293924cc8089a73428c9c03a3c2c0627bb8f205addb976ded0681f0dac \ 230 | --hash=sha256:3139f3f76434a8ebbf35b213d149e647c4d9546312b438e262df7ec41e7ef7bc \ 231 | --hash=sha256:3845ab10254c994ddebcf489b30c53a24c1d11585c9e0eeaf1cb0da422bee87f \ 232 | --hash=sha256:428d07b1e798777c4e9a8c245539d72743be095558010f0a86823e1c442930f9 \ 233 | --hash=sha256:4e7ecd8fd862729c243498951caa54d778ff480c2524039280ff3ebb9a64299f \ 234 | --hash=sha256:576229024d2ef8fc696d5a049ecd0d8f3d9b920a32e76f65e95840d24d804101 \ 235 | --hash=sha256:826203ad02c869ad8621993a608adb01394ef9c9c9ca6aa7dd7875b1f272aa46 \ 236 | --hash=sha256:94eb45e585fda019f7f9cbac198e10e31f81c704371887cbdec9b7a1ae2e0d29 \ 237 | --hash=sha256:c1cd9240e2c1b352d7ccc6b89ce84fcc0352f15bb9660cdc6bc34802b36251b6 \ 238 | --hash=sha256:d6eff0461df7e36eba6e7f0addf16ef98563cf8cb483e4c8393be5456d6af5c6 \ 239 | --hash=sha256:dcef0926b287449e853b878f6f34759a797d017cefb83afbcd74820d37259b78 \ 240 | --hash=sha256:de4a8566ca1eb87b5ff2669a6dd9474b16977c5d712534a5a9c7a950271da2d0 \ 241 | --hash=sha256:fd5c12118a8d65266ccdacfbc24bab26f77d509caaf263095cb96611ea6ce7e8 242 | tomli==2.2.1 ; python_full_version < '3.11' \ 243 | --hash=sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6 \ 244 | --hash=sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd \ 245 | --hash=sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c \ 246 | --hash=sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b \ 247 | --hash=sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8 \ 248 | --hash=sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6 \ 249 | --hash=sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77 \ 250 | --hash=sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff \ 251 | --hash=sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea \ 252 | --hash=sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192 \ 253 | --hash=sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249 \ 254 | --hash=sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee \ 255 | --hash=sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4 \ 256 | --hash=sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98 \ 257 | --hash=sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8 \ 258 | --hash=sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4 \ 259 | --hash=sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281 \ 260 | --hash=sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744 \ 261 | --hash=sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69 \ 262 | --hash=sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13 \ 263 | --hash=sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140 \ 264 | --hash=sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e \ 265 | --hash=sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e \ 266 | --hash=sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc \ 267 | --hash=sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff \ 268 | --hash=sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec \ 269 | --hash=sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2 \ 270 | --hash=sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222 \ 271 | --hash=sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106 \ 272 | --hash=sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272 \ 273 | --hash=sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a \ 274 | --hash=sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7 275 | typing-extensions==4.12.2 \ 276 | --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ 277 | --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 278 | virtualenv==20.29.1 \ 279 | --hash=sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779 \ 280 | --hash=sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35 281 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --no-dev --frozen --output-file=requirements.txt 3 | -e . 4 | annotated-types==0.7.0 \ 5 | --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ 6 | --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 7 | anyio==4.8.0 \ 8 | --hash=sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a \ 9 | --hash=sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a 10 | beautifulsoup4==4.12.3 \ 11 | --hash=sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051 \ 12 | --hash=sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed 13 | certifi==2024.12.14 \ 14 | --hash=sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56 \ 15 | --hash=sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db 16 | charset-normalizer==3.4.1 \ 17 | --hash=sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd \ 18 | --hash=sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601 \ 19 | --hash=sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd \ 20 | --hash=sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d \ 21 | --hash=sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313 \ 22 | --hash=sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd \ 23 | --hash=sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa \ 24 | --hash=sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8 \ 25 | --hash=sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1 \ 26 | --hash=sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2 \ 27 | --hash=sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d \ 28 | --hash=sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b \ 29 | --hash=sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408 \ 30 | --hash=sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3 \ 31 | --hash=sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f \ 32 | --hash=sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a \ 33 | --hash=sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146 \ 34 | --hash=sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6 \ 35 | --hash=sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9 \ 36 | --hash=sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f \ 37 | --hash=sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545 \ 38 | --hash=sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176 \ 39 | --hash=sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b \ 40 | --hash=sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f \ 41 | --hash=sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b \ 42 | --hash=sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9 \ 43 | --hash=sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125 \ 44 | --hash=sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de \ 45 | --hash=sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11 \ 46 | --hash=sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35 \ 47 | --hash=sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f \ 48 | --hash=sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda \ 49 | --hash=sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a \ 50 | --hash=sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971 \ 51 | --hash=sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d \ 52 | --hash=sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f \ 53 | --hash=sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757 \ 54 | --hash=sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a \ 55 | --hash=sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886 \ 56 | --hash=sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77 \ 57 | --hash=sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76 \ 58 | --hash=sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247 \ 59 | --hash=sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85 \ 60 | --hash=sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb \ 61 | --hash=sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7 \ 62 | --hash=sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037 \ 63 | --hash=sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1 \ 64 | --hash=sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807 \ 65 | --hash=sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407 \ 66 | --hash=sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12 \ 67 | --hash=sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3 \ 68 | --hash=sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd \ 69 | --hash=sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00 \ 70 | --hash=sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616 71 | colorama==0.4.6 ; sys_platform == 'win32' \ 72 | --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ 73 | --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 74 | exceptiongroup==1.2.2 ; python_full_version < '3.11' \ 75 | --hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b \ 76 | --hash=sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc 77 | fastapi==0.115.7 \ 78 | --hash=sha256:0f106da6c01d88a6786b3248fb4d7a940d071f6f488488898ad5d354b25ed015 \ 79 | --hash=sha256:eb6a8c8bf7f26009e8147111ff15b5177a0e19bb4a45bc3486ab14804539d21e 80 | idna==3.10 \ 81 | --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ 82 | --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 83 | iniconfig==2.0.0 \ 84 | --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ 85 | --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 86 | packaging==24.2 \ 87 | --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \ 88 | --hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f 89 | pluggy==1.5.0 \ 90 | --hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \ 91 | --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 92 | pydantic==2.10.6 \ 93 | --hash=sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584 \ 94 | --hash=sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236 95 | pydantic-core==2.27.2 \ 96 | --hash=sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50 \ 97 | --hash=sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9 \ 98 | --hash=sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6 \ 99 | --hash=sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc \ 100 | --hash=sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9 \ 101 | --hash=sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236 \ 102 | --hash=sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7 \ 103 | --hash=sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee \ 104 | --hash=sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b \ 105 | --hash=sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048 \ 106 | --hash=sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc \ 107 | --hash=sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130 \ 108 | --hash=sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4 \ 109 | --hash=sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4 \ 110 | --hash=sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7 \ 111 | --hash=sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7 \ 112 | --hash=sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4 \ 113 | --hash=sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e \ 114 | --hash=sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa \ 115 | --hash=sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6 \ 116 | --hash=sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962 \ 117 | --hash=sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b \ 118 | --hash=sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f \ 119 | --hash=sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474 \ 120 | --hash=sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5 \ 121 | --hash=sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459 \ 122 | --hash=sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a \ 123 | --hash=sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c \ 124 | --hash=sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4 \ 125 | --hash=sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934 \ 126 | --hash=sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306 \ 127 | --hash=sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3 \ 128 | --hash=sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2 \ 129 | --hash=sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af \ 130 | --hash=sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9 \ 131 | --hash=sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a \ 132 | --hash=sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27 \ 133 | --hash=sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b \ 134 | --hash=sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151 \ 135 | --hash=sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154 \ 136 | --hash=sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133 \ 137 | --hash=sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef \ 138 | --hash=sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15 \ 139 | --hash=sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4 \ 140 | --hash=sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc \ 141 | --hash=sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee \ 142 | --hash=sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c \ 143 | --hash=sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0 \ 144 | --hash=sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57 \ 145 | --hash=sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b \ 146 | --hash=sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8 \ 147 | --hash=sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1 \ 148 | --hash=sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e \ 149 | --hash=sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc \ 150 | --hash=sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c \ 151 | --hash=sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9 \ 152 | --hash=sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1 \ 153 | --hash=sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d \ 154 | --hash=sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99 \ 155 | --hash=sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3 \ 156 | --hash=sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31 \ 157 | --hash=sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c \ 158 | --hash=sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39 \ 159 | --hash=sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a \ 160 | --hash=sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9 161 | pytest==8.3.4 \ 162 | --hash=sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6 \ 163 | --hash=sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761 164 | python-dateutil==2.9.0.post0 \ 165 | --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ 166 | --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 167 | python-dotenv==1.0.1 \ 168 | --hash=sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca \ 169 | --hash=sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a 170 | requests==2.32.3 \ 171 | --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ 172 | --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 173 | requests-toolbelt==1.0.0 \ 174 | --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ 175 | --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 176 | six==1.17.0 \ 177 | --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ 178 | --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 179 | sniffio==1.3.1 \ 180 | --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ 181 | --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc 182 | soupsieve==2.6 \ 183 | --hash=sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb \ 184 | --hash=sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9 185 | starlette==0.45.3 \ 186 | --hash=sha256:2cbcba2a75806f8a41c722141486f37c28e30a0921c5f6fe4346cb0dcee1302f \ 187 | --hash=sha256:dfb6d332576f136ec740296c7e8bb8c8a7125044e7c6da30744718880cdd059d 188 | tomli==2.2.1 ; python_full_version < '3.11' \ 189 | --hash=sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6 \ 190 | --hash=sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd \ 191 | --hash=sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c \ 192 | --hash=sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b \ 193 | --hash=sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8 \ 194 | --hash=sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6 \ 195 | --hash=sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77 \ 196 | --hash=sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff \ 197 | --hash=sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea \ 198 | --hash=sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192 \ 199 | --hash=sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249 \ 200 | --hash=sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee \ 201 | --hash=sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4 \ 202 | --hash=sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98 \ 203 | --hash=sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8 \ 204 | --hash=sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4 \ 205 | --hash=sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281 \ 206 | --hash=sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744 \ 207 | --hash=sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69 \ 208 | --hash=sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13 \ 209 | --hash=sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140 \ 210 | --hash=sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e \ 211 | --hash=sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e \ 212 | --hash=sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc \ 213 | --hash=sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff \ 214 | --hash=sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec \ 215 | --hash=sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2 \ 216 | --hash=sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222 \ 217 | --hash=sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106 \ 218 | --hash=sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272 \ 219 | --hash=sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a \ 220 | --hash=sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7 221 | typing-extensions==4.12.2 \ 222 | --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ 223 | --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 224 | tzdata==2025.1 \ 225 | --hash=sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694 \ 226 | --hash=sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639 227 | urllib3==2.3.0 \ 228 | --hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \ 229 | --hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d 230 | -------------------------------------------------------------------------------- /src/gradescopeapi/__init__.py: -------------------------------------------------------------------------------- 1 | DEFAULT_GRADESCOPE_BASE_URL = "https://www.gradescope.com" 2 | -------------------------------------------------------------------------------- /src/gradescopeapi/_config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyuoss/gradescope-api/a69a8ba44c350422d2927cd913b88d21f859b9c8/src/gradescopeapi/_config/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/gradescopeapi/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyuoss/gradescope-api/a69a8ba44c350422d2927cd913b88d21f859b9c8/src/gradescopeapi/api/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyuoss/gradescope-api/a69a8ba44c350422d2927cd913b88d21f859b9c8/src/gradescopeapi/classes/__init__.py -------------------------------------------------------------------------------- /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 | def check_page_auth(session, endpoint): 11 | """ 12 | raises Exception if user not logged in or doesn't have appropriate authorities 13 | Returns response if otherwise good 14 | """ 15 | submissions_resp = session.get(endpoint) 16 | # check if page is valid, raise exception if not 17 | if submissions_resp.status_code == requests.codes.unauthorized: 18 | # check error type 19 | # TODO: how should we handle errors so that our API can read them? 20 | error_msg = [*json.loads(submissions_resp.text).values()][0] 21 | if error_msg == "You are not authorized to access this page.": 22 | raise Exception("You are not authorized to access this page.") 23 | elif error_msg == "You must be logged in to access this page.": 24 | raise Exception("You must be logged in to access this page.") 25 | elif submissions_resp.status_code == requests.codes.not_found: 26 | raise Exception("Page not Found") 27 | elif submissions_resp.status_code == requests.codes.ok: 28 | return submissions_resp 29 | 30 | 31 | def get_assignments_instructor_view(coursepage_soup): 32 | assignments_list = [] 33 | element_with_props = coursepage_soup.find( 34 | "div", {"data-react-class": "AssignmentsTable"} 35 | ) 36 | if element_with_props: 37 | # Extract the value of the data-react-props attribute 38 | props_str = element_with_props["data-react-props"] 39 | # Parse the JSON data 40 | assignment_json = json.loads(props_str) 41 | 42 | # Extract information for each assignment 43 | for assignment in assignment_json["table_data"]: 44 | # Skip non-assignment data like sections 45 | if assignment.get("type", "") != "assignment": 46 | continue 47 | 48 | assignment_obj = Assignment( 49 | assignment_id=assignment["url"].split("/")[-1], 50 | name=assignment["title"], 51 | release_date=assignment["submission_window"]["release_date"], 52 | due_date=assignment["submission_window"]["due_date"], 53 | late_due_date=assignment["submission_window"].get("hard_due_date"), 54 | submissions_status=None, 55 | grade=None, 56 | max_grade=str(float(assignment["total_points"])), 57 | ) 58 | 59 | # convert to datetime objects 60 | assignment_obj.release_date = ( 61 | dateutil.parser.parse(assignment_obj.release_date) 62 | if assignment_obj.release_date 63 | else assignment_obj.release_date 64 | ) 65 | 66 | assignment_obj.due_date = ( 67 | dateutil.parser.parse(assignment_obj.due_date) 68 | if assignment_obj.due_date 69 | else assignment_obj.due_date 70 | ) 71 | 72 | assignment_obj.late_due_date = ( 73 | dateutil.parser.parse(assignment_obj.late_due_date) 74 | if assignment_obj.late_due_date 75 | else assignment_obj.late_due_date 76 | ) 77 | 78 | # Add the assignment dictionary to the list 79 | assignments_list.append(assignment_obj) 80 | return assignments_list 81 | 82 | 83 | def get_assignments_student_view(coursepage_soup): 84 | # parse into list of lists: Assignments[row_elements[]] 85 | assignment_table = [] 86 | for assignment_row in coursepage_soup.findAll("tr", role="row")[ 87 | 1:-1 88 | ]: # Skip header row and tail row (dropzonePreview--fileNameHeader) 89 | row = [] 90 | for th in assignment_row.findAll("th"): 91 | row.append(th) 92 | for td in assignment_row.findAll("td"): 93 | row.append(td) 94 | assignment_table.append(row) 95 | assignment_info_list = [] 96 | 97 | # Iterate over the list of Tag objects 98 | for assignment in assignment_table: 99 | # Extract assignment ID and name 100 | name = assignment[0].text 101 | # 3 cases: 1. submitted -> href element, 2. not submitted, submittable -> button element, 3. not submitted, cant submit -> only text 102 | assignment_a_href = assignment[0].find("a", href=True) 103 | assignment_button = assignment[0].find("button", class_="js-submitAssignment") 104 | if assignment_a_href: 105 | assignment_id = assignment_a_href["href"].split("/")[4] 106 | elif assignment_button: 107 | assignment_id = assignment_button["data-assignment-id"] 108 | else: 109 | assignment_id = None 110 | 111 | # Extract submission status, grade, max_grade 112 | try: # Points not guaranteed 113 | points = assignment[1].text.split(" / ") 114 | grade = float(points[0]) 115 | max_grade = float(points[1]) 116 | submission_status = "Submitted" 117 | except (IndexError, ValueError): 118 | grade = None 119 | max_grade = None 120 | submission_status = assignment[1].text 121 | 122 | # Extract release date, due date, and late due date 123 | release_date = due_date = late_due_date = None 124 | try: # release date, due date, and late due date not guaranteed to be available 125 | release_obj = assignment[2].find(class_="submissionTimeChart--releaseDate") 126 | release_date = release_obj["datetime"] if release_obj else None 127 | # both due data and late due date have the same class 128 | due_dates_obj = assignment[2].find_all( 129 | class_="submissionTimeChart--dueDate" 130 | ) 131 | if due_dates_obj: 132 | due_date = due_dates_obj[0]["datetime"] if due_dates_obj else None 133 | if len(due_dates_obj) > 1: 134 | late_due_date = ( 135 | due_dates_obj[1]["datetime"] if due_dates_obj else None 136 | ) 137 | except IndexError: 138 | pass 139 | 140 | # convert to datetime objects 141 | release_date = ( 142 | dateutil.parser.parse(release_date) if release_date else release_date 143 | ) 144 | due_date = dateutil.parser.parse(due_date) if due_date else due_date 145 | late_due_date = ( 146 | dateutil.parser.parse(late_due_date) if late_due_date else late_due_date 147 | ) 148 | 149 | # Store the extracted information in a dictionary 150 | assignment_obj = Assignment( 151 | assignment_id=assignment_id, 152 | name=name, 153 | release_date=release_date, 154 | due_date=due_date, 155 | late_due_date=late_due_date, 156 | submissions_status=submission_status, 157 | grade=grade, 158 | max_grade=max_grade, 159 | ) 160 | 161 | # Append the dictionary to the list 162 | assignment_info_list.append(assignment_obj) 163 | 164 | return assignment_info_list 165 | 166 | 167 | def get_submission_files( 168 | session, 169 | course_id, 170 | assignment_id, 171 | submission_id, 172 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 173 | ): 174 | ASSIGNMENT_ENDPOINT = ( 175 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 176 | ) 177 | 178 | file_info_link = f"{ASSIGNMENT_ENDPOINT}/submissions/{submission_id}.json?content=react&only_keys[]=text_files&only_keys[]=file_comments" 179 | file_info_resp = session.get(file_info_link) 180 | if file_info_resp.status_code == requests.codes.ok: 181 | file_info_json = json.loads(file_info_resp.text) 182 | if file_info_json.get("text_files"): 183 | aws_links = [] 184 | for file_data in file_info_json["text_files"]: 185 | aws_links.append(file_data["file"]["url"]) 186 | else: 187 | raise NotImplementedError("Image only submissions not yet supported") 188 | # TODO add support for image questions 189 | return aws_links 190 | -------------------------------------------------------------------------------- /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( 11 | soup: BeautifulSoup, user_type: str 12 | ) -> tuple[dict[str, Course], bool]: 13 | """ 14 | Scrape all course info from the main page of Gradescope. 15 | 16 | Args: 17 | soup (BeautifulSoup): BeautifulSoup object with parsed HTML. 18 | user_type (str): The user type to scrape courses for (Instructor or Student courses). 19 | 20 | Returns: 21 | tuple: 22 | dict: A dictionary mapping course IDs to Course objects containing all course info. 23 | bool: Flag indicating if the user is an instructor or not. 24 | 25 | For example: 26 | { 27 | "123456": Course( 28 | name="CS 1134", 29 | full_name="Data Structures and Algorithms", 30 | semester="Fall", 31 | year="2021", 32 | num_grades_published="0", 33 | num_assignments="5" 34 | ) 35 | } 36 | """ 37 | 38 | # initalize dictionary to store all courses 39 | all_courses = {} 40 | 41 | # find heading for defined user_type's courses 42 | courses = soup.find("h1", class_="pageHeading", string=user_type) 43 | 44 | # if no courses found, return empty dictionary 45 | if courses is None: 46 | return all_courses, False 47 | 48 | # use button to check if user is an instructor or not 49 | button = courses.find_next("button") 50 | if button.text == " Create a new course": # intentional space before Create 51 | is_instructor = True 52 | else: 53 | is_instructor = False 54 | 55 | # find next div with class courseList 56 | course_list = courses.find_next("div", class_="courseList") 57 | 58 | for term in course_list.find_all("div", class_="courseList--term"): 59 | # find first "a" -> course 60 | course = term.find_next("a") 61 | while course is not None: 62 | # fetch course id and create new dictionary for each course 63 | course_id = course["href"].split("/")[-1] 64 | 65 | # fetch short name 66 | course_name = course.find("h3", class_="courseBox--shortname") 67 | short_name = course_name.text 68 | 69 | # fetch full name 70 | course_full_name = course.find("div", class_="courseBox--name") 71 | full_name = course_full_name.text 72 | 73 | # fetch semester and year 74 | time_of_year = term.text.split(" ") 75 | semester = time_of_year[0] 76 | year = time_of_year[1] 77 | 78 | # fetch number of grades published and number of assignments 79 | if user_type == "Instructor Courses" or is_instructor: 80 | # find number of grades published and number of assignments 81 | # if they exist 82 | num_grades_published = course.find( 83 | "div", class_="courseBox--noGradesPublised" 84 | ) 85 | if num_grades_published is not None: 86 | num_grades_published = num_grades_published.text 87 | 88 | num_assignments = course.find( 89 | "div", 90 | class_="courseBox--assignments courseBox--assignments-unpublished", 91 | ) 92 | if num_assignments is not None: 93 | num_assignments = num_assignments.text 94 | 95 | else: 96 | # students do not have number of grades published, so set to None 97 | num_grades_published = None 98 | num_assignments = course.find("div", class_="courseBox--assignments") 99 | num_assignments = num_assignments.text 100 | 101 | # create Course object with all relevant info 102 | course_info = Course( 103 | name=short_name, 104 | full_name=full_name, 105 | semester=semester, 106 | year=year, 107 | num_grades_published=num_grades_published, 108 | num_assignments=num_assignments, 109 | ) 110 | 111 | # store info for this course 112 | all_courses[course_id] = course_info 113 | 114 | # find next course, or "a" tag 115 | course = course.find_next_sibling("a") 116 | 117 | return all_courses, is_instructor 118 | 119 | 120 | def get_course_members(soup: BeautifulSoup, course_id: str) -> list[Member]: 121 | """ 122 | Scrape all course members from the membership page of a Gradescope course. 123 | 124 | Args: 125 | soup (BeautifulSoup): BeautifulSoup object with parsed HTML. 126 | course_id (str): The course ID to which the members belong. 127 | 128 | Returns: 129 | List: A list of Member objects containing all course members' info. 130 | 131 | For example: 132 | [ 133 | Member(...), 134 | Member(...) 135 | ] 136 | """ 137 | 138 | # assumed ordering 139 | # name, email, role, sections?, submissions, edit, remove 140 | # if course has sections, section column is added before number of submissions column 141 | headers = soup.find("table", class_="js-rosterTable").find_all("th") 142 | has_sections = any(h.text.startswith("Sections") for h in headers) 143 | num_submissions_column = 4 if has_sections else 3 144 | 145 | member_list = [] 146 | 147 | # maps role id to role name 148 | id_to_role = {"0": "Student", "1": "Instructor", "2": "TA", "3": "Reader"} 149 | 150 | # find all rows with class rosterRow (each row is a member) 151 | roster_rows: bs4.ResultSet[bs4.element.Tag] = soup.find_all( 152 | "tr", class_="rosterRow" 153 | ) 154 | 155 | for row in roster_rows: 156 | # get all table data for each row 157 | cells: bs4.ResultSet[bs4.element.Tag] = row.find_all("td") 158 | 159 | # get data from first cell 160 | cell = cells[0] 161 | 162 | data_button = cell.find("button", class_="rosterCell--editIcon") 163 | 164 | # fetch full name from data-cm attribute in button 165 | data_cm = data_button.get("data-cm") 166 | json_data_cm = json.loads(data_cm) # convert to json 167 | full_name = json_data_cm.get("full_name") 168 | 169 | # fetch LMS related attributes 170 | first_name = json_data_cm.get("first_name") 171 | last_name = json_data_cm.get("last_name") 172 | sid = json_data_cm.get("sid") 173 | 174 | # fetch other attributes: email, role, and section 175 | # from data attributes in button 176 | email = data_button.get("data-email") 177 | role = id_to_role[data_button.get("data-role")] 178 | sections = data_button.get("data-sections") # TODO: check if this is correct 179 | 180 | # fetch user_id, only available on 'student' accounts 181 | # 182 | rosterName_button = cell.find("button", class_="js-rosterName") 183 | user_id = None 184 | if rosterName_button is not None: 185 | # data-url="/courses/753413/gradebook.json?user_id=6515875" 186 | data_url: str = rosterName_button.get("data-url", None) 187 | user_id = data_url.split("user_id=")[-1] 188 | 189 | # fetch number of submissions from table cell 190 | num_submissions = int(cells[num_submissions_column].text) 191 | 192 | # create Member object with all relevant info 193 | member_list.append( 194 | Member( 195 | full_name=full_name, 196 | first_name=first_name, 197 | last_name=last_name, 198 | sid=sid, 199 | email=email, 200 | role=role, 201 | user_id=user_id, 202 | num_submissions=num_submissions, 203 | sections=sections, 204 | course_id=course_id, 205 | ) 206 | ) 207 | 208 | return member_list 209 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 19 | 20 | class Account: 21 | def __init__( 22 | self, 23 | session, 24 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 25 | ): 26 | self.session = session 27 | self.gradescope_base_url = gradescope_base_url 28 | 29 | def get_courses(self) -> dict: 30 | """ 31 | Get all courses for the user, including both instructor and student courses 32 | 33 | Returns: 34 | dict: A dictionary of dictionaries, where keys are "instructor" and "student" and values are 35 | dictionaries containing all courses, where keys are course IDs and values are Course objects. 36 | 37 | For example: 38 | { 39 | 'instructor': { 40 | "123456": Course(...), 41 | "234567": Course(...) 42 | }, 43 | 'student': { 44 | "654321": Course(...), 45 | "765432": Course(...) 46 | } 47 | } 48 | 49 | Raises: 50 | RuntimeError: If request to account page fails. 51 | """ 52 | 53 | endpoint = f"{self.gradescope_base_url}/account" 54 | 55 | # get main page 56 | response = self.session.get(endpoint) 57 | 58 | if response.status_code != 200: 59 | raise RuntimeError( 60 | "Failed to access account page on Gradescope. Status code: {response.status_code}" 61 | ) 62 | 63 | soup = BeautifulSoup(response.text, "html.parser") 64 | 65 | # see if user is solely a student or instructor 66 | user_courses, is_instructor = get_courses_info(soup, "Your Courses") 67 | 68 | # if the user is indeed solely a student or instructor 69 | # return the appropriate set of courses 70 | if user_courses: 71 | if is_instructor: 72 | return {"instructor": user_courses, "student": {}} 73 | else: 74 | return {"instructor": {}, "student": user_courses} 75 | 76 | # if user is both a student and instructor, get both sets of courses 77 | courses = {"instructor": {}, "student": {}} 78 | 79 | # get instructor courses 80 | instructor_courses, _ = get_courses_info(soup, "Instructor Courses") 81 | courses["instructor"] = instructor_courses 82 | 83 | # get student courses 84 | student_courses, _ = get_courses_info(soup, "Student Courses") 85 | courses["student"] = student_courses 86 | 87 | return courses 88 | 89 | def get_course_users(self, course_id: str) -> list[Member]: 90 | """ 91 | Get a list of all users in a course 92 | Returns: 93 | list: A list of users in the course (Member objects) 94 | Raises: 95 | Exceptions: 96 | "One or more invalid parameters": if course_id is null or empty value 97 | "You must be logged in to access this page.": if no user is logged in 98 | """ 99 | 100 | membership_endpoint = ( 101 | f"{self.gradescope_base_url}/courses/{course_id}/memberships" 102 | ) 103 | 104 | # check that course_id is valid (not empty) 105 | if not course_id: 106 | raise Exception("Invalid Course ID") 107 | 108 | session = self.session 109 | 110 | try: 111 | # scrape page 112 | membership_resp = check_page_auth(session, membership_endpoint) 113 | membership_soup = BeautifulSoup(membership_resp.text, "html.parser") 114 | 115 | # get all users in the course 116 | users = get_course_members(membership_soup, course_id) 117 | 118 | return users 119 | except Exception: 120 | return None 121 | 122 | def get_assignments(self, course_id: str) -> list[Assignment]: 123 | """ 124 | Get a list of detailed assignment information for a course 125 | Returns: 126 | list: A list of Assignments 127 | Raises: 128 | Exceptions: 129 | "One or more invalid parameters": if course_id or assignment_id is null or empty value 130 | "You are not authorized to access this page.": if logged in user is unable to access submissions 131 | "You must be logged in to access this page.": if no user is logged in 132 | """ 133 | course_endpoint = f"{self.gradescope_base_url}/courses/{course_id}" 134 | # check that course_id is valid (not empty) 135 | if not course_id: 136 | raise Exception("Invalid Course ID") 137 | session = self.session 138 | # scrape page 139 | coursepage_resp = check_page_auth(session, course_endpoint) 140 | coursepage_soup = BeautifulSoup(coursepage_resp.text, "html.parser") 141 | 142 | # two different helper functions to parse assignment info 143 | # webpage html structure differs based on if user if instructor or student 144 | assignment_info_list = get_assignments_instructor_view(coursepage_soup) 145 | if not assignment_info_list: 146 | assignment_info_list = get_assignments_student_view(coursepage_soup) 147 | 148 | return assignment_info_list 149 | 150 | def get_assignment_submissions( 151 | self, course_id: str, assignment_id: str 152 | ) -> dict[str, list[str]]: 153 | """ 154 | Get a list of dicts mapping AWS links for all submissions to each submission id 155 | Returns: 156 | dict: A dictionary of submissions, where the keys are the submission ids and the values are 157 | a list of aws links to the submission pdf 158 | For example: 159 | { 160 | 'submission_id': [ 161 | 'aws_link1.com', 162 | 'aws_link2.com', 163 | ... 164 | ], 165 | ... 166 | } 167 | Raises: 168 | Exceptions: 169 | "One or more invalid parameters": if course_id or assignment_id is null or empty value 170 | "You are not authorized to access this page.": if logged in user is unable to access submissions 171 | "You must be logged in to access this page.": if no user is logged in 172 | "Page not Found": When link is invalid: change in url, invalid course_if or assignment id 173 | "Image only submissions not yet supported": assignment is image submission only, which is not yet supported 174 | NOTE: 175 | 1. Image submissions not supports, need to find an endpoint to retrieve image pdfs 176 | 2. Not recommended for use, since this makes a GET request for every submission -> very slow! 177 | 3. so far only accessible for teachers, not for students to get submissions to an assignment 178 | """ 179 | ASSIGNMENT_ENDPOINT = f"{self.gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 180 | ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{ASSIGNMENT_ENDPOINT}/review_grades" 181 | if not course_id or not assignment_id: 182 | raise Exception("One or more invalid parameters") 183 | session = self.session 184 | submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) 185 | submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") 186 | # select submissions (class of td.table--primaryLink a tag, submission id stored in href link) 187 | submissions_a_tags = submissions_soup.select("td.table--primaryLink a") 188 | submission_ids = [ 189 | a_tag.attrs.get("href").split("/")[-1] for a_tag in submissions_a_tags 190 | ] 191 | submission_links = {} 192 | for submission_id in submission_ids: # doesn't support image submissions yet 193 | aws_links = get_submission_files( 194 | session, course_id, assignment_id, submission_id 195 | ) 196 | submission_links[submission_id] = aws_links 197 | # sleep for 0.1 seconds to avoid sending too many requests to gradescope 198 | time.sleep(0.1) 199 | return submission_links 200 | 201 | def get_assignment_submission( 202 | self, student_email: str, course_id: str, assignment_id: str 203 | ) -> list[str]: 204 | """ 205 | Get a list of aws links to files of the student's most recent submission to an assignment 206 | Returns: 207 | list: A list of aws links as strings 208 | For example: 209 | [ 210 | 'aws_link1.com', 211 | 'aws_link2.com', 212 | ... 213 | ] 214 | Raises: 215 | Exceptions: 216 | "One or more invalid parameters": if course_id or assignment_id is null or empty value 217 | "You are not authorized to access this page.": if logged in user is unable to access submissions 218 | "You must be logged in to access this page.": if no user is logged in 219 | "Page not Found": When link is invalid: change in url, invalid course_if or assignment id 220 | "PDF/Image only submissions not yet supported": assignment is pdf/image submission only, which is not yet supported 221 | "No submission found": When no submission is found for given student_email 222 | NOTE: so far only accessible for teachers, not for students to get their own submission 223 | """ 224 | # fetch submission id 225 | ASSIGNMENT_ENDPOINT = f"{self.gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 226 | ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{ASSIGNMENT_ENDPOINT}/review_grades" 227 | if not (student_email and course_id and assignment_id): 228 | raise Exception("One or more invalid parameters") 229 | session = self.session 230 | submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) 231 | submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") 232 | td_with_email = submissions_soup.find( 233 | "td", string=lambda s: student_email in str(s) 234 | ) 235 | if td_with_email: 236 | # grab submission from previous td 237 | submission_td = td_with_email.find_previous_sibling() 238 | # submission_td will have an anchor element as a child if there is a submission 239 | a_element = submission_td.find("a") 240 | if a_element: 241 | submission_id = a_element.get("href").split("/")[-1] 242 | else: 243 | raise Exception("No submission found") 244 | # call get_submission_files helper function 245 | aws_links = get_submission_files( 246 | session, course_id, assignment_id, submission_id 247 | ) 248 | return aws_links 249 | else: 250 | raise Exception("No submission found") 251 | 252 | def get_assignment_graders(self, course_id: str, question_id: str) -> set[str]: 253 | """ 254 | Get a set of graders for a specific question in an assignment 255 | Returns: 256 | set: A set of graders as strings 257 | For example: 258 | { 259 | 'grader1', 260 | 'grader2', 261 | ... 262 | } 263 | Raises: 264 | Exceptions: 265 | "One or more invalid parameters": if course_id or assignment_id is null or empty value 266 | "You are not authorized to access this page.": if logged in user is unable to access submissions 267 | "You must be logged in to access this page.": if no user is logged in 268 | "Page not Found": When link is invalid: change in url, invalid course_if or assignment id 269 | """ 270 | QUESTION_ENDPOINT = ( 271 | f"{self.gradescope_base_url}/courses/{course_id}/questions/{question_id}" 272 | ) 273 | ASSIGNMENT_SUBMISSIONS_ENDPOINT = f"{QUESTION_ENDPOINT}/submissions" 274 | if not course_id or not question_id: 275 | raise Exception("One or more invalid parameters") 276 | session = self.session 277 | submissions_resp = check_page_auth(session, ASSIGNMENT_SUBMISSIONS_ENDPOINT) 278 | submissions_soup = BeautifulSoup(submissions_resp.text, "html.parser") 279 | # select graders (class of td tag, grader name stored in text) 280 | graders = submissions_soup.select("td")[2::3] 281 | grader_names = set( 282 | [grader.text for grader in graders if grader.text] 283 | ) # get non-empty grader names 284 | return grader_names 285 | -------------------------------------------------------------------------------- /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 | @dataclass 14 | class Assignment: 15 | assignment_id: str 16 | name: str 17 | release_date: datetime.datetime 18 | due_date: datetime.datetime 19 | late_due_date: datetime.datetime 20 | submissions_status: str 21 | grade: str 22 | max_grade: str 23 | 24 | 25 | def update_assignment_date( 26 | session: requests.Session, 27 | course_id: str, 28 | assignment_id: str, 29 | release_date: datetime.datetime | None = None, 30 | due_date: datetime.datetime | None = None, 31 | late_due_date: datetime.datetime | None = None, 32 | gradescope_base_url: str = DEFAULT_GRADESCOPE_BASE_URL, 33 | ): 34 | """Update the dates of an assignment on Gradescope. 35 | 36 | Args: 37 | session (requests.Session): The session object for making HTTP requests. 38 | course_id (str): The ID of the course. 39 | assignment_id (str): The ID of the assignment. 40 | release_date (datetime.datetime | None, optional): The release date of the assignment. Defaults to None. 41 | due_date (datetime.datetime | None, optional): The due date of the assignment. Defaults to None. 42 | late_due_date (datetime.datetime | None, optional): The late due date of the assignment. Defaults to None. 43 | 44 | Notes: 45 | The timezone for dates used in Gradescope is specific to an institution. For example, for NYU, the timezone is America/New_York. 46 | For datetime objects passed to this function, the timezone should be set to the institution's timezone. 47 | 48 | Returns: 49 | bool: True if the assignment dates were successfully updated, False otherwise. 50 | """ 51 | GS_EDIT_ASSIGNMENT_ENDPOINT = ( 52 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}/edit" 53 | ) 54 | GS_POST_ASSIGNMENT_ENDPOINT = ( 55 | f"{gradescope_base_url}/courses/{course_id}/assignments/{assignment_id}" 56 | ) 57 | 58 | # Get auth token 59 | response = session.get(GS_EDIT_ASSIGNMENT_ENDPOINT) 60 | soup = BeautifulSoup(response.text, "html.parser") 61 | auth_token = soup.select_one('input[name="authenticity_token"]')["value"] 62 | 63 | # Setup multipart form data 64 | multipart = MultipartEncoder( 65 | fields={ 66 | "utf8": "✓", 67 | "_method": "patch", 68 | "authenticity_token": auth_token, 69 | "assignment[release_date_string]": ( 70 | release_date.strftime("%Y-%m-%dT%H:%M") if release_date else "" 71 | ), 72 | "assignment[due_date_string]": ( 73 | due_date.strftime("%Y-%m-%dT%H:%M") if due_date else "" 74 | ), 75 | "assignment[allow_late_submissions]": "1" if late_due_date else "0", 76 | "assignment[hard_due_date_string]": ( 77 | late_due_date.strftime("%Y-%m-%dT%H:%M") if late_due_date else "" 78 | ), 79 | "commit": "Save", 80 | } 81 | ) 82 | headers = { 83 | "Content-Type": multipart.content_type, 84 | "Referer": GS_EDIT_ASSIGNMENT_ENDPOINT, 85 | } 86 | 87 | response = session.post( 88 | GS_POST_ASSIGNMENT_ENDPOINT, data=multipart, headers=headers 89 | ) 90 | 91 | return response.status_code == 200 92 | -------------------------------------------------------------------------------- /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/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 11 | num_assignments: str 12 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/gradescopeapi/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyuoss/gradescope-api/a69a8ba44c350422d2927cd913b88d21f859b9c8/src/gradescopeapi/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyuoss/gradescope-api/a69a8ba44c350422d2927cd913b88d21f859b9c8/tests/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_edit_assignment.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from gradescopeapi.classes.assignments import update_assignment_date 4 | 5 | 6 | def test_valid_change_assignment(create_session): 7 | """Test valid extension for a student.""" 8 | # create test session 9 | test_session = create_session("instructor") 10 | 11 | course_id = "753413" 12 | assignment_id = "4436170" 13 | release_date = datetime(2024, 4, 15) 14 | due_date = release_date + timedelta(days=1) 15 | late_due_date = due_date + timedelta(days=1) 16 | 17 | result = update_assignment_date( 18 | test_session, 19 | course_id, 20 | assignment_id, 21 | release_date, 22 | due_date, 23 | late_due_date, 24 | ) 25 | assert result 26 | 27 | 28 | def test_boundary_date_assignment(create_session): 29 | """Test updating assignment with boundary date values.""" 30 | test_session = create_session("instructor") 31 | 32 | course_id = "753413" 33 | assignment_id = "4436170" 34 | boundary_date = datetime(1900, 1, 1) # Very old date 35 | 36 | result = update_assignment_date( 37 | test_session, 38 | course_id, 39 | assignment_id, 40 | boundary_date, 41 | boundary_date, 42 | boundary_date, 43 | ) 44 | assert result, "Failed to update assignment with boundary dates" 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/upload_files/text_file.txt: -------------------------------------------------------------------------------- 1 | This is a text file 2 | --------------------------------------------------------------------------------