Compare commits

...
Sign in to create a new pull request.

36 commits
asgi ... main

Author SHA1 Message Date
e2064ddba7 🧱 switch to hatchling, update docs and dev instructions 2025-10-11 22:23:42 -04:00
1a35ca0ed8 💥 bump version to 2.0.0 because of database switch 2025-10-11 21:56:59 -04:00
66a5f81230 📝 update database docs and improve cookie parsing robustness 2025-10-11 21:49:15 -04:00
b0e69727e2 🔥 remove peewee and migrate to sqlalchemy 2025-10-11 21:49:02 -04:00
ced11ac2da 👽️ add optional pydantic support
Pydantic is now an optional dependency, and the middleware provides
proper import guards and fallback behavior if Pydantic is not installed.
Updated docs to clarify installation and configuration steps.
2025-10-11 20:14:46 -04:00
223c7f3cc6 👽 fix config for poetry 2 2025-06-07 19:34:01 -04:00
3caf7cdb0b 📝 update logo for docs 2025-03-05 17:03:44 -05:00
1a4aafd773 📝 remove IDE-added reference 2024-10-30 12:10:52 -04:00
81a8cd1ee7 📝 fix typo 2024-10-30 00:41:18 -04:00
96f5748565 📝 clarify placement order 2024-10-30 00:40:41 -04:00
24ee9bdda2
Merge pull request #6 from itsthejoker/gzip
Updates to gzip middleware
2024-10-30 00:34:08 -04:00
0cd1bed62c
Merge pull request #5 from martinnedopil/gzip
Gzip Middleware
2024-10-30 00:33:37 -04:00
7740299ad8 🔖 release 1.4.0 2024-10-30 00:32:37 -04:00
991d6be5a3 🎨 run black 2024-10-30 00:32:02 -04:00
557cd39c13 add tests for server checks 2024-10-30 00:31:11 -04:00
ff8f50e44b 📝 expand doc 2024-10-30 00:26:10 -04:00
f4ffa14b00 add tests to cover new middleware 2024-10-30 00:15:32 -04:00
b9ad2467df 📝 clarify custom post_process docs 2024-10-29 23:51:13 -04:00
972225d8bc actually use those shiny new config settings 2024-10-29 23:50:53 -04:00
95f9479aa9 🎨 prettify a little 2024-10-29 23:50:32 -04:00
707a3a82c3 🏷️ fix types to be explicit about bytes being okay 2024-10-29 23:50:07 -04:00
dca1b89b39 🐛 don't actually clear out the check list lol 2024-10-29 23:32:02 -04:00
98ca09b681 🏷️ add type hints and docstring updates 2024-10-29 23:30:26 -04:00
6c2cfc5297 add new user settings and server checks 2024-10-29 23:28:40 -04:00
203b4f7e0f add gzip middleware to example server 2024-10-29 23:27:43 -04:00
236fc84be1 📝 clarify inline comments 2024-10-29 22:49:05 -04:00
b14db9a0ae 🎨 run black 2024-10-29 22:43:54 -04:00
Martin Nedopil
f1d1aebc96 updated MD file 2024-10-18 13:23:37 +02:00
Martin Nedopil
491f6c3c3a gzip middleware support 2024-10-18 13:12:54 +02:00
f94f0f5134 🔖 release 1.3.1 2024-10-16 17:27:00 -04:00
7ac76883fc add ability to adjust headers in post_process 2024-10-16 17:26:22 -04:00
12f6c726c9 💬 add http:// so url is clickable in terminal app 2024-10-16 17:23:52 -04:00
3d24b53fdf 📝 fix broken docs link 2024-10-15 15:06:33 -04:00
9a407495f8 🔖 release 1.3.0 2024-10-15 15:01:10 -04:00
61d30dca23 add post_process hook for middleware 2024-10-15 15:00:56 -04:00
fd6df38cdf ⬆️ Update deps to resolve security issue 2024-09-25 16:07:13 -04:00
31 changed files with 1223 additions and 642 deletions

View file

