Compare commits

...

39 Commits

Author SHA1 Message Date
f94f0f5134 🔖 release 1.3.1 2024-10-16 17:27:00 -04:00
7ac76883fc add ability to adjust headers in post_process 2024-10-16 17:26:22 -04:00
12f6c726c9 💬 add http:// so url is clickable in terminal app 2024-10-16 17:23:52 -04:00
3d24b53fdf 📝 fix broken docs link 2024-10-15 15:06:33 -04:00
9a407495f8 🔖 release 1.3.0 2024-10-15 15:01:10 -04:00
61d30dca23 add post_process hook for middleware 2024-10-15 15:00:56 -04:00
fd6df38cdf ⬆️ Update deps to resolve security issue 2024-09-25 16:07:13 -04:00
9b5256c5c0 🔖 release 1.2.1 2024-09-22 20:22:27 -04:00
eb1e46751d add lots more tests 2024-09-22 20:21:34 -04:00
1cc99412bc 🐛 fix header assignment bug 2024-09-22 20:01:07 -04:00
65dfa9d599 📝 add version markers 2024-09-17 14:34:14 -04:00
c19d34f336 🚨 fix ruff errors 2024-09-16 18:34:55 -04:00
b779bae6ca 🎨 run black 2024-09-16 18:23:46 -04:00
1b7ceb900b 🔖 release 1.2.0 2024-09-16 18:23:08 -04:00
889b62d4f9 📝 update routes.md to mention the new path converter 2024-09-16 18:22:29 -04:00
19ff69e999 📝 add page on static files 2024-09-16 18:22:05 -04:00
1d8559f766 🐛 fix importing middleware 2024-09-16 18:15:54 -04:00
24109014af add {% static ... %} template option 2024-09-16 18:15:39 -04:00
3cc86f0dbe 🐛 fix crash loop if template rendering fails 2024-09-16 18:05:10 -04:00
c4a4f9ead3 📝 fix docs error 2024-09-09 01:47:23 -04:00
9b9e1c8da0 Merge remote-tracking branch 'origin/main' 2024-09-09 01:44:21 -04:00
7ee119d42d 🔖 release 1.1.0 2024-09-09 01:44:09 -04:00
c9f3129b02 add app.reverse() function 2024-09-09 01:43:55 -04:00
6d4ff61637 🐛 fix broken HEAD data 2024-09-03 14:16:09 -04:00
ebe9c1df2e add opengraph data 2024-09-03 14:12:24 -04:00
aa41df4577 🐛 fix plausible install type 2024-09-03 01:41:56 -04:00
80e70f8843 📝 add missing info about error routes 2024-09-03 01:37:57 -04:00
d14bf8ae14 📝 update README 2024-09-03 01:20:04 -04:00
ac721957ba 📝 change db page title 2024-09-03 01:19:12 -04:00
653d6c497f 📝 add db page 2024-09-03 01:18:52 -04:00
aa0ddc02ba 📝 add routes page 2024-09-03 00:32:20 -04:00
0d1fba1aad 🔖 v1.0!!! 2024-09-02 17:35:06 -04:00
f9225848a6 add tests for cors and get coverage to 89% 2024-09-02 17:34:50 -04:00
5cf9dff13a 🎨 reformat tests to remove some duplicate lines 2024-09-02 10:50:07 -04:00
8cdc6eef44 🐛 fix broken link 2024-09-02 00:55:26 -04:00
3d69c28b09 📝 fix graphical display error on link 2024-09-02 00:54:12 -04:00
35e893e07e 📝 update readme 2024-09-02 00:52:27 -04:00
b86036455a 📈 move plausible script 2024-09-02 00:43:18 -04:00
68c0280cfc Merge pull request 'CORS!' (#1) from origins into main
Reviewed-on: #1
2024-09-02 00:39:32 -04:00
41 changed files with 1970 additions and 438 deletions

View File

@ -5,12 +5,10 @@
src="https://img.shields.io/pypi/v/spiderweb-framework.svg?style=for-the-badge" src="https://img.shields.io/pypi/v/spiderweb-framework.svg?style=for-the-badge"
alt="PyPI release version for Spiderweb" alt="PyPI release version for Spiderweb"
/> />
<a href="https://gitmoji.dev"> <img
<img
src="https://img.shields.io/badge/gitmoji-%20😜%20😍-FFDD67.svg?style=for-the-badge" src="https://img.shields.io/badge/gitmoji-%20😜%20😍-FFDD67.svg?style=for-the-badge"
alt="Gitmoji" alt="Gitmoji"
/> />
</a>
<img <img
src="https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge" src="https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge"
alt="Code style: Black" alt="Code style: Black"
@ -43,6 +41,8 @@ if __name__ == "__main__":
app.start() app.start()
``` ```
## [View the docs here!](https://itsthejoker.github.io/spiderweb/#/)
My goal with this framework was to do three things: My goal with this framework was to do three things:
1. Learn a lot 1. Learn a lot
@ -51,31 +51,22 @@ My goal with this framework was to do three things:
And, honestly, I think I got there. Here's a non-exhaustive list of things this can do: And, honestly, I think I got there. Here's a non-exhaustive list of things this can do:
* Function-based views - Function-based views
* Optional Flask-style URL routing - Optional Flask-style URL routing
* Optional Django-style URL routing - Optional Django-style URL routing
* URLs with variables in them a lá Django - URLs with variables in them a lá Django
* Gunicorn support - Full middleware implementation
* Full middleware implementation - Limit routes by HTTP verbs
* Limit routes by HTTP verbs - Custom error routes
* Custom error routes - Built-in dev server
* Built-in dev server - Gunicorn support
* HTML templates with Jinja2 - HTML templates with Jinja2
* Static files support - Static files support
* Cookies (reading and setting) - Cookies (reading and setting)
* Optional append_slash (with automatic redirects!) - Optional append_slash (with automatic redirects!)
* ~~CSRF middleware implementation~~ (it's there, but it's crappy and unsafe. I'm working on it.) - CSRF middleware
* Optional POST data validation middleware with Pydantic - CORS middleware
* Database support (using Peewee, but the end user can use whatever they want as long as there's a Peewee driver for it) - Optional POST data validation middleware with Pydantic
* Session middleware - Session middleware with built-in session store
- Database support (using Peewee, but you can use whatever you want as long as there's a Peewee driver for it)
The TODO list: - Tests (currently roughly 89% coverage)
* Tests (important)
* Fix CSRF middleware
Once tests are in and proven to work, then I'll release as version 1.0.
More documentation to follow!
If you're reading this on GitHub, this repository is a public mirror of https://git.joekaufeld.com/jkaufeld/spiderweb.

View File

@ -1,6 +1,8 @@
- [home](README.md) - [home](README.md)
- [quickstart](quickstart.md) - [quickstart](quickstart.md)
- [responses](responses.md) - [responses](responses.md)
- [routes](routes.md)
- [static files](static_files.md)
- middleware - middleware
- [overview](middleware/overview.md) - [overview](middleware/overview.md)
- [session](middleware/sessions.md) - [session](middleware/sessions.md)
@ -8,3 +10,4 @@
- [cors](middleware/cors.md) - [cors](middleware/cors.md)
- [pydantic](middleware/pydantic.md) - [pydantic](middleware/pydantic.md)
- [writing your own](middleware/custom_middleware.md) - [writing your own](middleware/custom_middleware.md)
- [databases](db.md)

View File

@ -1,3 +1,125 @@
# db # databases
... It's hard to find a server-side app without a database these days, and for good reason: there are a lot of things to keep track of. Spiderweb does its best to remain database-agnostic, though it does utilize `peewee` internally to handle its own data (such as session data). This means that you have three options for how to handle databases in your app.
## Option 1: Using Peewee
If you'd just like to use the same system that's already in place, you can import `SpiderwebModel` and get to work writing your own models for Peewee. See below for notes on writing your own database models and fitting them into the server and for changing the driver to a different type of database.
## Option 2: Using your own database ORM
You may not want to use Peewee, and that's totally fine; in that case, you will want to tell Spiderweb where the database is so that it can create the tables that it needs. To do this, you'll need to be be using a database type that Peewee supports; at this time, the options are SQLite, MySQL, MariaDB, and Postgres.
You'll want to instantiate your own ORM in the way that works best for you and let Spiderweb know where to find the database. See "Changing the Peewee Database Target" below for information on how to adjust where Spiderweb places data.
Instantiating your own ORM depends on whether your ORM can maintain an application-wide connection or if it needs a new connection on a per-request basis. For example, SQLAlchemy prefers that you use an `engine` to access the database. Since it's not clear at any given point which view will be receiving a request, this might be a good reason for some custom middleware to add an `engine` attribute onto the request that can be retrieved later:
```python
from spiderweb.middleware import SpiderwebMiddleware
from sqlalchemy import create_engine
class SQLAlchemyMiddleware(SpiderwebMiddleware):
# there's only one of these, so we can just make it a top-level attr
engine = None
def process_request(self, request) -> None:
# provide handles for the default `spiderweb.db` sqlite3 db
if not self.engine:
self.engine = create_engine("sqlite:///spiderweb.db")
request.engine = self.engine
```
Now, any view that receives the incoming request object will be able to access `request.engine` and interact with the database as needed.
> See [Writing Your Own Middleware](middleware/custom_middleware.md) for more information.
## Option 3: Using two databases
While this isn't the most delightful of options, admittedly, if your application needs to use a database that isn't something Peewee natively supports, you will want to set aside a database connection specifically for Spiderweb so that internal functions will continue to work as expected while your app uses the database you need for business logic.
## Changing the Peewee Database Target
By default, Spiderweb will create and use a SQLite db in the application directory named `spiderweb.db`. You can change this by selecting the right driver from Peewee and passing it to Spiderweb during the server instantiation, like this:
```python
from spiderweb import SpiderwebRouter
from peewee import SqliteDatabase
app = SpiderwebRouter(
db=SqliteDatabase("my_db.sqlite")
)
```
Peewee supports the following databases at this time:
- SQLite
- MySQL
- MariaDB
- Postgres
Connecting Spiderweb to Postgres would look like this:
```python
from spiderweb import SpiderwebRouter
from peewee import PostgresqlDatabase
app = SpiderwebRouter(
db = PostgresqlDatabase(
'my_app',
user='postgres',
password='secret',
host='10.1.0.9',
port=5432
)
)
```
## Writing Peewee Models
```python
from spiderweb.db import SpiderwebModel
```
If you'd like to use Peewee, then you can use the model code written for Spiderweb. There are two special powers this grants you: migration checking and automatic database assignments.
### Automatic Database Assignments
One of the odder quirks of Peewee is that you must specify what database object a model is attached to. From [the docs](https://docs.peewee-orm.com/en/latest/peewee/quickstart.html#model-definition):
```python
from peewee import *
db = SqliteDatabase('people.db')
class Person(Model):
name = CharField()
birthday = DateField()
class Meta:
database = db # This model uses the "people.db" database.
```
Spiderweb handles the database assignment for you so that your model is added to the same database that is already in use, regardless of driver:
```python
from spiderweb.db import SpiderwebModel
from peewee import *
class Person(SpiderwebModel):
name = CharField()
birthday = DateField()
```
### Migration Checking
Spiderweb also watches your model and raises an error if the state of the database and your model schema differ. This check attempts to be as thorough as possible, but may not be appropriate for you; if that's the case, then add the magic variable `skip_migration_check` to the `Meta` class for your model. For example:
```python
class Person(SpiderwebModel):
name = CharField()
birthday = DateField()
class Meta:
skip_migration_check = True
```

View File

@ -2,12 +2,18 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Document</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
<meta name="description" content="Description"> <meta name="description" content="Spiderweb: the web framework just big enough for a spider.">
<title>Spiderweb</title>
<meta property="og:title" content="Spiderweb" />
<meta property="og:description" content="Spiderweb: the web framework just big enough for a spider." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://github.com/itsthejoker/spiderweb" />
<meta property="og:image" content="https://github.com/itsthejoker/spiderweb/blob/main/docs/_media/CMSHub-500x500.jpeg?raw=true" />
<link rel="icon" type="image/png" sizes="32x32" href="/_media/Favicon-32x32.png"> <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"> <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"> <link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
<script defer data-domain="itsthejoker.github.io/spiderweb" src="https://plausible.io/js/script.hash.js"></script>
</head> </head>
<body> <body>
<style> <style>
@ -63,6 +69,5 @@
<!-- click to copy in code blocks --> <!-- click to copy in code blocks -->
<script src="//cdn.jsdelivr.net/npm/docsify-copy-code/dist/docsify-copy-code.min.js"></script> <script src="//cdn.jsdelivr.net/npm/docsify-copy-code/dist/docsify-copy-code.min.js"></script>
<script src="https://kit.fontawesome.com/940400877f.js" crossorigin="anonymous"></script> <script src="https://kit.fontawesome.com/940400877f.js" crossorigin="anonymous"></script>
<script defer data-domain="itsthejoker.github.io/spiderweb" src="https://plausible.io/js/script.js"></script>
</body> </body>
</html> </html>

View File

@ -1,3 +1,5 @@
from spiderweb import HttpResponse
# writing your own middleware # writing your own middleware
Sometimes you want to run the same code on every request or every response (or both!). Lots of processing happens in the middleware layer, and if you want to write your own, all you have to do is write a quick class and put it in a place that Spiderweb can find it. A piece of middleware only needs two things to be successful: Sometimes you want to run the same code on every request or every response (or both!). Lots of processing happens in the middleware layer, and if you want to write your own, all you have to do is write a quick class and put it in a place that Spiderweb can find it. A piece of middleware only needs two things to be successful:
@ -26,7 +28,7 @@ class TestMiddleware(SpiderwebMiddleware):
Middleware is run twice: once for the incoming request and once for the outgoing response. You only need to include whichever function is required for the functionality you need. Middleware is run twice: once for the incoming request and once for the outgoing response. You only need to include whichever function is required for the functionality you need.
## process_request(self, request): ## process_request(self, request: Request) -> Optional[HttpResponse]:
`process_request` is called before the view is reached in the execution order. You will receive the assembled Request object, and any middleware declared above this one will have already run. Because the request is the single instantiation of a class, you can modify it in-place without returning anything and your changes will stick. `process_request` is called before the view is reached in the execution order. You will receive the assembled Request object, and any middleware declared above this one will have already run. Because the request is the single instantiation of a class, you can modify it in-place without returning anything and your changes will stick.
@ -45,15 +47,74 @@ class JohnMiddleware(SpiderwebMiddleware):
In this case, if the user John tries to access any route that starts with "/admin", he'll immediately get denied and the view will never be called. If the request does not have a user attached to it (or the user is not John), then the middleware will return None and Spiderweb will continue processing. In this case, if the user John tries to access any route that starts with "/admin", he'll immediately get denied and the view will never be called. If the request does not have a user attached to it (or the user is not John), then the middleware will return None and Spiderweb will continue processing.
## process_response(self, request, response): ## process_response(self, request: Request, response: HttpResponse) -> None:
This function is called after the view has run and returned a response. You will receive the request object and the response object; like with the request object, the response is also a single instantiation of a class, so any changes you make will stick automatically. This function is called after the view has run and returned a response. You will receive the request object and the response object; like with the request object, the response is also a single instantiation of a class, so any changes you make will stick automatically.
Unlike `process_request`, returning a value here doesn't change anything. We're already processing a request, and there are opportunities to turn away requests / change the response at both the `process_request` layer and the view layer, so Spiderweb assumes that whatever it is working on here is what you mean to return to the user. The response object that you receive in the middleware is still prerendered, so any changes you make to it will take effect after it finishes the middleware and renders the response. Unlike `process_request`, returning a value here doesn't change anything. We're already processing a request, and there are opportunities to turn away requests / change the response at both the `process_request` layer and the view layer, so Spiderweb assumes that whatever it is working on here is what you mean to return to the user. The response object that you receive in the middleware is still prerendered, so any changes you make to it will take effect after it finishes the middleware and renders the response.
## on_error(self, request, triggered_exception): ## on_error(self, request: Request, triggered_exception: Exception) -> Optional[HttpResponse]:
This is a helper function that is available for you to override; it's not often used by middleware, but there are some ([like the pydantic middleware](pydantic.md)) that call `on_error` when there is a validation failure. This is a helper function that is available for you to override; it's not often used by middleware, but there are some ([like the pydantic middleware](middleware/pydantic.md)) that call `on_error` when there is a validation failure.
## post_process(self, request: Request, response: HttpResponse, rendered_response: str) -> str:
> New in 1.3.0!
After `process_request` and `process_response` run, the response is rendered out into the raw text that is going to be sent to the client. Right before that happens, `post_process` is called on each middleware in the same order as `process_response` (so the closer something is to the beginning of the middleware list, the more important it is).
There are three things passed to `post_process`:
- `request`: the request object. It's provided here purely for reference purposes; while you can technically change it here, it won't have any effect on the response.
- `response`: the response object. The full HTML of the response has already been rendered, but the headers can still be modified here. This object can be modified in place, like in `process_response`.
- `rendered_response`: the full HTML of the response as a string. This is the final output that will be sent to the client. Every instance of `post_process` must return the full HTML of the response, so if you want to make changes, you'll need to return the modified string.
Note that this function *must* return the full HTML of the response (provided at the start as `rendered_response`. Each invocation of `post_process` overwrites the entire output of the response, so make sure to return everything that you want to send. For example, here's a middleware that ~~breaks~~ adjusts the capitalization of the response and also demonstrates passing variables into the middleware and modifies the headers with the type of transformation:
```python
import random
from spiderweb.request import Request
from spiderweb.middleware import SpiderwebMiddleware
from spiderweb.exceptions import ConfigError
class CaseTransformMiddleware(SpiderwebMiddleware):
# this breaks everything, but it's hilarious so it's worth it.
# Blame Sam.
def post_process(self, request: Request, response: HttpResponse, rendered_response: str) -> str:
valid_options = ["spongebob", "random"]
# grab the value from the extra data passed into the server object
# during instantiation
method = self.server.extra_data.get("case_transform_middleware_type", "spongebob")
if method not in valid_options:
raise ConfigError(
f"Invalid method '{method}' for CaseTransformMiddleware."
f" Valid options are {', '.join(valid_options)}"
)
if method == "spongebob":
response.headers["X-Case-Transform"] = "spongebob"
return "".join(
char.upper()
if i % 2 == 0
else char.lower() for i, char in enumerate(rendered_response)
)
else:
response.headers["X-Case-Transform"] = "random"
return "".join(
char.upper()
if random.random() > 0.5
else char for char in rendered_response
)
# usage:
app = SpiderwebRouter(
middleware=["CaseTransformMiddleware"],
case_transform_middleware_type="random",
)
```
## checks ## checks
@ -109,4 +170,4 @@ List as many checks as you need there, and the server will run all of them durin
from spiderweb.exceptions import UnusedMiddleware from spiderweb.exceptions import UnusedMiddleware
``` ```
If you don't want your middleware to run for some reason, either `process_request` or `process_response` can raise the UnusedMiddleware exception. If this happens, Spiderweb will kick your middleware out of the processing order for the rest of the life of the server. Note that this applies to the middleware as a whole, so both functions will not be run if an UnusedMiddleware is raised. This is a great way to mark debug middleware that shouldn't run or create time-delay middleware that runs until a certain condition is met! If you don't want your middleware to run for some reason, `process_request`, `process_response` and `post_process` can all raise the UnusedMiddleware exception. If this happens, Spiderweb will kick your middleware out of the processing order for the rest of the life of the server. Note that this applies to the middleware as a whole, so all functions in the middleware will not be run if an UnusedMiddleware is raised. This is a great way to mark debug middleware that shouldn't run or create time-delay middleware that runs until a certain condition is met!

View File

@ -0,0 +1,199 @@
# routes
To have logic that your application can use, you must be able to route incoming requests from The Outside:tm: and properly get them to your code and back. There are three different ways to set up this up depending on what works best for your application, and all three can be used together (though this is probably a bad idea).
## `route()` Decorator
In this pattern, you'll create your server at the top of the file and assign it to an object (usually called `app`). Once it's created, you'll be able to use the `@app.route` decorator to assign routes. This is the pattern used by the quickstart app:
```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()
```
The `@app.route()` decorator takes two arguments — one required, one optional. The first is the path that your view will be found under; all paths start from the server root (`"/"`). The second is `allowed_methods`, which allows you to limit (or expand) the HTTP methods used for calling your view. For example, you may want to specify that a form view takes in `GET` and `POST` requests:
```python
@app.route("/myform", allowed_methods=["GET", "POST"])
def form_view(request):
...
```
If `allowed_methods` isn't passed in, the defaults (`["POST", "GET", "PUT", "PATCH", "DELETE"]`) will be used.
The decorator pattern is recommended simply because it's familiar to many, and for small apps, it's hard to beat the simplicity.
## After Instantiation
Some folks prefer to manually assign routes after the server has been instantiated, perhaps because the route isn't actually determined until runtime. To do this, the built server object has a function you can use:
```python
from spiderweb import SpiderwebRouter
from spiderweb.response import HttpResponse
app = SpiderwebRouter()
def index(request):
return HttpResponse("HELLO, WORLD!")
if __name__ == "__main__":
# shown here with the optional `allowed_methods` arg
app.add_route("/", index, allowed_methods=["GET"])
app.start()
```
The `allowed_methods` argument, like with the `.route()` decorator, is optional. If it's not passed, the defaults will be used instead.
## During Instantiation
The third and final way that you can assign routes is in a single block more akin to how Django handles it. This allows you to curate large numbers of routes and pass them all in at the same time with little fuss. Though it may be a little contrived here, you can see how this works in the following example:
```python
from spiderweb import SpiderwebRouter
from spiderweb.response import HttpResponse
def index(request):
return HttpResponse("HELLO, WORLD!")
if __name__ == "__main__":
app = SpiderwebRouter(
routes=[
("/", index, {"allowed_methods": ["GET", "POST"]})
]
)
app.start()
```
To declare routes during instantiation, pass in a list of tuples, where each tuple consists of three things:
```
routes = [
(path: str, function: Callable, args: dict),
...
]
```
The only two args that can be passed here are `allowed_methods` as discussed above and `csrf_exempt`, where the value is a boolean (presumably `True`, as it defaults to `False`). For example, if you had a view that took unverified `POST` requests, you might set it up like this:
```python
routes = [
("/submit", submit_view, {"allowed_methods": ["POST"], "csrf_exempt": True})
]
```
Note that passing in `csrf_exempt` is not listed on the other two methods, mostly because it doesn't really make sense for the other methods. Instead, they use a decorator to handle it, which can be found in [the docs for CSRF protection.](middleware/csrf.md?id=marking-views-as-csrf-exempt) You can also use the decorator in the same way for routes assigned in this manner, but when you have a large number of routes, being able to see all the attributes in one place is helpful.
## Passing Data Through Routes
Some views need to be able to take arguments via the URL path, so Spiderweb provides that ability for you. The syntax used is identical to Django's: `/routename/<str:argname>`. In this case, it will slice out that part of the URL, cast it to a string, and pass it as a variable named `argname` to your view. Here's what that looks like in practice:
```python
@app.route("/example/<int:id>")
def example(request, id): # <- note the additional arg!
return HttpResponse(body=f"Example with id {id}")
```
You can pass integers, strings, and positive floats with the following types:
- str
- int
- float
- path (see below)
A URL can also have multiple capture groups:
```python
@app.route("/example/<int:id>/<str:name>")
def example(request, id, name):
return HttpResponse(body=f"Example with id {id} and name {name}")
```
In this case, a valid URL might be `/example/3/james`, and both sections will be split out and passed to the view.
The `path` option is special; this is used when you want to capture everything after the slash. For example:
> New in 1.2.0!
```python
@app.route("/example/<path:rest>")
def example(request, rest):
return HttpResponse(body=f"Example with {rest}")
```
It will come in as a string, but it will include all the slashes and other characters that are in the URL.
## Adding Error Views
For some apps, you may want to have your own error views that are themed to your particular application. For this, there's a slightly different process, but the gist is the same. There are also three ways to handle error views, all very similar to adding regular views.
An error view has three pieces: the request, the response, and what error code triggers it. The easiest way to do this is with the decorator.
### `.error` Decorator
```python
@app.error(405)
def http405(request) -> HttpResponse:
return HttpResponse(body="Method not allowed", status_code=405)
```
Note that this is just a basic view with nothing really special about it; the only real difference is that the `.error()` decorator highlights the specific error that will trigger this view. If the server, at any point, hits this error value, it will retrieve this view and return it instead of the requested view.
### After Instantiation
Similar to the regular views, error views can also be added programmatically at runtime like this:
```python
def http405(request) -> HttpResponse:
return HttpResponse(body="Method not allowed", status_code=405)
app.add_error_route(405, http405)
```
No other attributes or arguments are available.
### During Instantiation
For those with larger numbers of routes, it may make more sense to declare them when the server object is built. For example:
```python
app = SpiderwebRouter(
error_routes={405: http405},
)
```
As with the `routes` argument, as many routes as you'd like can be registered here without issue.
## Finding Routes Again
> New in 1.1.0!
If you need to find the path that's associated with a route (for example, for a RedirectResponse), you can use the `app.reverse()` function to find it. This function takes the name of the view and returns the path that it's associated with. For example:
```python
@app.route("/example", name="example")
def example(request):
return HttpResponse(body="Example")
path = app.reverse("example")
print(path) # -> "/example"
```
If you have a route that takes arguments, you can pass them in as a dictionary:
```python
@app.route("/example/<int:obj_id>", name="example")
def example(request, obj_id):
return HttpResponse(body=f"Example with id {obj_id}")
path = app.reverse("example", {'obj_id': 3})
print(path) # -> "/example/3"
```
You can also provide a dictionary of query parameters to be added to the URL:
```python
path = app.reverse("example", {'obj_id': 3}, query={'name': 'james'})
print(path) # -> "/example/3?name=james"
```
The arguments you pass in must match what the path expects, or you'll get a `SpiderwebException`. If there's no route with that name, you'll get a `ReverseNotFound` exception instead.

69
docs/static_files.md Normal file
View File

@ -0,0 +1,69 @@
# serving static files for local development
When you're developing locally, it's often useful to be able to serve static files directly from your application, especially when you're working on the frontend. Spiderweb does have a mechanism for serving static files, but it's _not recommended_ (read: this is a Very Bad Idea) for production use. Instead, you should use a reverse proxy like nginx or Apache to serve them.
To serve static files locally, you'll need to tell Spiderweb where they are. Once you fill this out, Spiderweb will automatically handle the routing to find them.
Before we get started:
> [!DANGER]
> Having Spiderweb handle your static files in production is a **critical safety issue**. It does its best to identify if a request is malicious, but it is much safer to have this be handled by a reverse proxy.
```python
from spiderweb import SpiderwebRouter
app = SpiderwebRouter(
staticfiles_dirs=[
"my_static_files",
"maybe_other_static_files_here"
],
debug=True,
static_url="assets",
)
```
> [!NOTE]
> Note the `debug` attribute in the example above; even if `staticfiles_dirs` is set, Spiderweb will only serve the files if `debug` is set to `True`. This is a safety check for you and an easy toggle for deployment.
## `staticfiles_dirs`
> default: `[]`
This is a list of directories that Spiderweb will look in for static files. When a request comes in for a static file, Spiderweb will look in each of these directories in order to find the file. If it doesn't find the file in any of the directories, it will return a 404.
## `static_url`
> default: `static`
This is the URL that Spiderweb will use to serve static files. In the example above, the URL would be `http://localhost:8000/assets/`. If you don't set this, Spiderweb will default to `/static/`.
## `debug`
> New in 1.2.0!
> default: `False`
This is a boolean that tells Spiderweb whether it is running in debug mode or not. Among other things, it's used in serving static files. If this value is not included, it defaults to False, and Spiderweb will not serve static files. For local development, you will want to set it to True.
## Linking to static files
> New in 1.2.0!
There is a tag in the templates that you can use to link to static files. This tag will automatically generate the correct URL for the file based on the `static_url` attribute you set in the router.
```html
<img
src="{% static 'hello_world.gif' %}"
alt="A rotating globe with the caption, 'hello world'."
>
```
In this example, the `static` tag will generate a URL that looks like `/assets/hello_world.gif`. This is the URL that the browser will use to request the file. If you have a file that is in a folder, you can specify that in the tag:
```html
<img
src="{% static 'gifs/landing/hello_world.gif' %}"
alt="A rotating globe with the caption, 'hello world'."
>
```
This will pull the gif from `{your static folder}/gifs/landing/hello_world.gif`.

View File

@ -22,9 +22,14 @@ app = SpiderwebRouter(
"example_middleware.RedirectMiddleware", "example_middleware.RedirectMiddleware",
"spiderweb.middleware.pydantic.PydanticMiddleware", "spiderweb.middleware.pydantic.PydanticMiddleware",
"example_middleware.ExplodingMiddleware", "example_middleware.ExplodingMiddleware",
# "example_middleware.CaseTransformMiddleware",
], ],
staticfiles_dirs=["static_files"], staticfiles_dirs=["static_files"],
append_slash=False, # default append_slash=False, # default
cors_allow_all_origins=True,
static_url="static_stuff",
debug=True,
case_transform_middleware_type="spongebob",
) )
@ -33,6 +38,11 @@ def index(request):
return TemplateResponse(request, "test.html", context={"value": "TEST!"}) return TemplateResponse(request, "test.html", context={"value": "TEST!"})
@app.route("/example/<int:id>/<str:name>")
def example_with_multiple_values(request, id, name):
return HttpResponse(body=f"Example with id {id} and name {name}")
@app.route("/redirect") @app.route("/redirect")
def redirect(request): def redirect(request):
return RedirectResponse("/") return RedirectResponse("/")

View File

@ -1,3 +1,6 @@
import random
from spiderweb import ConfigError
from spiderweb.exceptions import UnusedMiddleware from spiderweb.exceptions import UnusedMiddleware
from spiderweb.middleware import SpiderwebMiddleware from spiderweb.middleware import SpiderwebMiddleware
from spiderweb.request import Request from spiderweb.request import Request
@ -24,3 +27,35 @@ class RedirectMiddleware(SpiderwebMiddleware):
class ExplodingMiddleware(SpiderwebMiddleware): class ExplodingMiddleware(SpiderwebMiddleware):
def process_request(self, request: Request) -> HttpResponse | None: def process_request(self, request: Request) -> HttpResponse | None:
raise UnusedMiddleware("Unfinished!") raise UnusedMiddleware("Unfinished!")
class CaseTransformMiddleware(SpiderwebMiddleware):
# this breaks everything, but it's hilarious so it's worth it.
# Blame Sam.
def post_process(
self, request: Request, response: HttpResponse, rendered_response: str
) -> str:
valid_options = ["spongebob", "random"]
# grab the value from the extra data passed into the server object
# during instantiation
method = self.server.extra_data.get(
"case_transform_middleware_type", "spongebob"
)
if method not in valid_options:
raise ConfigError(
f"Invalid method '{method}' for CaseTransformMiddleware."
f" Valid options are {', '.join(valid_options)}"
)
if method == "spongebob":
response.headers["X-Case-Transform"] = "spongebob"
return "".join(
char.upper() if i % 2 == 0 else char.lower()
for i, char in enumerate(rendered_response)
)
else:
response.headers["X-Case-Transform"] = "random"
return "".join(
char.upper() if random.random() > 0.5 else char
for char in rendered_response
)

422
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
@ -76,78 +76,78 @@ uvloop = ["uvloop (>=0.15.2)"]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "1.17.0" version = "1.17.1"
description = "Foreign Function Interface for Python calling C code." description = "Foreign Function Interface for Python calling C code."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"}, {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"}, {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"}, {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"}, {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
{file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"}, {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
{file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"}, {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
{file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"}, {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
{file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"}, {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"}, {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"}, {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"}, {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
{file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"}, {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
{file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"}, {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
{file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"}, {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
{file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"}, {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
{file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"}, {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
{file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"}, {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
{file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"}, {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
{file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"}, {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
{file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"}, {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
{file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"}, {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
{file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"}, {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
{file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"}, {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
{file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"}, {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
{file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"}, {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
{file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"}, {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
{file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"}, {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
{file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"}, {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
{file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"}, {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
{file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"}, {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"}, {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"}, {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"}, {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
{file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"}, {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
{file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"}, {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
] ]
[package.dependencies] [package.dependencies]
@ -264,38 +264,38 @@ toml = ["tomli"]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "43.0.0" version = "43.0.1"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"},
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"},
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"},
{file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"},
{file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"},
{file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"},
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"},
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"},
{file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"},
{file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"},
{file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"},
] ]
[package.dependencies] [package.dependencies]
@ -308,7 +308,7 @@ nox = ["nox"]
pep8test = ["check-sdist", "click", "mypy", "ruff"] pep8test = ["check-sdist", "click", "mypy", "ruff"]
sdist = ["build"] sdist = ["build"]
ssh = ["bcrypt (>=3.1.5)"] ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"] test-randomorder = ["pytest-randomly"]
[[package]] [[package]]
@ -369,13 +369,13 @@ tornado = ["tornado (>=0.2)"]
[[package]] [[package]]
name = "hypothesis" name = "hypothesis"
version = "6.111.2" version = "6.112.1"
description = "A library for property-based testing" description = "A library for property-based testing"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "hypothesis-6.111.2-py3-none-any.whl", hash = "sha256:055e8228958e22178d6077e455fd86a72044d02dac130dbf9c8b31e161b9809c"}, {file = "hypothesis-6.112.1-py3-none-any.whl", hash = "sha256:93631b1498b20d2c205ed304cbd41d50e9c069d78a9c773c1324ca094c5e30ce"},
{file = "hypothesis-6.111.2.tar.gz", hash = "sha256:0496ad28c7240ee9ba89fcc7fb1dc74e89f3e40fbcbbb5f73c0091558dec8e6e"}, {file = "hypothesis-6.112.1.tar.gz", hash = "sha256:b070d7a1bb9bd84706c31885c9aeddc138e2b36a9c112a91984f49501c567856"},
] ]
[package.dependencies] [package.dependencies]
@ -401,15 +401,18 @@ zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2024.1)"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.7" version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.6"
files = [ files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
] ]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.0.0" version = "2.0.0"
@ -552,19 +555,19 @@ files = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.2.2" version = "4.3.6"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
] ]
[package.extras] [package.extras]
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.8)"] type = ["mypy (>=1.11.2)"]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
@ -594,18 +597,18 @@ files = [
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.8.2" version = "2.9.2"
description = "Data validation using Python type hints" description = "Data validation using Python type hints"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"},
{file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"},
] ]
[package.dependencies] [package.dependencies]
annotated-types = ">=0.4.0" annotated-types = ">=0.6.0"
pydantic-core = "2.20.1" pydantic-core = "2.23.4"
typing-extensions = [ typing-extensions = [
{version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.12.2", markers = "python_version >= \"3.13\""},
{version = ">=4.6.1", markers = "python_version < \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""},
@ -613,103 +616,104 @@ typing-extensions = [
[package.extras] [package.extras]
email = ["email-validator (>=2.0.0)"] email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata"]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.20.1" version = "2.23.4"
description = "Core functionality for Pydantic validation and serialization" description = "Core functionality for Pydantic validation and serialization"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"},
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"},
{file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"},
{file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"},
{file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"},
{file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"},
{file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"},
{file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"},
{file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"},
{file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"},
{file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"},
{file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"},
{file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"},
{file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"},
{file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"},
] ]
[package.dependencies] [package.dependencies]
@ -717,13 +721,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.3.2" version = "8.3.3"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
] ]
[package.dependencies] [package.dependencies]

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "spiderweb-framework" name = "spiderweb-framework"
version = "0.12.0" version = "1.3.1"
description = "A small web framework, just big enough for a spider." description = "A small web framework, just big enough for a spider."
authors = ["Joe Kaufeld <opensource@joekaufeld.com>"] authors = ["Joe Kaufeld <opensource@joekaufeld.com>"]
readme = "README.md" readme = "README.md"
@ -62,7 +62,7 @@ addopts = ["--maxfail=2", "-rf"]
[tool.coverage.run] [tool.coverage.run]
branch = true branch = true
omit = ["conftest.py"] omit = ["conftest.py", "spiderweb/tests/*"]
[tool.coverage.report] [tool.coverage.report]
# Regexes for lines to exclude from consideration # Regexes for lines to exclude from consideration
@ -81,6 +81,8 @@ exclude_also = [
# Don't complain about abstract methods, they aren't run: # Don't complain about abstract methods, they aren't run:
"@(abc\\.)?abstractmethod", "@(abc\\.)?abstractmethod",
] # Type checking lines are never run:
"if TYPE_CHECKING:",
]
ignore_errors = true ignore_errors = true

View File

@ -1,8 +1,8 @@
from peewee import DatabaseProxy from peewee import DatabaseProxy
DEFAULT_ALLOWED_METHODS = ["GET"] DEFAULT_ALLOWED_METHODS = ["POST", "GET", "PUT", "PATCH", "DELETE"]
DEFAULT_ENCODING = "UTF-8" DEFAULT_ENCODING = "UTF-8"
__version__ = "0.12.0" __version__ = "1.3.1"
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
REGEX_COOKIE_NAME = r"^[a-zA-Z0-9\s\(\)<>@,;:\/\\\[\]\?=\{\}\"\t]*$" REGEX_COOKIE_NAME = r"^[a-zA-Z0-9\s\(\)<>@,;:\/\\\[\]\?=\{\}\"\t]*$"

View File

@ -20,3 +20,11 @@ class FloatConverter:
def to_python(self, value): def to_python(self, value):
return float(value) return float(value)
class PathConverter:
regex = r".+"
name = "path"
def to_python(self, value):
return str(value)

View File

@ -1,4 +1,4 @@
from peewee import Model, Field, SchemaManager, DatabaseProxy from peewee import Model, Field, SchemaManager
from spiderweb.constants import DATABASE_PROXY from spiderweb.constants import DATABASE_PROXY
@ -13,6 +13,9 @@ class SpiderwebModel(Model):
@classmethod @classmethod
def check_for_needed_migration(cls): def check_for_needed_migration(cls):
if hasattr(cls._meta, "skip_migration_check"):
return
current_model_fields: dict[str, Field] = cls._meta.fields current_model_fields: dict[str, Field] = cls._meta.fields
current_db_fields = { current_db_fields = {
c.name: { c.name: {
@ -64,7 +67,7 @@ class SpiderwebModel(Model):
) )
) )
if field_obj.__class__.__name__ == "BooleanField": if field_obj.__class__.__name__ == "BooleanField":
if field_obj.default == False and db_version["default"] not in ( if field_obj.default is False and db_version["default"] not in (
False, False,
None, None,
0, 0,
@ -74,7 +77,7 @@ class SpiderwebModel(Model):
f"BooleanField `{field_name}` has changed the default value." f"BooleanField `{field_name}` has changed the default value."
) )
) )
elif field_obj.default == True and db_version["default"] not in ( elif field_obj.default is True and db_version["default"] not in (
True, True,
1, 1,
): ):

View File

@ -90,3 +90,7 @@ class NoResponseError(SpiderwebException):
class StartupErrors(ExceptionGroup): class StartupErrors(ExceptionGroup):
pass pass
class ReverseNotFound(SpiderwebException):
pass

16
spiderweb/jinja_core.py Normal file
View File

@ -0,0 +1,16 @@
from typing import TYPE_CHECKING
from jinja2 import Environment
if TYPE_CHECKING:
from spiderweb import SpiderwebRouter
class SpiderwebEnvironment(Environment):
# Contains all the normal abilities of the Jinja environment, but with a link
# back to the server for easy access to settings and other server-related
# information.
def __init__(self, server=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.server: "SpiderwebRouter" = server

View File

@ -0,0 +1,19 @@
import posixpath
from jinja2 import nodes
from jinja2.ext import Extension
class StaticFilesExtension(Extension):
# Take things that look like `{% static "file" %}` and replace them with `/static/file`
tags = {"static"}
def parse(self, parser):
token = next(parser.stream)
args = [parser.parse_expression()]
return nodes.Output([self.call_method("_static", args)]).set_lineno(
token.lineno
)
def _static(self, file):
return posixpath.join(f"/{self.environment.server.static_url}", file)

View File

@ -36,7 +36,7 @@ class LocalServerMixin:
def start(self, blocking=False): def start(self, blocking=False):
signal.signal(signal.SIGINT, self.signal_handler) signal.signal(signal.SIGINT, self.signal_handler)
self.log.info(f"Starting server on {self.addr}:{self.port}") self.log.info(f"Starting server on http://{self.addr}:{self.port}")
self.log.info("Press CTRL+C to stop the server.") self.log.info("Press CTRL+C to stop the server.")
self._server = self.create_server() self._server = self.create_server()
self._thread = threading.Thread(target=self._server.serve_forever) self._thread = threading.Thread(target=self._server.serve_forever)

View File

@ -6,10 +6,10 @@ import traceback
import urllib.parse as urlparse import urllib.parse as urlparse
from logging import Logger from logging import Logger
from threading import Thread from threading import Thread
from typing import Optional, Callable, Sequence, LiteralString, Literal from typing import Optional, Callable, Sequence, Literal
from wsgiref.simple_server import WSGIServer from wsgiref.simple_server import WSGIServer
from jinja2 import BaseLoader, Environment, FileSystemLoader from jinja2 import BaseLoader, FileSystemLoader
from peewee import Database, SqliteDatabase from peewee import Database, SqliteDatabase
from spiderweb.middleware import MiddlewareMixin from spiderweb.middleware import MiddlewareMixin
@ -23,7 +23,13 @@ from spiderweb.constants import (
DEFAULT_ALLOWED_METHODS, DEFAULT_ALLOWED_METHODS,
) )
from spiderweb.db import SpiderwebModel from spiderweb.db import SpiderwebModel
from spiderweb.default_views import * # noqa: F403 from spiderweb.default_views import (
http403, # noqa: F401
http404, # noqa: F401
http405, # noqa: F401
http500, # noqa: F401
send_file,
)
from spiderweb.exceptions import ( from spiderweb.exceptions import (
ConfigError, ConfigError,
NotFound, NotFound,
@ -31,6 +37,7 @@ from spiderweb.exceptions import (
NoResponseError, NoResponseError,
SpiderwebNetworkException, SpiderwebNetworkException,
) )
from spiderweb.jinja_core import SpiderwebEnvironment
from spiderweb.local_server import LocalServerMixin from spiderweb.local_server import LocalServerMixin
from spiderweb.request import Request from spiderweb.request import Request
from spiderweb.response import HttpResponse, TemplateResponse, JsonResponse from spiderweb.response import HttpResponse, TemplateResponse, JsonResponse
@ -50,7 +57,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
port: int = None, port: int = None,
allowed_hosts: Sequence[str | re.Pattern] = None, allowed_hosts: Sequence[str | re.Pattern] = None,
cors_allowed_origins: Sequence[str] = None, cors_allowed_origins: Sequence[str] = None,
cors_allowed_origins_regexes: Sequence[str] = None, cors_allowed_origin_regexes: Sequence[str] = None,
cors_allow_all_origins: bool = False, cors_allow_all_origins: bool = False,
cors_urls_regex: str | re.Pattern[str] = r"^.*$", cors_urls_regex: str | re.Pattern[str] = r"^.*$",
cors_allow_methods: Sequence[str] = None, cors_allow_methods: Sequence[str] = None,
@ -61,10 +68,12 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
cors_allow_private_network: bool = False, cors_allow_private_network: bool = False,
csrf_trusted_origins: Sequence[str] = None, csrf_trusted_origins: Sequence[str] = None,
db: Optional[Database] = None, db: Optional[Database] = None,
debug: bool = False,
templates_dirs: Sequence[str] = None, templates_dirs: Sequence[str] = None,
middleware: Sequence[str] = None, middleware: Sequence[str] = None,
append_slash: bool = False, append_slash: bool = False,
staticfiles_dirs: Sequence[str] = None, staticfiles_dirs: Sequence[str] = None,
static_url: str = "static",
routes: Sequence[tuple[str, Callable] | tuple[str, Callable, dict]] = None, routes: Sequence[tuple[str, Callable] | tuple[str, Callable, dict]] = None,
error_routes: dict[int, Callable] = None, error_routes: dict[int, Callable] = None,
secret_key: str = None, secret_key: str = None,
@ -87,6 +96,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
self.append_slash = append_slash self.append_slash = append_slash
self.templates_dirs = templates_dirs self.templates_dirs = templates_dirs
self.staticfiles_dirs = staticfiles_dirs self.staticfiles_dirs = staticfiles_dirs
self.static_url = static_url
self._middleware: list[str] = middleware or [] self._middleware: list[str] = middleware or []
self.middleware: list[Callable] = [] self.middleware: list[Callable] = []
self.secret_key = secret_key if secret_key else self.generate_key() self.secret_key = secret_key if secret_key else self.generate_key()
@ -94,7 +104,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
self.allowed_hosts = [convert_url_to_regex(i) for i in self._allowed_hosts] self.allowed_hosts = [convert_url_to_regex(i) for i in self._allowed_hosts]
self.cors_allowed_origins = cors_allowed_origins or [] self.cors_allowed_origins = cors_allowed_origins or []
self.cors_allowed_origins_regexes = cors_allowed_origins_regexes or [] self.cors_allowed_origin_regexes = cors_allowed_origin_regexes or []
self.cors_allow_all_origins = cors_allow_all_origins self.cors_allow_all_origins = cors_allow_all_origins
self.cors_urls_regex = cors_urls_regex self.cors_urls_regex = cors_urls_regex
self.cors_allow_methods = cors_allow_methods or DEFAULT_CORS_ALLOW_METHODS self.cors_allow_methods = cors_allow_methods or DEFAULT_CORS_ALLOW_METHODS
@ -109,6 +119,8 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
convert_url_to_regex(i) for i in self._csrf_trusted_origins convert_url_to_regex(i) for i in self._csrf_trusted_origins
] ]
self.debug = debug
self.extra_data = kwargs self.extra_data = kwargs
# session middleware # session middleware
@ -144,15 +156,27 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
if self.error_routes: if self.error_routes:
self.add_error_routes() self.add_error_routes()
template_env_args = {
"server": self,
"extensions": [
"spiderweb.jinja_extensions.StaticFilesExtension",
],
}
if self.templates_dirs: if self.templates_dirs:
self.template_loader = Environment( self.template_loader = SpiderwebEnvironment(
loader=FileSystemLoader(self.templates_dirs) loader=FileSystemLoader(self.templates_dirs),
**template_env_args,
) )
else: else:
self.template_loader = None self.template_loader = None
self.string_loader = Environment(loader=BaseLoader()) self.string_loader = SpiderwebEnvironment(
loader=BaseLoader(), **template_env_args
)
if self.staticfiles_dirs: if self.staticfiles_dirs:
if not isinstance(self.staticfiles_dirs, list):
self.staticfiles_dirs = [self.staticfiles_dirs]
for static_dir in self.staticfiles_dirs: for static_dir in self.staticfiles_dirs:
static_dir = pathlib.Path(static_dir) static_dir = pathlib.Path(static_dir)
if not pathlib.Path(self.BASE_DIR / static_dir).exists(): if not pathlib.Path(self.BASE_DIR / static_dir).exists():
@ -160,7 +184,16 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
f"Static files directory '{str(static_dir)}' does not exist." f"Static files directory '{str(static_dir)}' does not exist."
) )
raise ConfigError raise ConfigError
self.add_route(r"/static/<str:filename>", send_file) # noqa: F405 if self.debug:
# We don't need a log message here because this is the expected behavior
self.add_route(
rf"/{self.static_url}/<path:filename>", send_file
) # noqa: F405
else:
self.log.warning(
"`staticfiles_dirs` is set, but `debug` is set to FALSE. Static"
" files will not be served."
)
# finally, run the startup checks to verify everything is correct and happy. # finally, run the startup checks to verify everything is correct and happy.
self.log.info("Run startup checks...") self.log.info("Run startup checks...")
@ -168,31 +201,41 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
def fire_response(self, start_response, request: Request, resp: HttpResponse): def fire_response(self, start_response, request: Request, resp: HttpResponse):
try: try:
try:
rendered_output: str = resp.render()
final_output: str | list[str] = self.post_process_middleware(
request, resp, rendered_output
)
except Exception as e:
self.log.error("Fatal error!")
self.log.error(e)
self.log.error(traceback.format_exc())
return [f"Internal Server Error: {e}".encode(DEFAULT_ENCODING)]
status = get_http_status_by_code(resp.status_code) status = get_http_status_by_code(resp.status_code)
cookies = [] cookies = []
varies = [] varies = []
resp.headers = {k.replace("_", "-"): v for k, v in resp.headers.items()}
if "set-cookie" in resp.headers: if "set-cookie" in resp.headers:
cookies = resp.headers["set-cookie"] cookies = resp.headers["set-cookie"]
del resp.headers["set-cookie"] del resp.headers["set-cookie"]
if "vary" in resp.headers: if "vary" in resp.headers:
varies = resp.headers["vary"] varies = resp.headers["vary"]
del resp.headers["vary"] del resp.headers["vary"]
resp.headers = {k: str(v) for k, v in resp.headers.items()}
headers = list(resp.headers.items()) headers = list(resp.headers.items())
for c in cookies: for c in cookies:
headers.append(("Set-Cookie", c)) headers.append(("set-cookie", str(c)))
for v in varies: for v in varies:
headers.append(("Vary", v)) headers.append(("vary", str(v)))
start_response(status, headers) if not isinstance(final_output, list):
final_output = [final_output]
rendered_output = resp.render()
if not isinstance(rendered_output, list):
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 final_output
] ]
start_response(status, headers)
return encoded_resp return encoded_resp
except APIError: except APIError:
raise raise
@ -271,7 +314,6 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
def __call__(self, environ, start_response, *args, **kwargs): def __call__(self, environ, start_response, *args, **kwargs):
"""Entry point for WSGI apps.""" """Entry point for WSGI apps."""
request = self.get_request(environ) request = self.get_request(environ)
try: try:
handler, additional_args, allowed_methods = self.get_route(request.path) handler, additional_args, allowed_methods = self.get_route(request.path)
except NotFound: except NotFound:

View File

@ -2,9 +2,6 @@ from typing import Callable, ClassVar
import sys import sys
from .base import SpiderwebMiddleware as SpiderwebMiddleware from .base import SpiderwebMiddleware as SpiderwebMiddleware
from .cors import CorsMiddleware as CorsMiddleware
from .csrf import CSRFMiddleware as CSRFMiddleware
from .sessions import SessionMiddleware as SessionMiddleware
from ..exceptions import ConfigError, UnusedMiddleware, StartupErrors from ..exceptions import ConfigError, UnusedMiddleware, StartupErrors
from ..request import Request from ..request import Request
from ..response import HttpResponse from ..response import HttpResponse
@ -38,7 +35,7 @@ class MiddlewareMixin:
if errors: if errors:
# just show the messages # just show the messages
sys.tracebacklimit = 0 sys.tracebacklimit = 1
raise StartupErrors( raise StartupErrors(
"Problems were identified during startup — cannot continue.", errors "Problems were identified during startup — cannot continue.", errors
) )
@ -63,3 +60,18 @@ class MiddlewareMixin:
except UnusedMiddleware: except UnusedMiddleware:
self.middleware.remove(middleware) self.middleware.remove(middleware)
continue continue
def post_process_middleware(
self, request: Request, response: HttpResponse, rendered_response: str
) -> str:
# run them in reverse order, same as process_response. The top of the middleware
# stack should be the first and last middleware to run.
for middleware in reversed(self.middleware):
try:
rendered_response = middleware.post_process(
request, response, rendered_response
)
except UnusedMiddleware:
self.middleware.remove(middleware)
continue
return rendered_response

View File

@ -9,6 +9,8 @@ class SpiderwebMiddleware:
process_request(self, request) -> None or Response process_request(self, request) -> None or Response
process_response(self, request, resp) -> None process_response(self, request, resp) -> None
on_error(self, request, e) -> Response
post_process(self, request, resp) -> Response
Middleware can be used to modify requests and responses in a variety of ways. Middleware can be used to modify requests and responses in a variety of ways.
If one of the two methods is not defined, the request or resp will be passed If one of the two methods is not defined, the request or resp will be passed
@ -22,12 +24,26 @@ class SpiderwebMiddleware:
self.server = server self.server = server
def process_request(self, request: Request) -> HttpResponse | None: def process_request(self, request: Request) -> HttpResponse | None:
# This method is called before the request is passed to the view. You can safely
# modify the request in this method, or return an HttpResponse to short-circuit
# the request and return a response immediately.
pass pass
def process_response( def process_response(self, request: Request, response: HttpResponse) -> None:
self, request: Request, response: HttpResponse # This method is called after the view has returned a response. You can modify
) -> HttpResponse | None: # the response in this method. The response will be returned to the client after
# all middleware has been processed.
pass pass
def on_error(self, request: Request, e: Exception) -> HttpResponse | None: def on_error(self, request: Request, e: Exception) -> HttpResponse | None:
# This method is called if an exception is raised during the request. You can
# return a response here to handle the error. If you return None, the exception
# will be re-raised.
pass pass
def post_process(
self, request: Request, response: HttpResponse, rendered_response: str
) -> str:
# This method is called after all the middleware has been processed and receives
# the final rendered response in str form. You can modify the response here.
return rendered_response

View File

@ -23,13 +23,11 @@ class VerifyValidCorsSetting(ServerCheck):
" `cors_allowed_origins`, `cors_allowed_origin_regexes`, or" " `cors_allowed_origins`, `cors_allowed_origin_regexes`, or"
" `cors_allow_all_origins`.", " `cors_allow_all_origins`.",
) )
def check(self): def check(self):
# - `cors_allowed_origins`
# - `cors_allowed_origin_regexes`
# - `cors_allow_all_origins`
if ( if (
not self.server.cors_allowed_origins not self.server.cors_allowed_origins
and not self.server.cors.allowed_origin_regexes and not self.server.cors_allowed_origin_regexes
and not self.server.cors_allow_all_origins and not self.server.cors_allow_all_origins
): ):
return ConfigError(self.INVALID_BASE_CONFIG) return ConfigError(self.INVALID_BASE_CONFIG)
@ -51,7 +49,6 @@ class CorsMiddleware(SpiderwebMiddleware):
enabled = getattr(request, "_cors_enabled", None) enabled = getattr(request, "_cors_enabled", None)
if enabled is None: if enabled is None:
enabled = self.is_enabled(request) enabled = self.is_enabled(request)
if not enabled: if not enabled:
return response return response
@ -60,7 +57,7 @@ class CorsMiddleware(SpiderwebMiddleware):
else: else:
response.headers["vary"] = ["origin"] response.headers["vary"] = ["origin"]
origin = request.headers.get("origin") origin = request.headers.get("http_origin")
if not origin: if not origin:
return response return response
@ -102,10 +99,12 @@ class CorsMiddleware(SpiderwebMiddleware):
response.headers[ACCESS_CONTROL_MAX_AGE] = str( response.headers[ACCESS_CONTROL_MAX_AGE] = str(
self.server.cors_preflight_max_age self.server.cors_preflight_max_age
) )
if ( if (
self.server.cors_allow_private_network self.server.cors_allow_private_network
and request.headers.get(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK) == "true" and request.headers.get(
ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK.replace("-", "_")
)
== "true"
): ):
response.headers[ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK] = "true" response.headers[ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK] = "true"
@ -133,8 +132,8 @@ class CorsMiddleware(SpiderwebMiddleware):
def process_request(self, request: Request) -> HttpResponse | None: def process_request(self, request: Request) -> HttpResponse | None:
# Identify and handle a preflight request # Identify and handle a preflight request
# origin = request.META.get("HTTP_ORIGIN")
request._cors_enabled = self.is_enabled(request) request._cors_enabled = self.is_enabled(request)
request.META["cors_ran"] = True
if ( if (
request._cors_enabled request._cors_enabled
and request.method == "OPTIONS" and request.method == "OPTIONS"
@ -150,9 +149,13 @@ class CorsMiddleware(SpiderwebMiddleware):
self.add_response_headers(request, resp) self.add_response_headers(request, resp)
return resp return resp
def process_response( def process_response(self, request: Request, response: HttpResponse) -> None:
self, request: Request, response: HttpResponse if not request.META.get("cors_ran"):
) -> None: # something happened and process_request didn't run. Abort early.
# We're not relying on request._cors_enabled because it's more
# visible and the view may have destroyed it accidentally.
return
self.add_response_headers(request, response) self.add_response_headers(request, response)
# [204]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#status_code # [204]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#status_code

View File

@ -72,7 +72,9 @@ class CSRFMiddleware(SpiderwebMiddleware):
def is_trusted_origin(self, request) -> bool: def is_trusted_origin(self, request) -> bool:
origin = request.headers.get("http_origin") origin = request.headers.get("http_origin")
referrer = request.headers.get("http_referer") or request.headers.get("http_referrer") referrer = request.headers.get("http_referer") or request.headers.get(
"http_referrer"
)
host = request.headers.get("http_host") host = request.headers.get("http_host")
if not origin and not (host == referrer): if not origin and not (host == referrer):
@ -88,13 +90,12 @@ class CSRFMiddleware(SpiderwebMiddleware):
def process_request(self, request: Request) -> HttpResponse | None: def process_request(self, request: Request) -> HttpResponse | None:
if request.method == "POST": if request.method == "POST":
if hasattr(request.handler, "csrf_exempt"): if hasattr(request.handler, "csrf_exempt"):
if request.handler.csrf_exempt is True: if request.handler.csrf_exempt is True:
return return
csrf_token = ( csrf_token = (
request.headers.get("X-CSRF-TOKEN") request.headers.get("x-csrf-token")
or request.GET.get("csrf_token") or request.GET.get("csrf_token")
or request.POST.get("csrf_token") or request.POST.get("csrf_token")
) )
@ -109,7 +110,7 @@ class CSRFMiddleware(SpiderwebMiddleware):
def process_response(self, request: Request, response: HttpResponse) -> None: def process_response(self, request: Request, response: HttpResponse) -> None:
token = self.get_csrf_token(request) token = self.get_csrf_token(request)
# do we need it in both places? # do we need it in both places?
response.headers["X-CSRF-TOKEN"] = token response.headers["x-csrf-token"] = token
response.context |= { response.context |= {
"csrf_token": f"""<input type="hidden" name="csrf_token" value="{token}">""", "csrf_token": f"""<input type="hidden" name="csrf_token" value="{token}">""",
"raw_csrf_token": token, # in case they want to format it themselves "raw_csrf_token": token, # in case they want to format it themselves

View File

@ -29,8 +29,10 @@ class HttpResponse:
self.data = data self.data = data
self.context = context if context else {} self.context = context if context else {}
self.status_code = status_code self.status_code = status_code
self.headers = headers if headers else {} self._headers = headers if headers else {}
self.headers = Headers(**{k.lower(): v for k, v in self.headers.items()}) self.headers = Headers()
for k, v in self._headers.items():
self.headers[k.lower()] = v
if not self.headers.get("content-type"): if not self.headers.get("content-type"):
self.headers["content-type"] = "text/html; charset=utf-8" self.headers["content-type"] = "text/html; charset=utf-8"
self.headers["server"] = "Spiderweb" self.headers["server"] = "Spiderweb"

View File

@ -1,10 +1,16 @@
import re import re
from typing import Callable, Any, Optional, Sequence from typing import Callable, Any, Sequence
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
from spiderweb.default_views import * # noqa: F403 from spiderweb.default_views import * # noqa: F403
from spiderweb.exceptions import NotFound, ConfigError, ParseError from spiderweb.exceptions import (
NotFound,
ConfigError,
ParseError,
SpiderwebException,
ReverseNotFound,
)
from spiderweb.response import RedirectResponse from spiderweb.response import RedirectResponse
@ -35,7 +41,7 @@ class RoutesMixin:
error_routes: dict[int, 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, name=None) -> Callable:
""" """
Decorator for adding a route to a view. Decorator for adding a route to a view.
@ -49,11 +55,12 @@ class RoutesMixin:
:param path: str :param path: str
:param allowed_methods: list[str] :param allowed_methods: list[str]
:param name: str
:return: Callable :return: Callable
""" """
def outer(func): def outer(func):
self.add_route(path, func, allowed_methods) self.add_route(path, func, allowed_methods, name)
return func return func
return outer return outer
@ -115,7 +122,11 @@ class RoutesMixin:
return re.compile(rf"^{'/'.join(parts)}$") return re.compile(rf"^{'/'.join(parts)}$")
def add_route( def add_route(
self, path: str, method: Callable, allowed_methods: None | list[str] = None self,
path: str,
method: Callable,
allowed_methods: None | list[str] = None,
name: str = None,
): ):
"""Add a route to the server.""" """Add a route to the server."""
allowed_methods = ( allowed_methods = (
@ -124,24 +135,27 @@ class RoutesMixin:
or DEFAULT_ALLOWED_METHODS or DEFAULT_ALLOWED_METHODS
) )
reverse_path = re.sub(r"<(.*?):(.*?)>", r"{\2}", path) if "<" in path else path
def get_packet(func):
return {
"func": func,
"allowed_methods": allowed_methods,
"name": name,
"reverse": reverse_path,
}
if self.append_slash and not path.endswith("/"): if self.append_slash and not path.endswith("/"):
updated_path = path + "/" updated_path = path + "/"
self.check_for_route_duplicates(updated_path) self.check_for_route_duplicates(updated_path)
self.check_for_route_duplicates(path) self.check_for_route_duplicates(path)
self._routes[self.convert_path(path)] = { self._routes[self.convert_path(path)] = get_packet(
"func": DummyRedirectRoute(updated_path), DummyRedirectRoute(updated_path)
"allowed_methods": allowed_methods, )
} self._routes[self.convert_path(updated_path)] = get_packet(method)
self._routes[self.convert_path(updated_path)] = {
"func": method,
"allowed_methods": allowed_methods,
}
else: else:
self.check_for_route_duplicates(path) self.check_for_route_duplicates(path)
self._routes[self.convert_path(path)] = { self._routes[self.convert_path(path)] = get_packet(method)
"func": method,
"allowed_methods": allowed_methods,
}
def add_routes(self): def add_routes(self):
for line in self.routes: for line in self.routes:
@ -156,3 +170,27 @@ class RoutesMixin:
def add_error_routes(self): def add_error_routes(self):
for code, func in self.error_routes.items(): for code, func in self.error_routes.items():
self.add_error_route(int(code), func) self.add_error_route(int(code), func)
def reverse(
self, view_name: str, data: dict[str, Any] = None, query: dict[str, Any] = None
) -> str:
# take in a view name and return the path
for option in self._routes.values():
if option["name"] == view_name:
path = option["reverse"]
if args := re.findall(r"{(.*?)}", path):
if not data:
raise SpiderwebException(
f"Missing arguments for reverse: {args}"
)
for arg in args:
if arg not in data:
raise SpiderwebException(
f"Missing argument '{arg}' for reverse."
)
path = path.replace(f"{{{arg}}}", str(data[arg]))
if query:
path += "?" + "&".join([f"{k}={str(v)}" for k, v in query.items()])
return path
raise ReverseNotFound(f"View '{view_name}' not found.")

View File

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

View File

@ -15,14 +15,16 @@ class StartResponse:
self.headers = headers self.headers = headers
def get_headers(self): def get_headers(self):
return {h[0]: h[1] for h in self.headers} return {h[0]: h[1] for h in self.headers} if self.headers else {}
def setup(): def setup(**kwargs):
environ = {} environ = {}
setup_testing_defaults(environ) setup_testing_defaults(environ)
if "db" not in kwargs:
kwargs["db"] = SqliteDatabase("spiderweb-tests.db")
return ( return (
SpiderwebRouter(db=SqliteDatabase("spiderweb-tests.db")), SpiderwebRouter(**kwargs),
environ, environ,
StartResponse(), StartResponse(),
) )

View File

@ -11,3 +11,30 @@ class ExplodingResponseMiddleware(SpiderwebMiddleware):
self, request: Request, response: HttpResponse self, request: Request, response: HttpResponse
) -> HttpResponse | None: ) -> HttpResponse | None:
raise UnusedMiddleware("Unfinished!") raise UnusedMiddleware("Unfinished!")
class InterruptingMiddleware(SpiderwebMiddleware):
def process_request(self, request: Request) -> HttpResponse:
return HttpResponse("Moo!")
class PostProcessingMiddleware(SpiderwebMiddleware):
def post_process(
self, request: Request, response: HttpResponse, rendered_response: str
) -> str:
return rendered_response + " Moo!"
class PostProcessingWithHeaderManipulation(SpiderwebMiddleware):
def post_process(
self, request: Request, response: HttpResponse, rendered_response: str
) -> str:
response.headers["X-Moo"] = "true"
return rendered_response
class ExplodingPostProcessingMiddleware(SpiderwebMiddleware):
def post_process(
self, request: Request, response: HttpResponse, rendered_response: str
) -> str:
raise UnusedMiddleware("Unfinished!")

View File

View File

@ -0,0 +1 @@
hi

View File

@ -0,0 +1,6 @@
.body {
background-color: #f0f0f0;
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}

View File

@ -0,0 +1,149 @@
from datetime import datetime
import pytest
from spiderweb import HttpResponse
from spiderweb.exceptions import GeneralException
from spiderweb.tests.helpers import setup
def test_valid_cookie():
app, environ, start_response = setup()
@app.route("/")
def index(request):
resp = HttpResponse("Hello, World!")
resp.set_cookie("cookie", "value")
return resp
response = app(environ, start_response)
assert response == [b"Hello, World!"]
assert start_response.get_headers()["set-cookie"] == "cookie=value"
def test_invalid_cookie_name():
app, environ, start_response = setup()
@app.route("/")
def index(request):
resp = HttpResponse("Hello, World!")
resp.set_cookie("cookie$%^&*name", "value")
return resp
with pytest.raises(GeneralException) as exc:
app(environ, start_response)
assert str(exc.value) == (
"GeneralException() - Cookie name has illegal characters."
" See https://developer.mozilla.org/en-US/docs/Web/HTTP/"
"Headers/Set-Cookie#attributes for information on allowed"
" characters."
)
def test_cookie_with_domain():
app, environ, start_response = setup()
@app.route("/")
def index(request):
resp = HttpResponse("Hello, World!")
resp.set_cookie("cookie", "value", domain="example.com")
return resp
response = app(environ, start_response)
assert response == [b"Hello, World!"]
assert (
start_response.get_headers()["set-cookie"] == "cookie=value; Domain=example.com"
)
def test_cookie_with_expires():
app, environ, start_response = setup()
expiry_time = datetime(2024, 10, 22, 7, 28)
expiry_time_str = expiry_time.strftime("%a, %d %b %Y %H:%M:%S GMT")
@app.route("/")
def index(request):
resp = HttpResponse("Hello, World!")
resp.set_cookie("cookie", "value", expires=expiry_time)
return resp
response = app(environ, start_response)
assert response == [b"Hello, World!"]
assert (
start_response.get_headers()["set-cookie"]
== f"cookie=value; Expires={expiry_time_str}"
)
def test_cookie_with_max_age():
app, environ, start_response = setup()
@app.route("/")
def index(request):
resp = HttpResponse("Hello, World!")
resp.set_cookie("cookie", "value", max_age=3600)
return resp
response = app(environ, start_response)
assert response == [b"Hello, World!"]
assert start_response.get_headers()["set-cookie"] == "cookie=value; Max-Age=3600"
def test_cookie_with_invalid_samesite_attr():
app, environ, start_response = setup()
@app.route("/")
def index(request):
resp = HttpResponse("Hello, World!")
resp.set_cookie("cookie", "value", same_site="invalid")
return resp
with pytest.raises(GeneralException) as exc:
app(environ, start_response)
assert str(exc.value) == (
"GeneralException() - Invalid value invalid for `same_site` cookie"
" attribute. Valid options are 'strict', 'lax', or 'none'."
)
def test_cookie_partitioned_attr():
app, environ, start_response = setup()
@app.route("/")
def index(request):
resp = HttpResponse()
resp.set_cookie("cookie", "value", partitioned=True)
return resp
app(environ, start_response)
assert start_response.get_headers()["set-cookie"] == "cookie=value; Partitioned"
def test_cookie_secure_attr():
app, environ, start_response = setup()
@app.route("/")
def index(request):
resp = HttpResponse()
resp.set_cookie("cookie", "value", secure=True)
return resp
app(environ, start_response)
assert start_response.get_headers()["set-cookie"] == "cookie=value; Secure"
def test_setting_multiple_cookies():
app, environ, start_response = setup()
@app.route("/")
def index(request):
resp = HttpResponse()
resp.set_cookie("cookie1", "value1")
resp.set_cookie("cookie2", "value2")
return resp
app(environ, start_response)
assert start_response.headers[-1] == ("set-cookie", "cookie2=value2")
assert start_response.headers[-2] == ("set-cookie", "cookie1=value1")

View File

@ -0,0 +1,35 @@
from spiderweb.constants import DEFAULT_ENCODING
from spiderweb.response import TemplateResponse
from spiderweb.tests.helpers import setup
def test_str_template_with_static_tag():
# test that the static tag works
template = """
<html>
<head>
<title>{{ title }}</title>
<link rel="stylesheet" href="{% static 'style.css' %}">
</head>
<body>
<h1>{{ title }}</h1>
<p>{{ content }}</p>
</body>
</html>
"""
context = {"title": "Test", "content": "This is a test."}
app, environ, start_response = setup(
staticfiles_dirs=["spiderweb/tests/staticfiles"], static_url="blorp"
)
@app.route("/")
def index(request):
return TemplateResponse(request, template_string=template, context=context)
rendered_template = (
template.replace("{% static 'style.css' %}", "/blorp/style.css")
.replace("{{ title }}", "Test")
.replace("{{ content }}", "This is a test.")
)
assert app(environ, start_response) == [bytes(rendered_template, DEFAULT_ENCODING)]

View File

@ -4,8 +4,17 @@ from datetime import timedelta
import pytest import pytest
from peewee import SqliteDatabase from peewee import SqliteDatabase
from spiderweb import SpiderwebRouter, HttpResponse, ConfigError, StartupErrors from spiderweb import SpiderwebRouter, HttpResponse, StartupErrors, ConfigError
from spiderweb.constants import DEFAULT_ENCODING from spiderweb.constants import DEFAULT_ENCODING
from spiderweb.middleware.cors import (
ACCESS_CONTROL_ALLOW_ORIGIN,
ACCESS_CONTROL_ALLOW_HEADERS,
ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_EXPOSE_HEADERS,
ACCESS_CONTROL_ALLOW_CREDENTIALS,
ACCESS_CONTROL_MAX_AGE,
ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK,
)
from spiderweb.middleware.sessions import Session from spiderweb.middleware.sessions import Session
from spiderweb.middleware import csrf from spiderweb.middleware import csrf
from spiderweb.tests.helpers import setup from spiderweb.tests.helpers import setup
@ -13,21 +22,11 @@ from spiderweb.tests.views_for_tests import (
form_view_with_csrf, form_view_with_csrf,
form_csrf_exempt, form_csrf_exempt,
form_view_without_csrf, form_view_without_csrf,
text_view,
unauthorized_view,
) )
# 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): def index(request):
if "value" in request.SESSION: if "value" in request.SESSION:
request.SESSION["value"] += 1 request.SESSION["value"] += 1
@ -37,10 +36,8 @@ def index(request):
def test_session_middleware(): def test_session_middleware():
_, environ, start_response = setup() app, environ, start_response = setup(
app = SpiderwebRouter(
middleware=["spiderweb.middleware.sessions.SessionMiddleware"], middleware=["spiderweb.middleware.sessions.SessionMiddleware"],
db=SqliteDatabase("spiderweb-tests.db"),
) )
app.add_route("/", index) app.add_route("/", index)
@ -58,10 +55,8 @@ def test_session_middleware():
def test_expired_session(): def test_expired_session():
_, environ, start_response = setup() app, environ, start_response = setup(
app = SpiderwebRouter(
middleware=["spiderweb.middleware.sessions.SessionMiddleware"], middleware=["spiderweb.middleware.sessions.SessionMiddleware"],
db=SqliteDatabase("spiderweb-tests.db"),
) )
app.add_route("/", index) app.add_route("/", index)
@ -85,13 +80,11 @@ def test_expired_session():
def test_exploding_middleware(): def test_exploding_middleware():
_, environ, start_response = setup() app, environ, start_response = setup(
app = SpiderwebRouter(
middleware=[ middleware=[
"spiderweb.tests.middleware.ExplodingRequestMiddleware", "spiderweb.tests.middleware.ExplodingRequestMiddleware",
"spiderweb.tests.middleware.ExplodingResponseMiddleware", "spiderweb.tests.middleware.ExplodingResponseMiddleware",
], ],
db=SqliteDatabase("spiderweb-tests.db"),
) )
app.add_route("/", index) app.add_route("/", index)
@ -101,8 +94,14 @@ def test_exploding_middleware():
assert len(app.middleware) == 0 assert len(app.middleware) == 0
def test_invalid_middleware():
with pytest.raises(ConfigError) as e:
SpiderwebRouter(middleware=["nonexistent.middleware"])
assert e.value.args[0] == "Middleware 'nonexistent.middleware' not found."
def test_csrf_middleware_without_session_middleware(): def test_csrf_middleware_without_session_middleware():
_, environ, start_response = setup()
with pytest.raises(StartupErrors) as e: with pytest.raises(StartupErrors) as e:
SpiderwebRouter( SpiderwebRouter(
middleware=["spiderweb.middleware.csrf.CSRFMiddleware"], middleware=["spiderweb.middleware.csrf.CSRFMiddleware"],
@ -116,15 +115,14 @@ def test_csrf_middleware_without_session_middleware():
def test_csrf_middleware_above_session_middleware(): def test_csrf_middleware_above_session_middleware():
_, environ, start_response = setup()
with pytest.raises(StartupErrors) as e: with pytest.raises(StartupErrors) as e:
SpiderwebRouter( app, environ, start_response = setup(
middleware=[ middleware=[
"spiderweb.middleware.csrf.CSRFMiddleware", "spiderweb.middleware.csrf.CSRFMiddleware",
"spiderweb.middleware.sessions.SessionMiddleware", "spiderweb.middleware.sessions.SessionMiddleware",
], ],
db=SqliteDatabase("spiderweb-tests.db"),
) )
exceptiongroup = e.value.args[1] exceptiongroup = e.value.args[1]
assert ( assert (
exceptiongroup[0].args[0] exceptiongroup[0].args[0]
@ -133,13 +131,11 @@ def test_csrf_middleware_above_session_middleware():
def test_csrf_middleware(): def test_csrf_middleware():
_, environ, start_response = setup() app, environ, start_response = setup(
app = SpiderwebRouter(
middleware=[ middleware=[
"spiderweb.middleware.sessions.SessionMiddleware", "spiderweb.middleware.sessions.SessionMiddleware",
"spiderweb.middleware.csrf.CSRFMiddleware", "spiderweb.middleware.csrf.CSRFMiddleware",
], ],
db=SqliteDatabase("spiderweb-tests.db"),
) )
app.add_route("/", form_view_with_csrf, ["GET", "POST"]) app.add_route("/", form_view_with_csrf, ["GET", "POST"])
@ -175,12 +171,12 @@ def test_csrf_middleware():
assert "bob" in resp2 assert "bob" in resp2
# test that it raises a CSRF error on wrong token # test that it raises a CSRF error on wrong token
formdata = f"name=bob&csrf_token=badtoken" formdata = "name=bob&csrf_token=badtoken"
b_handle = BytesIO() b_handle = BytesIO()
b_handle.write(formdata.encode(DEFAULT_ENCODING)) b_handle.write(formdata.encode(DEFAULT_ENCODING))
b_handle.seek(0) b_handle.seek(0)
environ["wsgi.input"] = BufferedReader(b_handle) environ["wsgi.input"] = BufferedReader(b_handle)
environ["HTTP_X_CSRF_TOKEN"] = None
resp3 = app(environ, start_response)[0].decode(DEFAULT_ENCODING) resp3 = app(environ, start_response)[0].decode(DEFAULT_ENCODING)
assert "CSRF token is invalid" in resp3 assert "CSRF token is invalid" in resp3
@ -198,14 +194,13 @@ def test_csrf_middleware():
def test_csrf_expired_token(): def test_csrf_expired_token():
_, environ, start_response = setup() app, environ, start_response = setup(
app = SpiderwebRouter(
middleware=[ middleware=[
"spiderweb.middleware.sessions.SessionMiddleware", "spiderweb.middleware.sessions.SessionMiddleware",
"spiderweb.middleware.csrf.CSRFMiddleware", "spiderweb.middleware.csrf.CSRFMiddleware",
], ],
db=SqliteDatabase("spiderweb-tests.db"),
) )
app.middleware[1].CSRF_EXPIRY = -1 app.middleware[1].CSRF_EXPIRY = -1
app.add_route("/", form_view_with_csrf, ["GET", "POST"]) app.add_route("/", form_view_with_csrf, ["GET", "POST"])
@ -235,13 +230,11 @@ def test_csrf_expired_token():
def test_csrf_exempt(): def test_csrf_exempt():
_, environ, start_response = setup() app, environ, start_response = setup(
app = SpiderwebRouter(
middleware=[ middleware=[
"spiderweb.middleware.sessions.SessionMiddleware", "spiderweb.middleware.sessions.SessionMiddleware",
"spiderweb.middleware.csrf.CSRFMiddleware", "spiderweb.middleware.csrf.CSRFMiddleware",
], ],
db=SqliteDatabase("spiderweb-tests.db"),
) )
app.add_route("/", form_csrf_exempt, ["GET", "POST"]) app.add_route("/", form_csrf_exempt, ["GET", "POST"])
@ -268,8 +261,7 @@ def test_csrf_exempt():
def test_csrf_trusted_origins(): def test_csrf_trusted_origins():
_, environ, start_response = setup() app, environ, start_response = setup(
app = SpiderwebRouter(
middleware=[ middleware=[
"spiderweb.middleware.sessions.SessionMiddleware", "spiderweb.middleware.sessions.SessionMiddleware",
"spiderweb.middleware.csrf.CSRFMiddleware", "spiderweb.middleware.csrf.CSRFMiddleware",
@ -277,9 +269,7 @@ def test_csrf_trusted_origins():
csrf_trusted_origins=[ csrf_trusted_origins=[
"example.com", "example.com",
], ],
db=SqliteDatabase("spiderweb-tests.db"),
) )
app.add_route("/", form_view_without_csrf, ["GET", "POST"]) app.add_route("/", form_view_without_csrf, ["GET", "POST"])
environ["HTTP_USER_AGENT"] = "hi" environ["HTTP_USER_AGENT"] = "hi"
@ -306,3 +296,515 @@ def test_csrf_trusted_origins():
environ["HTTP_ORIGIN"] = "example.com" environ["HTTP_ORIGIN"] = "example.com"
resp2 = app(environ, start_response)[0].decode(DEFAULT_ENCODING) resp2 = app(environ, start_response)[0].decode(DEFAULT_ENCODING)
assert resp2 == '{"name": "bob"}' assert resp2 == '{"name": "bob"}'
def test_post_process_middleware():
app, environ, start_response = setup(
middleware=[
"spiderweb.tests.middleware.PostProcessingMiddleware",
],
)
app.add_route("/", text_view)
environ["HTTP_USER_AGENT"] = "hi"
environ["REMOTE_ADDR"] = "/"
environ["REQUEST_METHOD"] = "GET"
assert app(environ, start_response) == [bytes("Hi! Moo!", DEFAULT_ENCODING)]
def test_post_process_header_manip():
app, environ, start_response = setup(
middleware=[
"spiderweb.tests.middleware.PostProcessingWithHeaderManipulation",
],
)
app.add_route("/", text_view)
environ["HTTP_USER_AGENT"] = "hi"
environ["REMOTE_ADDR"] = "/"
environ["REQUEST_METHOD"] = "GET"
assert app(environ, start_response) == [bytes("Hi!", DEFAULT_ENCODING)]
assert start_response.get_headers()["x-moo"] == "true"
def test_unused_post_process_middleware():
app, environ, start_response = setup(
middleware=[
"spiderweb.tests.middleware.ExplodingPostProcessingMiddleware",
],
)
app.add_route("/", text_view)
environ["HTTP_USER_AGENT"] = "hi"
environ["REMOTE_ADDR"] = "/"
environ["REQUEST_METHOD"] = "GET"
assert app(environ, start_response) == [bytes("Hi!", DEFAULT_ENCODING)]
# make sure it kicked out the middleware and isn't just ignoring it
assert len(app.middleware) == 0
class TestCorsMiddleware:
# adapted from:
# https://github.com/adamchainz/django-cors-headers/blob/main/tests/test_middleware.py
# to make sure I didn't miss anything
middleware = {"middleware": ["spiderweb.middleware.cors.CorsMiddleware"]}
def test_get_no_origin(self):
app, environ, start_response = setup(
**self.middleware, cors_allow_all_origins=True
)
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN not in start_response.get_headers()
def test_get_origin_vary_by_default(self):
app, environ, start_response = setup(
**self.middleware, cors_allow_all_origins=True
)
app(environ, start_response)
assert start_response.get_headers()["vary"] == "origin"
def test_get_invalid_origin(self):
app, environ, start_response = setup(
**self.middleware, cors_allow_all_origins=True
)
environ["HTTP_ORIGIN"] = "https://example.com]"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN not in start_response.get_headers()
def test_get_not_in_allowed_origins(self):
app, environ, start_response = setup(
**self.middleware, cors_allowed_origins=["https://example.com"]
)
environ["HTTP_ORIGIN"] = "https://example.org"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN not in start_response.get_headers()
def test_get_not_in_allowed_origins_due_to_wrong_scheme(self):
app, environ, start_response = setup(
**self.middleware, cors_allowed_origins=["http://example.org"]
)
environ["HTTP_ORIGIN"] = "https://example.org"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN not in start_response.get_headers()
def test_get_in_allowed_origins(self):
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origins=["https://example.com", "https://example.org"],
)
environ["HTTP_ORIGIN"] = "https://example.org"
app(environ, start_response)
assert (
start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN]
== "https://example.org"
)
def test_null_in_allowed_origins(self):
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origins=["https://example.com", "null"],
)
environ["HTTP_ORIGIN"] = "null"
app(environ, start_response)
assert start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN] == "null"
def test_file_in_allowed_origins(self):
"""
'file://' should be allowed as an origin since Chrome on Android
mistakenly sends it
"""
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origins=["https://example.com", "file://"],
)
environ["HTTP_ORIGIN"] = "file://"
app(environ, start_response)
assert start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN] == "file://"
def test_get_expose_headers(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_all_origins=True,
cors_expose_headers=["accept", "content-type"],
)
environ["HTTP_ORIGIN"] = "https://example.com"
app(environ, start_response)
assert (
start_response.get_headers()[ACCESS_CONTROL_EXPOSE_HEADERS]
== "accept, content-type"
)
def test_get_dont_expose_headers(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_all_origins=True,
)
environ["HTTP_ORIGIN"] = "https://example.com"
app(environ, start_response)
assert ACCESS_CONTROL_EXPOSE_HEADERS not in start_response.get_headers()
def test_get_allow_credentials(self):
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origins=["https://example.com"],
cors_allow_credentials=True,
)
environ["HTTP_ORIGIN"] = "https://example.com"
app(environ, start_response)
assert start_response.get_headers()[ACCESS_CONTROL_ALLOW_CREDENTIALS] == "true"
def test_get_allow_credentials_bad_origin(self):
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origins=["https://example.com"],
cors_allow_credentials=True,
)
environ["HTTP_ORIGIN"] = "https://example.org"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_CREDENTIALS not in start_response.get_headers()
def test_get_allow_credentials_disabled(self):
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origins=["https://example.com"],
)
environ["HTTP_ORIGIN"] = "https://example.com"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_CREDENTIALS not in start_response.get_headers()
def test_allow_private_network_added_if_enabled_and_requested(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_private_network=True,
cors_allow_all_origins=True,
)
environ["HTTP_ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK"] = "true"
environ["HTTP_ORIGIN"] = "http://example.com"
app(environ, start_response)
assert (
start_response.get_headers()[ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK] == "true"
)
def test_allow_private_network_not_added_if_enabled_and_not_requested(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_private_network=True,
cors_allow_all_origins=True,
)
environ["HTTP_ORIGIN"] = "http://example.com"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK not in start_response.get_headers()
def test_allow_private_network_not_added_if_enabled_and_no_cors_origin(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_private_network=True,
cors_allowed_origins=["http://example.com"],
)
environ["HTTP_ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK"] = "true"
environ["HTTP_ORIGIN"] = "http://example.org"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK not in start_response.get_headers()
def test_allow_private_network_not_added_if_disabled_and_requested(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_private_network=False,
cors_allow_all_origins=True,
)
environ["HTTP_ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK"] = "true"
environ["HTTP_ORIGIN"] = "http://example.com"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK not in start_response.get_headers()
def test_options_allowed_origin(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_headers=["content-type"],
cors_allow_methods=["GET", "OPTIONS"],
cors_preflight_max_age=1002,
cors_allow_all_origins=True,
)
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["HTTP_ORIGIN"] = "https://example.com"
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
headers = start_response.get_headers()
assert start_response.status == "200 OK"
assert headers[ACCESS_CONTROL_ALLOW_HEADERS] == "content-type"
assert headers[ACCESS_CONTROL_ALLOW_METHODS] == "GET, OPTIONS"
assert headers[ACCESS_CONTROL_MAX_AGE] == "1002"
def test_options_no_max_age(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_headers=["content-type"],
cors_allow_methods=["GET", "OPTIONS"],
cors_preflight_max_age=0,
cors_allow_all_origins=True,
)
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["HTTP_ORIGIN"] = "https://example.com"
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
headers = start_response.get_headers()
assert headers[ACCESS_CONTROL_ALLOW_HEADERS] == "content-type"
assert headers[ACCESS_CONTROL_ALLOW_METHODS] == "GET, OPTIONS"
assert ACCESS_CONTROL_MAX_AGE not in headers
def test_options_allowed_origins_with_port(self):
app, environ, start_response = setup(
**self.middleware, cors_allowed_origins=["https://localhost:9000"]
)
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["HTTP_ORIGIN"] = "https://localhost:9000"
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
assert (
start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN]
== "https://localhost:9000"
)
def test_options_adds_origin_when_domain_found_in_allowed_regexes(self):
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origin_regexes=[r"^https://\w+\.example\.com$"],
)
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["HTTP_ORIGIN"] = "https://foo.example.com"
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
assert (
start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN]
== "https://foo.example.com"
)
def test_options_adds_origin_when_domain_found_in_allowed_regexes_second(self):
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origin_regexes=[
r"^https://\w+\.example\.org$",
r"^https://\w+\.example\.com$",
],
)
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["HTTP_ORIGIN"] = "https://foo.example.com"
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
assert (
start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN]
== "https://foo.example.com"
)
def test_options_doesnt_add_origin_when_domain_not_found_in_allowed_regexes(self):
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origin_regexes=[r"^https://\w+\.example\.org$"],
)
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["HTTP_ORIGIN"] = "https://foo.example.com"
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN not in start_response.get_headers()
def test_options_empty_request_method(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_all_origins=True,
)
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = ""
environ["HTTP_ORIGIN"] = "https://example.com"
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
assert start_response.status == "200 OK"
def test_options_no_headers(self):
app, environ, start_response = setup(
**self.middleware, cors_allow_all_origins=True, routes=[("/", text_view)]
)
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
assert start_response.status == "405 Method Not Allowed"
def test_allow_all_origins_get(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_credentials=True,
cors_allow_all_origins=True,
routes=[("/", text_view)],
)
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["HTTP_ORIGIN"] = "https://example.com"
environ["REQUEST_METHOD"] = "GET"
app(environ, start_response)
assert start_response.status == "200 OK"
assert (
start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN]
== "https://example.com"
)
assert start_response.get_headers()["vary"] == "origin"
def test_allow_all_origins_options(self):
app, environ, start_response = setup(
**self.middleware,
cors_allow_credentials=True,
cors_allow_all_origins=True,
routes=[("/", text_view)],
)
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["HTTP_ORIGIN"] = "https://example.com"
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
assert start_response.status == "200 OK"
assert (
start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN]
== "https://example.com"
)
assert start_response.get_headers()["vary"] == "origin"
def test_non_200_headers_still_set(self):
"""
It's not clear whether the header should still be set for non-HTTP200
when not a preflight request. However, this is the existing behavior for
django-cors-middleware, and Spiderweb should mirror it.
"""
app, environ, start_response = setup(
**self.middleware,
cors_allow_credentials=True,
cors_allow_all_origins=True,
routes=[("/unauthorized", unauthorized_view)],
)
environ["HTTP_ORIGIN"] = "https://example.com"
environ["PATH_INFO"] = "/unauthorized"
app(environ, start_response)
assert start_response.status == "401 Unauthorized"
assert (
start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN]
== "https://example.com"
)
def test_auth_view_options(self):
"""
Ensure HTTP200 and header still set, for preflight requests to views requiring
authentication. See: https://github.com/adamchainz/django-cors-headers/issues/3
"""
app, environ, start_response = setup(
**self.middleware,
cors_allow_credentials=True,
cors_allow_all_origins=True,
routes=[("/unauthorized", unauthorized_view)],
)
environ["HTTP_ORIGIN"] = "https://example.com"
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["PATH_INFO"] = "/unauthorized"
environ["REQUEST_METHOD"] = "OPTIONS"
app(environ, start_response)
assert start_response.status == "200 OK"
assert (
start_response.get_headers()[ACCESS_CONTROL_ALLOW_ORIGIN]
== "https://example.com"
)
assert start_response.get_headers()["content-length"] == "0"
def test_get_short_circuit(self):
"""
Test a scenario when a middleware that returns a response is run before
the `CorsMiddleware`. In this case
`CorsMiddleware.process_response()` should ignore the request.
"""
app, environ, start_response = setup(
middleware=[
"spiderweb.tests.middleware.InterruptingMiddleware",
"spiderweb.middleware.cors.CorsMiddleware",
],
cors_allow_credentials=True,
cors_allowed_origins=["https://example.com"],
)
environ["HTTP_ORIGIN"] = "https://example.com"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN not in start_response.get_headers()
def test_get_short_circuit_should_be_ignored(self):
app, environ, start_response = setup(
middleware=[
"spiderweb.tests.middleware.InterruptingMiddleware",
"spiderweb.middleware.cors.CorsMiddleware",
],
cors_urls_regex=r"^/foo/$",
cors_allowed_origins=["https://example.com"],
)
environ["HTTP_ORIGIN"] = "https://example.com"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN not in start_response.get_headers()
def test_get_regex_matches(self):
app, environ, start_response = setup(
**self.middleware,
cors_urls_regex=r"^/foo$",
cors_allowed_origins=["https://example.com"],
)
environ["HTTP_ORIGIN"] = "https://example.com"
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["PATH_INFO"] = "/foo"
environ["REQUEST_METHOD"] = "GET"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN in start_response.get_headers()
def test_get_regex_doesnt_match(self):
app, environ, start_response = setup(
**self.middleware,
cors_urls_regex=r"^/not-foo/$",
cors_allowed_origins=["https://example.com"],
)
environ["HTTP_ORIGIN"] = "https://example.com"
environ["HTTP_ACCESS_CONTROL_REQUEST_METHOD"] = "GET"
environ["PATH_INFO"] = "/foo"
environ["REQUEST_METHOD"] = "GET"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN not in start_response.get_headers()
def test_works_if_view_deletes_cors_enabled(self):
"""
Just in case something crazy happens in the view or other middleware,
check that get_response doesn't fall over if `_cors_enabled` is removed
"""
def yeet(request):
del request._cors_enabled
return HttpResponse("hahaha")
app, environ, start_response = setup(
**self.middleware,
cors_allowed_origins=["https://example.com"],
routes=[("/yeet", yeet)],
)
environ["HTTP_ORIGIN"] = "https://example.com"
environ["PATH_INFO"] = "/yeet"
environ["REQUEST_METHOD"] = "GET"
app(environ, start_response)
assert ACCESS_CONTROL_ALLOW_ORIGIN in start_response.get_headers()

View File

@ -1,13 +1,20 @@
import pytest import pytest
from spiderweb import SpiderwebRouter, ConfigError from spiderweb import ConfigError
from spiderweb.constants import DEFAULT_ENCODING from spiderweb.constants import DEFAULT_ENCODING
from spiderweb.exceptions import NoResponseError, SpiderwebNetworkException from spiderweb.exceptions import (
NoResponseError,
SpiderwebNetworkException,
SpiderwebException,
ReverseNotFound,
GeneralException,
)
from spiderweb.response import ( from spiderweb.response import (
HttpResponse, HttpResponse,
JsonResponse, JsonResponse,
TemplateResponse, TemplateResponse,
RedirectResponse, RedirectResponse,
FileResponse,
) )
from hypothesis import given, strategies as st from hypothesis import given, strategies as st
@ -75,15 +82,13 @@ def test_redirect_response():
def test_add_route_at_server_start(): def test_add_route_at_server_start():
app, environ, start_response = setup()
def index(request): def index(request):
return RedirectResponse(location="/redirected") return RedirectResponse(location="/redirected")
def view2(request): def view2(request):
return HttpResponse("View 2") return HttpResponse("View 2")
app = SpiderwebRouter( app, environ, start_response = setup(
routes=[ routes=[
("/", index, {"allowed_methods": ["GET", "POST"], "csrf_exempt": True}), ("/", index, {"allowed_methods": ["GET", "POST"], "csrf_exempt": True}),
("/view2", view2), ("/view2", view2),
@ -95,23 +100,20 @@ def test_add_route_at_server_start():
def test_redirect_on_append_slash(): def test_redirect_on_append_slash():
_, environ, start_response = setup() app, environ, start_response = setup(append_slash=True)
app = SpiderwebRouter(append_slash=True)
@app.route("/hello") @app.route("/hello")
def index(request): def index(request):
pass pass
environ["PATH_INFO"] = f"/hello" environ["PATH_INFO"] = "/hello"
assert app(environ, start_response) == [b"None"] assert app(environ, start_response) == [b"None"]
assert start_response.get_headers()["location"] == "/hello/" assert start_response.get_headers()["location"] == "/hello/"
@given(st.text()) @given(st.text())
def test_template_response_with_template(text): def test_template_response_with_template(text):
_, environ, start_response = setup() app, environ, start_response = setup(templates_dirs=["spiderweb/tests"])
app = SpiderwebRouter(templates_dirs=["spiderweb/tests"])
@app.route("/") @app.route("/")
def index(request): def index(request):
@ -174,11 +176,160 @@ def test_duplicate_error_view():
def test_missing_view_with_custom_404_alt(): def test_missing_view_with_custom_404_alt():
_, environ, start_response = setup()
def custom_404(request): def custom_404(request):
return HttpResponse("Custom 404 2") return HttpResponse("Custom 404 2")
app = SpiderwebRouter(error_routes={404: custom_404}) app, environ, start_response = setup(error_routes={404: custom_404})
assert app(environ, start_response) == [b"Custom 404 2"] assert app(environ, start_response) == [b"Custom 404 2"]
def test_getting_nonexistent_error_view():
app, environ, start_response = setup()
assert app.get_error_route(10101).__name__ == "http500"
def test_view_gets_name():
app, environ, start_response = setup()
@app.route("/", name="asdfasdf")
def index(request): ...
assert [v for k, v in app._routes.items()][0]["name"] == "asdfasdf"
def test_view_can_be_reversed():
app, environ, start_response = setup()
@app.route("/", name="asdfasdf")
def index(request): ...
@app.route("/<int:hi>", name="qwer")
def index2(request, hi): ...
assert app.reverse("asdfasdf") == "/"
assert app.reverse("asdfasdf", {"id": 1}) == "/"
assert app.reverse("asdfasdf", {"id": 1}, query={"key": "value"}) == "/?key=value"
assert app.reverse("qwer", {"hi": 1}) == "/1"
assert app.reverse("qwer", {"hi": 1}, query={"key": "value"}) == "/1?key=value"
def test_reversed_views_explode_when_missing_all_args():
app, environ, start_response = setup()
@app.route("/<int:hi>", name="qwer")
def index(request, hi): ...
with pytest.raises(SpiderwebException):
app.reverse("qwer")
def test_reversed_views_explode_when_missing_some_args():
app, environ, start_response = setup()
@app.route("/<int:hi>/<str:bye>", name="qwer")
def index(request, hi, bye): ...
with pytest.raises(SpiderwebException):
app.reverse("qwer", {"hi": 1})
def test_reverse_nonexistent_view():
app, environ, start_response = setup()
with pytest.raises(ReverseNotFound):
app.reverse("qwer")
def test_setting_content_type_header():
app, environ, start_response = setup()
@app.route("/")
def index(request):
resp = HttpResponse("Hello, World!", headers={"content-type": "text/html"})
return resp
response = app(environ, start_response)
assert response == [b"Hello, World!"]
assert start_response.get_headers()["content-type"] == "text/html"
def test_httpresponse_str_returns_body():
resp = HttpResponse("Hello, World!")
assert str(resp) == "Hello, World!"
def test_template_response_with_no_templates_raises_errors():
app, environ, start_response = setup()
@app.route("/")
def index(request):
return TemplateResponse(request, "")
with pytest.raises(GeneralException) as exc:
app(environ, start_response)
assert (
str(exc.value) == "GeneralException() - TemplateResponse requires a template."
)
def test_template_response_with_no_template_dirs():
template = TemplateResponse("", "test.html")
with pytest.raises(GeneralException) as exc:
template.render()
assert str(exc.value) == (
"GeneralException() - TemplateResponse has no loader. Did you set templates_dirs?"
)
def test_file_response():
resp = FileResponse("spiderweb/tests/staticfiles/file_for_testing_fileresponse.txt")
assert resp.headers["content-type"] == "text/plain"
assert resp.render() == [b"hi"]
def test_requesting_static_file():
app, environ, start_response = setup(
staticfiles_dirs=["spiderweb/tests/staticfiles"], debug=True
)
environ["PATH_INFO"] = "/static/file_for_testing_fileresponse.txt"
assert app(environ, start_response) == [b"hi"]
def test_requesting_nonexistent_static_file():
app, environ, start_response = setup(
staticfiles_dirs=["spiderweb/tests/staticfiles"], debug=True
)
environ["PATH_INFO"] = "/static/does_not_exist.txt"
assert app(environ, start_response) == [
b"Something went wrong.\n\n"
b"Code: 404\n\n"
b"Msg: Not Found\n\n"
b"Desc: The requested resource could not be found"
]
def test_static_file_with_unsafe_path():
app, environ, start_response = setup(
staticfiles_dirs=["spiderweb/tests/staticfiles"], debug=True
)
environ["PATH_INFO"] = "/static/../__init__.py"
assert app(environ, start_response) == [
b"Something went wrong.\n\n"
b"Code: 404\n\n"
b"Msg: Not Found\n\n"
b"Desc: The requested resource could not be found"
]

View File

@ -0,0 +1,15 @@
import pytest
from spiderweb.exceptions import ConfigError
from spiderweb.tests.helpers import setup
def test_staticfiles_dirs_option():
app, environ, start_response = setup(staticfiles_dirs="spiderweb/tests/staticfiles")
assert app.staticfiles_dirs == ["spiderweb/tests/staticfiles"]
def test_staticfiles_dirs_not_found():
with pytest.raises(ConfigError):
app, environ, start_response = setup(staticfiles_dirs="not/a/real/path")

View File

@ -1,17 +1,12 @@
import pytest import pytest
from spiderweb import SpiderwebRouter
from spiderweb.constants import DEFAULT_ENCODING from spiderweb.constants import DEFAULT_ENCODING
from spiderweb.exceptions import ParseError, ConfigError from spiderweb.exceptions import ParseError, ConfigError
from spiderweb.response import ( from spiderweb.response import (
HttpResponse, HttpResponse,
JsonResponse,
TemplateResponse,
RedirectResponse,
) )
from hypothesis import given, strategies as st, assume from hypothesis import given, strategies as st, assume
from peewee import SqliteDatabase
from spiderweb.tests.helpers import setup from spiderweb.tests.helpers import setup

View File

@ -1,3 +1,4 @@
from spiderweb import HttpResponse
from spiderweb.decorators import csrf_exempt from spiderweb.decorators import csrf_exempt
from spiderweb.response import JsonResponse, TemplateResponse from spiderweb.response import JsonResponse, TemplateResponse
@ -38,3 +39,11 @@ def form_view_with_csrf(request):
return JsonResponse(data=request.POST) return JsonResponse(data=request.POST)
else: else:
return TemplateResponse(request, template_string=EXAMPLE_HTML_FORM_WITH_CSRF) return TemplateResponse(request, template_string=EXAMPLE_HTML_FORM_WITH_CSRF)
def text_view(request):
return HttpResponse("Hi!")
def unauthorized_view(request):
return HttpResponse("Unauthorized", status_code=401)

View File

@ -1,3 +1,4 @@
import importlib
import json import json
import re import re
import secrets import secrets
@ -13,12 +14,10 @@ VALID_CHARS = string.ascii_letters + string.digits
def import_by_string(name): def import_by_string(name):
# https://stackoverflow.com/a/547867 mod_name, klass_name = name.rsplit(".", 1)
components = name.split(".") module = importlib.import_module(mod_name)
mod = __import__(components[0]) klass = getattr(module, klass_name)
for comp in components[1:]: return klass
mod = getattr(mod, comp)
return mod
def is_safe_path(path: str) -> bool: def is_safe_path(path: str) -> bool:
@ -67,15 +66,35 @@ def is_jsonable(data: str) -> bool:
class Headers(dict): class Headers(dict):
# special dict that forces lowercase for all keys # special dict that forces lowercase and snake_case for all keys
def __getitem__(self, key): def __getitem__(self, key):
return super().__getitem__(key.lower()) key = key.replace("-", "_")
try:
regular = super().__getitem__(key.lower())
except KeyError:
regular = None
try:
http_version = super().__getitem__(f"http_{key.lower()}")
except KeyError:
http_version = None
return regular or http_version
def __contains__(self, item):
item = item.lower().replace("-", "_")
regular = super().__contains__(item)
http = super().__contains__(f"http_{item}")
return regular or http
def __setitem__(self, key, value): def __setitem__(self, key, value):
return super().__setitem__(key.lower(), value) return super().__setitem__(key.lower().replace("-", "_"), value)
def get(self, key, default=None): def get(self, key, default=None):
return super().get(key.lower(), default) key = key.replace("-", "_")
regular = super().get(key.lower(), default)
http_version = super().get(f"http_{key.lower()}", default)
return regular or http_version
def setdefault(self, key, default=None): def setdefault(self, key, default=None):
return super().setdefault(key.lower(), default) return super().setdefault(key.lower(), default)

View File

@ -13,7 +13,7 @@
middleware is working. middleware is working.
</p> </p>
<p> <p>
<img src="/static/aaaaaa.gif" alt="AAAAAAAAAA"> <img src="{% static 'aaaaaa.gif' %}" alt="AAAAAAAAAA">
</p> </p>
<p> <p>
{{ request.META }} {{ request.META }}

40
test.py
View File

@ -1,40 +0,0 @@
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])