├── .editorconfig ├── .github └── workflows │ ├── build.yml │ └── documentation.yml ├── .gitignore ├── LICENSE ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.md ├── docs └── index.md ├── mkdocs.yml ├── prerender_python_starlette ├── __init__.py └── middleware.py ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── test_middleware.py └── test_prerender_python_starlette.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.yml] 14 | indent_size = 2 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | test: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python_version: [3.7, 3.8] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up Python 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: ${{ matrix.python_version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pipenv 22 | pipenv install --python ${{ matrix.python_version }} --dev 23 | - name: Test with pytest 24 | env: 25 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 26 | run: | 27 | pipenv run pytest --cov=prerender_python_starlette/ 28 | pipenv run codecov 29 | 30 | release: 31 | runs-on: ubuntu-latest 32 | needs: test 33 | if: startsWith(github.ref, 'refs/tags/') 34 | 35 | steps: 36 | - uses: actions/checkout@v1 37 | - name: Set up Python 38 | uses: actions/setup-python@v1 39 | with: 40 | python-version: 3.7 41 | - name: Install dependencies 42 | run: | 43 | python -m pip install --upgrade pipenv 44 | pipenv install --dev 45 | - name: Release on PyPI 46 | env: 47 | FLIT_USERNAME: ${{ secrets.FLIT_USERNAME }} 48 | FLIT_PASSWORD: ${{ secrets.FLIT_PASSWORD }} 49 | run: | 50 | pipenv run flit publish 51 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Update documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up Python 3.7 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.7 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pipenv 22 | pipenv install --dev 23 | - name: Build 24 | run: pipenv run mkdocs build 25 | - name: Deploy 26 | uses: peaceiris/actions-gh-pages@v2.5.0 27 | env: 28 | ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }} 29 | PUBLISH_BRANCH: gh-pages 30 | PUBLISH_DIR: ./site 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | junit/ 50 | junit.xml 51 | test.db 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # .vscode 108 | .vscode/ 109 | 110 | # OS files 111 | .DS_Store 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020, François Voron 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 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PIPENV_RUN := pipenv run 2 | 3 | isort-src: 4 | $(PIPENV_RUN) isort -rc ./prerender_python_starlette 5 | 6 | isort-docs: 7 | $(PIPENV_RUN) isort -rc ./docs/src -o prerender_python_starlette 8 | 9 | format: isort-src isort-docs 10 | $(PIPENV_RUN) black . 11 | 12 | test: 13 | $(PIPENV_RUN) pytest --cov=prerender_python_starlette/ --cov-report=term-missing 14 | 15 | docs-serve: 16 | $(PIPENV_RUN) mkdocs serve 17 | 18 | docs-publish: 19 | $(PIPENV_RUN) mkdocs gh-deploy 20 | 21 | bumpversion-major: 22 | $(PIPENV_RUN) bumpversion major 23 | 24 | bumpversion-minor: 25 | $(PIPENV_RUN) bumpversion minor 26 | 27 | bumpversion-patch: 28 | $(PIPENV_RUN) bumpversion patch 29 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | flake8 = "*" 8 | pytest = "*" 9 | isort = "*" 10 | flake8-docstrings = "*" 11 | mkdocs = "*" 12 | mkdocs-material = "*" 13 | black = "*" 14 | mypy = "*" 15 | codecov = "*" 16 | pytest-cov = "*" 17 | pytest-mock = "*" 18 | flit = "*" 19 | markdown-include = "*" 20 | pygments = "*" 21 | pymdown-extensions = "*" 22 | bumpversion = "*" 23 | starlette = "*" 24 | respx = "*" 25 | 26 | [packages] 27 | httpx = ">=0.11,<0.13" 28 | 29 | [requires] 30 | python_version = "3.7" 31 | 32 | [pipenv] 33 | allow_prereleases = true 34 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "2676b4be08bd47456af3ad869dcaaf6181d23a9e342978f93706e8bbf78aa1b9" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", 22 | "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" 23 | ], 24 | "version": "==2019.11.28" 25 | }, 26 | "chardet": { 27 | "hashes": [ 28 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 29 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 30 | ], 31 | "version": "==3.0.4" 32 | }, 33 | "h11": { 34 | "hashes": [ 35 | "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1", 36 | "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1" 37 | ], 38 | "version": "==0.9.0" 39 | }, 40 | "h2": { 41 | "hashes": [ 42 | "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5", 43 | "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14" 44 | ], 45 | "version": "==3.2.0" 46 | }, 47 | "hpack": { 48 | "hashes": [ 49 | "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89", 50 | "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2" 51 | ], 52 | "version": "==3.0.0" 53 | }, 54 | "hstspreload": { 55 | "hashes": [ 56 | "sha256:0aa4c2ebb768a11109f4d2008b3fce987adaef2de584b93a48756847ec84403c", 57 | "sha256:e6b87847b1250c30e67bf68925d5e78b987d4be3fb61f921cdcd8ccea7dd4342" 58 | ], 59 | "version": "==2020.3.31" 60 | }, 61 | "httpx": { 62 | "hashes": [ 63 | "sha256:405b4749f597b1f45cae5bffc17b23dc251cce30a0c4c8126f1007b9e728a615", 64 | "sha256:ce51c8e8ed2834447fde5a94650299fd74017b7da69cc786b6421fefda09a393" 65 | ], 66 | "index": "pypi", 67 | "version": "==0.12.1" 68 | }, 69 | "hyperframe": { 70 | "hashes": [ 71 | "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40", 72 | "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f" 73 | ], 74 | "version": "==5.2.0" 75 | }, 76 | "idna": { 77 | "hashes": [ 78 | "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", 79 | "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" 80 | ], 81 | "version": "==2.9" 82 | }, 83 | "rfc3986": { 84 | "hashes": [ 85 | "sha256:0344d0bd428126ce554e7ca2b61787b6a28d2bbd19fc70ed2dd85efe31176405", 86 | "sha256:df4eba676077cefb86450c8f60121b9ae04b94f65f85b69f3f731af0516b7b18" 87 | ], 88 | "version": "==1.3.2" 89 | }, 90 | "sniffio": { 91 | "hashes": [ 92 | "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5", 93 | "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21" 94 | ], 95 | "version": "==1.1.0" 96 | }, 97 | "urllib3": { 98 | "hashes": [ 99 | "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", 100 | "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" 101 | ], 102 | "version": "==1.25.8" 103 | } 104 | }, 105 | "develop": { 106 | "appdirs": { 107 | "hashes": [ 108 | "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", 109 | "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" 110 | ], 111 | "version": "==1.4.3" 112 | }, 113 | "asynctest": { 114 | "hashes": [ 115 | "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676", 116 | "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac" 117 | ], 118 | "version": "==0.13.0" 119 | }, 120 | "attrs": { 121 | "hashes": [ 122 | "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", 123 | "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" 124 | ], 125 | "version": "==19.3.0" 126 | }, 127 | "black": { 128 | "hashes": [ 129 | "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", 130 | "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539" 131 | ], 132 | "index": "pypi", 133 | "version": "==19.10b0" 134 | }, 135 | "bumpversion": { 136 | "hashes": [ 137 | "sha256:6744c873dd7aafc24453d8b6a1a0d6d109faf63cd0cd19cb78fd46e74932c77e", 138 | "sha256:6753d9ff3552013e2130f7bc03c1007e24473b4835952679653fb132367bdd57" 139 | ], 140 | "index": "pypi", 141 | "version": "==0.5.3" 142 | }, 143 | "certifi": { 144 | "hashes": [ 145 | "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", 146 | "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" 147 | ], 148 | "version": "==2019.11.28" 149 | }, 150 | "chardet": { 151 | "hashes": [ 152 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 153 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 154 | ], 155 | "version": "==3.0.4" 156 | }, 157 | "click": { 158 | "hashes": [ 159 | "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc", 160 | "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a" 161 | ], 162 | "version": "==7.1.1" 163 | }, 164 | "codecov": { 165 | "hashes": [ 166 | "sha256:09fb045eb044a619cd2b9dacd7789ae8e322cb7f18196378579fd8d883e6b665", 167 | "sha256:aeeefa3a03cac8a78e4f988e935b51a4689bb1f17f20d4e827807ee11135f845" 168 | ], 169 | "index": "pypi", 170 | "version": "==2.0.22" 171 | }, 172 | "coverage": { 173 | "hashes": [ 174 | "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0", 175 | "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30", 176 | "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b", 177 | "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0", 178 | "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823", 179 | "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe", 180 | "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037", 181 | "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6", 182 | "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31", 183 | "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd", 184 | "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892", 185 | "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1", 186 | "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78", 187 | "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac", 188 | "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006", 189 | "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014", 190 | "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2", 191 | "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7", 192 | "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8", 193 | "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7", 194 | "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9", 195 | "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1", 196 | "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307", 197 | "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a", 198 | "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435", 199 | "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0", 200 | "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5", 201 | "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441", 202 | "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732", 203 | "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de", 204 | "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1" 205 | ], 206 | "version": "==5.0.4" 207 | }, 208 | "docutils": { 209 | "hashes": [ 210 | "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", 211 | "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" 212 | ], 213 | "version": "==0.16" 214 | }, 215 | "entrypoints": { 216 | "hashes": [ 217 | "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", 218 | "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" 219 | ], 220 | "version": "==0.3" 221 | }, 222 | "flake8": { 223 | "hashes": [ 224 | "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", 225 | "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca" 226 | ], 227 | "index": "pypi", 228 | "version": "==3.7.9" 229 | }, 230 | "flake8-docstrings": { 231 | "hashes": [ 232 | "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717", 233 | "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc" 234 | ], 235 | "index": "pypi", 236 | "version": "==1.5.0" 237 | }, 238 | "flit": { 239 | "hashes": [ 240 | "sha256:6ec794b13bddffafa327854f0812022e6c993287f536ffbef3563462b4d2eade", 241 | "sha256:a273dc2a5ab1f42f0e02878347d94f5462c42cbe232a5014b6778c9e5975327f" 242 | ], 243 | "index": "pypi", 244 | "version": "==2.2.0" 245 | }, 246 | "flit-core": { 247 | "hashes": [ 248 | "sha256:4df2b9b43f00764a81e7ea742829749183a7f5a9e360fa5c3a9e8643dadd716a", 249 | "sha256:4efb8bffc1a04d8e550e877f0c9acf53109a021cc27c2a89b1b467715dc1d657" 250 | ], 251 | "version": "==2.2.0" 252 | }, 253 | "future": { 254 | "hashes": [ 255 | "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" 256 | ], 257 | "version": "==0.18.2" 258 | }, 259 | "h11": { 260 | "hashes": [ 261 | "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1", 262 | "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1" 263 | ], 264 | "version": "==0.9.0" 265 | }, 266 | "h2": { 267 | "hashes": [ 268 | "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5", 269 | "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14" 270 | ], 271 | "version": "==3.2.0" 272 | }, 273 | "hpack": { 274 | "hashes": [ 275 | "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89", 276 | "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2" 277 | ], 278 | "version": "==3.0.0" 279 | }, 280 | "hstspreload": { 281 | "hashes": [ 282 | "sha256:0aa4c2ebb768a11109f4d2008b3fce987adaef2de584b93a48756847ec84403c", 283 | "sha256:e6b87847b1250c30e67bf68925d5e78b987d4be3fb61f921cdcd8ccea7dd4342" 284 | ], 285 | "version": "==2020.3.31" 286 | }, 287 | "httpx": { 288 | "hashes": [ 289 | "sha256:405b4749f597b1f45cae5bffc17b23dc251cce30a0c4c8126f1007b9e728a615", 290 | "sha256:ce51c8e8ed2834447fde5a94650299fd74017b7da69cc786b6421fefda09a393" 291 | ], 292 | "index": "pypi", 293 | "version": "==0.12.1" 294 | }, 295 | "hyperframe": { 296 | "hashes": [ 297 | "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40", 298 | "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f" 299 | ], 300 | "version": "==5.2.0" 301 | }, 302 | "idna": { 303 | "hashes": [ 304 | "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", 305 | "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" 306 | ], 307 | "version": "==2.9" 308 | }, 309 | "importlib-metadata": { 310 | "hashes": [ 311 | "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f", 312 | "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e" 313 | ], 314 | "markers": "python_version < '3.8'", 315 | "version": "==1.6.0" 316 | }, 317 | "isort": { 318 | "hashes": [ 319 | "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", 320 | "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" 321 | ], 322 | "index": "pypi", 323 | "version": "==4.3.21" 324 | }, 325 | "jinja2": { 326 | "hashes": [ 327 | "sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484", 328 | "sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668" 329 | ], 330 | "version": "==3.0.0a1" 331 | }, 332 | "joblib": { 333 | "hashes": [ 334 | "sha256:0630eea4f5664c463f23fbf5dcfc54a2bc6168902719fa8e19daf033022786c8", 335 | "sha256:bdb4fd9b72915ffb49fde2229ce482dd7ae79d842ed8c2b4c932441495af1403" 336 | ], 337 | "version": "==0.14.1" 338 | }, 339 | "livereload": { 340 | "hashes": [ 341 | "sha256:78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b", 342 | "sha256:89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66" 343 | ], 344 | "version": "==2.6.1" 345 | }, 346 | "lunr": { 347 | "extras": [ 348 | "languages" 349 | ], 350 | "hashes": [ 351 | "sha256:1208622930c915a07e6f8e8640474357826bad48534c0f57969b6fca9bffc88e", 352 | "sha256:7be69d7186f65784a4f2adf81e5c58efd6a9921aa95966babcb1f2f2ada75c20" 353 | ], 354 | "version": "==0.5.6" 355 | }, 356 | "markdown": { 357 | "hashes": [ 358 | "sha256:90fee683eeabe1a92e149f7ba74e5ccdc81cd397bd6c516d93a8da0ef90b6902", 359 | "sha256:e4795399163109457d4c5af2183fbe6b60326c17cfdf25ce6e7474c6624f725d" 360 | ], 361 | "version": "==3.2.1" 362 | }, 363 | "markdown-include": { 364 | "hashes": [ 365 | "sha256:72a45461b589489a088753893bc95c5fa5909936186485f4ed55caa57d10250f" 366 | ], 367 | "index": "pypi", 368 | "version": "==0.5.1" 369 | }, 370 | "markupsafe": { 371 | "hashes": [ 372 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 373 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 374 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 375 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 376 | "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", 377 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 378 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 379 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 380 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 381 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 382 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 383 | "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", 384 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 385 | "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", 386 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 387 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 388 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 389 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 390 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 391 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 392 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 393 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 394 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 395 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 396 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 397 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 398 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 399 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 400 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 401 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 402 | "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", 403 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", 404 | "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" 405 | ], 406 | "version": "==1.1.1" 407 | }, 408 | "mccabe": { 409 | "hashes": [ 410 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 411 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 412 | ], 413 | "version": "==0.6.1" 414 | }, 415 | "mkdocs": { 416 | "hashes": [ 417 | "sha256:1e385a70aea8a9dedb731aea4fd5f3704b2074801c4f96f06b2920999babda8a", 418 | "sha256:9243291392f59e20b655e4e46210233453faf97787c2cf72176510e868143174" 419 | ], 420 | "index": "pypi", 421 | "version": "==1.1" 422 | }, 423 | "mkdocs-material": { 424 | "hashes": [ 425 | "sha256:2bab93688d216c19d8e707143808eed3856af73b4883b3266409a9a5b119b235", 426 | "sha256:5355970c2a3e401736ba1b70ddb236ac75cb88bf57b354e8cd9d28938bee8fc2" 427 | ], 428 | "index": "pypi", 429 | "version": "==5.0.0rc4" 430 | }, 431 | "more-itertools": { 432 | "hashes": [ 433 | "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c", 434 | "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507" 435 | ], 436 | "version": "==8.2.0" 437 | }, 438 | "mypy": { 439 | "hashes": [ 440 | "sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2", 441 | "sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1", 442 | "sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164", 443 | "sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761", 444 | "sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce", 445 | "sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27", 446 | "sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754", 447 | "sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae", 448 | "sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9", 449 | "sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600", 450 | "sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65", 451 | "sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8", 452 | "sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913", 453 | "sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3" 454 | ], 455 | "index": "pypi", 456 | "version": "==0.770" 457 | }, 458 | "mypy-extensions": { 459 | "hashes": [ 460 | "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", 461 | "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" 462 | ], 463 | "version": "==0.4.3" 464 | }, 465 | "nltk": { 466 | "hashes": [ 467 | "sha256:5a0395b88cb709ab4e7af6012bd573bd181f12bf6b9722b9473d1f880c0c6e39" 468 | ], 469 | "version": "==3.5b1" 470 | }, 471 | "packaging": { 472 | "hashes": [ 473 | "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3", 474 | "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752" 475 | ], 476 | "version": "==20.3" 477 | }, 478 | "pathspec": { 479 | "hashes": [ 480 | "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424", 481 | "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96" 482 | ], 483 | "version": "==0.7.0" 484 | }, 485 | "pluggy": { 486 | "hashes": [ 487 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 488 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 489 | ], 490 | "version": "==0.13.1" 491 | }, 492 | "py": { 493 | "hashes": [ 494 | "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", 495 | "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" 496 | ], 497 | "version": "==1.8.1" 498 | }, 499 | "pycodestyle": { 500 | "hashes": [ 501 | "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", 502 | "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" 503 | ], 504 | "version": "==2.5.0" 505 | }, 506 | "pydocstyle": { 507 | "hashes": [ 508 | "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586", 509 | "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5" 510 | ], 511 | "version": "==5.0.2" 512 | }, 513 | "pyflakes": { 514 | "hashes": [ 515 | "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", 516 | "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" 517 | ], 518 | "version": "==2.1.1" 519 | }, 520 | "pygments": { 521 | "hashes": [ 522 | "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", 523 | "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" 524 | ], 525 | "index": "pypi", 526 | "version": "==2.6.1" 527 | }, 528 | "pymdown-extensions": { 529 | "hashes": [ 530 | "sha256:35ba85b92fbde1f331a34726a1bbdb4cc52bd3026329ae3880cdd01a0908093d", 531 | "sha256:38cd7de2e4e05232a034d6bbb375c0688e7f1a4d5f7d5770fcd715fe4ad74679" 532 | ], 533 | "index": "pypi", 534 | "version": "==7.0rc2" 535 | }, 536 | "pyparsing": { 537 | "hashes": [ 538 | "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", 539 | "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" 540 | ], 541 | "version": "==2.4.6" 542 | }, 543 | "pytest": { 544 | "hashes": [ 545 | "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172", 546 | "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970" 547 | ], 548 | "index": "pypi", 549 | "version": "==5.4.1" 550 | }, 551 | "pytest-cov": { 552 | "hashes": [ 553 | "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b", 554 | "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626" 555 | ], 556 | "index": "pypi", 557 | "version": "==2.8.1" 558 | }, 559 | "pytest-mock": { 560 | "hashes": [ 561 | "sha256:98e02534f170e4f37d7e1abdfc5973fd4207aa609582291717f643764e71c925", 562 | "sha256:a4494016753a30231f8519bfd160242a0f3c8fb82ca36e7b6f82a7fb602ac6b8" 563 | ], 564 | "index": "pypi", 565 | "version": "==3.0.0" 566 | }, 567 | "pytoml": { 568 | "hashes": [ 569 | "sha256:57a21e6347049f73bfb62011ff34cd72774c031b9828cb628a752225136dfc33", 570 | "sha256:8eecf7c8d0adcff3b375b09fe403407aa9b645c499e5ab8cac670ac4a35f61e7" 571 | ], 572 | "version": "==0.1.21" 573 | }, 574 | "pyyaml": { 575 | "hashes": [ 576 | "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", 577 | "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", 578 | "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", 579 | "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", 580 | "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", 581 | "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", 582 | "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", 583 | "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", 584 | "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", 585 | "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", 586 | "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" 587 | ], 588 | "version": "==5.3.1" 589 | }, 590 | "regex": { 591 | "hashes": [ 592 | "sha256:01b2d70cbaed11f72e57c1cfbaca71b02e3b98f739ce33f5f26f71859ad90431", 593 | "sha256:046e83a8b160aff37e7034139a336b660b01dbfe58706f9d73f5cdc6b3460242", 594 | "sha256:113309e819634f499d0006f6200700c8209a2a8bf6bd1bdc863a4d9d6776a5d1", 595 | "sha256:200539b5124bc4721247a823a47d116a7a23e62cc6695744e3eb5454a8888e6d", 596 | "sha256:25f4ce26b68425b80a233ce7b6218743c71cf7297dbe02feab1d711a2bf90045", 597 | "sha256:269f0c5ff23639316b29f31df199f401e4cb87529eafff0c76828071635d417b", 598 | "sha256:5de40649d4f88a15c9489ed37f88f053c15400257eeb18425ac7ed0a4e119400", 599 | "sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa", 600 | "sha256:82469a0c1330a4beb3d42568f82dffa32226ced006e0b063719468dcd40ffdf0", 601 | "sha256:8c2b7fa4d72781577ac45ab658da44c7518e6d96e2a50d04ecb0fd8f28b21d69", 602 | "sha256:974535648f31c2b712a6b2595969f8ab370834080e00ab24e5dbb9d19b8bfb74", 603 | "sha256:99272d6b6a68c7ae4391908fc15f6b8c9a6c345a46b632d7fdb7ef6c883a2bbb", 604 | "sha256:9b64a4cc825ec4df262050c17e18f60252cdd94742b4ba1286bcfe481f1c0f26", 605 | "sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5", 606 | "sha256:9ff16d994309b26a1cdf666a6309c1ef51ad4f72f99d3392bcd7b7139577a1f2", 607 | "sha256:b33ebcd0222c1d77e61dbcd04a9fd139359bded86803063d3d2d197b796c63ce", 608 | "sha256:bba52d72e16a554d1894a0cc74041da50eea99a8483e591a9edf1025a66843ab", 609 | "sha256:bed7986547ce54d230fd8721aba6fd19459cdc6d315497b98686d0416efaff4e", 610 | "sha256:c7f58a0e0e13fb44623b65b01052dae8e820ed9b8b654bb6296bc9c41f571b70", 611 | "sha256:d58a4fa7910102500722defbde6e2816b0372a4fcc85c7e239323767c74f5cbc", 612 | "sha256:f1ac2dc65105a53c1c2d72b1d3e98c2464a133b4067a51a3d2477b28449709a0" 613 | ], 614 | "version": "==2020.2.20" 615 | }, 616 | "requests": { 617 | "hashes": [ 618 | "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", 619 | "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" 620 | ], 621 | "version": "==2.23.0" 622 | }, 623 | "respx": { 624 | "hashes": [ 625 | "sha256:190d1fb5bddaf6fcc1319a3cdfbd682c77d7167017b3283cbe79b8fb74927135", 626 | "sha256:43aca802e0fd0c964865b07f101943e7b5902ea070ec94cf8e84a39db8729b06" 627 | ], 628 | "index": "pypi", 629 | "version": "==0.10.1" 630 | }, 631 | "rfc3986": { 632 | "hashes": [ 633 | "sha256:0344d0bd428126ce554e7ca2b61787b6a28d2bbd19fc70ed2dd85efe31176405", 634 | "sha256:df4eba676077cefb86450c8f60121b9ae04b94f65f85b69f3f731af0516b7b18" 635 | ], 636 | "version": "==1.3.2" 637 | }, 638 | "six": { 639 | "hashes": [ 640 | "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", 641 | "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" 642 | ], 643 | "version": "==1.14.0" 644 | }, 645 | "sniffio": { 646 | "hashes": [ 647 | "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5", 648 | "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21" 649 | ], 650 | "version": "==1.1.0" 651 | }, 652 | "snowballstemmer": { 653 | "hashes": [ 654 | "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", 655 | "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" 656 | ], 657 | "version": "==2.0.0" 658 | }, 659 | "starlette": { 660 | "hashes": [ 661 | "sha256:6169ee78ded501095d1dda7b141a1dc9f9934d37ad23196e180150ace2c6449b", 662 | "sha256:a9bb130fa7aa736eda8a814b6ceb85ccf7a209ed53843d0d61e246b380afa10f" 663 | ], 664 | "index": "pypi", 665 | "version": "==0.13.2" 666 | }, 667 | "toml": { 668 | "hashes": [ 669 | "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", 670 | "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" 671 | ], 672 | "version": "==0.10.0" 673 | }, 674 | "tornado": { 675 | "hashes": [ 676 | "sha256:0fe2d45ba43b00a41cd73f8be321a44936dc1aba233dee979f17a042b83eb6dc", 677 | "sha256:22aed82c2ea340c3771e3babc5ef220272f6fd06b5108a53b4976d0d722bcd52", 678 | "sha256:2c027eb2a393d964b22b5c154d1a23a5f8727db6fda837118a776b29e2b8ebc6", 679 | "sha256:5217e601700f24e966ddab689f90b7ea4bd91ff3357c3600fa1045e26d68e55d", 680 | "sha256:5618f72e947533832cbc3dec54e1dffc1747a5cb17d1fd91577ed14fa0dc081b", 681 | "sha256:5f6a07e62e799be5d2330e68d808c8ac41d4a259b9cea61da4101b83cb5dc673", 682 | "sha256:c58d56003daf1b616336781b26d184023ea4af13ae143d9dda65e31e534940b9", 683 | "sha256:c952975c8ba74f546ae6de2e226ab3cc3cc11ae47baf607459a6728585bb542a", 684 | "sha256:c98232a3ac391f5faea6821b53db8db461157baa788f5d6222a193e9456e1740" 685 | ], 686 | "version": "==6.0.4" 687 | }, 688 | "tqdm": { 689 | "hashes": [ 690 | "sha256:00339634a22c10a7a22476ee946bbde2dbe48d042ded784e4d88e0236eca5d81", 691 | "sha256:ea9e3fd6bd9a37e8783d75bfc4c1faf3c6813da6bd1c3e776488b41ec683af94" 692 | ], 693 | "version": "==4.45.0" 694 | }, 695 | "typed-ast": { 696 | "hashes": [ 697 | "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", 698 | "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", 699 | "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", 700 | "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", 701 | "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", 702 | "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", 703 | "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", 704 | "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", 705 | "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", 706 | "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", 707 | "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", 708 | "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", 709 | "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", 710 | "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", 711 | "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", 712 | "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", 713 | "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", 714 | "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", 715 | "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", 716 | "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", 717 | "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" 718 | ], 719 | "version": "==1.4.1" 720 | }, 721 | "typing-extensions": { 722 | "hashes": [ 723 | "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", 724 | "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", 725 | "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" 726 | ], 727 | "version": "==3.7.4.2" 728 | }, 729 | "urllib3": { 730 | "hashes": [ 731 | "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", 732 | "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" 733 | ], 734 | "version": "==1.25.8" 735 | }, 736 | "wcwidth": { 737 | "hashes": [ 738 | "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1", 739 | "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" 740 | ], 741 | "version": "==0.1.9" 742 | }, 743 | "zipp": { 744 | "hashes": [ 745 | "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", 746 | "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" 747 | ], 748 | "version": "==3.1.0" 749 | } 750 | } 751 | } 752 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prerender Python Starlette 2 | 3 |

