Compare commits

..

5 Commits

Author SHA1 Message Date
c27a7b28aa 📝 cover page 2024-08-26 01:57:03 -04:00
42552e2dbc 📝 first push of docs 2024-08-26 01:56:56 -04:00
3ab9e05442 add helpers 2024-08-26 01:56:31 -04:00
6c8e88b8d9 add test.html 2024-08-26 01:56:24 -04:00
6b44e34013 Testing! 2024-08-26 01:56:08 -04:00
34 changed files with 908 additions and 59 deletions

12
conftest.py Normal file
View File

@ -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")

44
docs/README.md Normal file
View File

@ -0,0 +1,44 @@
# spiderweb
As a professional web developer focusing on arcane uses of Django for arcane purposes, it occurred to me a little while ago that I didn't actually know how a web framework _worked_.
> So I built one.
This is `spiderweb`, a web framework that's just big enough to hold a spider. When building it, my goals were simple:
- Learn a lot
- Create an unholy blend of Django and Flask
- Not look at any existing code. Go off of vibes alone and try to solve all the problems I could think of in my own way
> [!WARNING]
> This is a learning project. It should not be used for production without heavy auditing. It's not secure. It's not fast. It's not well-tested. It's not well-documented. It's not well-anything. It's a learning project.
>
> That being said, it's fun and it works, so I'm counting that as a win.
Here's a non-exhaustive list of things this can do:
* Function-based views
* Optional Flask-style URL routing
* Optional Django-style URL routing
* URLs with variables in them a lá Django
* Full middleware implementation
* Limit routes by HTTP verbs
* (Only GET and POST are implemented right now)
* Custom error routes
* Built-in dev server
* Gunicorn support
* HTML templates with Jinja2
* Static files support
* Cookies (reading and setting)
* Optional append_slash (with automatic redirects!)
* ~~CSRF middleware implementation~~ (it's there, but it's crappy and unsafe. This might be beyond my skillset.)
* Optional POST data validation middleware with Pydantic
* Database support (using Peewee, but the end user can use whatever they want as long as there's a Peewee driver for it)
* Session middleware with built-in session store
* Tests (currently a little over 80% coverage)
The TODO list:
* Fix CSRF middleware
* Add more HTTP verbs

9
docs/_coverpage.md Normal file
View File

@ -0,0 +1,9 @@
![logo](_media/spiderweb_logo.png)
> the web framework just big enough for a spider
[GitHub](https://github.com/itsthejoker/spiderweb/)
[Get Started](#spiderweb)
![color](#222)

View File

@ -0,0 +1 @@
https://www.hubspot.com/hubfs/brand-kit-generator/prototype/fonts/DMSans-Bold.ttf

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
docs/_media/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

6
docs/_sidebar.md Normal file
View File

@ -0,0 +1,6 @@
- [home](README.md)
- [quickstart](quickstart.md)
- [responses](responses.md)
- Middleware
- [middleware](middleware/test.md)
- [middleware2](middleware/test2.md)

12
docs/example.md Normal file
View File

@ -0,0 +1,12 @@
> [!ATTENTION]
> An alert of type 'attention' using global style 'callout'.
> [!TIP]
> An alert of type 'tip' using global style 'callout'.
> [!WARNING]
> An alert of type 'warning' using global style 'callout'.
> [!NOTE]
> An alert of type 'note' using global style 'callout'.

44
docs/index.html Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="description" content="Description">
<link rel="icon" type="image/png" sizes="32x32" href="/_media/Favicon-32x32.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
</head>
<body>
<style>
@font-face {
font-family: "DMSans";
src: url('_media/DMSans-Medium.ttf') format('truetype');
}
body {
font-family: "DMSans", sans-serif;
}
</style>
<div id="app"></div>
<script>
window.$docsify = {
name: 'Spiderweb',
loadSidebar: true,
repo: 'https://github.com/itsthejoker/spiderweb',
maxLevel: 3,
coverpage: true,
'flexible-alerts': {
style: 'callout' // or 'flat'
},
auto2top: true,
}
</script>
<!-- Docsify v4 -->
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-python.min.js"></script>
<!-- admonitions -->
<script src="https://unpkg.com/docsify-plugin-flexible-alerts"></script>
<script src="//unpkg.com/docsify-pagination/dist/docsify-pagination.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/docsify-tabs@1"></script>
</body>
</html>

1
docs/middleware/test.md Normal file
View File

@ -0,0 +1 @@
asdf

1
docs/middleware/test2.md Normal file
View File

@ -0,0 +1 @@
asdfawaasdf

93
docs/quickstart.md Normal file
View File

@ -0,0 +1,93 @@
# quickstart
Start by installing the package with your favorite package manager:
<!-- tabs:start -->
<!-- tab:poetry -->
```shell
poetry add spiderweb-framework
```
<!-- tab:pip -->
```shell
pip install spiderweb-framework
```
<!-- tab:pipenv -->
```shell
pipenv install spiderweb-framework
```
<!-- tabs:end -->
Then, create a new file and drop this in it:
```python
from spiderweb import SpiderwebRouter
from spiderweb.response import HttpResponse
app = SpiderwebRouter()
@app.route("/")
def index(request):
return HttpResponse("HELLO, WORLD!")
if __name__ == "__main__":
app.start()
```
Start the dev server by running `python {yourfile.py}` and navigating to `http://localhost:8000/` in your browser. You should see `HELLO, WORLD!` displayed on the page. Press `Ctrl+C` to stop the server.
That's it! You've got a working web app. Let's take a look at what these few lines of code are doing:
```python
from spiderweb import SpiderwebRouter
```
The `SpiderwebRouter` class is the main object that everything stems from in `spiderweb`. It's where you'll set your options, your routes, and more.
```python
from spiderweb.response import HttpResponse
```
Rather than trying to infer what you want, spiderweb wants you to be specific about what you want it to do. Part of that is the One Response Rule:
> Every view must return a Response, and each Response must be a specific type.
There are four different types of responses; if you want to skip ahead, hop over to [the responses page](responses.md) to learn more. For this example, we'll focus on `HttpResponse`, which is the base response.
```python
app = SpiderwebRouter()
```
This line creates a new instance of the `SpiderwebRouter` class and assigns it to the variable `app`. This is the object that will handle all of your requests and responses. If you need to pass any options into spiderweb, you'll do that here.
```python
@app.route("/")
def index(request):
return HttpResponse("HELLO, WORLD!")
```
This is an example view. There are a few things to note here:
- The `@app.route("/")` decorator tells spiderweb that this view should be called when the user navigates to the root of the site.
- The `def index(request):` function is the view itself. It takes a single argument, `request`, which is a `Request` object that contains all the information about the incoming request.
- The `return HttpResponse("HELLO, WORLD!")` line is the response. In this case, it's a simple `HttpResponse` object that contains the string `HELLO, WORLD!`. This will be sent back to the user's browser.
> [!TIP]
> Every view must accept a `request` object as its first argument. This object contains all the information about the incoming request, including headers, cookies, and more.
>
> There's more that we can pass in, but for now, we'll keep it simple.
```python
if __name__ == "__main__":
app.start()
```
Once you finish setting up your app, it's time to start it! You can start the dev server by just calling `app.start()` (and its counterpart `app.stop()` to stop it). This will start a simple server on `localhost:8000` that you can access in your browser. It's not a secure server; don't even think about using it in production. It's just good enough for development.
Now that your app is done, you can also run it with Gunicorn by running `gunicorn --workers=2 {yourfile}:app` in your terminal. This will start a Gunicorn server on `localhost:8000` that you can access in your browser and is a little more robust than the dev server.

3
docs/responses.md Normal file
View File

@ -0,0 +1,3 @@
# responses
...

View File

@ -55,15 +55,15 @@ app = SpiderwebRouter(
], ],
staticfiles_dirs=["static_files"], staticfiles_dirs=["static_files"],
routes=[ routes=[
["/", index], ("/", index),
["/redirect", redirect], ("/redirect", redirect),
["/json", json], ("/json", json),
["/error", error], ("/error", error),
["/middleware", middleware], ("/middleware", middleware),
["/example/<int:id>", example], ("/example/<int:id>", example),
["/form", form, {"allowed_methods": ["GET", "POST"], "csrf_exempt": True}], ("/form", form, {"allowed_methods": ["GET", "POST"], "csrf_exempt": True}),
], ],
error_routes={"405": http405}, error_routes={405: http405},
) )

148
poetry.lock generated
View File

@ -11,6 +11,25 @@ files = [
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, {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]] [[package]]
name = "black" name = "black"
version = "24.8.0" version = "24.8.0"
@ -159,6 +178,90 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {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]] [[package]]
name = "cryptography" name = "cryptography"
version = "43.0.0" version = "43.0.0"
@ -264,6 +367,38 @@ setproctitle = ["setproctitle"]
testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"]
tornado = ["tornado (>=0.2)"] 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]] [[package]]
name = "idna" name = "idna"
version = "3.7" version = "3.7"
@ -627,6 +762,17 @@ files = [
{file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, {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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
@ -641,4 +787,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "84633fc94c48c2a05b5ec77367ad29f327be1dc249a6e4cb76b50ebbe14739b5" content-hash = "17f5dc4b157da57ad75a6f6aa3feb7adfa07500b805d5e79d1f09d640964949f"

View File

@ -41,6 +41,8 @@ ruff = "^0.5.5"
pytest = "^8.3.2" pytest = "^8.3.2"
black = "^24.8.0" black = "^24.8.0"
gunicorn = "^23.0.0" gunicorn = "^23.0.0"
hypothesis = "^6.111.2"
coverage = "^7.6.1"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
@ -53,3 +55,32 @@ Homepage = "https://github.com/itsthejoker/spiderweb"
Documentation = "https://github.com/itsthejoker/spiderweb" Documentation = "https://github.com/itsthejoker/spiderweb"
Repository = "https://git.joekaufeld.com/jkaufeld/spiderweb" Repository = "https://git.joekaufeld.com/jkaufeld/spiderweb"
"Bug Tracker" = "https://github.com/itsthejoker/spiderweb/issues" "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

View File

@ -1,7 +1,7 @@
from peewee import DatabaseProxy from peewee import DatabaseProxy
DEFAULT_ALLOWED_METHODS = ["GET"] DEFAULT_ALLOWED_METHODS = ["GET"]
DEFAULT_ENCODING = "ISO-8859-1" DEFAULT_ENCODING = "UTF-8"
__version__ = "0.10.0" __version__ = "0.10.0"
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie

View File

@ -5,9 +5,6 @@ class IntConverter:
def to_python(self, value): def to_python(self, value):
return int(value) return int(value)
def to_url(self, value):
return str(value)
class StrConverter: class StrConverter:
regex = r"[^/]+" regex = r"[^/]+"
@ -16,9 +13,6 @@ class StrConverter:
def to_python(self, value): def to_python(self, value):
return str(value) return str(value)
def to_url(self, value):
return str(value)
class FloatConverter: class FloatConverter:
regex = r"\d+\.\d+" regex = r"\d+\.\d+"
@ -26,6 +20,3 @@ class FloatConverter:
def to_python(self, value): def to_python(self, value):
return float(value) return float(value)
def to_url(self, value):
return str(value)

View File

@ -11,7 +11,7 @@ def http403(request):
def http404(request): def http404(request):
return JsonResponse( return JsonResponse(
data={"error": f"Route {request.url} not found"}, status_code=404 data={"error": f"Route `{request.path}` not found"}, status_code=404
) )

View File

@ -7,7 +7,7 @@ from threading import Thread
from typing import Optional, Callable from typing import Optional, Callable
from wsgiref.simple_server import WSGIServer from wsgiref.simple_server import WSGIServer
from jinja2 import Environment, FileSystemLoader from jinja2 import BaseLoader, Environment, FileSystemLoader
from peewee import Database, SqliteDatabase from peewee import Database, SqliteDatabase
from spiderweb.middleware import MiddlewareMixin from spiderweb.middleware import MiddlewareMixin
@ -46,8 +46,8 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
middleware: list[str] = None, middleware: list[str] = None,
append_slash: bool = False, append_slash: bool = False,
staticfiles_dirs: list[str] = None, staticfiles_dirs: list[str] = None,
routes: list[list[str | Callable | dict]] = None, routes: list[tuple[str, Callable] | tuple[str, Callable, dict]] = None,
error_routes: dict[str, Callable] = None, error_routes: dict[int, Callable] = None,
secret_key: str = None, secret_key: str = None,
session_max_age=60 * 60 * 24 * 14, # 2 weeks session_max_age=60 * 60 * 24 * 14, # 2 weeks
session_cookie_name="swsession", session_cookie_name="swsession",
@ -100,10 +100,14 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
if self.routes: if self.routes:
self.add_routes() self.add_routes()
if self.error_routes:
self.add_error_routes()
if self.templates_dirs: if self.templates_dirs:
self.env = Environment(loader=FileSystemLoader(self.templates_dirs)) self.template_loader = Environment(loader=FileSystemLoader(self.templates_dirs))
else: else:
self.env = None self.template_loader = None
self.string_loader = Environment(loader=BaseLoader())
if self.staticfiles_dirs: if self.staticfiles_dirs:
for static_dir in self.staticfiles_dirs: for static_dir in self.staticfiles_dirs:
@ -131,7 +135,6 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
rendered_output = resp.render() rendered_output = resp.render()
if not isinstance(rendered_output, list): if not isinstance(rendered_output, list):
rendered_output = [rendered_output] rendered_output = [rendered_output]
encoded_resp = [ encoded_resp = [
chunk.encode(DEFAULT_ENCODING) if isinstance(chunk, str) else chunk chunk.encode(DEFAULT_ENCODING) if isinstance(chunk, str) else chunk
for chunk in rendered_output 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]: def prepare_and_fire_response(self, start_response, request, resp) -> list[bytes]:
try: try:
if isinstance(resp, dict): 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): 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: self.process_response_middleware(request, resp)
middleware.process_response(request, resp)
return self.fire_response(start_response, request, resp) return self.fire_response(start_response, request, resp)

View File

@ -21,7 +21,7 @@ class Session(SpiderwebModel):
user_agent = TextField() user_agent = TextField()
class Meta: class Meta:
table_name = 'spiderweb_sessions' table_name = "spiderweb_sessions"
class SessionMiddleware(SpiderwebMiddleware): 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 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 # we generated a new one earlier, so we can use it now
session_key = request._session["id"] session_key = request._session["id"]
response.set_cookie( response.set_cookie(
@ -76,6 +76,8 @@ class SessionMiddleware(SpiderwebMiddleware):
session_key, session_key,
**cookie_settings, **cookie_settings,
) )
if not is_jsonable(request.SESSION):
raise ValueError("Session data is not JSON serializable.")
session = Session( session = Session(
session_key=session_key, session_key=session_key,
session_data=json.dumps(request.SESSION), session_data=json.dumps(request.SESSION),
@ -97,18 +99,6 @@ class SessionMiddleware(SpiderwebMiddleware):
) )
session = request.META["SESSION"] session = request.META["SESSION"]
if not session: session.session_data = json.dumps(request.SESSION)
if not is_jsonable(request.SESSION): session.last_active = datetime.now()
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.save() session.save()

View File

@ -10,7 +10,6 @@ from spiderweb.constants import REGEX_COOKIE_NAME
from spiderweb.exceptions import GeneralException from spiderweb.exceptions import GeneralException
from spiderweb.request import Request from spiderweb.request import Request
mimetypes.init() mimetypes.init()
@ -128,20 +127,39 @@ class RedirectResponse(HttpResponse):
class TemplateResponse(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) super().__init__(*args, **kwargs)
self.context["request"] = request self.context["request"] = request
self.template = template self.template_path = template_path
self.loader = None self.template_string = template_string
self.template_loader = None
self.string_loader = None
self._template = None self._template = None
if not template: if not template_path and not template_string:
raise GeneralException("TemplateResponse requires a template.") raise GeneralException("TemplateResponse requires a template.")
def render(self) -> str: def render(self) -> str:
if self.loader is None: if self.template_loader is None:
raise GeneralException("TemplateResponse requires a template loader.") if not self.template_string:
self._template = self.loader.get_template(self.template) 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) return self._template.render(**self.context)
def set_template_loader(self, env): def set_template_loader(self, loader):
self.loader = env self.template_loader = loader
def set_string_loader(self, loader):
self.string_loader = loader

View File

@ -1,5 +1,5 @@
import re import re
from typing import Callable, Any from typing import Callable, Any, Optional
from spiderweb.constants import DEFAULT_ALLOWED_METHODS from spiderweb.constants import DEFAULT_ALLOWED_METHODS
from spiderweb.converters import * # noqa: F403 from spiderweb.converters import * # noqa: F403
@ -30,9 +30,9 @@ class RoutesMixin:
# ones that start with underscores are the compiled versions, non-underscores # ones that start with underscores are the compiled versions, non-underscores
# are the user-supplied versions # are the user-supplied versions
_routes: dict _routes: dict
routes: list[list[str | Callable | dict]] routes: list[tuple[str, Callable] | tuple[str, Callable, dict]] = None,
_error_routes: dict _error_routes: dict
error_routes: dict[str, Callable] error_routes: dict[int, Callable]
append_slash: bool append_slash: bool
def route(self, path, allowed_methods=None) -> Callable: def route(self, path, allowed_methods=None) -> Callable:

View File

@ -0,0 +1 @@
from spiderweb.tests.middleware import ExplodingResponseMiddleware, ExplodingRequestMiddleware

View File

@ -0,0 +1,28 @@
from wsgiref.util import setup_testing_defaults
from peewee import SqliteDatabase
from spiderweb import SpiderwebRouter
class StartResponse:
def __init__(self):
self.status = None
self.headers = None
def __call__(self, status, headers):
self.status = status
self.headers = headers
def get_headers(self):
return {h[0]: h[1] for h in self.headers}
def setup():
environ = {}
setup_testing_defaults(environ)
return (
SpiderwebRouter(db=SqliteDatabase("spiderweb-tests.db")),
environ,
StartResponse(),
)

View File

@ -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!")

View File

@ -0,0 +1 @@
TEMPLATE! {{ message }}

View File

@ -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)]

View File

@ -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']

View File

@ -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("/<str:test_input>")
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("/<test_input>")
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("/<asdf:test_input>")
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("/<asdf:test__input>")
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("/<int:test_input>")
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("/<float:test_input>")
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)]

40
test.py Normal file
View File

@ -0,0 +1,40 @@
from peewee import *
from playhouse.migrate import SqliteMigrator, migrate
from spiderweb.db import SpiderwebModel
db = SqliteDatabase("people.db")
migrator = SqliteMigrator(db)
class Person(SpiderwebModel):
name = CharField()
birthday = DateField()
class Meta:
database = db # This model uses the "people.db" database.
class Pet(SpiderwebModel):
owner = ForeignKeyField(Person, backref="pets")
name = CharField(max_length=40)
animal_type = CharField()
age = IntegerField(null=True)
favorite_color = CharField(null=True)
class Meta:
database = db # this model uses the "people.db" database
if __name__ == "__main__":
db.connect()
Pet.check_for_needed_migration()
# try:
# Pet.check_for_needed_migration()
# except:
# migrate(
# migrator.add_column(
# Pet._meta.table_name, 'favorite_color', CharField(null=True)
# ),
# )
db.create_tables([Person, Pet])