@ -22,7 +22,9 @@ So I built one.
`spiderweb` is a small web framework, just big enough to hold a spider. Getting started is easy: `spiderweb` is a small web framework, just big enough to hold a spider. Getting started is easy:
```shell ```shell
poetry add spiderweb-framework uv add spiderweb-framework
# or
pip install spiderweb-framework
``` ```
Create a new file and drop this in it: Create a new file and drop this in it:
@ -43,6 +45,17 @@ if __name__ == "__main__":
## [View the docs here!](https://itsthejoker.github.io/spiderweb/#/) ## [View the docs here!](https://itsthejoker.github.io/spiderweb/#/)
### Development (using uv)
This repository uses uv for local development and testing.
- Create a virtual environment: `uv venv`
- Activate it (Windows): `.venv\Scripts\activate`
- Activate it (POSIX): `source .venv/bin/activate`
- Install deps (editable + dev): `uv pip install -e .[dev]`
- Run tests: `uv run python -m pytest`
- Lint/format: `uv run ruff check .` and `uv run black .`
My goal with this framework was to do three things: My goal with this framework was to do three things:
1. Learn a lot 1. Learn a lot
@ -68,5 +81,5 @@ And, honestly, I think I got there. Here's a non-exhaustive list of things this
- CORS middleware - CORS middleware
- Optional POST data validation middleware with Pydantic - Optional POST data validation middleware with Pydantic
- Session middleware with built-in session store - 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) - Database support (using SQLAlchemy, but you can use whatever you want as long as there's a SQLAlchemy driver for it)
- Tests (currently roughly 89% coverage) - Tests (currently roughly 89% coverage)

View file

@ -62,11 +62,12 @@ Spiderweb provides five types of responses out of the box:
### Database Agnosticism (Mostly) ### Database Agnosticism (Mostly)
One of the largest selling points of Django is the Django Object Relational Mapper (ORM); while there's nothing that compares to it in functionality, there are many other ORMs and database management solutions for developers to choose from. Spiderweb persists its internal data (like sessions) using Advanced Alchemy built on SQLAlchemy. Your application can use this same setup out of the box, or bring any ORM you prefer.
In order to use a database internally (and since this is not about writing an ORM too), Spiderweb depends on [peewee, a small ORM](https://github.com/coleifer/peewee). Applications using Spiderweb are more than welcome to use peewee models with first-class support or use whatever they're familiar with. Peewee supports PostgreSQL, MySQL, Sqlite, and CockroachDB; if you use one of these, Spiderweb can create the tables it needs in your database and stay out of the way. By default, Spiderweb creates a sqlite database in the application directory for its own use. - By default, Spiderweb creates a SQLite database file `spiderweb.db` next to your app.
- You can pass the `db` argument to `SpiderwebRouter` as a filesystem path (for SQLite), a SQLAlchemy database URL string, or a SQLAlchemy Engine instance.
> [Read more about the using a database in Spiderweb](db.md) > [Read more about databases and migrations](db.md)
### Easy to configure ### Easy to configure
@ -129,4 +130,4 @@ Here's a non-exhaustive list of things this can do:
- Database support (using Peewee, but you can use whatever you want as long as there's a Peewee driver for it) - Database support (using Peewee, but you can use whatever you want as long as there's a Peewee driver for it)
- Tests (currently a little over 80% coverage) - Tests (currently a little over 80% coverage)
[^1]: I mostly succeeded. The way that I'm approaching this is that I did my level best, then looked at (and copied) existing solutions where necessary. At the time of this writing, I did all of it solo except for the CORS middleware. [Read more about it here.](middleware/cors.md) [^1]: I mostly succeeded. The way that I'm approaching this is that I did my level best, then looked at (and copied) existing solutions where necessary. At the time of this writing, I did all of it solo except for the CORS middleware. [Read more about it here.](middleware/cors.md)

View file

@ -1,4 +1,4 @@
![logo](_media/spiderweb_logo_cleaned.png) ![logo](_media/spiderweb_logo_cropped.png)
> the web framework just big enough for a spider > the web framework just big enough for a spider

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View file

@ -9,5 +9,6 @@
- [csrf](middleware/csrf.md) - [csrf](middleware/csrf.md)
- [cors](middleware/cors.md) - [cors](middleware/cors.md)
- [pydantic](middleware/pydantic.md) - [pydantic](middleware/pydantic.md)
- [gzip](middleware/gzip.md)
- [writing your own](middleware/custom_middleware.md) - [writing your own](middleware/custom_middleware.md)
- [databases](db.md) - [databases](db.md)

View file

@ -1,19 +1,39 @@
# databases # databases
It's hard to find a server-side app without a database these days, and for good reason: there are a lot of things to keep track of. Spiderweb does its best to remain database-agnostic, though it does utilize `peewee` internally to handle its own data (such as session data). This means that you have three options for how to handle databases in your app. Spiderweb is intentionally ORM-agnostic. Internally, it now uses Advanced Alchemy (built on SQLAlchemy) to persist firstparty data like sessions. You can choose one of the following approaches for your application data:
## Option 1: Using Peewee - Option 1: Use the builtin Advanced Alchemy/SQLAlchemy setup
- Option 2: Bring your own ORM and manage its lifecycle
- Option 3: Use separate databases for Spiderweb internals and your app data
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 1: Use the builtin Advanced Alchemy/SQLAlchemy setup
By default, Spiderweb will create and use a SQLite database file named `spiderweb.db` in your application directory. You can change this by passing the `db` argument to `SpiderwebRouter` as any of the following:
## Option 2: Using your own database ORM - A SQLAlchemy Engine instance
- A database URL string (e.g., `sqlite:///path/to.db`, `postgresql+psycopg://user:pass@host/db`)
- A filesystem path string for SQLite (e.g., `my_db.sqlite`)
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. Examples:
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. ```python
from sqlalchemy import create_engine
from spiderweb import SpiderwebRouter
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: # Use a SQLite file by passing a path
app = SpiderwebRouter(db="my_db.sqlite")
# Or pass a SQLAlchemy engine
engine = create_engine("postgresql+psycopg://user:pass@localhost/myapp")
app = SpiderwebRouter(db=engine)
# Or pass a full URL string
app = SpiderwebRouter(db="sqlite:///./local.db")
```
## Option 2: Bring your own ORM
If you are using another ORM or data layer, create and manage it as you normally would. If you need perrequest access to a connection or session, you can attach it via custom middleware:
```python ```python
from spiderweb.middleware import SpiderwebMiddleware from spiderweb.middleware import SpiderwebMiddleware
@ -21,105 +41,60 @@ from sqlalchemy import create_engine
class SQLAlchemyMiddleware(SpiderwebMiddleware): class SQLAlchemyMiddleware(SpiderwebMiddleware):
# there's only one of these, so we can just make it a top-level attr
engine = None engine = None
def process_request(self, request) -> None: def process_request(self, request) -> None:
# provide handles for the default `spiderweb.db` sqlite3 db
if not self.engine: if not self.engine:
self.engine = create_engine("sqlite:///spiderweb.db") self.engine = create_engine("sqlite:///spiderweb.db")
request.engine = self.engine 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.
Now any view that receives the incoming request object can access `request.engine` and interact with the database as needed.
> See [Writing Your Own Middleware](middleware/custom_middleware.md) for more information. > See [Writing Your Own Middleware](middleware/custom_middleware.md) for more information.
## Option 3: Using two databases ## Option 3: Use 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. If your application requires a database not supported by SQLAlchemy or you prefer to keep concerns separated, you can run two databases: one for Spiderweb internals (sessions, etc.) and one for your application logic.
## Changing the Peewee Database Target ## Migrations
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: Advanced Alchemy works seamlessly with Alembic (SQLAlchemy's migration tool). To manage schema changes:
```python 1. Install Alembic:
from spiderweb import SpiderwebRouter
from peewee import SqliteDatabase
app = SpiderwebRouter( ```bash
db=SqliteDatabase("my_db.sqlite") pip install alembic
) ```
```
Peewee supports the following databases at this time: 2. Initialize a migration repository:
- SQLite ```bash
- MySQL alembic init migrations
- MariaDB ```
- Postgres
Connecting Spiderweb to Postgres would look like this: 3. Configure Alembic to use Spiderweb's metadata. In `migrations/env.py`, set:
```python ```python
from spiderweb import SpiderwebRouter from spiderweb.db import Base
from peewee import PostgresqlDatabase target_metadata = Base.metadata
```
app = SpiderwebRouter( Also set the database URL either in `alembic.ini` (`sqlalchemy.url = ...`) or dynamically in `env.py` (read from environment variables or config).
db = PostgresqlDatabase(
'my_app',
user='postgres',
password='secret',
host='10.1.0.9',
port=5432
)
)
```
## Writing Peewee Models 4. Generate migrations from model changes:
```python ```bash
from spiderweb.db import SpiderwebModel alembic revision --autogenerate -m "add my table"
``` ```
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. 5. Apply migrations:
### Automatic Database Assignments ```bash
alembic upgrade head
```
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): Notes:
- If you define your own SQLAlchemy models, make sure they inherit from `spiderweb.db.Base` (or include their metadata in `target_metadata`) so Alembic can discover them.
```python - For multi-database setups, you can configure multiple Alembic contexts or run Alembic separately per database.
from peewee import * - Advanced Alchemy provides additional helpers on top of SQLAlchemy; you can use them freely alongside the guidance above.
db = SqliteDatabase('people.db')
class Person(Model):
name = CharField()
birthday = DateField()
class Meta:
database = db # This model uses the "people.db" database.
```
Spiderweb handles the database assignment for you so that your model is added to the same database that is already in use, regardless of driver:
```python
from spiderweb.db import SpiderwebModel
from peewee import *
class Person(SpiderwebModel):
name = CharField()
birthday = DateField()
```
### Migration Checking
Spiderweb also watches your model and raises an error if the state of the database and your model schema differ. This check attempts to be as thorough as possible, but may not be appropriate for you; if that's the case, then add the magic variable `skip_migration_check` to the `Meta` class for your model. For example:
```python
class Person(SpiderwebModel):
name = CharField()
birthday = DateField()
class Meta:
skip_migration_check = True
```

View file

@ -26,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. Middleware is run twice: once for the incoming request and once for the outgoing response. You only need to include whichever function is required for the functionality you need.
## process_request(self, request): ## process_request(self, request: Request) -> Optional[HttpResponse]:
`process_request` is called before the view is reached in the execution order. You will receive the assembled Request object, and any middleware declared above this one will have already run. Because the request is the single instantiation of a class, you can modify it in-place without returning anything and your changes will stick. `process_request` is called before the view is reached in the execution order. You will receive the assembled Request object, and any middleware declared above this one will have already run. Because the request is the single instantiation of a class, you can modify it in-place without returning anything and your changes will stick.
@ -45,15 +45,75 @@ class JohnMiddleware(SpiderwebMiddleware):
In this case, if the user John tries to access any route that starts with "/admin", he'll immediately get denied and the view will never be called. If the request does not have a user attached to it (or the user is not John), then the middleware will return None and Spiderweb will continue processing. In this case, if the user John tries to access any route that starts with "/admin", he'll immediately get denied and the view will never be called. If the request does not have a user attached to it (or the user is not John), then the middleware will return None and Spiderweb will continue processing.
## process_response(self, request, response): ## process_response(self, request: Request, response: HttpResponse) -> None:
This function is called after the view has run and returned a response. You will receive the request object and the response object; like with the request object, the response is also a single instantiation of a class, so any changes you make will stick automatically. This function is called after the view has run and returned a response. You will receive the request object and the response object; like with the request object, the response is also a single instantiation of a class, so any changes you make will stick automatically.
Unlike `process_request`, returning a value here doesn't change anything. We're already processing a request, and there are opportunities to turn away requests / change the response at both the `process_request` layer and the view layer, so Spiderweb assumes that whatever it is working on here is what you mean to return to the user. The response object that you receive in the middleware is still prerendered, so any changes you make to it will take effect after it finishes the middleware and renders the response. Unlike `process_request`, returning a value here doesn't change anything. We're already processing a request, and there are opportunities to turn away requests / change the response at both the `process_request` layer and the view layer, so Spiderweb assumes that whatever it is working on here is what you mean to return to the user. The response object that you receive in the middleware is still prerendered, so any changes you make to it will take effect after it finishes the middleware and renders the response.
## on_error(self, request, triggered_exception): ## on_error(self, request: Request, triggered_exception: Exception) -> Optional[HttpResponse]:
This is a helper function that is available for you to override; it's not often used by middleware, but there are some ([like the pydantic middleware](pydantic.md)) that call `on_error` when there is a validation failure. This is a helper function that is available for you to override; it's not often used by middleware, but there are some ([like the pydantic middleware](middleware/pydantic.md)) that call `on_error` when there is a validation failure.
## post_process(self, request: Request, response: HttpResponse, rendered_response: str) -> str | bytes:
> 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 or bytes. 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. A string is _strongly_ preferred, but bytes are also acceptable; keep in mind that you'll be making things harder for any `post_process` middleware that comes after you.
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.response import HttpResponse
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:
from spiderweb import SpiderwebRouter
app = SpiderwebRouter(
middleware=["CaseTransformMiddleware"],
case_transform_middleware_type="random",
)
```
## checks ## checks
@ -109,4 +169,4 @@ List as many checks as you need there, and the server will run all of them durin
from spiderweb.exceptions import UnusedMiddleware from spiderweb.exceptions import UnusedMiddleware
``` ```
If you don't want your middleware to run for some reason, either `process_request` or `process_response` can raise the UnusedMiddleware exception. If this happens, Spiderweb will kick your middleware out of the processing order for the rest of the life of the server. Note that this applies to the middleware as a whole, so both functions will not be run if an UnusedMiddleware is raised. This is a great way to mark debug middleware that shouldn't run or create time-delay middleware that runs until a certain condition is met! If you don't want your middleware to run for some reason, `process_request`, `process_response` and `post_process` can all raise the UnusedMiddleware exception. If this happens, Spiderweb will kick your middleware out of the processing order for the rest of the life of the server. Note that this applies to the middleware as a whole, so all functions in the middleware will not be run if an UnusedMiddleware is raised. This is a great way to mark debug middleware that shouldn't run or create time-delay middleware that runs until a certain condition is met!

51
docs/middleware/gzip.md Normal file
View file

@ -0,0 +1,51 @@
# gzip compression middleware
> New in 1.4.0!
```python
from spiderweb import SpiderwebRouter
app = SpiderwebRouter(
middleware=["spiderweb.middleware.gzip.GzipMiddleware"],
)
```
If your app is serving large responses, you may want to compress them. We don't (currently) have built-in support for Brotli, deflate, zstd, or other compression methods, but we do support gzip. (Want to add support for other methods? We'd love to see a PR!)
> [!NOTE]
> `GzipMiddleware` must be the as close to the 'top' of the middleware stack as possible. If you have multiple middleware, make sure that `gzip` is the first one in the list. For example:
> ```python
> app = SpiderwebRouter(
> middleware=[
> "spiderweb.middleware.gzip.GzipMiddleware",
> "spiderweb.middleware.cors.CorsMiddleware",
> "spiderweb.middleware.csrf.CSRFMiddleware",
> ]
> )
> ```
The implementation in Spiderweb is simple: it compresses the response body if the client indicates that it is supported. If the client doesn't support gzip, the response is sent uncompressed. Compression happens at the end of the response cycle, so it won't interfere with other middleware.
Error responses and responses with status codes that indicate that the response body should not be sent (like 204, 304, etc.) will not be compressed. Responses with a `Content-Encoding` header already set (e.g. if you're serving pre-compressed files) will be handled the same way.
The available configuration options are:
## gzip_minimum_response_length
The minimum size in bytes of a response before it will be compressed. Defaults to `500`. Responses smaller than this will not be compressed.
```python
app = SpiderwebRouter(
gzip_minimum_response_length=1000
)
```
## gzip_compression_level
The level of compression to use. Defaults to `6`. This is a number between 0 and 9, where 0 is no compression and 9 is maximum compression. Higher levels will result in smaller files, but will take longer to compress and decompress. Level 6 is a good balance between file size and speed.
```python
app = SpiderwebRouter(
gzip_compression_level=9
)
```

View file

@ -9,6 +9,9 @@ app = SpiderwebRouter(
``` ```
When working with form data, you may not want to always have to perform your own validation on the incoming data. Spiderweb gives you a way out of the box to perform this validation using Pydantic. When working with form data, you may not want to always have to perform your own validation on the incoming data. Spiderweb gives you a way out of the box to perform this validation using Pydantic.
> [!WARNING]
> Pydantic is not installed by default. Install it with `pip install pydantic` or `pip install spiderweb[pydantic]`.
Let's assume that we have a form view that looks like this: Let's assume that we have a form view that looks like this:
```python ```python
@ -54,4 +57,4 @@ The Pydantic middleware will automatically detect that the model that you want t
If the validation fails, the middleware will call `on_error`, which by default will return a 400 with a list of the broken fields. You may not want this behavior, so the easiest way to address it is to subclass PydanticMiddleware with your own version and override `on_error` to do whatever you'd like. If the validation fails, the middleware will call `on_error`, which by default will return a 400 with a list of the broken fields. You may not want this behavior, so the easiest way to address it is to subclass PydanticMiddleware with your own version and override `on_error` to do whatever you'd like.
If validation succeeds, the data from the validator will appear on the request object under `request.validated_data` — to access it, just call `.dict()` on the validated data. If validation succeeds, the data from the validator will appear on the request object under `request.validated_data` — to access it, just call `.dict()` on the validated data.

View file

@ -4,10 +4,10 @@ Start by installing the package with your favorite package manager:
<!-- tabs:start --> <!-- tabs:start -->
<!-- tab:poetry --> <!-- tab:uv -->
```shell ```shell
poetry add spiderweb-framework uv add spiderweb-framework
``` ```
<!-- tab:pip --> <!-- tab:pip -->

View file

@ -15,6 +15,7 @@ from spiderweb.response import (
app = SpiderwebRouter( app = SpiderwebRouter(
templates_dirs=["templates"], templates_dirs=["templates"],
middleware=[ middleware=[
"spiderweb.middleware.gzip.GzipMiddleware",
"spiderweb.middleware.cors.CorsMiddleware", "spiderweb.middleware.cors.CorsMiddleware",
"spiderweb.middleware.sessions.SessionMiddleware", "spiderweb.middleware.sessions.SessionMiddleware",
"spiderweb.middleware.csrf.CSRFMiddleware", "spiderweb.middleware.csrf.CSRFMiddleware",
@ -22,12 +23,14 @@ app = SpiderwebRouter(
"example_middleware.RedirectMiddleware", "example_middleware.RedirectMiddleware",
"spiderweb.middleware.pydantic.PydanticMiddleware", "spiderweb.middleware.pydantic.PydanticMiddleware",
"example_middleware.ExplodingMiddleware", "example_middleware.ExplodingMiddleware",
# "example_middleware.CaseTransformMiddleware",
], ],
staticfiles_dirs=["static_files"], staticfiles_dirs=["static_files"],
append_slash=False, # default append_slash=False, # default
cors_allow_all_origins=True, cors_allow_all_origins=True,
static_url="static_stuff", static_url="static_stuff",
debug=True, debug=True,
case_transform_middleware_type="spongebob",
) )

View file

@ -48,12 +48,14 @@ def form(request):
app = SpiderwebRouter( app = SpiderwebRouter(
templates_dirs=["templates"], templates_dirs=["templates"],
middleware=[ middleware=[
"spiderweb.middleware.sessions.SessionMiddleware",
"spiderweb.middleware.csrf.CSRFMiddleware", "spiderweb.middleware.csrf.CSRFMiddleware",
"example_middleware.TestMiddleware", "example_middleware.TestMiddleware",
"example_middleware.RedirectMiddleware", "example_middleware.RedirectMiddleware",
"example_middleware.ExplodingMiddleware", "example_middleware.ExplodingMiddleware",
], ],
staticfiles_dirs=["static_files"], staticfiles_dirs=["static_files"],
debug=True,
routes=[ routes=[
("/", index), ("/", index),
("/redirect", redirect), ("/redirect", redirect),

View file

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

685
poetry.lock generated
View file

@ -1,11 +1,61 @@
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
[[package]]
name = "advanced-alchemy"
version = "1.6.3"
description = "Ready-to-go SQLAlchemy concoctions."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "advanced_alchemy-1.6.3-py3-none-any.whl", hash = "sha256:d905f51affb427a13787f4b562d283ee891fc3c3865b5d7869f2f483533f1546"},
{file = "advanced_alchemy-1.6.3.tar.gz", hash = "sha256:b0ed313c0e1b7ac3c1a9caf8349d1742099b1c1d5f73e8926826da942aa1bf6c"},
]
[package.dependencies]
alembic = ">=1.12.0"
greenlet = "*"
sqlalchemy = ">=2.0.20"
typing-extensions = ">=4.0.0"
[package.extras]
argon2 = ["argon2-cffi"]
cli = ["rich-click"]
fsspec = ["fsspec"]
nanoid = ["fastnanoid (>=0.4.1)"]
obstore = ["obstore"]
passlib = ["passlib[argon2]"]
pwdlib = ["pwdlib[argon2]"]
uuid = ["uuid-utils (>=0.6.1)"]
[[package]]
name = "alembic"
version = "1.17.0"
description = "A database migration tool for SQLAlchemy."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99"},
{file = "alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe"},
]
[package.dependencies]
Mako = "*"
SQLAlchemy = ">=1.4.0"
typing-extensions = ">=4.12"
[package.extras]
tz = ["tzdata"]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated" description = "Reusable constraint types to use with typing.Annotated"
optional = false optional = true
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"pydantic\""
files = [ files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
@ -17,18 +67,19 @@ version = "24.2.0"
description = "Classes Without Boilerplate" description = "Classes Without Boilerplate"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [ files = [
{file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"},
{file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"},
] ]
[package.extras] [package.extras]
benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"]
cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"]
dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"]
docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"]
tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""]
[[package]] [[package]]
name = "black" name = "black"
@ -36,6 +87,7 @@ version = "24.8.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"},
{file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"},
@ -70,95 +122,17 @@ platformdirs = ">=2"
[package.extras] [package.extras]
colorama = ["colorama (>=0.4.3)"] colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"] uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "cffi"
version = "1.17.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
files = [
{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]
pycparser = "*"
[[package]] [[package]]
name = "click" name = "click"
version = "8.1.7" version = "8.1.7"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [ files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
@ -173,6 +147,8 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@ -184,6 +160,7 @@ version = "7.6.1"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"},
{file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"},
@ -260,91 +237,75 @@ files = [
] ]
[package.extras] [package.extras]
toml = ["tomli"] toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]] [[package]]
name = "cryptography" name = "greenlet"
version = "43.0.0" version = "3.2.4"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." description = "Lightweight in-process concurrent programming"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"},
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
{file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
{file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
{file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"},
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
{file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
{file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
{file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
] {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
[package.dependencies] {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"},
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"},
[package.extras] {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
nox = ["nox"] {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
pep8test = ["check-sdist", "click", "mypy", "ruff"] {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
sdist = ["build"] {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
ssh = ["bcrypt (>=3.1.5)"] {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"},
test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
test-randomorder = ["pytest-randomly"] {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
[[package]] {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
name = "dnspython" {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
version = "2.6.1" {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
description = "DNS toolkit" {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"},
optional = false {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"},
python-versions = ">=3.8" {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"},
files = [ {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
{file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
{file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
{file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
] ]
[package.extras] [package.extras]
dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] docs = ["Sphinx", "furo"]
dnssec = ["cryptography (>=41)"] test = ["objgraph", "psutil", "setuptools"]
doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
doq = ["aioquic (>=0.9.25)"]
idna = ["idna (>=3.6)"]
trio = ["trio (>=0.23)"]
wmi = ["wmi (>=1.5.1)"]
[[package]]
name = "email-validator"
version = "2.2.0"
description = "A robust email address syntax and deliverability validation library."
optional = false
python-versions = ">=3.8"
files = [
{file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"},
{file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"},
]
[package.dependencies]
dnspython = ">=2.0.0"
idna = ">=2.0.0"
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
@ -352,6 +313,7 @@ version = "23.0.0"
description = "WSGI HTTP Server for UNIX" description = "WSGI HTTP Server for UNIX"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [ files = [
{file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"},
{file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"},
@ -369,13 +331,14 @@ tornado = ["tornado (>=0.2)"]
[[package]] [[package]]
name = "hypothesis" name = "hypothesis"
version = "6.111.2" version = "6.112.1"
description = "A library for property-based testing" description = "A library for property-based testing"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "hypothesis-6.111.2-py3-none-any.whl", hash = "sha256:055e8228958e22178d6077e455fd86a72044d02dac130dbf9c8b31e161b9809c"}, {file = "hypothesis-6.112.1-py3-none-any.whl", hash = "sha256:93631b1498b20d2c205ed304cbd41d50e9c069d78a9c773c1324ca094c5e30ce"},
{file = "hypothesis-6.111.2.tar.gz", hash = "sha256:0496ad28c7240ee9ba89fcc7fb1dc74e89f3e40fbcbbb5f73c0091558dec8e6e"}, {file = "hypothesis-6.112.1.tar.gz", hash = "sha256:b070d7a1bb9bd84706c31885c9aeddc138e2b36a9c112a91984f49501c567856"},
] ]
[package.dependencies] [package.dependencies]
@ -383,7 +346,7 @@ attrs = ">=22.2.0"
sortedcontainers = ">=2.1.0,<3.0.0" sortedcontainers = ">=2.1.0,<3.0.0"
[package.extras] [package.extras]
all = ["backports.zoneinfo (>=0.2.1)", "black (>=19.10b0)", "click (>=7.0)", "crosshair-tool (>=0.0.70)", "django (>=3.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.13)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.17.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2024.1)"] all = ["backports.zoneinfo (>=0.2.1) ; python_version < \"3.9\"", "black (>=19.10b0)", "click (>=7.0)", "crosshair-tool (>=0.0.70)", "django (>=3.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.13)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.17.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2024.1) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""]
cli = ["black (>=19.10b0)", "click (>=7.0)", "rich (>=9.0.0)"] cli = ["black (>=19.10b0)", "click (>=7.0)", "rich (>=9.0.0)"]
codemods = ["libcst (>=0.3.16)"] codemods = ["libcst (>=0.3.16)"]
crosshair = ["crosshair-tool (>=0.0.70)", "hypothesis-crosshair (>=0.0.13)"] crosshair = ["crosshair-tool (>=0.0.70)", "hypothesis-crosshair (>=0.0.13)"]
@ -397,18 +360,7 @@ pandas = ["pandas (>=1.1)"]
pytest = ["pytest (>=4.6)"] pytest = ["pytest (>=4.6)"]
pytz = ["pytz (>=2014.1)"] pytz = ["pytz (>=2014.1)"]
redis = ["redis (>=3.0.0)"] redis = ["redis (>=3.0.0)"]
zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2024.1)"] zoneinfo = ["backports.zoneinfo (>=0.2.1) ; python_version < \"3.9\"", "tzdata (>=2024.1) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""]
[[package]]
name = "idna"
version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
@ -416,27 +368,31 @@ version = "2.0.0"
description = "brain-dead simple config-ini parsing" description = "brain-dead simple config-ini parsing"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [ files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
] ]
[[package]] [[package]]
name = "jinja2" name = "mako"
version = "3.1.4" version = "1.3.10"
description = "A very fast and expressive template engine." description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"},
{file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"},
] ]
[package.dependencies] [package.dependencies]
MarkupSafe = ">=2.0" MarkupSafe = ">=0.9.2"
[package.extras] [package.extras]
i18n = ["Babel (>=2.7)"] babel = ["Babel"]
lingua = ["lingua"]
testing = ["pytest"]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
@ -444,6 +400,7 @@ version = "2.1.5"
description = "Safely add untrusted strings to HTML/XML markup." description = "Safely add untrusted strings to HTML/XML markup."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
{file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
@ -513,6 +470,7 @@ version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker." description = "Type system extensions for programs checked with the mypy type checker."
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
groups = ["dev"]
files = [ files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
@ -524,6 +482,7 @@ version = "24.1"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
@ -535,36 +494,28 @@ version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
] ]
[[package]]
name = "peewee"
version = "3.17.6"
description = "a little orm"
optional = false
python-versions = "*"
files = [
{file = "peewee-3.17.6.tar.gz", hash = "sha256:cea5592c6f4da1592b7cff8eaf655be6648a1f5857469e30037bf920c03fb8fb"},
]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.2.2" version = "4.3.6"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
] ]
[package.extras] [package.extras]
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.8)"] type = ["mypy (>=1.11.2)"]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
@ -572,6 +523,7 @@ version = "1.5.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
@ -581,31 +533,22 @@ files = [
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"] testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pycparser"
version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.8.2" version = "2.9.2"
description = "Data validation using Python type hints" description = "Data validation using Python type hints"
optional = false optional = true
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"pydantic\""
files = [ files = [
{file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"},
{file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"},
] ]
[package.dependencies] [package.dependencies]
annotated-types = ">=0.4.0" annotated-types = ">=0.6.0"
pydantic-core = "2.20.1" pydantic-core = "2.23.4"
typing-extensions = [ typing-extensions = [
{version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.12.2", markers = "python_version >= \"3.13\""},
{version = ">=4.6.1", markers = "python_version < \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""},
@ -613,103 +556,106 @@ typing-extensions = [
[package.extras] [package.extras]
email = ["email-validator (>=2.0.0)"] email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.20.1" version = "2.23.4"
description = "Core functionality for Pydantic validation and serialization" description = "Core functionality for Pydantic validation and serialization"
optional = false optional = true
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"pydantic\""
files = [ files = [
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"},
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"},
{file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"},
{file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"},
{file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"},
{file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"},
{file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"},
{file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"},
{file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"},
{file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"},
{file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"},
{file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"},
{file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"},
{file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"},
{file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"},
] ]
[package.dependencies] [package.dependencies]
@ -717,13 +663,14 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.3.2" version = "8.3.3"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
] ]
[package.dependencies] [package.dependencies]
@ -741,6 +688,7 @@ version = "0.5.7"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [ files = [
{file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"},
{file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"},
@ -768,23 +716,124 @@ version = "2.4.0"
description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["dev"]
files = [ files = [
{file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
{file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
] ]
[[package]]
name = "sqlalchemy"
version = "2.0.44"
description = "Database Abstraction Library"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "SQLAlchemy-2.0.44-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:471733aabb2e4848d609141a9e9d56a427c0a038f4abf65dd19d7a21fd563632"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48bf7d383a35e668b984c805470518b635d48b95a3c57cb03f37eaa3551b5f9f"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf4bb6b3d6228fcf3a71b50231199fb94d2dd2611b66d33be0578ea3e6c2726"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:e998cf7c29473bd077704cea3577d23123094311f59bdc4af551923b168332b1"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ebac3f0b5732014a126b43c2b7567f2f0e0afea7d9119a3378bde46d3dcad88e"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-win32.whl", hash = "sha256:3255d821ee91bdf824795e936642bbf43a4c7cedf5d1aed8d24524e66843aa74"},
{file = "SQLAlchemy-2.0.44-cp37-cp37m-win_amd64.whl", hash = "sha256:78e6c137ba35476adb5432103ae1534f2f5295605201d946a4198a0dea4b38e7"},
{file = "sqlalchemy-2.0.44-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c77f3080674fc529b1bd99489378c7f63fcb4ba7f8322b79732e0258f0ea3ce"},
{file = "sqlalchemy-2.0.44-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26ef74ba842d61635b0152763d057c8d48215d5be9bb8b7604116a059e9985"},
{file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a172b31785e2f00780eccab00bc240ccdbfdb8345f1e6063175b3ff12ad1b0"},
{file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e"},
{file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17835885016b9e4d0135720160db3095dc78c583e7b902b6be799fb21035e749"},
{file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cbe4f85f50c656d753890f39468fcd8190c5f08282caf19219f684225bfd5fd2"},
{file = "sqlalchemy-2.0.44-cp310-cp310-win32.whl", hash = "sha256:2fcc4901a86ed81dc76703f3b93ff881e08761c63263c46991081fd7f034b165"},
{file = "sqlalchemy-2.0.44-cp310-cp310-win_amd64.whl", hash = "sha256:9919e77403a483ab81e3423151e8ffc9dd992c20d2603bf17e4a8161111e55f5"},
{file = "sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd"},
{file = "sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa"},
{file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e"},
{file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e"},
{file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399"},
{file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b"},
{file = "sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3"},
{file = "sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5"},
{file = "sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250"},
{file = "sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29"},
{file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44"},
{file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1"},
{file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7"},
{file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d"},
{file = "sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4"},
{file = "sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e"},
{file = "sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1"},
{file = "sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45"},
{file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976"},
{file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c"},
{file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d"},
{file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40"},
{file = "sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73"},
{file = "sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e"},
{file = "sqlalchemy-2.0.44-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2fc44e5965ea46909a416fff0af48a219faefd5773ab79e5f8a5fcd5d62b2667"},
{file = "sqlalchemy-2.0.44-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dc8b3850d2a601ca2320d081874033684e246d28e1c5e89db0864077cfc8f5a9"},
{file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d733dec0614bb8f4bcb7c8af88172b974f685a31dc3a65cca0527e3120de5606"},
{file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22be14009339b8bc16d6b9dc8780bacaba3402aa7581658e246114abbd2236e3"},
{file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:357bade0e46064f88f2c3a99808233e67b0051cdddf82992379559322dfeb183"},
{file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4848395d932e93c1595e59a8672aa7400e8922c39bb9b0668ed99ac6fa867822"},
{file = "sqlalchemy-2.0.44-cp38-cp38-win32.whl", hash = "sha256:2f19644f27c76f07e10603580a47278abb2a70311136a7f8fd27dc2e096b9013"},
{file = "sqlalchemy-2.0.44-cp38-cp38-win_amd64.whl", hash = "sha256:1df4763760d1de0dfc8192cc96d8aa293eb1a44f8f7a5fbe74caf1b551905c5e"},
{file = "sqlalchemy-2.0.44-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7027414f2b88992877573ab780c19ecb54d3a536bef3397933573d6b5068be4"},
{file = "sqlalchemy-2.0.44-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fe166c7d00912e8c10d3a9a0ce105569a31a3d0db1a6e82c4e0f4bf16d5eca9"},
{file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3caef1ff89b1caefc28f0368b3bde21a7e3e630c2eddac16abd9e47bd27cc36a"},
{file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc2856d24afa44295735e72f3c75d6ee7fdd4336d8d3a8f3d44de7aa6b766df2"},
{file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:11bac86b0deada30b6b5f93382712ff0e911fe8d31cb9bf46e6b149ae175eff0"},
{file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d18cd0e9a0f37c9f4088e50e3839fcb69a380a0ec957408e0b57cff08ee0a26"},
{file = "sqlalchemy-2.0.44-cp39-cp39-win32.whl", hash = "sha256:9e9018544ab07614d591a26c1bd4293ddf40752cc435caf69196740516af7100"},
{file = "sqlalchemy-2.0.44-cp39-cp39-win_amd64.whl", hash = "sha256:8e0e4e66fd80f277a8c3de016a81a554e76ccf6b8d881ee0b53200305a8433f6"},
{file = "sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05"},
{file = "sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22"},
]
[package.dependencies]
greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""}
typing-extensions = ">=4.6.0"
[package.extras]
aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"]
aioodbc = ["aioodbc", "greenlet (>=1)"]
aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"]
asyncio = ["greenlet (>=1)"]
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"]
mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"]
mssql = ["pyodbc"]
mssql-pymssql = ["pymssql"]
mssql-pyodbc = ["pyodbc"]
mypy = ["mypy (>=0.910)"]
mysql = ["mysqlclient (>=1.4.0)"]
mysql-connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=8)"]
oracle-oracledb = ["oracledb (>=1.0.1)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"]
postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
postgresql-psycopg = ["psycopg (>=3.0.7)"]
postgresql-psycopg2binary = ["psycopg2-binary"]
postgresql-psycopg2cffi = ["psycopg2cffi"]
postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+" description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
] ]
[extras]
pydantic = ["pydantic"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.1"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "17f5dc4b157da57ad75a6f6aa3feb7adfa07500b805d5e79d1f09d640964949f" content-hash = "002b27684674c055d24144488bc7ad9f564b2788ab51d985c4b64fe1589b7ccb"

View file

@ -1,11 +1,11 @@
[tool.poetry] [project]
name = "spiderweb-framework" name = "spiderweb-framework"
version = "1.2.1" version = "2.0.0"
description = "A small web framework, just big enough for a spider." description = "A small web framework, just big enough for a spider."
authors = ["Joe Kaufeld <opensource@joekaufeld.com>"] authors = [{name="Joe Kaufeld", email="opensource@joekaufeld.com"}]
readme = "README.md" readme = "README.md"
packages = [{include = "spiderweb"}] packages = [{include = "spiderweb"}]
license = "LICENSE.txt" license = "MIT"
exclude = [ exclude = [
"tests/*", "tests/*",
"example.py", "example.py",
@ -27,14 +27,19 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
"Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Application Frameworks",
] ]
dependencies = [
"advanced-alchemy (>=1.6.3,<2.0.0)"
]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.11" python = "^3.11"
peewee = "^3.17.6" SQLAlchemy = "^2.0.32"
jinja2 = "^3.1.4" jinja2 = "^3.1.4"
cryptography = "^43.0.0" cryptography = "^43.0.0"
email-validator = "^2.2.0" email-validator = "^2.2.0"
pydantic = "^2.8.2"
[project.optional-dependencies]
pydantic = ["pydantic>=2.8.2,<3"]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
ruff = "^0.5.5" ruff = "^0.5.5"
@ -45,8 +50,11 @@ hypothesis = "^6.111.2"
coverage = "^7.6.1" coverage = "^7.6.1"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["hatchling"]
build-backend = "poetry.core.masonry.api" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["spiderweb/spiderweb-framework"]
[tool.poetry_bumpversion.file."spiderweb/constants.py"] [tool.poetry_bumpversion.file."spiderweb/constants.py"]
@ -85,4 +93,4 @@ exclude_also = [
"if TYPE_CHECKING:", "if TYPE_CHECKING:",
] ]
ignore_errors = true ignore_errors = true

View file

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

View file

@ -1,101 +1,28 @@
from peewee import Model, Field, SchemaManager from __future__ import annotations
from spiderweb.constants import DATABASE_PROXY from pathlib import Path
from typing import Optional, Union
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session as SASession
# Base class for SQLAlchemy models used internally by Spiderweb
Base = declarative_base()
# Type alias for sessions
DBSession = SASession
class MigrationsNeeded(ExceptionGroup): ... def create_sqlite_engine(db_path: Union[str, Path]) -> Engine:
"""Create a SQLite engine from a file path."""
path = Path(db_path)
# Ensure directory exists
if path.parent and not path.parent.exists():
path.parent.mkdir(parents=True, exist_ok=True)
return create_engine(f"sqlite:///{path}", future=True)
class MigrationRequired(Exception): ... def create_session_factory(engine: Engine):
"""Return a configured sessionmaker bound to the given engine."""
return sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
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: {
"data_type": c.data_type,
"null": c.null,
"primary_key": c.primary_key,
"default": c.default,
}
for c in cls._meta.database.get_columns(cls._meta.table_name)
}
problems = []
s = SchemaManager(cls, cls._meta.database)
ctx = s._create_context()
for field_name, field_obj in current_model_fields.items():
db_version = current_db_fields.get(field_obj.column_name)
if not db_version:
problems.append(
MigrationRequired(f"Field {field_name} not found in DB.")
)
continue
if field_obj.field_type == "VARCHAR":
field_obj.max_length = field_obj.max_length or 255
if (
cls._meta.fields[field_name].ddl_datatype(ctx).sql
!= db_version["data_type"]
):
problems.append(
MigrationRequired(
f"CharField `{field_name}` has changed the field type."
)
)
else:
if (
cls._meta.database.get_context_options()["field_types"][
field_obj.field_type
]
!= db_version["data_type"]
):
problems.append(
MigrationRequired(
f"Field `{field_name}` has changed the field type."
)
)
if field_obj.null != db_version["null"]:
problems.append(
MigrationRequired(
f"Field `{field_name}` has changed the nullability."
)
)
if field_obj.__class__.__name__ == "BooleanField":
if field_obj.default is False and db_version["default"] not in (
False,
None,
0,
):
problems.append(
MigrationRequired(
f"BooleanField `{field_name}` has changed the default value."
)
)
elif field_obj.default is True and db_version["default"] not in (
True,
1,
):
problems.append(
MigrationRequired(
f"BooleanField `{field_name}` has changed the default value."
)
)
else:
if field_obj.default != db_version["default"]:
problems.append(
MigrationRequired(
f"Field `{field_name}` has changed the default value."
)
)
if problems:
raise MigrationsNeeded(f"The model {cls} requires migrations.", problems)
class Meta:
database = DATABASE_PROXY

View file

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

View file

@ -10,7 +10,8 @@ from typing import Optional, Callable, Sequence, Literal
from wsgiref.simple_server import WSGIServer from wsgiref.simple_server import WSGIServer
from jinja2 import BaseLoader, FileSystemLoader from jinja2 import BaseLoader, FileSystemLoader
from peewee import Database, SqliteDatabase from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from spiderweb.middleware import MiddlewareMixin from spiderweb.middleware import MiddlewareMixin
from spiderweb.constants import ( from spiderweb.constants import (
@ -18,11 +19,10 @@ from spiderweb.constants import (
DEFAULT_CORS_ALLOW_HEADERS, DEFAULT_CORS_ALLOW_HEADERS,
) )
from spiderweb.constants import ( from spiderweb.constants import (
DATABASE_PROXY,
DEFAULT_ENCODING, DEFAULT_ENCODING,
DEFAULT_ALLOWED_METHODS, DEFAULT_ALLOWED_METHODS,
) )
from spiderweb.db import SpiderwebModel from spiderweb.db import Base, create_sqlite_engine, create_session_factory
from spiderweb.default_views import ( from spiderweb.default_views import (
http403, # noqa: F401 http403, # noqa: F401
http404, # noqa: F401 http404, # noqa: F401
@ -67,8 +67,10 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
cors_allow_credentials: bool = False, cors_allow_credentials: bool = False,
cors_allow_private_network: bool = False, cors_allow_private_network: bool = False,
csrf_trusted_origins: Sequence[str] = None, csrf_trusted_origins: Sequence[str] = None,
db: Optional[Database] = None, db: Optional[Engine | str] = None,
debug: bool = False, debug: bool = False,
gzip_compression_level: int = 6,
gzip_minimum_response_length: int = 500,
templates_dirs: Sequence[str] = None, templates_dirs: Sequence[str] = None,
middleware: Sequence[str] = None, middleware: Sequence[str] = None,
append_slash: bool = False, append_slash: bool = False,
@ -119,6 +121,9 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
convert_url_to_regex(i) for i in self._csrf_trusted_origins convert_url_to_regex(i) for i in self._csrf_trusted_origins
] ]
self.gzip_compression_level = gzip_compression_level
self.gzip_minimum_response_length = gzip_minimum_response_length
self.debug = debug self.debug = debug
self.extra_data = kwargs self.extra_data = kwargs
@ -143,12 +148,21 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
self.init_fernet() self.init_fernet()
self.init_middleware() self.init_middleware()
self.db = db or SqliteDatabase(self.BASE_DIR / "spiderweb.db") # Database setup (SQLAlchemy)
# give the models the db connection if isinstance(db, Engine):
DATABASE_PROXY.initialize(self.db) self.db_engine = db
self.db.create_tables(SpiderwebModel.__subclasses__()) elif isinstance(db, str):
for model in SpiderwebModel.__subclasses__(): # treat as URL if it looks like one, otherwise as a filesystem path
model.check_for_needed_migration() if "://" in db:
self.db_engine = create_engine(db, future=True)
else:
self.db_engine = create_sqlite_engine(self.BASE_DIR / db)
else:
self.db_engine = create_sqlite_engine(self.BASE_DIR / "spiderweb.db")
self.db_session_factory = create_session_factory(self.db_engine)
# Create internal tables (e.g., sessions)
Base.metadata.create_all(self.db_engine)
if self.routes: if self.routes:
self.add_routes() self.add_routes()
@ -201,6 +215,17 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
def fire_response(self, start_response, request: Request, resp: HttpResponse): def fire_response(self, start_response, request: Request, resp: HttpResponse):
try: try:
try:
rendered_output: str = resp.render()
final_output: str | list[str] = self.post_process_middleware(
request, resp, rendered_output
)
except Exception as e:
self.log.error("Fatal error!")
self.log.error(e)
self.log.error(traceback.format_exc())
return [f"Internal Server Error: {e}".encode(DEFAULT_ENCODING)]
status = get_http_status_by_code(resp.status_code) status = get_http_status_by_code(resp.status_code)
cookies = [] cookies = []
varies = [] varies = []
@ -218,23 +243,13 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
for v in varies: for v in varies:
headers.append(("vary", str(v))) headers.append(("vary", str(v)))
start_response(status, headers) if not isinstance(final_output, list):
final_output = [final_output]
try:
rendered_output = resp.render()
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)]
if not isinstance(rendered_output, list):
rendered_output = [rendered_output]
encoded_resp = [ encoded_resp = [
chunk.encode(DEFAULT_ENCODING) if isinstance(chunk, str) else chunk chunk.encode(DEFAULT_ENCODING) if isinstance(chunk, str) else chunk
for chunk in rendered_output for chunk in final_output
] ]
start_response(status, headers)
return encoded_resp return encoded_resp
except APIError: except APIError:
raise raise
@ -259,6 +274,10 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
server=self, server=self,
) )
def get_db_session(self):
"""Return a new SQLAlchemy session bound to the application's engine."""
return self.db_session_factory()
def send_error_response( def send_error_response(
self, start_response, request: Request, e: SpiderwebNetworkException self, start_response, request: Request, e: SpiderwebNetworkException
): ):

View file

@ -60,3 +60,18 @@ class MiddlewareMixin:
except UnusedMiddleware: except UnusedMiddleware:
self.middleware.remove(middleware) self.middleware.remove(middleware)
continue continue
def post_process_middleware(
self, request: Request, response: HttpResponse, rendered_response: str
) -> str | bytes:
# run them in reverse order, same as process_response. The top of the middleware
# stack should be the first and last middleware to run.
for middleware in reversed(self.middleware):
try:
rendered_response = middleware.post_process(
request, response, rendered_response
)
except UnusedMiddleware:
self.middleware.remove(middleware)
continue
return rendered_response

View file

@ -1,6 +1,11 @@
from typing import TYPE_CHECKING
from spiderweb.request import Request from spiderweb.request import Request
from spiderweb.response import HttpResponse from spiderweb.response import HttpResponse
if TYPE_CHECKING:
from spiderweb.server_checks import ServerCheck
class SpiderwebMiddleware: class SpiderwebMiddleware:
""" """
@ -9,6 +14,8 @@ class SpiderwebMiddleware:
process_request(self, request) -> None or Response process_request(self, request) -> None or Response
process_response(self, request, resp) -> None process_response(self, request, resp) -> None
on_error(self, request, e) -> Response
post_process(self, request, resp) -> Response
Middleware can be used to modify requests and responses in a variety of ways. Middleware can be used to modify requests and responses in a variety of ways.
If one of the two methods is not defined, the request or resp will be passed If one of the two methods is not defined, the request or resp will be passed
@ -20,14 +27,33 @@ class SpiderwebMiddleware:
def __init__(self, server): def __init__(self, server):
self.server = server self.server = server
# If there are any startup checks that need to be run, they should be added
# to this list. These checks should be classes that inherit from
# spiderweb.server_checks.ServerCheck.
self.checks: list[ServerCheck]
def process_request(self, request: Request) -> HttpResponse | None: def process_request(self, request: Request) -> HttpResponse | None:
# This method is called before the request is passed to the view. You can safely
# modify the request in this method, or return an HttpResponse to short-circuit
# the request and return a response immediately.
pass pass
def process_response( def process_response(self, request: Request, response: HttpResponse) -> None:
self, request: Request, response: HttpResponse # This method is called after the view has returned a response. You can modify
) -> HttpResponse | None: # the response in this method. The response will be returned to the client after
# all middleware has been processed.
pass pass
def on_error(self, request: Request, e: Exception) -> HttpResponse | None: def on_error(self, request: Request, e: Exception) -> HttpResponse | None:
# This method is called if an exception is raised during the request. You can
# return a response here to handle the error. If you return None, the exception
# will be re-raised.
pass pass
def post_process(
self, request: Request, response: HttpResponse, rendered_response: str
) -> str:
# This method is called after all the middleware has been processed and receives
# the final rendered response in str form. You can modify the response here. This
# method *must* return a str version of the rendered response.
return rendered_response

View file

@ -13,7 +13,9 @@ from spiderweb.server_checks import ServerCheck
class CheckForSessionMiddleware(ServerCheck): class CheckForSessionMiddleware(ServerCheck):
SESSION_MIDDLEWARE_NOT_FOUND = ( SESSION_MIDDLEWARE_NOT_FOUND = (
"Session middleware is not enabled. It must be listed above" "Session middleware is not enabled. It must be listed above"
"CSRFMiddleware in the middleware list." "CSRFMiddleware in the middleware list. Add"
" 'spiderweb.middleware.sessions.SessionMiddleware' to your"
" `middleware` list."
) )
def check(self) -> Optional[Exception]: def check(self) -> Optional[Exception]:
@ -26,8 +28,8 @@ class CheckForSessionMiddleware(ServerCheck):
class VerifyCorrectMiddlewarePlacement(ServerCheck): class VerifyCorrectMiddlewarePlacement(ServerCheck):
SESSION_MIDDLEWARE_BELOW_CSRF = ( SESSION_MIDDLEWARE_BELOW_CSRF = (
"SessionMiddleware is enabled, but it must be listed above" "Session middleware is enabled, but must be listed above"
"CSRFMiddleware in the middleware list." " CSRFMiddleware in the middleware list."
) )
def check(self) -> Optional[Exception]: def check(self) -> Optional[Exception]:

View file

@ -0,0 +1,70 @@
"""
Source code inspiration: https://github.com/colour-science/flask-compress/blob/master/flask_compress/flask_compress.py
"""
from spiderweb.exceptions import ConfigError
from spiderweb.middleware import SpiderwebMiddleware
from spiderweb.server_checks import ServerCheck
from spiderweb.request import Request
from spiderweb.response import HttpResponse
import gzip
class CheckValidGzipCompressionLevel(ServerCheck):
INVALID_GZIP_COMPRESSION_LEVEL = (
"`gzip_compression_level` must be an integer between 1 and 9."
)
def check(self):
if not isinstance(self.server.gzip_compression_level, int):
raise ConfigError(self.INVALID_GZIP_COMPRESSION_LEVEL)
if self.server.gzip_compression_level not in range(1, 10):
raise ConfigError(
"Gzip compression level must be an integer between 1 and 9."
)
class CheckValidGzipMinimumLength(ServerCheck):
INVALID_GZIP_MINIMUM_LENGTH = "`gzip_minimum_length` must be a positive integer."
def check(self):
if not isinstance(self.server.gzip_minimum_response_length, int):
raise ConfigError(self.INVALID_GZIP_MINIMUM_LENGTH)
if self.server.gzip_minimum_response_length < 1:
raise ConfigError(self.INVALID_GZIP_MINIMUM_LENGTH)
class GzipMiddleware(SpiderwebMiddleware):
checks = [CheckValidGzipCompressionLevel, CheckValidGzipMinimumLength]
algorithm = "gzip"
def post_process(
self, request: Request, response: HttpResponse, rendered_response: str
) -> str | bytes:
# Only actually compress the response if the following attributes are true:
#
# - The response status code is a 2xx success code
# - The response length is at least 500 bytes
# - The response is not a streaming response
# - (already bytes, like from FileResponse)
# - The response is not already compressed
# - The request accepts gzip encoding
if (
not (200 <= response.status_code < 300)
or len(rendered_response) < self.server.gzip_minimum_response_length
or not isinstance(rendered_response, str)
or self.algorithm in response.headers.get("Content-Encoding", "")
or self.algorithm not in request.headers.get("Accept-Encoding", "")
):
return rendered_response
zipped = gzip.compress(
rendered_response.encode("UTF-8"),
compresslevel=self.server.gzip_compression_level,
)
response.headers["Content-Encoding"] = self.algorithm
response.headers["Content-Length"] = str(len(zipped))
return zipped

View file

@ -1,8 +1,26 @@
import inspect import inspect
from typing import get_type_hints from typing import get_type_hints
from pydantic import BaseModel try: # pragma: no cover - import guard
from pydantic_core._pydantic_core import ValidationError from pydantic import BaseModel # type: ignore
from pydantic_core._pydantic_core import ValidationError # type: ignore
PYDANTIC_AVAILABLE = True
except Exception: # pragma: no cover - executed only when pydantic isn't installed
PYDANTIC_AVAILABLE = False
class BaseModel: # minimal stub to allow module import without pydantic
@classmethod
def parse_obj(cls, *args, **kwargs): # noqa: D401 - simple shim
raise RuntimeError(
"Pydantic is not installed. Install with 'pip install"
" spiderweb-framework[pydantic]' or 'pip install pydantic'"
" to use PydanticMiddleware."
)
class ValidationError(Exception): # simple stand-in so type hints resolve
def errors(self): # match pydantic's ValidationError API used below
return []
from spiderweb import SpiderwebMiddleware from spiderweb import SpiderwebMiddleware
from spiderweb.request import Request from spiderweb.request import Request
from spiderweb.response import JsonResponse from spiderweb.response import JsonResponse
@ -19,6 +37,12 @@ class PydanticMiddleware(SpiderwebMiddleware):
def process_request(self, request): def process_request(self, request):
if not request.method == "POST": if not request.method == "POST":
return return
if not PYDANTIC_AVAILABLE:
raise RuntimeError(
"Pydantic is not installed. Install with 'pip install"
" spiderweb-framework[pydantic]' or 'pip install pydantic'"
" to use PydanticMiddleware."
)
types = get_type_hints(request.handler) types = get_type_hints(request.handler)
# we don't know what the user named the request object, but # we don't know what the user named the request object, but
# we know that it's first in the list, and it's always an arg. # we know that it's first in the list, and it's always an arg.
@ -34,7 +58,15 @@ class PydanticMiddleware(SpiderwebMiddleware):
# Separated out into its own method so that it can be overridden # Separated out into its own method so that it can be overridden
errors = e.errors() errors = e.errors()
error_dict = {"message": "Validation error", "errors": []} error_dict = {"message": "Validation error", "errors": []}
# [{'type': 'missing', 'loc': ('comment',), 'msg': 'Field required', 'input': {'email': 'a@a.com'}, 'url': 'https://errors.pydantic.dev/2.8/v/missing'}] # [
# {
# 'type': 'missing',
# 'loc': ('comment',),
# 'msg': 'Field required',
# 'input': {'email': 'a@a.com'},
# 'url': 'https://errors.pydantic.dev/2.8/v/missing'
# }
# ]
for error in errors: for error in errors:
field = error["loc"][0] field = error["loc"][0]
msg = error["msg"] msg = error["msg"]

View file

@ -1,61 +1,71 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import json import json
from peewee import CharField, TextField, DateTimeField from sqlalchemy import Column, Integer, String, Text, DateTime
from sqlalchemy.orm import Mapped
from spiderweb.middleware import SpiderwebMiddleware from spiderweb.middleware import SpiderwebMiddleware
from spiderweb.request import Request from spiderweb.request import Request
from spiderweb.response import HttpResponse from spiderweb.response import HttpResponse
from spiderweb.db import SpiderwebModel from spiderweb.db import Base
from spiderweb.utils import generate_key, is_jsonable from spiderweb.utils import generate_key, is_jsonable
class Session(SpiderwebModel): class Session(Base):
session_key = CharField(max_length=64) __tablename__ = "spiderweb_sessions"
csrf_token = CharField(max_length=64, null=True)
user_id = CharField(max_length=64, null=True)
session_data = TextField()
created_at = DateTimeField()
last_active = DateTimeField()
ip_address = CharField(max_length=30)
user_agent = TextField()
class Meta: id: Mapped[int] = Column(Integer, primary_key=True, autoincrement=True)
table_name = "spiderweb_sessions" session_key: Mapped[str] = Column(String(64), index=True, nullable=False)
csrf_token: Mapped[str | None] = Column(String(64), nullable=True)
user_id: Mapped[str | None] = Column(String(64), nullable=True)
session_data: Mapped[str] = Column(Text, nullable=False)
created_at: Mapped[datetime] = Column(DateTime, nullable=False)
last_active: Mapped[datetime] = Column(DateTime, nullable=False)
ip_address: Mapped[str] = Column(String(30), nullable=False)
user_agent: Mapped[str] = Column(Text, nullable=False)
class SessionMiddleware(SpiderwebMiddleware): class SessionMiddleware(SpiderwebMiddleware):
def process_request(self, request: Request): def process_request(self, request: Request):
existing_session = ( dbsession = self.server.get_db_session()
Session.select() try:
.where( existing_session = (
Session.session_key dbsession.query(Session)
== request.COOKIES.get(self.server.session_cookie_name), .filter(
Session.ip_address == request.META.get("client_address"), Session.session_key
Session.user_agent == request.headers.get("HTTP_USER_AGENT"), == request.COOKIES.get(self.server.session_cookie_name),
Session.ip_address == request.META.get("client_address"),
Session.user_agent == request.headers.get("HTTP_USER_AGENT"),
)
.order_by(Session.id.desc())
.first()
) )
.first() new_session = False
) if not existing_session:
new_session = False new_session = True
if not existing_session: elif datetime.now() - existing_session.created_at > timedelta(
new_session = True seconds=self.server.session_max_age
elif datetime.now() - existing_session.created_at > timedelta( ):
seconds=self.server.session_max_age dbsession.delete(existing_session)
): dbsession.commit()
existing_session.delete_instance() new_session = True
new_session = True
if new_session: if new_session:
request.SESSION = {} request.SESSION = {}
request._session["id"] = generate_key() request._session["id"] = generate_key()
request._session["new_session"] = True request._session["new_session"] = True
request.META["SESSION"] = None request.META["SESSION"] = None
return return
request.SESSION = json.loads(existing_session.session_data) request.SESSION = json.loads(existing_session.session_data)
request.META["SESSION"] = existing_session request.META["SESSION"] = existing_session
request._session["id"] = existing_session.session_key request._session["id"] = existing_session.session_key
existing_session.save() # touch last_active
existing_session.last_active = datetime.now()
dbsession.add(existing_session)
dbsession.commit()
finally:
dbsession.close()
def process_response(self, request: Request, response: HttpResponse): def process_response(self, request: Request, response: HttpResponse):
cookie_settings = { cookie_settings = {
@ -78,19 +88,25 @@ class SessionMiddleware(SpiderwebMiddleware):
) )
if not is_jsonable(request.SESSION): if not is_jsonable(request.SESSION):
raise ValueError("Session data is not JSON serializable.") raise ValueError("Session data is not JSON serializable.")
session = Session( dbsession = self.server.get_db_session()
session_key=session_key, try:
session_data=json.dumps(request.SESSION), session = Session(
created_at=datetime.now(), session_key=session_key,
last_active=datetime.now(), session_data=json.dumps(request.SESSION),
ip_address=request.META.get("client_address"), created_at=datetime.now(),
user_agent=request.headers.get("HTTP_USER_AGENT"), last_active=datetime.now(),
) ip_address=request.META.get("client_address"),
session.save() user_agent=request.headers.get("HTTP_USER_AGENT"),
)
dbsession.add(session)
dbsession.commit()
finally:
dbsession.close()
return return
# Otherwise, we can save the one we already have. # Otherwise, we can save the one we already have.
session_key = request.META["SESSION"].session_key # Use the cached session id to avoid touching a detached SQLAlchemy instance.
session_key = request._session["id"]
# update the session expiration time # update the session expiration time
response.set_cookie( response.set_cookie(
self.server.session_cookie_name, self.server.session_cookie_name,
@ -98,7 +114,18 @@ class SessionMiddleware(SpiderwebMiddleware):
**cookie_settings, **cookie_settings,
) )
session = request.META["SESSION"] dbsession = self.server.get_db_session()
session.session_data = json.dumps(request.SESSION) try:
session.last_active = datetime.now() session = (
session.save() dbsession.query(Session)
.filter(Session.session_key == session_key)
.order_by(Session.id.desc())
.first()
)
if session:
session.session_data = json.dumps(request.SESSION)
session.last_active = datetime.now()
dbsession.add(session)
dbsession.commit()
finally:
dbsession.close()

View file

@ -80,11 +80,21 @@ class Request:
self.META["client_address"] = get_client_address(self.environ) self.META["client_address"] = get_client_address(self.environ)
def populate_cookies(self) -> None: def populate_cookies(self) -> None:
if cookies := self.environ.get("HTTP_COOKIE"): cookies_header = self.environ.get("HTTP_COOKIE")
self.COOKIES = { if not cookies_header:
option.split("=")[0]: option.split("=")[1] return
for option in cookies.split("; ") cookies: dict[str, str] = {}
} # Split on ';' and be tolerant of optional spaces and malformed segments
for segment in cookies_header.split(";"):
part = segment.strip()
if not part:
continue
if "=" not in part:
# Ignore flag-like segments that don't conform to name=value
continue
name, _, value = part.partition("=") # only split on first '='
cookies[name.strip()] = value.strip()
self.COOKIES = cookies
def json(self): def json(self):
return json.loads(self.content) return json.loads(self.content)

View file

@ -1,7 +1,5 @@
from wsgiref.util import setup_testing_defaults from wsgiref.util import setup_testing_defaults
from peewee import SqliteDatabase
from spiderweb import SpiderwebRouter from spiderweb import SpiderwebRouter
@ -22,7 +20,7 @@ def setup(**kwargs):
environ = {} environ = {}
setup_testing_defaults(environ) setup_testing_defaults(environ)
if "db" not in kwargs: if "db" not in kwargs:
kwargs["db"] = SqliteDatabase("spiderweb-tests.db") kwargs["db"] = "spiderweb-tests.db"
return ( return (
SpiderwebRouter(**kwargs), SpiderwebRouter(**kwargs),
environ, environ,

View file

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

View file

@ -147,3 +147,71 @@ def test_setting_multiple_cookies():
app(environ, start_response) app(environ, start_response)
assert start_response.headers[-1] == ("set-cookie", "cookie2=value2") assert start_response.headers[-1] == ("set-cookie", "cookie2=value2")
assert start_response.headers[-2] == ("set-cookie", "cookie1=value1") assert start_response.headers[-2] == ("set-cookie", "cookie1=value1")
import json as _json
@pytest.mark.parametrize(
"cookie_header,expected",
[
("", {}),
(" ", {}),
(";", {}),
(";; ; ", {}),
("a=1", {"a": "1"}),
("a=1; b=2", {"a": "1", "b": "2"}),
("a=1; b", {"a": "1"}), # flag-like segment ignored
("flag", {}), # single flag ignored
("a=1; flag; c=3", {"a": "1", "c": "3"}),
("a=1; c=", {"a": "1", "c": ""}), # empty value allowed
("token=abc=def==", {"token": "abc=def=="}), # values may contain '='
(" d = q ", {"d": "q"}), # tolerate spaces around name/value
("a=1; ; ; c=3", {"a": "1", "c": "3"}), # empty segments ignored
("a=1; a=2", {"a": "2"}), # last duplicate wins
("q=\"a b c\"", {"q": '"a b c"'}), # quotes preserved
("u=hello%3Dworld", {"u": "hello%3Dworld"}), # url-encoded preserved
("=novalue; a=1", {"": "novalue", "a": "1"}), # empty name retained per current parser
("lead=1; ; trail=2;", {"lead": "1", "trail": "2"}),
(" spaced = value ; another= thing ", {"spaced": "value", "another": "thing"}),
("a=1; b=2; flag; c=; token=abc=def==; d = q ; ;", {"a": "1", "b": "2", "c": "", "token": "abc=def==", "d": "q"}),
],
ids=[
"empty",
"space-only",
"single-semicolon",
"many-empty",
"single-pair",
"two-pairs",
"flag-after",
"single-flag",
"mix-flag",
"empty-value",
"value-with-equals",
"spaces-around",
"ignore-empty-segments",
"duplicate-last-wins",
"quoted-value",
"url-encoded",
"empty-name",
"lead-trail-with-empties",
"spaces-around-multi",
"mixed-case-from-original",
],
)
def test_cookie_parsing_tolerates_malformed_segments(cookie_header, expected):
app, environ, start_response = setup()
from spiderweb.response import JsonResponse
@app.route("/")
def index(request):
return JsonResponse(data=request.COOKIES)
environ["HTTP_COOKIE"] = cookie_header
body = app(environ, start_response)[0].decode("utf-8")
data = _json.loads(body)
assert data == expected

View file

@ -2,7 +2,6 @@ from io import BytesIO, BufferedReader
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from peewee import SqliteDatabase
from spiderweb import SpiderwebRouter, HttpResponse, StartupErrors, ConfigError from spiderweb import SpiderwebRouter, HttpResponse, StartupErrors, ConfigError
from spiderweb.constants import DEFAULT_ENCODING from spiderweb.constants import DEFAULT_ENCODING
@ -24,6 +23,11 @@ from spiderweb.tests.views_for_tests import (
form_view_without_csrf, form_view_without_csrf,
text_view, text_view,
unauthorized_view, unauthorized_view,
file_view,
)
from spiderweb.middleware.gzip import (
CheckValidGzipMinimumLength,
CheckValidGzipCompressionLevel,
) )
@ -47,7 +51,11 @@ def test_session_middleware():
assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)] assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)]
session_key = Session.select().first().session_key _s = app.get_db_session()
try:
session_key = _s.query(Session).order_by(Session.id.asc()).first().session_key
finally:
_s.close()
environ["HTTP_COOKIE"] = f"swsession={session_key}" environ["HTTP_COOKIE"] = f"swsession={session_key}"
assert app(environ, start_response) == [bytes(str(1), DEFAULT_ENCODING)] assert app(environ, start_response) == [bytes(str(1), DEFAULT_ENCODING)]
@ -66,17 +74,25 @@ def test_expired_session():
assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)] assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)]
session = Session.select().first() _s = app.get_db_session()
session.created_at = session.created_at - timedelta(seconds=app.session_max_age) try:
session.save() session = _s.query(Session).order_by(Session.id.asc()).first()
session.created_at = session.created_at - timedelta(seconds=app.session_max_age)
environ["HTTP_COOKIE"] = f"swsession={session.session_key}" _s.add(session)
_s.commit()
environ["HTTP_COOKIE"] = f"swsession={session.session_key}"
finally:
_s.close()
# it shouldn't increment because we get a new session # it shouldn't increment because we get a new session
assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)] assert app(environ, start_response) == [bytes(str(0), DEFAULT_ENCODING)]
session2 = list(Session.select())[-1] _s2 = app.get_db_session()
assert session2.session_key != session.session_key try:
session2 = _s2.query(Session).order_by(Session.id.desc()).first()
assert session2.session_key != session.session_key
finally:
_s2.close()
def test_exploding_middleware(): def test_exploding_middleware():
@ -105,7 +121,7 @@ def test_csrf_middleware_without_session_middleware():
with pytest.raises(StartupErrors) as e: with pytest.raises(StartupErrors) as e:
SpiderwebRouter( SpiderwebRouter(
middleware=["spiderweb.middleware.csrf.CSRFMiddleware"], middleware=["spiderweb.middleware.csrf.CSRFMiddleware"],
db=SqliteDatabase("spiderweb-tests.db"), db="spiderweb-tests.db",
) )
exceptiongroup = e.value.args[1] exceptiongroup = e.value.args[1]
assert ( assert (
@ -152,9 +168,12 @@ def test_csrf_middleware():
formdata = f"name=bob&csrf_token={token}" formdata = f"name=bob&csrf_token={token}"
environ["CONTENT_TYPE"] = "application/x-www-form-urlencoded" environ["CONTENT_TYPE"] = "application/x-www-form-urlencoded"
environ["HTTP_COOKIE"] = ( _s = app.get_db_session()
f"swsession={[i for i in Session.select().dicts()][-1]['session_key']}" try:
) _last = _s.query(Session).order_by(Session.id.desc()).first()
environ["HTTP_COOKIE"] = f"swsession={_last.session_key}"
finally:
_s.close()
environ["REQUEST_METHOD"] = "POST" environ["REQUEST_METHOD"] = "POST"
environ["HTTP_X_CSRF_TOKEN"] = token environ["HTTP_X_CSRF_TOKEN"] = token
environ["CONTENT_LENGTH"] = len(formdata) environ["CONTENT_LENGTH"] = len(formdata)
@ -212,9 +231,12 @@ def test_csrf_expired_token():
formdata = f"name=bob&csrf_token={token}" formdata = f"name=bob&csrf_token={token}"
environ["CONTENT_TYPE"] = "application/x-www-form-urlencoded" environ["CONTENT_TYPE"] = "application/x-www-form-urlencoded"
environ["HTTP_COOKIE"] = ( _s = app.get_db_session()
f"swsession={[i for i in Session.select().dicts()][-1]['session_key']}" try:
) _last = _s.query(Session).order_by(Session.id.desc()).first()
environ["HTTP_COOKIE"] = f"swsession={_last.session_key}"
finally:
_s.close()
environ["REQUEST_METHOD"] = "POST" environ["REQUEST_METHOD"] = "POST"
environ["HTTP_ORIGIN"] = "example.com" environ["HTTP_ORIGIN"] = "example.com"
environ["HTTP_X_CSRF_TOKEN"] = token environ["HTTP_X_CSRF_TOKEN"] = token
@ -298,6 +320,149 @@ def test_csrf_trusted_origins():
assert resp2 == '{"name": "bob"}' assert resp2 == '{"name": "bob"}'
def test_post_process_middleware():
app, environ, start_response = setup(
middleware=[
"spiderweb.tests.middleware.PostProcessingMiddleware",
],
)
app.add_route("/", text_view)
environ["HTTP_USER_AGENT"] = "hi"
environ["REMOTE_ADDR"] = "/"
environ["REQUEST_METHOD"] = "GET"
assert app(environ, start_response) == [bytes("Hi! Moo!", DEFAULT_ENCODING)]
def test_post_process_header_manip():
app, environ, start_response = setup(
middleware=[
"spiderweb.tests.middleware.PostProcessingWithHeaderManipulation",
],
)
app.add_route("/", text_view)
environ["HTTP_USER_AGENT"] = "hi"
environ["REMOTE_ADDR"] = "/"
environ["REQUEST_METHOD"] = "GET"
assert app(environ, start_response) == [bytes("Hi!", DEFAULT_ENCODING)]
assert start_response.get_headers()["x-moo"] == "true"
def test_unused_post_process_middleware():
app, environ, start_response = setup(
middleware=[
"spiderweb.tests.middleware.ExplodingPostProcessingMiddleware",
],
)
app.add_route("/", text_view)
environ["HTTP_USER_AGENT"] = "hi"
environ["REMOTE_ADDR"] = "/"
environ["REQUEST_METHOD"] = "GET"
assert app(environ, start_response) == [bytes("Hi!", DEFAULT_ENCODING)]
# make sure it kicked out the middleware and isn't just ignoring it
assert len(app.middleware) == 0
class TestGzipMiddleware:
middleware = {"middleware": ["spiderweb.middleware.gzip.GzipMiddleware"]}
def test_not_enabled_on_small_response(self):
app, environ, start_response = setup(
**self.middleware,
gzip_minimum_response_length=500,
)
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 "Content-Encoding" not in start_response.get_headers()
def test_changing_minimum_response_length(self):
app, environ, start_response = setup(
**self.middleware,
gzip_minimum_response_length=1,
)
app.add_route("/", text_view)
environ["HTTP_ACCEPT_ENCODING"] = "gzip"
environ["HTTP_USER_AGENT"] = "hi"
environ["REMOTE_ADDR"] = "/"
environ["REQUEST_METHOD"] = "GET"
assert str(app(environ, start_response)[0]).startswith("b'\\x1f\\x8b\\x08")
assert "content-encoding" in start_response.get_headers()
def test_not_enabled_on_error_response(self):
app, environ, start_response = setup(
**self.middleware,
gzip_minimum_response_length=1,
)
app.add_route("/", unauthorized_view)
environ["HTTP_ACCEPT_ENCODING"] = "gzip"
environ["HTTP_USER_AGENT"] = "hi"
environ["REMOTE_ADDR"] = "/"
environ["REQUEST_METHOD"] = "GET"
assert app(environ, start_response) == [bytes("Unauthorized", DEFAULT_ENCODING)]
assert "content-encoding" not in start_response.get_headers()
def test_not_enabled_on_bytes_response(self):
app, environ, start_response = setup(
**self.middleware,
gzip_minimum_response_length=1,
)
# send a file that's already in bytes form
app.add_route("/", file_view)
environ["HTTP_ACCEPT_ENCODING"] = "gzip"
environ["HTTP_USER_AGENT"] = "hi"
environ["REMOTE_ADDR"] = "/"
environ["REQUEST_METHOD"] = "GET"
assert app(environ, start_response) == [bytes("hi", DEFAULT_ENCODING)]
assert "content-encoding" not in start_response.get_headers()
def test_invalid_response_length(self):
class FakeServer:
gzip_minimum_response_length = "asdf"
with pytest.raises(ConfigError) as e:
CheckValidGzipMinimumLength(server=FakeServer).check()
assert (
e.value.args[0] == CheckValidGzipMinimumLength.INVALID_GZIP_MINIMUM_LENGTH
)
def test_negative_response_length(self):
class FakeServer:
gzip_minimum_response_length = -1
with pytest.raises(ConfigError) as e:
CheckValidGzipMinimumLength(server=FakeServer).check()
assert (
e.value.args[0] == CheckValidGzipMinimumLength.INVALID_GZIP_MINIMUM_LENGTH
)
def test_bad_compression_level(self):
class FakeServer:
gzip_compression_level = "asdf"
with pytest.raises(ConfigError) as e:
CheckValidGzipCompressionLevel(server=FakeServer).check()
assert (
e.value.args[0]
== CheckValidGzipCompressionLevel.INVALID_GZIP_COMPRESSION_LEVEL
)
class TestCorsMiddleware: class TestCorsMiddleware:
# adapted from: # adapted from:
# https://github.com/adamchainz/django-cors-headers/blob/main/tests/test_middleware.py # https://github.com/adamchainz/django-cors-headers/blob/main/tests/test_middleware.py

View file

@ -1,7 +1,6 @@
from spiderweb import HttpResponse from spiderweb import HttpResponse
from spiderweb.decorators import csrf_exempt from spiderweb.decorators import csrf_exempt
from spiderweb.response import JsonResponse, TemplateResponse from spiderweb.response import JsonResponse, TemplateResponse, FileResponse
EXAMPLE_HTML_FORM = """ EXAMPLE_HTML_FORM = """
<form action="" method="post"> <form action="" method="post">
@ -47,3 +46,7 @@ def text_view(request):
def unauthorized_view(request): def unauthorized_view(request):
return HttpResponse("Unauthorized", status_code=401) return HttpResponse("Unauthorized", status_code=401)
def file_view(request):
return FileResponse("spiderweb/tests/staticfiles/file_for_testing_fileresponse.txt")