4 | Starlette middleware for Prerender 5 |

6 | 7 | [![build](https://github.com/BeeMyDesk/prerender-python-starlette/workflows/Build/badge.svg)](https://github.com/BeeMyDesk/prerender-python-starlette/actions) 8 | [![codecov](https://codecov.io/gh/BeeMyDesk/prerender-python-starlette/branch/master/graph/badge.svg)](https://codecov.io/gh/BeeMyDesk/prerender-python-starlette) 9 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=BeeMyDesk/prerender-python-starlette)](https://dependabot.com) 10 | [![PyPI version](https://badge.fury.io/py/prerender-python-starlette.svg)](https://badge.fury.io/py/prerender-python-starlette) 11 | 12 | --- 13 | 14 | **Documentation**: https://BeeMyDesk.github.io/prerender-python-starlette/ 15 | 16 | **Source Code**: https://github.com/BeeMyDesk/prerender-python-starlette 17 | 18 | --- 19 | 20 | ## Introduction 21 | 22 | > Google, Facebook, Twitter, and Bing are constantly trying to view your website... but Google is the only crawler that executes a meaningful amount of JavaScript and Google even admits that they can execute JavaScript weeks after actually crawling. Prerender allows you to serve the full HTML of your website back to Google and other crawlers so that they don't have to execute any JavaScript. [Google recommends using Prerender.io](https://developers.google.com/search/docs/guides/dynamic-rendering) to prevent indexation issues on sites with large amounts of JavaScript. 23 | > 24 | > Prerender is perfect for Angular SEO, React SEO, Vue SEO, and any other JavaScript framework. 25 | > 26 | > This middleware intercepts requests to your Node.js website from crawlers, and then makes a call to the (external) Prerender Service to get the static HTML instead of the JavaScript for that page. That HTML is then returned to the crawler. 27 | 28 | *README of [prerender_rails](https://github.com/prerender/prerender_rails)* 29 | 30 | This library is a Python implementation of a Prerender middleware for [Starlette](https://www.starlette.io). It should work flawlessly with [FastAPI](https://fastapi.tiangolo.com/) and, probably, with any ASGI framework. 31 | 32 | ## Installation 33 | 34 | ```bash 35 | pip install prerender-python-starlette 36 | ``` 37 | 38 | ## Usage 39 | 40 | ```py 41 | from starlette.applications import Starlette 42 | from starlette.middleware import Middleware 43 | from prerender_python_starlette import PrerenderMiddleware 44 | 45 | routes = ... 46 | 47 | middleware = [ 48 | Middleware(PrerenderMiddleware), 49 | ] 50 | 51 | app = Starlette(routes=routes, middleware=middleware) 52 | ``` 53 | 54 | ### Parameters 55 | 56 | * `prerender_service_url`: URL of Prerender server. Defaults to `PRERENDER_SERVICE_URL` environment variable. 57 | * `prerender_service_username`: HTTP basic auth username of Prerender server. Defaults to `PRERENDER_SERVICE_USERNAME` environment variable. 58 | * `prerender_service_password`: HTTP basic auth password of Prerender server. Defaults to `PRERENDER_SERVICE_PASSWORD` environment variable. 59 | * `prerender_service_token`: Token set in `X-Prerender-Token` header. Defaults to `PRERENDER_SERVICE_TOKEN` environment variable. 60 | * `crawler_user_agents`: List of crawler user agents to intercept. Defaults to `DEFAULT_CRAWLER_USER_AGENTS` list. 61 | * `extensions_to_ignore`: List of file extensions to ignore. Defaults to `DEFAULT_EXTENSIONS_TO_IGNORE` list. 62 | * `whitelist`: List of path patterns to whitelist. Path not matching a pattern in the list won't be prerendered. Defaults to `None`. 63 | * `blacklist`: List of path patterns to blacklist. Path matching a pattern in the list won't be prerendered. Defaults to `None`. 64 | * `before_render`: Async function called before the prerendering. If it returns an `HTMLResponse`, it will be considered as cache and will bypass the call to the Prerender server. Defaults to `None`. 65 | * `after_render`: Async function called after the prerendering. Defaults to `None`. 66 | 67 | ### Cache example 68 | 69 | ```py 70 | from starlette.applications import Starlette 71 | from starlette.middleware import Middleware 72 | from prerender_python_starlette import PrerenderMiddleware 73 | 74 | 75 | async def before_render(request: Request) -> Optional[HTMLResponse]: 76 | cached_response = await cache.get(f"prerender:{request.url.path}") 77 | if cached_response: 78 | return HTMLResponse(cached_response) 79 | return None 80 | 81 | 82 | async def after_render( 83 | request: Request, response: HTMLResponse, cached: bool 84 | ) -> None: 85 | if not cached: 86 | await cache.set( 87 | f"prerender:{request.url.path}", response.body.decode(response.charset) 88 | ) 89 | 90 | 91 | routes = ... 92 | 93 | middleware = [ 94 | Middleware(PrerenderMiddleware, before_render=before_render, after_render=after_render), 95 | ] 96 | 97 | app = Starlette(routes=routes, middleware=middleware) 98 | ``` 99 | 100 | ## Development 101 | 102 | ### Setup environement 103 | 104 | You should have [Pipenv](https://pipenv.readthedocs.io/en/latest/) installed. Then, you can install the dependencies with: 105 | 106 | ```bash 107 | pipenv install --dev 108 | ``` 109 | 110 | After that, activate the virtual environment: 111 | 112 | ```bash 113 | pipenv shell 114 | ``` 115 | 116 | ### Run unit tests 117 | 118 | You can run all the tests with: 119 | 120 | ```bash 121 | make test 122 | ``` 123 | 124 | Alternatively, you can run `pytest` yourself: 125 | 126 | ```bash 127 | pytest 128 | ``` 129 | 130 | ### Format the code 131 | 132 | Execute the following command to apply `isort` and `black` formatting: 133 | 134 | ```bash 135 | make format 136 | ``` 137 | 138 | ## License 139 | 140 | This project is licensed under the terms of the MIT license. 141 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | {!../README.md!} 2 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Prerender Python Starlette 2 | site_description: Starlette middleware for Prerender 3 | 4 | theme: 5 | name: material 6 | palette: 7 | primary: amber 8 | accent: orange 9 | 10 | repo_name: BeeMyDesk/prerender-python-starlette 11 | repo_url: https://github.com/BeeMyDesk/prerender-python-starlette 12 | edit_uri: "" 13 | 14 | markdown_extensions: 15 | - markdown_include.include: 16 | base_path: docs 17 | - toc: 18 | permalink: true 19 | - admonition 20 | - codehilite 21 | - pymdownx.superfences 22 | - pymdownx.tasklist 23 | 24 | nav: 25 | - README: index.md 26 | -------------------------------------------------------------------------------- /prerender_python_starlette/__init__.py: -------------------------------------------------------------------------------- 1 | """Starlette middleware for Prerender.""" 2 | 3 | __version__ = "1.0.1" 4 | 5 | 6 | from prerender_python_starlette.middleware import ( # noqa: F401 7 | DEFAULT_CRAWLER_USER_AGENTS, 8 | DEFAULT_EXTENSIONS_TO_IGNORE, 9 | PrerenderMiddleware, 10 | ) 11 | -------------------------------------------------------------------------------- /prerender_python_starlette/middleware.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from typing import Awaitable, Callable, List, Optional 4 | 5 | import httpx 6 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 7 | from starlette.requests import Request 8 | from starlette.responses import HTMLResponse, Response 9 | from starlette.types import ASGIApp 10 | 11 | DEFAULT_CRAWLER_USER_AGENTS = [ 12 | "googlebot", 13 | "yahoo", 14 | "bingbot", 15 | "baiduspider", 16 | "facebookexternalhit", 17 | "twitterbot", 18 | "rogerbot", 19 | "linkedinbot", 20 | "embedly", 21 | "bufferbot", 22 | "quora link preview", 23 | "showyoubot", 24 | "outbrain", 25 | "pinterest/0.", 26 | "developers.google.com/+/web/snippet", 27 | "www.google.com/webmasters/tools/richsnippets", 28 | "slackbot", 29 | "vkshare", 30 | "w3c_validator", 31 | "redditbot", 32 | "applebot", 33 | "whatsapp", 34 | "flipboard", 35 | "tumblr", 36 | "bitlybot", 37 | "skypeuripreview", 38 | "nuzzel", 39 | "discordbot", 40 | "google page speed", 41 | "qwantify", 42 | "chrome-lighthouse", 43 | ] 44 | 45 | DEFAULT_EXTENSIONS_TO_IGNORE = [ 46 | ".js", 47 | ".css", 48 | ".xml", 49 | ".less", 50 | ".png", 51 | ".jpg", 52 | ".jpeg", 53 | ".gif", 54 | ".svg", 55 | ".pdf", 56 | ".doc", 57 | ".txt", 58 | ".ico", 59 | ".rss", 60 | ".zip", 61 | ".mp3", 62 | ".rar", 63 | ".exe", 64 | ".wmv", 65 | ".doc", 66 | ".avi", 67 | ".ppt", 68 | ".mpg", 69 | ".mpeg", 70 | ".tif", 71 | ".wav", 72 | ".mov", 73 | ".psd", 74 | ".ai", 75 | ".xls", 76 | ".mp4", 77 | ".m4a", 78 | ".swf", 79 | ".dat", 80 | ".dmg", 81 | ".iso", 82 | ".flv", 83 | ".m4v", 84 | ".torrent", 85 | ] 86 | 87 | PRERENDER_SERVICE_URL = os.environ.get( 88 | "PRERENDER_SERVICE_URL", "http://service.prerender.io/" 89 | ) 90 | PRERENDER_SERVICE_USERNAME = os.environ.get("PRERENDER_SERVICE_USERNAME") 91 | PRERENDER_SERVICE_PASSWORD = os.environ.get("PRERENDER_SERVICE_PASSWORD") 92 | PRERENDER_SERVICE_TOKEN = os.environ.get("PRERENDER_SERVICE_TOKEN") 93 | 94 | 95 | def is_matching_user_agent(user_agent: str, crawler_user_agents: List[str]) -> bool: 96 | normalized_user_agent = user_agent.lower() 97 | 98 | for crawler_user_agent in crawler_user_agents: 99 | if crawler_user_agent in normalized_user_agent: 100 | return True 101 | 102 | return False 103 | 104 | 105 | def compile_patterns(patterns: List[str]) -> List[re.Pattern]: 106 | compiled_patterns: List[re.Pattern] = [] 107 | for pattern in patterns: 108 | compiled_patterns.append(re.compile(pattern)) 109 | return compiled_patterns 110 | 111 | 112 | def has_matching_pattern(patterns: List[re.Pattern], string: str) -> bool: 113 | for pattern in patterns: 114 | if pattern.match(string): 115 | return True 116 | return False 117 | 118 | 119 | class PrerenderMiddleware(BaseHTTPMiddleware): 120 | """ 121 | Middleware intercepting requests made by crawler \ 122 | bots to prerender them against a Prerender server. 123 | 124 | :param app: ASGI app. 125 | 126 | :param prerender_service_url: URL of Prerender server. 127 | Defaults to PRERENDER_SERVICE_URL environment variable. 128 | 129 | :param prerender_service_username: HTTP basic auth username of Prerender server. 130 | Defaults to PRERENDER_SERVICE_USERNAME environment variable. 131 | 132 | :param prerender_service_password: HTTP basic auth password of Prerender server. 133 | Defaults to PRERENDER_SERVICE_PASSWORD environment variable. 134 | 135 | :param prerender_service_token: Token set in X-Prerender-Token header. 136 | Defaults to PRERENDER_SERVICE_TOKEN environment variable. 137 | 138 | :param crawler_user_agents: List of crawler user agents to intercept. 139 | Defaults to DEFAULT_CRAWLER_USER_AGENTS list. 140 | 141 | :param extensions_to_ignore: List of file extensions to ignore. 142 | Defaults to DEFAULT_EXTENSIONS_TO_IGNORE list. 143 | 144 | :param whitelist: List of path patterns to whitelist. 145 | Path not matching a pattern in the list won't be prerendered. 146 | Defaults to None. 147 | 148 | :param blacklist: List of path patterns to blacklist. 149 | Path matching a pattern in the list won't be prerendered. 150 | Defaults to None. 151 | 152 | :param before_render: Async function called before the prerendering. 153 | If it returns an `HTMLResponse`, it will be considered as cache 154 | and will bypass the call to the Prerender server. 155 | Defaults to None. 156 | 157 | :param after_render: Async function called after the prerendering. 158 | Defaults to None. 159 | """ 160 | 161 | prerender_service_url: str 162 | prerender_service_username: Optional[str] 163 | prerender_service_password: Optional[str] 164 | prerender_service_token: Optional[str] 165 | crawler_user_agents: List[str] 166 | extensions_to_ignore: List[str] 167 | whitelist: Optional[List[re.Pattern]] = None 168 | blacklist: Optional[List[re.Pattern]] = None 169 | before_render: Optional[ 170 | Callable[[Request], Awaitable[Optional[HTMLResponse]]] 171 | ] = None 172 | after_render: Optional[ 173 | Callable[[Request, HTMLResponse, bool], Awaitable[None]] 174 | ] = None 175 | 176 | def __init__( 177 | self, 178 | app: ASGIApp, 179 | prerender_service_url: str = PRERENDER_SERVICE_URL, 180 | prerender_service_username: Optional[str] = PRERENDER_SERVICE_USERNAME, 181 | prerender_service_password: Optional[str] = PRERENDER_SERVICE_PASSWORD, 182 | prerender_service_token: Optional[str] = PRERENDER_SERVICE_TOKEN, 183 | crawler_user_agents: List[str] = None, 184 | extensions_to_ignore: List[str] = None, 185 | whitelist: List[str] = None, 186 | blacklist: List[str] = None, 187 | before_render: Callable[[Request], Awaitable[Optional[HTMLResponse]]] = None, 188 | after_render: Callable[[Request, HTMLResponse, bool], Awaitable[None]] = None, 189 | ): 190 | super().__init__(app) 191 | self.prerender_service_url = prerender_service_url 192 | self.prerender_service_username = prerender_service_username 193 | self.prerender_service_password = prerender_service_password 194 | self.prerender_service_token = prerender_service_token 195 | self.crawler_user_agents = ( 196 | crawler_user_agents if crawler_user_agents else DEFAULT_CRAWLER_USER_AGENTS 197 | ) 198 | self.extensions_to_ignore = ( 199 | extensions_to_ignore 200 | if extensions_to_ignore 201 | else DEFAULT_EXTENSIONS_TO_IGNORE 202 | ) 203 | 204 | if whitelist: 205 | self.whitelist = compile_patterns(whitelist) 206 | 207 | if blacklist: 208 | self.blacklist = compile_patterns(blacklist) 209 | 210 | self.before_render = before_render 211 | self.after_render = after_render 212 | 213 | async def dispatch( 214 | self, request: Request, call_next: RequestResponseEndpoint 215 | ) -> Response: 216 | if self._should_prerender(request): 217 | response = None 218 | cached = True 219 | 220 | if self.before_render: 221 | response = await self.before_render(request) 222 | 223 | if not response: 224 | response = await self._get_prerendered_response(request) 225 | cached = False 226 | 227 | if self.after_render: 228 | await self.after_render(request, response, cached) 229 | 230 | return response 231 | else: 232 | return await call_next(request) 233 | 234 | def _should_prerender(self, request: Request) -> bool: 235 | method = request.method 236 | user_agent = request.headers.get("user-agent") 237 | buffer_agent = request.headers.get("x-bufferbot") 238 | prerender_agent = request.headers.get("x-prerender") 239 | 240 | if method != "GET" or not user_agent or prerender_agent: 241 | return False 242 | 243 | if buffer_agent or is_matching_user_agent(user_agent, self.crawler_user_agents): 244 | request_path = request.url.path 245 | 246 | # Check ignored extensions 247 | for extension in self.extensions_to_ignore: 248 | if request_path.endswith(extension): 249 | return False 250 | 251 | # Check whitelist 252 | if self.whitelist: 253 | whitelist_match = has_matching_pattern(self.whitelist, request_path) 254 | if not whitelist_match: 255 | return False 256 | 257 | # Check blacklist 258 | if self.blacklist: 259 | blacklist_match = has_matching_pattern(self.blacklist, request_path) 260 | if blacklist_match: 261 | return False 262 | 263 | return True 264 | 265 | return False 266 | 267 | async def _get_prerendered_response(self, request: Request) -> HTMLResponse: 268 | auth = None 269 | if self.prerender_service_username and self.prerender_service_password: 270 | auth = (self.prerender_service_username, self.prerender_service_password) 271 | 272 | headers = {} 273 | if self.prerender_service_token: 274 | headers["x-prerender-token"] = self.prerender_service_token 275 | 276 | async with httpx.AsyncClient( 277 | base_url=self.prerender_service_url, timeout=30, auth=auth, headers=headers 278 | ) as client: 279 | response = await client.get(f"/{request.url}") 280 | return HTMLResponse(response.text) 281 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<3"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "prerender_python_starlette" 7 | dist-name = "prerender-python-starlette" 8 | author = "François Voron" 9 | author-email = "fvoron@gmail.com" 10 | home-page = "https://github.com/BeeMyDesk/prerender-python-starlette" 11 | classifiers = [ 12 | "Programming Language :: Python :: 3.7", 13 | "Programming Language :: Python :: 3.8", 14 | "Programming Language :: Python :: 3 :: Only", 15 | ] 16 | description-file = "README.md" 17 | requires-python = ">=3.7" 18 | requires = [ 19 | "httpx >=0.11,<0.13", 20 | ] 21 | 22 | [tool.flit.metadata.requires-extra] 23 | 24 | [tool.flit.metadata.urls] 25 | Documentation = "https://BeeMyDesk.github.io/prerender-python-starlette/" 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.0.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:prerender_python_starlette/__init__.py] 7 | search = __version__ = "{current_version}" 8 | replace = __version__ = "{new_version}" 9 | 10 | [flake8] 11 | exclude = docs 12 | max-line-length = 88 13 | docstring-convention = numpy 14 | ignore = D1 15 | 16 | [isort] 17 | atomic = true 18 | line_length = 88 19 | multi_line_output = 5 20 | known_standard_library = types 21 | known_third_party = pytest,_pytest 22 | 23 | [tool:pytest] 24 | markers = 25 | 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeeMyDesk/prerender-python-starlette/0fbbf142c57ebd5c1c6d0271ca8ddea19906edf0/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BeeMyDesk/prerender-python-starlette/0fbbf142c57ebd5c1c6d0271ca8ddea19906edf0/tests/conftest.py -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | from base64 import b64encode 4 | from typing import Awaitable, Callable, List, Optional 5 | 6 | import respx 7 | from starlette.applications import Starlette 8 | from starlette.middleware import Middleware 9 | from starlette.requests import Request 10 | from starlette.responses import HTMLResponse 11 | from starlette.routing import Route 12 | from starlette.testclient import TestClient 13 | 14 | from prerender_python_starlette.middleware import PrerenderMiddleware 15 | 16 | 17 | @pytest.fixture 18 | def get_test_client(): 19 | def _get_test_client( 20 | whitelist: List[str] = None, 21 | blacklist: List[str] = None, 22 | before_render: Callable[[Request], Awaitable[Optional[HTMLResponse]]] = None, 23 | after_render: Callable[[Request, HTMLResponse], Awaitable[None]] = None, 24 | username: str = None, 25 | password: str = None, 26 | token: str = None, 27 | ) -> TestClient: 28 | def main_get(r): 29 | return HTMLResponse("RAW") 30 | 31 | def main_post(r): 32 | return HTMLResponse("RAW") 33 | 34 | def js_get(r): 35 | return HTMLResponse("RAW") 36 | 37 | def whitelisted_url1(r): 38 | return HTMLResponse("RAW") 39 | 40 | def blacklisted_url1(r): 41 | return HTMLResponse("RAW") 42 | 43 | def whitelisted_blacklisted_url(r): 44 | return HTMLResponse("RAW") 45 | 46 | routes = [ 47 | Route("/", endpoint=main_get, methods=["GET"]), 48 | Route("/", endpoint=main_post, methods=["POST"]), 49 | Route("/file.js", endpoint=js_get, methods=["GET"]), 50 | Route("/whitelisted-url1", endpoint=whitelisted_url1, methods=["GET"]), 51 | Route("/blacklisted-url1", endpoint=blacklisted_url1, methods=["GET"]), 52 | Route( 53 | "/whitelisted-url-blacklisted-url", 54 | endpoint=whitelisted_blacklisted_url, 55 | methods=["GET"], 56 | ), 57 | ] 58 | 59 | middleware = [ 60 | Middleware( 61 | PrerenderMiddleware, 62 | prerender_service_url="http://prerender.bar.com", 63 | prerender_service_username=username, 64 | prerender_service_password=password, 65 | prerender_service_token=token, 66 | whitelist=whitelist, 67 | blacklist=blacklist, 68 | before_render=before_render, 69 | after_render=after_render, 70 | ), 71 | ] 72 | 73 | app = Starlette(routes=routes, middleware=middleware) 74 | 75 | return TestClient(app) 76 | 77 | return _get_test_client 78 | 79 | 80 | @respx.mock 81 | @pytest.mark.parametrize( 82 | "whitelist,blacklist,user_agent,buffer_agent,prerender_agent,method,path,prerendered", 83 | [ 84 | (None, None, None, None, None, "GET", "/", False), 85 | (None, None, "Chrome", None, None, "GET", "/", False), 86 | (None, None, "googlebot", None, None, "GET", "/", True), 87 | (None, None, "LinkedInBot/1.0", None, None, "GET", "/", True), 88 | (None, None, "Chrome", "Buffer", None, "GET", "/", True), 89 | (None, None, "Chrome", None, "Prerender", "GET", "/", False), 90 | (None, None, "googlebot", None, None, "POST", "/", False), 91 | (None, None, "googlebot", None, None, "GET", "/file.js", False), 92 | ( 93 | ["^/whitelisted-url"], 94 | None, 95 | "googlebot", 96 | None, 97 | None, 98 | "GET", 99 | "/whitelisted-url1", 100 | True, 101 | ), 102 | (["^/whitelisted-url"], None, "googlebot", None, None, "GET", "/", False), 103 | ( 104 | None, 105 | ["^/blacklisted-url"], 106 | "googlebot", 107 | None, 108 | None, 109 | "GET", 110 | "/blacklisted-url1", 111 | False, 112 | ), 113 | (None, ["^/blacklisted-url"], "googlebot", None, None, "GET", "/", True), 114 | ( 115 | ["^/whitelisted-url"], 116 | [".*blacklisted-url$"], 117 | "googlebot", 118 | None, 119 | None, 120 | "GET", 121 | "/whitelisted-url-blacklisted-url", 122 | False, 123 | ), 124 | ], 125 | ) 126 | def test_prerender( 127 | get_test_client, 128 | whitelist, 129 | blacklist, 130 | user_agent, 131 | buffer_agent, 132 | prerender_agent, 133 | method, 134 | path, 135 | prerendered, 136 | ): 137 | request = respx.request( 138 | method, 139 | f"http://prerender.bar.com/http://testserver{path}", 140 | content="PRERENDERED", 141 | ) 142 | 143 | test_client = get_test_client(whitelist, blacklist) 144 | 145 | headers = {} 146 | if user_agent: 147 | headers["user-agent"] = user_agent 148 | if buffer_agent: 149 | headers["x-bufferbot"] = buffer_agent 150 | if prerender_agent: 151 | headers["x-prerender"] = prerender_agent 152 | 153 | response = test_client.request(method, path, headers=headers) 154 | 155 | if prerendered: 156 | assert request.called 157 | assert response.text == "PRERENDERED" 158 | assert response.headers.get("content-type") == "text/html; charset=utf-8" 159 | else: 160 | assert request.called is False 161 | assert response.text == "RAW" 162 | 163 | 164 | def test_before_render(mocker, get_test_client): 165 | future = asyncio.Future() 166 | future.set_result(HTMLResponse("PRERENDERED")) 167 | get_prerendered_response_mock = mocker.patch( 168 | "prerender_python_starlette.middleware.PrerenderMiddleware._get_prerendered_response", 169 | return_value=future, 170 | ) 171 | 172 | async def before_render(request: Request) -> Optional[HTMLResponse]: 173 | return HTMLResponse("CACHED") 174 | 175 | test_client = get_test_client(before_render=before_render) 176 | response = test_client.request("GET", "/", headers={"user-agent": "googlebot"}) 177 | 178 | assert get_prerendered_response_mock.called is False 179 | assert response.text == "CACHED" 180 | assert response.headers.get("content-type") == "text/html; charset=utf-8" 181 | 182 | 183 | @respx.mock 184 | def test_basic_auth(get_test_client): 185 | request = respx.get( 186 | f"http://prerender.bar.com/http://testserver/", 187 | content="PRERENDERED", 188 | ) 189 | 190 | test_client = get_test_client(username="foo", password="bar") 191 | test_client.request("GET", "/", headers={"user-agent": "googlebot"}) 192 | 193 | assert request.called 194 | encoded_auth = b64encode(b"foo:bar").decode("latin-1") 195 | assert request.calls[0][0].headers["Authorization"] == f"Basic {encoded_auth}" 196 | 197 | 198 | @respx.mock 199 | def test_token(get_test_client): 200 | request = respx.get( 201 | f"http://prerender.bar.com/http://testserver/", 202 | content="PRERENDERED", 203 | ) 204 | 205 | test_client = get_test_client(token="foo") 206 | test_client.request("GET", "/", headers={"user-agent": "googlebot"}) 207 | 208 | assert request.called 209 | assert request.calls[0][0].headers["x-prerender-token"] == "foo" 210 | 211 | 212 | @respx.mock 213 | def test_after_render(mocker, get_test_client): 214 | request = respx.get( 215 | f"http://prerender.bar.com/http://testserver/", 216 | content="PRERENDERED", 217 | ) 218 | 219 | future = asyncio.Future() 220 | future.set_result(None) 221 | after_render = mocker.MagicMock(return_value=future) 222 | 223 | test_client = get_test_client(after_render=after_render) 224 | response = test_client.request("GET", "/", headers={"user-agent": "googlebot"}) 225 | 226 | assert after_render.called 227 | assert type(after_render.call_args[0][0]) == Request 228 | assert type(after_render.call_args[0][1]) == HTMLResponse 229 | assert after_render.call_args[0][2] is False 230 | 231 | assert request.called 232 | assert response.text == "PRERENDERED" 233 | assert response.headers.get("content-type") == "text/html; charset=utf-8" 234 | 235 | 236 | @respx.mock 237 | def test_before_after_render(mocker, get_test_client): 238 | request = respx.get( 239 | f"http://prerender.bar.com/http://testserver/", 240 | content="PRERENDERED", 241 | ) 242 | 243 | before_future = asyncio.Future() 244 | before_future.set_result(HTMLResponse("CACHED")) 245 | before_render = mocker.MagicMock(return_value=before_future) 246 | 247 | future = asyncio.Future() 248 | future.set_result(None) 249 | after_render = mocker.MagicMock(return_value=future) 250 | 251 | test_client = get_test_client( 252 | before_render=before_render, after_render=after_render 253 | ) 254 | response = test_client.request("GET", "/", headers={"user-agent": "googlebot"}) 255 | 256 | assert before_render.called 257 | 258 | assert after_render.called 259 | assert type(after_render.call_args[0][0]) == Request 260 | assert type(after_render.call_args[0][1]) == HTMLResponse 261 | assert after_render.call_args[0][2] is True 262 | 263 | assert request.called is False 264 | assert response.text == "CACHED" 265 | assert response.headers.get("content-type") == "text/html; charset=utf-8" 266 | -------------------------------------------------------------------------------- /tests/test_prerender_python_starlette.py: -------------------------------------------------------------------------------- 1 | from prerender_python_starlette import ( 2 | PrerenderMiddleware, 3 | DEFAULT_CRAWLER_USER_AGENTS, 4 | DEFAULT_EXTENSIONS_TO_IGNORE, 5 | ) 6 | 7 | 8 | def test_default_imports(): 9 | assert PrerenderMiddleware is not None 10 | assert type(DEFAULT_CRAWLER_USER_AGENTS) == list 11 | assert type(DEFAULT_EXTENSIONS_TO_IGNORE) == list 12 | --------------------------------------------------------------------------------