├── .coveragerc ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── add-depr-ticket-to-depr-board.yml │ ├── add-remove-label-on-comment.yml │ ├── ci.yml │ ├── commitlint.yml │ ├── publish_pypi.yml │ ├── self-assign-issue.yml │ └── upgrade-python-requirements.yml ├── .gitignore ├── .pep8 ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── catalog-info.yaml ├── codecov.yml ├── docs └── decisions │ ├── 0001-record-architecture-decisions.rst │ └── 0002-oauth-api-client-replacement.rst ├── edx_rest_api_client ├── __init__.py ├── __version__.py ├── auth.py ├── client.py ├── exceptions.py └── tests │ ├── __init__.py │ ├── mixins.py │ ├── test_auth.py │ └── test_client.py ├── manage.py ├── openedx.yaml ├── pylintrc ├── pylintrc_tweaks ├── requirements ├── base.in ├── base.txt ├── ci.in ├── ci.txt ├── common_constraints.txt ├── constraints.txt ├── dev.in ├── dev.txt ├── pip-tools.in ├── pip-tools.txt ├── pip.in ├── pip.txt ├── private.readme ├── test.in └── test.txt ├── setup.py ├── test_settings.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | data_file = .coverage 4 | source=edx_rest_api_client 5 | omit = 6 | test_settings 7 | 8 | [html] 9 | directory = build/coverage/html 10 | 11 | [xml] 12 | output = build/coverage/coverage.xml 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # *************************** 2 | # ** DO NOT EDIT THIS FILE ** 3 | # *************************** 4 | # 5 | # This file was generated by edx-lint: https://github.com/edx/edx-lint 6 | # 7 | # If you want to change this file, you have two choices, depending on whether 8 | # you want to make a local change that applies only to this repo, or whether 9 | # you want to make a central change that applies to all repos using edx-lint. 10 | # 11 | # Note: If your .editorconfig file is simply out-of-date relative to the latest 12 | # .editorconfig in edx-lint, ensure you have the latest edx-lint installed 13 | # and then follow the steps for a "LOCAL CHANGE". 14 | # 15 | # LOCAL CHANGE: 16 | # 17 | # 1. Edit the local .editorconfig_tweaks file to add changes just to this 18 | # repo's file. 19 | # 20 | # 2. Run: 21 | # 22 | # $ edx_lint write .editorconfig 23 | # 24 | # 3. This will modify the local file. Submit a pull request to get it 25 | # checked in so that others will benefit. 26 | # 27 | # 28 | # CENTRAL CHANGE: 29 | # 30 | # 1. Edit the .editorconfig file in the edx-lint repo at 31 | # https://github.com/edx/edx-lint/blob/master/edx_lint/files/.editorconfig 32 | # 33 | # 2. install the updated version of edx-lint (in edx-lint): 34 | # 35 | # $ pip install . 36 | # 37 | # 3. Run (in edx-lint): 38 | # 39 | # $ edx_lint write .editorconfig 40 | # 41 | # 4. Make a new version of edx_lint, submit and review a pull request with the 42 | # .editorconfig update, and after merging, update the edx-lint version and 43 | # publish the new version. 44 | # 45 | # 5. In your local repo, install the newer version of edx-lint. 46 | # 47 | # 6. Run: 48 | # 49 | # $ edx_lint write .editorconfig 50 | # 51 | # 7. This will modify the local file. Submit a pull request to get it 52 | # checked in so that others will benefit. 53 | # 54 | # 55 | # 56 | # 57 | # 58 | # STAY AWAY FROM THIS FILE! 59 | # 60 | # 61 | # 62 | # 63 | # 64 | # SERIOUSLY. 65 | # 66 | # ------------------------------ 67 | # Generated by edx-lint version: 5.2.4 68 | # ------------------------------ 69 | [*] 70 | end_of_line = lf 71 | insert_final_newline = true 72 | charset = utf-8 73 | indent_style = space 74 | indent_size = 4 75 | max_line_length = 120 76 | trim_trailing_whitespace = true 77 | 78 | [{Makefile, *.mk}] 79 | indent_style = tab 80 | indent_size = 8 81 | 82 | [*.{yml,yaml,json}] 83 | indent_size = 2 84 | 85 | [*.js] 86 | indent_size = 2 87 | 88 | [*.diff] 89 | trim_trailing_whitespace = false 90 | 91 | [.git/*] 92 | trim_trailing_whitespace = false 93 | 94 | [COMMIT_EDITMSG] 95 | max_line_length = 72 96 | 97 | [*.rst] 98 | max_line_length = 79 99 | 100 | # e8d6d74df5c52d1a858cc2f3e9302ccc330416aa 101 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Adding new check for github-actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | reviewers: 9 | - "openedx/arbi-bom" 10 | -------------------------------------------------------------------------------- /.github/workflows/add-depr-ticket-to-depr-board.yml: -------------------------------------------------------------------------------- 1 | # Run the workflow that adds new tickets that are either: 2 | # - labelled "DEPR" 3 | # - title starts with "[DEPR]" 4 | # - body starts with "Proposal Date" (this is the first template field) 5 | # to the org-wide DEPR project board 6 | 7 | name: Add newly created DEPR issues to the DEPR project board 8 | 9 | on: 10 | issues: 11 | types: [opened] 12 | 13 | jobs: 14 | routeissue: 15 | uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master 16 | secrets: 17 | GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} 18 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} 19 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/add-remove-label-on-comment.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "label: " it tries to apply 3 | # the label indicated in rest of comment. 4 | # If the comment starts with "remove label: ", it tries 5 | # to remove the indicated label. 6 | # Note: Labels are allowed to have spaces and this script does 7 | # not parse spaces (as often a space is legitimate), so the command 8 | # "label: really long lots of words label" will apply the 9 | # label "really long lots of words label" 10 | 11 | name: Allows for the adding and removing of labels via comment 12 | 13 | on: 14 | issue_comment: 15 | types: [created] 16 | 17 | jobs: 18 | add_remove_labels: 19 | uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: 8 | - '**' 9 | 10 | 11 | jobs: 12 | run_tests: 13 | name: tests 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | python-version: 19 | - '3.11' 20 | - '3.12' 21 | toxenv: [quality, django42, django52] 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: setup python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install pip 30 | run: pip install -r requirements/pip.txt 31 | 32 | - name: Install Dependencies 33 | run: pip install -r requirements/ci.txt 34 | 35 | - name: Run Tests 36 | env: 37 | TOXENV: ${{ matrix.toxenv }} 38 | run: make test 39 | 40 | - name: Run Coverage 41 | if: matrix.python-version == '3.12' && matrix.toxenv=='django52' 42 | uses: codecov/codecov-action@v5 43 | with: 44 | token: ${{ secrets.CODECOV_TOKEN }} 45 | flags: unittests 46 | fail_ci_if_error: true 47 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | # Run commitlint on the commit messages in a pull request. 2 | 3 | name: Lint Commit Messages 4 | 5 | on: 6 | - pull_request 7 | 8 | jobs: 9 | commitlint: 10 | uses: openedx/.github/.github/workflows/commitlint.yml@master 11 | -------------------------------------------------------------------------------- /.github/workflows/publish_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to PyPi 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | 10 | push: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: setup python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: 3.11 20 | 21 | - name: Install pip 22 | run: pip install -r requirements/pip.txt 23 | 24 | - name: Build package 25 | run: python setup.py sdist bdist_wheel 26 | 27 | - name: Publish to PyPi 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | with: 30 | user: __token__ 31 | password: ${{ secrets.PYPI_UPLOAD_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/self-assign-issue.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "assign me" it assigns the author to the 3 | # ticket (case insensitive) 4 | 5 | name: Assign comment author to ticket if they say "assign me" 6 | on: 7 | issue_comment: 8 | types: [created] 9 | 10 | jobs: 11 | self_assign_by_comment: 12 | uses: openedx/.github/.github/workflows/self-assign-issue.yml@master 13 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-python-requirements.yml: -------------------------------------------------------------------------------- 1 | name: Upgrade Requirements 2 | 3 | on: 4 | schedule: 5 | - cron: "0 5 * * 2" 6 | workflow_dispatch: 7 | inputs: 8 | branch: 9 | description: 'Target branch to create requirements PR against' 10 | required: true 11 | default: 'master' 12 | jobs: 13 | call-upgrade-python-requirements-workflow: 14 | with: 15 | branch: ${{ github.event.inputs.branch }} 16 | send_success_notification: false 17 | secrets: 18 | requirements_bot_github_token: ${{ secrets.REQUIREMENTS_BOT_GITHUB_TOKEN }} 19 | requirements_bot_github_email: ${{ secrets.REQUIREMENTS_BOT_GITHUB_EMAIL }} 20 | edx_smtp_username: ${{ secrets.EDX_SMTP_USERNAME }} 21 | edx_smtp_password: ${{ secrets.EDX_SMTP_PASSWORD }} 22 | uses: openedx/.github/.github/workflows/upgrade-python-requirements.yml@master 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .python-version 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | __pycache__ 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Django 27 | default.db 28 | 29 | # Oscar 30 | media/cache/ 31 | assets 32 | 33 | # Unit test / coverage reports 34 | .coverage 35 | htmlcov 36 | .tox 37 | nosetests.xml 38 | unittests.xml 39 | 40 | # Translations 41 | *.mo 42 | 43 | # Mr Developer 44 | .mr.developer.cfg 45 | .project 46 | .pydevproject 47 | 48 | # QA 49 | coverage.xml 50 | diff_*.html 51 | *.report 52 | report 53 | venv 54 | acceptance_tests.*.log 55 | acceptance_tests.*.png 56 | 57 | # Override config files 58 | override.cfg 59 | 60 | # JetBrains 61 | .idea 62 | 63 | # OS X 64 | .DS_Store 65 | 66 | *.trace 67 | -------------------------------------------------------------------------------- /.pep8: -------------------------------------------------------------------------------- 1 | [pep8] 2 | ignore=E501 3 | max_line_length=119 4 | exclude=settings 5 | 6 | [pycodestyle] 7 | ignore=E501,E722 8 | max_line_length=119 9 | exclude=settings 10 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | .. 5 | All enhancements and patches to edx-rest-api-client will be documented 6 | in this file. It adheres to the structure of http://keepachangelog.com/ , 7 | but in reStructuredText instead of Markdown (for ease of incorporation into 8 | Sphinx documentation and the PyPI description). Additionally, we no longer 9 | track the date here since PyPi has its own history of dates based on when 10 | the package is published. 11 | 12 | This project adheres to Semantic Versioning (http://semver.org/). 13 | 14 | .. There should always be an "Unreleased" section for changes pending release. 15 | 16 | Unreleased 17 | ---------- 18 | * Nothing 19 | 20 | [6.2.0] 21 | ------- 22 | - Added support for `Django 5.2`. 23 | 24 | [6.1.0] 25 | ------- 26 | - Updated Python requirement files with `Python 3.11`. 27 | - Dropped support for `Python 3.8` from `tox.ini`. 28 | - Bumped the package version to `6.1.0`. 29 | 30 | [6.0.0] 31 | ------- 32 | * Breaking Change: The EdxRestApiClient` has been deprecated and removed in this release. 33 | 34 | * Breaking Change: Dropping Python 3.8 support 35 | 36 | * We no longer test with ubuntu 20.04 and test with whatever is the latest 37 | ubuntu supplied by the ``ubuntu-latest`` github action. Not actually a 38 | breaking change but a change in expectations that we're noting here. We 39 | believe there is no code tied to the OS version in this library. 40 | 41 | [5.7.1] 42 | -------- 43 | chore: Update Requirements specifically to unpin the requests library 44 | 45 | [5.7.0] 46 | -------- 47 | feat: Adding python 3.11 and 3.12 support. Dropped django32 support. 48 | 49 | [5.6.0] 50 | -------- 51 | chore: Update Requirements 52 | 53 | [5.5.2] 54 | -------- 55 | feat: improve how we look for x-request-id headers to forward 56 | 57 | [5.5.1] 58 | -------- 59 | feat: forward x-request-id headers if `crum` can find them 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include requirements/base.in 4 | include requirements/constraints.txt 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: quality requirements test upgrade validate 2 | 3 | .DEFAULT_GOAL := help 4 | 5 | help: ## display this help message 6 | @echo "Please use \`make ' where is one of" 7 | @awk -F ':.*?## ' '/^[a-zA-Z]/ && NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort 8 | 9 | quality: 10 | tox -e quality 11 | 12 | requirements: ## install development environment requirements 13 | pip install -qr requirements/pip.txt 14 | pip install -qr requirements/pip-tools.txt 15 | pip-sync requirements/dev.txt requirements/private.* 16 | 17 | test: 18 | tox 19 | 20 | piptools: 21 | pip install -q -r requirements/pip-tools.txt 22 | 23 | define COMMON_CONSTRAINTS_TEMP_COMMENT 24 | # This is a temporary solution to override the real common_constraints.txt\n# In edx-lint, until the pyjwt constraint in edx-lint has been removed.\n# See BOM-2721 for more details.\n# Below is the copied and edited version of common_constraints\n 25 | endef 26 | 27 | COMMON_CONSTRAINTS_TXT=requirements/common_constraints.txt 28 | .PHONY: $(COMMON_CONSTRAINTS_TXT) 29 | $(COMMON_CONSTRAINTS_TXT): 30 | wget -O "$(@)" https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt || touch "$(@)" 31 | echo "$(COMMON_CONSTRAINTS_TEMP_COMMENT)" | cat - $(@) > temp && mv temp $(@) 32 | 33 | 34 | export CUSTOM_COMPILE_COMMAND = make upgrade 35 | compile-requirements: piptools $(COMMON_CONSTRAINTS_TXT) ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in 36 | # Make sure to compile files after any other files they include! 37 | pip-compile ${COMPILE_OPTS} --allow-unsafe --rebuild -o requirements/pip.txt requirements/pip.in 38 | pip-compile ${COMPILE_OPTS} --allow-unsafe --verbose --rebuild -o requirements/pip-tools.txt requirements/pip-tools.in 39 | pip install -qr requirements/pip.txt 40 | pip install -qr requirements/pip-tools.txt 41 | pip-compile ${COMPILE_OPTS} --allow-unsafe --verbose --rebuild -o requirements/base.txt requirements/base.in 42 | pip-compile ${COMPILE_OPTS} --allow-unsafe --verbose --rebuild -o requirements/test.txt requirements/test.in 43 | pip-compile ${COMPILE_OPTS} --allow-unsafe --verbose --rebuild -o requirements/dev.txt requirements/dev.in 44 | pip-compile ${COMPILE_OPTS} --allow-unsafe --verbose --rebuild -o requirements/pip-tools.txt requirements/pip-tools.in 45 | pip-compile ${COMPILE_OPTS} --allow-unsafe --verbose --rebuild -o requirements/ci.txt requirements/ci.in 46 | # Let tox control the Django and DRF versions for tests 47 | sed -i.tmp '/^django==/d' requirements/test.txt 48 | sed -i.tmp '/^djangorestframework==/d' requirements/test.txt 49 | rm requirements/test.txt.tmp 50 | 51 | upgrade: ## update the pip requirements files to use the latest releases satisfying our constraints 52 | $(MAKE) compile-requirements COMPILE_OPTS="--upgrade" 53 | 54 | validate: test quality 55 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | edX REST API Client 2 | ################### 3 | 4 | | |status-badge| |license-badge| |CI| |Codecov| |pypi-badge| 5 | 6 | The edX REST API Client simplifies communicating with other Open edX services by providing OAuth2 and JWT utilities. 7 | 8 | 9 | Getting Started with Development 10 | ******************************** 11 | 12 | In a Python 3.11 virtual environment: 13 | 14 | .. code-block:: shell 15 | 16 | $ make requirements 17 | $ make validate 18 | 19 | 20 | Clients & REST API Clients code 21 | ******************************* 22 | 23 | Open edX services, including LMS, should use the ``OAuthAPIClient`` class to make OAuth2 client requests and REST API calls. 24 | 25 | Usage 26 | ===== 27 | 28 | By default the ``OAuthAPIClient`` object can be used like any `requests.Session`_ object and you can follow the docs that the requests library provides. 29 | 30 | The ``OAuthAPIClient`` sessions makes some extra requests to get access tokens from the auth endpoints. These requests have a default timeout that can be overridden by passing in a ``timeout`` parameter when instantiating the ``OAuthAPIClient`` object. 31 | 32 | .. code-block:: python 33 | 34 | # create client with default timeouts for token retrieval 35 | client = OAuthAPIClient('https://lms.root', 'client_id', 'client_secret') 36 | 37 | # create client, overriding default timeouts for token retrieval 38 | client = OAuthAPIClient('https://lms.root', 'client_id', 'client_secret', timeout=(6.1, 2)) 39 | client = OAuthAPIClient('https://lms.root', 'client_id', 'client_secret', 40 | timeout=(REQUEST_CONNECT_TIMEOUT, 3) 41 | ) 42 | 43 | # for a request to some.url, a separate timeout should always be set on your requests 44 | client.get('https://some.url', timeout=(3.1, 0.5)) 45 | 46 | The value of the ``timeout`` setting is the same as for any request made with the ``requests`` library. See the `Requests timeouts documentation`_ for more details. 47 | 48 | .. _requests.Session: https://requests.readthedocs.io/en/master/user/advanced/#session-objects 49 | .. _Requests timeouts documentation: https://requests.readthedocs.io/en/master/user/advanced/#timeouts 50 | 51 | Additional Requirements 52 | *********************** 53 | 54 | The OAuthAPIClient uses the TieredCache internally for caching. Read more about the `requirements of TieredCache`_, which include Django caching and some custom middleware. 55 | 56 | .. _requirements of TieredCache: https://github.com/openedx/edx-django-utils/blob/master/edx_django_utils/cache/README.rst#tieredcache 57 | 58 | Contributing 59 | ************ 60 | 61 | Contributions are very welcome. 62 | Please read `How To Contribute `_ for details. 63 | 64 | This project is currently accepting all types of contributions, bug fixes, 65 | security fixes, maintenance work, or new features. However, please make sure 66 | to have a discussion about your new feature idea with the maintainers prior to 67 | beginning development to maximize the chances of your change being accepted. 68 | You can start a conversation by creating a new issue on this repo summarizing 69 | your idea. 70 | 71 | More Help 72 | ********* 73 | 74 | If you're having trouble, we have discussion forums at 75 | `discuss.openedx.org `_ where you can connect with others in the 76 | community. 77 | 78 | Our real-time conversations are on Slack. You can request a `Slack 79 | invitation`_, then join our `community Slack workspace`_. 80 | 81 | For anything non-trivial, the best path is to `open an issue`__ in this 82 | repository with as many details about the issue you are facing as you 83 | can provide. 84 | 85 | __ https://github.com/openedx/edx-rest-api-client/issues 86 | 87 | For more information about these options, see the `Getting Help`_ page. 88 | 89 | .. _Slack invitation: https://openedx.org/slack 90 | .. _community Slack workspace: https://openedx.slack.com/ 91 | .. _Getting Help: https://openedx.org/getting-help 92 | 93 | The Open edX Code of Conduct 94 | **************************** 95 | 96 | All community members are expected to follow the `Open edX Code of Conduct`_. 97 | 98 | .. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/ 99 | 100 | Reporting Security Issues 101 | ************************* 102 | 103 | Please do not report security issues in public. Please email security@openedx.org. 104 | 105 | 106 | .. |CI| image:: https://github.com/openedx/edx-rest-api-client/workflows/Python%20CI/badge.svg?branch=master 107 | :target: https://github.com/openedx/edx-rest-api-client/actions?query=workflow%3A%22Python+CI%22 108 | :alt: Test suite status 109 | 110 | .. |Codecov| image:: https://codecov.io/github/openedx/edx-rest-api-client/coverage.svg?branch=master 111 | :target: https://codecov.io/github/openedx/edx-rest-api-client?branch=master 112 | :alt: Code coverage 113 | 114 | .. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen 115 | :alt: Maintained 116 | 117 | .. |license-badge| image:: https://img.shields.io/github/license/openedx/edx-rest-api-client.svg 118 | :target: https://github.com/openedx/edx-rest-api-client/blob/master/LICENSE 119 | :alt: License 120 | 121 | .. |pypi-badge| image:: https://img.shields.io/pypi/v/edx-rest-api-client.svg 122 | :target: https://pypi.python.org/pypi/edx-rest-api-client/ 123 | :alt: PyPI 124 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | # This file records information about this repo. Its use is described in OEP-55: 2 | # https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html 3 | # 4 | # If you're thinking about copying this, check out the sample file in the relevant ADR instead: 5 | # https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055/decisions/0001-use-backstage-to-support-maintainers.html#references 6 | 7 | apiVersion: backstage.io/v1alpha1 8 | kind: Component 9 | metadata: 10 | name: 'edx-rest-api-client' 11 | description: "Utility library to simplify REST API access" 12 | annotations: 13 | # (Optional) Annotation keys and values can be whatever you want. 14 | # We use it in Open edX repos to have a comma-separated list of GitHub user 15 | # names that might be interested in changes to the architecture of this 16 | # component. 17 | openedx.org/arch-interest-groups: "" 18 | spec: 19 | # (Required) This can be a group (`group:`) or a user (`user:`). 20 | # Don't forget the "user:" or "group:" prefix. Groups must be GitHub team 21 | # names in the openedx GitHub organization: https://github.com/orgs/openedx/teams 22 | owner: "user:usamasadiq" 23 | type: 'library' 24 | lifecycle: 'production' 25 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | coverage: 3 | status: 4 | patch: 5 | default: 6 | target: 95 7 | project: 8 | default: 9 | target: 95 10 | -------------------------------------------------------------------------------- /docs/decisions/0001-record-architecture-decisions.rst: -------------------------------------------------------------------------------- 1 | 1. Record Architecture Decisions 2 | -------------------------------- 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | We would like to keep a historical record on the architectural decisions we make with this app as it evolves over time. 13 | 14 | Decision 15 | -------- 16 | 17 | We will use Architecture Decision Records, as described by Michael Nygard in `Documenting Architecture Decisions`_ 18 | 19 | .. _Documenting Architecture Decisions: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions 20 | 21 | Consequences 22 | ------------ 23 | 24 | See Michael Nygard's article, linked above. 25 | 26 | References 27 | ---------- 28 | 29 | * https://resources.sei.cmu.edu/asset_files/Presentation/2017_017_001_497746.pdf 30 | * https://github.com/npryce/adr-tools/tree/master/doc/adr 31 | -------------------------------------------------------------------------------- /docs/decisions/0002-oauth-api-client-replacement.rst: -------------------------------------------------------------------------------- 1 | 2. OAuth API Client replacement 2 | ------------------------------- 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | The ``EdxRestApiClient`` accepts various types of tokens for authentication, which allows for a wide variety of usages. This means that how authentication is performed is leaked into many different applications and is difficult to standardize and change. 13 | 14 | Additionally, ``EdxRestApiClient`` made use of the ``slumber`` python library which is no longer supported. 15 | 16 | Decision 17 | -------- 18 | 19 | We have introduced a new ``OAuthAPIClient`` to replace the now deprecated ``EdxRestApiClient``. The ``OAuthAPIClient`` can be used for server-to-server calls, accepting a client id and client secret. The underlying implementation of the authentication is meant to be encapsulated and is subject to change. 20 | 21 | Because the ``slumber`` python library is no longer supported, it was not used when implementing the ``OAuthAPIClient``. Instead, the ``OAuthAPIClient`` is now a subclass of the `requests.Session`_ object. 22 | 23 | Consequences 24 | ------------ 25 | 26 | All uses of ``EdxRestApiClient`` should ultimately be replaced. Any server-to-server calls can be replaced with ``OAuthAPIClient`` using a client id and client secret. 27 | 28 | Other uses and features of ``EdxRestApiClient`` not yet available in ``OAuthAPIClient`` may require additional decisions regarding how and if to replace in such a way that maintains the integrity of this decision to keep the authentication implementation encapsulated inside the client and simpler to update in the future. 29 | 30 | Since the ``OAuthAPIClient`` is just a `requests.Session`_ object, its usage and features are well documented. It is hopefully less likely for this library to go out of support than ``slumber``, and trying to add an abstraction layer to avoid this situation seems like it will add more cost than benefit. 31 | 32 | .. _requests.Session: https://requests.readthedocs.io/en/master/user/advanced/#session-objects 33 | -------------------------------------------------------------------------------- /edx_rest_api_client/__init__.py: -------------------------------------------------------------------------------- 1 | from .__version__ import __version__ 2 | -------------------------------------------------------------------------------- /edx_rest_api_client/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = '6.2.0' 2 | -------------------------------------------------------------------------------- /edx_rest_api_client/auth.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import jwt 4 | from edx_django_utils.monitoring import set_custom_attribute 5 | from requests.auth import AuthBase 6 | 7 | 8 | # pylint: disable=line-too-long 9 | class JwtAuth(AuthBase): 10 | """ 11 | Attaches JWT Authentication to the given Request object. 12 | 13 | Deprecated: 14 | See https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst 15 | 16 | Todos: 17 | * Remove pyjwt dependency from edx-rest-api-client when this class is 18 | removed. 19 | * This class is only used by ecomworker according to data supplied by 20 | the `deprecated_jwt_signing` custom attribute. This class should be 21 | moved to ecomworker and out of the shared library until it can be 22 | removed completely. The new class should rename 23 | `deprecated_jwt_signing` to `deprecated_ecomworker_jwt_signing` 24 | to ensure the transition. 25 | 26 | """ 27 | 28 | def __init__(self, username, full_name, email, signing_key, issuer=None, expires_in=30, tracking_context=None): 29 | self.issuer = issuer 30 | self.expires_in = expires_in 31 | self.username = username 32 | self.email = email 33 | self.full_name = full_name 34 | self.signing_key = signing_key 35 | self.tracking_context = tracking_context 36 | 37 | def __call__(self, r): 38 | now = datetime.datetime.utcnow() 39 | data = { 40 | 'username': self.username, 41 | 'full_name': self.full_name, 42 | 'email': self.email, 43 | 'iat': now, 44 | } 45 | 46 | if self.issuer: 47 | data['iss'] = self.issuer 48 | 49 | if self.expires_in: 50 | data['exp'] = now + datetime.timedelta(seconds=self.expires_in) 51 | 52 | if self.tracking_context is not None: 53 | data['tracking_context'] = self.tracking_context 54 | 55 | set_custom_attribute('deprecated_jwt_signing', 'JwtAuth') 56 | r.headers['Authorization'] = 'JWT {jwt}'.format(jwt=jwt.encode(data, self.signing_key)) 57 | return r 58 | 59 | 60 | class SuppliedJwtAuth(AuthBase): 61 | """ 62 | Attaches a supplied JWT to the given Request object. 63 | """ 64 | 65 | def __init__(self, token): 66 | """ 67 | Instantiate the auth class. 68 | """ 69 | self.token = token 70 | 71 | def __call__(self, r): 72 | """ 73 | Update the request headers. 74 | """ 75 | r.headers['Authorization'] = f'JWT {self.token}' 76 | return r 77 | 78 | 79 | class BearerAuth(AuthBase): 80 | """ 81 | Attaches Bearer Authentication to the given Request object. 82 | """ 83 | 84 | def __init__(self, token): 85 | """ 86 | Instantiate the auth class. 87 | """ 88 | self.token = token 89 | 90 | def __call__(self, r): 91 | """ 92 | Update the request headers. 93 | """ 94 | r.headers['Authorization'] = f'Bearer {self.token}' 95 | return r 96 | -------------------------------------------------------------------------------- /edx_rest_api_client/client.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import socket 4 | import os 5 | 6 | import crum 7 | import requests 8 | import requests.utils 9 | from edx_django_utils.cache import TieredCache 10 | from edx_django_utils.monitoring import set_custom_attribute 11 | 12 | from edx_rest_api_client.__version__ import __version__ 13 | from edx_rest_api_client.auth import SuppliedJwtAuth 14 | 15 | # When caching tokens, use this value to err on expiring tokens a little early so they are 16 | # sure to be valid at the time they are used. 17 | ACCESS_TOKEN_EXPIRED_THRESHOLD_SECONDS = 5 18 | 19 | # How long should we wait to connect to the auth service. 20 | # https://requests.readthedocs.io/en/master/user/advanced/#timeouts 21 | REQUEST_CONNECT_TIMEOUT = 3.05 22 | REQUEST_READ_TIMEOUT = 5 23 | 24 | 25 | def user_agent(): 26 | """ 27 | Return a User-Agent that identifies this client. 28 | 29 | Example: 30 | python-requests/2.9.1 edx-rest-api-client/1.7.2 ecommerce 31 | 32 | The last item in the list will be the application name, taken from the 33 | OS environment variable EDX_REST_API_CLIENT_NAME. If that environment 34 | variable is not set, it will default to the hostname. 35 | """ 36 | client_name = 'unknown_client_name' 37 | try: 38 | client_name = os.environ.get("EDX_REST_API_CLIENT_NAME") or socket.gethostbyname(socket.gethostname()) 39 | except: # pylint: disable=bare-except 40 | pass # using 'unknown_client_name' is good enough. no need to log. 41 | return "{} edx-rest-api-client/{} {}".format( 42 | requests.utils.default_user_agent(), # e.g. "python-requests/2.9.1" 43 | __version__, # version of this client 44 | client_name 45 | ) 46 | 47 | 48 | USER_AGENT = user_agent() 49 | 50 | 51 | def _get_oauth_url(url): 52 | """ 53 | Returns the complete url for the oauth2 endpoint. 54 | 55 | Args: 56 | url (str): base url of the LMS oauth endpoint, which can optionally include some or all of the path 57 | ``/oauth2/access_token``. Common example settings that would work for ``url`` would include: 58 | LMS_BASE_URL = 'http://edx.devstack.lms:18000' 59 | BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = 'http://edx.devstack.lms:18000/oauth2' 60 | 61 | """ 62 | stripped_url = url.rstrip('/') 63 | if stripped_url.endswith('/access_token'): 64 | return url 65 | 66 | if stripped_url.endswith('/oauth2'): 67 | return stripped_url + '/access_token' 68 | 69 | return stripped_url + '/oauth2/access_token' 70 | 71 | 72 | def get_request_id(): 73 | """ 74 | Helper to get the request id - usually set via an X-Request-ID header 75 | """ 76 | request = crum.get_current_request() 77 | if request is not None and request.headers is not None: 78 | return request.headers.get('X-Request-ID') 79 | else: 80 | return None 81 | 82 | 83 | def get_oauth_access_token(url, client_id, client_secret, token_type='jwt', grant_type='client_credentials', 84 | refresh_token=None, 85 | timeout=(REQUEST_CONNECT_TIMEOUT, REQUEST_READ_TIMEOUT)): 86 | """ 87 | Retrieves OAuth 2.0 access token using the given grant type. 88 | 89 | Args: 90 | url (str): Oauth2 access token endpoint, optionally including part of the path. 91 | client_id (str): client ID 92 | client_secret (str): client secret 93 | Kwargs: 94 | token_type (str): Type of token to return. Options include bearer and jwt. 95 | grant_type (str): One of 'client_credentials' or 'refresh_token' 96 | refresh_token (str): The previous access token (for grant_type=refresh_token) 97 | 98 | Raises: 99 | requests.RequestException if there is a problem retrieving the access token. 100 | 101 | Returns: 102 | tuple: Tuple containing (access token string, expiration datetime). 103 | 104 | """ 105 | now = datetime.datetime.utcnow() 106 | data = { 107 | 'grant_type': grant_type, 108 | 'client_id': client_id, 109 | 'client_secret': client_secret, 110 | 'token_type': token_type, 111 | } 112 | if refresh_token: 113 | data['refresh_token'] = refresh_token 114 | else: 115 | assert grant_type != 'refresh_token', "refresh_token parameter required" 116 | 117 | response = requests.post( 118 | _get_oauth_url(url), 119 | data=data, 120 | headers={ 121 | 'User-Agent': USER_AGENT, 122 | }, 123 | timeout=timeout 124 | ) 125 | 126 | response.raise_for_status() # Raise an exception for bad status codes. 127 | try: 128 | data = response.json() 129 | access_token = data['access_token'] 130 | expires_in = data['expires_in'] 131 | except (KeyError, json.decoder.JSONDecodeError) as json_error: 132 | raise requests.RequestException(response=response) from json_error 133 | 134 | expires_at = now + datetime.timedelta(seconds=expires_in) 135 | 136 | return access_token, expires_at 137 | 138 | 139 | def get_and_cache_oauth_access_token(url, client_id, client_secret, token_type='jwt', grant_type='client_credentials', 140 | refresh_token=None, 141 | timeout=(REQUEST_CONNECT_TIMEOUT, REQUEST_READ_TIMEOUT)): 142 | """ 143 | Retrieves a possibly cached OAuth 2.0 access token using the given grant type. 144 | 145 | See ``get_oauth_access_token`` for usage details. 146 | 147 | First retrieves the access token from the cache and ensures it has not expired. If 148 | the access token either wasn't found in the cache, or was expired, retrieves a new 149 | access token and caches it for the lifetime of the token. 150 | 151 | Note: Consider tokens to be expired ACCESS_TOKEN_EXPIRED_THRESHOLD_SECONDS early 152 | to ensure the token won't expire while it is in use. 153 | 154 | Returns: 155 | tuple: Tuple containing (access token string, expiration datetime). 156 | 157 | """ 158 | oauth_url = _get_oauth_url(url) 159 | cache_key = 'edx_rest_api_client.access_token.{}.{}.{}.{}'.format( 160 | token_type, 161 | grant_type, 162 | client_id, 163 | oauth_url, 164 | ) 165 | cached_response = TieredCache.get_cached_response(cache_key) 166 | 167 | # Attempt to get an unexpired cached access token 168 | if cached_response.is_found: 169 | _, expiration = cached_response.value 170 | # Double-check the token hasn't already expired as a safety net. 171 | adjusted_expiration = expiration - datetime.timedelta(seconds=ACCESS_TOKEN_EXPIRED_THRESHOLD_SECONDS) 172 | if datetime.datetime.utcnow() < adjusted_expiration: 173 | return cached_response.value 174 | 175 | # Get a new access token if no unexpired access token was found in the cache. 176 | oauth_access_token_response = get_oauth_access_token( 177 | oauth_url, 178 | client_id, 179 | client_secret, 180 | grant_type=grant_type, 181 | refresh_token=refresh_token, 182 | timeout=timeout, 183 | ) 184 | 185 | # Cache the new access token with an expiration matching the lifetime of the token. 186 | _, expiration = oauth_access_token_response 187 | expires_in = (expiration - datetime.datetime.utcnow()).seconds - ACCESS_TOKEN_EXPIRED_THRESHOLD_SECONDS 188 | TieredCache.set_all_tiers(cache_key, oauth_access_token_response, expires_in) 189 | 190 | return oauth_access_token_response 191 | 192 | 193 | class OAuthAPIClient(requests.Session): 194 | """ 195 | A :class:`requests.Session` that automatically authenticates against edX's preferred 196 | authentication method, given a client id and client secret. The underlying implementation 197 | is subject to change. 198 | 199 | Usage example:: 200 | 201 | client = OAuthAPIClient( 202 | settings.BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, 203 | settings.BACKEND_SERVICE_EDX_OAUTH2_KEY, 204 | settings.BACKEND_SERVICE_EDX_OAUTH2_SECRET, 205 | ) 206 | response = client.get( 207 | settings.EXAMPLE_API_SERVICE_URL + 'example/', 208 | params={'username': user.username}, 209 | timeout=(3.1, 0.5), # Always set a timeout. 210 | ) 211 | response.raise_for_status() # could be an error response 212 | response_data = response.json() 213 | 214 | For more usage details, see documentation of the :class:`requests.Session` object: 215 | - https://requests.readthedocs.io/en/master/user/advanced/#session-objects 216 | 217 | Note: Requires Django + Middleware for TieredCache, used for caching the access token. 218 | See https://github.com/openedx/edx-django-utils/blob/master/edx_django_utils/cache/README.rst#tieredcache 219 | 220 | """ 221 | 222 | # If the oauth_uri is set, it will be appended to the base_url. 223 | # Also, if oauth_uri does not end with `/oauth2/access_token`, it will be adjusted as necessary to do so. 224 | # This was needed when using the client to connect with a third-party (rather than LMS). 225 | oauth_uri = None 226 | 227 | def __init__(self, base_url, client_id, client_secret, 228 | timeout=(REQUEST_CONNECT_TIMEOUT, REQUEST_READ_TIMEOUT), 229 | **kwargs): 230 | """ 231 | Args: 232 | base_url (str): base url of the LMS oauth endpoint, which can optionally include the path `/oauth2`. 233 | Commonly example settings that would work for `base_url` might include: 234 | LMS_BASE_URL = 'http://edx.devstack.lms:18000' 235 | BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = 'http://edx.devstack.lms:18000/oauth2' 236 | client_id (str): Client ID 237 | client_secret (str): Client secret 238 | timeout (tuple(float,float)): Requests timeout parameter for access token requests. 239 | (https://requests.readthedocs.io/en/master/user/advanced/#timeouts) 240 | 241 | """ 242 | super().__init__(**kwargs) 243 | self.headers['user-agent'] = USER_AGENT 244 | self.auth = SuppliedJwtAuth(None) 245 | 246 | self._base_url = base_url.rstrip('/') 247 | self._client_id = client_id 248 | self._client_secret = client_secret 249 | self._timeout = timeout 250 | 251 | def _ensure_authentication(self): 252 | """ 253 | Ensures that the Session's auth.token is set with an unexpired token. 254 | 255 | Raises: 256 | requests.RequestException if there is a problem retrieving the access token. 257 | 258 | """ 259 | oauth_url = self._base_url if not self.oauth_uri else self._base_url + self.oauth_uri 260 | 261 | oauth_access_token_response = get_and_cache_oauth_access_token( 262 | oauth_url, 263 | self._client_id, 264 | self._client_secret, 265 | grant_type='client_credentials', 266 | timeout=self._timeout, 267 | ) 268 | 269 | self.auth.token, _ = oauth_access_token_response 270 | 271 | def get_jwt_access_token(self): 272 | """ 273 | Returns the JWT access token that will be used to make authenticated calls. 274 | 275 | The intention of this method is only to allow you to decode the JWT if you require 276 | any of its details, like the username. You should not use the JWT to make calls by 277 | another client. 278 | 279 | Here is example code that properly uses the configured JWT decoder: 280 | https://github.com/openedx/edx-drf-extensions/blob/master/edx_rest_framework_extensions/auth/jwt/authentication.py#L180-L190 281 | """ 282 | self._ensure_authentication() 283 | return self.auth.token 284 | 285 | def request(self, method, url, headers=None, **kwargs): # pylint: disable=arguments-differ 286 | """ 287 | Overrides Session.request to ensure that the session is authenticated. 288 | 289 | Note: Typically, users of the client won't call this directly, but will 290 | instead use Session.get or Session.post. 291 | 292 | """ 293 | request_id = get_request_id() 294 | if headers is None: 295 | headers = {} 296 | if headers.get('X-Request-ID') is None and request_id is not None: 297 | headers['X-Request-ID'] = request_id 298 | set_custom_attribute('api_client', 'OAuthAPIClient') 299 | self._ensure_authentication() 300 | return super().request(method, url, headers=headers, **kwargs) 301 | -------------------------------------------------------------------------------- /edx_rest_api_client/exceptions.py: -------------------------------------------------------------------------------- 1 | # noinspection PyUnresolvedReferences 2 | from requests.exceptions import Timeout # pylint: disable=unused-import 3 | -------------------------------------------------------------------------------- /edx_rest_api_client/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/edx-rest-api-client/891764fb9cf4cae70dc201d6d092289ccd4acfee/edx_rest_api_client/tests/__init__.py -------------------------------------------------------------------------------- /edx_rest_api_client/tests/mixins.py: -------------------------------------------------------------------------------- 1 | import responses 2 | 3 | 4 | class AuthenticationTestMixin: 5 | """ 6 | Mixin for testing authentication. 7 | """ 8 | def setUp(self): 9 | super().setUp() 10 | responses.reset() 11 | 12 | def _mock_auth_api(self, url, status, body=None): 13 | body = body or {} 14 | responses.add( 15 | responses.POST, 16 | url, 17 | status=status, 18 | json=body, 19 | content_type='application/json' 20 | ) 21 | -------------------------------------------------------------------------------- /edx_rest_api_client/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest import TestCase, mock 3 | 4 | import jwt 5 | import requests 6 | import responses 7 | 8 | from edx_rest_api_client import auth 9 | 10 | CURRENT_TIME = datetime.datetime(2015, 7, 2, 10, 10, 10) 11 | 12 | 13 | class JwtAuthTests(TestCase): 14 | 15 | def setUp(self): 16 | super().setUp() 17 | 18 | self.url = 'http://example.com/' 19 | self.username = 'alice' 20 | self.full_name = 'αlícє whítє' 21 | self.email = 'alice@example.com' 22 | self.signing_key = 'edx' 23 | 24 | datetime_patcher = mock.patch.object( 25 | auth.datetime, 'datetime', 26 | mock.Mock(wraps=datetime.datetime) 27 | ) 28 | mocked_datetime = datetime_patcher.start() 29 | mocked_datetime.utcnow.return_value = CURRENT_TIME 30 | self.addCleanup(datetime_patcher.stop) 31 | 32 | responses.add(responses.GET, self.url) 33 | 34 | def assert_expected_token_value(self, tracking_context=None, issuer=None, expires_in=None): 35 | """ 36 | DRY helper. 37 | """ 38 | 39 | # Mock the HTTP response and issue the request 40 | auth_kwargs = {'expires_in': expires_in} 41 | 42 | signing_data = { 43 | 'username': self.username, 44 | 'full_name': self.full_name, 45 | 'email': self.email, 46 | 'iat': CURRENT_TIME, 47 | } 48 | 49 | if issuer: 50 | auth_kwargs['issuer'] = issuer 51 | signing_data['iss'] = issuer 52 | 53 | if tracking_context: 54 | auth_kwargs['tracking_context'] = tracking_context 55 | signing_data['tracking_context'] = tracking_context 56 | 57 | if expires_in: 58 | signing_data['exp'] = CURRENT_TIME + datetime.timedelta(seconds=expires_in) 59 | 60 | requests.get( 61 | self.url, 62 | auth=auth.JwtAuth( 63 | self.username, self.full_name, self.email, self.signing_key, 64 | **auth_kwargs 65 | ) 66 | ) 67 | 68 | # Verify the header was set as expected on the request 69 | token = jwt.encode(signing_data, self.signing_key) 70 | self.assertEqual(responses.calls[0].request.headers['Authorization'], f'JWT {token}') 71 | 72 | @responses.activate 73 | def test_headers(self): 74 | """ 75 | Verify the class adds an Authorization header that includes the correct JWT. 76 | """ 77 | self.assert_expected_token_value() 78 | 79 | @responses.activate 80 | def test_tracking_context(self): 81 | """ 82 | Verify the tracking context is enclosed in the token payload, when specified. 83 | """ 84 | self.assert_expected_token_value(tracking_context={'foo': 'bar'}) 85 | 86 | @responses.activate 87 | def test_issuer(self): 88 | """ 89 | Verify that the issuer is enclosed in the token payload, when specified. 90 | """ 91 | self.assert_expected_token_value(issuer='http://example.com/oauth') 92 | 93 | @responses.activate 94 | def test_expires_in(self): 95 | """ 96 | Verify the expiration date is enclosed in the token payload, when specified. 97 | """ 98 | self.assert_expected_token_value(expires_in=60) 99 | 100 | 101 | class BearerAuthTests(TestCase): 102 | def setUp(self): 103 | super().setUp() 104 | self.url = 'http://example.com/' 105 | responses.add(responses.GET, self.url) 106 | 107 | @responses.activate 108 | def test_headers(self): 109 | """ 110 | Verify the class adds an Authorization headers with the bearer token. 111 | """ 112 | token = 'abc123' 113 | requests.get(self.url, auth=auth.BearerAuth(token)) 114 | self.assertEqual(responses.calls[0].request.headers['Authorization'], f'Bearer {token}') 115 | 116 | 117 | class SuppliedJwtAuthTests(TestCase): 118 | 119 | signing_key = 'super-secret' 120 | url = 'http://example.com/' 121 | 122 | def setUp(self): 123 | """Set up tests.""" 124 | super().setUp() 125 | responses.add(responses.GET, self.url) 126 | 127 | @responses.activate 128 | def test_headers(self): 129 | """Verify that the token is added to the Authorization headers.""" 130 | payload = { 131 | 'key1': 'value1', 132 | 'key2': 'vαlue2' 133 | } 134 | token = jwt.encode(payload, self.signing_key) 135 | requests.get(self.url, auth=auth.SuppliedJwtAuth(token)) 136 | self.assertEqual(responses.calls[0].request.headers['Authorization'], f'JWT {token}') 137 | -------------------------------------------------------------------------------- /edx_rest_api_client/tests/test_client.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from unittest import TestCase, mock 4 | 5 | import ddt 6 | import requests 7 | import responses 8 | from edx_django_utils.cache import TieredCache 9 | from freezegun import freeze_time 10 | 11 | from edx_rest_api_client import __version__ 12 | from edx_rest_api_client.client import (OAuthAPIClient, get_and_cache_oauth_access_token, 13 | get_oauth_access_token) 14 | from edx_rest_api_client.tests.mixins import AuthenticationTestMixin 15 | 16 | URL = 'http://example.com/api/v2' 17 | OAUTH_URL = "http://test-auth.com/oauth2/access_token" 18 | OAUTH_URL_2 = "http://test-auth.com/edx/oauth2/access_token" 19 | SIGNING_KEY = 'edx' 20 | USERNAME = 'edx' 21 | FULL_NAME = 'édx äpp' 22 | EMAIL = 'edx@example.com' 23 | TRACKING_CONTEXT = {'foo': 'bar'} 24 | ACCESS_TOKEN = 'abc123' 25 | JWT = 'abc.123.doremi' 26 | 27 | 28 | @ddt.ddt 29 | class ClientCredentialTests(AuthenticationTestMixin, TestCase): 30 | """ 31 | Test client credentials requests. 32 | """ 33 | 34 | def test_refresh_token_required(self): 35 | self._mock_auth_api(OAUTH_URL, 200, body=None) 36 | with self.assertRaises(AssertionError): 37 | get_oauth_access_token(OAUTH_URL, 'client_id', 'client_secret', grant_type='refresh_token') 38 | 39 | 40 | class CachedClientCredentialTests(AuthenticationTestMixin, TestCase): 41 | """ 42 | Test cached client credentials requests. 43 | """ 44 | 45 | def setUp(self): 46 | super().setUp() 47 | TieredCache.dangerous_clear_all_tiers() 48 | 49 | @responses.activate 50 | def test_token_caching(self): 51 | """ 52 | Test that tokens are cached based on client, token_type, and grant_type 53 | """ 54 | tokens = [ 55 | 'auth2-cred4', 'auth2-cred3', 'auth2-cred2', 'auth2-cred1', 56 | 'auth1-cred4', 'auth1-cred3', 'auth1-cred2', 'auth1-cred1', 57 | ] 58 | 59 | def auth_callback(request): # pylint: disable=unused-argument 60 | resp = {'expires_in': 60} 61 | resp['access_token'] = 'no-more-credentials' if not tokens else tokens.pop() 62 | return (200, {}, json.dumps(resp)) 63 | 64 | responses.add_callback(responses.POST, OAUTH_URL, callback=auth_callback, content_type='application/json') 65 | responses.add_callback(responses.POST, OAUTH_URL_2, callback=auth_callback, content_type='application/json') 66 | 67 | kwargs_list = [ 68 | {'client_id': 'test-id-1', 'token_type': "jwt", 'grant_type': 'client_credentials'}, 69 | {'client_id': 'test-id-2', 'token_type': "jwt", 'grant_type': 'client_credentials'}, 70 | {'client_id': 'test-id-1', 'token_type': "bearer", 'grant_type': 'client_credentials'}, 71 | {'client_id': 'test-id-1', 'token_type': "jwt", 'grant_type': 'refresh_token'}, 72 | ] 73 | 74 | # initial requests to OAUTH_URL should call the mock client and get the correct credentials 75 | for index, kwargs in enumerate(kwargs_list): 76 | token_response = self._get_and_cache_oauth_access_token(OAUTH_URL, **kwargs) 77 | expected_token = 'auth1-cred{}'.format(index + 1) 78 | self.assertEqual(token_response[0], expected_token) 79 | self.assertEqual(len(responses.calls), 4) 80 | 81 | # initial requests to OAUTH_URL_2 should call the mock client and get the correct credentials 82 | for index, kwargs in enumerate(kwargs_list): 83 | token_response = self._get_and_cache_oauth_access_token(OAUTH_URL_2, **kwargs) 84 | expected_token = 'auth2-cred{}'.format(index + 1) 85 | self.assertEqual(token_response[0], expected_token) 86 | self.assertEqual(len(responses.calls), 8) 87 | 88 | # second set of requests to OAUTH_URL should return the same credentials without making any new mock calls 89 | for index, kwargs in enumerate(kwargs_list): 90 | token_response = self._get_and_cache_oauth_access_token(OAUTH_URL, **kwargs) 91 | expected_token = 'auth1-cred{}'.format(index + 1) 92 | self.assertEqual(token_response[0], expected_token) 93 | self.assertEqual(len(responses.calls), 8) 94 | 95 | # second set of requests to OAUTH_URL_2 should return the same credentials without making any new mock calls 96 | for index, kwargs in enumerate(kwargs_list): 97 | token_response = self._get_and_cache_oauth_access_token(OAUTH_URL_2, **kwargs) 98 | expected_token = 'auth2-cred{}'.format(index + 1) 99 | self.assertEqual(token_response[0], expected_token) 100 | self.assertEqual(len(responses.calls), 8) 101 | 102 | def _get_and_cache_oauth_access_token(self, auth_url, client_id, token_type, grant_type): 103 | refresh_token = 'test-refresh-token' if grant_type == 'refresh_token' else None 104 | return get_and_cache_oauth_access_token( 105 | auth_url, client_id, 'test-secret', token_type=token_type, grant_type=grant_type, 106 | refresh_token=refresh_token, 107 | ) 108 | 109 | 110 | @ddt.ddt 111 | class OAuthAPIClientTests(AuthenticationTestMixin, TestCase): 112 | """ 113 | Tests for OAuthAPIClient 114 | """ 115 | base_url = 'http://testing.test' 116 | client_id = 'test' 117 | client_secret = 'secret' 118 | 119 | def setUp(self): 120 | super().setUp() 121 | TieredCache.dangerous_clear_all_tiers() 122 | 123 | @responses.activate 124 | @ddt.data( 125 | ('http://testing.test', None, 'http://testing.test/oauth2/access_token'), 126 | ('http://testing.test', '/edx', 'http://testing.test/edx/oauth2/access_token'), 127 | ('http://testing.test', '/edx/oauth2', 'http://testing.test/edx/oauth2/access_token'), 128 | ('http://testing.test', '/edx/oauth2/access_token', 'http://testing.test/edx/oauth2/access_token'), 129 | ('http://testing.test/oauth2', None, 'http://testing.test/oauth2/access_token'), 130 | ('http://testing.test/test', '/edx/oauth2/access_token', 'http://testing.test/test/edx/oauth2/access_token'), 131 | ) 132 | @ddt.unpack 133 | def test_automatic_auth(self, client_base_url, custom_oauth_uri, expected_oauth_url): 134 | """ 135 | Test that the JWT token is automatically set 136 | """ 137 | client_session = OAuthAPIClient(client_base_url, self.client_id, self.client_secret) 138 | client_session.oauth_uri = custom_oauth_uri 139 | 140 | self._mock_auth_api(expected_oauth_url, 200, {'access_token': 'abcd', 'expires_in': 60}) 141 | self._mock_auth_api(self.base_url + '/endpoint', 200, {'status': 'ok'}) 142 | response = client_session.post(self.base_url + '/endpoint', data={'test': 'ok'}) 143 | self.assertIn('client_id=%s' % self.client_id, responses.calls[0].request.body) 144 | self.assertEqual(client_session.auth.token, 'abcd') 145 | self.assertEqual(response.json()['status'], 'ok') 146 | 147 | @responses.activate 148 | def test_automatic_token_refresh(self): 149 | """ 150 | Test that the JWT token is automatically refreshed 151 | """ 152 | tokens = ['cred2', 'cred1'] 153 | 154 | def auth_callback(request): 155 | resp = {'expires_in': 60} 156 | if 'grant_type=client_credentials' in request.body: 157 | resp['access_token'] = tokens.pop() 158 | return (200, {}, json.dumps(resp)) 159 | 160 | responses.add_callback( 161 | responses.POST, self.base_url + '/oauth2/access_token', 162 | callback=auth_callback, 163 | content_type='application/json', 164 | ) 165 | 166 | client_session = OAuthAPIClient(self.base_url, self.client_id, self.client_secret) 167 | self._mock_auth_api(self.base_url + '/endpoint', 200, {'status': 'ok'}) 168 | response = client_session.post(self.base_url + '/endpoint', data={'test': 'ok'}) 169 | first_call_datetime = datetime.datetime.utcnow() 170 | self.assertEqual(client_session.auth.token, 'cred1') 171 | self.assertEqual(response.json()['status'], 'ok') 172 | # after only 30 seconds should still use the cached token 173 | with freeze_time(first_call_datetime + datetime.timedelta(seconds=30)): 174 | response = client_session.post(self.base_url + '/endpoint', data={'test': 'ok'}) 175 | self.assertEqual(client_session.auth.token, 'cred1') 176 | # after just under a minute, should request a new token 177 | # - expires early due to ACCESS_TOKEN_EXPIRED_THRESHOLD_SECONDS 178 | with freeze_time(first_call_datetime + datetime.timedelta(seconds=56)): 179 | response = client_session.post(self.base_url + '/endpoint', data={'test': 'ok'}) 180 | self.assertEqual(client_session.auth.token, 'cred2') 181 | 182 | @mock.patch('edx_rest_api_client.client.requests.post') 183 | def test_access_token_request_timeout_wiring2(self, mock_access_token_post): 184 | mock_access_token_post.return_value.json.return_value = {'access_token': 'token', 'expires_in': 1000} 185 | 186 | timeout_override = (6.1, 2) 187 | client = OAuthAPIClient(self.base_url, self.client_id, self.client_secret, timeout=timeout_override) 188 | client._ensure_authentication() # pylint: disable=protected-access 189 | 190 | assert mock_access_token_post.call_args.kwargs['timeout'] == timeout_override 191 | 192 | @responses.activate 193 | def test_access_token_invalid_json_response(self): 194 | responses.add(responses.POST, 195 | self.base_url + '/oauth2/access_token', 196 | status=200, 197 | body="Not JSON") 198 | client = OAuthAPIClient(self.base_url, self.client_id, self.client_secret) 199 | 200 | with self.assertRaises(requests.RequestException): 201 | client._ensure_authentication() # pylint: disable=protected-access 202 | 203 | @responses.activate 204 | def test_access_token_bad_response_code(self): 205 | responses.add(responses.POST, 206 | self.base_url + '/oauth2/access_token', 207 | status=500, 208 | json={}) 209 | client = OAuthAPIClient(self.base_url, self.client_id, self.client_secret) 210 | with self.assertRaises(requests.HTTPError): 211 | client._ensure_authentication() # pylint: disable=protected-access 212 | 213 | @responses.activate 214 | def test_get_jwt_access_token(self): 215 | token = 'abcd' 216 | self._mock_auth_api(self.base_url + '/oauth2/access_token', 200, {'access_token': token, 'expires_in': 60}) 217 | client = OAuthAPIClient(self.base_url, self.client_id, self.client_secret) 218 | access_token = client.get_jwt_access_token() 219 | self.assertEqual(access_token, token) 220 | 221 | @responses.activate 222 | @mock.patch('crum.get_current_request') 223 | def test_request_id_forwarding(self, mock_crum_get_current_request): 224 | request_id = 'a-fake-request-id' 225 | mock_request = mock.MagicMock() 226 | mock_request.headers.get.return_value = request_id 227 | mock_crum_get_current_request.return_value = mock_request 228 | token = 'abcd' 229 | self._mock_auth_api(self.base_url + '/oauth2/access_token', 200, {'access_token': token, 'expires_in': 60}) 230 | client = OAuthAPIClient(self.base_url, self.client_id, self.client_secret) 231 | post_url = self.base_url + '/oauth2/access_token' 232 | responses.add(responses.POST, 233 | post_url, 234 | status=200, 235 | json={}) 236 | response = client.post(post_url, data={'test': 'ok'}) 237 | assert response.request.headers.get('X-Request-ID') == request_id 238 | 239 | @responses.activate 240 | @mock.patch('crum.get_current_request') 241 | def test_request_id_forwarding_no_id(self, mock_crum_get_current_request): 242 | mock_request = mock.MagicMock() 243 | mock_request.headers.get.return_value = None 244 | mock_crum_get_current_request.return_value = mock_request 245 | token = 'abcd' 246 | self._mock_auth_api(self.base_url + '/oauth2/access_token', 200, {'access_token': token, 'expires_in': 60}) 247 | client = OAuthAPIClient(self.base_url, self.client_id, self.client_secret) 248 | post_url = self.base_url + '/oauth2/access_token' 249 | responses.add(responses.POST, 250 | post_url, 251 | status=200, 252 | json={}) 253 | response = client.post(post_url, data={'test': 'ok'}) 254 | assert response.request.headers.get('X-Request-ID') is None 255 | 256 | @responses.activate 257 | @mock.patch('crum.get_current_request') 258 | def test_request_id_forwarding_no_request(self, mock_crum_get_current_request): 259 | mock_crum_get_current_request.return_value = None 260 | token = 'abcd' 261 | self._mock_auth_api(self.base_url + '/oauth2/access_token', 200, {'access_token': token, 'expires_in': 60}) 262 | client = OAuthAPIClient(self.base_url, self.client_id, self.client_secret) 263 | post_url = self.base_url + '/oauth2/access_token' 264 | responses.add(responses.POST, 265 | post_url, 266 | status=200, 267 | json={}) 268 | response = client.post(post_url, data={'test': 'ok'}) 269 | assert response.request.headers.get('X-Request-ID') is None 270 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Django administration utility. 4 | """ 5 | 6 | import os 7 | import sys 8 | 9 | PWD = os.path.abspath(os.path.dirname(__file__)) 10 | 11 | if __name__ == '__main__': 12 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings') 13 | sys.path.append(PWD) 14 | try: 15 | from django.core.management import execute_from_command_line # pylint: disable=wrong-import-position 16 | except ImportError: 17 | # The above import may fail for some other reason. Ensure that the 18 | # issue is really that Django is missing to avoid masking other 19 | # exceptions. 20 | try: 21 | import django # pylint: disable=unused-import, wrong-import-position 22 | except ImportError: 23 | raise ImportError( 24 | "Couldn't import Django. Are you sure it's installed and " 25 | "available on your PYTHONPATH environment variable? Did you " 26 | "forget to activate a virtual environment?" 27 | ) 28 | raise 29 | execute_from_command_line(sys.argv) 30 | -------------------------------------------------------------------------------- /openedx.yaml: -------------------------------------------------------------------------------- 1 | oeps: 2 | oep-7: true 3 | oep-18: true 4 | 5 | tags: 6 | - library 7 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | # *************************** 2 | # ** DO NOT EDIT THIS FILE ** 3 | # *************************** 4 | # 5 | # This file was generated by edx-lint: https://github.com/openedx/edx-lint 6 | # 7 | # If you want to change this file, you have two choices, depending on whether 8 | # you want to make a local change that applies only to this repo, or whether 9 | # you want to make a central change that applies to all repos using edx-lint. 10 | # 11 | # Note: If your pylintrc file is simply out-of-date relative to the latest 12 | # pylintrc in edx-lint, ensure you have the latest edx-lint installed 13 | # and then follow the steps for a "LOCAL CHANGE". 14 | # 15 | # LOCAL CHANGE: 16 | # 17 | # 1. Edit the local pylintrc_tweaks file to add changes just to this 18 | # repo's file. 19 | # 20 | # 2. Run: 21 | # 22 | # $ edx_lint write pylintrc 23 | # 24 | # 3. This will modify the local file. Submit a pull request to get it 25 | # checked in so that others will benefit. 26 | # 27 | # 28 | # CENTRAL CHANGE: 29 | # 30 | # 1. Edit the pylintrc file in the edx-lint repo at 31 | # https://github.com/openedx/edx-lint/blob/master/edx_lint/files/pylintrc 32 | # 33 | # 2. install the updated version of edx-lint (in edx-lint): 34 | # 35 | # $ pip install . 36 | # 37 | # 3. Run (in edx-lint): 38 | # 39 | # $ edx_lint write pylintrc 40 | # 41 | # 4. Make a new version of edx_lint, submit and review a pull request with the 42 | # pylintrc update, and after merging, update the edx-lint version and 43 | # publish the new version. 44 | # 45 | # 5. In your local repo, install the newer version of edx-lint. 46 | # 47 | # 6. Run: 48 | # 49 | # $ edx_lint write pylintrc 50 | # 51 | # 7. This will modify the local file. Submit a pull request to get it 52 | # checked in so that others will benefit. 53 | # 54 | # 55 | # 56 | # 57 | # 58 | # STAY AWAY FROM THIS FILE! 59 | # 60 | # 61 | # 62 | # 63 | # 64 | # SERIOUSLY. 65 | # 66 | # ------------------------------ 67 | # Generated by edx-lint version: 5.4.1 68 | # ------------------------------ 69 | [MASTER] 70 | ignore = 71 | persistent = yes 72 | load-plugins = edx_lint.pylint 73 | 74 | [MESSAGES CONTROL] 75 | enable = 76 | blacklisted-name, 77 | line-too-long, 78 | 79 | abstract-class-instantiated, 80 | abstract-method, 81 | access-member-before-definition, 82 | anomalous-backslash-in-string, 83 | anomalous-unicode-escape-in-string, 84 | arguments-differ, 85 | assert-on-tuple, 86 | assigning-non-slot, 87 | assignment-from-no-return, 88 | assignment-from-none, 89 | attribute-defined-outside-init, 90 | bad-except-order, 91 | bad-format-character, 92 | bad-format-string-key, 93 | bad-format-string, 94 | bad-open-mode, 95 | bad-reversed-sequence, 96 | bad-staticmethod-argument, 97 | bad-str-strip-call, 98 | bad-super-call, 99 | binary-op-exception, 100 | boolean-datetime, 101 | catching-non-exception, 102 | cell-var-from-loop, 103 | confusing-with-statement, 104 | continue-in-finally, 105 | dangerous-default-value, 106 | duplicate-argument-name, 107 | duplicate-bases, 108 | duplicate-except, 109 | duplicate-key, 110 | expression-not-assigned, 111 | format-combined-specification, 112 | format-needs-mapping, 113 | function-redefined, 114 | global-variable-undefined, 115 | import-error, 116 | import-self, 117 | inconsistent-mro, 118 | inherit-non-class, 119 | init-is-generator, 120 | invalid-all-object, 121 | invalid-format-index, 122 | invalid-length-returned, 123 | invalid-sequence-index, 124 | invalid-slice-index, 125 | invalid-slots-object, 126 | invalid-slots, 127 | invalid-unary-operand-type, 128 | logging-too-few-args, 129 | logging-too-many-args, 130 | logging-unsupported-format, 131 | lost-exception, 132 | method-hidden, 133 | misplaced-bare-raise, 134 | misplaced-future, 135 | missing-format-argument-key, 136 | missing-format-attribute, 137 | missing-format-string-key, 138 | no-member, 139 | no-method-argument, 140 | no-name-in-module, 141 | no-self-argument, 142 | no-value-for-parameter, 143 | non-iterator-returned, 144 | non-parent-method-called, 145 | nonexistent-operator, 146 | not-a-mapping, 147 | not-an-iterable, 148 | not-callable, 149 | not-context-manager, 150 | not-in-loop, 151 | pointless-statement, 152 | pointless-string-statement, 153 | raising-bad-type, 154 | raising-non-exception, 155 | redefined-builtin, 156 | redefined-outer-name, 157 | redundant-keyword-arg, 158 | repeated-keyword, 159 | return-arg-in-generator, 160 | return-in-init, 161 | return-outside-function, 162 | signature-differs, 163 | super-init-not-called, 164 | super-method-not-called, 165 | syntax-error, 166 | test-inherits-tests, 167 | too-few-format-args, 168 | too-many-format-args, 169 | too-many-function-args, 170 | translation-of-non-string, 171 | truncated-format-string, 172 | undefined-all-variable, 173 | undefined-loop-variable, 174 | undefined-variable, 175 | unexpected-keyword-arg, 176 | unexpected-special-method-signature, 177 | unpacking-non-sequence, 178 | unreachable, 179 | unsubscriptable-object, 180 | unsupported-binary-operation, 181 | unsupported-membership-test, 182 | unused-format-string-argument, 183 | unused-format-string-key, 184 | used-before-assignment, 185 | using-constant-test, 186 | yield-outside-function, 187 | 188 | astroid-error, 189 | fatal, 190 | method-check-failed, 191 | parse-error, 192 | raw-checker-failed, 193 | 194 | empty-docstring, 195 | invalid-characters-in-docstring, 196 | missing-docstring, 197 | wrong-spelling-in-comment, 198 | wrong-spelling-in-docstring, 199 | 200 | unused-argument, 201 | unused-import, 202 | unused-variable, 203 | 204 | eval-used, 205 | exec-used, 206 | 207 | bad-classmethod-argument, 208 | bad-mcs-classmethod-argument, 209 | bad-mcs-method-argument, 210 | bare-except, 211 | broad-except, 212 | consider-iterating-dictionary, 213 | consider-using-enumerate, 214 | global-at-module-level, 215 | global-variable-not-assigned, 216 | literal-used-as-attribute, 217 | logging-format-interpolation, 218 | logging-not-lazy, 219 | multiple-imports, 220 | multiple-statements, 221 | no-classmethod-decorator, 222 | no-staticmethod-decorator, 223 | protected-access, 224 | redundant-unittest-assert, 225 | reimported, 226 | simplifiable-if-statement, 227 | simplifiable-range, 228 | singleton-comparison, 229 | superfluous-parens, 230 | unidiomatic-typecheck, 231 | unnecessary-lambda, 232 | unnecessary-pass, 233 | unnecessary-semicolon, 234 | unneeded-not, 235 | useless-else-on-loop, 236 | wrong-assert-type, 237 | 238 | deprecated-method, 239 | deprecated-module, 240 | 241 | too-many-boolean-expressions, 242 | too-many-nested-blocks, 243 | too-many-statements, 244 | 245 | wildcard-import, 246 | wrong-import-order, 247 | wrong-import-position, 248 | 249 | missing-final-newline, 250 | mixed-line-endings, 251 | trailing-newlines, 252 | trailing-whitespace, 253 | unexpected-line-ending-format, 254 | 255 | bad-inline-option, 256 | bad-option-value, 257 | deprecated-pragma, 258 | unrecognized-inline-option, 259 | useless-suppression, 260 | disable = 261 | bad-indentation, 262 | broad-exception-raised, 263 | consider-using-f-string, 264 | duplicate-code, 265 | file-ignored, 266 | fixme, 267 | global-statement, 268 | invalid-name, 269 | locally-disabled, 270 | no-else-return, 271 | suppressed-message, 272 | too-few-public-methods, 273 | too-many-ancestors, 274 | too-many-arguments, 275 | too-many-branches, 276 | too-many-instance-attributes, 277 | too-many-lines, 278 | too-many-locals, 279 | too-many-public-methods, 280 | too-many-return-statements, 281 | ungrouped-imports, 282 | unspecified-encoding, 283 | unused-wildcard-import, 284 | use-maxsplit-arg, 285 | 286 | feature-toggle-needs-doc, 287 | illegal-waffle-usage, 288 | 289 | logging-fstring-interpolation, 290 | missing-docstring, 291 | consider-using-f-string, 292 | missing-timeout, 293 | too-many-positional-arguments 294 | 295 | [REPORTS] 296 | output-format = text 297 | reports = no 298 | score = no 299 | 300 | [BASIC] 301 | module-rgx = (([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 302 | const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$ 303 | class-rgx = [A-Z_][a-zA-Z0-9]+$ 304 | function-rgx = ([a-z_][a-z0-9_]{2,40}|test_[a-z0-9_]+)$ 305 | method-rgx = ([a-z_][a-z0-9_]{2,40}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ 306 | attr-rgx = [a-z_][a-z0-9_]{2,30}$ 307 | argument-rgx = [a-z_][a-z0-9_]{2,30}$ 308 | variable-rgx = [a-z_][a-z0-9_]{2,30}$ 309 | class-attribute-rgx = ([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 310 | inlinevar-rgx = [A-Za-z_][A-Za-z0-9_]*$ 311 | good-names = f,i,j,k,db,ex,Run,_,__ 312 | bad-names = foo,bar,baz,toto,tutu,tata 313 | no-docstring-rgx = __.*__$|test_.+|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$ 314 | docstring-min-length = 5 315 | 316 | [FORMAT] 317 | max-line-length = 120 318 | ignore-long-lines = ^\s*(# )?((?)|(\.\. \w+: .*))$ 319 | single-line-if-stmt = no 320 | max-module-lines = 1000 321 | indent-string = ' ' 322 | 323 | [MISCELLANEOUS] 324 | notes = FIXME,XXX,TODO 325 | 326 | [SIMILARITIES] 327 | min-similarity-lines = 4 328 | ignore-comments = yes 329 | ignore-docstrings = yes 330 | ignore-imports = no 331 | 332 | [TYPECHECK] 333 | ignore-mixin-members = yes 334 | ignored-classes = SQLObject,responses 335 | unsafe-load-any-extension = yes 336 | generated-members = 337 | REQUEST, 338 | acl_users, 339 | aq_parent, 340 | objects, 341 | DoesNotExist, 342 | can_read, 343 | can_write, 344 | get_url, 345 | size, 346 | content, 347 | status_code, 348 | create, 349 | build, 350 | fields, 351 | tag, 352 | org, 353 | course, 354 | category, 355 | name, 356 | revision, 357 | _meta, 358 | 359 | [VARIABLES] 360 | init-import = no 361 | dummy-variables-rgx = _|dummy|unused|.*_unused 362 | additional-builtins = 363 | 364 | [CLASSES] 365 | defining-attr-methods = __init__,__new__,setUp 366 | valid-classmethod-first-arg = cls 367 | valid-metaclass-classmethod-first-arg = mcs 368 | 369 | [DESIGN] 370 | max-args = 5 371 | ignored-argument-names = _.* 372 | max-locals = 15 373 | max-returns = 6 374 | max-branches = 12 375 | max-statements = 50 376 | max-parents = 7 377 | max-attributes = 7 378 | min-public-methods = 2 379 | max-public-methods = 20 380 | 381 | [IMPORTS] 382 | deprecated-modules = regsub,TERMIOS,Bastion,rexec 383 | import-graph = 384 | ext-import-graph = 385 | int-import-graph = 386 | 387 | [EXCEPTIONS] 388 | overgeneral-exceptions = builtins.Exception 389 | 390 | # 1cd25098a0ed6e5e6b5c6b52acb7517fe98154da 391 | -------------------------------------------------------------------------------- /pylintrc_tweaks: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | load-plugins = edx_lint.pylint 3 | 4 | [MESSAGES CONTROL] 5 | DISABLE+= 6 | missing-docstring, 7 | consider-using-f-string, 8 | missing-timeout, 9 | too-many-positional-arguments 10 | 11 | [TYPECHECK] 12 | ignored-classes+= ,responses 13 | -------------------------------------------------------------------------------- /requirements/base.in: -------------------------------------------------------------------------------- 1 | # Core requirements for using this package template 2 | -c constraints.txt 3 | 4 | edx-django-utils 5 | requests 6 | PyJWT 7 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | asgiref==3.8.1 8 | # via django 9 | certifi==2025.1.31 10 | # via requests 11 | cffi==1.17.1 12 | # via pynacl 13 | charset-normalizer==3.4.1 14 | # via requests 15 | click==8.1.8 16 | # via edx-django-utils 17 | django==4.2.20 18 | # via 19 | # -c requirements/common_constraints.txt 20 | # django-crum 21 | # django-waffle 22 | # edx-django-utils 23 | django-crum==0.7.9 24 | # via edx-django-utils 25 | django-waffle==4.2.0 26 | # via edx-django-utils 27 | edx-django-utils==7.2.0 28 | # via -r requirements/base.in 29 | idna==3.10 30 | # via requests 31 | newrelic==10.7.0 32 | # via edx-django-utils 33 | pbr==6.1.1 34 | # via stevedore 35 | psutil==7.0.0 36 | # via edx-django-utils 37 | pycparser==2.22 38 | # via cffi 39 | pyjwt==2.10.1 40 | # via -r requirements/base.in 41 | pynacl==1.5.0 42 | # via edx-django-utils 43 | requests==2.32.3 44 | # via -r requirements/base.in 45 | sqlparse==0.5.3 46 | # via django 47 | stevedore==5.4.1 48 | # via edx-django-utils 49 | urllib3==2.2.3 50 | # via 51 | # -c requirements/common_constraints.txt 52 | # requests 53 | 54 | # The following packages are considered to be unsafe in a requirements file: 55 | setuptools==76.1.0 56 | # via pbr 57 | -------------------------------------------------------------------------------- /requirements/ci.in: -------------------------------------------------------------------------------- 1 | # Requirements for running tests in Travis 2 | -c constraints.txt 3 | 4 | tox # Virtualenv management for tests 5 | -------------------------------------------------------------------------------- /requirements/ci.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | cachetools==5.5.2 8 | # via tox 9 | chardet==5.2.0 10 | # via tox 11 | colorama==0.4.6 12 | # via tox 13 | distlib==0.3.9 14 | # via virtualenv 15 | filelock==3.18.0 16 | # via 17 | # tox 18 | # virtualenv 19 | packaging==24.2 20 | # via 21 | # pyproject-api 22 | # tox 23 | platformdirs==4.3.6 24 | # via 25 | # tox 26 | # virtualenv 27 | pluggy==1.5.0 28 | # via tox 29 | pyproject-api==1.9.0 30 | # via tox 31 | tox==4.24.2 32 | # via -r requirements/ci.in 33 | virtualenv==20.29.3 34 | # via tox 35 | -------------------------------------------------------------------------------- /requirements/common_constraints.txt: -------------------------------------------------------------------------------- 1 | # This is a temporary solution to override the real common_constraints.txt 2 | # In edx-lint, until the pyjwt constraint in edx-lint has been removed. 3 | # See BOM-2721 for more details. 4 | # Below is the copied and edited version of common_constraints 5 | 6 | # A central location for most common version constraints 7 | # (across edx repos) for pip-installation. 8 | # 9 | # Similar to other constraint files this file doesn't install any packages. 10 | # It specifies version constraints that will be applied if a package is needed. 11 | # When pinning something here, please provide an explanation of why it is a good 12 | # idea to pin this package across all edx repos, Ideally, link to other information 13 | # that will help people in the future to remove the pin when possible. 14 | # Writing an issue against the offending project and linking to it here is good. 15 | # 16 | # Note: Changes to this file will automatically be used by other repos, referencing 17 | # this file from Github directly. It does not require packaging in edx-lint. 18 | 19 | # using LTS django version 20 | Django<5.0 21 | 22 | # elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process. 23 | # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html 24 | # See https://github.com/openedx/edx-platform/issues/35126 for more info 25 | elasticsearch<7.14.0 26 | 27 | # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected 28 | django-simple-history==3.0.0 29 | 30 | # Cause: https://github.com/openedx/edx-lint/issues/458 31 | # This can be unpinned once https://github.com/openedx/edx-lint/issues/459 has been resolved. 32 | pip<24.3 33 | 34 | # Cause: https://github.com/openedx/edx-lint/issues/475 35 | # This can be unpinned once https://github.com/openedx/edx-lint/issues/476 has been resolved. 36 | urllib3<2.3.0 37 | -------------------------------------------------------------------------------- /requirements/constraints.txt: -------------------------------------------------------------------------------- 1 | # Version constraints for pip-installation. 2 | # 3 | # This file doesn't install any packages. It specifies version constraints 4 | # that will be applied if a package is needed. 5 | # 6 | # When pinning something here, please provide an explanation of why. Ideally, 7 | # link to other information that will help people in the future to remove the 8 | # pin when possible. Writing an issue against the offending project and 9 | # linking to it here is good. 10 | 11 | # Common constraints for edx repos 12 | -c common_constraints.txt 13 | -------------------------------------------------------------------------------- /requirements/dev.in: -------------------------------------------------------------------------------- 1 | # Additional requirements for development of this template 2 | -c constraints.txt 3 | 4 | -r pip-tools.txt # pip-tools and its dependencies, for managing requirements files 5 | -r test.txt # Dependencies for testing and quality checks 6 | -r ci.txt # tox and associated dependencies 7 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | asgiref==3.8.1 8 | # via 9 | # -r requirements/test.txt 10 | # django 11 | astroid==3.3.9 12 | # via 13 | # -r requirements/test.txt 14 | # pylint 15 | # pylint-celery 16 | backports-tarfile==1.2.0 17 | # via 18 | # -r requirements/test.txt 19 | # jaraco-context 20 | build==1.2.2.post1 21 | # via 22 | # -r requirements/pip-tools.txt 23 | # pip-tools 24 | cachetools==5.5.2 25 | # via 26 | # -r requirements/ci.txt 27 | # tox 28 | certifi==2025.1.31 29 | # via 30 | # -r requirements/test.txt 31 | # requests 32 | cffi==1.17.1 33 | # via 34 | # -r requirements/test.txt 35 | # cryptography 36 | # pynacl 37 | chardet==5.2.0 38 | # via 39 | # -r requirements/ci.txt 40 | # tox 41 | charset-normalizer==3.4.1 42 | # via 43 | # -r requirements/test.txt 44 | # requests 45 | click==8.1.8 46 | # via 47 | # -r requirements/pip-tools.txt 48 | # -r requirements/test.txt 49 | # click-log 50 | # code-annotations 51 | # edx-django-utils 52 | # edx-lint 53 | # pip-tools 54 | click-log==0.4.0 55 | # via 56 | # -r requirements/test.txt 57 | # edx-lint 58 | code-annotations==2.2.0 59 | # via 60 | # -r requirements/test.txt 61 | # edx-lint 62 | colorama==0.4.6 63 | # via 64 | # -r requirements/ci.txt 65 | # tox 66 | coverage[toml]==7.7.0 67 | # via 68 | # -r requirements/test.txt 69 | # pytest-cov 70 | cryptography==44.0.2 71 | # via 72 | # -r requirements/test.txt 73 | # secretstorage 74 | ddt==1.7.2 75 | # via -r requirements/test.txt 76 | dill==0.3.9 77 | # via 78 | # -r requirements/test.txt 79 | # pylint 80 | distlib==0.3.9 81 | # via 82 | # -r requirements/ci.txt 83 | # virtualenv 84 | django==4.2.20 85 | # via 86 | # -c requirements/common_constraints.txt 87 | # -r requirements/test.txt 88 | # django-crum 89 | # django-waffle 90 | # edx-django-utils 91 | django-crum==0.7.9 92 | # via 93 | # -r requirements/test.txt 94 | # edx-django-utils 95 | django-waffle==4.2.0 96 | # via 97 | # -r requirements/test.txt 98 | # edx-django-utils 99 | docutils==0.21.2 100 | # via 101 | # -r requirements/test.txt 102 | # readme-renderer 103 | edx-django-utils==7.2.0 104 | # via -r requirements/test.txt 105 | edx-lint==5.6.0 106 | # via -r requirements/test.txt 107 | filelock==3.17.0 108 | # via 109 | # -r requirements/ci.txt 110 | # tox 111 | # virtualenv 112 | freezegun==1.5.1 113 | # via -r requirements/test.txt 114 | id==1.5.0 115 | # via 116 | # -r requirements/test.txt 117 | # twine 118 | idna==3.10 119 | # via 120 | # -r requirements/test.txt 121 | # requests 122 | importlib-metadata==8.6.1 123 | # via 124 | # -r requirements/test.txt 125 | # keyring 126 | iniconfig==2.0.0 127 | # via 128 | # -r requirements/test.txt 129 | # pytest 130 | isort==6.0.1 131 | # via 132 | # -r requirements/test.txt 133 | # pylint 134 | jaraco-classes==3.4.0 135 | # via 136 | # -r requirements/test.txt 137 | # keyring 138 | jaraco-context==6.0.1 139 | # via 140 | # -r requirements/test.txt 141 | # keyring 142 | jaraco-functools==4.1.0 143 | # via 144 | # -r requirements/test.txt 145 | # keyring 146 | jeepney==0.9.0 147 | # via 148 | # -r requirements/test.txt 149 | # keyring 150 | # secretstorage 151 | jinja2==3.1.6 152 | # via 153 | # -r requirements/test.txt 154 | # code-annotations 155 | keyring==25.6.0 156 | # via 157 | # -r requirements/test.txt 158 | # twine 159 | markdown-it-py==3.0.0 160 | # via 161 | # -r requirements/test.txt 162 | # rich 163 | markupsafe==3.0.2 164 | # via 165 | # -r requirements/test.txt 166 | # jinja2 167 | mccabe==0.7.0 168 | # via 169 | # -r requirements/test.txt 170 | # pylint 171 | mdurl==0.1.2 172 | # via 173 | # -r requirements/test.txt 174 | # markdown-it-py 175 | more-itertools==10.6.0 176 | # via 177 | # -r requirements/test.txt 178 | # jaraco-classes 179 | # jaraco-functools 180 | newrelic==10.7.0 181 | # via 182 | # -r requirements/test.txt 183 | # edx-django-utils 184 | nh3==0.2.21 185 | # via 186 | # -r requirements/test.txt 187 | # readme-renderer 188 | packaging==24.2 189 | # via 190 | # -r requirements/ci.txt 191 | # -r requirements/pip-tools.txt 192 | # -r requirements/test.txt 193 | # build 194 | # pyproject-api 195 | # pytest 196 | # tox 197 | # twine 198 | pbr==6.1.1 199 | # via 200 | # -r requirements/test.txt 201 | # stevedore 202 | pip-tools==7.4.1 203 | # via -r requirements/pip-tools.txt 204 | platformdirs==4.3.6 205 | # via 206 | # -r requirements/ci.txt 207 | # -r requirements/test.txt 208 | # pylint 209 | # tox 210 | # virtualenv 211 | pluggy==1.5.0 212 | # via 213 | # -r requirements/ci.txt 214 | # -r requirements/test.txt 215 | # pytest 216 | # tox 217 | psutil==7.0.0 218 | # via 219 | # -r requirements/test.txt 220 | # edx-django-utils 221 | pycodestyle==2.12.1 222 | # via -r requirements/test.txt 223 | pycparser==2.22 224 | # via 225 | # -r requirements/test.txt 226 | # cffi 227 | pygments==2.19.1 228 | # via 229 | # -r requirements/test.txt 230 | # readme-renderer 231 | # rich 232 | pyjwt==2.10.1 233 | # via -r requirements/test.txt 234 | pylint==3.3.5 235 | # via 236 | # -r requirements/test.txt 237 | # edx-lint 238 | # pylint-celery 239 | # pylint-django 240 | # pylint-plugin-utils 241 | pylint-celery==0.3 242 | # via 243 | # -r requirements/test.txt 244 | # edx-lint 245 | pylint-django==2.6.1 246 | # via 247 | # -r requirements/test.txt 248 | # edx-lint 249 | pylint-plugin-utils==0.8.2 250 | # via 251 | # -r requirements/test.txt 252 | # pylint-celery 253 | # pylint-django 254 | pynacl==1.5.0 255 | # via 256 | # -r requirements/test.txt 257 | # edx-django-utils 258 | pyproject-api==1.9.0 259 | # via 260 | # -r requirements/ci.txt 261 | # tox 262 | pyproject-hooks==1.2.0 263 | # via 264 | # -r requirements/pip-tools.txt 265 | # build 266 | # pip-tools 267 | pytest==8.3.5 268 | # via 269 | # -r requirements/test.txt 270 | # pytest-cov 271 | # pytest-django 272 | pytest-cov==6.0.0 273 | # via -r requirements/test.txt 274 | pytest-django==4.10.0 275 | # via -r requirements/test.txt 276 | python-dateutil==2.9.0.post0 277 | # via 278 | # -r requirements/test.txt 279 | # freezegun 280 | python-slugify==8.0.4 281 | # via 282 | # -r requirements/test.txt 283 | # code-annotations 284 | pyyaml==6.0.2 285 | # via 286 | # -r requirements/test.txt 287 | # code-annotations 288 | # responses 289 | readme-renderer==44.0 290 | # via 291 | # -r requirements/test.txt 292 | # twine 293 | requests==2.32.3 294 | # via 295 | # -r requirements/test.txt 296 | # id 297 | # requests-toolbelt 298 | # responses 299 | # twine 300 | requests-toolbelt==1.0.0 301 | # via 302 | # -r requirements/test.txt 303 | # twine 304 | responses==0.25.7 305 | # via -r requirements/test.txt 306 | rfc3986==2.0.0 307 | # via 308 | # -r requirements/test.txt 309 | # twine 310 | rich==13.9.4 311 | # via 312 | # -r requirements/test.txt 313 | # twine 314 | secretstorage==3.3.3 315 | # via 316 | # -r requirements/test.txt 317 | # keyring 318 | six==1.17.0 319 | # via 320 | # -r requirements/test.txt 321 | # edx-lint 322 | # python-dateutil 323 | sqlparse==0.5.3 324 | # via 325 | # -r requirements/test.txt 326 | # django 327 | stevedore==5.4.1 328 | # via 329 | # -r requirements/test.txt 330 | # code-annotations 331 | # edx-django-utils 332 | text-unidecode==1.3 333 | # via 334 | # -r requirements/test.txt 335 | # python-slugify 336 | tomlkit==0.13.2 337 | # via 338 | # -r requirements/test.txt 339 | # pylint 340 | tox==4.24.1 341 | # via -r requirements/ci.txt 342 | twine==6.1.0 343 | # via -r requirements/test.txt 344 | urllib3==2.2.3 345 | # via 346 | # -c requirements/common_constraints.txt 347 | # -r requirements/test.txt 348 | # requests 349 | # responses 350 | # twine 351 | virtualenv==20.29.2 352 | # via 353 | # -r requirements/ci.txt 354 | # tox 355 | wheel==0.45.1 356 | # via 357 | # -r requirements/pip-tools.txt 358 | # pip-tools 359 | zipp==3.21.0 360 | # via 361 | # -r requirements/test.txt 362 | # importlib-metadata 363 | 364 | # The following packages are considered to be unsafe in a requirements file: 365 | pip==24.2 366 | # via 367 | # -c requirements/common_constraints.txt 368 | # -r requirements/pip-tools.txt 369 | # pip-tools 370 | setuptools==76.1.0 371 | # via 372 | # -r requirements/pip-tools.txt 373 | # -r requirements/test.txt 374 | # pbr 375 | # pip-tools 376 | -------------------------------------------------------------------------------- /requirements/pip-tools.in: -------------------------------------------------------------------------------- 1 | # Just the dependencies to run pip-tools, mainly for the "upgrade" make target 2 | -c constraints.txt 3 | 4 | pip-tools # Contains pip-compile, used to generate pip requirements files 5 | -------------------------------------------------------------------------------- /requirements/pip-tools.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | build==1.2.2.post1 8 | # via pip-tools 9 | click==8.1.8 10 | # via pip-tools 11 | packaging==24.2 12 | # via build 13 | pip-tools==7.4.1 14 | # via -r requirements/pip-tools.in 15 | pyproject-hooks==1.2.0 16 | # via 17 | # build 18 | # pip-tools 19 | wheel==0.45.1 20 | # via pip-tools 21 | 22 | # The following packages are considered to be unsafe in a requirements file: 23 | pip==24.2 24 | # via 25 | # -c requirements/common_constraints.txt 26 | # pip-tools 27 | setuptools==76.1.0 28 | # via pip-tools 29 | -------------------------------------------------------------------------------- /requirements/pip.in: -------------------------------------------------------------------------------- 1 | # Core dependencies for installing other packages 2 | -c constraints.txt 3 | 4 | pip 5 | setuptools 6 | wheel 7 | -------------------------------------------------------------------------------- /requirements/pip.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | wheel==0.45.1 8 | # via -r requirements/pip.in 9 | 10 | # The following packages are considered to be unsafe in a requirements file: 11 | pip==24.2 12 | # via 13 | # -c requirements/common_constraints.txt 14 | # -r requirements/pip.in 15 | setuptools==76.1.0 16 | # via -r requirements/pip.in 17 | -------------------------------------------------------------------------------- /requirements/private.readme: -------------------------------------------------------------------------------- 1 | # If there are any Python packages you want to keep in your virtualenv beyond 2 | # those listed in the official requirements files, create a "private.in" file 3 | # and list them there. Generate the corresponding "private.txt" file pinning 4 | # all of their indirect dependencies to specific versions as follows: 5 | 6 | # pip-compile private.in 7 | 8 | # This allows you to use "pip-sync" without removing these packages: 9 | 10 | # pip-sync requirements/*.txt 11 | 12 | # "private.in" and "private.txt" aren't checked into git to avoid merge 13 | # conflicts, and the presence of this file allows "private.*" to be 14 | # included in scripted pip-sync usage without requiring that those files be 15 | # created first. 16 | -------------------------------------------------------------------------------- /requirements/test.in: -------------------------------------------------------------------------------- 1 | # Requirements for test runs 2 | -c constraints.txt 3 | 4 | -r base.txt # Core dependencies 5 | 6 | ddt 7 | edx-lint 8 | freezegun 9 | pycodestyle 10 | pytest-cov # pytest extension for code coverage statistics 11 | pytest-django # pytest extension for better Django support 12 | responses 13 | twine # to be able to run `twine check` as a part of 14 | # quality tests to detect packaging related issues 15 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # make upgrade 6 | # 7 | asgiref==3.8.1 8 | # via 9 | # -r requirements/base.txt 10 | # django 11 | astroid==3.3.9 12 | # via 13 | # pylint 14 | # pylint-celery 15 | backports-tarfile==1.2.0 16 | # via jaraco-context 17 | certifi==2025.1.31 18 | # via 19 | # -r requirements/base.txt 20 | # requests 21 | cffi==1.17.1 22 | # via 23 | # -r requirements/base.txt 24 | # cryptography 25 | # pynacl 26 | charset-normalizer==3.4.1 27 | # via 28 | # -r requirements/base.txt 29 | # requests 30 | click==8.1.8 31 | # via 32 | # -r requirements/base.txt 33 | # click-log 34 | # code-annotations 35 | # edx-django-utils 36 | # edx-lint 37 | click-log==0.4.0 38 | # via edx-lint 39 | code-annotations==2.2.0 40 | # via edx-lint 41 | coverage[toml]==7.7.0 42 | # via pytest-cov 43 | cryptography==44.0.2 44 | # via secretstorage 45 | ddt==1.7.2 46 | # via -r requirements/test.in 47 | dill==0.3.9 48 | # via pylint 49 | # via 50 | # -c requirements/common_constraints.txt 51 | # -r requirements/base.txt 52 | # django-crum 53 | # django-waffle 54 | # edx-django-utils 55 | django-crum==0.7.9 56 | # via 57 | # -r requirements/base.txt 58 | # edx-django-utils 59 | django-waffle==4.2.0 60 | # via 61 | # -r requirements/base.txt 62 | # edx-django-utils 63 | docutils==0.21.2 64 | # via readme-renderer 65 | edx-django-utils==7.2.0 66 | # via -r requirements/base.txt 67 | edx-lint==5.6.0 68 | # via -r requirements/test.in 69 | freezegun==1.5.1 70 | # via -r requirements/test.in 71 | id==1.5.0 72 | # via twine 73 | idna==3.10 74 | # via 75 | # -r requirements/base.txt 76 | # requests 77 | importlib-metadata==8.6.1 78 | # via keyring 79 | iniconfig==2.0.0 80 | # via pytest 81 | isort==6.0.1 82 | # via pylint 83 | jaraco-classes==3.4.0 84 | # via keyring 85 | jaraco-context==6.0.1 86 | # via keyring 87 | jaraco-functools==4.1.0 88 | # via keyring 89 | jeepney==0.9.0 90 | # via 91 | # keyring 92 | # secretstorage 93 | jinja2==3.1.6 94 | # via code-annotations 95 | keyring==25.6.0 96 | # via twine 97 | markdown-it-py==3.0.0 98 | # via rich 99 | markupsafe==3.0.2 100 | # via jinja2 101 | mccabe==0.7.0 102 | # via pylint 103 | mdurl==0.1.2 104 | # via markdown-it-py 105 | more-itertools==10.6.0 106 | # via 107 | # jaraco-classes 108 | # jaraco-functools 109 | newrelic==10.7.0 110 | # via 111 | # -r requirements/base.txt 112 | # edx-django-utils 113 | nh3==0.2.21 114 | # via readme-renderer 115 | packaging==24.2 116 | # via 117 | # pytest 118 | # twine 119 | pbr==6.1.1 120 | # via 121 | # -r requirements/base.txt 122 | # stevedore 123 | platformdirs==4.3.6 124 | # via pylint 125 | pluggy==1.5.0 126 | # via pytest 127 | psutil==7.0.0 128 | # via 129 | # -r requirements/base.txt 130 | # edx-django-utils 131 | pycodestyle==2.12.1 132 | # via -r requirements/test.in 133 | pycparser==2.22 134 | # via 135 | # -r requirements/base.txt 136 | # cffi 137 | pygments==2.19.1 138 | # via 139 | # readme-renderer 140 | # rich 141 | pyjwt==2.10.1 142 | # via -r requirements/base.txt 143 | pylint==3.3.5 144 | # via 145 | # edx-lint 146 | # pylint-celery 147 | # pylint-django 148 | # pylint-plugin-utils 149 | pylint-celery==0.3 150 | # via edx-lint 151 | pylint-django==2.6.1 152 | # via edx-lint 153 | pylint-plugin-utils==0.8.2 154 | # via 155 | # pylint-celery 156 | # pylint-django 157 | pynacl==1.5.0 158 | # via 159 | # -r requirements/base.txt 160 | # edx-django-utils 161 | pytest==8.3.5 162 | # via 163 | # pytest-cov 164 | # pytest-django 165 | pytest-cov==6.0.0 166 | # via -r requirements/test.in 167 | pytest-django==4.10.0 168 | # via -r requirements/test.in 169 | python-dateutil==2.9.0.post0 170 | # via freezegun 171 | python-slugify==8.0.4 172 | # via code-annotations 173 | pyyaml==6.0.2 174 | # via 175 | # code-annotations 176 | # responses 177 | readme-renderer==44.0 178 | # via twine 179 | requests==2.32.3 180 | # via 181 | # -r requirements/base.txt 182 | # id 183 | # requests-toolbelt 184 | # responses 185 | # twine 186 | requests-toolbelt==1.0.0 187 | # via twine 188 | responses==0.25.7 189 | # via -r requirements/test.in 190 | rfc3986==2.0.0 191 | # via twine 192 | rich==13.9.4 193 | # via twine 194 | secretstorage==3.3.3 195 | # via keyring 196 | six==1.17.0 197 | # via 198 | # edx-lint 199 | # python-dateutil 200 | sqlparse==0.5.3 201 | # via 202 | # -r requirements/base.txt 203 | # django 204 | stevedore==5.4.1 205 | # via 206 | # -r requirements/base.txt 207 | # code-annotations 208 | # edx-django-utils 209 | text-unidecode==1.3 210 | # via python-slugify 211 | tomlkit==0.13.2 212 | # via pylint 213 | twine==6.1.0 214 | # via -r requirements/test.in 215 | urllib3==2.2.3 216 | # via 217 | # -c requirements/common_constraints.txt 218 | # -r requirements/base.txt 219 | # requests 220 | # responses 221 | # twine 222 | zipp==3.21.0 223 | # via importlib-metadata 224 | 225 | # The following packages are considered to be unsafe in a requirements file: 226 | setuptools==76.1.0 227 | # via 228 | # -r requirements/base.txt 229 | # pbr 230 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Setup to allow pip installs of edx-rest-api-client module. 4 | """ 5 | 6 | import os 7 | import re 8 | 9 | from setuptools import find_packages, setup 10 | 11 | from edx_rest_api_client import __version__ 12 | 13 | with open('README.rst') as readme: 14 | long_description = readme.read() 15 | 16 | 17 | def load_requirements(*requirements_paths): 18 | """ 19 | Load all requirements from the specified requirements files. 20 | 21 | Requirements will include any constraints from files specified 22 | with -c in the requirements files. 23 | Returns a list of requirement strings. 24 | """ 25 | # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why. 26 | 27 | # e.g. {"django": "Django", "confluent-kafka": "confluent_kafka[avro]"} 28 | by_canonical_name = {} 29 | 30 | def check_name_consistent(package): 31 | """ 32 | Raise exception if package is named different ways. 33 | 34 | This ensures that packages are named consistently so we can match 35 | constraints to packages. It also ensures that if we require a package 36 | with extras we don't constrain it without mentioning the extras (since 37 | that too would interfere with matching constraints.) 38 | """ 39 | canonical = package.lower().replace('_', '-').split('[')[0] 40 | seen_spelling = by_canonical_name.get(canonical) 41 | if seen_spelling is None: 42 | by_canonical_name[canonical] = package 43 | elif seen_spelling != package: 44 | raise Exception( 45 | f'Encountered both "{seen_spelling}" and "{package}" in requirements ' 46 | 'and constraints files; please use just one or the other.' 47 | ) 48 | 49 | requirements = {} 50 | constraint_files = set() 51 | 52 | # groups "pkg<=x.y.z,..." into ("pkg", "<=x.y.z,...") 53 | re_package_name_base_chars = r"a-zA-Z0-9\-_." # chars allowed in base package name 54 | # Two groups: name[maybe,extras], and optionally a constraint 55 | requirement_line_regex = re.compile( 56 | r"([%s]+(?:\[[%s,\s]+\])?)([<>=][^#\s]+)?" 57 | % (re_package_name_base_chars, re_package_name_base_chars) 58 | ) 59 | 60 | def add_version_constraint_or_raise(current_line, current_requirements, add_if_not_present): 61 | regex_match = requirement_line_regex.match(current_line) 62 | if regex_match: 63 | package = regex_match.group(1) 64 | version_constraints = regex_match.group(2) 65 | check_name_consistent(package) 66 | existing_version_constraints = current_requirements.get(package, None) 67 | # It's fine to add constraints to an unconstrained package, 68 | # but raise an error if there are already constraints in place. 69 | if existing_version_constraints and existing_version_constraints != version_constraints: 70 | raise BaseException(f'Multiple constraint definitions found for {package}:' 71 | f' "{existing_version_constraints}" and "{version_constraints}".' 72 | f'Combine constraints into one location with {package}' 73 | f'{existing_version_constraints},{version_constraints}.') 74 | if add_if_not_present or package in current_requirements: 75 | current_requirements[package] = version_constraints 76 | 77 | # Read requirements from .in files and store the path to any 78 | # constraint files that are pulled in. 79 | for path in requirements_paths: 80 | with open(path) as reqs: 81 | for line in reqs: 82 | if is_requirement(line): 83 | add_version_constraint_or_raise(line, requirements, True) 84 | if line and line.startswith('-c') and not line.startswith('-c http'): 85 | constraint_files.add(os.path.dirname(path) + '/' + line.split('#')[0].replace('-c', '').strip()) 86 | 87 | # process constraint files: add constraints to existing requirements 88 | for constraint_file in constraint_files: 89 | with open(constraint_file) as reader: 90 | for line in reader: 91 | if is_requirement(line): 92 | add_version_constraint_or_raise(line, requirements, False) 93 | 94 | # process back into list of pkg><=constraints strings 95 | constrained_requirements = [f'{pkg}{version or ""}' for (pkg, version) in sorted(requirements.items())] 96 | return constrained_requirements 97 | 98 | 99 | def is_requirement(line): 100 | """ 101 | Return True if the requirement line is a package requirement. 102 | 103 | Returns: 104 | bool: True if the line is not blank, a comment, 105 | a URL, or an included file 106 | """ 107 | # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why 108 | 109 | return line and line.strip() and not line.startswith(('-r', '#', '-e', 'git+', '-c')) 110 | 111 | 112 | setup( 113 | name='edx-rest-api-client', 114 | version=__version__, 115 | description='Client utilities to access various Open edX Platform REST APIs.', 116 | long_description=long_description, 117 | long_description_content_type="text/x-rst", 118 | classifiers=[ 119 | 'Development Status :: 5 - Production/Stable', 120 | 'License :: OSI Approved :: Apache Software License', 121 | 'Programming Language :: Python', 122 | 'Programming Language :: Python :: 3.11', 123 | 'Programming Language :: Python :: 3.12', 124 | 'Framework :: Django', 125 | 'Framework :: Django :: 4.2', 126 | 'Framework :: Django :: 5.2', 127 | 'Topic :: Internet', 128 | 'Intended Audience :: Developers', 129 | 'Environment :: Web Environment', 130 | ], 131 | keywords='edx rest api client', 132 | url='https://github.com/openedx/edx-rest-api-client', 133 | author='edX', 134 | author_email='oscm@edx.org', 135 | license='Apache', 136 | packages=find_packages(exclude=['*.tests']), 137 | install_requires=load_requirements('requirements/base.in'), 138 | ) 139 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | These settings are here to use during tests, because django requires them. 3 | 4 | In a real-world use case, apps in this project are installed into other 5 | Django applications, so these settings will not be used. 6 | """ 7 | 8 | from os.path import abspath, dirname, join 9 | 10 | 11 | def root(*args): 12 | """ 13 | Get the absolute path of the given path relative to the project root. 14 | """ 15 | return join(abspath(dirname(__file__)), *args) 16 | 17 | 18 | DATABASES = { 19 | 'default': { 20 | 'ENGINE': 'django.db.backends.sqlite3', 21 | 'NAME': 'default.db', 22 | 'USER': '', 23 | 'PASSWORD': '', 24 | 'HOST': '', 25 | 'PORT': '', 26 | } 27 | } 28 | 29 | INSTALLED_APPS = ( 30 | 'django.contrib.auth', 31 | 'django.contrib.contenttypes', 32 | 'edx_rest_api_client', 33 | ) 34 | 35 | LOCALE_PATHS = [ 36 | root('edx_rest_api_client', 'conf', 'locale'), 37 | ] 38 | 39 | ROOT_URLCONF = 'edx_rest_api_client.urls' 40 | 41 | SECRET_KEY = 'insecure-secret-key' 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{311, 312}-django{42, 52}, quality 3 | 4 | [pytest] 5 | DJANGO_SETTINGS_MODULE = test_settings 6 | addopts = --cov edx_rest_api_client --cov-report term-missing --cov-report xml 7 | norecursedirs = .* docs requirements 8 | 9 | [testenv] 10 | deps = 11 | setuptools 12 | wheel 13 | django42: Django>=4.2,<4.3 14 | django52: Django>=5.2,<5.3 15 | -r{toxinidir}/requirements/test.txt 16 | commands = 17 | pytest {posargs} 18 | 19 | [testenv:quality] 20 | setenv = 21 | DJANGO_SETTINGS_MODULE = test_settings 22 | PYTHONPATH = . 23 | commands = 24 | pycodestyle --config=.pep8 edx_rest_api_client 25 | pylint --rcfile=pylintrc edx_rest_api_client 26 | python setup.py bdist_wheel 27 | twine check dist/* 28 | --------------------------------------------------------------------------------