diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..5448f9d --- /dev/null +++ b/conftest.py @@ -0,0 +1,12 @@ +import os + +from pytest import fixture + + +@fixture(autouse=True, scope="session") +def db(): + if os.path.exists("spiderweb-tests.db"): + os.remove("spiderweb-tests.db") + yield + if os.path.exists("spiderweb-tests.db"): + os.remove("spiderweb-tests.db") diff --git a/example2.py b/example2.py index fc733d1..53155c9 100644 --- a/example2.py +++ b/example2.py @@ -55,15 +55,15 @@ app = SpiderwebRouter( ], staticfiles_dirs=["static_files"], routes=[ - ["/", index], - ["/redirect", redirect], - ["/json", json], - ["/error", error], - ["/middleware", middleware], - ["/example/", example], - ["/form", form, {"allowed_methods": ["GET", "POST"], "csrf_exempt": True}], + ("/", index), + ("/redirect", redirect), + ("/json", json), + ("/error", error), + ("/middleware", middleware), + ("/example/", example), + ("/form", form, {"allowed_methods": ["GET", "POST"], "csrf_exempt": True}), ], - error_routes={"405": http405}, + error_routes={405: http405}, ) diff --git a/poetry.lock b/poetry.lock index 6a2e005..eea4a67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,6 +11,25 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "black" version = "24.8.0" @@ -159,6 +178,90 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.6.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "cryptography" version = "43.0.0" @@ -264,6 +367,38 @@ setproctitle = ["setproctitle"] testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] tornado = ["tornado (>=0.2)"] +[[package]] +name = "hypothesis" +version = "6.111.2" +description = "A library for property-based testing" +optional = false +python-versions = ">=3.8" +files = [ + {file = "hypothesis-6.111.2-py3-none-any.whl", hash = "sha256:055e8228958e22178d6077e455fd86a72044d02dac130dbf9c8b31e161b9809c"}, + {file = "hypothesis-6.111.2.tar.gz", hash = "sha256:0496ad28c7240ee9ba89fcc7fb1dc74e89f3e40fbcbbb5f73c0091558dec8e6e"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["backports.zoneinfo (>=0.2.1)", "black (>=19.10b0)", "click (>=7.0)", "crosshair-tool (>=0.0.70)", "django (>=3.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.13)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.17.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2024.1)"] +cli = ["black (>=19.10b0)", "click (>=7.0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +crosshair = ["crosshair-tool (>=0.0.70)", "hypothesis-crosshair (>=0.0.13)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=3.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=19.10b0)"] +lark = ["lark (>=0.10.1)"] +numpy = ["numpy (>=1.17.3)"] +pandas = ["pandas (>=1.1)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2024.1)"] + [[package]] name = "idna" version = "3.7" @@ -627,6 +762,17 @@ files = [ {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -641,4 +787,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "84633fc94c48c2a05b5ec77367ad29f327be1dc249a6e4cb76b50ebbe14739b5" +content-hash = "17f5dc4b157da57ad75a6f6aa3feb7adfa07500b805d5e79d1f09d640964949f" diff --git a/pyproject.toml b/pyproject.toml index 83afce0..0801600 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ ruff = "^0.5.5" pytest = "^8.3.2" black = "^24.8.0" gunicorn = "^23.0.0" +hypothesis = "^6.111.2" +coverage = "^7.6.1" [build-system] requires = ["poetry-core"] @@ -53,3 +55,32 @@ Homepage = "https://github.com/itsthejoker/spiderweb" Documentation = "https://github.com/itsthejoker/spiderweb" Repository = "https://git.joekaufeld.com/jkaufeld/spiderweb" "Bug Tracker" = "https://github.com/itsthejoker/spiderweb/issues" + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = ["--maxfail=2", "-rf"] + +[tool.coverage.run] +branch = true +omit = ["conftest.py"] + +[tool.coverage.report] +# Regexes for lines to exclude from consideration +exclude_also = [ + # Don't complain about missing debug-only code: + "def __repr__", + "if self\\.debug", + + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", + ] + +ignore_errors = true \ No newline at end of file diff --git a/spiderweb/constants.py b/spiderweb/constants.py index 4495ae5..ab3aed6 100644 --- a/spiderweb/constants.py +++ b/spiderweb/constants.py @@ -1,7 +1,7 @@ from peewee import DatabaseProxy DEFAULT_ALLOWED_METHODS = ["GET"] -DEFAULT_ENCODING = "ISO-8859-1" +DEFAULT_ENCODING = "UTF-8" __version__ = "0.10.0" # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie diff --git a/spiderweb/converters.py b/spiderweb/converters.py index 2b3cb92..939e1c0 100644 --- a/spiderweb/converters.py +++ b/spiderweb/converters.py @@ -5,9 +5,6 @@ class IntConverter: def to_python(self, value): return int(value) - def to_url(self, value): - return str(value) - class StrConverter: regex = r"[^/]+" @@ -16,9 +13,6 @@ class StrConverter: def to_python(self, value): return str(value) - def to_url(self, value): - return str(value) - class FloatConverter: regex = r"\d+\.\d+" @@ -26,6 +20,3 @@ class FloatConverter: def to_python(self, value): return float(value) - - def to_url(self, value): - return str(value) diff --git a/spiderweb/default_views.py b/spiderweb/default_views.py index 1a26917..c7b590c 100644 --- a/spiderweb/default_views.py +++ b/spiderweb/default_views.py @@ -11,7 +11,7 @@ def http403(request): def http404(request): return JsonResponse( - data={"error": f"Route {request.url} not found"}, status_code=404 + data={"error": f"Route `{request.path}` not found"}, status_code=404 ) diff --git a/spiderweb/main.py b/spiderweb/main.py index b6d1efb..0274de1 100644 --- a/spiderweb/main.py +++ b/spiderweb/main.py @@ -7,7 +7,7 @@ from threading import Thread from typing import Optional, Callable from wsgiref.simple_server import WSGIServer -from jinja2 import Environment, FileSystemLoader +from jinja2 import BaseLoader, Environment, FileSystemLoader from peewee import Database, SqliteDatabase from spiderweb.middleware import MiddlewareMixin @@ -46,8 +46,8 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi middleware: list[str] = None, append_slash: bool = False, staticfiles_dirs: list[str] = None, - routes: list[list[str | Callable | dict]] = None, - error_routes: dict[str, Callable] = None, + routes: list[tuple[str, Callable] | tuple[str, Callable, dict]] = None, + error_routes: dict[int, Callable] = None, secret_key: str = None, session_max_age=60 * 60 * 24 * 14, # 2 weeks session_cookie_name="swsession", @@ -100,10 +100,14 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi if self.routes: self.add_routes() + if self.error_routes: + self.add_error_routes() + if self.templates_dirs: - self.env = Environment(loader=FileSystemLoader(self.templates_dirs)) + self.template_loader = Environment(loader=FileSystemLoader(self.templates_dirs)) else: - self.env = None + self.template_loader = None + self.string_loader = Environment(loader=BaseLoader()) if self.staticfiles_dirs: for static_dir in self.staticfiles_dirs: @@ -131,7 +135,6 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi rendered_output = resp.render() if not isinstance(rendered_output, list): rendered_output = [rendered_output] - encoded_resp = [ chunk.encode(DEFAULT_ENCODING) if isinstance(chunk, str) else chunk for chunk in rendered_output @@ -183,12 +186,12 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi def prepare_and_fire_response(self, start_response, request, resp) -> list[bytes]: try: if isinstance(resp, dict): - self.fire_response(start_response, request, JsonResponse(data=resp)) + return self.fire_response(start_response, request, JsonResponse(data=resp)) if isinstance(resp, TemplateResponse): - resp.set_template_loader(self.env) + resp.set_template_loader(self.template_loader) + resp.set_string_loader(self.string_loader) - for middleware in self.middleware: - middleware.process_response(request, resp) + self.process_response_middleware(request, resp) return self.fire_response(start_response, request, resp) diff --git a/spiderweb/middleware/sessions.py b/spiderweb/middleware/sessions.py index a778012..c2979bd 100644 --- a/spiderweb/middleware/sessions.py +++ b/spiderweb/middleware/sessions.py @@ -21,7 +21,7 @@ class Session(SpiderwebModel): user_agent = TextField() class Meta: - table_name = 'spiderweb_sessions' + table_name = "spiderweb_sessions" class SessionMiddleware(SpiderwebMiddleware): @@ -68,7 +68,7 @@ class SessionMiddleware(SpiderwebMiddleware): } # if a new session has been requested, ignore everything else and make that happen - if request._session["new_session"]: + if request._session["new_session"] is True: # we generated a new one earlier, so we can use it now session_key = request._session["id"] response.set_cookie( @@ -76,6 +76,8 @@ class SessionMiddleware(SpiderwebMiddleware): session_key, **cookie_settings, ) + if not is_jsonable(request.SESSION): + raise ValueError("Session data is not JSON serializable.") session = Session( session_key=session_key, session_data=json.dumps(request.SESSION), @@ -97,18 +99,6 @@ class SessionMiddleware(SpiderwebMiddleware): ) session = request.META["SESSION"] - if not session: - if not is_jsonable(request.SESSION): - raise ValueError("Session data is not JSON serializable.") - session = Session( - session_key=session_key, - session_data=json.dumps(request.SESSION), - created_at=datetime.now(), - last_active=datetime.now(), - ip_address=request.META.get("client_address"), - user_agent=request.META.get("HTTP_USER_AGENT"), - ) - else: - session.session_data = json.dumps(request.SESSION) - session.last_active = datetime.now() + session.session_data = json.dumps(request.SESSION) + session.last_active = datetime.now() session.save() diff --git a/spiderweb/response.py b/spiderweb/response.py index b0ded65..1a8c084 100644 --- a/spiderweb/response.py +++ b/spiderweb/response.py @@ -10,7 +10,6 @@ from spiderweb.constants import REGEX_COOKIE_NAME from spiderweb.exceptions import GeneralException from spiderweb.request import Request - mimetypes.init() @@ -128,20 +127,39 @@ class RedirectResponse(HttpResponse): class TemplateResponse(HttpResponse): - def __init__(self, request: Request, template=None, *args, **kwargs): + def __init__( + self, + request: Request, + template_path: str = None, + template_string: str = None, + *args, + **kwargs, + ): super().__init__(*args, **kwargs) self.context["request"] = request - self.template = template - self.loader = None + self.template_path = template_path + self.template_string = template_string + self.template_loader = None + self.string_loader = None self._template = None - if not template: + if not template_path and not template_string: raise GeneralException("TemplateResponse requires a template.") def render(self) -> str: - if self.loader is None: - raise GeneralException("TemplateResponse requires a template loader.") - self._template = self.loader.get_template(self.template) + if self.template_loader is None: + if not self.template_string: + raise GeneralException( + "TemplateResponse has no loader. Did you set templates_dirs?" + ) + else: + self._template = self.string_loader.from_string(self.template_string) + else: + self._template = self.template_loader.get_template(self.template_path) + return self._template.render(**self.context) - def set_template_loader(self, env): - self.loader = env + def set_template_loader(self, loader): + self.template_loader = loader + + def set_string_loader(self, loader): + self.string_loader = loader diff --git a/spiderweb/routes.py b/spiderweb/routes.py index 49fbcde..5b4421b 100644 --- a/spiderweb/routes.py +++ b/spiderweb/routes.py @@ -1,5 +1,5 @@ import re -from typing import Callable, Any +from typing import Callable, Any, Optional from spiderweb.constants import DEFAULT_ALLOWED_METHODS from spiderweb.converters import * # noqa: F403 @@ -30,9 +30,9 @@ class RoutesMixin: # ones that start with underscores are the compiled versions, non-underscores # are the user-supplied versions _routes: dict - routes: list[list[str | Callable | dict]] + routes: list[tuple[str, Callable] | tuple[str, Callable, dict]] = None, _error_routes: dict - error_routes: dict[str, Callable] + error_routes: dict[int, Callable] append_slash: bool def route(self, path, allowed_methods=None) -> Callable: diff --git a/spiderweb/tests/__init__.py b/spiderweb/tests/__init__.py new file mode 100644 index 0000000..187efdc --- /dev/null +++ b/spiderweb/tests/__init__.py @@ -0,0 +1 @@ +from spiderweb.tests.middleware import ExplodingResponseMiddleware, ExplodingRequestMiddleware \ No newline at end of file diff --git a/spiderweb/tests/middleware.py b/spiderweb/tests/middleware.py new file mode 100644 index 0000000..c2e32c4 --- /dev/null +++ b/spiderweb/tests/middleware.py @@ -0,0 +1,11 @@ +from spiderweb import SpiderwebMiddleware, Request, HttpResponse, UnusedMiddleware + + +class ExplodingRequestMiddleware(SpiderwebMiddleware): + def process_request(self, request: Request) -> HttpResponse | None: + raise UnusedMiddleware("Boom!") + + +class ExplodingResponseMiddleware(SpiderwebMiddleware): + def process_response(self, request: Request, response: HttpResponse) -> HttpResponse | None: + raise UnusedMiddleware("Unfinished!") \ No newline at end of file diff --git a/spiderweb/tests/test_middleware.py b/spiderweb/tests/test_middleware.py new file mode 100644 index 0000000..0aff358 --- /dev/null +++ b/spiderweb/tests/test_middleware.py @@ -0,0 +1,88 @@ +from datetime import timedelta + +from peewee import SqliteDatabase + +from spiderweb import SpiderwebRouter, HttpResponse +from spiderweb.constants import DEFAULT_ENCODING +from spiderweb.middleware.sessions import Session +from spiderweb.tests.helpers import setup + +# app = SpiderwebRouter( +# middleware=[ +# "spiderweb.middleware.sessions.SessionMiddleware", +# "spiderweb.middleware.csrf.CSRFMiddleware", +# "example_middleware.TestMiddleware", +# "example_middleware.RedirectMiddleware", +# "spiderweb.middleware.pydantic.PydanticMiddleware", +# "example_middleware.ExplodingMiddleware", +# ], +# ) + +def index(request): + if "value" in request.SESSION: + request.SESSION['value'] += 1 + else: + request.SESSION['value'] = 0 + return HttpResponse(body=str(request.SESSION['value'])) + + +def test_session_middleware(): + _, environ, start_response = setup() + app = SpiderwebRouter( + middleware=["spiderweb.middleware.sessions.SessionMiddleware"], + db=SqliteDatabase("spiderweb-tests.db") + ) + + app.add_route("/", index) + + environ["HTTP_USER_AGENT"] = "hi" + environ["REMOTE_ADDR"] = "1.1.1.1" + + assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)] + + session_key = Session.select().first().session_key + environ["HTTP_COOKIE"] = f"swsession={session_key}" + + assert app(environ, start_response) == [bytes(str(1), DEFAULT_ENCODING)] + assert app(environ, start_response) == [bytes(str(2), DEFAULT_ENCODING)] + +def test_expired_session(): + _, environ, start_response = setup() + app = SpiderwebRouter( + middleware=["spiderweb.middleware.sessions.SessionMiddleware"], + db=SqliteDatabase("spiderweb-tests.db") + ) + + app.add_route("/", index) + + environ["HTTP_USER_AGENT"] = "hi" + environ["REMOTE_ADDR"] = "1.1.1.1" + + assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)] + + session = Session.select().first() + session.created_at = session.created_at - timedelta(seconds=app.session_max_age) + session.save() + + environ["HTTP_COOKIE"] = f"swsession={session.session_key}" + + # it shouldn't increment because we get a new session + assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)] + + session2 = list(Session.select())[-1] + assert session2.session_key != session.session_key + + +def test_exploding_middleware(): + _, environ, start_response = setup() + app = SpiderwebRouter( + middleware=[ + "spiderweb.tests.middleware.ExplodingRequestMiddleware", + "spiderweb.tests.middleware.ExplodingResponseMiddleware", + ], + db=SqliteDatabase("spiderweb-tests.db") + ) + + app.add_route("/", index) + + assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)] diff --git a/spiderweb/tests/test_responses.py b/spiderweb/tests/test_responses.py new file mode 100644 index 0000000..1375c93 --- /dev/null +++ b/spiderweb/tests/test_responses.py @@ -0,0 +1,173 @@ +import pytest + +from spiderweb import SpiderwebRouter, ConfigError +from spiderweb.constants import DEFAULT_ENCODING +from spiderweb.exceptions import NoResponseError, SpiderwebNetworkException +from spiderweb.response import HttpResponse, JsonResponse, TemplateResponse, RedirectResponse +from hypothesis import given, strategies as st + +from spiderweb.tests.helpers import setup + + +@given(st.text()) +def test_http_response(text): + app, environ, start_response = setup() + + @app.route("/") + def index(request): + return HttpResponse(text) + + assert app(environ, start_response) == [bytes(text, DEFAULT_ENCODING)] + + +def test_json_response(): + app, environ, start_response = setup() + + @app.route("/") + def index(request): + return JsonResponse(data={"message": "text"}) + + assert app(environ, start_response) == [bytes('{"message": "text"}', DEFAULT_ENCODING)] + + +def test_dict_response(): + app, environ, start_response = setup() + + @app.route("/") + def index(request): + return {"message": "Hello, World!"} + + assert app(environ, start_response) == [b'{"message": "Hello, World!"}'] + + +@given(st.text()) +def test_template_response(text): + app, environ, start_response = setup() + template = "MESSAGE: {{ message }}" + + @app.route("/") + def index(request): + return TemplateResponse( + request, template_string=template, context={"message": text} + ) + + assert app(environ, start_response) == [b"MESSAGE: " + bytes(text, DEFAULT_ENCODING)] + + +def test_redirect_response(): + app, environ, start_response = setup() + + @app.route("/") + def index(request): + return RedirectResponse(location="/redirected") + + assert app(environ, start_response) == [b'None'] + assert start_response.get_headers()["Location"] == "/redirected" + + +def test_add_route_at_server_start(): + app, environ, start_response = setup() + + def index(request): + return RedirectResponse(location="/redirected") + + def view2(request): + return HttpResponse("View 2") + + app = SpiderwebRouter(routes=[ + ("/", index, {"allowed_methods": ["GET", "POST"], "csrf_exempt": True}), + ("/view2", view2), + ]) + + assert app(environ, start_response) == [b'None'] + assert start_response.get_headers()["Location"] == "/redirected" + + +def test_redirect_on_append_slash(): + _, environ, start_response = setup() + app = SpiderwebRouter(append_slash=True) + + @app.route("/hello") + def index(request): + pass + + environ["PATH_INFO"] = f"/hello" + assert app(environ, start_response) == [b'None'] + assert start_response.get_headers()["Location"] == "/hello/" + + +@given(st.text()) +def test_template_response_with_template(text): + _, environ, start_response = setup() + + app = SpiderwebRouter(templates_dirs=["spiderweb/tests"]) + + @app.route("/") + def index(request): + return TemplateResponse( + request, "test.html", context={"message": text} + ) + + assert app(environ, start_response) == [b"TEMPLATE! " + bytes(text, DEFAULT_ENCODING)] + + +def test_view_returns_none(): + app, environ, start_response = setup() + + @app.route("/") + def index(request): + pass + + with pytest.raises(NoResponseError): + assert app(environ, start_response) == [b'None'] + + +def test_exploding_view(): + app, environ, start_response = setup() + + @app.route("/") + def index(request): + raise SpiderwebNetworkException("Boom!") + + assert app(environ, start_response) == [ + b'Something went wrong.\n\nCode: Boom!\n\nMsg: None\n\nDesc: None' + ] + +def test_missing_view(): + app, environ, start_response = setup() + + assert app(environ, start_response) == [b'{"error": "Route `/` not found"}'] + + +def test_missing_view_with_custom_404(): + app, environ, start_response = setup() + + @app.error(404) + def custom_404(request): + return HttpResponse("Custom 404") + + assert app(environ, start_response) == [b'Custom 404'] + + +def test_duplicate_error_view(): + app, environ, start_response = setup() + + @app.error(404) + def custom_404(request): + ... + + with pytest.raises(ConfigError): + @app.error(404) + def custom_404(request): + ... + + +def test_missing_view_with_custom_404_alt(): + _, environ, start_response = setup() + + def custom_404(request): + return HttpResponse("Custom 404 2") + + app = SpiderwebRouter(error_routes={404: custom_404}) + + assert app(environ, start_response) == [b'Custom 404 2'] \ No newline at end of file diff --git a/spiderweb/tests/test_variable_urls.py b/spiderweb/tests/test_variable_urls.py new file mode 100644 index 0000000..5112140 --- /dev/null +++ b/spiderweb/tests/test_variable_urls.py @@ -0,0 +1,102 @@ +import pytest + +from spiderweb import SpiderwebRouter +from spiderweb.constants import DEFAULT_ENCODING +from spiderweb.exceptions import ParseError, ConfigError +from spiderweb.response import HttpResponse, JsonResponse, TemplateResponse, RedirectResponse +from hypothesis import given, strategies as st, assume + +from peewee import SqliteDatabase + +from spiderweb.tests.helpers import setup + + +@given(st.text()) +def test_str_converter(text): + assume(len(text) > 0) + assume("/" not in text) + app, environ, start_response = setup() + + @app.route("/") + def index(request, test_input: str): + return HttpResponse(test_input) + + environ["PATH_INFO"] = f"/{text}" + assert app(environ, start_response) == [bytes(text, DEFAULT_ENCODING)] + + +@given(st.text()) +def test_default_str_converter(text): + assume(len(text) > 0) + assume("/" not in text) + app, environ, start_response = setup() + + @app.route("/") + def index(request, test_input: str): + return HttpResponse(test_input) + + environ["PATH_INFO"] = f"/{text}" + assert app(environ, start_response) == [bytes(text, DEFAULT_ENCODING)] + + +def test_unknown_converter(): + app, environ, start_response = setup() + + with pytest.raises(ParseError): + @app.route("/") + def index(request, test_input: str): + return HttpResponse(test_input) + + +def test_duplicate_route(): + app, environ, start_response = setup() + + @app.route("/") + def index(request): + ... + + with pytest.raises(ConfigError): + @app.route("/") + def index(request): + ... + + +def test_url_with_double_underscore(): + app, environ, start_response = setup() + + with pytest.raises(ConfigError): + @app.route("/") + def index(request, test_input: str): + return HttpResponse(test_input) + + +@given(st.integers()) +def test_int_converter(integer): + assume(integer > 0) + app, environ, start_response = setup() + + @app.route("/") + def index(request, test_input: str): + return HttpResponse(test_input) + + environ["PATH_INFO"] = f"/{integer}" + assert app(environ, start_response) == [bytes(str(integer), DEFAULT_ENCODING)] + + +@pytest.mark.parametrize( + "number", + [ + 1.0000000000000002, + 294744.2324, + 0000.3, + ], +) +def test_float_converter(number): + app, environ, start_response = setup() + + @app.route("/") + def index(request, test_input: str): + return HttpResponse(test_input) + + environ["PATH_INFO"] = f"/{number}" + assert app(environ, start_response) == [bytes(str(number), DEFAULT_ENCODING)] \ No newline at end of file