diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..00c922a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,29 @@
+# python specific
+env*
+.cache/
+.pytest_cache/
+.idea/
+*.pyc
+*.so
+*.pyd
+aiohttp_csrf.egg-info
+build/*
+dist/*
+MANIFEST
+__pycache__/
+*.egg-info/
+.coverage
+.python-version
+htmlcov
+
+# generic files to ignore
+*~
+*.DS_Store
+*.swp
+*.out
+
+.tox/
+deps/
+docs/_build/
+
+idea/
\ No newline at end of file
diff --git a/.isort.cfg b/.isort.cfg
new file mode 100644
index 0000000..47f77e9
--- /dev/null
+++ b/.isort.cfg
@@ -0,0 +1,2 @@
+[settings]
+known_third_party=aiohttp_csrf
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..53c1bfd
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,20 @@
+dist: trusty
+language: python
+python:
+ - "3.5"
+ - "3.6"
+install:
+ - pip install -U setuptools
+ - pip install -U pip
+ - pip install -U wheel
+ - pip install -U tox
+script:
+ - export TOXENV=py`python -c 'import sys; print("".join(map(str, sys.version_info[:2])))'`
+ - echo "$TOXENV"
+
+ - tox
+cache:
+ directories:
+ - $HOME/.cache/pip
+notifications:
+ email: false
diff --git a/LICENCE b/LICENCE
new file mode 100644
index 0000000..22f6914
--- /dev/null
+++ b/LICENCE
@@ -0,0 +1,21 @@
+The MIT License
+
+Copyright (c) Ocean S.A. https://ocean.io/
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..e24206f
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,4 @@
+include README.rst
+include LICENSE
+recursive-exclude * __pycache__
+recursive-exclude * *.py[co]
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..8f39852
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,21 @@
+[[source]]
+name = "pypi"
+url = "https://pypi.org/simple"
+verify_ssl = true
+
+[dev-packages]
+pytest = ">=5.3.5"
+pytest-aiohttp = "==0.*,>=0.3.0"
+pytest-cov = ">=2.8.1"
+tox = ">=3.14.5"
+flake8 = ">=3.7.9"
+isort = "==4.3.21"
+ipdb = "*"
+
+[packages]
+aiohttp = "<3.8,>=3.6.2"
+aiohttp-session = {extras = ["aioredis"], git = "https://github.com/TheDoctorAI/aiohttp-session", ref = "master"}
+blake3 = "==0.*,>=0.1.8"
+
+[requires]
+python_version = "3.8"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..39f6cbf
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,501 @@
+{
+ "_meta": {
+ "hash": {
+ "sha256": "e092f3bbe7824881c5a4642836e64d8dec79ed9e7619304a65aa340ff18e1be0"
+ },
+ "pipfile-spec": 6,
+ "requires": {
+ "python_version": "3.8"
+ },
+ "sources": [
+ {
+ "name": "pypi",
+ "url": "https://pypi.org/simple",
+ "verify_ssl": true
+ }
+ ]
+ },
+ "default": {
+ "aiohttp": {
+ "hashes": [
+ "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e",
+ "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326",
+ "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a",
+ "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654",
+ "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a",
+ "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4",
+ "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17",
+ "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec",
+ "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd",
+ "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48",
+ "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59",
+ "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"
+ ],
+ "index": "pypi",
+ "version": "==3.6.2"
+ },
+ "aiohttp-session": {
+ "hashes": [
+ "sha256:74853d1177541cccfefb436409f9ea5d67a62f84e13946a3e115a765d9a0349c",
+ "sha256:959413468b84e30e7ca09719617cfb0000066a2e0f6c20062d043433e82aeb74"
+ ],
+ "index": "pypi",
+ "version": "==2.9.0"
+ },
+ "async-timeout": {
+ "hashes": [
+ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
+ "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
+ ],
+ "version": "==3.0.1"
+ },
+ "attrs": {
+ "hashes": [
+ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
+ "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
+ ],
+ "version": "==19.3.0"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "version": "==3.0.4"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
+ "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
+ ],
+ "version": "==2.9"
+ },
+ "multidict": {
+ "hashes": [
+ "sha256:317f96bc0950d249e96d8d29ab556d01dd38888fbe68324f46fd834b430169f1",
+ "sha256:42f56542166040b4474c0c608ed051732033cd821126493cf25b6c276df7dd35",
+ "sha256:4b7df040fb5fe826d689204f9b544af469593fb3ff3a069a6ad3409f742f5928",
+ "sha256:544fae9261232a97102e27a926019100a9db75bec7b37feedd74b3aa82f29969",
+ "sha256:620b37c3fea181dab09267cd5a84b0f23fa043beb8bc50d8474dd9694de1fa6e",
+ "sha256:6e6fef114741c4d7ca46da8449038ec8b1e880bbe68674c01ceeb1ac8a648e78",
+ "sha256:7774e9f6c9af3f12f296131453f7b81dabb7ebdb948483362f5afcaac8a826f1",
+ "sha256:85cb26c38c96f76b7ff38b86c9d560dea10cf3459bb5f4caf72fc1bb932c7136",
+ "sha256:a326f4240123a2ac66bb163eeba99578e9d63a8654a59f4688a79198f9aa10f8",
+ "sha256:ae402f43604e3b2bc41e8ea8b8526c7fa7139ed76b0d64fc48e28125925275b2",
+ "sha256:aee283c49601fa4c13adc64c09c978838a7e812f85377ae130a24d7198c0331e",
+ "sha256:b51249fdd2923739cd3efc95a3d6c363b67bbf779208e9f37fd5e68540d1a4d4",
+ "sha256:bb519becc46275c594410c6c28a8a0adc66fe24fef154a9addea54c1adb006f5",
+ "sha256:c2c37185fb0af79d5c117b8d2764f4321eeb12ba8c141a95d0aa8c2c1d0a11dd",
+ "sha256:dc561313279f9d05a3d0ffa89cd15ae477528ea37aa9795c4654588a3287a9ab",
+ "sha256:e439c9a10a95cb32abd708bb8be83b2134fa93790a4fb0535ca36db3dda94d20",
+ "sha256:fc3b4adc2ee8474cb3cd2a155305d5f8eda0a9c91320f83e55748e1fcb68f8e3"
+ ],
+ "version": "==4.7.5"
+ },
+ "yarl": {
+ "hashes": [
+ "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce",
+ "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6",
+ "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce",
+ "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae",
+ "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d",
+ "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f",
+ "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b",
+ "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b",
+ "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb",
+ "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462",
+ "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea",
+ "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70",
+ "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1",
+ "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a",
+ "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b",
+ "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080",
+ "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"
+ ],
+ "version": "==1.4.2"
+ }
+ },
+ "develop": {
+ "aiohttp": {
+ "hashes": [
+ "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e",
+ "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326",
+ "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a",
+ "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654",
+ "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a",
+ "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4",
+ "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17",
+ "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec",
+ "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd",
+ "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48",
+ "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59",
+ "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"
+ ],
+ "index": "pypi",
+ "version": "==3.6.2"
+ },
+ "aiohttp-csrf": {
+ "editable": true,
+ "path": "."
+ },
+ "appdirs": {
+ "hashes": [
+ "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
+ "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
+ ],
+ "version": "==1.4.3"
+ },
+ "async-timeout": {
+ "hashes": [
+ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
+ "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
+ ],
+ "version": "==3.0.1"
+ },
+ "attrs": {
+ "hashes": [
+ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
+ "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
+ ],
+ "version": "==19.3.0"
+ },
+ "backcall": {
+ "hashes": [
+ "sha256:38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4",
+ "sha256:bbbf4b1e5cd2bdb08f915895b51081c041bac22394fdfcfdfbe9f14b77c08bf2"
+ ],
+ "version": "==0.1.0"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "version": "==3.0.4"
+ },
+ "coverage": {
+ "hashes": [
+ "sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3",
+ "sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c",
+ "sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0",
+ "sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477",
+ "sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a",
+ "sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf",
+ "sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691",
+ "sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73",
+ "sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987",
+ "sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894",
+ "sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e",
+ "sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef",
+ "sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf",
+ "sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68",
+ "sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8",
+ "sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954",
+ "sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2",
+ "sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40",
+ "sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc",
+ "sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc",
+ "sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e",
+ "sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d",
+ "sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f",
+ "sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc",
+ "sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301",
+ "sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea",
+ "sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb",
+ "sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af",
+ "sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52",
+ "sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37",
+ "sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0"
+ ],
+ "version": "==5.0.3"
+ },
+ "decorator": {
+ "hashes": [
+ "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760",
+ "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"
+ ],
+ "version": "==4.4.2"
+ },
+ "distlib": {
+ "hashes": [
+ "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21"
+ ],
+ "version": "==0.3.0"
+ },
+ "entrypoints": {
+ "hashes": [
+ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
+ "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
+ ],
+ "version": "==0.3"
+ },
+ "filelock": {
+ "hashes": [
+ "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
+ "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"
+ ],
+ "version": "==3.0.12"
+ },
+ "flake8": {
+ "hashes": [
+ "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
+ "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
+ ],
+ "index": "pypi",
+ "version": "==3.7.9"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
+ "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
+ ],
+ "version": "==2.9"
+ },
+ "ipdb": {
+ "hashes": [
+ "sha256:77fb1c2a6fccdfee0136078c9ed6fe547ab00db00bebff181f1e8c9e13418d49"
+ ],
+ "index": "pypi",
+ "version": "==0.13.2"
+ },
+ "ipython": {
+ "hashes": [
+ "sha256:ca478e52ae1f88da0102360e57e528b92f3ae4316aabac80a2cd7f7ab2efb48a",
+ "sha256:eb8d075de37f678424527b5ef6ea23f7b80240ca031c2dd6de5879d687a65333"
+ ],
+ "version": "==7.13.0"
+ },
+ "ipython-genutils": {
+ "hashes": [
+ "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8",
+ "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"
+ ],
+ "version": "==0.2.0"
+ },
+ "isort": {
+ "hashes": [
+ "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
+ "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
+ ],
+ "index": "pypi",
+ "version": "==4.3.21"
+ },
+ "jedi": {
+ "hashes": [
+ "sha256:b4f4052551025c6b0b0b193b29a6ff7bdb74c52450631206c262aef9f7159ad2",
+ "sha256:d5c871cb9360b414f981e7072c52c33258d598305280fef91c6cae34739d65d5"
+ ],
+ "version": "==0.16.0"
+ },
+ "mccabe": {
+ "hashes": [
+ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
+ "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
+ ],
+ "version": "==0.6.1"
+ },
+ "more-itertools": {
+ "hashes": [
+ "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c",
+ "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"
+ ],
+ "version": "==8.2.0"
+ },
+ "multidict": {
+ "hashes": [
+ "sha256:317f96bc0950d249e96d8d29ab556d01dd38888fbe68324f46fd834b430169f1",
+ "sha256:42f56542166040b4474c0c608ed051732033cd821126493cf25b6c276df7dd35",
+ "sha256:4b7df040fb5fe826d689204f9b544af469593fb3ff3a069a6ad3409f742f5928",
+ "sha256:544fae9261232a97102e27a926019100a9db75bec7b37feedd74b3aa82f29969",
+ "sha256:620b37c3fea181dab09267cd5a84b0f23fa043beb8bc50d8474dd9694de1fa6e",
+ "sha256:6e6fef114741c4d7ca46da8449038ec8b1e880bbe68674c01ceeb1ac8a648e78",
+ "sha256:7774e9f6c9af3f12f296131453f7b81dabb7ebdb948483362f5afcaac8a826f1",
+ "sha256:85cb26c38c96f76b7ff38b86c9d560dea10cf3459bb5f4caf72fc1bb932c7136",
+ "sha256:a326f4240123a2ac66bb163eeba99578e9d63a8654a59f4688a79198f9aa10f8",
+ "sha256:ae402f43604e3b2bc41e8ea8b8526c7fa7139ed76b0d64fc48e28125925275b2",
+ "sha256:aee283c49601fa4c13adc64c09c978838a7e812f85377ae130a24d7198c0331e",
+ "sha256:b51249fdd2923739cd3efc95a3d6c363b67bbf779208e9f37fd5e68540d1a4d4",
+ "sha256:bb519becc46275c594410c6c28a8a0adc66fe24fef154a9addea54c1adb006f5",
+ "sha256:c2c37185fb0af79d5c117b8d2764f4321eeb12ba8c141a95d0aa8c2c1d0a11dd",
+ "sha256:dc561313279f9d05a3d0ffa89cd15ae477528ea37aa9795c4654588a3287a9ab",
+ "sha256:e439c9a10a95cb32abd708bb8be83b2134fa93790a4fb0535ca36db3dda94d20",
+ "sha256:fc3b4adc2ee8474cb3cd2a155305d5f8eda0a9c91320f83e55748e1fcb68f8e3"
+ ],
+ "version": "==4.7.5"
+ },
+ "packaging": {
+ "hashes": [
+ "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73",
+ "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"
+ ],
+ "version": "==20.1"
+ },
+ "parso": {
+ "hashes": [
+ "sha256:0c5659e0c6eba20636f99a04f469798dca8da279645ce5c387315b2c23912157",
+ "sha256:8515fc12cfca6ee3aa59138741fc5624d62340c97e401c74875769948d4f2995"
+ ],
+ "version": "==0.6.2"
+ },
+ "pexpect": {
+ "hashes": [
+ "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937",
+ "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"
+ ],
+ "markers": "sys_platform != 'win32'",
+ "version": "==4.8.0"
+ },
+ "pickleshare": {
+ "hashes": [
+ "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca",
+ "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"
+ ],
+ "version": "==0.7.5"
+ },
+ "pluggy": {
+ "hashes": [
+ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
+ "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
+ ],
+ "version": "==0.13.1"
+ },
+ "prompt-toolkit": {
+ "hashes": [
+ "sha256:a402e9bf468b63314e37460b68ba68243d55b2f8c4d0192f85a019af3945050e",
+ "sha256:c93e53af97f630f12f5f62a3274e79527936ed466f038953dfa379d4941f651a"
+ ],
+ "version": "==3.0.3"
+ },
+ "ptyprocess": {
+ "hashes": [
+ "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0",
+ "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"
+ ],
+ "version": "==0.6.0"
+ },
+ "py": {
+ "hashes": [
+ "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
+ "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
+ ],
+ "version": "==1.8.1"
+ },
+ "pycodestyle": {
+ "hashes": [
+ "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
+ "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
+ ],
+ "version": "==2.5.0"
+ },
+ "pyflakes": {
+ "hashes": [
+ "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
+ "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
+ ],
+ "version": "==2.1.1"
+ },
+ "pygments": {
+ "hashes": [
+ "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
+ "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"
+ ],
+ "version": "==2.5.2"
+ },
+ "pyparsing": {
+ "hashes": [
+ "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
+ "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
+ ],
+ "version": "==2.4.6"
+ },
+ "pytest": {
+ "hashes": [
+ "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d",
+ "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6"
+ ],
+ "index": "pypi",
+ "version": "==5.3.5"
+ },
+ "pytest-aiohttp": {
+ "hashes": [
+ "sha256:0b9b660b146a65e1313e2083d0d2e1f63047797354af9a28d6b7c9f0726fa33d",
+ "sha256:c929854339637977375838703b62fef63528598bc0a9d451639eba95f4aaa44f"
+ ],
+ "index": "pypi",
+ "version": "==0.3.0"
+ },
+ "pytest-cov": {
+ "hashes": [
+ "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b",
+ "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"
+ ],
+ "index": "pypi",
+ "version": "==2.8.1"
+ },
+ "six": {
+ "hashes": [
+ "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
+ "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
+ ],
+ "version": "==1.14.0"
+ },
+ "toml": {
+ "hashes": [
+ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
+ "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
+ ],
+ "version": "==0.10.0"
+ },
+ "tox": {
+ "hashes": [
+ "sha256:0cbe98369081fa16bd6f1163d3d0b2a62afa29d402ccfad2bd09fb2668be0956",
+ "sha256:676f1e3e7de245ad870f956436b84ea226210587d1f72c8dfb8cd5ac7b6f0e70"
+ ],
+ "index": "pypi",
+ "version": "==3.14.5"
+ },
+ "traitlets": {
+ "hashes": [
+ "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44",
+ "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"
+ ],
+ "version": "==4.3.3"
+ },
+ "virtualenv": {
+ "hashes": [
+ "sha256:0c04c7e8e0314470b4c2b43740ff68be1c62bb3fdef8309341ff1daea60d49d1",
+ "sha256:1f0369d068d9761b5c1ed7b44dad1ec124727eb10bc7f4aaefbba0cdca3bd924"
+ ],
+ "version": "==20.0.8"
+ },
+ "wcwidth": {
+ "hashes": [
+ "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603",
+ "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"
+ ],
+ "version": "==0.1.8"
+ },
+ "yarl": {
+ "hashes": [
+ "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce",
+ "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6",
+ "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce",
+ "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae",
+ "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d",
+ "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f",
+ "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b",
+ "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b",
+ "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb",
+ "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462",
+ "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea",
+ "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70",
+ "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1",
+ "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a",
+ "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b",
+ "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080",
+ "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"
+ ],
+ "version": "==1.4.2"
+ }
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..eab2f4f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,246 @@
+aiohttp_csrf
+=============
+
+The library provides csrf (xsrf) protection for [aiohttp.web](https://docs.aiohttp.org/en/latest/web.html).
+
+**Breaking Change:** New in 0.1.0 is Blake3 hashes are used by default. This means you must pass `secret_phrase` to
+`aiohttp_csrf.storage.SessionStorage`
+
+**note:** The package [aiohttp-csrf-fixed](https://pypi.org/project/aiohttp-csrf-fixed) is aiohttp_csrf 0.0.2 +
+[this commit](https://github.com/oplik0/aiohttp-csrf/commit/b1bd9207f43a2abf30e32e72ecdb10983a251823). The maintainer
+didn't submit a PR so I just saw it by chance. I haven't had time to closely examine it but I think it's just removing
+the HTTP security error that happens if no CSRF is provided. Why do that? An HTTP error is good because it tells the
+client what happened and lets you handle it by middleware.
+
+__0.1.1:__ Converted `@aiohttp_csrf.csrf_exempt` decorator to a co-routine to make it compatible with latest aiohttp.
+
+
+
+Basic usage
+-----------
+
+The library allows you to implement csrf (xsrf) protection for requests
+
+Basic usage example:
+
+```python
+import aiohttp_csrf
+from aiohttp import web
+
+FORM_FIELD_NAME = '_csrf_token'
+COOKIE_NAME = 'csrf_token'
+
+
+def make_app():
+ csrf_policy = aiohttp_csrf.policy.FormPolicy(FORM_FIELD_NAME)
+
+ csrf_storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ app = web.Application()
+
+ aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage)
+
+ app.middlewares.append(aiohttp_csrf.csrf_middleware)
+
+ async def handler_get_form_with_token(request):
+ token = await aiohttp_csrf.generate_token(request)
+
+
+ body = '''
+
+
Form with csrf protection
+
+
+
+
+ ''' # noqa
+
+ body = body.format(field_name=FORM_FIELD_NAME, token=token)
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ async def handler_post_check(request):
+ post = await request.post()
+
+ body = 'Hello, {name}'.format(name=post['name'])
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ app.router.add_route(
+ 'GET',
+ '/',
+ handler_get_form_with_token,
+ )
+
+ app.router.add_route(
+ 'POST',
+ '/',
+ handler_post_check,
+ )
+
+ return app
+
+
+web.run_app(make_app())
+```
+
+### Initialize
+
+First of all, you need to initialize `aiohttp_csrf` in your application:
+
+```python
+app = web.Application()
+
+csrf_policy = aiohttp_csrf.policy.FormPolicy(FORM_FIELD_NAME)
+
+csrf_storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage)
+```
+
+### Middleware and decorators
+
+After initialize you can use `@aiohttp_csrf.csrf_protect` for handlers, that you want to protect. Or you can
+initialize `aiohttp_csrf.csrf_middleware` and do not disturb about using
+decorator ([full middleware example here](demo/middleware.py)):
+
+```python
+# ...
+app.middlewares.append(aiohttp_csrf.csrf_middleware)
+# ...
+```
+
+In this case all your handlers will be protected.
+
+**Note:** we strongly recommend to use `aiohttp_csrf.csrf_middleware` and `@aiohttp_csrf.csrf_exempt` instead of
+manually managing with `@aiohttp_csrf.csrf_protect`. But if you prefer to use `@aiohttp_csrf.csrf_protect`, don't forget
+to use `@aiohttp_csrf.csrf_protect` for both methods: GET and
+POST ([manual protection example](demo/manual_protection.py))
+
+If you want to use middleware, but need handlers without protection, you can use `@aiohttp_csrf.csrf_exempt`. Mark you
+handler with this decorator and this handler will not check the token:
+
+```python
+@aiohttp_csrf.csrf_exempt
+async def handler_post_not_check(request):
+ ...
+```
+
+### Generate token
+
+For generate token you need to call `aiohttp_csrf.generate_token` in your handler:
+
+```python
+@aiohttp_csrf.csrf_protect
+async def handler_get(request):
+ token = await aiohttp_csrf.generate_token(request)
+ ...
+```
+
+Advanced usage
+--------------
+
+### Policies
+
+You can use different policies for check tokens. Library provides 3 types of policy:
+
+- **FormPolicy**. This policy will search token in the body of your POST request (Usually use for forms) or as a GET
+ variable of the same name. You need to specify name of field that will be checked.
+- **HeaderPolicy**. This policy will search token in headers of your POST request (Usually use for AJAX requests). You
+ need to specify name of header that will be checked.
+- **FormAndHeaderPolicy**. This policy combines behavior of **FormPolicy** and **HeaderPolicy**.
+
+You can implement your custom policies if needed. But make sure that your custom policy
+implements `aiohttp_csrf.policy.AbstractPolicy` interface.
+
+### Storages
+
+You can use different types of storages for storing token. Library provides 2 types of storage:
+
+- **CookieStorage**. Your token will be stored in cookie variable. You need to specify cookie name.
+- **SessionStorage**. Your token will be stored in session. You need to specify session variable name.
+
+**Important:** If you want to use session storage, you need setup aiohttp\_session in your
+application ([session storage example](demo/session_storage.py#L22))
+
+You can implement your custom storages if needed. But make sure that your custom storage
+implements `aiohttp_csrf.storage.AbstractStorage` interface.
+
+### Token generators
+
+You can use different token generator in your application. By default storages
+using `aiohttp_csrf.token_generator.SimpleTokenGenerator`
+
+But if you need more secure token generator - you can use `aiohttp_csrf.token_generator.HashedTokenGenerator`
+
+And you can implement your custom token generators if needed. But make sure that your custom token generator
+implements `aiohttp_csrf.token_generator.AbstractTokenGenerator` interface.
+
+### Invalid token behavior
+
+By default, if token is invalid, `aiohttp_csrf` will raise `aiohttp.web.HTTPForbidden` exception.
+
+You have ability to specify your custom error handler. It can be:
+
+- **callable instance. Input parameter - aiohttp request.**
+
+```python
+def custom_error_handler(request):
+ # do something
+ return aiohttp.web.Response(status=403)
+
+# or
+
+async def custom_async_error_handler(request):
+ # await do something
+ return aiohttp.web.Response(status=403)
+```
+
+It will be called instead of protected handler.
+
+- **sub class of Exception**. In this case this Exception will be raised.
+
+```python
+class CustomException(Exception):
+ pass
+```
+
+You can specify custom error handler globally, when initialize `aiohttp_csrf` in your application:
+
+```python
+...
+class CustomException(Exception):
+ pass
+
+...
+aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage, error_renderer=CustomException)
+...
+```
+
+In this case custom error handler will be applied to all protected handlers.
+
+Or you can specify custom error handler locally, for specific handler:
+
+```python
+...
+class CustomException(Exception):
+ pass
+
+...
+@aiohttp_csrf.csrf_protect(error_renderer=CustomException)
+def handler_with_custom_csrf_error(request):
+ ...
+```
+
+In this case custom error handler will be applied to this handler only. For all other handlers will be applied global
+error handler.
diff --git a/README.rst b/README.rst
index b779bd2..c544c6e 100644
--- a/README.rst
+++ b/README.rst
@@ -1 +1,273 @@
-# aiohttp-csrf
\ No newline at end of file
+aiohttp_csrf
+============
+
+The library provides csrf (xsrf) protection for `aiohttp.web`__.
+
+The library provides csrf (xsrf) protection for [aiohttp.web](https://docs.aiohttp.org/en/latest/web.html).
+
+**note:** The package [aiohttp-csrf-fixed](https://pypi.org/project/aiohttp-csrf-fixed) is aiohttp_csrf 0.0.2 +
+[this commit](https://github.com/oplik0/aiohttp-csrf/commit/b1bd9207f43a2abf30e32e72ecdb10983a251823). The maintainer
+didn't submit a PR so I just saw it by chance. I haven't had time to closely examine it but I think it's just removing
+the HTTP security error that happens if no CSRF is provided. Why do that? An HTTP error is good because it tells the
+client what happened and lets you handle it by middleware.
+
+**Breaking Change:** New in 0.1.0 is Blake3 hashes are used by default. This means you must pass `secret_phrase` to
+`aiohttp_csrf.storage.SessionStorage`
+
+.. _aiohttp_web: https://docs.aiohttp.org/en/latest/web.html
+
+__ aiohttp_web_
+
+.. image:: https://img.shields.io/travis/wikibusiness/aiohttp-csrf.svg
+ :target: https://travis-ci.org/wikibusiness/aiohttp-csrf
+
+Basic usage
+-----------
+
+The library allows you to implement csrf (xsrf) protection for requests
+
+
+Basic usage example:
+
+.. code-block:: python
+
+ import aiohttp_csrf
+ from aiohttp import web
+
+ FORM_FIELD_NAME = '_csrf_token'
+ COOKIE_NAME = 'csrf_token'
+
+
+ def make_app():
+ csrf_policy = aiohttp_csrf.policy.FormPolicy(FORM_FIELD_NAME)
+
+ csrf_storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ app = web.Application()
+
+ aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage)
+
+ app.middlewares.append(aiohttp_csrf.csrf_middleware)
+
+ async def handler_get_form_with_token(request):
+ token = await aiohttp_csrf.generate_token(request)
+
+
+ body = '''
+
+ Form with csrf protection
+
+
+
+
+ ''' # noqa
+
+ body = body.format(field_name=FORM_FIELD_NAME, token=token)
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ async def handler_post_check(request):
+ post = await request.post()
+
+ body = 'Hello, {name}'.format(name=post['name'])
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ app.router.add_route(
+ 'GET',
+ '/',
+ handler_get_form_with_token,
+ )
+
+ app.router.add_route(
+ 'POST',
+ '/',
+ handler_post_check,
+ )
+
+ return app
+
+
+ web.run_app(make_app())
+
+
+Initialize
+~~~~~~~~~~
+
+
+First of all, you need to initialize ``aiohttp_csrf`` in your application:
+
+.. code-block:: python
+
+ app = web.Application()
+
+ csrf_policy = aiohttp_csrf.policy.FormPolicy(FORM_FIELD_NAME)
+
+ csrf_storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage)
+
+
+Middleware and decorators
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+
+After initialize you can use ``@aiohttp_csrf.csrf_protect`` for handlers, that you want to protect.
+Or you can initialize ``aiohttp_csrf.csrf_middleware`` and do not disturb about using decorator (`full middleware example here`_):
+
+.. _full middleware example here: demo/middleware.py
+
+.. code-block:: python
+
+ ...
+ app.middlewares.append(aiohttp_csrf.csrf_middleware)
+ ...
+
+
+In this case all your handlers will be protected.
+
+
+**Note:** we strongly recommend to use ``aiohttp_csrf.csrf_middleware`` and ``@aiohttp_csrf.csrf_exempt`` instead of manually managing with ``@aiohttp_csrf.csrf_protect``.
+But if you prefer to use ``@aiohttp_csrf.csrf_protect``, don't forget to use ``@aiohttp_csrf.csrf_protect`` for both methods: GET and POST
+(`manual protection example`_)
+
+.. _manual protection example: demo/manual_protection.py
+
+
+If you want to use middleware, but need handlers without protection, you can use ``@aiohttp_csrf.csrf_exempt``.
+Mark you handler with this decorator and this handler will not check the token:
+
+.. code-block:: python
+
+ @aiohttp_csrf.csrf_exempt
+ async def handler_post_not_check(request):
+ ...
+
+
+
+Generate token
+~~~~~~~~~~~~~~
+
+For generate token you need to call ``aiohttp_csrf.generate_token`` in your handler:
+
+.. code-block:: python
+
+ @aiohttp_csrf.csrf_protect
+ async def handler_get(request):
+ token = await aiohttp_csrf.generate_token(request)
+ ...
+
+
+Advanced usage
+--------------
+
+
+Policies
+~~~~~~~~
+
+You can use different policies for check tokens. Library provides 3 types of policy:
+
+- **FormPolicy**. This policy will search token in the body of your POST request (Usually use for forms) or as a GET variable of the same name. You need to specify name of field that will be checked.
+- **HeaderPolicy**. This policy will search token in headers of your POST request (Usually use for AJAX requests). You need to specify name of header that will be checked.
+- **FormAndHeaderPolicy**. This policy combines behavior of **FormPolicy** and **HeaderPolicy**.
+
+You can implement your custom policies if needed. But make sure that your custom policy implements ``aiohttp_csrf.policy.AbstractPolicy`` interface.
+
+Storages
+~~~~~~~~
+
+You can use different types of storages for storing token. Library provides 2 types of storage:
+
+- **CookieStorage**. Your token will be stored in cookie variable. You need to specify cookie name.
+- **SessionStorage**. Your token will be stored in session. You need to specify session variable name.
+
+**Important:** If you want to use session storage, you need setup aiohttp_session in your application
+(`session storage example`_)
+
+.. _session storage example: demo/session_storage.py#L22
+
+You can implement your custom storages if needed. But make sure that your custom storage implements ``aiohttp_csrf.storage.AbstractStorage`` interface.
+
+
+Token generators
+~~~~~~~~~~~~~~~~
+
+You can use different token generator in your application.
+By default storages using ``aiohttp_csrf.token_generator.SimpleTokenGenerator``
+
+But if you need more secure token generator - you can use ``aiohttp_csrf.token_generator.HashedTokenGenerator``
+
+And you can implement your custom token generators if needed. But make sure that your custom token generator implements ``aiohttp_csrf.token_generator.AbstractTokenGenerator`` interface.
+
+
+Invalid token behavior
+~~~~~~~~~~~~~~~~~~~~~~
+
+By default, if token is invalid, ``aiohttp_csrf`` will raise ``aiohttp.web.HTTPForbidden`` exception.
+
+You have ability to specify your custom error handler. It can be:
+
+- **callable instance. Input parameter - aiohttp request.**
+
+.. code-block:: python
+
+ def custom_error_handler(request):
+ # do something
+ return aiohttp.web.Response(status=403)
+
+ # or
+
+ async def custom_async_error_handler(request):
+ # await do something
+ return aiohttp.web.Response(status=403)
+
+It will be called instead of protected handler.
+
+- **sub class of Exception**. In this case this Exception will be raised.
+
+.. code-block:: python
+
+ class CustomException(Exception):
+ pass
+
+
+You can specify custom error handler globally, when initialize ``aiohttp_csrf`` in your application:
+
+.. code-block:: python
+
+ ...
+ class CustomException(Exception):
+ pass
+
+ ...
+ aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage, error_renderer=CustomException)
+ ...
+
+In this case custom error handler will be applied to all protected handlers.
+
+Or you can specify custom error handler locally, for specific handler:
+
+.. code-block:: python
+
+ ...
+ class CustomException(Exception):
+ pass
+
+ ...
+ @aiohttp_csrf.csrf_protect(error_renderer=CustomException)
+ def handler_with_custom_csrf_error(request):
+ ...
+
+
+In this case custom error handler will be applied to this handler only.
+For all other handlers will be applied global error handler.
diff --git a/aiohttp_csrf/__init__.py b/aiohttp_csrf/__init__.py
new file mode 100644
index 0000000..38d10ab
--- /dev/null
+++ b/aiohttp_csrf/__init__.py
@@ -0,0 +1,175 @@
+import asyncio
+import inspect
+
+from functools import wraps
+
+from aiohttp import web
+
+from .policy import AbstractPolicy
+from .storage import AbstractStorage
+
+
+__version__ = '0.1.1'
+
+APP_POLICY_KEY = 'aiohttp_csrf_policy'
+APP_STORAGE_KEY = 'aiohttp_csrf_storage'
+APP_ERROR_RENDERER_KEY = 'aiohttp_csrf_error_renderer'
+
+MIDDLEWARE_SKIP_PROPERTY = 'csrf_middleware_skip'
+
+UNPROTECTED_HTTP_METHODS = ('GET', 'HEAD', 'OPTIONS', 'TRACE')
+
+
+def setup(app, *, policy, storage, error_renderer=web.HTTPForbidden):
+ if not isinstance(policy, AbstractPolicy):
+ raise TypeError('Policy must be instance of AbstractPolicy')
+
+ if not isinstance(storage, AbstractStorage):
+ raise TypeError('Storage must be instance of AbstractStorage')
+
+ if not isinstance(error_renderer, Exception) and not callable(error_renderer): # noqa
+ raise TypeError(
+ 'Default error renderer must be instance of Exception or callable.'
+ )
+
+ app[APP_POLICY_KEY] = policy
+ app[APP_STORAGE_KEY] = storage
+ app[APP_ERROR_RENDERER_KEY] = error_renderer
+
+
+def _get_policy(request):
+ try:
+ return request.app[APP_POLICY_KEY]
+ except KeyError:
+ raise RuntimeError(
+ 'Policy not found. Install aiohttp_csrf in your '
+ 'aiohttp.web.Application using aiohttp_csrf.setup()'
+ )
+
+
+def _get_storage(request):
+ try:
+ return request.app[APP_STORAGE_KEY]
+ except KeyError:
+ raise RuntimeError(
+ 'Storage not found. Install aiohttp_csrf in your '
+ 'aiohttp.web.Application using aiohttp_csrf.setup()'
+ )
+
+
+async def _render_error(request, error_renderer=None):
+ if error_renderer is None:
+ try:
+ error_renderer = request.app[APP_ERROR_RENDERER_KEY]
+ except KeyError:
+ raise RuntimeError(
+ 'Default error renderer not found. Install aiohttp_csrf in '
+ 'your aiohttp.web.Application using aiohttp_csrf.setup()'
+ )
+
+ if inspect.isclass(error_renderer) and issubclass(error_renderer, Exception): # noqa
+ raise error_renderer
+ elif callable(error_renderer):
+ if asyncio.iscoroutinefunction(error_renderer):
+ return await error_renderer(request)
+ else:
+ return error_renderer(request)
+ else:
+ raise NotImplementedError
+
+
+async def get_token(request):
+ storage = _get_storage(request)
+
+ return await storage.get(request)
+
+
+async def generate_token(request):
+ storage = _get_storage(request)
+
+ return await storage.generate_new_token(request)
+
+
+async def save_token(request, response):
+ storage = _get_storage(request)
+
+ await storage.save_token(request, response)
+
+
+def csrf_exempt(handler):
+ @wraps(handler)
+ async def wrapped_handler(*args, **kwargs):
+ return await handler(*args, **kwargs)
+
+ setattr(wrapped_handler, MIDDLEWARE_SKIP_PROPERTY, True)
+
+ return wrapped_handler
+
+
+async def _check(request):
+ if not isinstance(request, web.Request):
+ raise RuntimeError('Can\'t get request from handler params')
+
+ original_token = await get_token(request)
+
+ policy = _get_policy(request)
+
+ return await policy.check(request, original_token)
+
+
+def csrf_protect(handler=None, error_renderer=None):
+ if (
+ error_renderer is not None
+ and not isinstance(error_renderer, Exception)
+ and not callable(error_renderer)
+ ):
+ raise TypeError(
+ 'Renderer must be instance of Exception or callable.'
+ )
+
+ def wrapper(handler):
+ @wraps(handler)
+ async def wrapped(*args, **kwargs):
+ request = args[-1]
+
+ if isinstance(request, web.View):
+ request = request.request
+
+ if (
+ request.method not in UNPROTECTED_HTTP_METHODS
+ and not await _check(request)
+ ):
+ return await _render_error(request, error_renderer)
+
+ raise_response = False
+
+ try:
+ response = await handler(*args, **kwargs)
+ except web.HTTPException as exc:
+ response = exc
+ raise_response = True
+
+ if isinstance(response, web.Response):
+ await save_token(request, response)
+
+ if raise_response:
+ raise response
+
+ return response
+
+ setattr(wrapped, MIDDLEWARE_SKIP_PROPERTY, True)
+
+ return wrapped
+
+ if handler is None:
+ return wrapper
+
+ return wrapper(handler)
+
+
+@web.middleware
+async def csrf_middleware(request, handler):
+ if not getattr(handler, MIDDLEWARE_SKIP_PROPERTY, False):
+ handler = csrf_protect(handler=handler)
+
+ return await handler(request)
diff --git a/aiohttp_csrf/policy.py b/aiohttp_csrf/policy.py
new file mode 100644
index 0000000..c84083a
--- /dev/null
+++ b/aiohttp_csrf/policy.py
@@ -0,0 +1,58 @@
+import abc
+from secrets import compare_digest
+
+
+class AbstractPolicy(metaclass=abc.ABCMeta):
+ @abc.abstractmethod
+ async def check(self, request, original_value):
+ pass # pragma: no cover
+
+
+class FormPolicy(AbstractPolicy):
+
+ def __init__(self, field_name):
+ self.field_name = field_name
+
+ async def check(self, request, original_value):
+ get = request.match_info.get(self.field_name, None)
+ post_req = await request.post() if get is None else None
+ post = post_req.get(self.field_name) if post_req is not None else None
+ post = post if post is not None else ''
+ token = get if get is not None else post
+
+ return compare_digest(token, original_value)
+
+
+class HeaderPolicy(AbstractPolicy):
+
+ def __init__(self, header_name):
+ self.header_name = header_name
+
+ async def check(self, request, original_value):
+ token = request.headers.get(self.header_name)
+
+ return compare_digest(token, original_value)
+
+
+class FormAndHeaderPolicy(HeaderPolicy, FormPolicy):
+
+ def __init__(self, header_name, field_name):
+ self.header_name = header_name
+ self.field_name = field_name
+
+ async def check(self, request, original_value):
+ header_check = await HeaderPolicy.check(
+ self,
+ request,
+ original_value,
+ )
+
+ if header_check:
+ return True
+
+ form_check = await FormPolicy.check(self, request, original_value)
+
+ if form_check:
+ return True
+
+ return False
diff --git a/aiohttp_csrf/storage.py b/aiohttp_csrf/storage.py
new file mode 100644
index 0000000..89a332b
--- /dev/null
+++ b/aiohttp_csrf/storage.py
@@ -0,0 +1,118 @@
+import abc
+
+from .token_generator import AbstractTokenGenerator, HashedTokenGenerator
+
+try:
+ from aiohttp_session import get_session
+except ImportError: # pragma: no cover
+ pass
+
+
+REQUEST_NEW_TOKEN_KEY = 'aiohttp_csrf_new_token'
+
+
+class AbstractStorage(metaclass=abc.ABCMeta):
+
+ @abc.abstractmethod
+ async def generate_new_token(self, request):
+ pass # pragma: no cover
+
+ @abc.abstractmethod
+ async def get(self, request):
+ pass # pragma: no cover
+
+ @abc.abstractmethod
+ async def save_token(self, request, response):
+ pass # pragma: no cover
+
+
+class BaseStorage(AbstractStorage, metaclass=abc.ABCMeta):
+
+ def __init__(self, token_generator=None, secret_phrase=None):
+ if token_generator is None:
+ if secret_phrase is None:
+ raise TypeError('secret_phrase is required for default token type (Hash)')
+ token_generator = HashedTokenGenerator(secret_phrase)
+ elif not isinstance(token_generator, AbstractTokenGenerator):
+ raise TypeError(
+ 'Token generator must be instance of AbstractTokenGenerator',
+ )
+
+ self.token_generator = token_generator
+
+ def _generate_token(self):
+ return self.token_generator.generate()
+
+ async def generate_new_token(self, request):
+ if REQUEST_NEW_TOKEN_KEY in request:
+ return request[REQUEST_NEW_TOKEN_KEY]
+
+ token = self._generate_token()
+
+ request[REQUEST_NEW_TOKEN_KEY] = token
+
+ return token
+
+ @abc.abstractmethod
+ async def _get(self, request):
+ pass # pragma: no cover
+
+ async def get(self, request):
+ token = await self._get(request)
+
+ await self.generate_new_token(request)
+
+ return token
+
+ @abc.abstractmethod
+ async def _save_token(self, request, response, token):
+ pass # pragma: no cover
+
+ async def save_token(self, request, response):
+ old_token = await self._get(request)
+
+ if REQUEST_NEW_TOKEN_KEY in request:
+ token = request[REQUEST_NEW_TOKEN_KEY]
+ elif old_token is None:
+ token = await self.generate_new_token(request)
+ else:
+ token = None
+
+ if token is not None:
+ await self._save_token(request, response, token)
+
+
+class CookieStorage(BaseStorage):
+
+ def __init__(self, cookie_name, cookie_kwargs=None, *args, **kwargs):
+ self.cookie_name = cookie_name
+ self.cookie_kwargs = cookie_kwargs or {}
+
+ super().__init__(*args, **kwargs)
+
+ async def _get(self, request):
+ return request.cookies.get(self.cookie_name, None)
+
+ async def _save_token(self, request, response, token):
+ response.set_cookie(
+ self.cookie_name,
+ token,
+ **self.cookie_kwargs,
+ )
+
+
+class SessionStorage(BaseStorage):
+ def __init__(self, session_name, *args, **kwargs):
+ self.session_name = session_name
+
+ super().__init__(*args, **kwargs)
+
+ async def _get(self, request):
+ session = await get_session(request)
+
+ return session.get(self.session_name, None)
+
+ async def _save_token(self, request, response, token):
+ session = await get_session(request)
+
+ session[self.session_name] = token
diff --git a/aiohttp_csrf/token_generator.py b/aiohttp_csrf/token_generator.py
new file mode 100644
index 0000000..364f66c
--- /dev/null
+++ b/aiohttp_csrf/token_generator.py
@@ -0,0 +1,30 @@
+import abc
+from blake3 import blake3
+import uuid
+
+
+class AbstractTokenGenerator(metaclass=abc.ABCMeta):
+ @abc.abstractmethod
+ def generate(self):
+ pass # pragma: no cover
+
+
+class SimpleTokenGenerator(AbstractTokenGenerator):
+ def generate(self):
+ return uuid.uuid4().hex
+
+
+class HashedTokenGenerator(AbstractTokenGenerator):
+ encoding = 'utf-8'
+
+ def __init__(self, secret_phrase):
+ self.secret_phrase = secret_phrase
+
+ def generate(self):
+ token = uuid.uuid4().hex
+
+ token += self.secret_phrase
+
+ hasher = blake3(token.encode(self.encoding))
+
+ return hasher.hexdigest()
diff --git a/demo/manual_protection.py b/demo/manual_protection.py
new file mode 100644
index 0000000..07caaa1
--- /dev/null
+++ b/demo/manual_protection.py
@@ -0,0 +1,68 @@
+import aiohttp_csrf
+from aiohttp import web
+
+FORM_FIELD_NAME = '_csrf_token'
+COOKIE_NAME = 'csrf_token'
+
+
+def make_app():
+ csrf_policy = aiohttp_csrf.policy.FormPolicy(FORM_FIELD_NAME)
+
+ csrf_storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ app = web.Application()
+
+ aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage)
+
+ # IMPORTANT! You need use @csrf_protect for both methods: GET and POST
+ @aiohttp_csrf.csrf_protect
+ async def handler_get(request):
+ token = await aiohttp_csrf.generate_token(request)
+
+ body = '''
+
+ Form with csrf protection
+
+
+
+
+ ''' # noqa
+
+ body = body.format(field_name=FORM_FIELD_NAME, token=token)
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ @aiohttp_csrf.csrf_protect
+ async def handler_post(request):
+ post = await request.post()
+
+ body = 'Hello, {name}'.format(name=post['name'])
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ app.router.add_route(
+ 'GET',
+ '/',
+ handler_get,
+ )
+
+ app.router.add_route(
+ 'POST',
+ '/',
+ handler_post,
+ )
+
+ return app
+
+
+web.run_app(make_app())
diff --git a/demo/middleware.py b/demo/middleware.py
new file mode 100644
index 0000000..b554138
--- /dev/null
+++ b/demo/middleware.py
@@ -0,0 +1,132 @@
+import aiohttp_csrf
+from aiohttp import web
+
+FORM_FIELD_NAME = '_csrf_token'
+COOKIE_NAME = 'csrf_token'
+
+
+def make_app():
+ csrf_policy = aiohttp_csrf.policy.FormPolicy(FORM_FIELD_NAME)
+
+ csrf_storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ app = web.Application()
+
+ aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage)
+
+ app.middlewares.append(aiohttp_csrf.csrf_middleware)
+
+ async def handler_get_form_with_post_token(request):
+ token = await aiohttp_csrf.generate_token(request)
+
+ body = '''
+
+ Form with csrf protection
+
+
+
+
+ ''' # noqa
+
+ body = body.format(field_name=FORM_FIELD_NAME, token=token)
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ async def handler_get_form_with_get_token(request):
+ token = await aiohttp_csrf.generate_token(request)
+
+ body = '''
+
+ Form with csrf protection
+
+
+
+
+ ''' # noqa
+
+ body = body.format(field_name=FORM_FIELD_NAME, token=token)
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ async def handler_post_check(request):
+ post = await request.post()
+
+ body = 'Hello, {name}'.format(name=post['name'])
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ async def handler_get_form_without_token(request):
+ body = '''
+
+ Form without csrf protection
+
+
+
+
+ '''
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ @aiohttp_csrf.csrf_exempt
+ async def handler_post_not_check(request):
+ post = await request.post()
+
+ body = 'Hello, {name}'.format(name=post['name'])
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ app.router.add_route(
+ 'GET',
+ '/form_with_post_check',
+ handler_get_form_with_post_token,
+ )
+ app.router.add_route(
+ 'GET',
+ '/form_with_get_check',
+ handler_get_form_with_get_token,
+ )
+ app.router.add_route(
+ 'POST',
+ '/post_with_check/{'+FORM_FIELD_NAME+'}',
+ handler_post_check,
+ )
+ app.router.add_route(
+ 'GET',
+ '/form_without_check',
+ handler_get_form_without_token,
+ )
+ app.router.add_route(
+ 'POST',
+ '/post_without_check',
+ handler_post_not_check,
+ )
+
+ return app
+
+
+web.run_app(make_app())
diff --git a/demo/session_storage.py b/demo/session_storage.py
new file mode 100644
index 0000000..e27a489
--- /dev/null
+++ b/demo/session_storage.py
@@ -0,0 +1,113 @@
+import aiohttp_csrf
+from aiohttp import web
+from aiohttp_session import setup as setup_session
+from aiohttp_session import SimpleCookieStorage
+
+FORM_FIELD_NAME = '_csrf_token'
+SESSION_NAME = 'csrf_token'
+
+
+def make_app():
+ csrf_policy = aiohttp_csrf.policy.FormPolicy(FORM_FIELD_NAME)
+
+ csrf_storage = aiohttp_csrf.storage.SessionStorage(SESSION_NAME)
+
+ app = web.Application()
+
+ aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage)
+
+ session_storage = SimpleCookieStorage()
+
+ # Important!!!
+ setup_session(app, session_storage)
+
+ app.middlewares.append(aiohttp_csrf.csrf_middleware)
+
+ async def handler_get_form_with_token(request):
+ token = await aiohttp_csrf.generate_token(request)
+
+ body = '''
+
+ Form with csrf protection
+
+
+
+
+ ''' # noqa
+
+ body = body.format(field_name=FORM_FIELD_NAME, token=token)
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ async def handler_post_check(request):
+ post = await request.post()
+
+ body = 'Hello, {name}'.format(name=post['name'])
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ async def handler_get_form_without_token(request):
+ body = '''
+
+ Form without csrf protection
+
+
+
+
+ '''
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ @aiohttp_csrf.csrf_exempt
+ async def handler_post_not_check(request):
+ post = await request.post()
+
+ body = 'Hello, {name}'.format(name=post['name'])
+
+ return web.Response(
+ body=body.encode('utf-8'),
+ content_type='text/html',
+ )
+
+ app.router.add_route(
+ 'GET',
+ '/form_with_check',
+ handler_get_form_with_token,
+ )
+ app.router.add_route(
+ 'POST',
+ '/post_with_check',
+ handler_post_check,
+ )
+
+ app.router.add_route(
+ 'GET',
+ '/form_without_check',
+ handler_get_form_without_token,
+ )
+ app.router.add_route(
+ 'POST',
+ '/post_without_check',
+ handler_post_not_check,
+ )
+
+ return app
+
+
+web.run_app(make_app())
diff --git a/gensetup.sh b/gensetup.sh
new file mode 100644
index 0000000..0db24d0
--- /dev/null
+++ b/gensetup.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+tar -xvf $1 --wildcards --no-anchored '*/setup.py' --strip=1
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..d3c9e72
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,920 @@
+[[package]]
+name = "aiohttp"
+version = "3.7.3"
+description = "Async http client/server framework (asyncio)"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+speedups = ["aiodns", "brotlipy", "cchardet"]
+
+[package.dependencies]
+async-timeout = ">=3.0,<4.0"
+attrs = ">=17.3.0"
+chardet = ">=2.0,<4.0"
+multidict = ">=4.5,<7.0"
+typing-extensions = ">=3.6.5"
+yarl = ">=1.0,<2.0"
+
+[[package]]
+name = "aiohttp-session"
+version = "2.9.0"
+description = "sessions for aiohttp.web"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+aiomcache = ["aiomcache (>=0.5.2)"]
+aioredis = ["aioredis (>=1.0.0)"]
+pycrypto = ["cryptography"]
+pynacl = ["pynacl"]
+secure = ["cryptography"]
+
+[package.dependencies]
+aiohttp = ">=3.0.1"
+
+[[package]]
+name = "appdirs"
+version = "1.4.4"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "appnope"
+version = "0.1.2"
+description = "Disable App Nap on macOS >= 10.9"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\" and sys_platform == \"darwin\""
+
+[[package]]
+name = "async-timeout"
+version = "3.0.1"
+description = "Timeout context manager for asyncio programs"
+category = "main"
+optional = false
+python-versions = ">=3.5.3"
+
+[[package]]
+name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+marker = "sys_platform == \"win32\""
+
+[[package]]
+name = "attrs"
+version = "20.3.0"
+description = "Classes Without Boilerplate"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"]
+docs = ["furo", "sphinx", "zope.interface"]
+tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
+tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
+
+[[package]]
+name = "backcall"
+version = "0.2.0"
+description = "Specifications for callback functions passed in to an API"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\""
+
+[[package]]
+name = "blake3"
+version = "0.1.8"
+description = "Python bindings for the Rust blake3 crate"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "chardet"
+version = "3.0.4"
+description = "Universal encoding detector for Python 2 and 3"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "colorama"
+version = "0.4.4"
+description = "Cross-platform colored terminal text."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+marker = "python_version >= \"3.4\" and sys_platform == \"win32\" or sys_platform == \"win32\" or platform_system == \"Windows\""
+
+[[package]]
+name = "coverage"
+version = "5.3.1"
+description = "Code coverage measurement for Python"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
+
+[package.extras]
+toml = ["toml"]
+
+[[package]]
+name = "decorator"
+version = "4.4.2"
+description = "Decorators for Humans"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*"
+marker = "python_version >= \"3.4\""
+
+[[package]]
+name = "distlib"
+version = "0.3.1"
+description = "Distribution utilities"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "filelock"
+version = "3.0.12"
+description = "A platform independent file lock."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "flake8"
+version = "3.8.4"
+description = "the modular source code checker: pep8 pyflakes and co"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
+
+[package.dependencies]
+mccabe = ">=0.6.0,<0.7.0"
+pycodestyle = ">=2.6.0a1,<2.7.0"
+pyflakes = ">=2.2.0,<2.3.0"
+
+[[package]]
+name = "idna"
+version = "2.10"
+description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "ipdb"
+version = "0.13.4"
+description = "IPython-enabled pdb"
+category = "dev"
+optional = false
+python-versions = ">=2.7"
+
+[package.dependencies]
+setuptools = "*"
+
+[package.dependencies.ipython]
+version = ">=5.1.0"
+python = ">=3.4"
+
+[[package]]
+name = "ipython"
+version = "7.19.0"
+description = "IPython: Productive Interactive Computing"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+marker = "python_version >= \"3.4\""
+
+[package.extras]
+all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"]
+doc = ["Sphinx (>=1.3)"]
+kernel = ["ipykernel"]
+nbconvert = ["nbconvert"]
+nbformat = ["nbformat"]
+notebook = ["notebook", "ipywidgets"]
+parallel = ["ipyparallel"]
+qtconsole = ["qtconsole"]
+test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"]
+
+[package.dependencies]
+appnope = "*"
+backcall = "*"
+colorama = "*"
+decorator = "*"
+jedi = ">=0.10"
+pexpect = ">4.3"
+pickleshare = "*"
+prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0"
+pygments = "*"
+setuptools = ">=18.5"
+traitlets = ">=4.2"
+
+[[package]]
+name = "ipython-genutils"
+version = "0.2.0"
+description = "Vestigial utilities from IPython"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\""
+
+[[package]]
+name = "isort"
+version = "4.3.21"
+description = "A Python utility / library to sort Python imports."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+pipfile = ["pipreqs", "requirementslib"]
+pyproject = ["toml"]
+requirements = ["pipreqs", "pip-api"]
+xdg_home = ["appdirs (>=1.4.0)"]
+
+[[package]]
+name = "jedi"
+version = "0.18.0"
+description = "An autocompletion tool for Python that can be used for text editors."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+marker = "python_version >= \"3.4\""
+
+[package.extras]
+qa = ["flake8 (3.8.3)", "mypy (0.782)"]
+testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<6.0.0)"]
+
+[package.dependencies]
+parso = ">=0.8.0,<0.9.0"
+
+[[package]]
+name = "mccabe"
+version = "0.6.1"
+description = "McCabe checker, plugin for flake8"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "multidict"
+version = "5.1.0"
+description = "multidict implementation"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "packaging"
+version = "20.8"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+pyparsing = ">=2.0.2"
+
+[[package]]
+name = "parso"
+version = "0.8.1"
+description = "A Python Parser"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+marker = "python_version >= \"3.4\""
+
+[package.extras]
+qa = ["flake8 (3.8.3)", "mypy (0.782)"]
+testing = ["docopt", "pytest (<6.0.0)"]
+
+[[package]]
+name = "pexpect"
+version = "4.8.0"
+description = "Pexpect allows easy control of interactive console applications."
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\" and sys_platform != \"win32\""
+
+[package.dependencies]
+ptyprocess = ">=0.5"
+
+[[package]]
+name = "pickleshare"
+version = "0.7.5"
+description = "Tiny 'shelve'-like database with concurrency support"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\""
+
+[[package]]
+name = "pluggy"
+version = "0.13.1"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.8"
+description = "Library for building powerful interactive command lines in Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6.1"
+marker = "python_version >= \"3.4\""
+
+[package.dependencies]
+wcwidth = "*"
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+description = "Run a subprocess in a pseudo terminal"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\" and sys_platform != \"win32\""
+
+[[package]]
+name = "py"
+version = "1.10.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pycodestyle"
+version = "2.6.0"
+description = "Python style guide checker"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pyflakes"
+version = "2.2.0"
+description = "passive checker of Python programs"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pygments"
+version = "2.7.3"
+description = "Pygments is a syntax highlighting package written in Python."
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+marker = "python_version >= \"3.4\""
+
+[[package]]
+name = "pyparsing"
+version = "2.4.7"
+description = "Python parsing module"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "pytest"
+version = "6.2.1"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+
+[package.dependencies]
+atomicwrites = ">=1.0"
+attrs = ">=19.2.0"
+colorama = "*"
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<1.0.0a1"
+py = ">=1.8.2"
+toml = "*"
+
+[[package]]
+name = "pytest-aiohttp"
+version = "0.3.0"
+description = "pytest plugin for aiohttp support"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+aiohttp = ">=2.3.5"
+pytest = "*"
+
+[[package]]
+name = "pytest-cov"
+version = "2.10.1"
+description = "Pytest plugin for measuring coverage."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"]
+
+[package.dependencies]
+coverage = ">=4.4"
+pytest = ">=4.6"
+
+[[package]]
+name = "six"
+version = "1.15.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "tox"
+version = "3.20.1"
+description = "tox is a generic virtualenv management and test command line tool"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[package.extras]
+docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"]
+testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)"]
+
+[package.dependencies]
+colorama = ">=0.4.1"
+filelock = ">=3.0.0"
+packaging = ">=14"
+pluggy = ">=0.12.0"
+py = ">=1.4.17"
+six = ">=1.14.0"
+toml = ">=0.9.4"
+virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7"
+
+[[package]]
+name = "traitlets"
+version = "5.0.5"
+description = "Traitlets Python configuration system"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+marker = "python_version >= \"3.4\""
+
+[package.extras]
+test = ["pytest"]
+
+[package.dependencies]
+ipython-genutils = "*"
+
+[[package]]
+name = "typing-extensions"
+version = "3.7.4.3"
+description = "Backported and Experimental Type Hints for Python 3.5+"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "virtualenv"
+version = "20.2.2"
+description = "Virtual Python Environment builder"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
+
+[package.extras]
+docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
+testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
+
+[package.dependencies]
+appdirs = ">=1.4.3,<2"
+distlib = ">=0.3.1,<1"
+filelock = ">=3.0.0,<4"
+six = ">=1.9.0,<2"
+
+[[package]]
+name = "wcwidth"
+version = "0.2.5"
+description = "Measures the displayed width of unicode strings in a terminal"
+category = "dev"
+optional = false
+python-versions = "*"
+marker = "python_version >= \"3.4\""
+
+[[package]]
+name = "yarl"
+version = "1.6.3"
+description = "Yet another URL library"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+idna = ">=2.0"
+multidict = ">=4.0"
+
+[metadata]
+python-versions = ">=3.8.3, <4"
+content-hash = "8fab42c7fc25f0f2db49dfcf2ec0da6e7ee9fc3c84db3e7649d8251c7190c5cc"
+
+[metadata.files]
+aiohttp = [
+ {file = "aiohttp-3.7.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:328b552513d4f95b0a2eea4c8573e112866107227661834652a8984766aa7656"},
+ {file = "aiohttp-3.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c733ef3bdcfe52a1a75564389bad4064352274036e7e234730526d155f04d914"},
+ {file = "aiohttp-3.7.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:2858b2504c8697beb9357be01dc47ef86438cc1cb36ecb6991796d19475faa3e"},
+ {file = "aiohttp-3.7.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:d2cfac21e31e841d60dc28c0ec7d4ec47a35c608cb8906435d47ef83ffb22150"},
+ {file = "aiohttp-3.7.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:3228b7a51e3ed533f5472f54f70fd0b0a64c48dc1649a0f0e809bec312934d7a"},
+ {file = "aiohttp-3.7.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:dcc119db14757b0c7bce64042158307b9b1c76471e655751a61b57f5a0e4d78e"},
+ {file = "aiohttp-3.7.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:7d9b42127a6c0bdcc25c3dcf252bb3ddc70454fac593b1b6933ae091396deb13"},
+ {file = "aiohttp-3.7.3-cp36-cp36m-win32.whl", hash = "sha256:df48a623c58180874d7407b4d9ec06a19b84ed47f60a3884345b1a5099c1818b"},
+ {file = "aiohttp-3.7.3-cp36-cp36m-win_amd64.whl", hash = "sha256:0b795072bb1bf87b8620120a6373a3c61bfcb8da7e5c2377f4bb23ff4f0b62c9"},
+ {file = "aiohttp-3.7.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:0d438c8ca703b1b714e82ed5b7a4412c82577040dadff479c08405e2a715564f"},
+ {file = "aiohttp-3.7.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8389d6044ee4e2037dca83e3f6994738550f6ee8cfb746762283fad9b932868f"},
+ {file = "aiohttp-3.7.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3ea8c252d8df5e9166bcf3d9edced2af132f4ead8ac422eac723c5781063709a"},
+ {file = "aiohttp-3.7.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:78e2f18a82b88cbc37d22365cf8d2b879a492faedb3f2975adb4ed8dfe994d3a"},
+ {file = "aiohttp-3.7.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:df3a7b258cc230a65245167a202dd07320a5af05f3d41da1488ba0fa05bc9347"},
+ {file = "aiohttp-3.7.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:f326b3c1bbfda5b9308252ee0dcb30b612ee92b0e105d4abec70335fab5b1245"},
+ {file = "aiohttp-3.7.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5e479df4b2d0f8f02133b7e4430098699450e1b2a826438af6bec9a400530957"},
+ {file = "aiohttp-3.7.3-cp37-cp37m-win32.whl", hash = "sha256:6d42debaf55450643146fabe4b6817bb2a55b23698b0434107e892a43117285e"},
+ {file = "aiohttp-3.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c9c58b0b84055d8bc27b7df5a9d141df4ee6ff59821f922dd73155861282f6a3"},
+ {file = "aiohttp-3.7.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:f411cb22115cb15452d099fec0ee636b06cf81bfb40ed9c02d30c8dc2bc2e3d1"},
+ {file = "aiohttp-3.7.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c1e0920909d916d3375c7a1fdb0b1c78e46170e8bb42792312b6eb6676b2f87f"},
+ {file = "aiohttp-3.7.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:59d11674964b74a81b149d4ceaff2b674b3b0e4d0f10f0be1533e49c4a28408b"},
+ {file = "aiohttp-3.7.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:41608c0acbe0899c852281978492f9ce2c6fbfaf60aff0cefc54a7c4516b822c"},
+ {file = "aiohttp-3.7.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:16a3cb5df5c56f696234ea9e65e227d1ebe9c18aa774d36ff42f532139066a5f"},
+ {file = "aiohttp-3.7.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:6ccc43d68b81c424e46192a778f97da94ee0630337c9bbe5b2ecc9b0c1c59001"},
+ {file = "aiohttp-3.7.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:d03abec50df423b026a5aa09656bd9d37f1e6a49271f123f31f9b8aed5dc3ea3"},
+ {file = "aiohttp-3.7.3-cp38-cp38-win32.whl", hash = "sha256:39f4b0a6ae22a1c567cb0630c30dd082481f95c13ca528dc501a7766b9c718c0"},
+ {file = "aiohttp-3.7.3-cp38-cp38-win_amd64.whl", hash = "sha256:c68fdf21c6f3573ae19c7ee65f9ff185649a060c9a06535e9c3a0ee0bbac9235"},
+ {file = "aiohttp-3.7.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:710376bf67d8ff4500a31d0c207b8941ff4fba5de6890a701d71680474fe2a60"},
+ {file = "aiohttp-3.7.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2406dc1dda01c7f6060ab586e4601f18affb7a6b965c50a8c90ff07569cf782a"},
+ {file = "aiohttp-3.7.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:2a7b7640167ab536c3cb90cfc3977c7094f1c5890d7eeede8b273c175c3910fd"},
+ {file = "aiohttp-3.7.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:684850fb1e3e55c9220aad007f8386d8e3e477c4ec9211ae54d968ecdca8c6f9"},
+ {file = "aiohttp-3.7.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:1edfd82a98c5161497bbb111b2b70c0813102ad7e0aa81cbeb34e64c93863005"},
+ {file = "aiohttp-3.7.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:77149002d9386fae303a4a162e6bce75cc2161347ad2ba06c2f0182561875d45"},
+ {file = "aiohttp-3.7.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:756ae7efddd68d4ea7d89c636b703e14a0c686688d42f588b90778a3c2fc0564"},
+ {file = "aiohttp-3.7.3-cp39-cp39-win32.whl", hash = "sha256:3b0036c978cbcc4a4512278e98e3e6d9e6b834dc973206162eddf98b586ef1c6"},
+ {file = "aiohttp-3.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:e1b95972a0ae3f248a899cdbac92ba2e01d731225f566569311043ce2226f5e7"},
+ {file = "aiohttp-3.7.3.tar.gz", hash = "sha256:9c1a81af067e72261c9cbe33ea792893e83bc6aa987bfbd6fdc1e5e7b22777c4"},
+]
+aiohttp-session = [
+ {file = "aiohttp-session-2.9.0.tar.gz", hash = "sha256:959413468b84e30e7ca09719617cfb0000066a2e0f6c20062d043433e82aeb74"},
+ {file = "aiohttp_session-2.9.0-py3-none-any.whl", hash = "sha256:74853d1177541cccfefb436409f9ea5d67a62f84e13946a3e115a765d9a0349c"},
+]
+appdirs = [
+ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
+ {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
+]
+appnope = [
+ {file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"},
+ {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"},
+]
+async-timeout = [
+ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
+ {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"},
+]
+atomicwrites = [
+ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
+attrs = [
+ {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"},
+ {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"},
+]
+backcall = [
+ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
+ {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
+]
+blake3 = [
+ {file = "blake3-0.1.8-cp35-cp35m-macosx_10_7_x86_64.whl", hash = "sha256:71f1a49ca7b8b5cbefcac64cfb23d432493e4ae9e4ed421b1834484815ccba2e"},
+ {file = "blake3-0.1.8-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:62198369bd794087882216db94fb1deb2ad8144d3ea5ac5c8c200a5b7c2180bf"},
+ {file = "blake3-0.1.8-cp35-none-win32.whl", hash = "sha256:5b3f48ae9adc3d6bfc97f3a3aebd8f27a579505e5453e05a2f8ee63fb81eb975"},
+ {file = "blake3-0.1.8-cp35-none-win_amd64.whl", hash = "sha256:8b6f925b454d58a194deed54f15d24131da45dfdd21714f103a33b0ffbe3e318"},
+ {file = "blake3-0.1.8-cp36-cp36m-macosx_10_7_x86_64.whl", hash = "sha256:d1af4a89e3755e78d95e54c05c1c967d2bc0da1939b5bc034766fa5131f35fc0"},
+ {file = "blake3-0.1.8-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:28d02decd14bbbc65e0f04bf8c9b389f31c53e4cc3685cfbb5f7ba3e123e7670"},
+ {file = "blake3-0.1.8-cp36-none-win32.whl", hash = "sha256:f6d34840dc0c8b2a9c920a91db1e9c4917c4ff156af42f247f87fa85f19850f1"},
+ {file = "blake3-0.1.8-cp36-none-win_amd64.whl", hash = "sha256:1b58114cd1cc849c0af6e63e8b543c89f5c5804a34ec61b82d8baaffa4e11d94"},
+ {file = "blake3-0.1.8-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:8c174da153b739e2aa46d362a11a5d224420e50e29f32d1ba0b3220babbbc22e"},
+ {file = "blake3-0.1.8-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:897717f157b6d9f7fd1670cd0b07bb58e761f3147b1a0e5e542412210f581f02"},
+ {file = "blake3-0.1.8-cp37-none-win32.whl", hash = "sha256:3c94995ea9477200e438451d42ddfedc210c596f166415068ed87e6db2abfa03"},
+ {file = "blake3-0.1.8-cp37-none-win_amd64.whl", hash = "sha256:891fa7fd3062cc0c59b0458e0ef971f6c65ab5a54b8b4efd99901b47c05a7de4"},
+ {file = "blake3-0.1.8-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:5f0f2c9ec12175c54f593a55b49e467a1fa8839db9087b11a6297b2afe6c8c25"},
+ {file = "blake3-0.1.8-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:a2cbeeda01fee7d71e1198eb2b9a7dbca53b1bf4ecdf69bf8f65b4ef7aeb3642"},
+ {file = "blake3-0.1.8-cp38-none-win32.whl", hash = "sha256:494cbc6d3ec0da44e196cbe1dbfd7dd01cd1ba32420c19d53e47bbc909921654"},
+ {file = "blake3-0.1.8-cp38-none-win_amd64.whl", hash = "sha256:5422f98d49afb3a89f0c2e56045275148b63370ff8aa25357ee4739b34f5c8a9"},
+ {file = "blake3-0.1.8-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:13f460849ed4f399d53129353723524c0ac5b67e3bed7c50152e57e20deb54ff"},
+ {file = "blake3-0.1.8-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:9a97aba70bcc131d9b4f059a7a295717ec434a3a82b84290e86b95cfa61c9272"},
+ {file = "blake3-0.1.8-cp39-none-win32.whl", hash = "sha256:b70c0d157fe12ca3e43c630da86afd2122be206b5ad6cd29bdf8660be7e03656"},
+ {file = "blake3-0.1.8-cp39-none-win_amd64.whl", hash = "sha256:c5d1cd1218089e105f75b5472878bd7cabfaad13f83c5511dab326858fff9890"},
+ {file = "blake3-0.1.8.tar.gz", hash = "sha256:b131129196ac4242bc9127a425daad46a8e7a451daef21a9337fcec17db445a8"},
+]
+chardet = [
+ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
+ {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
+]
+colorama = [
+ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
+]
+coverage = [
+ {file = "coverage-5.3.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d"},
+ {file = "coverage-5.3.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7"},
+ {file = "coverage-5.3.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528"},
+ {file = "coverage-5.3.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044"},
+ {file = "coverage-5.3.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b"},
+ {file = "coverage-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297"},
+ {file = "coverage-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb"},
+ {file = "coverage-5.3.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899"},
+ {file = "coverage-5.3.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36"},
+ {file = "coverage-5.3.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500"},
+ {file = "coverage-5.3.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7"},
+ {file = "coverage-5.3.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f"},
+ {file = "coverage-5.3.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b"},
+ {file = "coverage-5.3.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec"},
+ {file = "coverage-5.3.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714"},
+ {file = "coverage-5.3.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b"},
+ {file = "coverage-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7"},
+ {file = "coverage-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72"},
+ {file = "coverage-5.3.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b"},
+ {file = "coverage-5.3.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4"},
+ {file = "coverage-5.3.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105"},
+ {file = "coverage-5.3.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448"},
+ {file = "coverage-5.3.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277"},
+ {file = "coverage-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f"},
+ {file = "coverage-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c"},
+ {file = "coverage-5.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd"},
+ {file = "coverage-5.3.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4"},
+ {file = "coverage-5.3.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff"},
+ {file = "coverage-5.3.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8"},
+ {file = "coverage-5.3.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e"},
+ {file = "coverage-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2"},
+ {file = "coverage-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879"},
+ {file = "coverage-5.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b"},
+ {file = "coverage-5.3.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497"},
+ {file = "coverage-5.3.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059"},
+ {file = "coverage-5.3.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631"},
+ {file = "coverage-5.3.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830"},
+ {file = "coverage-5.3.1-cp38-cp38-win32.whl", hash = "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae"},
+ {file = "coverage-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606"},
+ {file = "coverage-5.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f"},
+ {file = "coverage-5.3.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1"},
+ {file = "coverage-5.3.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8"},
+ {file = "coverage-5.3.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4"},
+ {file = "coverage-5.3.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d"},
+ {file = "coverage-5.3.1-cp39-cp39-win32.whl", hash = "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98"},
+ {file = "coverage-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1"},
+ {file = "coverage-5.3.1-pp36-none-any.whl", hash = "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3"},
+ {file = "coverage-5.3.1-pp37-none-any.whl", hash = "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c"},
+ {file = "coverage-5.3.1.tar.gz", hash = "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b"},
+]
+decorator = [
+ {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"},
+ {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"},
+]
+distlib = [
+ {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
+ {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
+]
+filelock = [
+ {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
+ {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
+]
+flake8 = [
+ {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"},
+ {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"},
+]
+idna = [
+ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
+ {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
+]
+iniconfig = [
+ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
+]
+ipdb = [
+ {file = "ipdb-0.13.4.tar.gz", hash = "sha256:c85398b5fb82f82399fc38c44fe3532c0dde1754abee727d8f5cfcc74547b334"},
+]
+ipython = [
+ {file = "ipython-7.19.0-py3-none-any.whl", hash = "sha256:c987e8178ced651532b3b1ff9965925bfd445c279239697052561a9ab806d28f"},
+ {file = "ipython-7.19.0.tar.gz", hash = "sha256:cbb2ef3d5961d44e6a963b9817d4ea4e1fa2eb589c371a470fed14d8d40cbd6a"},
+]
+ipython-genutils = [
+ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"},
+ {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"},
+]
+isort = [
+ {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
+ {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
+]
+jedi = [
+ {file = "jedi-0.18.0-py2.py3-none-any.whl", hash = "sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93"},
+ {file = "jedi-0.18.0.tar.gz", hash = "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"},
+]
+mccabe = [
+ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
+ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
+]
+multidict = [
+ {file = "multidict-5.1.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f"},
+ {file = "multidict-5.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf"},
+ {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281"},
+ {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d"},
+ {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d"},
+ {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da"},
+ {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224"},
+ {file = "multidict-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26"},
+ {file = "multidict-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6"},
+ {file = "multidict-5.1.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76"},
+ {file = "multidict-5.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a"},
+ {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f"},
+ {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348"},
+ {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93"},
+ {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9"},
+ {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37"},
+ {file = "multidict-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5"},
+ {file = "multidict-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632"},
+ {file = "multidict-5.1.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952"},
+ {file = "multidict-5.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79"},
+ {file = "multidict-5.1.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456"},
+ {file = "multidict-5.1.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7"},
+ {file = "multidict-5.1.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635"},
+ {file = "multidict-5.1.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a"},
+ {file = "multidict-5.1.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea"},
+ {file = "multidict-5.1.0-cp38-cp38-win32.whl", hash = "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656"},
+ {file = "multidict-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3"},
+ {file = "multidict-5.1.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93"},
+ {file = "multidict-5.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647"},
+ {file = "multidict-5.1.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d"},
+ {file = "multidict-5.1.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8"},
+ {file = "multidict-5.1.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1"},
+ {file = "multidict-5.1.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841"},
+ {file = "multidict-5.1.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda"},
+ {file = "multidict-5.1.0-cp39-cp39-win32.whl", hash = "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"},
+ {file = "multidict-5.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359"},
+ {file = "multidict-5.1.0.tar.gz", hash = "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5"},
+]
+packaging = [
+ {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"},
+ {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"},
+]
+parso = [
+ {file = "parso-0.8.1-py2.py3-none-any.whl", hash = "sha256:15b00182f472319383252c18d5913b69269590616c947747bc50bf4ac768f410"},
+ {file = "parso-0.8.1.tar.gz", hash = "sha256:8519430ad07087d4c997fda3a7918f7cfa27cb58972a8c89c2a0295a1c940e9e"},
+]
+pexpect = [
+ {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
+ {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
+]
+pickleshare = [
+ {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
+ {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"},
+]
+pluggy = [
+ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
+ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
+]
+prompt-toolkit = [
+ {file = "prompt_toolkit-3.0.8-py3-none-any.whl", hash = "sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63"},
+ {file = "prompt_toolkit-3.0.8.tar.gz", hash = "sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c"},
+]
+ptyprocess = [
+ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
+ {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
+]
+py = [
+ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
+ {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"},
+]
+pycodestyle = [
+ {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"},
+ {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"},
+]
+pyflakes = [
+ {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"},
+ {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"},
+]
+pygments = [
+ {file = "Pygments-2.7.3-py3-none-any.whl", hash = "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"},
+ {file = "Pygments-2.7.3.tar.gz", hash = "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716"},
+]
+pyparsing = [
+ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
+ {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
+]
+pytest = [
+ {file = "pytest-6.2.1-py3-none-any.whl", hash = "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8"},
+ {file = "pytest-6.2.1.tar.gz", hash = "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"},
+]
+pytest-aiohttp = [
+ {file = "pytest-aiohttp-0.3.0.tar.gz", hash = "sha256:c929854339637977375838703b62fef63528598bc0a9d451639eba95f4aaa44f"},
+ {file = "pytest_aiohttp-0.3.0-py3-none-any.whl", hash = "sha256:0b9b660b146a65e1313e2083d0d2e1f63047797354af9a28d6b7c9f0726fa33d"},
+]
+pytest-cov = [
+ {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"},
+ {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"},
+]
+six = [
+ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
+ {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
+]
+toml = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+tox = [
+ {file = "tox-3.20.1-py2.py3-none-any.whl", hash = "sha256:42ce19ce5dc2f6d6b1fdc5666c476e1f1e2897359b47e0aa3a5b774f335d57c2"},
+ {file = "tox-3.20.1.tar.gz", hash = "sha256:4321052bfe28f9d85082341ca8e233e3ea901fdd14dab8a5d3fbd810269fbaf6"},
+]
+traitlets = [
+ {file = "traitlets-5.0.5-py3-none-any.whl", hash = "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"},
+ {file = "traitlets-5.0.5.tar.gz", hash = "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396"},
+]
+typing-extensions = [
+ {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
+ {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
+ {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
+]
+virtualenv = [
+ {file = "virtualenv-20.2.2-py2.py3-none-any.whl", hash = "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c"},
+ {file = "virtualenv-20.2.2.tar.gz", hash = "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"},
+]
+wcwidth = [
+ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
+ {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
+]
+yarl = [
+ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"},
+ {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"},
+ {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"},
+ {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"},
+ {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"},
+ {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"},
+ {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"},
+ {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"},
+ {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"},
+ {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"},
+ {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"},
+ {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"},
+ {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"},
+ {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"},
+ {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"},
+ {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"},
+ {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"},
+ {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"},
+ {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"},
+ {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"},
+ {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"},
+ {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"},
+ {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"},
+ {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"},
+ {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"},
+ {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"},
+ {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"},
+ {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"},
+ {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"},
+ {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"},
+ {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"},
+ {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"},
+ {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"},
+ {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"},
+ {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"},
+ {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"},
+ {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"},
+]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..ea2da55
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,24 @@
+[tool]
+[tool.poetry]
+name = "aiohttp_csrf"
+version = "0.1.1"
+description = "CSRF protection for aiohttp-server"
+authors = ["TensorTom"]
+license = "MIT"
+readme = "README.md"
+homepage = "https://github.com/TensorTom/aiohttp-csrf"
+
+[tool.poetry.dependencies]
+python = ">=3.8.3, <4"
+aiohttp = ">=3.6.2, <4.1"
+aiohttp-session = "^2"
+blake3 = "^0.1.8"
+
+[tool.poetry.dev-dependencies]
+flake8 = ">=3.7.9"
+ipdb = "*"
+isort = "==4.3.21"
+pytest = ">=5.3.5"
+pytest-aiohttp = "^0.3"
+pytest-cov = ">=2.8.1"
+tox = ">=3.14.5"
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..870e595
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+addopts= --no-cov-on-fail --cov=aiohttp_csrf --cov-report=term --cov-report=html
diff --git a/requirements_dev.txt b/requirements_dev.txt
new file mode 100644
index 0000000..f5d515e
--- /dev/null
+++ b/requirements_dev.txt
@@ -0,0 +1,9 @@
+aiohttp==2.3.10
+-e .
+aiohttp-session==1.0.1
+flake8==3.4.1
+isort==4.2.15
+pytest==3.4.0
+pytest-aiohttp==0.1.3
+pytest-cov==2.5.1
+tox==2.9.1
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..f57cec6
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+from setuptools import setup
+
+packages = \
+['aiohttp_csrf']
+
+package_data = \
+{'': ['*']}
+
+install_requires = \
+['aiohttp-session>=2,<3', 'aiohttp>=3.6.2,<4.1', 'blake3>=0.1.8,<0.2.0']
+
+setup_kwargs = {
+ 'name': 'aiohttp-csrf',
+ 'version': '0.1.1',
+ 'description': 'CSRF protection for aiohttp-server',
+ 'long_description': 'aiohttp_csrf\n=============\n\nThe library provides csrf (xsrf) protection for [aiohttp.web](https://docs.aiohttp.org/en/latest/web.html).\n\n**Breaking Change:** New in 0.1.0 is Blake3 hashes are used by default. This means you must pass `secret_phrase` to\n`aiohttp_csrf.storage.SessionStorage`\n\n**note:** The package [aiohttp-csrf-fixed](https://pypi.org/project/aiohttp-csrf-fixed) is aiohttp_csrf 0.0.2 +\n[this commit](https://github.com/oplik0/aiohttp-csrf/commit/b1bd9207f43a2abf30e32e72ecdb10983a251823). The maintainer\ndidn\'t submit a PR so I just saw it by chance. I haven\'t had time to closely examine it but I think it\'s just removing\nthe HTTP security error that happens if no CSRF is provided. Why do that? An HTTP error is good because it tells the\nclient what happened and lets you handle it by middleware.\n\n__0.1.1:__ Converted `@aiohttp_csrf.csrf_exempt` decorator to a co-routine to make it compatible with latest aiohttp.\n\n\n\nBasic usage\n-----------\n\nThe library allows you to implement csrf (xsrf) protection for requests\n\nBasic usage example:\n\n```python\nimport aiohttp_csrf\nfrom aiohttp import web\n\nFORM_FIELD_NAME = \'_csrf_token\'\nCOOKIE_NAME = \'csrf_token\'\n\n\ndef make_app():\n csrf_policy = aiohttp_csrf.policy.FormPolicy(FORM_FIELD_NAME)\n\n csrf_storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)\n\n app = web.Application()\n\n aiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage)\n\n app.middlewares.append(aiohttp_csrf.csrf_middleware)\n\n async def handler_get_form_with_token(request):\n token = await aiohttp_csrf.generate_token(request)\n\n\n body = \'\'\'\n \n Form with csrf protection\n \n \n \n \n \'\'\' # noqa\n\n body = body.format(field_name=FORM_FIELD_NAME, token=token)\n\n return web.Response(\n body=body.encode(\'utf-8\'),\n content_type=\'text/html\',\n )\n\n async def handler_post_check(request):\n post = await request.post()\n\n body = \'Hello, {name}\'.format(name=post[\'name\'])\n\n return web.Response(\n body=body.encode(\'utf-8\'),\n content_type=\'text/html\',\n )\n\n app.router.add_route(\n \'GET\',\n \'/\',\n handler_get_form_with_token,\n )\n\n app.router.add_route(\n \'POST\',\n \'/\',\n handler_post_check,\n )\n\n return app\n\n\nweb.run_app(make_app())\n```\n\n### Initialize\n\nFirst of all, you need to initialize `aiohttp_csrf` in your application:\n\n```python\napp = web.Application()\n\ncsrf_policy = aiohttp_csrf.policy.FormPolicy(FORM_FIELD_NAME)\n\ncsrf_storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)\n\naiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage)\n```\n\n### Middleware and decorators\n\nAfter initialize you can use `@aiohttp_csrf.csrf_protect` for handlers, that you want to protect. Or you can\ninitialize `aiohttp_csrf.csrf_middleware` and do not disturb about using\ndecorator ([full middleware example here](demo/middleware.py)):\n\n```python\n# ...\napp.middlewares.append(aiohttp_csrf.csrf_middleware)\n# ...\n```\n\nIn this case all your handlers will be protected.\n\n**Note:** we strongly recommend to use `aiohttp_csrf.csrf_middleware` and `@aiohttp_csrf.csrf_exempt` instead of\nmanually managing with `@aiohttp_csrf.csrf_protect`. But if you prefer to use `@aiohttp_csrf.csrf_protect`, don\'t forget\nto use `@aiohttp_csrf.csrf_protect` for both methods: GET and\nPOST ([manual protection example](demo/manual_protection.py))\n\nIf you want to use middleware, but need handlers without protection, you can use `@aiohttp_csrf.csrf_exempt`. Mark you\nhandler with this decorator and this handler will not check the token:\n\n```python\n@aiohttp_csrf.csrf_exempt\nasync def handler_post_not_check(request):\n ...\n```\n\n### Generate token\n\nFor generate token you need to call `aiohttp_csrf.generate_token` in your handler:\n\n```python\n@aiohttp_csrf.csrf_protect\nasync def handler_get(request):\n token = await aiohttp_csrf.generate_token(request)\n ...\n```\n\nAdvanced usage\n--------------\n\n### Policies\n\nYou can use different policies for check tokens. Library provides 3 types of policy:\n\n- **FormPolicy**. This policy will search token in the body of your POST request (Usually use for forms) or as a GET\n variable of the same name. You need to specify name of field that will be checked.\n- **HeaderPolicy**. This policy will search token in headers of your POST request (Usually use for AJAX requests). You\n need to specify name of header that will be checked.\n- **FormAndHeaderPolicy**. This policy combines behavior of **FormPolicy** and **HeaderPolicy**.\n\nYou can implement your custom policies if needed. But make sure that your custom policy\nimplements `aiohttp_csrf.policy.AbstractPolicy` interface.\n\n### Storages\n\nYou can use different types of storages for storing token. Library provides 2 types of storage:\n\n- **CookieStorage**. Your token will be stored in cookie variable. You need to specify cookie name.\n- **SessionStorage**. Your token will be stored in session. You need to specify session variable name.\n\n**Important:** If you want to use session storage, you need setup aiohttp\\_session in your\napplication ([session storage example](demo/session_storage.py#L22))\n\nYou can implement your custom storages if needed. But make sure that your custom storage\nimplements `aiohttp_csrf.storage.AbstractStorage` interface.\n\n### Token generators\n\nYou can use different token generator in your application. By default storages\nusing `aiohttp_csrf.token_generator.SimpleTokenGenerator`\n\nBut if you need more secure token generator - you can use `aiohttp_csrf.token_generator.HashedTokenGenerator`\n\nAnd you can implement your custom token generators if needed. But make sure that your custom token generator\nimplements `aiohttp_csrf.token_generator.AbstractTokenGenerator` interface.\n\n### Invalid token behavior\n\nBy default, if token is invalid, `aiohttp_csrf` will raise `aiohttp.web.HTTPForbidden` exception.\n\nYou have ability to specify your custom error handler. It can be:\n\n- **callable instance. Input parameter - aiohttp request.**\n\n```python\ndef custom_error_handler(request):\n # do something\n return aiohttp.web.Response(status=403)\n\n# or\n\nasync def custom_async_error_handler(request):\n # await do something\n return aiohttp.web.Response(status=403)\n```\n\nIt will be called instead of protected handler.\n\n- **sub class of Exception**. In this case this Exception will be raised.\n\n```python\nclass CustomException(Exception):\n pass\n```\n\nYou can specify custom error handler globally, when initialize `aiohttp_csrf` in your application:\n\n```python\n...\nclass CustomException(Exception):\n pass\n\n...\naiohttp_csrf.setup(app, policy=csrf_policy, storage=csrf_storage, error_renderer=CustomException)\n...\n```\n\nIn this case custom error handler will be applied to all protected handlers.\n\nOr you can specify custom error handler locally, for specific handler:\n\n```python\n...\nclass CustomException(Exception):\n pass\n\n...\n@aiohttp_csrf.csrf_protect(error_renderer=CustomException)\ndef handler_with_custom_csrf_error(request):\n ...\n```\n\nIn this case custom error handler will be applied to this handler only. For all other handlers will be applied global\nerror handler.\n',
+ 'author': 'TensorTom',
+ 'author_email': None,
+ 'maintainer': None,
+ 'maintainer_email': None,
+ 'url': 'https://github.com/TensorTom/aiohttp-csrf',
+ 'packages': packages,
+ 'package_data': package_data,
+ 'install_requires': install_requires,
+ 'python_requires': '>=3.8.3,<4',
+}
+
+
+setup(**setup_kwargs)
diff --git a/test.py b/test.py
new file mode 100644
index 0000000..4bee5ef
--- /dev/null
+++ b/test.py
@@ -0,0 +1,36 @@
+import asyncio
+
+from aiohttp import web
+
+
+async def hello(request):
+ return web.Response(text="Hello, world")
+
+
+def dec(handler):
+ def wrapped(*args, **kwargs):
+ request = args[-1]
+ import ipdb;ipdb.set_trace()
+ return handler(*args, **kwargs)
+
+ return wrapped
+
+
+class MyView(web.View):
+ @dec
+ async def get(self):
+ return web.Response(text="Get Hello, world")
+
+ async def post(self):
+ return web.Response(text="Post Hello, world")
+
+
+@web.middleware
+async def middleware(request, handler):
+ return await handler(request)
+
+
+app = web.Application(middlewares=[middleware])
+app.router.add_route('*', '/', MyView)
+
+web.run_app(app)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..1a2f168
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,69 @@
+import aiohttp_csrf
+import pytest
+from aiohttp import web
+
+SESSION_NAME = COOKIE_NAME = 'csrf_token'
+FORM_FIELD_NAME = HEADER_NAME = 'X-CSRF-TOKEN'
+
+
+@pytest.yield_fixture
+def init_app():
+ def go(
+ loop,
+ policy,
+ storage,
+ handlers,
+ error_renderer=None,
+ ):
+ app = web.Application()
+
+ kwargs = {
+ 'policy': policy,
+ 'storage': storage,
+ }
+
+ if error_renderer is not None:
+ kwargs['error_renderer'] = error_renderer
+
+ aiohttp_csrf.setup(app, **kwargs)
+
+ for method, url, handler in handlers:
+ app.router.add_route(
+ method,
+ url,
+ handler,
+ )
+
+ return app
+
+ yield go
+
+
+@pytest.fixture(params=[
+ (aiohttp_csrf.policy.FormPolicy, (FORM_FIELD_NAME,)),
+ (aiohttp_csrf.policy.FormAndHeaderPolicy, (HEADER_NAME, FORM_FIELD_NAME)),
+])
+def csrf_form_policy(request):
+ _class, args = request.param
+
+ return _class(*args)
+
+
+@pytest.fixture(params=[
+ (aiohttp_csrf.policy.HeaderPolicy, (HEADER_NAME,)),
+ (aiohttp_csrf.policy.FormAndHeaderPolicy, (HEADER_NAME, FORM_FIELD_NAME)),
+])
+def csrf_header_policy(request):
+ _class, args = request.param
+
+ return _class(*args)
+
+
+@pytest.fixture(params=[
+ (aiohttp_csrf.storage.SessionStorage, (SESSION_NAME,)),
+ (aiohttp_csrf.storage.CookieStorage, (COOKIE_NAME,)),
+])
+def csrf_storage(request):
+ _class, args = request.param
+
+ return _class(*args)
diff --git a/tests/test_custom_error_renderer.py b/tests/test_custom_error_renderer.py
new file mode 100644
index 0000000..a4160a0
--- /dev/null
+++ b/tests/test_custom_error_renderer.py
@@ -0,0 +1,99 @@
+import asyncio
+
+import aiohttp_csrf
+import pytest
+from aiohttp import web
+
+COOKIE_NAME = 'csrf_token'
+HEADER_NAME = 'X-CSRF-TOKEN'
+
+
+@pytest.yield_fixture
+def create_app(init_app):
+ def go(loop, error_renderer):
+ @aiohttp_csrf.csrf_protect
+ async def handler_get(request):
+ await aiohttp_csrf.generate_token(request)
+
+ return web.Response(body=b'OK')
+
+ @aiohttp_csrf.csrf_protect(error_renderer=error_renderer)
+ async def handler_post(request):
+ return web.Response(body=b'OK')
+
+ handlers = [
+ ('GET', '/', handler_get),
+ ('POST', '/', handler_post)
+ ]
+
+ storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+ policy = aiohttp_csrf.policy.HeaderPolicy(HEADER_NAME)
+
+ app = init_app(
+ policy=policy,
+ storage=storage,
+ handlers=handlers,
+ loop=loop,
+ )
+
+ return app
+
+ yield go
+
+
+async def test_custom_exception_error_renderer(test_client, create_app):
+ client = await test_client(
+ create_app,
+ error_renderer=web.HTTPBadRequest,
+ )
+
+ await client.get('/')
+
+ resp = await client.post('/')
+
+ assert resp.status == web.HTTPBadRequest.status_code
+
+
+@pytest.fixture(params=[False, True])
+def make_error_renderer(request):
+ is_coroutine = request.param
+
+ def make_renderer(error_body):
+ def error_renderer(request):
+ return web.Response(body=error_body)
+
+ if not is_coroutine:
+ return error_renderer
+
+ return asyncio.coroutine(error_renderer)
+
+ return make_renderer
+
+
+async def test_custom_coroutine_callable_error_renderer(test_client, create_app, make_error_renderer): # noqa
+ error_body = b'CSRF error'
+
+ error_renderer = make_error_renderer(error_body)
+
+ client = await test_client(
+ create_app,
+ error_renderer=error_renderer,
+ )
+
+ await client.get('/')
+
+ resp = await client.post('/')
+
+ assert resp.status == 200
+
+ assert await resp.read() == error_body
+
+
+async def test_bad_error_renderer(test_client, create_app):
+ error_renderer = 'trololo'
+
+ with pytest.raises(TypeError):
+ await test_client(
+ create_app,
+ error_renderer=error_renderer,
+ )
diff --git a/tests/test_errors.py b/tests/test_errors.py
new file mode 100644
index 0000000..0d7cf36
--- /dev/null
+++ b/tests/test_errors.py
@@ -0,0 +1,77 @@
+import aiohttp_csrf
+import pytest
+from aiohttp import web
+
+COOKIE_NAME = 'csrf_token'
+HEADER_NAME = 'X-CSRF-TOKEN'
+
+
+class FakeClass:
+ pass
+
+
+async def test_bad_policy(test_client, init_app):
+ policy = FakeClass()
+ storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ with pytest.raises(TypeError):
+ await test_client(
+ init_app,
+ policy=policy,
+ storage=storage,
+ handlers=[],
+ )
+
+
+async def test_bad_storage(test_client, init_app):
+ policy = aiohttp_csrf.policy.HeaderPolicy(HEADER_NAME)
+ storage = FakeClass()
+
+ with pytest.raises(TypeError):
+ await test_client(
+ init_app,
+ policy=policy,
+ storage=storage,
+ handlers=[],
+ )
+
+
+async def test_bad_error_renderer(test_client, init_app):
+ policy = aiohttp_csrf.policy.HeaderPolicy(HEADER_NAME)
+ storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ with pytest.raises(TypeError):
+ await test_client(
+ init_app,
+ policy=policy,
+ storage=storage,
+ error_renderer=1,
+ handlers=[],
+ )
+
+
+async def test_app_without_setup(test_client):
+ def create_app(loop):
+ app = web.Application()
+
+ @aiohttp_csrf.csrf_protect
+ async def handler(request):
+ await aiohttp_csrf.generate_token(request)
+
+ return web.Response()
+
+ app.router.add_route(
+ 'GET',
+ '/',
+ handler,
+ )
+
+ return app
+
+ client = await test_client(
+ create_app,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 500
diff --git a/tests/test_exempt_decorator.py b/tests/test_exempt_decorator.py
new file mode 100644
index 0000000..6802827
--- /dev/null
+++ b/tests/test_exempt_decorator.py
@@ -0,0 +1,55 @@
+import aiohttp_csrf
+import pytest
+from aiohttp import web
+
+COOKIE_NAME = 'csrf_token'
+HEADER_NAME = 'X-CSRF-TOKEN'
+
+
+@pytest.yield_fixture
+def create_app(init_app):
+ def go(loop):
+ async def handler_get(request):
+ await aiohttp_csrf.generate_token(request)
+
+ return web.Response(body=b'OK')
+
+ @aiohttp_csrf.csrf_exempt
+ async def handler_post(request):
+ return web.Response(body=b'OK')
+
+ handlers = [
+ ('GET', '/', handler_get),
+ ('POST', '/', handler_post),
+ ]
+
+ policy = aiohttp_csrf.policy.HeaderPolicy(HEADER_NAME)
+ storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ app = init_app(
+ policy=policy,
+ storage=storage,
+ handlers=handlers,
+ loop=loop,
+ )
+
+ app.middlewares.append(aiohttp_csrf.csrf_middleware)
+
+ return app
+
+ yield go
+
+
+async def test_decorator_method_view(test_client, create_app):
+
+ client = await test_client(
+ create_app,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ resp = await client.post('/')
+
+ assert resp.status == 200
diff --git a/tests/test_form_policy.py b/tests/test_form_policy.py
new file mode 100644
index 0000000..11a9689
--- /dev/null
+++ b/tests/test_form_policy.py
@@ -0,0 +1,158 @@
+import re
+import uuid
+from unittest import mock
+
+import aiohttp_csrf
+import pytest
+from aiohttp import web
+from aiohttp_session import setup as setup_session
+from aiohttp_session import SimpleCookieStorage
+
+from .conftest import FORM_FIELD_NAME
+
+FORM_FIELD_REGEX = re.compile(
+ r'[^"]+)".*>',
+)
+
+
+@pytest.yield_fixture
+def create_app(init_app):
+ def go(loop, policy, storage):
+ async def handler_get(request):
+ token = await aiohttp_csrf.generate_token(request)
+
+ body = '''
+
+
+
+
+
+
+ ''' # noqa
+
+ body = body.format(field_name=FORM_FIELD_NAME, token=token)
+
+ return web.Response(body=body.encode('utf-8'))
+
+ async def handler_post(request):
+ return web.Response(body=b'OK')
+
+ handlers = [
+ ('GET', '/', handler_get),
+ ('POST', '/', handler_post)
+ ]
+
+ app = init_app(
+ policy=policy,
+ storage=storage,
+ handlers=handlers,
+ loop=loop,
+ )
+
+ if isinstance(storage, aiohttp_csrf.storage.SessionStorage):
+ session_storage = SimpleCookieStorage()
+ setup_session(app, session_storage)
+
+ app.middlewares.append(aiohttp_csrf.csrf_middleware)
+
+ return app
+
+ yield go
+
+
+async def test_form_policy_success(
+ test_client,
+ create_app,
+ csrf_form_policy,
+ csrf_storage,
+):
+ client = await test_client(
+ create_app,
+ policy=csrf_form_policy,
+ storage=csrf_storage,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ body = await resp.text()
+
+ search_result = FORM_FIELD_REGEX.search(body)
+
+ token = search_result.group('token')
+
+ data = {FORM_FIELD_NAME: token}
+
+ resp = await client.post('/', data=data)
+
+ assert resp.status == 200
+
+
+async def test_form_policy_bad_token(
+ test_client,
+ create_app,
+ csrf_form_policy,
+ csrf_storage,
+):
+ real_token = uuid.uuid4().hex
+
+ bad_token = real_token
+
+ while bad_token == real_token:
+ bad_token = uuid.uuid4().hex
+
+ with mock.patch(
+ 'aiohttp_csrf.token_generator.SimpleTokenGenerator.generate',
+ return_value=real_token,
+ ):
+ client = await test_client(
+ create_app,
+ policy=csrf_form_policy,
+ storage=csrf_storage,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ data = {FORM_FIELD_NAME: bad_token}
+
+ resp = await client.post('/', data=data)
+
+ assert resp.status == 403
+
+
+async def test_form_policy_reuse_token(
+ test_client,
+ create_app,
+ csrf_form_policy,
+ csrf_storage,
+):
+ client = await test_client(
+ create_app,
+ policy=csrf_form_policy,
+ storage=csrf_storage,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ body = await resp.text()
+
+ search_result = FORM_FIELD_REGEX.search(body)
+
+ token = search_result.group('token')
+
+ data = {FORM_FIELD_NAME: token}
+
+ resp = await client.post('/', data=data)
+
+ assert resp.status == 200
+
+ resp = await client.post('/', data=data)
+
+ assert resp.status == 403
diff --git a/tests/test_header_policy.py b/tests/test_header_policy.py
new file mode 100644
index 0000000..66b9bbf
--- /dev/null
+++ b/tests/test_header_policy.py
@@ -0,0 +1,111 @@
+import uuid
+from unittest import mock
+
+import aiohttp_csrf
+import pytest
+from aiohttp import web
+
+from .conftest import COOKIE_NAME, HEADER_NAME
+
+
+@pytest.yield_fixture
+def create_app(init_app):
+ def go(loop, policy):
+ async def handler_get(request):
+ await aiohttp_csrf.generate_token(request)
+
+ return web.Response(body=b'OK')
+
+ async def handler_post(request):
+ return web.Response(body=b'OK')
+
+ handlers = [
+ ('GET', '/', handler_get),
+ ('POST', '/', handler_post)
+ ]
+
+ storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ app = init_app(
+ policy=policy,
+ storage=storage,
+ handlers=handlers,
+ loop=loop,
+ )
+
+ app.middlewares.append(aiohttp_csrf.csrf_middleware)
+
+ return app
+
+ yield go
+
+
+async def test_header_policy_success(test_client, create_app, csrf_header_policy): # noqa
+ client = await test_client(
+ create_app,
+ policy=csrf_header_policy,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ token = resp.cookies[COOKIE_NAME].value
+
+ headers = {HEADER_NAME: token}
+
+ resp = await client.post('/', headers=headers)
+
+ assert resp.status == 200
+
+
+async def test_header_policy_bad_token(test_client, create_app, csrf_header_policy): # noqa
+ real_token = uuid.uuid4().hex
+
+ bad_token = real_token
+
+ while bad_token == real_token:
+ bad_token = uuid.uuid4().hex
+
+ with mock.patch(
+ 'aiohttp_csrf.token_generator.SimpleTokenGenerator.generate',
+ return_value=real_token,
+ ):
+
+ client = await test_client(
+ create_app,
+ policy=csrf_header_policy,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ headers = {HEADER_NAME: bad_token}
+
+ resp = await client.post('/', headers=headers)
+
+ assert resp.status == 403
+
+
+async def test_header_policy_reuse_token(test_client, create_app, csrf_header_policy): # noqa
+ client = await test_client(
+ create_app,
+ policy=csrf_header_policy,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ token = resp.cookies[COOKIE_NAME].value
+
+ headers = {HEADER_NAME: token}
+
+ resp = await client.post('/', headers=headers)
+
+ assert resp.status == 200
+
+ resp = await client.post('/', headers=headers)
+
+ assert resp.status == 403
diff --git a/tests/test_protect_decorator.py b/tests/test_protect_decorator.py
new file mode 100644
index 0000000..94976f6
--- /dev/null
+++ b/tests/test_protect_decorator.py
@@ -0,0 +1,136 @@
+import aiohttp_csrf
+from aiohttp import web
+
+COOKIE_NAME = 'csrf_token'
+HEADER_NAME = 'X-CSRF-TOKEN'
+
+
+async def test_decorator_method_view(test_client, init_app):
+ @aiohttp_csrf.csrf_protect
+ async def handler_get(request):
+ await aiohttp_csrf.generate_token(request)
+
+ return web.Response(body=b'OK')
+
+ @aiohttp_csrf.csrf_protect
+ async def handler_post(request):
+ return web.Response(body=b'OK')
+
+ handlers = [
+ ('GET', '/', handler_get),
+ ('POST', '/', handler_post)
+ ]
+
+ policy = aiohttp_csrf.policy.HeaderPolicy(HEADER_NAME)
+ storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ client = await test_client(
+ init_app,
+ policy=policy,
+ storage=storage,
+ handlers=handlers,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ token = resp.cookies[COOKIE_NAME].value
+
+ headers = {HEADER_NAME: token}
+
+ resp = await client.post('/', headers=headers)
+
+ assert resp.status == 200
+
+ resp = await client.post('/', headers=headers)
+
+ assert resp.status == 403
+
+
+async def test_decorator_class_view(test_client):
+ class TestView(web.View):
+ @aiohttp_csrf.csrf_protect
+ async def get(self):
+ await aiohttp_csrf.generate_token(self.request)
+
+ return web.Response(body=b'OK')
+
+ @aiohttp_csrf.csrf_protect
+ async def post(self):
+ return web.Response(body=b'OK')
+
+ def create_app(loop):
+ policy = aiohttp_csrf.policy.HeaderPolicy(HEADER_NAME)
+ storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ app = web.Application()
+
+ aiohttp_csrf.setup(app, policy=policy, storage=storage)
+
+ if hasattr(app.router, 'add_view'):
+ # For aiohttp >= 3.0.0
+ app.router.add_view('/', TestView)
+ else:
+ app.router.add_route('*', '/', TestView)
+
+ return app
+
+ client = await test_client(
+ create_app,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ token = resp.cookies[COOKIE_NAME].value
+
+ headers = {HEADER_NAME: token}
+
+ resp = await client.post('/', headers=headers)
+
+ assert resp.status == 200
+
+ resp = await client.post('/', headers=headers)
+
+ assert resp.status == 403
+
+
+async def test_handle_http_exceptions(test_client, init_app):
+ @aiohttp_csrf.csrf_protect
+ async def handler_get(request):
+ await aiohttp_csrf.generate_token(request)
+
+ return web.Response(body=b'OK')
+
+ @aiohttp_csrf.csrf_protect
+ async def handler_post(request):
+ raise web.HTTPBadRequest
+
+ handlers = [
+ ('GET', '/', handler_get),
+ ('POST', '/', handler_post)
+ ]
+
+ policy = aiohttp_csrf.policy.HeaderPolicy(HEADER_NAME)
+ storage = aiohttp_csrf.storage.CookieStorage(COOKIE_NAME)
+
+ client = await test_client(
+ init_app,
+ policy=policy,
+ storage=storage,
+ handlers=handlers,
+ )
+
+ resp = await client.get('/')
+
+ assert resp.status == 200
+
+ token = resp.cookies[COOKIE_NAME].value
+
+ headers = {HEADER_NAME: token}
+
+ resp = await client.post('/', headers=headers)
+
+ assert resp.status == 400
diff --git a/tests/test_storage_api.py b/tests/test_storage_api.py
new file mode 100644
index 0000000..519dab8
--- /dev/null
+++ b/tests/test_storage_api.py
@@ -0,0 +1,65 @@
+from unittest.mock import MagicMock
+
+import aiohttp_csrf
+import pytest
+from aiohttp.test_utils import make_mocked_request
+
+
+class FakeStorage(aiohttp_csrf.storage.BaseStorage):
+
+ async def _get(self, request):
+ return request.get('my_field')
+
+ async def _save_token(self, request, response, token):
+ request['my_field'] = token
+
+
+async def test_1():
+ storage = FakeStorage()
+
+ storage._generate_token = MagicMock(return_value='1')
+ storage._get = MagicMock(return_value='1')
+ storage._save = MagicMock()
+
+ assert storage._generate_token.call_count == 0
+
+ request = make_mocked_request('/', 'GET')
+
+ await storage.generate_new_token(request)
+
+ assert storage._generate_token.call_count == 1
+
+ await storage.generate_new_token(request)
+ await storage.generate_new_token(request)
+
+ assert storage._generate_token.call_count == 1
+
+
+async def test_2():
+ storage = FakeStorage()
+
+ storage._generate_token = MagicMock(return_value='1')
+
+ request = make_mocked_request('/', 'GET')
+
+ assert storage._generate_token.call_count == 0
+
+ await storage.save_token(request, None)
+
+ assert storage._generate_token.call_count == 1
+
+ request2 = make_mocked_request('/', 'GET')
+
+ request2['my_field'] = 1
+
+ await storage.save_token(request2, None)
+
+
+async def test_3():
+ class Some:
+ pass
+
+ token_generator = Some()
+
+ with pytest.raises(TypeError):
+ FakeStorage(token_generator=token_generator)
diff --git a/tests/test_token_generator.py b/tests/test_token_generator.py
new file mode 100644
index 0000000..f2ee926
--- /dev/null
+++ b/tests/test_token_generator.py
@@ -0,0 +1,37 @@
+from blake3 import blake3
+import uuid
+from unittest import mock
+
+import aiohttp_csrf
+
+COOKIE_NAME = 'csrf_token'
+HEADER_NAME = 'X-CSRF-TOKEN'
+
+
+def test_simple_token_generator():
+ token_generator = aiohttp_csrf.token_generator.SimpleTokenGenerator()
+
+ u = uuid.uuid4()
+
+ with mock.patch('uuid.uuid4', return_value=u):
+ token = token_generator.generate()
+
+ assert u.hex == token
+
+
+def test_hashed_token_generator():
+ encoding = aiohttp_csrf.token_generator.HashedTokenGenerator.encoding
+
+ token_generator = aiohttp_csrf.token_generator.HashedTokenGenerator(
+ 'secret',
+ )
+
+ u = uuid.uuid4()
+ token_string = u.hex + 'secret'
+
+ hasher = blake3(token_string.encode(encoding=encoding))
+
+ with mock.patch('hashlib.sha256', return_value=hasher):
+ token = token_generator.generate()
+
+ assert token == hasher.hexdigest()
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..6e8ebe8
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,23 @@
+[tox]
+envlist =
+ py3{5,6}
+skip_missing_interpreters = True
+skipsdist = True
+
+[testenv]
+deps =
+ -r{toxinidir}/requirements_dev.txt
+commands =
+ flake8 --show-source aiohttp_csrf
+ isort --check-only -rc aiohttp_csrf --diff
+
+ flake8 --show-source demo
+ isort --check-only -rc demo --diff
+
+ flake8 --show-source tests
+ isort --check-only -rc tests --diff
+
+ flake8 --show-source setup.py
+ isort --check-only setup.py --diff
+
+ pytest tests