├── .github ├── cdk │ ├── .gitignore │ ├── cdkactions.yaml │ ├── main.ts │ ├── package.json │ ├── tsconfig.json │ └── yarn.lock └── workflows │ ├── cdkactions_build-and-publish.yaml │ └── cdkactions_validate.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── api.md ├── index.md ├── installation.md ├── mixin.md ├── router.md ├── signals.md ├── testing.md └── usage.md ├── manage.py ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── rest_live ├── __init__.py ├── apps.py ├── consumers.py ├── mixins.py ├── routers.py ├── signals.py └── testing.py ├── test_app ├── __init__.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── serializers.py └── views.py ├── tests ├── settings.py ├── test_live.py └── utils.py ├── tox.ini └── version.sh /.github/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | main.js 3 | main.d.ts 4 | -------------------------------------------------------------------------------- /.github/cdk/cdkactions.yaml: -------------------------------------------------------------------------------- 1 | language: typescript 2 | app: node main.js 3 | -------------------------------------------------------------------------------- /.github/cdk/main.ts: -------------------------------------------------------------------------------- 1 | // import dedent from 'ts-dedent'; 2 | import { App } from "cdkactions"; 3 | import { PyPIPublishStack } from "@pennlabs/kraken"; 4 | 5 | const app = new App(); 6 | new PyPIPublishStack(app); 7 | 8 | app.synth(); 9 | -------------------------------------------------------------------------------- /.github/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "main": "main.js", 5 | "types": "main.ts", 6 | "license": "Apache-2.0", 7 | "private": true, 8 | "scripts": { 9 | "synth": "cdkactions synth", 10 | "compile": "tsc", 11 | "watch": "tsc -w", 12 | "build": "yarn compile && yarn synth", 13 | "upgrade-cdk": "yarn upgrade cdkactions@latest cdkactions-cli@latest" 14 | }, 15 | "dependencies": { 16 | "@pennlabs/kraken": "^0.7.1", 17 | "cdkactions": "^0.2.3", 18 | "constructs": "^3.2.109" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^16.11.11", 22 | "cdkactions-cli": "^0.2.3", 23 | "typescript": "^4.5.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "charset": "utf8", 5 | "declaration": true, 6 | "experimentalDecorators": true, 7 | "inlineSourceMap": true, 8 | "inlineSources": true, 9 | "lib": [ 10 | "es2018" 11 | ], 12 | "module": "CommonJS", 13 | "noEmitOnError": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitAny": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "strictPropertyInitialization": true, 24 | "stripInternal": true, 25 | "target": "ES2018" 26 | }, 27 | "include": [ 28 | "**/*.ts" 29 | ], 30 | "exclude": [ 31 | "node_modules" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.github/cdk/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@pennlabs/kraken@^0.7.1": 6 | version "0.7.1" 7 | resolved "https://registry.yarnpkg.com/@pennlabs/kraken/-/kraken-0.7.1.tgz#cd7c09a83a7a868b03d98ae416c71015d2f1d338" 8 | integrity sha512-ZJjsm2TY6JO9Ucv74DtAg9lEJQHtbMBTGCek4yweIMWQCXlXv3J4eBKyfz1yUei+CITs1CTSY7qSIZoBwFvR2Q== 9 | dependencies: 10 | cdkactions "^0.2.0" 11 | constructs "^3.2.80" 12 | ts-dedent "^2.2.0" 13 | 14 | "@types/node@^16.11.11": 15 | version "16.11.14" 16 | resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.14.tgz#4939fb42e5b0ffb3ea7e193c28244fe7414977a6" 17 | integrity sha512-mK6BKLpL0bG6v2CxHbm0ed6RcZrAtTHBTd/ZpnlVPVa3HkumsqLE4BC4u6TQ8D7pnrRbOU0am6epuALs+Ncnzw== 18 | 19 | ansi-regex@^5.0.1: 20 | version "5.0.1" 21 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" 22 | integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== 23 | 24 | ansi-styles@^4.0.0: 25 | version "4.3.0" 26 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" 27 | integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== 28 | dependencies: 29 | color-convert "^2.0.1" 30 | 31 | argparse@^2.0.1: 32 | version "2.0.1" 33 | resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" 34 | integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== 35 | 36 | cdkactions-cli@^0.2.3: 37 | version "0.2.3" 38 | resolved "https://registry.yarnpkg.com/cdkactions-cli/-/cdkactions-cli-0.2.3.tgz#2393682b37ab0b04c6964160b393e8d71b08118f" 39 | integrity sha512-qYPbzuQ1M5gQGa8NRnaWwm3iXmdqMoiHR7YTh6oYROpfBGER7kwBBb6ydFlSwKK62hE0B++by43hbEBXlHvr8A== 40 | dependencies: 41 | cdkactions "^0.2.3" 42 | constructs "^3.2.109" 43 | fs-extra "^8.1.0" 44 | sscaff "^1.2.0" 45 | yaml "^1.10.0" 46 | yargs "^16.2.0" 47 | 48 | cdkactions@^0.2.0, cdkactions@^0.2.3: 49 | version "0.2.3" 50 | resolved "https://registry.yarnpkg.com/cdkactions/-/cdkactions-0.2.3.tgz#aa27bf720962376d54f8ef95cdfb0ab46458b966" 51 | integrity sha512-/DYQ2qsT6fzgZB+cmQjtPqR4aAWCqAytWbFpJK+iJLQ4jQrl6l4uMf01TLiWY3mAILS0YGlwPcoBbGvq9Jnz5g== 52 | dependencies: 53 | js-yaml "^4.0.0" 54 | ts-dedent "^2.0.0" 55 | 56 | cliui@^7.0.2: 57 | version "7.0.4" 58 | resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" 59 | integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== 60 | dependencies: 61 | string-width "^4.2.0" 62 | strip-ansi "^6.0.0" 63 | wrap-ansi "^7.0.0" 64 | 65 | color-convert@^2.0.1: 66 | version "2.0.1" 67 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" 68 | integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== 69 | dependencies: 70 | color-name "~1.1.4" 71 | 72 | color-name@~1.1.4: 73 | version "1.1.4" 74 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" 75 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 76 | 77 | constructs@^3.2.109, constructs@^3.2.80: 78 | version "3.3.166" 79 | resolved "https://registry.yarnpkg.com/constructs/-/constructs-3.3.166.tgz#0d8ff31366cbe3c15df6f7aeba15b4d5e81f0087" 80 | integrity sha512-vhFswEqFb5BRkeYbWPd66A+BtvSSSdRI/1TYNwetC2reJul+ztI40vK9l2CNx1Vi/EOAQp1qjjjTEg+29irtYA== 81 | 82 | emoji-regex@^8.0.0: 83 | version "8.0.0" 84 | resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" 85 | integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== 86 | 87 | escalade@^3.1.1: 88 | version "3.1.1" 89 | resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" 90 | integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== 91 | 92 | fs-extra@^8.1.0: 93 | version "8.1.0" 94 | resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" 95 | integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== 96 | dependencies: 97 | graceful-fs "^4.2.0" 98 | jsonfile "^4.0.0" 99 | universalify "^0.1.0" 100 | 101 | get-caller-file@^2.0.5: 102 | version "2.0.5" 103 | resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" 104 | integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== 105 | 106 | graceful-fs@^4.1.6, graceful-fs@^4.2.0: 107 | version "4.2.8" 108 | resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" 109 | integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== 110 | 111 | is-fullwidth-code-point@^3.0.0: 112 | version "3.0.0" 113 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" 114 | integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== 115 | 116 | js-yaml@^4.0.0: 117 | version "4.1.0" 118 | resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" 119 | integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== 120 | dependencies: 121 | argparse "^2.0.1" 122 | 123 | jsonfile@^4.0.0: 124 | version "4.0.0" 125 | resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" 126 | integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= 127 | optionalDependencies: 128 | graceful-fs "^4.1.6" 129 | 130 | require-directory@^2.1.1: 131 | version "2.1.1" 132 | resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" 133 | integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= 134 | 135 | sscaff@^1.2.0: 136 | version "1.2.151" 137 | resolved "https://registry.yarnpkg.com/sscaff/-/sscaff-1.2.151.tgz#de527e36cc90cb0fbd011db27820bda855637a13" 138 | integrity sha512-6UfOVW4ElZDCutLaPUULDA/RdtV5zs2qsmSVkg47mE0WrGXmaG/vC/4k668/s+MbpGjCCW+dytbmNkcduREEJw== 139 | 140 | string-width@^4.1.0, string-width@^4.2.0: 141 | version "4.2.3" 142 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 143 | integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 144 | dependencies: 145 | emoji-regex "^8.0.0" 146 | is-fullwidth-code-point "^3.0.0" 147 | strip-ansi "^6.0.1" 148 | 149 | strip-ansi@^6.0.0, strip-ansi@^6.0.1: 150 | version "6.0.1" 151 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 152 | integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 153 | dependencies: 154 | ansi-regex "^5.0.1" 155 | 156 | ts-dedent@^2.0.0, ts-dedent@^2.2.0: 157 | version "2.2.0" 158 | resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" 159 | integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== 160 | 161 | typescript@^4.5.2: 162 | version "4.5.4" 163 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8" 164 | integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg== 165 | 166 | universalify@^0.1.0: 167 | version "0.1.2" 168 | resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" 169 | integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== 170 | 171 | wrap-ansi@^7.0.0: 172 | version "7.0.0" 173 | resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 174 | integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== 175 | dependencies: 176 | ansi-styles "^4.0.0" 177 | string-width "^4.1.0" 178 | strip-ansi "^6.0.0" 179 | 180 | y18n@^5.0.5: 181 | version "5.0.8" 182 | resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" 183 | integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== 184 | 185 | yaml@^1.10.0: 186 | version "1.10.2" 187 | resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" 188 | integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== 189 | 190 | yargs-parser@^20.2.2: 191 | version "20.2.9" 192 | resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" 193 | integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== 194 | 195 | yargs@^16.2.0: 196 | version "16.2.0" 197 | resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" 198 | integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== 199 | dependencies: 200 | cliui "^7.0.2" 201 | escalade "^3.1.1" 202 | get-caller-file "^2.0.5" 203 | require-directory "^2.1.1" 204 | string-width "^4.2.0" 205 | y18n "^5.0.5" 206 | yargs-parser "^20.2.2" 207 | -------------------------------------------------------------------------------- /.github/workflows/cdkactions_build-and-publish.yaml: -------------------------------------------------------------------------------- 1 | # Generated by cdkactions. Do not modify 2 | # Generated as part of the 'pypi' stack. 3 | name: Build and Publish 4 | on: 5 | push: 6 | branches: 7 | - "**" 8 | tags: 9 | - "[0-9]+.[0-9]+.[0-9]+" 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: 16 | - "3.7" 17 | - "3.8" 18 | - "3.9" 19 | - "3.10" 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: pip install poetry tox tox-gh-actions codecov 28 | - name: Test 29 | run: tox 30 | - name: Upload Code Coverage 31 | run: codecov 32 | publish: 33 | runs-on: ubuntu-latest 34 | container: 35 | image: python:3.8 36 | needs: test 37 | if: startsWith(github.ref, 'refs/tags') 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Install dependencies 41 | run: pip install poetry 42 | - name: Verify tag 43 | shell: bash 44 | run: |- 45 | GIT_TAG=${GITHUB_REF/refs\/tags\//} 46 | LIBRARY_VERSION=$(poetry version -s) 47 | if [[ "$GIT_TAG" != "$LIBRARY_VERSION" ]]; then echo "Tag ($GIT_TAG) does not match poetry version ($LIBRARY_VERSION)"; exit 1; fi 48 | - name: Build 49 | run: poetry build 50 | - name: Publish 51 | run: poetry publish 52 | env: 53 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_PASSWORD }} 54 | -------------------------------------------------------------------------------- /.github/workflows/cdkactions_validate.yaml: -------------------------------------------------------------------------------- 1 | # Generated by cdkactions. Do not modify 2 | # Generated as part of the 'validate' stack. 3 | name: Validate cdkactions manifests 4 | on: push 5 | jobs: 6 | validate: 7 | name: Validate cdkactions manifests 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | token: ${{ github.token }} 13 | - name: Validate manifests 14 | run: |- 15 | cd .github/cdk 16 | yarn install 17 | yarn build 18 | git --no-pager diff ../workflows 19 | git diff-index --quiet HEAD -- ../workflows 20 | - name: Push updated manifests 21 | if: "false" 22 | run: |- 23 | cd .github/workflows 24 | git config user.name github-actions 25 | git config user.email github-actions[bot]@users.noreply.github.com 26 | git add . 27 | git commit -m "Update cdkactions manifests" || exit 0 28 | git push 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEs and OSs 2 | .vscode 3 | .idea 4 | .DS_Store 5 | 6 | # Python 7 | build/ 8 | .coverage 9 | dist/ 10 | *.egg-info 11 | *.pyc 12 | __pycache__/ 13 | .tox 14 | test-results/ 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 0.7.0 (2022-02-13) 4 | ------------------ 5 | * Support for database deletes 6 | 7 | 0.3.0 (2020-10-23) 8 | ------------------ 9 | * Improve efficiency by remove a blanket signal connect 10 | * Fix extraneous context switching 11 | 12 | 0.2.2 (2020-09-13) 13 | ------------------ 14 | * Initial working version 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Penn Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Django REST Live 2 | 3 | [![Documentation](https://readthedocs.org/projects/django-rest-live/badge/?version=latest)](https://django-rest-live.readthedocs.io/en/latest/?badge=latest) 4 | [![CircleCI](https://circleci.com/gh/pennlabs/django-rest-live.svg?style=shield)](https://circleci.com/gh/pennlabs/django-rest-live) 5 | [![Coverage Status](https://codecov.io/gh/pennlabs/django-rest-live/branch/master/graph/badge.svg)](https://codecov.io/gh/pennlabs/django-rest-live) 6 | [![PyPi Package](https://img.shields.io/pypi/v/django-rest-live.svg)](https://pypi.org/project/django-rest-live/) 7 | 8 | Django REST Live enables clients which use an API built with [Django REST Framework](https://github.com/encode/django-rest-framework) to receive a stream of updates for querysets and model instances over a websocket connection managed by [Django Channels](https://github.com/django/channels). There had been plans for real-time websocket support in REST Framework on a few occasions ([2016](https://www.django-rest-framework.org/community/mozilla-grant/#realtime-apis), [2018](https://groups.google.com/g/django-rest-framework/c/3-QNn3SYlZI/m/Gwx6rFr4BQAJ?pli=1)), but at the time, async support in Django was in the early planning stages and Channels was being [rewritten with breaking API changes](https://channels.readthedocs.io/en/2.x/one-to-two.html). 9 | 10 | This plugin aims to bridge that gap between Channels and REST Framework while being as generic and boilerplate-free as possible. Clients are be able to subscribe to real-time updates for any queryset that's exposed through a [Generic API View](https://www.django-rest-framework.org/api-guide/generic-views/#genericapiview) or any of its subclasses, including [Model ViewSet](https://www.django-rest-framework.org/api-guide/viewsets/#modelviewset), with just one mixin! 11 | 12 | Check out [the full tutorial and reference documentation](https://django-rest-live.readthedocs.io) for specifics. 13 | 14 | ### Dependencies 15 | 16 | - [Django](https://github.com/django/django/) (3.1 and up) 17 | - [Django Channels](https://github.com/django/channels) (2.x and 3.x both supported) 18 | - [Django REST Framework](https://github.com/encode/django-rest-framework/) (3.11 and up) 19 | - [`channels_redis`](https://github.com/django/channels_redis) for 20 | [channel layer](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) support in production. Channel layers is what allows a Django signal to broadcast an update to all websocket clients. 21 | 22 | ### Installation and Setup 23 | 24 | Make sure to [install and properly set up Django Channels](https://channels.readthedocs.io/en/latest/installation.html) before installing `django-rest-live`. 25 | 26 | ``` 27 | pip install django-rest-live 28 | ``` 29 | 30 | Add `rest_live` to your `INSTALLED_APPS`: 31 | 32 | ```python 33 | INSTALLED_APPS = [ 34 | ... 35 | "rest_framework", 36 | "channels", 37 | "rest_live", 38 | ] 39 | ``` 40 | 41 | Create a `RealtimeRouter` in your ASGI routing file (generally `asgi.py`) and add the router's consumer to the websocket routing you set up with Django Channels. Feel free to choose any URL endpoint for the websocket, here we've chosen `/ws/subscribe/`. 42 | 43 | ```python 44 | from channels.auth import AuthMiddlewareStack 45 | from channels.routing import ProtocolTypeRouter, URLRouter 46 | from django.urls import path 47 | from django.core.asgi import get_asgi_application 48 | from rest_live.routers import RealtimeRouter 49 | 50 | router = RealtimeRouter() 51 | 52 | application = ProtocolTypeRouter({ 53 | "http": get_asgi_application(), 54 | "websocket": AuthMiddlewareStack( 55 | URLRouter([ 56 | path("ws/subscribe/", router.as_consumer().as_asgi(), name="subscriptions"), 57 | ]) 58 | ), 59 | }) 60 | ``` 61 | 62 | ### Configuration 63 | 64 | > Check out the [Tutorial](https://django-rest-live.readthedocs.io/en/latest/usage/) for an in-depth example. 65 | 66 | To allow subscriptions to a queryset, add the `RealtimeMixin` to a GenericAPIView or ModelViewSet that exposes that queryset. Then, register the view with the `RealtimeRouter` instance you created during setup. 67 | 68 | ```python 69 | ... 70 | router = RealtimeRouter() 71 | router.register(MyViewSet) # Register all ViewSets here 72 | ... 73 | ``` 74 | 75 | ### Client-Side 76 | 77 | Subscribing to a updates equires opening a [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) 78 | on the client connection to the URL you specified during setup. Feel free to use any frontend web framework you'd like. Below is a simple example in vanilla JavaScript which logs updates to the console. 79 | 80 | ```javascript 81 | const socket = new WebSocket("ws:///ws/subscribe"); 82 | 83 | socket.addEventListener("message", function (event) { 84 | console.log("Update received:", JSON.parse(event.data)); 85 | }); 86 | 87 | // Subscribe to updates for the model instance with the ID of 1. 88 | socket.send( 89 | JSON.stringify({ 90 | id: 1337, 91 | type: "subscribe", 92 | model: "appname.ModelName", 93 | action: "retrieve", 94 | lookup_by: 1, 95 | }) 96 | ); 97 | 98 | // Subscribe to updates for every model in the queryset. 99 | socket.send( 100 | JSON.stringify({ 101 | id: 1338, 102 | type: "subscribe", 103 | model: "appname.ModelName", 104 | action: "list", 105 | }) 106 | ); 107 | 108 | // After 5 seconds, unsubscribe from updates for the single model instance with ID 1. 109 | setTimeout(5 * 1000, () => 110 | socket.sent( 111 | JSON.stringify({ 112 | type: "unsubscribe", 113 | id: 1337, 114 | }) 115 | ) 116 | ); 117 | ``` 118 | 119 | Broadcast updates will be sent from the server in this format: 120 | 121 | ```json 122 | { 123 | "type": "broadcast", 124 | "id": 1337, 125 | "model": "appname.ModelName", 126 | "action": "UPDATED", 127 | "instance": { "id": 1, "field1": "value1", "field2": "value2" } 128 | } 129 | ``` 130 | 131 | This is only a basic example. For more details, including how to send arguments and parameters along with subscriptions, read the [Tutorial](https://django-rest-live.readthedocs.io/en/latest/usage/) and the [Websocket API Reference](https://django-rest-live.readthedocs.io/en/latest/api/). 132 | 133 | ### Closing Notes 134 | 135 | `django-rest-live` took initial inspiration from [this article by Kit La Touche](https://www.oddbird.net/2018/12/12/channels-and-drf/). 136 | Differently from projects like [`djangochannelsrestframework`](https://github.com/hishnash/djangochannelsrestframework), 137 | `django-rest-live` does not aim to supplant REST Framework for performing CRUD actions through a REST API. Instead, 138 | it is designed to be used in conjunction with HTTP REST endpoints. Clients should still use normal REST framework 139 | endpoints generated by ViewSets and other API views to get initial data to populate a page, as well as any write-driven 140 | behavior (`POST`, `PATCH`, `PUT`, `DELETE`). `django-rest-live` gets rid of the need for periodic polling GET 141 | requests to for resource updates after page load. 142 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # Websocket API 2 | ## General format 3 | Messages are sent from over the websocket connection 4 | as JSON strings. The easiest way to generate these is to construct an object 5 | in JavaScript and pass it to `JSON.stringify`. Incoming messages can be parsed with `JSON.parse`. 6 | 7 | All messages have a `type` property which determines all other properties 8 | accessible on the message 9 | and a `id` which specifies which request it pertains to. It's 10 | important to remember that this is an asynchronous API, and so responses 11 | may arrive out-of-order – clients should rely on the `id` property to 12 | match requests and responses. The details of each type are explained below. 13 | 14 | ### Errors 15 | Errors have the following form: 16 | 17 | - `type` (_string_) – Always `"error"`. 18 | - `id` (_string_) – The request ID from the message which prompted the error. 19 | - `code` (_number_) – Code identifying the error message. Generally modeled after HTTP status codes. 20 | - `message` (_string_) – Description of the error. 21 | 22 | An error with code `400` will be sent if an unknown message `type` is sent. 23 | 24 | ## Subscription Request 25 | Subscription requests are sent from the client over the websocket connection with the server. 26 | It has the following properties: 27 | 28 | - `type` (_string_) – Designates a message as a subscription request; should always be `"subscribe"`. 29 | - `id` (_number_) – Identifier for this subscription request. Should be unique 30 | per-connection. Broadcasts from the server, unsubscribe requests, and error messages 31 | all refer to this request ID. 32 | - `model` (_string_) – The Django model you want to subscribe to, in standard 33 | `appname.ModelName` format. 34 | - `action` (_string_) – The viewset action you'd like to subscribe to; 35 | Must be either `"retrieve"` or `"list"`. 36 | * `"retrieve"` subscriptions only broadcast updates for a single model instance. 37 | * `"list"` subscriptions will broadcast updates for every instance within the queryset 38 | specified in the view's `get_queryset()` method or `queryset` property. 39 | - `lookup_by` (_string or number_) – Only defined on `retrieve` subscriptions. The value of the `lookup_field` 40 | on the instance to be subscribed to. 41 | - `view_kwargs` (_object_) – View keyword arguments to be passed along to the view when processing 42 | subscriptions. See [here](https://docs.djangoproject.com/en/3.1/topics/http/urls/#how-django-processes-a-request) 43 | for information on keyword arguments. Optional; defaults to `{}`. 44 | 45 | – `query_params` (_object_) – `GET` parameters to be accessible on the view. 46 | See [Django documentation](https://docs.djangoproject.com/en/3.1/ref/request-response/#django.http.HttpRequest.GET) 47 | and [DRF documentation](https://www.django-rest-framework.org/api-guide/requests/#query_params). 48 | Optional; defaults to `{}`. Note that parameters must be URL serializable. 49 | 50 | ### Error Codes 51 | - `400`: Some required field is missing or not properly specified in the request. 52 | Example: no `model` field, or a non-standard `action`. 53 | - `403`: Unauthorized to perform subscription based on 54 | [permissions](https://www.django-rest-framework.org/api-guide/permissions/) on the view. 55 | - `404`: Resource not found. Could either be that no view is registered for a given model, 56 | or no model instance found with the `lookup_by` field in the view's queryset. 57 | 58 | 59 | ## Broadcast 60 | Broadcasts are sent from the server when model instances update. 61 | 62 | - `type` (_string_) – Always `"broadcast"` 63 | - `id` (_string_) – ID of the request which subscribed to this broadcast. 64 | - `model`: (_string_) – Model label for model this broadcast refers to. 65 | - `action`: (_string_) – One of `"CREATED"`, `"UPDATED"`, or `"DELETED"`. 66 | New objects and objects which are updated so that they enter the queryset 67 | are marked as `"CREATED"`, and objects which are updated so that they leave 68 | the queryset are marked as `"DELETED"`. 69 | - `instance`: (_object_) – The serialized model instance that this broadcast 70 | refers to. Only present with `CREATED` and `UPDATED` actions. Serializer 71 | determined from `get_serializer_class()` on the view. 72 | 73 | 74 | ## Unsubscribe 75 | Unsubscribe requests are sent from the client. 76 | 77 | - `type` (_string_) – Always `"unsubscribe"`. 78 | - `id` (_number_) – Original request ID for the subscription to unsubscribe from. 79 | 80 | ### Error Codes 81 | - `404`: No subscription with the provided request ID could be found. 82 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Django REST Live documentation 2 | 3 | `django-rest-live` adds real-time subscriptions over websockets to [Django REST Framework](https://github.com/encode/django-rest-framework) 4 | views by leveraging websocket support provided by [Django Channels](https://github.com/django/channels). 5 | 6 | ## Inspiration and Goals 7 | `django-rest-live` took initial inspiration from [this article by Kit La Touche](https://www.oddbird.net/2018/12/12/channels-and-drf/). 8 | 9 | The goal of this project is to be as close as possible to a drop-in realtime solution for projects already 10 | using Django REST Framework. 11 | 12 | Differently from projects like [`djangochannelsrestframework`](https://github.com/hishnash/djangochannelsrestframework), 13 | `django-rest-live` does not aim to supplant REST Framework for performing CRUD actions through a REST API. Instead, 14 | it is designed to be used in conjunction with HTTP REST endpoints. Clients should still use normal REST framework 15 | endpoints generated by ViewSets and other API views to get initial data to populate a page, as well as any write-driven 16 | behavior (`POST`, `PATCH`, `PUT`, `DELETE`). `django-rest-live` gets rid of the need for periodic polling GET 17 | requests to for resource updates after page load. 18 | 19 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Dependencies 4 | 5 | - [Django](https://github.com/django/django/) (3.1 and up) 6 | - [Django Channels](https://github.com/django/channels) (2.x and 3.x both supported) 7 | - [Django REST Framework](https://github.com/encode/django-rest-framework/) (3.11 and up) 8 | - [`channels_redis`](https://github.com/django/channels_redis) for 9 | [channel layer](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) support in production. 10 | 11 | ## Set Up 12 | 13 | If you haven't 14 | [installed and properly configured Django Channels](https://channels.readthedocs.io/en/latest/installation.html), 15 | then make sure to do that before continuing on with Django REST Live. 16 | 17 | 1. Add `rest_live` to your `INSTALLED_APPS`. 18 | 19 | ```python 20 | INSTALLED_APPS = [ 21 | # Any other django apps 22 | "rest_framework", 23 | "channels", 24 | "rest_live", 25 | ] 26 | ``` 27 | 28 | 2. Create a `RealtimeRouter` in your ASGI routing file and add `router.consumer` to the websocket routing you set up with Django Channels. Feel free to choose any URL path, here we've chosen `/ws/subscribe/`. 29 | 30 | ```python 31 | from channels.auth import AuthMiddlewareStack 32 | from channels.routing import ProtocolTypeRouter, URLRouter 33 | from django.urls import path 34 | from django.core.asgi import get_asgi_application 35 | from rest_live.routers import RealtimeRouter 36 | 37 | router = RealtimeRouter() 38 | 39 | application = ProtocolTypeRouter({ 40 | "http": get_asgi_application(), 41 | "websocket": AuthMiddlewareStack( 42 | URLRouter([ 43 | path("ws/subscribe/", router.as_consumer().as_asgi(), name="subscriptions"), 44 | ]) 45 | ), 46 | }) 47 | ``` 48 | 49 | > Note: if using Channels version 2, omit the `as_asgi()` method. 50 | 51 | That's it! You're now ready to configure and use `django-rest-live`. 52 | -------------------------------------------------------------------------------- /docs/mixin.md: -------------------------------------------------------------------------------- 1 | # RealtimeMixin 2 | 3 | `rest_live.mixins.RealtimeMixin` marks a Django REST Framework 4 | Generic APIView as realtime capable. `RealtimeMixin` was designed to work with any subclass class of 5 | [`rest_framework.generics.GenericAPIView`](https://www.django-rest-framework.org/api-guide/generic-views/#genericapiview), 6 | like `ListAPIView`, `RetrieveAPIView`, and `ModelViewSet`. 7 | 8 | These are the View properties and methods used by the `RealtimeMixin`: 9 | 10 | - `lookup_field` (defaults to `pk` in DRF) 11 | - `queryset` 12 | * Even if `get_queryset()` is defined, `queryset` must 13 | also be defined so that the [`RealtimeRouter`](router.md) 14 | can determine the underlying model class at register-time. 15 | If you use `get_queryset` to dynamically filter the queryset 16 | in your view, you should also define an empty "sentinel" queryset 17 | on the view of the form `Model.queryset.none()`. This is 18 | what's recommended for other [parts of REST Framework](https://www.django-rest-framework.org/api-guide/permissions/#using-with-views-that-do-not-include-a-queryset-attribute) 19 | which require knowledge of a view's backing model. 20 | - `get_serializer_class()` or `serializer_class` 21 | - `permission_classes` or `get_permissions()` 22 | -------------------------------------------------------------------------------- /docs/router.md: -------------------------------------------------------------------------------- 1 | # RealtimeRouter 2 | 3 | The `RealtimeRouter` object builds up mappings between DRF views and Django models, registers 4 | signal handlers to get save and delete signals, and generates a channels `Consumer` subclass 5 | to handle incoming websocket requests. Multiple `RealtimeRouter`s can be instantiated in the same application 6 | to be handled by separate consumers. 7 | 8 | ## API 9 | 10 | ### `router.register(view)` 11 | Where `view` is a Generic APIView or ViewSet which inherits from 12 | [`RealtimeMixin`](mixin.md). Only one APIView can be registered for any given 13 | model; a `RuntimeWarning` will be raised if yoou try to register 14 | two views to the same router that have the same underlying model for their 15 | `queryset` 16 | 17 | ### `as_consumer()` 18 | Returns a subclass of `Consumer` that is set up to receive subscriptions and 19 | send broadcasts 20 | 21 | -------------------------------------------------------------------------------- /docs/signals.md: -------------------------------------------------------------------------------- 1 | # A note on Django signals 2 | This package works by listening in on model lifecycle events sent off by Django's [signal dispatcher](https://docs.djangoproject.com/en/3.1/topics/signals/). 3 | Specifically, the [`post_save`](https://docs.djangoproject.com/en/3.1/ref/signals/#post-save) 4 | and [`post_delete`](https://docs.djangoproject.com/en/3.1/ref/signals/#post-delete) signals. This means that `django-rest-live` 5 | can only pick up changes that Django knows about. Bulk operations, like `filter().update()`, `bulk_create` 6 | and `bulk_delete` do not trigger Django's lifecycle signals, so updates will not be sent. 7 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | As of Django 3.1, you can write asynchronous tests in Django `TestCase`s. You can set up a test case by following 3 | the snippet below, using the test communicator provided in `rest_live.testing.APICommunicator`. 4 | 5 | ## Sample TestCase 6 | 7 | ```python 8 | from django.test import TransactionTestCase 9 | from app.routing import application # Replace this line with the import to your ASGI router. 10 | from channels.db import database_sync_to_async 11 | from rest_live.testing import APICommunicator 12 | 13 | class MyTests(TransactionTestCase): 14 | async def test_subscribe(self): 15 | client = APICommunicator(application, "/ws/subscribe/") 16 | connected, _ = await self.client.connect() 17 | self.assertTrue(connected) 18 | await client.send_json_to( 19 | { 20 | "type": "subscribe", 21 | "id": 1337, 22 | "model": "app.Model", 23 | "action": "retrieve", 24 | "lookup_by": "1", 25 | } 26 | ) 27 | self.assertTrue(await client.receive_nothing()) 28 | await database_sync_to_async(Model.objects.create)(...) 29 | response = await client.receive_json_from() 30 | self.assertEqual(response, { 31 | "type": "broadcast", 32 | "id": 1337, 33 | "model": "app.Model", 34 | "instance": { "": "..." }, 35 | "action": "CREATED", 36 | }) 37 | await client.disconnect() 38 | ``` 39 | 40 | Since REST Live makes use of the database for its functionality, make sure to use `django.test.TransactionTestCase` 41 | instead of `django.test.TestCase` so that database connections within the async test functions get cleaned up approprately. 42 | 43 | Remember to wrap all ORM calls in the `database_sync_to_async` decorator as demonstrated in the above example. The ORM 44 | is still fully synchronous, and the regular `sync_to_async` decorator does not properly clean up connections! 45 | 46 | ## setUp and tearDown 47 | The normal `TestCase.setUp` and `TestCase.tearDown` methods run in different threads from the actual test itself, 48 | and so they don't work for creating async objects like `WebsocketCommunicator`. REST Live comes with a decorator called 49 | `@async_test` which will enable test cases to define lifecycle methods `asyncSetUp()` and `asyncTearDown()` to 50 | run certain code before and after every test case decorated with `@async_test`. Here is an example: 51 | 52 | ```python 53 | ... 54 | from rest_live.testing import APICommunicator, async_test 55 | class MyTests(TransactionTestCase): 56 | 57 | async def asyncSetUp(self): 58 | self.client = APICommunicator(application, "/ws/subscribe/") 59 | connected, _ = await self.client.connect() 60 | self.assertTrue(connected) 61 | 62 | async def asyncTearDown(self): 63 | await self.client.disconnect() 64 | 65 | @async_test 66 | async def test_subscribe(self): 67 | ... # a new connection has been opened and is accessible in `self.client` 68 | ``` 69 | 70 | ## Authentication 71 | Make sure to follow the below pattern if you use `request.user` or `request.session` anywhere in your View code. 72 | 73 | Authentication in unit tests for django channels is a bit tricky, but the utility that `rest_live` provides 74 | is based on this [github issue comment](https://github.com/django/channels/issues/903#issuecomment-365735926). 75 | 76 | The `WebsocketCommunicator` class can take HTTP headers as part of its constructor. In order to open a connection 77 | as a logged-in user, you can use `rest_live.testing.get_headers_for_user`: 78 | 79 | ```python 80 | from rest_live.testing import get_headers_for_user 81 | 82 | user = await database_sync_to_async(User.objects.create_user)(username="test") 83 | headers = await get_headers_for_user(user) 84 | client = APICommunicator(appliction, "/ws/subscribe/", headers) 85 | ... 86 | ``` 87 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Tutorial 2 | 3 | This page will use an example to-do app called `todolist` with the following models and serializers: 4 | 5 | ```python 6 | # todolist/models.py 7 | from django.db import models 8 | 9 | class List(models.Model): 10 | name = models.CharField(max_length=64) 11 | 12 | class Task(models.Model): 13 | text = models.CharField(max_length=140) 14 | done = models.BooleanField(default=False) 15 | list = models.ForeignKey("List", on_delete=models.CASCADE) 16 | 17 | # todolist/serializers.py 18 | from rest_framework import serializers 19 | 20 | class TaskSerializer(serializers.ModelSerializer): 21 | class Meta: 22 | model = Task 23 | fields = ["id", "text", "done"] 24 | 25 | class TodoListSerializer(serializers.ModelSerializer): 26 | tasks = TaskSerializer(many=True, read_only=True) 27 | class Meta: 28 | model = List 29 | fields = ["id", "name", "tasks"] 30 | ``` 31 | 32 | ## Server-side setup 33 | 34 | `django-rest-live` extends the existing generic API views using a mixin called `RealtimeMixin`. In order to 35 | designate your view as realtime-capable, add `RealtimeMixin` to its superclasses: 36 | 37 | ```python 38 | from rest_framework.viewsets import ModelViewSet 39 | from rest_live.mixins import RealtimeMixin 40 | 41 | class TaskViewSet(ModelViewSet, RealtimeMixin): 42 | queryset = Task.objects.all() 43 | serializer_class = TaskSerializer 44 | ``` 45 | 46 | Note that throughout this documentation we use `ViewSet`s as our base class. It's important to note that `django-rest-live` 47 | works just as well with any [generic view](https://www.django-rest-framework.org/api-guide/generic-views/) 48 | that defines a `queryset` attribute along with either a `serializer_class` atttribute or a 49 | [`get_serializer_class()`](https://www.django-rest-framework.org/api-guide/generic-views/#attributes) method. 50 | 51 | Just like [parts of REST Framework](https://www.django-rest-framework.org/api-guide/permissions/#using-with-views-that-do-not-include-a-queryset-attribute) 52 | which require knowledge of a backing model for a view, `RealtimeMixin` requires that you have a `queryset` attribute 53 | defined on your view, even if you have overridden the `get_queryset()` method. The DRF solution, recommended here as 54 | well, is to define an empty "sentinel" queryset on the view that `RealtimeMixin` can use to determine the model: 55 | 56 | ```python 57 | from rest_framework.viewsets import ModelViewSet 58 | from rest_live.mixins import RealtimeMixin 59 | 60 | class FilteredTaskViewSet(ModelViewSet, RealtimeMixin): 61 | serializer_class = TaskSerializer 62 | queryset = Task.objects.none() # Empty queryset indicating the backing model for this view 63 | 64 | def get_queryset(self): # Actual queryset for the view 65 | return Task.objects.filter(user=self.request.user) 66 | ``` 67 | 68 | The last backend step is to register your View in the `RealtimeRouter` you defined in the first setup step: 69 | 70 | ```python 71 | from rest_live.routers import RealtimeRouter 72 | 73 | router = RealtimeRouter() 74 | router.register(TaskViewSet) # Register all ViewSets here 75 | 76 | websockets = AuthMiddlewareStack( 77 | URLRouter([ 78 | path("ws/subscribe/", router.as_consumer().as_asgi(), name="subscriptions"), 79 | # Other routing here... 80 | ]) 81 | ``` 82 | 83 | > Note: if using Channels version 2, omit the `as_asgi()` method. 84 | 85 | ## Subscribing to single instances 86 | 87 | Subscribing to a updates equires opening a [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) 88 | on the client connection to the URL you specified during setup. In our example case, that URL is `/ws/subscribe/`. After the connection 89 | is established, send a JSON message (using `JSON.stringify()`) in this format: 90 | 91 | ```json 92 | { 93 | "type": "subscribe", 94 | "id": 1337, 95 | "model": "todolist.Task", 96 | "action": "retrieve", 97 | "lookup_by": 1 98 | } 99 | ``` 100 | 101 | You should generate the `id` client side. It's used to track the subscription you request throughout 102 | its lifetime, so it should be unique for this connection. We'll see how it's referenced both in error messages and broadcasts. 103 | 104 | The model label should be in Django's standard `app.modelname` format. `lookup_by` should be the value of the 105 | [lookup field](https://www.django-rest-framework.org/api-guide/generic-views/#attributes) for the model instance 106 | we're subscribing to. Since this defaults to [`pk`](https://docs.djangoproject.com/en/3.1/topics/db/queries/#the-pk-lookup-shortcut), 107 | it's the conceptual equivalent of subscribing to the instance which would be returned from 108 | `Task.objects.filter(pk=)`. 109 | 110 | The client should make RESTful HTTP requests for resources to determine which IDs it wants to 111 | subscribe to; there's no capability for querying built in to the Websocket API, just subscriptions and broadcasts. 112 | 113 | When the Task with primary key `1` updates, a message in this format will be sent over the websocket: 114 | 115 | ```json 116 | { 117 | "type": "broadcast", 118 | "id": 1337, 119 | "model": "test_app.Todo", 120 | "action": "UPDATED", 121 | "instance": { "id": 1, "text": "test", "done": true } 122 | } 123 | ``` 124 | 125 | Valid `action` values are `UPDATED`, `CREATED`, and `DELETED`. `instance` is the JSON-serialized model instance 126 | using the serializer defined in the view's `serializer_class` attribute or returned from the `get_serializer_class` 127 | method. 128 | 129 | Unsubscribing is even simpler – simply pass the original request `id` along in a websocket message: 130 | 131 | ```json 132 | { 133 | "type": "unsubscribe", 134 | "id": 1337 135 | } 136 | ``` 137 | 138 | ## Subscribing to lists 139 | 140 | Being attached to a generic view with a `get_queryset()` method, you can also subscribe to updates to a view's queryset. 141 | The subscription looks like this: 142 | 143 | ```json 144 | { 145 | "id": 1338, 146 | "type": "subscribe", 147 | "model": "todolist.Task", 148 | "action": "list" 149 | } 150 | ``` 151 | 152 | Note that `lookup_by` isn't used here since we're referring to the whole queryset. You'll receive broadcasts in the same 153 | format as shown above. 154 | 155 | `CREATE` and `DELETE` actions are not the actual create and delete actions in the database, but are relative to their 156 | inclusion in the view's queryset. If an instance is created, or is modified such that it is now included in the queryset 157 | when it wasn't before, the action in the broadcast will be `CREATED`. If the instance is modified so that it is no 158 | longer included in the queryset, the action in the broadcast will be `DELETED`. 159 | 160 | Note that `DELETED` actions can't be triggered from actual deletions from the database at this time. 161 | 162 | ## `request.user` and `request.session` 163 | 164 | Django REST Framework makes heavy use of the [`Request`](https://www.django-rest-framework.org/api-guide/requests/) 165 | object as a general context throughout the framework. 166 | [Permissions](https://www.django-rest-framework.org/api-guide/permissions/) are a good example: each permission check 167 | gets passed the `request` object along with the current `view` in order to verify if 168 | a given request has permission to view an object. 169 | 170 | However, broadcasts originate from database updates rather than an HTTP request, so 171 | `django-rest-live` uses the HTTP request that establishes the websocket connection as a basis for the `request` 172 | object accessible in views, permissions and serializers. `request.user` and `request.session`, normally populated 173 | via middleware, are available as expected. 174 | 175 | ## Passing parameters to subscriptions 176 | 177 | Views are often filtered in some way, using parameters in the URL 178 | passed as keyword arguments to the view, or as GET parameters after the `?` 179 | in the URL. These arguments can be passed to DRF through extra fields 180 | on the initial subscription request to filter the queryset appropriately 181 | for a given subscription. 182 | 183 | ### `view.kwargs` 184 | 185 | Something that can't be 186 | inferred from the initial request are [view keyword arguments](https://docs.djangoproject.com/en/3.1/ref/urls/#django.urls.path), 187 | normally derived from the URL path to a resource in HTTP requests. 188 | `django-rest-live` allows you to declare view arguments in your subscription request using the `view_kwargs` key: 189 | 190 | ```json 191 | { 192 | "type": "subscribe", 193 | "id": 1339, 194 | "model": "todolist.Task", 195 | "action": "retrieve", 196 | "lookup_by": 29, 197 | "view_kwargs": { 198 | "list": 14 199 | } 200 | } 201 | ``` 202 | 203 | As a rule of thumb, if you have angle brackets in your URL pattern, like `title` in 204 | `path('articles//', views.article)`, then you're providing your view with 205 | keyword arguments, and you most likely need to provide those arguments to your View when requesting subscriptions too. 206 | 207 | ### `request.query_params` 208 | 209 | If you use `request.query_params` in your view at all, potentially from 210 | [filters](https://www.django-rest-framework.org/api-guide/filtering/#filtering-against-query-parameters) on your queryset, 211 | you can also pass in query parameters to your subscription with the `query_params` key: 212 | 213 | ```json 214 | { 215 | "type": "subscribe", 216 | "id": 1340, 217 | "model": "todolist.Task", 218 | "action": "list", 219 | "query_params": { 220 | "active": true 221 | } 222 | } 223 | ``` 224 | 225 | If you're getting an `AttributeError` in your View when receiving a broadcast but not when doing normal HTTP REST 226 | operations, then you're probably making use of an attribute we didn't think of. In that case, 227 | please open an issue describing your use case! It'll go a long way to making this library more useful to all. 228 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django REST Live 2 | theme: 3 | name: readthedocs 4 | include_homepage_in_sidebar: False 5 | repo_name: https://github.com/pennlabs/django-rest-live 6 | nav: 7 | - GitHub: 'https://github.com/pennlabs/django-rest-live' 8 | - Getting Started: 9 | - 'installation.md' 10 | - 'usage.md' 11 | - 'testing.md' 12 | - Reference: 13 | - 'api.md' 14 | - 'mixin.md' 15 | - 'router.md' 16 | - 'signals.md' 17 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "asgiref" 11 | version = "3.4.1" 12 | description = "ASGI specs, helper code, and adapters" 13 | category = "main" 14 | optional = false 15 | python-versions = ">=3.6" 16 | 17 | [package.dependencies] 18 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 19 | 20 | [package.extras] 21 | tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] 22 | 23 | [[package]] 24 | name = "atomicwrites" 25 | version = "1.4.0" 26 | description = "Atomic file writes." 27 | category = "dev" 28 | optional = false 29 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 30 | 31 | [[package]] 32 | name = "attrs" 33 | version = "21.2.0" 34 | description = "Classes Without Boilerplate" 35 | category = "main" 36 | optional = false 37 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 38 | 39 | [package.extras] 40 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] 41 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 42 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] 43 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] 44 | 45 | [[package]] 46 | name = "autobahn" 47 | version = "21.11.1" 48 | description = "WebSocket client & server library, WAMP real-time framework" 49 | category = "main" 50 | optional = false 51 | python-versions = ">=3.7" 52 | 53 | [package.dependencies] 54 | cryptography = ">=3.4.6" 55 | hyperlink = ">=21.0.0" 56 | txaio = ">=21.2.1" 57 | 58 | [package.extras] 59 | accelerate = ["wsaccel (>=0.6.3)"] 60 | all = ["zope.interface (>=5.2.0)", "twisted (>=20.3.0)", "attrs (>=20.3.0)", "wsaccel (>=0.6.3)", "python-snappy (>=0.6.0)", "msgpack (>=1.0.2)", "ujson (>=4.0.2)", "cbor2 (>=5.2.0)", "cbor (>=1.0.0)", "py-ubjson (>=0.16.1)", "flatbuffers (>=1.12)", "pyopenssl (>=20.0.1)", "service_identity (>=18.1.0)", "pynacl (>=1.4.0)", "pytrie (>=0.4.0)", "pyqrcode (>=1.2.1)", "cffi (>=1.14.5)", "argon2_cffi (>=20.1.0)", "passlib (>=1.7.4)", "cffi (>=1.14.5)", "xbr (>=21.2.1)", "cbor2 (>=5.2.0)", "zlmdb (>=21.2.1)", "twisted (>=20.3.0)", "web3 (>=5.16.0)", "rlp (>=2.0.1)", "py-eth-sig-utils (>=0.4.0)", "py-ecc (>=5.1.0)", "eth-abi (>=2.1.1)", "mnemonic (>=0.19)", "base58 (>=2.1.0)", "ecdsa (>=0.16.1)", "py-multihash (>=2.0.1)", "jinja2 (>=2.11.3)", "yapf (==0.29.0)", "spake2 (>=0.8)", "hkdf (>=0.0.3)", "PyGObject (>=3.40.0)"] 61 | compress = ["python-snappy (>=0.6.0)"] 62 | dev = ["pep8-naming (>=0.3.3)", "flake8 (>=2.5.1)", "pyflakes (>=1.0.0)", "pytest (>=2.8.6,<3.3.0)", "twine (>=1.6.5)", "sphinx (>=1.2.3)", "sphinxcontrib-images (>=0.9.2)", "pyenchant (>=1.6.6)", "sphinxcontrib-spelling (>=2.1.2)", "sphinx_rtd_theme (>=0.1.9)", "awscli", "qualname", "passlib", "wheel", "pytest_asyncio (<0.6)", "pytest-aiohttp"] 63 | encryption = ["pyopenssl (>=20.0.1)", "service_identity (>=18.1.0)", "pynacl (>=1.4.0)", "pytrie (>=0.4.0)", "pyqrcode (>=1.2.1)"] 64 | nvx = ["cffi (>=1.14.5)"] 65 | scram = ["cffi (>=1.14.5)", "argon2_cffi (>=20.1.0)", "passlib (>=1.7.4)"] 66 | serialization = ["msgpack (>=1.0.2)", "ujson (>=4.0.2)", "cbor2 (>=5.2.0)", "cbor (>=1.0.0)", "py-ubjson (>=0.16.1)", "flatbuffers (>=1.12)"] 67 | twisted = ["zope.interface (>=5.2.0)", "twisted (>=20.3.0)", "attrs (>=20.3.0)"] 68 | xbr = ["xbr (>=21.2.1)", "cbor2 (>=5.2.0)", "zlmdb (>=21.2.1)", "twisted (>=20.3.0)", "web3 (>=5.16.0)", "rlp (>=2.0.1)", "py-eth-sig-utils (>=0.4.0)", "py-ecc (>=5.1.0)", "eth-abi (>=2.1.1)", "mnemonic (>=0.19)", "base58 (>=2.1.0)", "ecdsa (>=0.16.1)", "py-multihash (>=2.0.1)", "jinja2 (>=2.11.3)", "yapf (==0.29.0)", "spake2 (>=0.8)", "hkdf (>=0.0.3)", "PyGObject (>=3.40.0)"] 69 | 70 | [[package]] 71 | name = "automat" 72 | version = "20.2.0" 73 | description = "Self-service finite-state machines for the programmer on the go." 74 | category = "main" 75 | optional = false 76 | python-versions = "*" 77 | 78 | [package.dependencies] 79 | attrs = ">=19.2.0" 80 | six = "*" 81 | 82 | [package.extras] 83 | visualize = ["graphviz (>0.5.1)", "Twisted (>=16.1.1)"] 84 | 85 | [[package]] 86 | name = "black" 87 | version = "20.8b1" 88 | description = "The uncompromising code formatter." 89 | category = "dev" 90 | optional = false 91 | python-versions = ">=3.6" 92 | 93 | [package.dependencies] 94 | appdirs = "*" 95 | click = ">=7.1.2" 96 | mypy-extensions = ">=0.4.3" 97 | pathspec = ">=0.6,<1" 98 | regex = ">=2020.1.8" 99 | toml = ">=0.10.1" 100 | typed-ast = ">=1.4.0" 101 | typing-extensions = ">=3.7.4" 102 | 103 | [package.extras] 104 | colorama = ["colorama (>=0.4.3)"] 105 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 106 | 107 | [[package]] 108 | name = "cffi" 109 | version = "1.15.0" 110 | description = "Foreign Function Interface for Python calling C code." 111 | category = "main" 112 | optional = false 113 | python-versions = "*" 114 | 115 | [package.dependencies] 116 | pycparser = "*" 117 | 118 | [[package]] 119 | name = "channels" 120 | version = "3.0.4" 121 | description = "Brings async, event-driven capabilities to Django. Django 2.2 and up only." 122 | category = "main" 123 | optional = false 124 | python-versions = ">=3.6" 125 | 126 | [package.dependencies] 127 | asgiref = ">=3.3.1,<4" 128 | daphne = ">=3.0,<4" 129 | Django = ">=2.2" 130 | 131 | [package.extras] 132 | tests = ["pytest", "pytest-django", "pytest-asyncio", "async-timeout", "coverage (>=4.5,<5.0)"] 133 | 134 | [[package]] 135 | name = "click" 136 | version = "8.0.3" 137 | description = "Composable command line interface toolkit" 138 | category = "dev" 139 | optional = false 140 | python-versions = ">=3.6" 141 | 142 | [package.dependencies] 143 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 144 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 145 | 146 | [[package]] 147 | name = "colorama" 148 | version = "0.4.4" 149 | description = "Cross-platform colored terminal text." 150 | category = "dev" 151 | optional = false 152 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 153 | 154 | [[package]] 155 | name = "constantly" 156 | version = "15.1.0" 157 | description = "Symbolic constants in Python" 158 | category = "main" 159 | optional = false 160 | python-versions = "*" 161 | 162 | [[package]] 163 | name = "coverage" 164 | version = "5.5" 165 | description = "Code coverage measurement for Python" 166 | category = "dev" 167 | optional = false 168 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 169 | 170 | [package.extras] 171 | toml = ["toml"] 172 | 173 | [[package]] 174 | name = "cryptography" 175 | version = "36.0.0" 176 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 177 | category = "main" 178 | optional = false 179 | python-versions = ">=3.6" 180 | 181 | [package.dependencies] 182 | cffi = ">=1.12" 183 | 184 | [package.extras] 185 | docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] 186 | docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] 187 | pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] 188 | sdist = ["setuptools_rust (>=0.11.4)"] 189 | ssh = ["bcrypt (>=3.1.5)"] 190 | test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] 191 | 192 | [[package]] 193 | name = "daphne" 194 | version = "3.0.2" 195 | description = "Django ASGI (HTTP/WebSocket) server" 196 | category = "main" 197 | optional = false 198 | python-versions = ">=3.6" 199 | 200 | [package.dependencies] 201 | asgiref = ">=3.2.10,<4" 202 | autobahn = ">=0.18" 203 | twisted = {version = ">=18.7", extras = ["tls"]} 204 | 205 | [package.extras] 206 | tests = ["hypothesis (==4.23)", "pytest (>=3.10,<4.0)", "pytest-asyncio (>=0.8,<1.0)"] 207 | 208 | [[package]] 209 | name = "django" 210 | version = "3.2.9" 211 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 212 | category = "main" 213 | optional = false 214 | python-versions = ">=3.6" 215 | 216 | [package.dependencies] 217 | asgiref = ">=3.3.2,<4" 218 | pytz = "*" 219 | sqlparse = ">=0.2.2" 220 | 221 | [package.extras] 222 | argon2 = ["argon2-cffi (>=19.1.0)"] 223 | bcrypt = ["bcrypt"] 224 | 225 | [[package]] 226 | name = "djangorestframework" 227 | version = "3.12.4" 228 | description = "Web APIs for Django, made easy." 229 | category = "main" 230 | optional = false 231 | python-versions = ">=3.5" 232 | 233 | [package.dependencies] 234 | django = ">=2.2" 235 | 236 | [[package]] 237 | name = "djangorestframework-camel-case" 238 | version = "1.2.0" 239 | description = "Camel case JSON support for Django REST framework." 240 | category = "dev" 241 | optional = false 242 | python-versions = ">=3.5" 243 | 244 | [[package]] 245 | name = "flake8" 246 | version = "3.9.2" 247 | description = "the modular source code checker: pep8 pyflakes and co" 248 | category = "dev" 249 | optional = false 250 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 251 | 252 | [package.dependencies] 253 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 254 | mccabe = ">=0.6.0,<0.7.0" 255 | pycodestyle = ">=2.7.0,<2.8.0" 256 | pyflakes = ">=2.3.0,<2.4.0" 257 | 258 | [[package]] 259 | name = "flake8-absolute-import" 260 | version = "1.0" 261 | description = "flake8 plugin to require absolute imports" 262 | category = "dev" 263 | optional = false 264 | python-versions = ">=3.4" 265 | 266 | [package.dependencies] 267 | flake8 = ">=3.0" 268 | 269 | [[package]] 270 | name = "flake8-isort" 271 | version = "4.1.1" 272 | description = "flake8 plugin that integrates isort ." 273 | category = "dev" 274 | optional = false 275 | python-versions = "*" 276 | 277 | [package.dependencies] 278 | flake8 = ">=3.2.1,<5" 279 | isort = ">=4.3.5,<6" 280 | testfixtures = ">=6.8.0,<7" 281 | 282 | [package.extras] 283 | test = ["pytest-cov"] 284 | 285 | [[package]] 286 | name = "flake8-quotes" 287 | version = "3.3.1" 288 | description = "Flake8 lint for quotes." 289 | category = "dev" 290 | optional = false 291 | python-versions = "*" 292 | 293 | [package.dependencies] 294 | flake8 = "*" 295 | 296 | [[package]] 297 | name = "hyperlink" 298 | version = "21.0.0" 299 | description = "A featureful, immutable, and correct URL for Python." 300 | category = "main" 301 | optional = false 302 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 303 | 304 | [package.dependencies] 305 | idna = ">=2.5" 306 | 307 | [[package]] 308 | name = "idna" 309 | version = "3.3" 310 | description = "Internationalized Domain Names in Applications (IDNA)" 311 | category = "main" 312 | optional = false 313 | python-versions = ">=3.5" 314 | 315 | [[package]] 316 | name = "importlib-metadata" 317 | version = "4.8.2" 318 | description = "Read metadata from Python packages" 319 | category = "dev" 320 | optional = false 321 | python-versions = ">=3.6" 322 | 323 | [package.dependencies] 324 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 325 | zipp = ">=0.5" 326 | 327 | [package.extras] 328 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 329 | perf = ["ipython"] 330 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 331 | 332 | [[package]] 333 | name = "incremental" 334 | version = "21.3.0" 335 | description = "A small library that versions your Python projects." 336 | category = "main" 337 | optional = false 338 | python-versions = "*" 339 | 340 | [package.extras] 341 | scripts = ["click (>=6.0)", "twisted (>=16.4.0)"] 342 | 343 | [[package]] 344 | name = "iniconfig" 345 | version = "1.1.1" 346 | description = "iniconfig: brain-dead simple config-ini parsing" 347 | category = "dev" 348 | optional = false 349 | python-versions = "*" 350 | 351 | [[package]] 352 | name = "isort" 353 | version = "5.10.1" 354 | description = "A Python utility / library to sort Python imports." 355 | category = "dev" 356 | optional = false 357 | python-versions = ">=3.6.1,<4.0" 358 | 359 | [package.extras] 360 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 361 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 362 | colors = ["colorama (>=0.4.3,<0.5.0)"] 363 | plugins = ["setuptools"] 364 | 365 | [[package]] 366 | name = "mccabe" 367 | version = "0.6.1" 368 | description = "McCabe checker, plugin for flake8" 369 | category = "dev" 370 | optional = false 371 | python-versions = "*" 372 | 373 | [[package]] 374 | name = "mypy-extensions" 375 | version = "0.4.3" 376 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 377 | category = "dev" 378 | optional = false 379 | python-versions = "*" 380 | 381 | [[package]] 382 | name = "packaging" 383 | version = "21.3" 384 | description = "Core utilities for Python packages" 385 | category = "dev" 386 | optional = false 387 | python-versions = ">=3.6" 388 | 389 | [package.dependencies] 390 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 391 | 392 | [[package]] 393 | name = "pathspec" 394 | version = "0.9.0" 395 | description = "Utility library for gitignore style pattern matching of file paths." 396 | category = "dev" 397 | optional = false 398 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 399 | 400 | [[package]] 401 | name = "pluggy" 402 | version = "1.0.0" 403 | description = "plugin and hook calling mechanisms for python" 404 | category = "dev" 405 | optional = false 406 | python-versions = ">=3.6" 407 | 408 | [package.dependencies] 409 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 410 | 411 | [package.extras] 412 | dev = ["pre-commit", "tox"] 413 | testing = ["pytest", "pytest-benchmark"] 414 | 415 | [[package]] 416 | name = "py" 417 | version = "1.11.0" 418 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 419 | category = "dev" 420 | optional = false 421 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 422 | 423 | [[package]] 424 | name = "pyasn1" 425 | version = "0.4.8" 426 | description = "ASN.1 types and codecs" 427 | category = "main" 428 | optional = false 429 | python-versions = "*" 430 | 431 | [[package]] 432 | name = "pyasn1-modules" 433 | version = "0.2.8" 434 | description = "A collection of ASN.1-based protocols modules." 435 | category = "main" 436 | optional = false 437 | python-versions = "*" 438 | 439 | [package.dependencies] 440 | pyasn1 = ">=0.4.6,<0.5.0" 441 | 442 | [[package]] 443 | name = "pycodestyle" 444 | version = "2.7.0" 445 | description = "Python style guide checker" 446 | category = "dev" 447 | optional = false 448 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 449 | 450 | [[package]] 451 | name = "pycparser" 452 | version = "2.21" 453 | description = "C parser in Python" 454 | category = "main" 455 | optional = false 456 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 457 | 458 | [[package]] 459 | name = "pyflakes" 460 | version = "2.3.1" 461 | description = "passive checker of Python programs" 462 | category = "dev" 463 | optional = false 464 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 465 | 466 | [[package]] 467 | name = "pyopenssl" 468 | version = "21.0.0" 469 | description = "Python wrapper module around the OpenSSL library" 470 | category = "main" 471 | optional = false 472 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" 473 | 474 | [package.dependencies] 475 | cryptography = ">=3.3" 476 | six = ">=1.5.2" 477 | 478 | [package.extras] 479 | docs = ["sphinx", "sphinx-rtd-theme"] 480 | test = ["flaky", "pretend", "pytest (>=3.0.1)"] 481 | 482 | [[package]] 483 | name = "pyparsing" 484 | version = "3.0.6" 485 | description = "Python parsing module" 486 | category = "dev" 487 | optional = false 488 | python-versions = ">=3.6" 489 | 490 | [package.extras] 491 | diagrams = ["jinja2", "railroad-diagrams"] 492 | 493 | [[package]] 494 | name = "pytest" 495 | version = "6.2.5" 496 | description = "pytest: simple powerful testing with Python" 497 | category = "dev" 498 | optional = false 499 | python-versions = ">=3.6" 500 | 501 | [package.dependencies] 502 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 503 | attrs = ">=19.2.0" 504 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 505 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 506 | iniconfig = "*" 507 | packaging = "*" 508 | pluggy = ">=0.12,<2.0" 509 | py = ">=1.8.2" 510 | toml = "*" 511 | 512 | [package.extras] 513 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 514 | 515 | [[package]] 516 | name = "pytest-asyncio" 517 | version = "0.16.0" 518 | description = "Pytest support for asyncio." 519 | category = "dev" 520 | optional = false 521 | python-versions = ">= 3.6" 522 | 523 | [package.dependencies] 524 | pytest = ">=5.4.0" 525 | 526 | [package.extras] 527 | testing = ["coverage", "hypothesis (>=5.7.1)"] 528 | 529 | [[package]] 530 | name = "pytest-cov" 531 | version = "2.12.1" 532 | description = "Pytest plugin for measuring coverage." 533 | category = "dev" 534 | optional = false 535 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 536 | 537 | [package.dependencies] 538 | coverage = ">=5.2.1" 539 | pytest = ">=4.6" 540 | toml = "*" 541 | 542 | [package.extras] 543 | testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 544 | 545 | [[package]] 546 | name = "pytest-django" 547 | version = "4.4.0" 548 | description = "A Django plugin for pytest." 549 | category = "dev" 550 | optional = false 551 | python-versions = ">=3.5" 552 | 553 | [package.dependencies] 554 | pytest = ">=5.4.0" 555 | 556 | [package.extras] 557 | docs = ["sphinx", "sphinx-rtd-theme"] 558 | testing = ["django", "django-configurations (>=2.0)"] 559 | 560 | [[package]] 561 | name = "pytest-mock" 562 | version = "3.6.1" 563 | description = "Thin-wrapper around the mock package for easier use with pytest" 564 | category = "dev" 565 | optional = false 566 | python-versions = ">=3.6" 567 | 568 | [package.dependencies] 569 | pytest = ">=5.0" 570 | 571 | [package.extras] 572 | dev = ["pre-commit", "tox", "pytest-asyncio"] 573 | 574 | [[package]] 575 | name = "pytz" 576 | version = "2021.3" 577 | description = "World timezone definitions, modern and historical" 578 | category = "main" 579 | optional = false 580 | python-versions = "*" 581 | 582 | [[package]] 583 | name = "regex" 584 | version = "2021.11.10" 585 | description = "Alternative regular expression module, to replace re." 586 | category = "dev" 587 | optional = false 588 | python-versions = "*" 589 | 590 | [[package]] 591 | name = "service-identity" 592 | version = "21.1.0" 593 | description = "Service identity verification for pyOpenSSL & cryptography." 594 | category = "main" 595 | optional = false 596 | python-versions = "*" 597 | 598 | [package.dependencies] 599 | attrs = ">=19.1.0" 600 | cryptography = "*" 601 | pyasn1 = "*" 602 | pyasn1-modules = "*" 603 | six = "*" 604 | 605 | [package.extras] 606 | dev = ["coverage[toml] (>=5.0.2)", "pytest", "sphinx", "furo", "idna", "pyopenssl"] 607 | docs = ["sphinx", "furo"] 608 | idna = ["idna"] 609 | tests = ["coverage[toml] (>=5.0.2)", "pytest"] 610 | 611 | [[package]] 612 | name = "six" 613 | version = "1.16.0" 614 | description = "Python 2 and 3 compatibility utilities" 615 | category = "main" 616 | optional = false 617 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 618 | 619 | [[package]] 620 | name = "sqlparse" 621 | version = "0.4.2" 622 | description = "A non-validating SQL parser." 623 | category = "main" 624 | optional = false 625 | python-versions = ">=3.5" 626 | 627 | [[package]] 628 | name = "testfixtures" 629 | version = "6.18.3" 630 | description = "A collection of helpers and mock objects for unit tests and doc tests." 631 | category = "dev" 632 | optional = false 633 | python-versions = "*" 634 | 635 | [package.extras] 636 | build = ["setuptools-git", "wheel", "twine"] 637 | docs = ["sphinx", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] 638 | test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] 639 | 640 | [[package]] 641 | name = "toml" 642 | version = "0.10.2" 643 | description = "Python Library for Tom's Obvious, Minimal Language" 644 | category = "dev" 645 | optional = false 646 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 647 | 648 | [[package]] 649 | name = "twisted" 650 | version = "21.7.0" 651 | description = "An asynchronous networking framework written in Python" 652 | category = "main" 653 | optional = false 654 | python-versions = ">=3.6.7" 655 | 656 | [package.dependencies] 657 | attrs = ">=19.2.0" 658 | Automat = ">=0.8.0" 659 | constantly = ">=15.1" 660 | hyperlink = ">=17.1.1" 661 | idna = {version = ">=2.4", optional = true, markers = "extra == \"tls\""} 662 | incremental = ">=21.3.0" 663 | pyopenssl = {version = ">=16.0.0", optional = true, markers = "extra == \"tls\""} 664 | service-identity = {version = ">=18.1.0", optional = true, markers = "extra == \"tls\""} 665 | twisted-iocpsupport = {version = ">=1.0.0,<1.1.0", markers = "platform_system == \"Windows\""} 666 | typing-extensions = ">=3.6.5" 667 | "zope.interface" = ">=4.4.2" 668 | 669 | [package.extras] 670 | all_non_platform = ["cython-test-exception-raiser (>=1.0,<2.0)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] 671 | conch = ["pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)"] 672 | contextvars = ["contextvars (>=2.4,<3)"] 673 | dev = ["towncrier (>=19.2,<20.0)", "sphinx-rtd-theme (>=0.5,<1.0)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "sphinx (>=3.3,<4.0)", "pyflakes (>=2.2,<3.0)", "twistedchecker (>=0.7,<1.0)", "coverage (>=6b1,<7)", "python-subunit (>=1.4,<2.0)", "pydoctor (>=21.2.2,<21.3.0)"] 674 | dev_release = ["towncrier (>=19.2,<20.0)", "sphinx-rtd-theme (>=0.5,<1.0)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "sphinx (>=3.3,<4.0)", "pydoctor (>=21.2.2,<21.3.0)"] 675 | http2 = ["h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)"] 676 | macos_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "cython-test-exception-raiser (>=1.0,<2.0)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] 677 | mypy = ["mypy (==0.910)", "mypy-zope (==0.3.2)", "types-setuptools", "towncrier (>=19.2,<20.0)", "sphinx-rtd-theme (>=0.5,<1.0)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "sphinx (>=3.3,<4.0)", "pyflakes (>=2.2,<3.0)", "twistedchecker (>=0.7,<1.0)", "coverage (>=6b1,<7)", "cython-test-exception-raiser (>=1.0,<2.0)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "python-subunit (>=1.4,<2.0)", "contextvars (>=2.4,<3)", "pydoctor (>=21.2.2,<21.3.0)"] 678 | osx_platform = ["pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "cython-test-exception-raiser (>=1.0,<2.0)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] 679 | serial = ["pyserial (>=3.0)", "pywin32 (!=226)"] 680 | test = ["cython-test-exception-raiser (>=1.0,<2.0)", "PyHamcrest (>=1.9.0)"] 681 | tls = ["pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)"] 682 | windows_platform = ["pywin32 (!=226)", "cython-test-exception-raiser (>=1.0,<2.0)", "PyHamcrest (>=1.9.0)", "pyopenssl (>=16.0.0)", "service-identity (>=18.1.0)", "idna (>=2.4)", "pyasn1", "cryptography (>=2.6)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "pyserial (>=3.0)", "h2 (>=3.0,<4.0)", "priority (>=1.1.0,<2.0)", "pywin32 (!=226)", "contextvars (>=2.4,<3)"] 683 | 684 | [[package]] 685 | name = "twisted-iocpsupport" 686 | version = "1.0.2" 687 | description = "An extension for use in the twisted I/O Completion Ports reactor." 688 | category = "main" 689 | optional = false 690 | python-versions = "*" 691 | 692 | [[package]] 693 | name = "txaio" 694 | version = "21.2.1" 695 | description = "Compatibility API between asyncio/Twisted/Trollius" 696 | category = "main" 697 | optional = false 698 | python-versions = ">=3.6" 699 | 700 | [package.extras] 701 | all = ["zope.interface (>=5.2.0)", "twisted (>=20.3.0)"] 702 | dev = ["wheel", "pytest (>=2.6.4)", "pytest-cov (>=1.8.1)", "pep8 (>=1.6.2)", "sphinx (>=1.2.3)", "pyenchant (>=1.6.6)", "sphinxcontrib-spelling (>=2.1.2)", "sphinx-rtd-theme (>=0.1.9)", "tox (>=2.1.1)", "mock (==1.3.0)", "twine (>=1.6.5)", "tox-gh-actions (>=2.2.0)"] 703 | twisted = ["zope.interface (>=5.2.0)", "twisted (>=20.3.0)"] 704 | 705 | [[package]] 706 | name = "typed-ast" 707 | version = "1.5.0" 708 | description = "a fork of Python 2 and 3 ast modules with type comment support" 709 | category = "dev" 710 | optional = false 711 | python-versions = ">=3.6" 712 | 713 | [[package]] 714 | name = "typing-extensions" 715 | version = "4.0.0" 716 | description = "Backported and Experimental Type Hints for Python 3.6+" 717 | category = "main" 718 | optional = false 719 | python-versions = ">=3.6" 720 | 721 | [[package]] 722 | name = "zipp" 723 | version = "3.6.0" 724 | description = "Backport of pathlib-compatible object wrapper for zip files" 725 | category = "dev" 726 | optional = false 727 | python-versions = ">=3.6" 728 | 729 | [package.extras] 730 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 731 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 732 | 733 | [[package]] 734 | name = "zope.interface" 735 | version = "5.4.0" 736 | description = "Interfaces for Python" 737 | category = "main" 738 | optional = false 739 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 740 | 741 | [package.extras] 742 | docs = ["sphinx", "repoze.sphinx.autointerface"] 743 | test = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] 744 | testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] 745 | 746 | [metadata] 747 | lock-version = "1.1" 748 | python-versions = "^3.7" 749 | content-hash = "259ca93f40be546b3b42ab4dfe2eaaf91fd9e4d43722c6b498d37473c7f6cb8a" 750 | 751 | [metadata.files] 752 | appdirs = [ 753 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 754 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 755 | ] 756 | asgiref = [ 757 | {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"}, 758 | {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, 759 | ] 760 | atomicwrites = [ 761 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 762 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 763 | ] 764 | attrs = [ 765 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, 766 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, 767 | ] 768 | autobahn = [ 769 | {file = "autobahn-21.11.1.tar.gz", hash = "sha256:bd6f46315419ca0a5be4109f737410208ad5f19718f67ca6a4a674cc66ca9b18"}, 770 | ] 771 | automat = [ 772 | {file = "Automat-20.2.0-py2.py3-none-any.whl", hash = "sha256:b6feb6455337df834f6c9962d6ccf771515b7d939bca142b29c20c2376bc6111"}, 773 | {file = "Automat-20.2.0.tar.gz", hash = "sha256:7979803c74610e11ef0c0d68a2942b152df52da55336e0c9d58daf1831cbdf33"}, 774 | ] 775 | black = [ 776 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, 777 | ] 778 | cffi = [ 779 | {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, 780 | {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, 781 | {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, 782 | {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, 783 | {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, 784 | {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, 785 | {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, 786 | {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, 787 | {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, 788 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, 789 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, 790 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, 791 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, 792 | {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, 793 | {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, 794 | {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, 795 | {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, 796 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, 797 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, 798 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, 799 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, 800 | {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, 801 | {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, 802 | {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, 803 | {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, 804 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, 805 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, 806 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, 807 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, 808 | {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, 809 | {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, 810 | {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, 811 | {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, 812 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, 813 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, 814 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, 815 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, 816 | {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, 817 | {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, 818 | {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, 819 | {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, 820 | {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, 821 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, 822 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, 823 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, 824 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, 825 | {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, 826 | {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, 827 | {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, 828 | {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, 829 | ] 830 | channels = [ 831 | {file = "channels-3.0.4-py3-none-any.whl", hash = "sha256:0ff0422b4224d10efac76e451575517f155fe7c97d369b5973b116f22eeaf86c"}, 832 | {file = "channels-3.0.4.tar.gz", hash = "sha256:fdd9a94987a23d8d7ebd97498ed8b8cc83163f37e53fc6c85098aba7a3bb8b75"}, 833 | ] 834 | click = [ 835 | {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, 836 | {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, 837 | ] 838 | colorama = [ 839 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 840 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 841 | ] 842 | constantly = [ 843 | {file = "constantly-15.1.0-py2.py3-none-any.whl", hash = "sha256:dd2fa9d6b1a51a83f0d7dd76293d734046aa176e384bf6e33b7e44880eb37c5d"}, 844 | {file = "constantly-15.1.0.tar.gz", hash = "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35"}, 845 | ] 846 | coverage = [ 847 | {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, 848 | {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, 849 | {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, 850 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, 851 | {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, 852 | {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, 853 | {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, 854 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, 855 | {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, 856 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, 857 | {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, 858 | {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, 859 | {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, 860 | {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, 861 | {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, 862 | {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, 863 | {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, 864 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, 865 | {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, 866 | {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, 867 | {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, 868 | {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, 869 | {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, 870 | {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, 871 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, 872 | {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, 873 | {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, 874 | {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, 875 | {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, 876 | {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, 877 | {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, 878 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, 879 | {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, 880 | {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, 881 | {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, 882 | {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, 883 | {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, 884 | {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, 885 | {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, 886 | {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, 887 | {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, 888 | {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, 889 | {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, 890 | {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, 891 | {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, 892 | {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, 893 | {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, 894 | {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, 895 | {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, 896 | {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, 897 | {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, 898 | {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, 899 | ] 900 | cryptography = [ 901 | {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:9511416e85e449fe1de73f7f99b21b3aa04fba4c4d335d30c486ba3756e3a2a6"}, 902 | {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:97199a13b772e74cdcdb03760c32109c808aff7cd49c29e9cf4b7754bb725d1d"}, 903 | {file = "cryptography-36.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:494106e9cd945c2cadfce5374fa44c94cfadf01d4566a3b13bb487d2e6c7959e"}, 904 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6fbbbb8aab4053fa018984bb0e95a16faeb051dd8cca15add2a27e267ba02b58"}, 905 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:684993ff6f67000a56454b41bdc7e015429732d65a52d06385b6e9de6181c71e"}, 906 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c702855cd3174666ef0d2d13dcc879090aa9c6c38f5578896407a7028f75b9f"}, 907 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d91bc9f535599bed58f6d2e21a2724cb0c3895bf41c6403fe881391d29096f1d"}, 908 | {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:b17d83b3d1610e571fedac21b2eb36b816654d6f7496004d6a0d32f99d1d8120"}, 909 | {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8982c19bb90a4fa2aad3d635c6d71814e38b643649b4000a8419f8691f20ac44"}, 910 | {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:24469d9d33217ffd0ce4582dfcf2a76671af115663a95328f63c99ec7ece61a4"}, 911 | {file = "cryptography-36.0.0-cp36-abi3-win32.whl", hash = "sha256:f6a5a85beb33e57998dc605b9dbe7deaa806385fdf5c4810fb849fcd04640c81"}, 912 | {file = "cryptography-36.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:2deab5ec05d83ddcf9b0916319674d3dae88b0e7ee18f8962642d3cde0496568"}, 913 | {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2049f8b87f449fc6190350de443ee0c1dd631f2ce4fa99efad2984de81031681"}, 914 | {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a776bae1629c8d7198396fd93ec0265f8dd2341c553dc32b976168aaf0e6a636"}, 915 | {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:aa94d617a4cd4cdf4af9b5af65100c036bce22280ebb15d8b5262e8273ebc6ba"}, 916 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5c49c9e8fb26a567a2b3fa0343c89f5d325447956cc2fc7231c943b29a973712"}, 917 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef216d13ac8d24d9cd851776662f75f8d29c9f2d05cdcc2d34a18d32463a9b0b"}, 918 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231c4a69b11f6af79c1495a0e5a85909686ea8db946935224b7825cfb53827ed"}, 919 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:f92556f94e476c1b616e6daec5f7ddded2c082efa7cee7f31c7aeda615906ed8"}, 920 | {file = "cryptography-36.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d73e3a96c38173e0aa5646c31bf8473bc3564837977dd480f5cbeacf1d7ef3a3"}, 921 | {file = "cryptography-36.0.0.tar.gz", hash = "sha256:52f769ecb4ef39865719aedc67b4b7eae167bafa48dbc2a26dd36fa56460507f"}, 922 | ] 923 | daphne = [ 924 | {file = "daphne-3.0.2-py3-none-any.whl", hash = "sha256:a9af943c79717bc52fe64a3c236ae5d3adccc8b5be19c881b442d2c3db233393"}, 925 | {file = "daphne-3.0.2.tar.gz", hash = "sha256:76ffae916ba3aa66b46996c14fa713e46004788167a4873d647544e750e0e99f"}, 926 | ] 927 | django = [ 928 | {file = "Django-3.2.9-py3-none-any.whl", hash = "sha256:e22c9266da3eec7827737cde57694d7db801fedac938d252bf27377cec06ed1b"}, 929 | {file = "Django-3.2.9.tar.gz", hash = "sha256:51284300f1522ffcdb07ccbdf676a307c6678659e1284f0618e5a774127a6a08"}, 930 | ] 931 | djangorestframework = [ 932 | {file = "djangorestframework-3.12.4-py3-none-any.whl", hash = "sha256:6d1d59f623a5ad0509fe0d6bfe93cbdfe17b8116ebc8eda86d45f6e16e819aaf"}, 933 | {file = "djangorestframework-3.12.4.tar.gz", hash = "sha256:f747949a8ddac876e879190df194b925c177cdeb725a099db1460872f7c0a7f2"}, 934 | ] 935 | djangorestframework-camel-case = [ 936 | {file = "djangorestframework-camel-case-1.2.0.tar.gz", hash = "sha256:9714d43fba5bb654057c29501649684d3d9f11a92319ae417fd4d65e80d1159d"}, 937 | ] 938 | flake8 = [ 939 | {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, 940 | {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, 941 | ] 942 | flake8-absolute-import = [ 943 | {file = "flake8-absolute-import-1.0.tar.gz", hash = "sha256:06f2784078d91e52812dac10c77e09515916c4e455c8bb15cc538fb95f20d9a3"}, 944 | {file = "flake8_absolute_import-1.0-py3-none-any.whl", hash = "sha256:8ea7e60817038133dd7a0d8b5719b955bc22317ab35c7d247b1a2985f96cf8d4"}, 945 | ] 946 | flake8-isort = [ 947 | {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, 948 | {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"}, 949 | ] 950 | flake8-quotes = [ 951 | {file = "flake8-quotes-3.3.1.tar.gz", hash = "sha256:633adca6fb8a08131536af0d750b44d6985b9aba46f498871e21588c3e6f525a"}, 952 | ] 953 | hyperlink = [ 954 | {file = "hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4"}, 955 | {file = "hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b"}, 956 | ] 957 | idna = [ 958 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 959 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 960 | ] 961 | importlib-metadata = [ 962 | {file = "importlib_metadata-4.8.2-py3-none-any.whl", hash = "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100"}, 963 | {file = "importlib_metadata-4.8.2.tar.gz", hash = "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb"}, 964 | ] 965 | incremental = [ 966 | {file = "incremental-21.3.0-py2.py3-none-any.whl", hash = "sha256:92014aebc6a20b78a8084cdd5645eeaa7f74b8933f70fa3ada2cfbd1e3b54321"}, 967 | {file = "incremental-21.3.0.tar.gz", hash = "sha256:02f5de5aff48f6b9f665d99d48bfc7ec03b6e3943210de7cfc88856d755d6f57"}, 968 | ] 969 | iniconfig = [ 970 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 971 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 972 | ] 973 | isort = [ 974 | {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, 975 | {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, 976 | ] 977 | mccabe = [ 978 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 979 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 980 | ] 981 | mypy-extensions = [ 982 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 983 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 984 | ] 985 | packaging = [ 986 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 987 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 988 | ] 989 | pathspec = [ 990 | {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, 991 | {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, 992 | ] 993 | pluggy = [ 994 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 995 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 996 | ] 997 | py = [ 998 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 999 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 1000 | ] 1001 | pyasn1 = [ 1002 | {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, 1003 | {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, 1004 | {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"}, 1005 | {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"}, 1006 | {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, 1007 | {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"}, 1008 | {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"}, 1009 | {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"}, 1010 | {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"}, 1011 | {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"}, 1012 | {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"}, 1013 | {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, 1014 | {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, 1015 | ] 1016 | pyasn1-modules = [ 1017 | {file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"}, 1018 | {file = "pyasn1_modules-0.2.8-py2.4.egg", hash = "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199"}, 1019 | {file = "pyasn1_modules-0.2.8-py2.5.egg", hash = "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"}, 1020 | {file = "pyasn1_modules-0.2.8-py2.6.egg", hash = "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb"}, 1021 | {file = "pyasn1_modules-0.2.8-py2.7.egg", hash = "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8"}, 1022 | {file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"}, 1023 | {file = "pyasn1_modules-0.2.8-py3.1.egg", hash = "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d"}, 1024 | {file = "pyasn1_modules-0.2.8-py3.2.egg", hash = "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45"}, 1025 | {file = "pyasn1_modules-0.2.8-py3.3.egg", hash = "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4"}, 1026 | {file = "pyasn1_modules-0.2.8-py3.4.egg", hash = "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811"}, 1027 | {file = "pyasn1_modules-0.2.8-py3.5.egg", hash = "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed"}, 1028 | {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, 1029 | {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, 1030 | ] 1031 | pycodestyle = [ 1032 | {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, 1033 | {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, 1034 | ] 1035 | pycparser = [ 1036 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 1037 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 1038 | ] 1039 | pyflakes = [ 1040 | {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, 1041 | {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, 1042 | ] 1043 | pyopenssl = [ 1044 | {file = "pyOpenSSL-21.0.0-py2.py3-none-any.whl", hash = "sha256:8935bd4920ab9abfebb07c41a4f58296407ed77f04bd1a92914044b848ba1ed6"}, 1045 | {file = "pyOpenSSL-21.0.0.tar.gz", hash = "sha256:5e2d8c5e46d0d865ae933bef5230090bdaf5506281e9eec60fa250ee80600cb3"}, 1046 | ] 1047 | pyparsing = [ 1048 | {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, 1049 | {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, 1050 | ] 1051 | pytest = [ 1052 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 1053 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 1054 | ] 1055 | pytest-asyncio = [ 1056 | {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, 1057 | {file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"}, 1058 | ] 1059 | pytest-cov = [ 1060 | {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, 1061 | {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, 1062 | ] 1063 | pytest-django = [ 1064 | {file = "pytest-django-4.4.0.tar.gz", hash = "sha256:b5171e3798bf7e3fc5ea7072fe87324db67a4dd9f1192b037fed4cc3c1b7f455"}, 1065 | {file = "pytest_django-4.4.0-py3-none-any.whl", hash = "sha256:65783e78382456528bd9d79a35843adde9e6a47347b20464eb2c885cb0f1f606"}, 1066 | ] 1067 | pytest-mock = [ 1068 | {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, 1069 | {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, 1070 | ] 1071 | pytz = [ 1072 | {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, 1073 | {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, 1074 | ] 1075 | regex = [ 1076 | {file = "regex-2021.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9345b6f7ee578bad8e475129ed40123d265464c4cfead6c261fd60fc9de00bcf"}, 1077 | {file = "regex-2021.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:416c5f1a188c91e3eb41e9c8787288e707f7d2ebe66e0a6563af280d9b68478f"}, 1078 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0538c43565ee6e703d3a7c3bdfe4037a5209250e8502c98f20fea6f5fdf2965"}, 1079 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee1227cf08b6716c85504aebc49ac827eb88fcc6e51564f010f11a406c0a667"}, 1080 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6650f16365f1924d6014d2ea770bde8555b4a39dc9576abb95e3cd1ff0263b36"}, 1081 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30ab804ea73972049b7a2a5c62d97687d69b5a60a67adca07eb73a0ddbc9e29f"}, 1082 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68a067c11463de2a37157930d8b153005085e42bcb7ad9ca562d77ba7d1404e0"}, 1083 | {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:162abfd74e88001d20cb73ceaffbfe601469923e875caf9118333b1a4aaafdc4"}, 1084 | {file = "regex-2021.11.10-cp310-cp310-win32.whl", hash = "sha256:98ba568e8ae26beb726aeea2273053c717641933836568c2a0278a84987b2a1a"}, 1085 | {file = "regex-2021.11.10-cp310-cp310-win_amd64.whl", hash = "sha256:780b48456a0f0ba4d390e8b5f7c661fdd218934388cde1a974010a965e200e12"}, 1086 | {file = "regex-2021.11.10-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dba70f30fd81f8ce6d32ddeef37d91c8948e5d5a4c63242d16a2b2df8143aafc"}, 1087 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1f54b9b4b6c53369f40028d2dd07a8c374583417ee6ec0ea304e710a20f80a0"}, 1088 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbb9dc00e39f3e6c0ef48edee202f9520dafb233e8b51b06b8428cfcb92abd30"}, 1089 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666abff54e474d28ff42756d94544cdfd42e2ee97065857413b72e8a2d6a6345"}, 1090 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5537f71b6d646f7f5f340562ec4c77b6e1c915f8baae822ea0b7e46c1f09b733"}, 1091 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2e07c6a26ed4bea91b897ee2b0835c21716d9a469a96c3e878dc5f8c55bb23"}, 1092 | {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca5f18a75e1256ce07494e245cdb146f5a9267d3c702ebf9b65c7f8bd843431e"}, 1093 | {file = "regex-2021.11.10-cp36-cp36m-win32.whl", hash = "sha256:93a5051fcf5fad72de73b96f07d30bc29665697fb8ecdfbc474f3452c78adcf4"}, 1094 | {file = "regex-2021.11.10-cp36-cp36m-win_amd64.whl", hash = "sha256:b483c9d00a565633c87abd0aaf27eb5016de23fed952e054ecc19ce32f6a9e7e"}, 1095 | {file = "regex-2021.11.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fff55f3ce50a3ff63ec8e2a8d3dd924f1941b250b0aac3d3d42b687eeff07a8e"}, 1096 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e32d2a2b02ccbef10145df9135751abea1f9f076e67a4e261b05f24b94219e36"}, 1097 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53db2c6be8a2710b359bfd3d3aa17ba38f8aa72a82309a12ae99d3c0c3dcd74d"}, 1098 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2207ae4f64ad3af399e2d30dde66f0b36ae5c3129b52885f1bffc2f05ec505c8"}, 1099 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5ca078bb666c4a9d1287a379fe617a6dccd18c3e8a7e6c7e1eb8974330c626a"}, 1100 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd33eb9bdcfbabab3459c9ee651d94c842bc8a05fabc95edf4ee0c15a072495e"}, 1101 | {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05b7d6d7e64efe309972adab77fc2af8907bb93217ec60aa9fe12a0dad35874f"}, 1102 | {file = "regex-2021.11.10-cp37-cp37m-win32.whl", hash = "sha256:e71255ba42567d34a13c03968736c5d39bb4a97ce98188fafb27ce981115beec"}, 1103 | {file = "regex-2021.11.10-cp37-cp37m-win_amd64.whl", hash = "sha256:07856afef5ffcc052e7eccf3213317fbb94e4a5cd8177a2caa69c980657b3cb4"}, 1104 | {file = "regex-2021.11.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba05430e819e58544e840a68b03b28b6d328aff2e41579037e8bab7653b37d83"}, 1105 | {file = "regex-2021.11.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f301b11b9d214f83ddaf689181051e7f48905568b0c7017c04c06dfd065e244"}, 1106 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aaa4e0705ef2b73dd8e36eeb4c868f80f8393f5f4d855e94025ce7ad8525f50"}, 1107 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:788aef3549f1924d5c38263104dae7395bf020a42776d5ec5ea2b0d3d85d6646"}, 1108 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8af619e3be812a2059b212064ea7a640aff0568d972cd1b9e920837469eb3cb"}, 1109 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85bfa6a5413be0ee6c5c4a663668a2cad2cbecdee367630d097d7823041bdeec"}, 1110 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f23222527b307970e383433daec128d769ff778d9b29343fb3496472dc20dabe"}, 1111 | {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da1a90c1ddb7531b1d5ff1e171b4ee61f6345119be7351104b67ff413843fe94"}, 1112 | {file = "regex-2021.11.10-cp38-cp38-win32.whl", hash = "sha256:0617383e2fe465732af4509e61648b77cbe3aee68b6ac8c0b6fe934db90be5cc"}, 1113 | {file = "regex-2021.11.10-cp38-cp38-win_amd64.whl", hash = "sha256:a3feefd5e95871872673b08636f96b61ebef62971eab044f5124fb4dea39919d"}, 1114 | {file = "regex-2021.11.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7f325be2804246a75a4f45c72d4ce80d2443ab815063cdf70ee8fb2ca59ee1b"}, 1115 | {file = "regex-2021.11.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:537ca6a3586931b16a85ac38c08cc48f10fc870a5b25e51794c74df843e9966d"}, 1116 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eef2afb0fd1747f33f1ee3e209bce1ed582d1896b240ccc5e2697e3275f037c7"}, 1117 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:432bd15d40ed835a51617521d60d0125867f7b88acf653e4ed994a1f8e4995dc"}, 1118 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b43c2b8a330a490daaef5a47ab114935002b13b3f9dc5da56d5322ff218eeadb"}, 1119 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:962b9a917dd7ceacbe5cd424556914cb0d636001e393b43dc886ba31d2a1e449"}, 1120 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa8c626d6441e2d04b6ee703ef2d1e17608ad44c7cb75258c09dd42bacdfc64b"}, 1121 | {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3c5fb32cc6077abad3bbf0323067636d93307c9fa93e072771cf9a64d1c0f3ef"}, 1122 | {file = "regex-2021.11.10-cp39-cp39-win32.whl", hash = "sha256:3b5df18db1fccd66de15aa59c41e4f853b5df7550723d26aa6cb7f40e5d9da5a"}, 1123 | {file = "regex-2021.11.10-cp39-cp39-win_amd64.whl", hash = "sha256:83ee89483672b11f8952b158640d0c0ff02dc43d9cb1b70c1564b49abe92ce29"}, 1124 | {file = "regex-2021.11.10.tar.gz", hash = "sha256:f341ee2df0999bfdf7a95e448075effe0db212a59387de1a70690e4acb03d4c6"}, 1125 | ] 1126 | service-identity = [ 1127 | {file = "service-identity-21.1.0.tar.gz", hash = "sha256:6e6c6086ca271dc11b033d17c3a8bea9f24ebff920c587da090afc9519419d34"}, 1128 | {file = "service_identity-21.1.0-py2.py3-none-any.whl", hash = "sha256:f0b0caac3d40627c3c04d7a51b6e06721857a0e10a8775f2d1d7e72901b3a7db"}, 1129 | ] 1130 | six = [ 1131 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 1132 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 1133 | ] 1134 | sqlparse = [ 1135 | {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, 1136 | {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, 1137 | ] 1138 | testfixtures = [ 1139 | {file = "testfixtures-6.18.3-py2.py3-none-any.whl", hash = "sha256:6ddb7f56a123e1a9339f130a200359092bd0a6455e31838d6c477e8729bb7763"}, 1140 | {file = "testfixtures-6.18.3.tar.gz", hash = "sha256:2600100ae96ffd082334b378e355550fef8b4a529a6fa4c34f47130905c7426d"}, 1141 | ] 1142 | toml = [ 1143 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1144 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1145 | ] 1146 | twisted = [ 1147 | {file = "Twisted-21.7.0-py3-none-any.whl", hash = "sha256:13c1d1d2421ae556d91e81e66cf0d4f4e4e1e4a36a0486933bee4305c6a4fb9b"}, 1148 | {file = "Twisted-21.7.0.tar.gz", hash = "sha256:2cd652542463277378b0d349f47c62f20d9306e57d1247baabd6d1d38a109006"}, 1149 | ] 1150 | twisted-iocpsupport = [ 1151 | {file = "twisted-iocpsupport-1.0.2.tar.gz", hash = "sha256:72068b206ee809c9c596b57b5287259ea41ddb4774d86725b19f35bf56aa32a9"}, 1152 | {file = "twisted_iocpsupport-1.0.2-cp310-cp310-win32.whl", hash = "sha256:985c06a33f5c0dae92c71a036d1ea63872ee86a21dd9b01e1f287486f15524b4"}, 1153 | {file = "twisted_iocpsupport-1.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:81b3abe3527b367da0220482820cb12a16c661672b7bcfcde328902890d63323"}, 1154 | {file = "twisted_iocpsupport-1.0.2-cp36-cp36m-win32.whl", hash = "sha256:9dbb8823b49f06d4de52721b47de4d3b3026064ef4788ce62b1a21c57c3fff6f"}, 1155 | {file = "twisted_iocpsupport-1.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:b9fed67cf0f951573f06d560ac2f10f2a4bbdc6697770113a2fc396ea2cb2565"}, 1156 | {file = "twisted_iocpsupport-1.0.2-cp37-cp37m-win32.whl", hash = "sha256:b76b4eed9b27fd63ddb0877efdd2d15835fdcb6baa745cb85b66e5d016ac2878"}, 1157 | {file = "twisted_iocpsupport-1.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:851b3735ca7e8102e661872390e3bce88f8901bece95c25a0c8bb9ecb8a23d32"}, 1158 | {file = "twisted_iocpsupport-1.0.2-cp38-cp38-win32.whl", hash = "sha256:bf4133139d77fc706d8f572e6b7d82871d82ec7ef25d685c2351bdacfb701415"}, 1159 | {file = "twisted_iocpsupport-1.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:306becd6e22ab6e8e4f36b6bdafd9c92e867c98a5ce517b27fdd27760ee7ae41"}, 1160 | {file = "twisted_iocpsupport-1.0.2-cp39-cp39-win32.whl", hash = "sha256:3c61742cb0bc6c1ac117a7e5f422c129832f0c295af49e01d8a6066df8cfc04d"}, 1161 | {file = "twisted_iocpsupport-1.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:b435857b9efcbfc12f8c326ef0383f26416272260455bbca2cd8d8eca470c546"}, 1162 | {file = "twisted_iocpsupport-1.0.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:7d972cfa8439bdcb35a7be78b7ef86d73b34b808c74be56dfa785c8a93b851bf"}, 1163 | ] 1164 | txaio = [ 1165 | {file = "txaio-21.2.1-py2.py3-none-any.whl", hash = "sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb"}, 1166 | {file = "txaio-21.2.1.tar.gz", hash = "sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8"}, 1167 | ] 1168 | typed-ast = [ 1169 | {file = "typed_ast-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b310a207ee9fde3f46ba327989e6cba4195bc0c8c70a158456e7b10233e6bed"}, 1170 | {file = "typed_ast-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52ca2b2b524d770bed7a393371a38e91943f9160a190141e0df911586066ecda"}, 1171 | {file = "typed_ast-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:14fed8820114a389a2b7e91624db5f85f3f6682fda09fe0268a59aabd28fe5f5"}, 1172 | {file = "typed_ast-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:65c81abbabda7d760df7304d843cc9dbe7ef5d485504ca59a46ae2d1731d2428"}, 1173 | {file = "typed_ast-1.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:37ba2ab65a0028b1a4f2b61a8fe77f12d242731977d274a03d68ebb751271508"}, 1174 | {file = "typed_ast-1.5.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:49af5b8f6f03ed1eb89ee06c1d7c2e7c8e743d720c3746a5857609a1abc94c94"}, 1175 | {file = "typed_ast-1.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:e4374a76e61399a173137e7984a1d7e356038cf844f24fd8aea46c8029a2f712"}, 1176 | {file = "typed_ast-1.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ea517c2bb11c5e4ba7a83a91482a2837041181d57d3ed0749a6c382a2b6b7086"}, 1177 | {file = "typed_ast-1.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:51040bf45aacefa44fa67fb9ebcd1f2bec73182b99a532c2394eea7dabd18e24"}, 1178 | {file = "typed_ast-1.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:806e0c7346b9b4af8c62d9a29053f484599921a4448c37fbbcbbf15c25138570"}, 1179 | {file = "typed_ast-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a67fd5914603e2165e075f1b12f5a8356bfb9557e8bfb74511108cfbab0f51ed"}, 1180 | {file = "typed_ast-1.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:224afecb8b39739f5c9562794a7c98325cb9d972712e1a98b6989a4720219541"}, 1181 | {file = "typed_ast-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:155b74b078be842d2eb630dd30a280025eca0a5383c7d45853c27afee65f278f"}, 1182 | {file = "typed_ast-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:361b9e5d27bd8e3ccb6ea6ad6c4f3c0be322a1a0f8177db6d56264fa0ae40410"}, 1183 | {file = "typed_ast-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:618912cbc7e17b4aeba86ffe071698c6e2d292acbd6d1d5ec1ee724b8c4ae450"}, 1184 | {file = "typed_ast-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7e6731044f748340ef68dcadb5172a4b1f40847a2983fe3983b2a66445fbc8e6"}, 1185 | {file = "typed_ast-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e8a9b9c87801cecaad3b4c2b8876387115d1a14caa602c1618cedbb0cb2a14e6"}, 1186 | {file = "typed_ast-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:ec184dfb5d3d11e82841dbb973e7092b75f306b625fad7b2e665b64c5d60ab3f"}, 1187 | {file = "typed_ast-1.5.0.tar.gz", hash = "sha256:ff4ad88271aa7a55f19b6a161ed44e088c393846d954729549e3cde8257747bb"}, 1188 | ] 1189 | typing-extensions = [ 1190 | {file = "typing_extensions-4.0.0-py3-none-any.whl", hash = "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9"}, 1191 | {file = "typing_extensions-4.0.0.tar.gz", hash = "sha256:2cdf80e4e04866a9b3689a51869016d36db0814d84b8d8a568d22781d45d27ed"}, 1192 | ] 1193 | zipp = [ 1194 | {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, 1195 | {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, 1196 | ] 1197 | "zope.interface" = [ 1198 | {file = "zope.interface-5.4.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:7df1e1c05304f26faa49fa752a8c690126cf98b40b91d54e6e9cc3b7d6ffe8b7"}, 1199 | {file = "zope.interface-5.4.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2c98384b254b37ce50eddd55db8d381a5c53b4c10ee66e1e7fe749824f894021"}, 1200 | {file = "zope.interface-5.4.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:08f9636e99a9d5410181ba0729e0408d3d8748026ea938f3b970a0249daa8192"}, 1201 | {file = "zope.interface-5.4.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0ea1d73b7c9dcbc5080bb8aaffb776f1c68e807767069b9ccdd06f27a161914a"}, 1202 | {file = "zope.interface-5.4.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:273f158fabc5ea33cbc936da0ab3d4ba80ede5351babc4f577d768e057651531"}, 1203 | {file = "zope.interface-5.4.0-cp27-cp27m-win32.whl", hash = "sha256:a1e6e96217a0f72e2b8629e271e1b280c6fa3fe6e59fa8f6701bec14e3354325"}, 1204 | {file = "zope.interface-5.4.0-cp27-cp27m-win_amd64.whl", hash = "sha256:877473e675fdcc113c138813a5dd440da0769a2d81f4d86614e5d62b69497155"}, 1205 | {file = "zope.interface-5.4.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:f7ee479e96f7ee350db1cf24afa5685a5899e2b34992fb99e1f7c1b0b758d263"}, 1206 | {file = "zope.interface-5.4.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:b0297b1e05fd128d26cc2460c810d42e205d16d76799526dfa8c8ccd50e74959"}, 1207 | {file = "zope.interface-5.4.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:af310ec8335016b5e52cae60cda4a4f2a60a788cbb949a4fbea13d441aa5a09e"}, 1208 | {file = "zope.interface-5.4.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:9a9845c4c6bb56e508651f005c4aeb0404e518c6f000d5a1123ab077ab769f5c"}, 1209 | {file = "zope.interface-5.4.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0b465ae0962d49c68aa9733ba92a001b2a0933c317780435f00be7ecb959c702"}, 1210 | {file = "zope.interface-5.4.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:5dd9ca406499444f4c8299f803d4a14edf7890ecc595c8b1c7115c2342cadc5f"}, 1211 | {file = "zope.interface-5.4.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:469e2407e0fe9880ac690a3666f03eb4c3c444411a5a5fddfdabc5d184a79f05"}, 1212 | {file = "zope.interface-5.4.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:52de7fc6c21b419078008f697fd4103dbc763288b1406b4562554bd47514c004"}, 1213 | {file = "zope.interface-5.4.0-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:3dd4952748521205697bc2802e4afac5ed4b02909bb799ba1fe239f77fd4e117"}, 1214 | {file = "zope.interface-5.4.0-cp35-cp35m-win32.whl", hash = "sha256:dd93ea5c0c7f3e25335ab7d22a507b1dc43976e1345508f845efc573d3d779d8"}, 1215 | {file = "zope.interface-5.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:3748fac0d0f6a304e674955ab1365d515993b3a0a865e16a11ec9d86fb307f63"}, 1216 | {file = "zope.interface-5.4.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:66c0061c91b3b9cf542131148ef7ecbecb2690d48d1612ec386de9d36766058f"}, 1217 | {file = "zope.interface-5.4.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:d0c1bc2fa9a7285719e5678584f6b92572a5b639d0e471bb8d4b650a1a910920"}, 1218 | {file = "zope.interface-5.4.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2876246527c91e101184f63ccd1d716ec9c46519cc5f3d5375a3351c46467c46"}, 1219 | {file = "zope.interface-5.4.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:334701327f37c47fa628fc8b8d28c7d7730ce7daaf4bda1efb741679c2b087fc"}, 1220 | {file = "zope.interface-5.4.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:71aace0c42d53abe6fc7f726c5d3b60d90f3c5c055a447950ad6ea9cec2e37d9"}, 1221 | {file = "zope.interface-5.4.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5bb3489b4558e49ad2c5118137cfeaf59434f9737fa9c5deefc72d22c23822e2"}, 1222 | {file = "zope.interface-5.4.0-cp36-cp36m-win32.whl", hash = "sha256:1c0e316c9add0db48a5b703833881351444398b04111188069a26a61cfb4df78"}, 1223 | {file = "zope.interface-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f0c02cbb9691b7c91d5009108f975f8ffeab5dff8f26d62e21c493060eff2a1"}, 1224 | {file = "zope.interface-5.4.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:7d97a4306898b05404a0dcdc32d9709b7d8832c0c542b861d9a826301719794e"}, 1225 | {file = "zope.interface-5.4.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:867a5ad16892bf20e6c4ea2aab1971f45645ff3102ad29bd84c86027fa99997b"}, 1226 | {file = "zope.interface-5.4.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5f931a1c21dfa7a9c573ec1f50a31135ccce84e32507c54e1ea404894c5eb96f"}, 1227 | {file = "zope.interface-5.4.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:194d0bcb1374ac3e1e023961610dc8f2c78a0f5f634d0c737691e215569e640d"}, 1228 | {file = "zope.interface-5.4.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8270252effc60b9642b423189a2fe90eb6b59e87cbee54549db3f5562ff8d1b8"}, 1229 | {file = "zope.interface-5.4.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:15e7d1f7a6ee16572e21e3576d2012b2778cbacf75eb4b7400be37455f5ca8bf"}, 1230 | {file = "zope.interface-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:8892f89999ffd992208754851e5a052f6b5db70a1e3f7d54b17c5211e37a98c7"}, 1231 | {file = "zope.interface-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2e5a26f16503be6c826abca904e45f1a44ff275fdb7e9d1b75c10671c26f8b94"}, 1232 | {file = "zope.interface-5.4.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:0f91b5b948686659a8e28b728ff5e74b1be6bf40cb04704453617e5f1e945ef3"}, 1233 | {file = "zope.interface-5.4.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:4de4bc9b6d35c5af65b454d3e9bc98c50eb3960d5a3762c9438df57427134b8e"}, 1234 | {file = "zope.interface-5.4.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bf68f4b2b6683e52bec69273562df15af352e5ed25d1b6641e7efddc5951d1a7"}, 1235 | {file = "zope.interface-5.4.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:63b82bb63de7c821428d513607e84c6d97d58afd1fe2eb645030bdc185440120"}, 1236 | {file = "zope.interface-5.4.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:db1fa631737dab9fa0b37f3979d8d2631e348c3b4e8325d6873c2541d0ae5a48"}, 1237 | {file = "zope.interface-5.4.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:f44e517131a98f7a76696a7b21b164bcb85291cee106a23beccce454e1f433a4"}, 1238 | {file = "zope.interface-5.4.0-cp38-cp38-win32.whl", hash = "sha256:a9506a7e80bcf6eacfff7f804c0ad5350c8c95b9010e4356a4b36f5322f09abb"}, 1239 | {file = "zope.interface-5.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:3c02411a3b62668200910090a0dff17c0b25aaa36145082a5a6adf08fa281e54"}, 1240 | {file = "zope.interface-5.4.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:0cee5187b60ed26d56eb2960136288ce91bcf61e2a9405660d271d1f122a69a4"}, 1241 | {file = "zope.interface-5.4.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:a8156e6a7f5e2a0ff0c5b21d6bcb45145efece1909efcbbbf48c56f8da68221d"}, 1242 | {file = "zope.interface-5.4.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:205e40ccde0f37496904572035deea747390a8b7dc65146d30b96e2dd1359a83"}, 1243 | {file = "zope.interface-5.4.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:3f24df7124c323fceb53ff6168da70dbfbae1442b4f3da439cd441681f54fe25"}, 1244 | {file = "zope.interface-5.4.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5208ebd5152e040640518a77827bdfcc73773a15a33d6644015b763b9c9febc1"}, 1245 | {file = "zope.interface-5.4.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:17776ecd3a1fdd2b2cd5373e5ef8b307162f581c693575ec62e7c5399d80794c"}, 1246 | {file = "zope.interface-5.4.0-cp39-cp39-win32.whl", hash = "sha256:d4d9d6c1a455d4babd320203b918ccc7fcbefe308615c521062bc2ba1aa4d26e"}, 1247 | {file = "zope.interface-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:0cba8477e300d64a11a9789ed40ee8932b59f9ee05f85276dbb4b59acee5dd09"}, 1248 | {file = "zope.interface-5.4.0.tar.gz", hash = "sha256:5dba5f530fec3f0988d83b78cc591b58c0b6eb8431a85edd1569a0539a8a5a0e"}, 1249 | ] 1250 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-rest-live" 3 | version = "0.7.0" 4 | description = "Subscriptions for Django REST Framework over Websockets." 5 | authors = ["Penn Labs "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/pennlabs/django-rest-live" 9 | repository = "https://github.com/pennlabs/django-rest-live" 10 | documentation = "https://django-rest-live.readthedocs.io/en/latest/" 11 | classifiers = [ 12 | "Framework :: Django", 13 | ] 14 | include = ["CHANGELOG.md", "LICENSE"] 15 | exclude = ["tox.ini"] 16 | packages = [ 17 | { include = "rest_live" }, 18 | ] 19 | 20 | [tool.poetry.dependencies] 21 | python = ">=3.7" 22 | Django = ">=3.1" 23 | channels = ">=2.0.0" 24 | djangorestframework = ">=3.11" 25 | 26 | [tool.poetry.dev-dependencies] 27 | 28 | [build-system] 29 | requires = ["poetry-core>=1.0.0"] 30 | build-backend = "poetry.core.masonry.api" 31 | -------------------------------------------------------------------------------- /rest_live/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "rest_live.apps.RestLiveConfig" 2 | 3 | DEFAULT_GROUP_BY_FIELD = "pk" 4 | 5 | 6 | def get_group_name(model_label) -> str: 7 | return f"RESOURCE-{model_label}" 8 | 9 | 10 | CREATED = "CREATED" 11 | UPDATED = "UPDATED" 12 | DELETED = "DELETED" 13 | -------------------------------------------------------------------------------- /rest_live/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RestLiveConfig(AppConfig): 5 | name = "rest_live" 6 | -------------------------------------------------------------------------------- /rest_live/consumers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Type, List, Union, Set 2 | from dataclasses import dataclass 3 | 4 | from asgiref.sync import async_to_sync 5 | from channels.generic.websocket import JsonWebsocketConsumer 6 | from django.http import Http404 7 | from rest_framework.exceptions import NotAuthenticated, PermissionDenied 8 | 9 | from rest_live import get_group_name, DELETED, UPDATED, CREATED 10 | from rest_live.mixins import RealtimeMixin 11 | 12 | KwargType = Dict[str, Union[int, str]] 13 | 14 | 15 | @dataclass 16 | class Subscription: 17 | """ 18 | Data representing a subscription request from the client. See documentation for explanation 19 | of what each field does. 20 | """ 21 | 22 | request_id: int 23 | action: str 24 | view_kwargs: Dict[str, Union[int, str]] 25 | query_params: Dict[str, Union[int, str]] 26 | 27 | # To determine if an instance should be considered "created" or "deleted", we need 28 | # to keep track of all the instances that a given subscription currently considers 29 | # visible. This map keeps track of that and will additionally map the primary keys of 30 | # each instance to the lookup field. This will probably be the main resource bottleneck 31 | # in django-rest-live 32 | pks_to_lookup_in_queryset: Dict[int, object] 33 | 34 | 35 | class SubscriptionConsumer(JsonWebsocketConsumer): 36 | """ 37 | Consumer that handles websocket connections, collecting subscriptions and sending broadcasts. 38 | Useful consumers which have a registry of views must subclass `SubscriptionConsumer` and override the `registry` 39 | property. 40 | 41 | One instance of a Consumer class communicates with exactly one client. 42 | """ 43 | 44 | registry: Dict[str, Type[RealtimeMixin]] = dict() 45 | public = True 46 | 47 | def connect(self): 48 | if not self.public and not ( 49 | self.scope.get("user") is not None 50 | and self.scope.get("user").is_authenticated 51 | ): 52 | self.close(code=4003) 53 | 54 | self.subscriptions: Dict[str, List[Subscription]] = dict() 55 | self.accept() 56 | 57 | def send_error(self, request_id, code, message): 58 | self.send_json( 59 | { 60 | "type": "error", 61 | "id": request_id, 62 | "code": code, 63 | "message": message, 64 | } 65 | ) 66 | 67 | def send_broadcast(self, request_id, model_label, action, instance_data, renderer): 68 | # https://www.django-rest-framework.org/api-guide/content-negotiation/ 69 | self.send( 70 | text_data=renderer.render( 71 | { 72 | "type": "broadcast", 73 | "id": request_id, 74 | "model": model_label, 75 | "action": action, 76 | "instance": instance_data, 77 | } 78 | ).decode("utf-8") 79 | ) 80 | 81 | def receive_json(self, content: Dict[str, Any], **kwargs): 82 | """ 83 | Entrypoint for incoming messages from the connected client. 84 | """ 85 | 86 | request_id = content.get("id", None) 87 | if request_id is None: 88 | return # Can't send error message without request ID, so just return. 89 | message_type = content.get("type", None) 90 | if message_type == "subscribe": 91 | model_label = content.get("model") 92 | if model_label is None: 93 | self.send_error(request_id, 400, "No model specified.") 94 | return 95 | 96 | if model_label not in self.registry: 97 | self.send_error( 98 | request_id, 99 | 404, 100 | f"Model {model_label} not registered for realtime updates.", 101 | ) 102 | return 103 | 104 | view_action = content.get("action", None) 105 | if view_action is None or view_action not in ["list", "retrieve"]: 106 | self.send_error( 107 | request_id, 108 | 400, 109 | "`action` must be present and the value must be either `list` or `retrieve`.", 110 | ) 111 | 112 | lookup_value = content.get("lookup_by", None) 113 | view_kwargs = content.get("view_kwargs", dict()) 114 | query_params = content.get("query_params", dict()) 115 | 116 | view = self.registry[model_label].from_scope( 117 | view_action, self.scope, view_kwargs, query_params 118 | ) 119 | 120 | # Check to make sure client has permissions to make this subscription. 121 | has_permission = True 122 | for permission in view.get_permissions(): 123 | has_permission = has_permission and permission.has_permission( 124 | view.request, view 125 | ) 126 | 127 | # Retrieve actions use get_object() to check object permissions as well. 128 | if view.action == "retrieve": 129 | view.kwargs.setdefault(view.lookup_field, lookup_value) 130 | try: 131 | view.get_object() 132 | except Http404: 133 | self.send_error( 134 | request_id, 135 | 404, 136 | "Instance not found. Make sure 'lookup_by' is set to a valid ID", 137 | ) 138 | return 139 | except (NotAuthenticated, PermissionDenied): 140 | has_permission = False 141 | 142 | if not has_permission: 143 | self.send_error( 144 | request_id, 145 | 403, 146 | f"Unauthorized to subscribe to {model_label} for action {view_action}", 147 | ) 148 | return 149 | 150 | # If we've reached this point, then the client can subscribe. 151 | group_name = get_group_name(model_label) 152 | print(f"[REST-LIVE] got subscription to {group_name}") 153 | 154 | self.subscriptions.setdefault(group_name, []).append( 155 | Subscription( 156 | request_id, 157 | action=view_action, 158 | view_kwargs=view_kwargs, 159 | query_params=query_params, 160 | pks_to_lookup_in_queryset=dict( 161 | { 162 | inst["pk"]: inst[view.lookup_field] 163 | for inst in view.get_queryset().all().values("pk", view.lookup_field) 164 | } 165 | ), 166 | ) 167 | ) 168 | 169 | # Add subscribe to updates from channel layer: this is the "actual" subscription action. 170 | async_to_sync(self.channel_layer.group_add)(group_name, self.channel_name) 171 | self.groups.append(group_name) 172 | 173 | elif message_type == "unsubscribe": 174 | # Get the group name given the request_id 175 | try: 176 | # List comprehension is empty if the provided request_id doesn't show up for this consumer 177 | group_name = [ 178 | k 179 | for k, v in self.subscriptions.items() 180 | if request_id in [s.request_id for s in v] 181 | ][0] 182 | except IndexError: 183 | self.send_error( 184 | request_id, 185 | 404, 186 | "Attempted to unsubscribe for request ID before subscribing.", 187 | ) 188 | return 189 | 190 | self.subscriptions[group_name] = [ 191 | sub 192 | for sub in self.subscriptions[group_name] 193 | if sub.request_id != request_id 194 | ] 195 | self.groups.remove( 196 | group_name 197 | ) # Removes the first occurrence of this group name. 198 | if ( 199 | group_name not in self.groups 200 | ): # If there are no more occurrences, unsubscribe to the channel layer. 201 | async_to_sync(self.channel_layer.group_discard)( 202 | group_name, self.channel_name 203 | ) 204 | 205 | # Delete the key in the dictionary if no more subscriptions. 206 | if len(self.subscriptions[group_name]) == 0: 207 | del self.subscriptions[group_name] 208 | else: 209 | self.send_error(request_id, 400, f"unknown message type `{message_type}`.") 210 | 211 | def model_saved(self, event): 212 | channel_name: str = event["channel_name"] 213 | instance_pk: int = event["instance_pk"] 214 | model_label: str = event["model"] 215 | 216 | viewset_class = self.registry[model_label] 217 | 218 | for subscription in self.subscriptions[channel_name]: 219 | view = viewset_class.from_scope( 220 | subscription.action, 221 | self.scope, 222 | subscription.view_kwargs, 223 | subscription.query_params, 224 | ) 225 | 226 | model = view.get_model_class() 227 | renderer = view.perform_content_negotiation(view.request)[0] 228 | 229 | is_existing_instance = instance_pk in subscription.pks_to_lookup_in_queryset 230 | try: 231 | instance = view.filter_queryset(view.get_queryset()).get(pk=instance_pk) 232 | action = UPDATED if is_existing_instance else CREATED 233 | except model.DoesNotExist: 234 | if not is_existing_instance: 235 | # If the model doesn't exist in the queryset now, and also is not in the set of PKs that we've seen, 236 | # then we truly don't have permission to see it. 237 | return 238 | 239 | # If the instance has been seen, then we should get it from the database to serialize and 240 | # send the delete message. 241 | instance = model.objects.get(pk=instance_pk) 242 | action = DELETED 243 | 244 | serializer_class = view.get_serializer_class() 245 | instance_data = serializer_class( 246 | instance, 247 | context={ 248 | "request": view.request, 249 | "format": "json", # TODO: change this to be general based on content negotiation 250 | "view": view, 251 | }, 252 | ).data 253 | 254 | if action == DELETED: 255 | # If an object's deleted from a user's queryset, there's no guarantee that the user still 256 | # has permission to see the contents of the instance, so the instance just returns the lookup_field. 257 | # TODO: clients might expect `id` as well as `pk`, since django defaults to `id`. 258 | if view.lookup_field == "pk" and "id" in instance_data: 259 | instance_data = { 260 | view.lookup_field: getattr(instance, view.lookup_field), 261 | "id": instance_data["id"], 262 | } 263 | else: 264 | instance_data = { 265 | view.lookup_field: getattr(instance, view.lookup_field) 266 | } 267 | 268 | # We don't need to check for membership since it's implicit given broadcast_data isn't None. 269 | if action == DELETED: 270 | del subscription.pks_to_lookup_in_queryset[instance_pk] 271 | else: 272 | subscription.pks_to_lookup_in_queryset[instance_pk] = getattr( 273 | instance, view.lookup_field 274 | ) 275 | self.send_broadcast( 276 | subscription.request_id, model_label, action, instance_data, renderer 277 | ) 278 | 279 | def model_deleted(self, event): 280 | channel_name: str = event["channel_name"] 281 | instance_pk: int = event["instance_pk"] 282 | model_label: str = event["model"] 283 | 284 | viewset_class = self.registry[model_label] 285 | 286 | for subscription in self.subscriptions[channel_name]: 287 | view = viewset_class.from_scope( 288 | subscription.action, 289 | self.scope, 290 | subscription.view_kwargs, 291 | subscription.query_params, 292 | ) 293 | renderer = view.perform_content_negotiation(view.request)[0] 294 | is_existing_instance = instance_pk in subscription.pks_to_lookup_in_queryset 295 | 296 | if is_existing_instance: 297 | instance_data = { 298 | view.lookup_field: subscription.pks_to_lookup_in_queryset[ 299 | instance_pk 300 | ], 301 | "id": instance_pk, 302 | } 303 | del subscription.pks_to_lookup_in_queryset[instance_pk] 304 | self.send_broadcast( 305 | subscription.request_id, 306 | model_label, 307 | DELETED, 308 | instance_data, 309 | renderer, 310 | ) 311 | -------------------------------------------------------------------------------- /rest_live/mixins.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from typing import Type, Set, Tuple, Dict, Any, Optional 3 | 4 | from channels.http import AsgiRequest 5 | from django.db.models import Model 6 | from django.db.models.signals import post_save, post_delete 7 | from django.utils.decorators import classonlymethod 8 | from django.utils.http import urlencode 9 | from rest_framework.generics import GenericAPIView 10 | from rest_live.signals import delete_handler, save_handler 11 | 12 | 13 | class RealtimeMixin(object): 14 | """ 15 | This mixin marks a DRF Generic APIView as realtime capable. It contains utility methods 16 | used internally for initializing the view class based on a ASGI websocket scope and subscription 17 | metadata rather than an HTTP request. 18 | """ 19 | 20 | def get_model_class(self) -> Type[Model]: 21 | """ 22 | Get the model class from the `queryset` property on the view class. This method can be called 23 | when a viewset hasn't been properly initialized, as long as there is a static `queryset` property. 24 | """ 25 | 26 | # TODO: Better model inference from `get_queryset` if we can. 27 | assert getattr(self, "queryset", None) is not None or hasattr( 28 | self, "get_queryset" 29 | ), ( 30 | f"{self.__class__.__name__} does not define a `.queryset` attribute and so no backing model could be" 31 | "determined. Views must provide `.queryset` attribute in order to be realtime-compatible." 32 | ) 33 | assert getattr(self, "queryset", None) is not None, ( 34 | f"{self.__class__.__name__} only defines a dynamic `.get_queryset()` method and so no backing" 35 | "model could be determined. Provide a 'sentinel' queryset of the form `queryset = Model.queryset.none()`" 36 | "to your view class in order to be realtime-compatible." 37 | ) 38 | 39 | return self.queryset.model 40 | 41 | @classmethod 42 | def register_signal_handler(cls, dispatch_uid): 43 | """ 44 | Register post_save signal handler for the view's model. 45 | """ 46 | viewset = cls() 47 | model_class = viewset.get_model_class() 48 | 49 | post_save.connect(save_handler, sender=model_class, dispatch_uid=f"rest-live") 50 | post_delete.connect(delete_handler, sender=model_class, dispatch_uid=f"rest-live") 51 | return viewset.get_model_class()._meta.label 52 | 53 | @classonlymethod 54 | def from_scope(cls, viewset_action, scope, view_kwargs, query_params): 55 | """ 56 | "This is the magic." 57 | (reference: https://github.com/encode/django-rest-framework/blob/1e383f/rest_framework/viewsets.py#L47) 58 | 59 | This method initializes a view properly so that calls to methods like get_queryset() and get_serializer_class(), 60 | and permission checks have all the properties set, like self.kwargs and self.request, that they would expect. 61 | 62 | The production of a Django HttpRequest object from a base websocket asgi scope, rather than an actual HTTP 63 | request, is probably the largest "hack" in this project. By inspection of the ASGI spec, however, 64 | the only difference between websocket and HTTP scopes is the existence of an HTTP method 65 | (https://asgi.readthedocs.io/en/latest/specs/www.html). 66 | 67 | This is because websocket connections are established over an HTTP connection, and so headers and everything 68 | else are set just as they would be in a normal HTTP request. Therefore, the base of the request object for 69 | every broadcast is the initial HTTP request. Subscriptions are retrieval operations, so the method is hard-coded 70 | as GET. 71 | """ 72 | self = cls() 73 | 74 | self.format_kwarg = None 75 | self.action_map = dict() 76 | self.args = [] 77 | self.kwargs = view_kwargs 78 | 79 | base_request = AsgiRequest( 80 | {**scope, "method": "GET", "query_string": urlencode(query_params)}, 81 | BytesIO(), 82 | ) 83 | # TODO: Run other middleware? 84 | base_request.user = scope.get("user", None) 85 | base_request.session = scope.get("session", None) 86 | 87 | self.request = self.initialize_request(base_request) 88 | self.action = viewset_action # TODO: custom subscription actions? 89 | return self 90 | -------------------------------------------------------------------------------- /rest_live/routers.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Type 2 | 3 | from rest_live.consumers import SubscriptionConsumer 4 | from rest_live.mixins import RealtimeMixin 5 | 6 | 7 | class RealtimeRouter: 8 | """ 9 | This router collects views/model pairs that are registered as realtime-capable, and generates 10 | a Django Channels Consumer to handle subscriptions for those models. 11 | """ 12 | 13 | def __init__(self, public=True, uid="default"): 14 | self.registry: Dict[str, Type[RealtimeMixin]] = dict() 15 | self.uid = uid 16 | self.public = public 17 | 18 | def register_all(self, views): 19 | for viewset in views: 20 | self.register(viewset) 21 | 22 | def register(self, view): 23 | if not hasattr(view, "register_signal_handler"): 24 | raise RuntimeError( 25 | f"View {view.__name__}" 26 | "passed to RealtimeRouter does not have RealtimeMixin applied." 27 | ) 28 | label = view.register_signal_handler(self.uid) 29 | if label in self.registry: 30 | raise RuntimeWarning( 31 | "You should not register two realitime views for the same model." 32 | ) 33 | 34 | self.registry[label] = view 35 | 36 | def as_consumer(self): 37 | # Create a subclass of `SubscriptionConsumer` where the consumer's model 38 | # registry is set to this router's registry. Basically a subclass inside a closure. 39 | return type( 40 | "BoundSubscriptionConsumer", 41 | (SubscriptionConsumer,), 42 | dict(registry=self.registry, public=self.public), 43 | ) 44 | -------------------------------------------------------------------------------- /rest_live/signals.py: -------------------------------------------------------------------------------- 1 | from asgiref.sync import async_to_sync 2 | from channels.layers import get_channel_layer 3 | 4 | from rest_live import get_group_name 5 | 6 | 7 | def save_handler(sender, instance, *args, **kwargs): 8 | model_label = sender._meta.label # noqa 9 | channel_layer = get_channel_layer() 10 | group_name = get_group_name(model_label) 11 | async_to_sync(channel_layer.group_send)( 12 | group_name, 13 | { 14 | "type": "model.saved", 15 | "model": model_label, 16 | "instance_pk": instance.pk, 17 | "channel_name": group_name, 18 | }, 19 | ) 20 | 21 | 22 | def delete_handler(sender, instance, *args, **kwargs): 23 | model_label = sender._meta.label # noqa 24 | channel_layer = get_channel_layer() 25 | group_name = get_group_name(model_label) 26 | async_to_sync(channel_layer.group_send)( 27 | group_name, 28 | { 29 | "type": "model.deleted", 30 | "model": model_label, 31 | "instance_pk": instance.pk, 32 | "channel_name": group_name, 33 | }, 34 | ) 35 | -------------------------------------------------------------------------------- /rest_live/testing.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse, unquote 2 | import json 3 | 4 | from asgiref.sync import async_to_sync 5 | from asgiref.testing import ApplicationCommunicator 6 | from django.conf import settings 7 | from django.http import HttpRequest, SimpleCookie 8 | from importlib import import_module 9 | from channels.db import database_sync_to_async 10 | 11 | 12 | class APICommunicator(ApplicationCommunicator): 13 | def __init__(self, application, path, headers=None, subprotocols=None, **extra): 14 | if not isinstance(path, str): 15 | raise TypeError("Expected str, got {}".format(type(path))) 16 | parsed = urlparse(path) 17 | 18 | self.scope = { 19 | "asgi": {"version": "3.0"}, 20 | "type": "websocket", 21 | "scheme": "ws", 22 | "http_version": "1.1", 23 | "client": ["127.0.0.1", 0], 24 | "server": ("testserver", "80"), 25 | "path": unquote(parsed.path), 26 | "query_string": parsed.query.encode("utf-8"), 27 | "headers": headers or [], 28 | "subprotocols": subprotocols or [], 29 | **extra, 30 | } 31 | super().__init__(application, self.scope) 32 | 33 | async def connect(self, timeout=1): 34 | """ 35 | Trigger the connection code. 36 | 37 | On an accepted connection, returns (True, ) 38 | On a rejected connection, returns (False, ) 39 | """ 40 | await self.send_input({"type": "websocket.connect"}) 41 | response = await self.receive_output(timeout) 42 | if response["type"] == "websocket.close": 43 | return (False, response.get("code", 1000)) 44 | else: 45 | return (True, response.get("subprotocol", None)) 46 | 47 | async def send_to(self, text_data=None, bytes_data=None): 48 | """ 49 | Sends a WebSocket frame to the application. 50 | """ 51 | # Make sure we have exactly one of the arguments 52 | assert bool(text_data) != bool( 53 | bytes_data 54 | ), "You must supply exactly one of text_data or bytes_data" 55 | # Send the right kind of event 56 | if text_data: 57 | assert isinstance(text_data, str), "The text_data argument must be a str" 58 | await self.send_input({"type": "websocket.receive", "text": text_data}) 59 | else: 60 | assert isinstance( 61 | bytes_data, bytes 62 | ), "The bytes_data argument must be bytes" 63 | await self.send_input({"type": "websocket.receive", "bytes": bytes_data}) 64 | 65 | async def send_json_to(self, data): 66 | """ 67 | Sends JSON data as a text frame 68 | """ 69 | await self.send_to(text_data=json.dumps(data)) 70 | 71 | async def receive_from(self, timeout=1): 72 | """ 73 | Receives a data frame from the view. Will fail if the connection 74 | closes instead. Returns either a bytestring or a unicode string 75 | depending on what sort of frame you got. 76 | """ 77 | response = await self.receive_output(timeout) 78 | # Make sure this is a send message 79 | assert response["type"] == "websocket.send" 80 | # Make sure there's exactly one key in the response 81 | assert ("text" in response) != ( 82 | "bytes" in response 83 | ), "The response needs exactly one of 'text' or 'bytes'" 84 | # Pull out the right key and typecheck it for our users 85 | if "text" in response: 86 | assert isinstance(response["text"], str), "Text frame payload is not str" 87 | return response["text"] 88 | else: 89 | assert isinstance( 90 | response["bytes"], bytes 91 | ), "Binary frame payload is not bytes" 92 | return response["bytes"] 93 | 94 | async def receive_json_from(self, timeout=1): 95 | """ 96 | Receives a JSON text frame payload and decodes it 97 | """ 98 | payload = await self.receive_from(timeout) 99 | assert isinstance(payload, str), "JSON data is not a text frame" 100 | return json.loads(payload) 101 | 102 | async def disconnect(self, code=1000, timeout=1): 103 | """ 104 | Closes the socket 105 | """ 106 | await self.send_input({"type": "websocket.disconnect", "code": code}) 107 | await self.wait(timeout) 108 | 109 | 110 | def async_test(fun): 111 | async def wrapped(self, *args, **kwargs): 112 | if hasattr(self, "asyncSetUp"): 113 | await self.asyncSetUp() 114 | ret = await fun(self, *args, **kwargs) 115 | if hasattr(self, "asyncTearDown"): 116 | await self.asyncTearDown() 117 | return ret 118 | 119 | return wrapped 120 | 121 | 122 | def _login(user, backend=None): 123 | from django.contrib.auth import login 124 | 125 | engine = import_module(settings.SESSION_ENGINE) 126 | 127 | # Create a fake request to store login details. 128 | request = HttpRequest() 129 | request.session = engine.SessionStore() 130 | login(request, user, backend) 131 | 132 | # Save the session values. 133 | request.session.save() 134 | 135 | # Create a cookie to represent the session. 136 | session_cookie = settings.SESSION_COOKIE_NAME 137 | cookies = SimpleCookie() 138 | cookies[session_cookie] = request.session.session_key 139 | cookie_data = { 140 | "max-age": None, 141 | "path": "/", 142 | "domain": settings.SESSION_COOKIE_DOMAIN, 143 | "secure": settings.SESSION_COOKIE_SECURE or None, 144 | "expires": None, 145 | } 146 | cookies[session_cookie].update(cookie_data) 147 | return cookies 148 | 149 | 150 | @database_sync_to_async 151 | def force_login(user, backend=None): 152 | def get_backend(): 153 | from django.contrib.auth import load_backend 154 | 155 | for backend_path in settings.AUTHENTICATION_BACKENDS: 156 | backend = load_backend(backend_path) 157 | if hasattr(backend, "get_user"): 158 | return backend_path 159 | 160 | if backend is None: 161 | backend = get_backend() 162 | user.backend = backend 163 | return _login(user, backend) 164 | 165 | 166 | async def get_headers_for_user(user): 167 | cookies = await force_login(user) 168 | return [(b"cookie", cookies.output(header="", sep="; ").encode())] 169 | -------------------------------------------------------------------------------- /test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/django-rest-live/52047b6e734b30928b7fae1d44cdc8708d45303a/test_app/__init__.py -------------------------------------------------------------------------------- /test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = "test_app" 6 | -------------------------------------------------------------------------------- /test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-08-29 20:32 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="List", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=64)), 27 | ], 28 | ), 29 | migrations.CreateModel( 30 | name="Todo", 31 | fields=[ 32 | ( 33 | "id", 34 | models.AutoField( 35 | auto_created=True, 36 | primary_key=True, 37 | serialize=False, 38 | verbose_name="ID", 39 | ), 40 | ), 41 | ("text", models.CharField(max_length=140)), 42 | ("done", models.BooleanField(default=False)), 43 | ("another_field", models.BooleanField(default=True)), 44 | ( 45 | "list", 46 | models.ForeignKey( 47 | on_delete=django.db.models.deletion.CASCADE, to="test_app.list" 48 | ), 49 | ), 50 | ], 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/django-rest-live/52047b6e734b30928b7fae1d44cdc8708d45303a/test_app/migrations/__init__.py -------------------------------------------------------------------------------- /test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.db import models 3 | 4 | 5 | class List(models.Model): 6 | name = models.CharField(max_length=64) 7 | 8 | 9 | class Todo(models.Model): 10 | text = models.CharField(max_length=140) 11 | done = models.BooleanField(default=False) 12 | list = models.ForeignKey("List", on_delete=models.CASCADE) 13 | another_field = models.BooleanField(default=True) 14 | 15 | 16 | admin.site.register(List) 17 | admin.site.register(Todo) 18 | -------------------------------------------------------------------------------- /test_app/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.permissions import BasePermission 3 | 4 | from test_app.models import Todo 5 | 6 | 7 | class TodoSerializer(serializers.ModelSerializer): 8 | text_length = serializers.IntegerField(required=False, read_only=True) 9 | 10 | class Meta: 11 | model = Todo 12 | fields = ["id", "text", "done", "another_field", "text_length"] 13 | read_only_fields = ["text_length"] 14 | 15 | 16 | class KwargsTodoSerializer(serializers.ModelSerializer): 17 | message = serializers.SerializerMethodField() 18 | 19 | class Meta: 20 | model = Todo 21 | fields = ["message"] 22 | 23 | def get_message(self, *args, **kwargs): 24 | return self.context["view"].kwargs.get("message") 25 | 26 | 27 | class AuthedTodoSerializer(serializers.ModelSerializer): 28 | auth = serializers.SerializerMethodField() 29 | 30 | class Meta: 31 | model = Todo 32 | fields = ["id", "text", "done", "another_field", "auth"] 33 | 34 | def get_auth(self, obj): 35 | return "ADMIN" 36 | -------------------------------------------------------------------------------- /test_app/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models.functions import Length 2 | 3 | from rest_framework import viewsets, filters 4 | from rest_framework.generics import GenericAPIView 5 | from rest_framework.permissions import ( 6 | IsAuthenticated, 7 | BasePermission, 8 | ) 9 | 10 | from rest_live.mixins import RealtimeMixin 11 | from test_app.models import Todo 12 | 13 | from test_app.serializers import ( 14 | TodoSerializer, 15 | AuthedTodoSerializer, 16 | KwargsTodoSerializer, 17 | ) 18 | 19 | 20 | class TodoViewSet(GenericAPIView, RealtimeMixin): 21 | queryset = Todo.objects.all() 22 | serializer_class = TodoSerializer 23 | filter_backends = [filters.SearchFilter] 24 | search_fields = ["text"] 25 | 26 | 27 | class ConditionalTodoViewSet(viewsets.ModelViewSet, RealtimeMixin): 28 | queryset = Todo.objects.all() 29 | 30 | def get_serializer_class(self): 31 | if self.request.user.is_authenticated: 32 | return AuthedTodoSerializer 33 | else: 34 | return TodoSerializer 35 | 36 | 37 | class AuthedTodoViewSet(viewsets.ModelViewSet, RealtimeMixin): 38 | queryset = Todo.objects.all() 39 | serializer_class = TodoSerializer 40 | permission_classes = [IsAuthenticated] 41 | 42 | 43 | class LookupTodoViewSet(viewsets.ModelViewSet, RealtimeMixin): 44 | queryset = Todo.objects.all() 45 | serializer_class = TodoSerializer 46 | lookup_field = "text" 47 | 48 | 49 | class KwargPermission(BasePermission): 50 | def has_permission(self, request, view): 51 | return view.kwargs.get("password", "") == "opensesame" 52 | 53 | 54 | class ParamPermission(BasePermission): 55 | def has_permission(self, request, view): 56 | return request.query_params.get("password", "") == "opensesame-param" 57 | 58 | 59 | class KwargViewSet(GenericAPIView, RealtimeMixin): 60 | queryset = Todo.objects.all() 61 | serializer_class = KwargsTodoSerializer 62 | permission_classes = [KwargPermission | ParamPermission] 63 | 64 | 65 | class FilteredViewSet(GenericAPIView, RealtimeMixin): 66 | queryset = Todo.objects.filter(text="special") 67 | serializer_class = TodoSerializer 68 | 69 | 70 | class AnnotatedTodoViewSet(GenericAPIView, RealtimeMixin): 71 | queryset = Todo.objects.all() 72 | serializer_class = TodoSerializer 73 | 74 | def get_queryset(self): 75 | return super().get_queryset().annotate(text_length=Length("text")) 76 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "supersecret" 2 | 3 | DEBUG = True 4 | 5 | ALLOWED_HOSTS = [] 6 | 7 | INSTALLED_APPS = [ 8 | "django.contrib.admin", 9 | "django.contrib.auth", 10 | "django.contrib.contenttypes", 11 | "django.contrib.sessions", 12 | "django.contrib.messages", 13 | "django.contrib.staticfiles", 14 | "channels", 15 | "rest_framework", 16 | "rest_live", 17 | "test_app.apps.TestAppConfig", 18 | ] 19 | 20 | MIDDLEWARE = [ 21 | "django.middleware.security.SecurityMiddleware", 22 | "django.contrib.sessions.middleware.SessionMiddleware", 23 | "django.middleware.common.CommonMiddleware", 24 | "django.middleware.csrf.CsrfViewMiddleware", 25 | "django.contrib.auth.middleware.AuthenticationMiddleware", 26 | "django.contrib.messages.middleware.MessageMiddleware", 27 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 28 | ] 29 | 30 | TEMPLATES = [ 31 | { 32 | "BACKEND": "django.template.backends.django.DjangoTemplates", 33 | "DIRS": [], 34 | "APP_DIRS": True, 35 | "OPTIONS": { 36 | "context_processors": [ 37 | "django.template.context_processors.debug", 38 | "django.template.context_processors.request", 39 | "django.contrib.auth.context_processors.auth", 40 | "django.contrib.messages.context_processors.messages", 41 | ], 42 | }, 43 | }, 44 | ] 45 | 46 | ASGI_APPLICATION = "tests.routing.application" 47 | 48 | DATABASES = { 49 | "default": { 50 | "ENGINE": "django.db.backends.sqlite3", 51 | "NAME": "db.sqlite3", 52 | } 53 | } 54 | 55 | # Internationalization 56 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 57 | 58 | LANGUAGE_CODE = "en-us" 59 | 60 | TIME_ZONE = "UTC" 61 | 62 | USE_I18N = True 63 | 64 | USE_L10N = True 65 | 66 | USE_TZ = True 67 | 68 | 69 | # Static files (CSS, JavaScript, Images) 70 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 71 | 72 | STATIC_URL = "/static/" 73 | 74 | # Test outputs 75 | TEST_OUTPUT_DIR = "test-results" 76 | 77 | # Django Channels Setup 78 | CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} 79 | 80 | REST_FRAMEWORK = { 81 | "DEFAULT_RENDERER_CLASSES": ( 82 | "djangorestframework_camel_case.render.CamelCaseJSONRenderer", 83 | "djangorestframework_camel_case.render.CamelCaseBrowsableAPIRenderer", 84 | # Any other renders 85 | ), 86 | "DEFAULT_PARSER_CLASSES": ( 87 | # If you use MultiPartFormParser or FormParser, we also have a camel case version 88 | "djangorestframework_camel_case.parser.CamelCaseFormParser", 89 | "djangorestframework_camel_case.parser.CamelCaseMultiPartParser", 90 | "djangorestframework_camel_case.parser.CamelCaseJSONParser", 91 | # Any other parsers 92 | ), 93 | } 94 | -------------------------------------------------------------------------------- /tests/test_live.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from channels.auth import AuthMiddlewareStack 4 | from django.contrib.auth import get_user_model 5 | from rest_framework.generics import GenericAPIView 6 | from rest_framework.views import APIView 7 | 8 | from rest_live.mixins import RealtimeMixin 9 | from rest_live.testing import APICommunicator 10 | from channels.db import database_sync_to_async as db 11 | from channels import __version__ as channels_version 12 | 13 | from rest_live import CREATED, UPDATED, DELETED 14 | from rest_live.routers import RealtimeRouter 15 | from rest_live.testing import async_test, get_headers_for_user 16 | 17 | from test_app.models import List, Todo 18 | from test_app.serializers import AuthedTodoSerializer, TodoSerializer 19 | from test_app.views import ( 20 | TodoViewSet, 21 | AuthedTodoViewSet, 22 | ConditionalTodoViewSet, 23 | KwargViewSet, 24 | FilteredViewSet, 25 | AnnotatedTodoViewSet, 26 | LookupTodoViewSet, 27 | ) 28 | from tests.utils import RestLiveTestCase 29 | 30 | User = get_user_model() 31 | 32 | 33 | def make_client(consumer, path, middleware=lambda x: x, headers=None): 34 | if channels_version.startswith("2"): 35 | return APICommunicator(middleware(consumer), path, headers) 36 | else: 37 | return APICommunicator(middleware(consumer.as_asgi()), path, headers) 38 | 39 | 40 | class BasicResourceTests(RestLiveTestCase): 41 | """ 42 | Basic subscription tests on single resources, retrieving by the lookup_field 43 | on the view. 44 | """ 45 | 46 | async def asyncSetUp(self): 47 | router = RealtimeRouter() 48 | router.register(TodoViewSet) 49 | 50 | self.client = make_client(router.as_consumer(), "/ws/subscribe/") 51 | connected, _ = await self.client.connect() 52 | self.assertTrue(connected) 53 | self.list = await db(List.objects.create)(name="test list") 54 | 55 | async def asyncTearDown(self): 56 | await self.client.disconnect() 57 | 58 | @async_test 59 | async def test_single_update(self): 60 | self.todo = await db(Todo.objects.create)(list=self.list, text="test") 61 | req = await self.subscribe_to_todo() 62 | self.todo.text = "MODIFIED" 63 | await db(self.todo.save)() 64 | await self.assertReceivedBroadcastForTodo(self.todo, UPDATED, req) 65 | 66 | @async_test 67 | async def test_list_unsubscribe(self): 68 | self.todo = await self.make_todo() 69 | req = await self.subscribe_to_todo() 70 | self.todo.text = "MODIFIED" 71 | await db(self.todo.save)() 72 | await self.assertReceivedBroadcastForTodo(self.todo, UPDATED, req) 73 | await self.unsubscribe(req) 74 | self.todo.text = "MODIFIED AGAIN" 75 | await db(self.todo.save)() 76 | self.assertTrue(await self.client.receive_nothing()) 77 | 78 | 79 | class BasicListTests(RestLiveTestCase): 80 | """ 81 | Basic tests on list actions based on a foreign key. 82 | """ 83 | 84 | async def asyncSetUp(self): 85 | router = RealtimeRouter() 86 | router.register(TodoViewSet) 87 | 88 | self.client = make_client(router.as_consumer(), "/ws/subscribe/") 89 | connected, _ = await self.client.connect() 90 | self.assertTrue(connected) 91 | self.list = await db(List.objects.create)(name="test list") 92 | # register_subscription(TodoSerializer, "list_id", None) 93 | 94 | async def asyncTearDown(self): 95 | await self.client.disconnect() 96 | 97 | @async_test 98 | async def test_list_subscribe_create(self): 99 | req = await self.subscribe_to_list() 100 | 101 | new_todo = await db(Todo.objects.create)(list=self.list, text="test") 102 | await self.assertReceivedBroadcastForTodo(new_todo, CREATED, req) 103 | 104 | @async_test 105 | async def test_list_unsubscribe(self): 106 | req = await self.subscribe_to_list() 107 | 108 | new_todo = await self.make_todo() 109 | await self.assertReceivedBroadcastForTodo(new_todo, CREATED, req) 110 | await self.unsubscribe(req) 111 | await self.make_todo() 112 | self.assertTrue(await self.client.receive_nothing()) 113 | 114 | @async_test 115 | async def test_list_unsubscribe_does_stack(self): 116 | # Subscribe twice. 117 | req1 = await self.subscribe_to_list() 118 | req2 = await self.subscribe_to_list() 119 | 120 | new_todo = await self.make_todo() 121 | await self.assertReceivedBroadcastForTodo(new_todo, CREATED, req1) 122 | await self.assertReceivedBroadcastForTodo(new_todo, CREATED, req2) 123 | self.assertTrue(await self.client.receive_nothing()) 124 | 125 | # Unsubscribe once, make sure the message is still sent on update. 126 | await self.unsubscribe(req1) 127 | new_todo.done = True 128 | await db(new_todo.save)() 129 | await self.assertReceivedBroadcastForTodo(new_todo, UPDATED, req2) 130 | self.assertTrue(await self.client.receive_nothing()) 131 | 132 | # Unsubscribe again, make sure no message is sent on update. 133 | await self.unsubscribe(req2) 134 | new_todo.text = "changed" 135 | await db(new_todo.save)() 136 | self.assertTrue(await self.client.receive_nothing()) 137 | 138 | @async_test 139 | async def test_list_subscribe_update(self): 140 | new_todo = await self.make_todo() 141 | req = await self.subscribe_to_list() 142 | new_todo.done = True 143 | await db(new_todo.save)() 144 | await self.assertReceivedBroadcastForTodo(new_todo, UPDATED, req) 145 | 146 | # TODO: Fix delete 147 | # TODO: Think about adding a way to mark a field as a "conditional delete" so when it updates in the DB it 148 | # sends the delete signal to the frontend 149 | @async_test 150 | async def test_list_subscribe_delete(self): 151 | new_todo = await self.make_todo() 152 | req = await self.subscribe_to_list() 153 | pk = new_todo.pk 154 | await db(new_todo.delete)() 155 | new_todo.id = pk 156 | await self.assertReceivedBroadcastForTodo(new_todo, DELETED, req) 157 | 158 | 159 | class PermissionsTests(RestLiveTestCase): 160 | async def asyncSetUp(self): 161 | self.list = await db(List.objects.create)(name="test list") 162 | router = RealtimeRouter() 163 | router.register(AuthedTodoViewSet) 164 | self.client = make_client( 165 | router.as_consumer(), 166 | "/ws/subscribe/", 167 | AuthMiddlewareStack 168 | ) 169 | connected, _ = await self.client.connect() 170 | self.assertTrue(connected) 171 | 172 | self.user = await db(User.objects.create_user)("test") 173 | headers = await get_headers_for_user(self.user) 174 | self.auth_client = make_client( 175 | router.as_consumer(), 176 | "/ws/subscribe/", 177 | AuthMiddlewareStack, 178 | headers, 179 | ) 180 | connected, _ = await self.auth_client.connect() 181 | self.assertTrue(connected) 182 | 183 | async def asyncTearDown(self): 184 | await self.client.disconnect() 185 | await self.auth_client.disconnect() 186 | 187 | @async_test 188 | async def test_list_sub_no_permission(self): 189 | await self.subscribe_to_list(error=403) 190 | await self.make_todo() 191 | self.assertTrue(await self.client.receive_nothing()) 192 | 193 | @async_test 194 | async def test_list_sub_with_permission(self): 195 | req = await self.subscribe_to_list(self.auth_client) 196 | new_todo = await self.make_todo() 197 | await self.assertReceivedBroadcastForTodo( 198 | new_todo, CREATED, req, communicator=self.auth_client 199 | ) 200 | 201 | @async_test 202 | async def test_list_sub_conditional_serializers(self): 203 | await self.client.disconnect() 204 | await self.auth_client.disconnect() 205 | router = RealtimeRouter() 206 | router.register(ConditionalTodoViewSet) 207 | self.client = make_client( 208 | router.as_consumer(), 209 | "/ws/subscribe/", 210 | AuthMiddlewareStack, 211 | ) 212 | connected, _ = await self.client.connect() 213 | self.assertTrue(connected) 214 | 215 | headers = await get_headers_for_user(self.user) 216 | self.auth_client = make_client( 217 | router.as_consumer(), 218 | "/ws/subscribe/", 219 | AuthMiddlewareStack, 220 | headers, 221 | ) 222 | connected, _ = await self.auth_client.connect() 223 | self.assertTrue(connected) 224 | 225 | req = await self.subscribe_to_list(self.client) 226 | req_auth = await self.subscribe_to_list(self.auth_client) 227 | new_todo = await self.make_todo() 228 | 229 | await self.assertReceivedBroadcastForTodo( 230 | new_todo, CREATED, req, communicator=self.client 231 | ) 232 | await self.assertReceivedBroadcastForTodo( 233 | new_todo, 234 | CREATED, 235 | req_auth, 236 | communicator=self.auth_client, 237 | serializer=AuthedTodoSerializer, 238 | ) 239 | 240 | # Assert that each connection has only received a single update. 241 | self.assertTrue(await self.client.receive_nothing()) 242 | self.assertTrue(await self.auth_client.receive_nothing()) 243 | 244 | 245 | class ViewKwargTests(RestLiveTestCase): 246 | """ 247 | Tests to ensure that view kwargs (generally URL parameters) passed along with subscriptions are 248 | properly handled in permissions and serializers. 249 | """ 250 | 251 | async def asyncSetUp(self): 252 | router = RealtimeRouter() 253 | router.register(KwargViewSet) 254 | self.client = make_client(router.as_consumer(), "/ws/subscribe/") 255 | connected, _ = await self.client.connect() 256 | self.assertTrue(connected) 257 | self.list = await db(List.objects.create)(name="test list") 258 | 259 | async def asyncTearDown(self): 260 | await self.client.disconnect() 261 | 262 | @async_test 263 | async def test_permission_with_kwargs_fails(self): 264 | await self.subscribe_to_list(error=403) 265 | new_todo = await db(Todo.objects.create)(list=self.list, text="test") 266 | self.assertTrue(await self.client.receive_nothing()) 267 | 268 | @async_test 269 | async def test_permission_with_kwargs_succeeds(self): 270 | await self.subscribe_to_list(kwargs={"password": "opensesame"}) 271 | 272 | @async_test 273 | async def test_permission_with_params_succeeds(self): 274 | await self.subscribe_to_list(params={"password": "opensesame-param"}) 275 | 276 | @async_test 277 | async def test_serializer_with_kwargs(self): 278 | request_id = await self.subscribe_to_list( 279 | kwargs={"password": "opensesame", "message": "hello"} 280 | ) 281 | new_todo = await db(Todo.objects.create)(list=self.list, text="test") 282 | response = await self.client.receive_json_from() 283 | self.assertDictEqual( 284 | { 285 | "type": "broadcast", 286 | "id": request_id, 287 | "model": "test_app.Todo", 288 | "action": CREATED, 289 | "instance": {"message": "hello"}, 290 | }, 291 | response, 292 | ) 293 | 294 | 295 | class QueryParamsTests(RestLiveTestCase): 296 | """ 297 | Tests to ensure that query (GET) params passed along with subscriptions are 298 | handled properly. This ensures compatibility with DRF filter backends. 299 | """ 300 | 301 | async def asyncSetUp(self): 302 | router = RealtimeRouter() 303 | router.register(TodoViewSet) 304 | self.client = make_client(router.as_consumer(), "/ws/subscribe/") 305 | connected, _ = await self.client.connect() 306 | self.assertTrue(connected) 307 | self.list = await db(List.objects.create)(name="test list") 308 | 309 | async def asyncTearDown(self): 310 | await self.client.disconnect() 311 | 312 | @async_test 313 | async def test_list(self): 314 | request_id = await self.subscribe_to_list(params={"search": "hello"}) 315 | 316 | todo = await self.make_todo("hello world") 317 | await self.assertReceivedBroadcastForTodo(todo, CREATED, request_id) 318 | 319 | await db(todo.save)() 320 | await self.assertReceivedBroadcastForTodo(todo, UPDATED, request_id) 321 | 322 | todo.text = "goodbye world" # No longer matches search query 323 | await db(todo.save)() 324 | await self.assertReceivedBroadcastForTodo(todo, DELETED, request_id) 325 | 326 | # Make sure new ToDos that don't match the query are never broadcasted 327 | await self.make_todo("no match") 328 | self.assertTrue(await self.client.receive_nothing()) 329 | 330 | @async_test 331 | async def test_retrieve(self): 332 | self.todo = await self.make_todo("hello world") 333 | request_id = await self.subscribe_to_todo(params={"search": "hello"}) 334 | 335 | await db(self.todo.save)() 336 | await self.assertReceivedBroadcastForTodo(self.todo, UPDATED, request_id) 337 | 338 | self.todo.text = "goodbye world" # No longer matches the query 339 | await db(self.todo.save)() 340 | await self.assertReceivedBroadcastForTodo(self.todo, DELETED, request_id) 341 | 342 | 343 | class QuerysetFetchTest(RestLiveTestCase): 344 | """ 345 | Tests to make sure that subscriptions properly respect the queryset on the view. 346 | """ 347 | 348 | async def asyncSetUp(self): 349 | router = RealtimeRouter() 350 | router.register(FilteredViewSet) 351 | self.client = make_client(router.as_consumer(), "/ws/subscribe/") 352 | connected, _ = await self.client.connect() 353 | self.assertTrue(connected) 354 | self.list = await db(List.objects.create)(name="test list") 355 | 356 | async def asyncTearDown(self): 357 | await self.client.disconnect() 358 | 359 | @async_test 360 | async def test_empty_queryset_not_found_list(self): 361 | await self.subscribe_to_list() 362 | new_todo = await db(Todo.objects.create)(list=self.list, text="test") 363 | self.assertTrue(await self.client.receive_nothing()) 364 | 365 | @async_test 366 | async def test_empty_queryset_not_found_individual(self): 367 | self.todo = await db(Todo.objects.create)(list=self.list, text="test") 368 | await self.subscribe_to_todo(client=self.client, error=404) 369 | 370 | @async_test 371 | async def test_filter_matches(self): 372 | req = await self.subscribe_to_list() 373 | new_todo = await self.make_todo("special") 374 | await self.assertReceivedBroadcastForTodo(new_todo, CREATED, req) 375 | 376 | @async_test 377 | async def test_filter_matches_then_doesnt(self): 378 | new_todo = await self.make_todo("special") 379 | req = await self.subscribe_to_list() 380 | new_todo.text = "not special" 381 | await db(new_todo.save)() 382 | await self.assertReceivedBroadcastForTodo(new_todo, DELETED, req) 383 | new_todo.text = "not special at all" 384 | # Make sure we only send the delete message once 385 | await db(new_todo.save)() 386 | self.assertTrue(await self.client.receive_nothing()) 387 | 388 | @async_test 389 | async def test_filter_doesnt_match_then_does(self): 390 | new_todo = await self.make_todo("not special") 391 | req = await self.subscribe_to_list() 392 | new_todo.text = "special" 393 | await db(new_todo.save)() 394 | await self.assertReceivedBroadcastForTodo(new_todo, CREATED, req) 395 | 396 | 397 | class AnnotatedTodoTest(RestLiveTestCase): 398 | """ 399 | Tests to make sure that subscriptions properly annotate the queryset. 400 | """ 401 | 402 | async def asyncSetUp(self): 403 | router = RealtimeRouter() 404 | router.register(AnnotatedTodoViewSet) 405 | self.client = make_client(router.as_consumer(), "/ws/subscribe/") 406 | connected, _ = await self.client.connect() 407 | self.assertTrue(connected) 408 | self.list = await db(List.objects.create)(name="test list") 409 | 410 | async def asyncTearDown(self): 411 | await self.client.disconnect() 412 | 413 | @async_test 414 | async def test_annotations(self): 415 | req = await self.subscribe_to_list() 416 | 417 | todo_text = "hello" 418 | todo = await self.make_todo(text=todo_text) 419 | res = await self.client.receive_json_from() 420 | self.assertEqual(res["instance"]["textLength"], len(todo_text)) 421 | 422 | todo_text = "modified" 423 | todo.text = todo_text 424 | await db(todo.save)() 425 | res = await self.client.receive_json_from() 426 | self.assertEqual(res["instance"]["textLength"], len(todo_text)) 427 | 428 | 429 | class LookupTodoTest(RestLiveTestCase): 430 | """ 431 | Tests to make sure that subscriptions properly account for lookup fields other than pk. 432 | """ 433 | 434 | async def asyncSetUp(self): 435 | router = RealtimeRouter() 436 | router.register(LookupTodoViewSet) 437 | self.client = make_client(router.as_consumer(), "/ws/subscribe/") 438 | connected, _ = await self.client.connect() 439 | self.assertTrue(connected) 440 | self.list = await db(List.objects.create)(name="test list") 441 | await db(Todo.objects.create)(list=self.list, text="test") 442 | 443 | async def asyncTearDown(self): 444 | await self.client.disconnect() 445 | 446 | @async_test 447 | async def test_list_subscription(self): 448 | req = await self.subscribe_to_list() 449 | 450 | new_todo = await self.make_todo(text="test 1") 451 | await self.assertReceivedBroadcastForTodo( 452 | new_todo, CREATED, req, lookup_field="text" 453 | ) 454 | 455 | new_todo.text = "new text" 456 | await db(new_todo.save)() 457 | await self.assertReceivedBroadcastForTodo( 458 | new_todo, UPDATED, req, lookup_field="text" 459 | ) 460 | 461 | pk = new_todo.pk 462 | await db(new_todo.delete)() 463 | new_todo.id = pk 464 | await self.assertReceivedBroadcastForTodo( 465 | new_todo, DELETED, req, lookup_field="text" 466 | ) 467 | 468 | @async_test 469 | async def test_retrieve_subscription(self): 470 | self.todo = await db(Todo.objects.create)(list=self.list, text="test 5") 471 | req = await self.subscribe_to_todo(lookup_field="text") 472 | 473 | self.todo.text = "new test" 474 | await db(self.todo.save)() 475 | await self.assertReceivedBroadcastForTodo( 476 | self.todo, UPDATED, req, lookup_field="text" 477 | ) 478 | 479 | pk = self.todo.pk 480 | await db(self.todo.delete)() 481 | self.todo.id = pk 482 | await self.assertReceivedBroadcastForTodo( 483 | self.todo, DELETED, req, lookup_field="text" 484 | ) 485 | 486 | 487 | class PrivateRouterTests(RestLiveTestCase): 488 | async def asyncSetUp(self): 489 | self.list = await db(List.objects.create)(name="test list") 490 | self.router = RealtimeRouter(public=False) 491 | self.router.register(TodoViewSet) 492 | 493 | @async_test 494 | async def test_reject_no_auth(self): 495 | self.client = make_client( 496 | self.router.as_consumer(), 497 | "/ws/subscribe/", 498 | AuthMiddlewareStack 499 | ) 500 | connected, code = await self.client.connect() 501 | self.assertFalse(connected) 502 | self.assertEqual(4003, code) 503 | 504 | @async_test 505 | async def test_reject_no_middleware(self): 506 | self.client = make_client( 507 | self.router.as_consumer(), 508 | "/ws/subscribe/", 509 | ) 510 | connected, code = await self.client.connect() 511 | self.assertFalse(connected) 512 | self.assertEqual(4003, code) 513 | 514 | @async_test 515 | async def test_accept_with_auth(self): 516 | user = await db(User.objects.create_user)("test") 517 | headers = await get_headers_for_user(user) 518 | self.client = make_client( 519 | self.router.as_consumer(), "/ws/subscribe/", AuthMiddlewareStack, headers 520 | ) 521 | connected, _ = await self.client.connect() 522 | self.assertTrue(connected) 523 | 524 | 525 | class MultiRouterTests(RestLiveTestCase): 526 | """ 527 | Tests to ensure that multiple routers/consumers can be stood up on 528 | separate domains and that they don't interfere with each other. 529 | """ 530 | 531 | async def asyncSetUp(self): 532 | self.list = await db(List.objects.create)(name="test list") 533 | self.router1 = RealtimeRouter() 534 | self.router1.register(TodoViewSet) 535 | self.router2 = RealtimeRouter("auth") 536 | self.router2.register(AuthedTodoViewSet) 537 | 538 | self.user = await db(User.objects.create_user)("test") 539 | self.headers = await get_headers_for_user(self.user) 540 | 541 | async def asyncTearDown(self): 542 | await self.client1.disconnect() 543 | await self.client2.disconnect() 544 | 545 | @async_test 546 | async def test_broadcasts_one_per_router(self): 547 | self.client1 = make_client( 548 | self.router1.as_consumer(), 549 | "/ws/subscribe/", 550 | AuthMiddlewareStack, 551 | self.headers, 552 | ) 553 | self.assertTrue(await self.client1.connect()) 554 | self.client2 = make_client( 555 | self.router2.as_consumer(), 556 | "/ws/subscribe/auth/", 557 | AuthMiddlewareStack, 558 | self.headers, 559 | ) 560 | self.assertTrue(await self.client2.connect()) 561 | 562 | req1 = await self.subscribe_to_list(self.client1) 563 | req2 = await self.subscribe_to_list(self.client2) 564 | 565 | new_todo = await db(Todo.objects.create)(list=self.list, text="test") 566 | await self.assertReceivedBroadcastForTodo(new_todo, CREATED, req1, self.client1) 567 | self.assertTrue(await self.client1.receive_nothing()) 568 | await self.assertReceivedBroadcastForTodo(new_todo, CREATED, req2, self.client2) 569 | self.assertTrue(await self.client2.receive_nothing()) 570 | 571 | @async_test 572 | async def test_broadcasts_only_to_one(self): 573 | self.client1 = make_client( 574 | self.router1.as_consumer(), 575 | "/ws/subscribe/", 576 | AuthMiddlewareStack, 577 | self.headers, 578 | ) 579 | self.assertTrue(await self.client1.connect()) 580 | self.client2 = make_client( 581 | self.router2.as_consumer(), "/ws/subscribe/auth/", AuthMiddlewareStack, 582 | ) 583 | self.assertTrue(await self.client2.connect()) 584 | 585 | req1 = await self.subscribe_to_list(self.client1) 586 | req2 = await self.subscribe_to_list(self.client2, 403) 587 | 588 | new_todo = await db(Todo.objects.create)(list=self.list, text="test") 589 | await self.assertReceivedBroadcastForTodo(new_todo, CREATED, req1, self.client1) 590 | self.assertTrue(await self.client1.receive_nothing()) 591 | self.assertTrue(await self.client2.receive_nothing()) 592 | 593 | 594 | class RealtimeSetupErrorTests(RestLiveTestCase): 595 | """ 596 | Tests making sure that we error at registration-time 597 | if a view does not have all the information we need to 598 | use it for realtime broadcasts. 599 | """ 600 | 601 | async def asyncSetUp(self): 602 | self.router = RealtimeRouter() 603 | 604 | @async_test 605 | async def test_view_no_mixin(self): 606 | class TestView(GenericAPIView): 607 | queryset = Todo.objects.all() 608 | serializer_class = TodoSerializer 609 | 610 | self.assertRaises(RuntimeError, self.router.register, TestView) 611 | 612 | @async_test 613 | async def test_model_has_two_views(self): 614 | class TestView(GenericAPIView, RealtimeMixin): 615 | queryset = Todo.objects.all() 616 | serializer_class = TodoSerializer 617 | 618 | self.router.register(TestView) 619 | self.assertRaises(RuntimeWarning, self.router.register, TestView) 620 | 621 | @async_test 622 | async def test_not_apiview(self): 623 | class TestView(APIView, RealtimeMixin): 624 | pass 625 | 626 | self.assertRaises(AssertionError, self.router.register, TestView) 627 | 628 | @async_test 629 | async def test_no_queryset_attribute(self): 630 | class TestView(GenericAPIView, RealtimeMixin): 631 | def get_queryset(self): 632 | return Todo.objects.all() 633 | 634 | self.assertRaises(AssertionError, self.router.register, TestView) 635 | 636 | 637 | class APIErrorTests(RestLiveTestCase): 638 | """ 639 | Tests making sure we send the right errors to the client in the 640 | websocket API. 641 | """ 642 | 643 | async def asyncSetUp(self): 644 | router = RealtimeRouter() 645 | router.register(TodoViewSet) 646 | 647 | self.client = make_client(router.as_consumer(), "/ws/subscribe/") 648 | connected, _ = await self.client.connect() 649 | self.assertTrue(connected) 650 | 651 | async def asyncTearDown(self): 652 | await self.client.disconnect() 653 | 654 | async def assertReceiveError(self, request_id, error_code): 655 | response = await self.client.receive_json_from() 656 | self.assertEqual("error", response["type"]) 657 | self.assertEqual(request_id, response["id"]) 658 | self.assertEqual(error_code, response["code"]) 659 | 660 | @async_test 661 | async def test_unsubscribe_before_subscribe(self): 662 | await self.client.send_json_to({"id": 1337, "type": "unsubscribe"}) 663 | await self.assertReceiveError(1337, 404) 664 | 665 | @async_test 666 | async def test_no_model_in_request(self): 667 | await self.client.send_json_to({"type": "subscribe", "id": 1337, "value": 1}) 668 | await self.assertReceiveError(1337, 400) 669 | 670 | @async_test 671 | async def test_no_value_in_request(self): 672 | await self.client.send_json_to( 673 | {"type": "subscribe", "id": 1337, "model": "test_app.Todo"} 674 | ) 675 | await self.assertReceiveError(1337, 400) 676 | 677 | @async_test 678 | async def test_subscribe_to_unknown_model(self): 679 | await self.client.send_json_to( 680 | {"type": "subscribe", "id": 1337, "model": "blah.Model", "value": 1} 681 | ) 682 | await self.assertReceiveError(1337, 404) 683 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from channels.db import database_sync_to_async 2 | from channels.testing import WebsocketCommunicator 3 | from django.contrib.auth import get_user_model 4 | 5 | from django.test import TransactionTestCase 6 | from djangorestframework_camel_case.util import camelize 7 | 8 | from rest_live import DELETED 9 | from rest_live.testing import APICommunicator 10 | from test_app.models import List, Todo 11 | from test_app.serializers import TodoSerializer 12 | 13 | 14 | User = get_user_model() 15 | db = database_sync_to_async 16 | 17 | 18 | class RestLiveTestCase(TransactionTestCase): 19 | client: APICommunicator 20 | list: List 21 | 22 | def __init__(self, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | self.counter = 0 25 | 26 | async def subscribe( 27 | self, model, action, value=None, client=None, kwargs=None, params=None 28 | ): 29 | self.counter += 1 30 | request_id = self.counter 31 | 32 | if client is None: 33 | client = self.client 34 | 35 | payload = { 36 | "type": "subscribe", 37 | "id": request_id, 38 | "model": model, 39 | "action": action, 40 | } 41 | if value is not None: 42 | payload["lookup_by"] = value 43 | 44 | if kwargs is not None: 45 | payload["view_kwargs"] = kwargs 46 | if params is not None: 47 | payload["query_params"] = params 48 | 49 | await client.send_json_to(payload) 50 | return request_id 51 | 52 | async def unsubscribe(self, request_id, client=None): 53 | if client is None: 54 | client = self.client 55 | await client.send_json_to( 56 | { 57 | "type": "unsubscribe", 58 | "id": request_id, 59 | } 60 | ) 61 | self.assertTrue(await client.receive_nothing()) 62 | 63 | async def assertResponseEquals(self, expected, client=None): 64 | if client is None: 65 | client = self.client 66 | response = await client.receive_json_from() 67 | self.assertDictEqual(response, expected) 68 | 69 | def make_todo_sub_response( 70 | self, todo, action, request_id, serializer=TodoSerializer, lookup_field="pk" 71 | ): 72 | response = { 73 | "type": "broadcast", 74 | "id": request_id, 75 | "model": "test_app.Todo", 76 | "action": action, 77 | "instance": camelize(serializer(todo).data), 78 | } 79 | if action == "DELETED": 80 | response["instance"] = { 81 | lookup_field: getattr(todo, lookup_field), 82 | "id": todo.id, 83 | } 84 | 85 | return response 86 | 87 | async def subscribe_to_todo( 88 | self, client=None, error=None, kwargs=None, params=None, lookup_field="pk" 89 | ): 90 | if kwargs is None: 91 | kwargs = dict() 92 | if client is None: 93 | client = self.client 94 | 95 | request_id = await self.subscribe( 96 | "test_app.Todo", 97 | "retrieve", 98 | getattr(self.todo, lookup_field), 99 | client, 100 | kwargs, 101 | params, 102 | ) 103 | if error is None: 104 | self.assertTrue(await client.receive_nothing()) 105 | else: 106 | msg = await client.receive_json_from() 107 | self.assertTrue(error, msg["code"]) 108 | return request_id 109 | 110 | async def subscribe_to_list( 111 | self, client=None, error=None, kwargs=None, params=None 112 | ): 113 | if client is None: 114 | client = self.client 115 | 116 | request_id = await self.subscribe( 117 | "test_app.Todo", "list", None, client, kwargs, params 118 | ) 119 | if error is None: 120 | self.assertTrue(await client.receive_nothing()) 121 | else: 122 | msg = await client.receive_json_from() 123 | self.assertTrue(error, msg["code"]) 124 | return request_id 125 | 126 | async def make_todo(self, text="test"): 127 | return await db(Todo.objects.create)(list=self.list, text=text) 128 | 129 | async def assertReceivedBroadcastForTodo( 130 | self, 131 | todo, 132 | action, 133 | request_id, 134 | communicator=None, 135 | serializer=TodoSerializer, 136 | lookup_field="pk", 137 | ): 138 | await self.assertResponseEquals( 139 | self.make_todo_sub_response( 140 | todo, action, request_id, serializer, lookup_field 141 | ), 142 | communicator, 143 | ) 144 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | ; lint, 5 | py3{7,8,9,10}-django{31,32}-channels{2,3}-drf3{11,12,13} 6 | py3{8,9,10}-django40-channels3-drf3{13} 7 | 8 | [testenv] 9 | commands = 10 | pytest --cov=rest_live --cov-append {posargs} 11 | setenv = 12 | DJANGO_SETTINGS_MODULE = tests.settings 13 | PYTHONPATH = {toxinidir} 14 | ; PYTHONWARNINGS = all 15 | deps = 16 | django31: Django>=3.1,<3.2 17 | django32: Django>=3.2,<3.3 18 | django40: Django>=4.0,<5.0 19 | ; djangomain: https://github.com/django/django/archive/main.tar.gz 20 | channels2: channels>=2.0,<3.0 21 | channels3: channels>=3.0,<4.0 22 | drf39: djangorestframework>=3.9,<3.10 23 | drf310: djangorestframework>=3.10,<3.11 24 | drf311: djangorestframework>=3.11,<3.12 25 | drf312: djangorestframework>=3.12,<3.13 26 | drf313: djangorestframework>=3.13,<3.14 27 | coverage 28 | pytest 29 | pytest-cov 30 | pytest-django 31 | pytest-asyncio 32 | pytest-mock 33 | djangorestframework 34 | djangorestframework-camel-case 35 | 36 | [testenv:lint] 37 | skip_install = True 38 | commands = 39 | flake8 40 | isort . --check 41 | black --check . 42 | deps = 43 | black 44 | flake8 45 | flake8-absolute-import 46 | flake8-isort 47 | flake8-quotes 48 | 49 | [flake8] 50 | max-line-length = 88 51 | exclude = docs/, .tox/, build/ 52 | inline-quotes = double 53 | 54 | [isort] 55 | default_section = THIRDPARTY 56 | known_first_party = rest_live 57 | line_length = 88 58 | lines_after_imports = 2 59 | multi_line_output = 3 60 | include_trailing_comma = True 61 | use_parentheses = True 62 | 63 | [coverage:run] 64 | source = rest_live 65 | 66 | [pytest] 67 | django_find_project = false 68 | 69 | [gh-actions] 70 | python = 71 | 3.7: py37 72 | 3.8: py38, lint 73 | 3.9: py39 74 | 3.10: py310 75 | -------------------------------------------------------------------------------- /version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | git commit -am "Release $1" 4 | git push 5 | git tag -a $1 -m "Release $1" 6 | git push --tags 7 | --------------------------------------------------------------------------------