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