Compare commits
No commits in common. "main" and "origins" have entirely different histories.
51
README.md
51
README.md
@ -5,10 +5,12 @@
|
||||
src="https://img.shields.io/pypi/v/spiderweb-framework.svg?style=for-the-badge"
|
||||
alt="PyPI release version for Spiderweb"
|
||||
/>
|
||||
<a href="https://gitmoji.dev">
|
||||
<img
|
||||
src="https://img.shields.io/badge/gitmoji-%20😜%20😍-FFDD67.svg?style=for-the-badge"
|
||||
alt="Gitmoji"
|
||||
/>
|
||||
</a>
|
||||
<img
|
||||
src="https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge"
|
||||
alt="Code style: Black"
|
||||
@ -41,8 +43,6 @@ if __name__ == "__main__":
|
||||
app.start()
|
||||
```
|
||||
|
||||
## [View the docs here!](https://itsthejoker.github.io/spiderweb/#/)
|
||||
|
||||
My goal with this framework was to do three things:
|
||||
|
||||
1. Learn a lot
|
||||
@ -51,22 +51,31 @@ 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:
|
||||
|
||||
- Function-based views
|
||||
- Optional Flask-style URL routing
|
||||
- Optional Django-style URL routing
|
||||
- URLs with variables in them a lá Django
|
||||
- Full middleware implementation
|
||||
- Limit routes by HTTP verbs
|
||||
- Custom error routes
|
||||
- Built-in dev server
|
||||
- Gunicorn support
|
||||
- HTML templates with Jinja2
|
||||
- Static files support
|
||||
- Cookies (reading and setting)
|
||||
- Optional append_slash (with automatic redirects!)
|
||||
- CSRF middleware
|
||||
- CORS middleware
|
||||
- Optional POST data validation middleware with Pydantic
|
||||
- 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)
|
||||
- Tests (currently roughly 89% coverage)
|
||||
* Function-based views
|
||||
* Optional Flask-style URL routing
|
||||
* Optional Django-style URL routing
|
||||
* URLs with variables in them a lá Django
|
||||
* Gunicorn support
|
||||
* Full middleware implementation
|
||||
* Limit routes by HTTP verbs
|
||||
* Custom error routes
|
||||
* Built-in dev server
|
||||
* HTML templates with Jinja2
|
||||
* Static files support
|
||||
* Cookies (reading and setting)
|
||||
* Optional append_slash (with automatic redirects!)
|
||||
* ~~CSRF middleware implementation~~ (it's there, but it's crappy and unsafe. I'm working on it.)
|
||||
* Optional POST data validation middleware with Pydantic
|
||||
* Database support (using Peewee, but the end user can use whatever they want as long as there's a Peewee driver for it)
|
||||
* Session middleware
|
||||
|
||||
The TODO list:
|
||||
|
||||
* 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.
|
@ -1,8 +1,6 @@
|
||||
- [home](README.md)
|
||||
- [quickstart](quickstart.md)
|
||||
- [responses](responses.md)
|
||||
- [routes](routes.md)
|
||||
- [static files](static_files.md)
|
||||
- middleware
|
||||
- [overview](middleware/overview.md)
|
||||
- [session](middleware/sessions.md)
|
||||
@ -10,4 +8,3 @@
|
||||
- [cors](middleware/cors.md)
|
||||
- [pydantic](middleware/pydantic.md)
|
||||
- [writing your own](middleware/custom_middleware.md)
|
||||
- [databases](db.md)
|
||||
|
126
docs/db.md
126
docs/db.md
@ -1,125 +1,3 @@
|
||||
# databases
|
||||
# db
|
||||
|
||||
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
|
||||
```
|
||||
...
|
||||
|
@ -2,18 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Document</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
|
||||
<meta name="description" content="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" />
|
||||
<meta name="description" content="Description">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/_media/Favicon-32x32.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
|
||||
<script defer data-domain="itsthejoker.github.io/spiderweb" src="https://plausible.io/js/script.hash.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<style>
|
||||
@ -69,5 +63,6 @@
|
||||
<!-- click to copy in code blocks -->
|
||||
<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 defer data-domain="itsthejoker.github.io/spiderweb" src="https://plausible.io/js/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,5 +1,3 @@
|
||||
from spiderweb import HttpResponse
|
||||
|
||||
# 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:
|
||||
@ -28,7 +26,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.
|
||||
|
||||
## process_request(self, request: Request) -> Optional[HttpResponse]:
|
||||
## process_request(self, request):
|
||||
|
||||
`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.
|
||||
|
||||
@ -47,74 +45,15 @@ 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.
|
||||
|
||||
## process_response(self, request: Request, response: HttpResponse) -> None:
|
||||
## process_response(self, request, response):
|
||||
|
||||
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.
|
||||
|
||||
## on_error(self, request: Request, triggered_exception: Exception) -> Optional[HttpResponse]:
|
||||
## on_error(self, request, triggered_exception):
|
||||
|
||||
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",
|
||||
)
|
||||
```
|
||||
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.
|
||||
|
||||
## checks
|
||||
|
||||
@ -170,4 +109,4 @@ List as many checks as you need there, and the server will run all of them durin
|
||||
from spiderweb.exceptions import UnusedMiddleware
|
||||
```
|
||||
|
||||
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!
|
||||
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!
|
||||
|
199
docs/routes.md
199
docs/routes.md
@ -1,199 +0,0 @@
|
||||
# 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.
|
@ -1,69 +0,0 @@
|
||||
# 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`.
|
10
example.py
10
example.py
@ -22,14 +22,9 @@ app = SpiderwebRouter(
|
||||
"example_middleware.RedirectMiddleware",
|
||||
"spiderweb.middleware.pydantic.PydanticMiddleware",
|
||||
"example_middleware.ExplodingMiddleware",
|
||||
# "example_middleware.CaseTransformMiddleware",
|
||||
],
|
||||
staticfiles_dirs=["static_files"],
|
||||
append_slash=False, # default
|
||||
cors_allow_all_origins=True,
|
||||
static_url="static_stuff",
|
||||
debug=True,
|
||||
case_transform_middleware_type="spongebob",
|
||||
)
|
||||
|
||||
|
||||
@ -38,11 +33,6 @@ def index(request):
|
||||
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")
|
||||
def redirect(request):
|
||||
return RedirectResponse("/")
|
||||
|
@ -1,6 +1,3 @@
|
||||
import random
|
||||
|
||||
from spiderweb import ConfigError
|
||||
from spiderweb.exceptions import UnusedMiddleware
|
||||
from spiderweb.middleware import SpiderwebMiddleware
|
||||
from spiderweb.request import Request
|
||||
@ -27,35 +24,3 @@ class RedirectMiddleware(SpiderwebMiddleware):
|
||||
class ExplodingMiddleware(SpiderwebMiddleware):
|
||||
def process_request(self, request: Request) -> HttpResponse | None:
|
||||
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
422
poetry.lock
generated
@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
@ -76,78 +76,78 @@ uvloop = ["uvloop (>=0.15.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "1.17.1"
|
||||
version = "1.17.0"
|
||||
description = "Foreign Function Interface for Python calling C code."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
|
||||
{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.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
|
||||
{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.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
|
||||
{file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
|
||||
{file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
|
||||
{file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
|
||||
{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.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
|
||||
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
|
||||
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
|
||||
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
|
||||
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
|
||||
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
|
||||
{file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
|
||||
{file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
|
||||
{file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
|
||||
{file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
|
||||
{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.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
|
||||
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
|
||||
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
|
||||
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
|
||||
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
|
||||
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
|
||||
{file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
|
||||
{file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
|
||||
{file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
|
||||
{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.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
|
||||
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
|
||||
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
|
||||
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
|
||||
{file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
|
||||
{file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
|
||||
{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.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
|
||||
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
|
||||
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"},
|
||||
{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.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"},
|
||||
{file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"},
|
||||
{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.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"},
|
||||
{file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"},
|
||||
{file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"},
|
||||
{file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"},
|
||||
{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.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"},
|
||||
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"},
|
||||
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"},
|
||||
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"},
|
||||
{file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"},
|
||||
{file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"},
|
||||
{file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"},
|
||||
{file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"},
|
||||
{file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"},
|
||||
{file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"},
|
||||
{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.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"},
|
||||
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"},
|
||||
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"},
|
||||
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"},
|
||||
{file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"},
|
||||
{file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"},
|
||||
{file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"},
|
||||
{file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"},
|
||||
{file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"},
|
||||
{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.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"},
|
||||
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"},
|
||||
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"},
|
||||
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"},
|
||||
{file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"},
|
||||
{file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"},
|
||||
{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.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"},
|
||||
{file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"},
|
||||
{file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -264,38 +264,38 @@ toml = ["tomli"]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "43.0.1"
|
||||
version = "43.0.0"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"},
|
||||
{file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"},
|
||||
{file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"},
|
||||
{file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"},
|
||||
{file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"},
|
||||
{file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"},
|
||||
{file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"},
|
||||
{file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"},
|
||||
{file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"},
|
||||
{file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"},
|
||||
{file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"},
|
||||
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"},
|
||||
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"},
|
||||
{file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"},
|
||||
{file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"},
|
||||
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"},
|
||||
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"},
|
||||
{file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"},
|
||||
{file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -308,7 +308,7 @@ nox = ["nox"]
|
||||
pep8test = ["check-sdist", "click", "mypy", "ruff"]
|
||||
sdist = ["build"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
|
||||
test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
@ -369,13 +369,13 @@ tornado = ["tornado (>=0.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.112.1"
|
||||
version = "6.111.2"
|
||||
description = "A library for property-based testing"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "hypothesis-6.112.1-py3-none-any.whl", hash = "sha256:93631b1498b20d2c205ed304cbd41d50e9c069d78a9c773c1324ca094c5e30ce"},
|
||||
{file = "hypothesis-6.112.1.tar.gz", hash = "sha256:b070d7a1bb9bd84706c31885c9aeddc138e2b36a9c112a91984f49501c567856"},
|
||||
{file = "hypothesis-6.111.2-py3-none-any.whl", hash = "sha256:055e8228958e22178d6077e455fd86a72044d02dac130dbf9c8b31e161b9809c"},
|
||||
{file = "hypothesis-6.111.2.tar.gz", hash = "sha256:0496ad28c7240ee9ba89fcc7fb1dc74e89f3e40fbcbbb5f73c0091558dec8e6e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -401,18 +401,15 @@ zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2024.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
version = "3.7"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
||||
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
||||
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
|
||||
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.0.0"
|
||||
@ -555,19 +552,19 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.6"
|
||||
version = "4.2.2"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
|
||||
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
|
||||
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
|
||||
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
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 (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
|
||||
type = ["mypy (>=1.11.2)"]
|
||||
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
|
||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
|
||||
type = ["mypy (>=1.8)"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
@ -597,18 +594,18 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.9.2"
|
||||
version = "2.8.2"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"},
|
||||
{file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"},
|
||||
{file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"},
|
||||
{file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
pydantic-core = "2.23.4"
|
||||
annotated-types = ">=0.4.0"
|
||||
pydantic-core = "2.20.1"
|
||||
typing-extensions = [
|
||||
{version = ">=4.12.2", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=4.6.1", markers = "python_version < \"3.13\""},
|
||||
@ -616,104 +613,103 @@ typing-extensions = [
|
||||
|
||||
[package.extras]
|
||||
email = ["email-validator (>=2.0.0)"]
|
||||
timezone = ["tzdata"]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.23.4"
|
||||
version = "2.20.1"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"},
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"},
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"},
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"},
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"},
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"},
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"},
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"},
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"},
|
||||
{file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"},
|
||||
{file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"},
|
||||
{file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"},
|
||||
{file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"},
|
||||
{file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"},
|
||||
{file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"},
|
||||
{file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"},
|
||||
{file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"},
|
||||
{file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"},
|
||||
{file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"},
|
||||
{file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"},
|
||||
{file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"},
|
||||
{file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"},
|
||||
{file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"},
|
||||
{file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"},
|
||||
{file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"},
|
||||
{file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"},
|
||||
{file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"},
|
||||
{file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"},
|
||||
{file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"},
|
||||
{file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"},
|
||||
{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.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"},
|
||||
{file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"},
|
||||
{file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"},
|
||||
{file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"},
|
||||
{file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"},
|
||||
{file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"},
|
||||
{file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"},
|
||||
{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.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"},
|
||||
{file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"},
|
||||
{file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"},
|
||||
{file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"},
|
||||
{file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"},
|
||||
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"},
|
||||
{file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"},
|
||||
{file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"},
|
||||
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"},
|
||||
{file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"},
|
||||
{file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"},
|
||||
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"},
|
||||
{file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"},
|
||||
{file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"},
|
||||
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"},
|
||||
{file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"},
|
||||
{file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"},
|
||||
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"},
|
||||
{file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"},
|
||||
{file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"},
|
||||
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"},
|
||||
{file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"},
|
||||
{file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"},
|
||||
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"},
|
||||
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"},
|
||||
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"},
|
||||
{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.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"},
|
||||
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"},
|
||||
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"},
|
||||
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"},
|
||||
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"},
|
||||
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"},
|
||||
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"},
|
||||
{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.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"},
|
||||
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"},
|
||||
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"},
|
||||
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"},
|
||||
{file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -721,13 +717,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.3.3"
|
||||
version = "8.3.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
|
||||
{file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
|
||||
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
|
||||
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "spiderweb-framework"
|
||||
version = "1.3.1"
|
||||
version = "0.12.0"
|
||||
description = "A small web framework, just big enough for a spider."
|
||||
authors = ["Joe Kaufeld <opensource@joekaufeld.com>"]
|
||||
readme = "README.md"
|
||||
@ -62,7 +62,7 @@ addopts = ["--maxfail=2", "-rf"]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
omit = ["conftest.py", "spiderweb/tests/*"]
|
||||
omit = ["conftest.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
# Regexes for lines to exclude from consideration
|
||||
@ -81,8 +81,6 @@ exclude_also = [
|
||||
|
||||
# Don't complain about abstract methods, they aren't run:
|
||||
"@(abc\\.)?abstractmethod",
|
||||
# Type checking lines are never run:
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
]
|
||||
|
||||
ignore_errors = true
|
@ -1,8 +1,8 @@
|
||||
from peewee import DatabaseProxy
|
||||
|
||||
DEFAULT_ALLOWED_METHODS = ["POST", "GET", "PUT", "PATCH", "DELETE"]
|
||||
DEFAULT_ALLOWED_METHODS = ["GET"]
|
||||
DEFAULT_ENCODING = "UTF-8"
|
||||
__version__ = "1.3.1"
|
||||
__version__ = "0.12.0"
|
||||
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
|
||||
REGEX_COOKIE_NAME = r"^[a-zA-Z0-9\s\(\)<>@,;:\/\\\[\]\?=\{\}\"\t]*$"
|
||||
|
@ -20,11 +20,3 @@ class FloatConverter:
|
||||
|
||||
def to_python(self, value):
|
||||
return float(value)
|
||||
|
||||
|
||||
class PathConverter:
|
||||
regex = r".+"
|
||||
name = "path"
|
||||
|
||||
def to_python(self, value):
|
||||
return str(value)
|
||||
|
@ -1,4 +1,4 @@
|
||||
from peewee import Model, Field, SchemaManager
|
||||
from peewee import Model, Field, SchemaManager, DatabaseProxy
|
||||
|
||||
from spiderweb.constants import DATABASE_PROXY
|
||||
|
||||
@ -13,9 +13,6 @@ class SpiderwebModel(Model):
|
||||
|
||||
@classmethod
|
||||
def check_for_needed_migration(cls):
|
||||
if hasattr(cls._meta, "skip_migration_check"):
|
||||
return
|
||||
|
||||
current_model_fields: dict[str, Field] = cls._meta.fields
|
||||
current_db_fields = {
|
||||
c.name: {
|
||||
@ -67,7 +64,7 @@ class SpiderwebModel(Model):
|
||||
)
|
||||
)
|
||||
if field_obj.__class__.__name__ == "BooleanField":
|
||||
if field_obj.default is False and db_version["default"] not in (
|
||||
if field_obj.default == False and db_version["default"] not in (
|
||||
False,
|
||||
None,
|
||||
0,
|
||||
@ -77,7 +74,7 @@ class SpiderwebModel(Model):
|
||||
f"BooleanField `{field_name}` has changed the default value."
|
||||
)
|
||||
)
|
||||
elif field_obj.default is True and db_version["default"] not in (
|
||||
elif field_obj.default == True and db_version["default"] not in (
|
||||
True,
|
||||
1,
|
||||
):
|
||||
|
@ -90,7 +90,3 @@ class NoResponseError(SpiderwebException):
|
||||
|
||||
class StartupErrors(ExceptionGroup):
|
||||
pass
|
||||
|
||||
|
||||
class ReverseNotFound(SpiderwebException):
|
||||
pass
|
||||
|
@ -1,16 +0,0 @@
|
||||
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
|
@ -1,19 +0,0 @@
|
||||
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)
|
@ -36,7 +36,7 @@ class LocalServerMixin:
|
||||
|
||||
def start(self, blocking=False):
|
||||
signal.signal(signal.SIGINT, self.signal_handler)
|
||||
self.log.info(f"Starting server on http://{self.addr}:{self.port}")
|
||||
self.log.info(f"Starting server on {self.addr}:{self.port}")
|
||||
self.log.info("Press CTRL+C to stop the server.")
|
||||
self._server = self.create_server()
|
||||
self._thread = threading.Thread(target=self._server.serve_forever)
|
||||
|
@ -6,10 +6,10 @@ import traceback
|
||||
import urllib.parse as urlparse
|
||||
from logging import Logger
|
||||
from threading import Thread
|
||||
from typing import Optional, Callable, Sequence, Literal
|
||||
from typing import Optional, Callable, Sequence, LiteralString, Literal
|
||||
from wsgiref.simple_server import WSGIServer
|
||||
|
||||
from jinja2 import BaseLoader, FileSystemLoader
|
||||
from jinja2 import BaseLoader, Environment, FileSystemLoader
|
||||
from peewee import Database, SqliteDatabase
|
||||
|
||||
from spiderweb.middleware import MiddlewareMixin
|
||||
@ -23,13 +23,7 @@ from spiderweb.constants import (
|
||||
DEFAULT_ALLOWED_METHODS,
|
||||
)
|
||||
from spiderweb.db import SpiderwebModel
|
||||
from spiderweb.default_views import (
|
||||
http403, # noqa: F401
|
||||
http404, # noqa: F401
|
||||
http405, # noqa: F401
|
||||
http500, # noqa: F401
|
||||
send_file,
|
||||
)
|
||||
from spiderweb.default_views import * # noqa: F403
|
||||
from spiderweb.exceptions import (
|
||||
ConfigError,
|
||||
NotFound,
|
||||
@ -37,7 +31,6 @@ from spiderweb.exceptions import (
|
||||
NoResponseError,
|
||||
SpiderwebNetworkException,
|
||||
)
|
||||
from spiderweb.jinja_core import SpiderwebEnvironment
|
||||
from spiderweb.local_server import LocalServerMixin
|
||||
from spiderweb.request import Request
|
||||
from spiderweb.response import HttpResponse, TemplateResponse, JsonResponse
|
||||
@ -57,7 +50,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
|
||||
port: int = None,
|
||||
allowed_hosts: Sequence[str | re.Pattern] = None,
|
||||
cors_allowed_origins: Sequence[str] = None,
|
||||
cors_allowed_origin_regexes: Sequence[str] = None,
|
||||
cors_allowed_origins_regexes: Sequence[str] = None,
|
||||
cors_allow_all_origins: bool = False,
|
||||
cors_urls_regex: str | re.Pattern[str] = r"^.*$",
|
||||
cors_allow_methods: Sequence[str] = None,
|
||||
@ -68,12 +61,10 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
|
||||
cors_allow_private_network: bool = False,
|
||||
csrf_trusted_origins: Sequence[str] = None,
|
||||
db: Optional[Database] = None,
|
||||
debug: bool = False,
|
||||
templates_dirs: Sequence[str] = None,
|
||||
middleware: Sequence[str] = None,
|
||||
append_slash: bool = False,
|
||||
staticfiles_dirs: Sequence[str] = None,
|
||||
static_url: str = "static",
|
||||
routes: Sequence[tuple[str, Callable] | tuple[str, Callable, dict]] = None,
|
||||
error_routes: dict[int, Callable] = None,
|
||||
secret_key: str = None,
|
||||
@ -96,7 +87,6 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
|
||||
self.append_slash = append_slash
|
||||
self.templates_dirs = templates_dirs
|
||||
self.staticfiles_dirs = staticfiles_dirs
|
||||
self.static_url = static_url
|
||||
self._middleware: list[str] = middleware or []
|
||||
self.middleware: list[Callable] = []
|
||||
self.secret_key = secret_key if secret_key else self.generate_key()
|
||||
@ -104,7 +94,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
|
||||
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_origin_regexes = cors_allowed_origin_regexes or []
|
||||
self.cors_allowed_origins_regexes = cors_allowed_origins_regexes or []
|
||||
self.cors_allow_all_origins = cors_allow_all_origins
|
||||
self.cors_urls_regex = cors_urls_regex
|
||||
self.cors_allow_methods = cors_allow_methods or DEFAULT_CORS_ALLOW_METHODS
|
||||
@ -119,8 +109,6 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
|
||||
convert_url_to_regex(i) for i in self._csrf_trusted_origins
|
||||
]
|
||||
|
||||
self.debug = debug
|
||||
|
||||
self.extra_data = kwargs
|
||||
|
||||
# session middleware
|
||||
@ -156,27 +144,15 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
|
||||
if self.error_routes:
|
||||
self.add_error_routes()
|
||||
|
||||
template_env_args = {
|
||||
"server": self,
|
||||
"extensions": [
|
||||
"spiderweb.jinja_extensions.StaticFilesExtension",
|
||||
],
|
||||
}
|
||||
|
||||
if self.templates_dirs:
|
||||
self.template_loader = SpiderwebEnvironment(
|
||||
loader=FileSystemLoader(self.templates_dirs),
|
||||
**template_env_args,
|
||||
self.template_loader = Environment(
|
||||
loader=FileSystemLoader(self.templates_dirs)
|
||||
)
|
||||
else:
|
||||
self.template_loader = None
|
||||
self.string_loader = SpiderwebEnvironment(
|
||||
loader=BaseLoader(), **template_env_args
|
||||
)
|
||||
self.string_loader = Environment(loader=BaseLoader())
|
||||
|
||||
if self.staticfiles_dirs:
|
||||
if not isinstance(self.staticfiles_dirs, list):
|
||||
self.staticfiles_dirs = [self.staticfiles_dirs]
|
||||
for static_dir in self.staticfiles_dirs:
|
||||
static_dir = pathlib.Path(static_dir)
|
||||
if not pathlib.Path(self.BASE_DIR / static_dir).exists():
|
||||
@ -184,16 +160,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
|
||||
f"Static files directory '{str(static_dir)}' does not exist."
|
||||
)
|
||||
raise ConfigError
|
||||
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."
|
||||
)
|
||||
self.add_route(r"/static/<str:filename>", send_file) # noqa: F405
|
||||
|
||||
# finally, run the startup checks to verify everything is correct and happy.
|
||||
self.log.info("Run startup checks...")
|
||||
@ -201,41 +168,31 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
|
||||
|
||||
def fire_response(self, start_response, request: Request, resp: HttpResponse):
|
||||
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)
|
||||
cookies = []
|
||||
varies = []
|
||||
resp.headers = {k.replace("_", "-"): v for k, v in resp.headers.items()}
|
||||
if "set-cookie" in resp.headers:
|
||||
cookies = resp.headers["set-cookie"]
|
||||
del resp.headers["set-cookie"]
|
||||
if "vary" in resp.headers:
|
||||
varies = resp.headers["vary"]
|
||||
del resp.headers["vary"]
|
||||
resp.headers = {k: str(v) for k, v in resp.headers.items()}
|
||||
headers = list(resp.headers.items())
|
||||
for c in cookies:
|
||||
headers.append(("set-cookie", str(c)))
|
||||
headers.append(("Set-Cookie", c))
|
||||
for v in varies:
|
||||
headers.append(("vary", str(v)))
|
||||
headers.append(("Vary", v))
|
||||
|
||||
if not isinstance(final_output, list):
|
||||
final_output = [final_output]
|
||||
start_response(status, headers)
|
||||
|
||||
rendered_output = resp.render()
|
||||
if not isinstance(rendered_output, list):
|
||||
rendered_output = [rendered_output]
|
||||
encoded_resp = [
|
||||
chunk.encode(DEFAULT_ENCODING) if isinstance(chunk, str) else chunk
|
||||
for chunk in final_output
|
||||
for chunk in rendered_output
|
||||
]
|
||||
start_response(status, headers)
|
||||
|
||||
return encoded_resp
|
||||
except APIError:
|
||||
raise
|
||||
@ -314,6 +271,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
|
||||
def __call__(self, environ, start_response, *args, **kwargs):
|
||||
"""Entry point for WSGI apps."""
|
||||
request = self.get_request(environ)
|
||||
|
||||
try:
|
||||
handler, additional_args, allowed_methods = self.get_route(request.path)
|
||||
except NotFound:
|
||||
|
@ -2,6 +2,9 @@ from typing import Callable, ClassVar
|
||||
import sys
|
||||
|
||||
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 ..request import Request
|
||||
from ..response import HttpResponse
|
||||
@ -35,7 +38,7 @@ class MiddlewareMixin:
|
||||
|
||||
if errors:
|
||||
# just show the messages
|
||||
sys.tracebacklimit = 1
|
||||
sys.tracebacklimit = 0
|
||||
raise StartupErrors(
|
||||
"Problems were identified during startup — cannot continue.", errors
|
||||
)
|
||||
@ -60,18 +63,3 @@ class MiddlewareMixin:
|
||||
except UnusedMiddleware:
|
||||
self.middleware.remove(middleware)
|
||||
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
|
||||
|
@ -9,8 +9,6 @@ class SpiderwebMiddleware:
|
||||
|
||||
process_request(self, request) -> None or Response
|
||||
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.
|
||||
If one of the two methods is not defined, the request or resp will be passed
|
||||
@ -24,26 +22,12 @@ class SpiderwebMiddleware:
|
||||
self.server = server
|
||||
|
||||
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
|
||||
|
||||
def process_response(self, request: Request, response: HttpResponse) -> None:
|
||||
# This method is called after the view has returned a response. You can modify
|
||||
# the response in this method. The response will be returned to the client after
|
||||
# all middleware has been processed.
|
||||
def process_response(
|
||||
self, request: Request, response: HttpResponse
|
||||
) -> HttpResponse | None:
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
@ -23,11 +23,13 @@ class VerifyValidCorsSetting(ServerCheck):
|
||||
" `cors_allowed_origins`, `cors_allowed_origin_regexes`, or"
|
||||
" `cors_allow_all_origins`.",
|
||||
)
|
||||
|
||||
def check(self):
|
||||
# - `cors_allowed_origins`
|
||||
# - `cors_allowed_origin_regexes`
|
||||
# - `cors_allow_all_origins`
|
||||
if (
|
||||
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
|
||||
):
|
||||
return ConfigError(self.INVALID_BASE_CONFIG)
|
||||
@ -49,6 +51,7 @@ class CorsMiddleware(SpiderwebMiddleware):
|
||||
enabled = getattr(request, "_cors_enabled", None)
|
||||
if enabled is None:
|
||||
enabled = self.is_enabled(request)
|
||||
|
||||
if not enabled:
|
||||
return response
|
||||
|
||||
@ -57,7 +60,7 @@ class CorsMiddleware(SpiderwebMiddleware):
|
||||
else:
|
||||
response.headers["vary"] = ["origin"]
|
||||
|
||||
origin = request.headers.get("http_origin")
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return response
|
||||
|
||||
@ -99,12 +102,10 @@ class CorsMiddleware(SpiderwebMiddleware):
|
||||
response.headers[ACCESS_CONTROL_MAX_AGE] = str(
|
||||
self.server.cors_preflight_max_age
|
||||
)
|
||||
|
||||
if (
|
||||
self.server.cors_allow_private_network
|
||||
and request.headers.get(
|
||||
ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK.replace("-", "_")
|
||||
)
|
||||
== "true"
|
||||
and request.headers.get(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK) == "true"
|
||||
):
|
||||
response.headers[ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK] = "true"
|
||||
|
||||
@ -132,8 +133,8 @@ class CorsMiddleware(SpiderwebMiddleware):
|
||||
|
||||
def process_request(self, request: Request) -> HttpResponse | None:
|
||||
# Identify and handle a preflight request
|
||||
# origin = request.META.get("HTTP_ORIGIN")
|
||||
request._cors_enabled = self.is_enabled(request)
|
||||
request.META["cors_ran"] = True
|
||||
if (
|
||||
request._cors_enabled
|
||||
and request.method == "OPTIONS"
|
||||
@ -149,13 +150,9 @@ class CorsMiddleware(SpiderwebMiddleware):
|
||||
self.add_response_headers(request, resp)
|
||||
return resp
|
||||
|
||||
def process_response(self, request: Request, response: HttpResponse) -> None:
|
||||
if not request.META.get("cors_ran"):
|
||||
# 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
|
||||
def process_response(
|
||||
self, request: Request, response: HttpResponse
|
||||
) -> None:
|
||||
self.add_response_headers(request, response)
|
||||
|
||||
|
||||
# [204]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS#status_code
|
||||
|
@ -72,9 +72,7 @@ class CSRFMiddleware(SpiderwebMiddleware):
|
||||
|
||||
def is_trusted_origin(self, request) -> bool:
|
||||
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")
|
||||
|
||||
if not origin and not (host == referrer):
|
||||
@ -90,12 +88,13 @@ class CSRFMiddleware(SpiderwebMiddleware):
|
||||
|
||||
def process_request(self, request: Request) -> HttpResponse | None:
|
||||
if request.method == "POST":
|
||||
|
||||
if hasattr(request.handler, "csrf_exempt"):
|
||||
if request.handler.csrf_exempt is True:
|
||||
return
|
||||
|
||||
csrf_token = (
|
||||
request.headers.get("x-csrf-token")
|
||||
request.headers.get("X-CSRF-TOKEN")
|
||||
or request.GET.get("csrf_token")
|
||||
or request.POST.get("csrf_token")
|
||||
)
|
||||
@ -110,7 +109,7 @@ class CSRFMiddleware(SpiderwebMiddleware):
|
||||
def process_response(self, request: Request, response: HttpResponse) -> None:
|
||||
token = self.get_csrf_token(request)
|
||||
# do we need it in both places?
|
||||
response.headers["x-csrf-token"] = token
|
||||
response.headers["X-CSRF-TOKEN"] = token
|
||||
response.context |= {
|
||||
"csrf_token": f"""<input type="hidden" name="csrf_token" value="{token}">""",
|
||||
"raw_csrf_token": token, # in case they want to format it themselves
|
||||
|
@ -29,10 +29,8 @@ class HttpResponse:
|
||||
self.data = data
|
||||
self.context = context if context else {}
|
||||
self.status_code = status_code
|
||||
self._headers = headers if headers else {}
|
||||
self.headers = Headers()
|
||||
for k, v in self._headers.items():
|
||||
self.headers[k.lower()] = v
|
||||
self.headers = headers if headers else {}
|
||||
self.headers = Headers(**{k.lower(): v for k, v in self.headers.items()})
|
||||
if not self.headers.get("content-type"):
|
||||
self.headers["content-type"] = "text/html; charset=utf-8"
|
||||
self.headers["server"] = "Spiderweb"
|
||||
|
@ -1,16 +1,10 @@
|
||||
import re
|
||||
from typing import Callable, Any, Sequence
|
||||
from typing import Callable, Any, Optional, Sequence
|
||||
|
||||
from spiderweb.constants import DEFAULT_ALLOWED_METHODS
|
||||
from spiderweb.converters import * # noqa: F403
|
||||
from spiderweb.default_views import * # noqa: F403
|
||||
from spiderweb.exceptions import (
|
||||
NotFound,
|
||||
ConfigError,
|
||||
ParseError,
|
||||
SpiderwebException,
|
||||
ReverseNotFound,
|
||||
)
|
||||
from spiderweb.exceptions import NotFound, ConfigError, ParseError
|
||||
from spiderweb.response import RedirectResponse
|
||||
|
||||
|
||||
@ -41,7 +35,7 @@ class RoutesMixin:
|
||||
error_routes: dict[int, Callable]
|
||||
append_slash: bool
|
||||
|
||||
def route(self, path, allowed_methods=None, name=None) -> Callable:
|
||||
def route(self, path, allowed_methods=None) -> Callable:
|
||||
"""
|
||||
Decorator for adding a route to a view.
|
||||
|
||||
@ -55,12 +49,11 @@ class RoutesMixin:
|
||||
|
||||
:param path: str
|
||||
:param allowed_methods: list[str]
|
||||
:param name: str
|
||||
:return: Callable
|
||||
"""
|
||||
|
||||
def outer(func):
|
||||
self.add_route(path, func, allowed_methods, name)
|
||||
self.add_route(path, func, allowed_methods)
|
||||
return func
|
||||
|
||||
return outer
|
||||
@ -122,11 +115,7 @@ class RoutesMixin:
|
||||
return re.compile(rf"^{'/'.join(parts)}$")
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
path: str,
|
||||
method: Callable,
|
||||
allowed_methods: None | list[str] = None,
|
||||
name: str = None,
|
||||
self, path: str, method: Callable, allowed_methods: None | list[str] = None
|
||||
):
|
||||
"""Add a route to the server."""
|
||||
allowed_methods = (
|
||||
@ -135,27 +124,24 @@ class RoutesMixin:
|
||||
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("/"):
|
||||
updated_path = path + "/"
|
||||
self.check_for_route_duplicates(updated_path)
|
||||
self.check_for_route_duplicates(path)
|
||||
self._routes[self.convert_path(path)] = get_packet(
|
||||
DummyRedirectRoute(updated_path)
|
||||
)
|
||||
self._routes[self.convert_path(updated_path)] = get_packet(method)
|
||||
self._routes[self.convert_path(path)] = {
|
||||
"func": DummyRedirectRoute(updated_path),
|
||||
"allowed_methods": allowed_methods,
|
||||
}
|
||||
self._routes[self.convert_path(updated_path)] = {
|
||||
"func": method,
|
||||
"allowed_methods": allowed_methods,
|
||||
}
|
||||
else:
|
||||
self.check_for_route_duplicates(path)
|
||||
self._routes[self.convert_path(path)] = get_packet(method)
|
||||
self._routes[self.convert_path(path)] = {
|
||||
"func": method,
|
||||
"allowed_methods": allowed_methods,
|
||||
}
|
||||
|
||||
def add_routes(self):
|
||||
for line in self.routes:
|
||||
@ -170,27 +156,3 @@ class RoutesMixin:
|
||||
def add_error_routes(self):
|
||||
for code, func in self.error_routes.items():
|
||||
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.")
|
||||
|
@ -0,0 +1,4 @@
|
||||
from spiderweb.tests.middleware import (
|
||||
ExplodingResponseMiddleware,
|
||||
ExplodingRequestMiddleware,
|
||||
)
|
@ -15,16 +15,14 @@ class StartResponse:
|
||||
self.headers = headers
|
||||
|
||||
def get_headers(self):
|
||||
return {h[0]: h[1] for h in self.headers} if self.headers else {}
|
||||
return {h[0]: h[1] for h in self.headers}
|
||||
|
||||
|
||||
def setup(**kwargs):
|
||||
def setup():
|
||||
environ = {}
|
||||
setup_testing_defaults(environ)
|
||||
if "db" not in kwargs:
|
||||
kwargs["db"] = SqliteDatabase("spiderweb-tests.db")
|
||||
return (
|
||||
SpiderwebRouter(**kwargs),
|
||||
SpiderwebRouter(db=SqliteDatabase("spiderweb-tests.db")),
|
||||
environ,
|
||||
StartResponse(),
|
||||
)
|
||||
|
@ -11,30 +11,3 @@ class ExplodingResponseMiddleware(SpiderwebMiddleware):
|
||||
self, request: Request, response: HttpResponse
|
||||
) -> HttpResponse | None:
|
||||
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!")
|
||||
|
@ -1 +0,0 @@
|
||||
hi
|
@ -1,6 +0,0 @@
|
||||
.body {
|
||||
background-color: #f0f0f0;
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
@ -1,149 +0,0 @@
|
||||
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")
|
@ -1,35 +0,0 @@
|
||||
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)]
|
@ -4,17 +4,8 @@ from datetime import timedelta
|
||||
import pytest
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from spiderweb import SpiderwebRouter, HttpResponse, StartupErrors, ConfigError
|
||||
from spiderweb import SpiderwebRouter, HttpResponse, ConfigError, StartupErrors
|
||||
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 import csrf
|
||||
from spiderweb.tests.helpers import setup
|
||||
@ -22,11 +13,21 @@ from spiderweb.tests.views_for_tests import (
|
||||
form_view_with_csrf,
|
||||
form_csrf_exempt,
|
||||
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):
|
||||
if "value" in request.SESSION:
|
||||
request.SESSION["value"] += 1
|
||||
@ -36,8 +37,10 @@ def index(request):
|
||||
|
||||
|
||||
def test_session_middleware():
|
||||
app, environ, start_response = setup(
|
||||
_, environ, start_response = setup()
|
||||
app = SpiderwebRouter(
|
||||
middleware=["spiderweb.middleware.sessions.SessionMiddleware"],
|
||||
db=SqliteDatabase("spiderweb-tests.db"),
|
||||
)
|
||||
|
||||
app.add_route("/", index)
|
||||
@ -55,8 +58,10 @@ def test_session_middleware():
|
||||
|
||||
|
||||
def test_expired_session():
|
||||
app, environ, start_response = setup(
|
||||
_, environ, start_response = setup()
|
||||
app = SpiderwebRouter(
|
||||
middleware=["spiderweb.middleware.sessions.SessionMiddleware"],
|
||||
db=SqliteDatabase("spiderweb-tests.db"),
|
||||
)
|
||||
|
||||
app.add_route("/", index)
|
||||
@ -80,11 +85,13 @@ def test_expired_session():
|
||||
|
||||
|
||||
def test_exploding_middleware():
|
||||
app, environ, start_response = setup(
|
||||
_, environ, start_response = setup()
|
||||
app = SpiderwebRouter(
|
||||
middleware=[
|
||||
"spiderweb.tests.middleware.ExplodingRequestMiddleware",
|
||||
"spiderweb.tests.middleware.ExplodingResponseMiddleware",
|
||||
],
|
||||
db=SqliteDatabase("spiderweb-tests.db"),
|
||||
)
|
||||
|
||||
app.add_route("/", index)
|
||||
@ -94,14 +101,8 @@ def test_exploding_middleware():
|
||||
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():
|
||||
_, environ, start_response = setup()
|
||||
with pytest.raises(StartupErrors) as e:
|
||||
SpiderwebRouter(
|
||||
middleware=["spiderweb.middleware.csrf.CSRFMiddleware"],
|
||||
@ -115,14 +116,15 @@ def test_csrf_middleware_without_session_middleware():
|
||||
|
||||
|
||||
def test_csrf_middleware_above_session_middleware():
|
||||
_, environ, start_response = setup()
|
||||
with pytest.raises(StartupErrors) as e:
|
||||
app, environ, start_response = setup(
|
||||
SpiderwebRouter(
|
||||
middleware=[
|
||||
"spiderweb.middleware.csrf.CSRFMiddleware",
|
||||
"spiderweb.middleware.sessions.SessionMiddleware",
|
||||
],
|
||||
db=SqliteDatabase("spiderweb-tests.db"),
|
||||
)
|
||||
|
||||
exceptiongroup = e.value.args[1]
|
||||
assert (
|
||||
exceptiongroup[0].args[0]
|
||||
@ -131,11 +133,13 @@ def test_csrf_middleware_above_session_middleware():
|
||||
|
||||
|
||||
def test_csrf_middleware():
|
||||
app, environ, start_response = setup(
|
||||
_, environ, start_response = setup()
|
||||
app = SpiderwebRouter(
|
||||
middleware=[
|
||||
"spiderweb.middleware.sessions.SessionMiddleware",
|
||||
"spiderweb.middleware.csrf.CSRFMiddleware",
|
||||
],
|
||||
db=SqliteDatabase("spiderweb-tests.db"),
|
||||
)
|
||||
|
||||
app.add_route("/", form_view_with_csrf, ["GET", "POST"])
|
||||
@ -171,12 +175,12 @@ def test_csrf_middleware():
|
||||
assert "bob" in resp2
|
||||
|
||||
# test that it raises a CSRF error on wrong token
|
||||
formdata = "name=bob&csrf_token=badtoken"
|
||||
formdata = f"name=bob&csrf_token=badtoken"
|
||||
b_handle = BytesIO()
|
||||
b_handle.write(formdata.encode(DEFAULT_ENCODING))
|
||||
b_handle.seek(0)
|
||||
|
||||
environ["wsgi.input"] = BufferedReader(b_handle)
|
||||
environ["HTTP_X_CSRF_TOKEN"] = None
|
||||
resp3 = app(environ, start_response)[0].decode(DEFAULT_ENCODING)
|
||||
assert "CSRF token is invalid" in resp3
|
||||
|
||||
@ -194,13 +198,14 @@ def test_csrf_middleware():
|
||||
|
||||
|
||||
def test_csrf_expired_token():
|
||||
app, environ, start_response = setup(
|
||||
_, environ, start_response = setup()
|
||||
app = SpiderwebRouter(
|
||||
middleware=[
|
||||
"spiderweb.middleware.sessions.SessionMiddleware",
|
||||
"spiderweb.middleware.csrf.CSRFMiddleware",
|
||||
],
|
||||
db=SqliteDatabase("spiderweb-tests.db"),
|
||||
)
|
||||
|
||||
app.middleware[1].CSRF_EXPIRY = -1
|
||||
|
||||
app.add_route("/", form_view_with_csrf, ["GET", "POST"])
|
||||
@ -230,11 +235,13 @@ def test_csrf_expired_token():
|
||||
|
||||
|
||||
def test_csrf_exempt():
|
||||
app, environ, start_response = setup(
|
||||
_, environ, start_response = setup()
|
||||
app = SpiderwebRouter(
|
||||
middleware=[
|
||||
"spiderweb.middleware.sessions.SessionMiddleware",
|
||||
"spiderweb.middleware.csrf.CSRFMiddleware",
|
||||
],
|
||||
db=SqliteDatabase("spiderweb-tests.db"),
|
||||
)
|
||||
|
||||
app.add_route("/", form_csrf_exempt, ["GET", "POST"])
|
||||
@ -261,7 +268,8 @@ def test_csrf_exempt():
|
||||
|
||||
|
||||
def test_csrf_trusted_origins():
|
||||
app, environ, start_response = setup(
|
||||
_, environ, start_response = setup()
|
||||
app = SpiderwebRouter(
|
||||
middleware=[
|
||||
"spiderweb.middleware.sessions.SessionMiddleware",
|
||||
"spiderweb.middleware.csrf.CSRFMiddleware",
|
||||
@ -269,7 +277,9 @@ def test_csrf_trusted_origins():
|
||||
csrf_trusted_origins=[
|
||||
"example.com",
|
||||
],
|
||||
db=SqliteDatabase("spiderweb-tests.db"),
|
||||
)
|
||||
|
||||
app.add_route("/", form_view_without_csrf, ["GET", "POST"])
|
||||
|
||||
environ["HTTP_USER_AGENT"] = "hi"
|
||||
@ -296,515 +306,3 @@ def test_csrf_trusted_origins():
|
||||
environ["HTTP_ORIGIN"] = "example.com"
|
||||
resp2 = app(environ, start_response)[0].decode(DEFAULT_ENCODING)
|
||||
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()
|
||||
|
@ -1,20 +1,13 @@
|
||||
import pytest
|
||||
|
||||
from spiderweb import ConfigError
|
||||
from spiderweb import SpiderwebRouter, ConfigError
|
||||
from spiderweb.constants import DEFAULT_ENCODING
|
||||
from spiderweb.exceptions import (
|
||||
NoResponseError,
|
||||
SpiderwebNetworkException,
|
||||
SpiderwebException,
|
||||
ReverseNotFound,
|
||||
GeneralException,
|
||||
)
|
||||
from spiderweb.exceptions import NoResponseError, SpiderwebNetworkException
|
||||
from spiderweb.response import (
|
||||
HttpResponse,
|
||||
JsonResponse,
|
||||
TemplateResponse,
|
||||
RedirectResponse,
|
||||
FileResponse,
|
||||
)
|
||||
from hypothesis import given, strategies as st
|
||||
|
||||
@ -82,13 +75,15 @@ def test_redirect_response():
|
||||
|
||||
|
||||
def test_add_route_at_server_start():
|
||||
app, environ, start_response = setup()
|
||||
|
||||
def index(request):
|
||||
return RedirectResponse(location="/redirected")
|
||||
|
||||
def view2(request):
|
||||
return HttpResponse("View 2")
|
||||
|
||||
app, environ, start_response = setup(
|
||||
app = SpiderwebRouter(
|
||||
routes=[
|
||||
("/", index, {"allowed_methods": ["GET", "POST"], "csrf_exempt": True}),
|
||||
("/view2", view2),
|
||||
@ -100,20 +95,23 @@ def test_add_route_at_server_start():
|
||||
|
||||
|
||||
def test_redirect_on_append_slash():
|
||||
app, environ, start_response = setup(append_slash=True)
|
||||
_, environ, start_response = setup()
|
||||
app = SpiderwebRouter(append_slash=True)
|
||||
|
||||
@app.route("/hello")
|
||||
def index(request):
|
||||
pass
|
||||
|
||||
environ["PATH_INFO"] = "/hello"
|
||||
environ["PATH_INFO"] = f"/hello"
|
||||
assert app(environ, start_response) == [b"None"]
|
||||
assert start_response.get_headers()["location"] == "/hello/"
|
||||
|
||||
|
||||
@given(st.text())
|
||||
def test_template_response_with_template(text):
|
||||
app, environ, start_response = setup(templates_dirs=["spiderweb/tests"])
|
||||
_, environ, start_response = setup()
|
||||
|
||||
app = SpiderwebRouter(templates_dirs=["spiderweb/tests"])
|
||||
|
||||
@app.route("/")
|
||||
def index(request):
|
||||
@ -176,160 +174,11 @@ def test_duplicate_error_view():
|
||||
|
||||
|
||||
def test_missing_view_with_custom_404_alt():
|
||||
_, environ, start_response = setup()
|
||||
|
||||
def custom_404(request):
|
||||
return HttpResponse("Custom 404 2")
|
||||
|
||||
app, environ, start_response = setup(error_routes={404: custom_404})
|
||||
app = SpiderwebRouter(error_routes={404: custom_404})
|
||||
|
||||
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"
|
||||
]
|
||||
|
@ -1,15 +0,0 @@
|
||||
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")
|
@ -1,12 +1,17 @@
|
||||
import pytest
|
||||
|
||||
from spiderweb import SpiderwebRouter
|
||||
from spiderweb.constants import DEFAULT_ENCODING
|
||||
from spiderweb.exceptions import ParseError, ConfigError
|
||||
from spiderweb.response import (
|
||||
HttpResponse,
|
||||
JsonResponse,
|
||||
TemplateResponse,
|
||||
RedirectResponse,
|
||||
)
|
||||
from hypothesis import given, strategies as st, assume
|
||||
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from spiderweb.tests.helpers import setup
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
from spiderweb import HttpResponse
|
||||
from spiderweb.decorators import csrf_exempt
|
||||
from spiderweb.response import JsonResponse, TemplateResponse
|
||||
|
||||
@ -39,11 +38,3 @@ def form_view_with_csrf(request):
|
||||
return JsonResponse(data=request.POST)
|
||||
else:
|
||||
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)
|
||||
|
@ -1,4 +1,3 @@
|
||||
import importlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
@ -14,10 +13,12 @@ VALID_CHARS = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
def import_by_string(name):
|
||||
mod_name, klass_name = name.rsplit(".", 1)
|
||||
module = importlib.import_module(mod_name)
|
||||
klass = getattr(module, klass_name)
|
||||
return klass
|
||||
# https://stackoverflow.com/a/547867
|
||||
components = name.split(".")
|
||||
mod = __import__(components[0])
|
||||
for comp in components[1:]:
|
||||
mod = getattr(mod, comp)
|
||||
return mod
|
||||
|
||||
|
||||
def is_safe_path(path: str) -> bool:
|
||||
@ -66,35 +67,15 @@ def is_jsonable(data: str) -> bool:
|
||||
|
||||
|
||||
class Headers(dict):
|
||||
# special dict that forces lowercase and snake_case for all keys
|
||||
# special dict that forces lowercase for all keys
|
||||
def __getitem__(self, key):
|
||||
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
|
||||
return super().__getitem__(key.lower())
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
return super().__setitem__(key.lower().replace("-", "_"), value)
|
||||
return super().__setitem__(key.lower(), value)
|
||||
|
||||
def get(self, key, default=None):
|
||||
key = key.replace("-", "_")
|
||||
regular = super().get(key.lower(), default)
|
||||
http_version = super().get(f"http_{key.lower()}", default)
|
||||
return regular or http_version
|
||||
return super().get(key.lower(), default)
|
||||
|
||||
def setdefault(self, key, default=None):
|
||||
return super().setdefault(key.lower(), default)
|
||||
|
@ -13,7 +13,7 @@
|
||||
middleware is working.
|
||||
</p>
|
||||
<p>
|
||||
<img src="{% static 'aaaaaa.gif' %}" alt="AAAAAAAAAA">
|
||||
<img src="/static/aaaaaa.gif" alt="AAAAAAAAAA">
|
||||
</p>
|
||||
<p>
|
||||
{{ request.META }}
|
||||
|
40
test.py
Normal file
40
test.py
Normal file
@ -0,0 +1,40 @@
|
||||
from peewee import *
|
||||
from playhouse.migrate import SqliteMigrator, migrate
|
||||
|
||||
from spiderweb.db import SpiderwebModel
|
||||
|
||||
db = SqliteDatabase("people.db")
|
||||
migrator = SqliteMigrator(db)
|
||||
|
||||
|
||||
class Person(SpiderwebModel):
|
||||
name = CharField()
|
||||
birthday = DateField()
|
||||
|
||||
class Meta:
|
||||
database = db # This model uses the "people.db" database.
|
||||
|
||||
|
||||
class Pet(SpiderwebModel):
|
||||
owner = ForeignKeyField(Person, backref="pets")
|
||||
name = CharField(max_length=40)
|
||||
animal_type = CharField()
|
||||
age = IntegerField(null=True)
|
||||
favorite_color = CharField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db # this model uses the "people.db" database
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
db.connect()
|
||||
Pet.check_for_needed_migration()
|
||||
# try:
|
||||
# Pet.check_for_needed_migration()
|
||||
# except:
|
||||
# migrate(
|
||||
# migrator.add_column(
|
||||
# Pet._meta.table_name, 'favorite_color', CharField(null=True)
|
||||
# ),
|
||||
# )
|
||||
db.create_tables([Person, Pet])
|
Loading…
Reference in New Issue
Block a user