4 Commits
Author SHA1 Message Date
dsc 08ee20ac07 Release 0.0.1 2020-01-05 17:00:46 +01:00
dsc 6a51c15dbf Update README 2020-01-05 15:31:36 +01:00
dsc df20bbff18 Redis+Trio support 2020-01-05 15:31:32 +01:00
dsc 499e46c93d Initial commit 2020-01-05 01:41:30 +01:00
23 changed files with 759 additions and 1815 deletions
-30
View File
@@ -1,30 +0,0 @@
name: Build & Publish Package
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
python-version: "3.15"
- name: Build package
run: uv build
- name: Publish to Gitea
run: |
uv publish \
--publish-url "${{ vars.REGISTRY_URL }}/api/packages/${{ github.repository_owner }}/pypi" \
--username "${{ github.repository_owner }}" \
--password "${{ secrets.ACTION_ACCESS_TOKEN }}"
-1
View File
@@ -1 +0,0 @@
3.15
-59
View File
@@ -1,59 +0,0 @@
### 4.1.0
- Add opt-in native JSON session storage for Redis/Valkey via `SESSION_REDIS_JSON`.
- Read existing string sessions and migrate them to native JSON on their next save.
- Validate JSON command support at startup and apply session expiry transactionally.
### 4.0.0
- **Breaking:** require Python 3.15 or newer (`requires-python = ">=3.15"`, classifier, `.python-version`).
- Fix `AttributeError: 'str' object has no attribute 'decode'` in `save_session()` when
`SESSION_USE_SIGNER` is disabled (the default), which made every request fail with a 500.
- `save_session()` no longer crashes when no `secret_key` is set while `SESSION_USE_SIGNER`
is enabled; it now logs a warning and skips the cookie, like `open_session()` does.
- Add a pytest suite covering the session cookie round-trip.
- `quart_session.__version__` is now read from the installed package metadata
instead of a hardcoded string (it had drifted to `3.0.0` while the release was `3.0.2`).
### 3.0.2
- Add package metadata for Gitea PyPI registry publishing.
- Add Gitea workflow for tagged package releases.
- Add explicit redis-trio extra for the bundled Trio Redis client.
### 2.1.0
- `session_cookie_name` fix
### 2.0.0
- Move from aioredis to redis
### 1.0.7
- Updated memcached support (removed asyncio.loop, removed coroutine decorator)
### 1.0.6
- MongoDB support
- Transfer copyright to Kroket Ltd.
### 1.0.3 2021-08-31
- Migrated to aioredis 2
- SameSite support https://github.com/kroketio/quart-session/commit/8daae3a6734e8f7da13954d5a1a5da8f5fc5a49a
- Memcached stuff https://github.com/filak/quart-session/commit/004871c495a069784e57e604b69f65af1b7e645a
### 1.0.0 2020-01-15
- Added support for arbitrary usage of caching backends.
- Exposed `get`, `set`, `delete` on the session interface for direct usage.
- Renamed `SESSION_HIJACK_REVERSE_PROXY` to `SESSION_REVERSE_PROXY`.
- Renamed `SESSION_HIJACK_PROTECTION` to `SESSION_PROTECTION`.
- Removed fallback when `X-Forwarded-For` is not present whilst USING `SESSION_REVERSE_PROXY`, emit error instead.
- Fixed a bug where session timeouts would default to 600 seconds.
- Deprecated/disabled the `dirty()` method.
### 0.0.1 2020-01-04
- Released initial pre alpha version.
+1 -1
View File
@@ -1,5 +1,5 @@
Copyright (c) 2014 by Shipeng Feng. Copyright (c) 2014 by Shipeng Feng.
Copyright (c) 2020 by Kroket Ltd. Copyright (c) 2020 by dsc.
Some rights reserved. Some rights reserved.
-1
View File
@@ -7,4 +7,3 @@ recursive-include quart_session *.md
exclude .gitlab-ci.yml exclude .gitlab-ci.yml
exclude examples exclude examples
exclude docs exclude docs
exclude venv
+84 -101
View File
@@ -1,20 +1,23 @@
# Quart-Session # Quart-Session
![pyversions](https://img.shields.io/pypi/pyversions/Quart-Session.svg) [![pypiversion](https://badge.fury.io/py/Quart-Session.svg)](https://pypi.org/project/Quart-Session/) ![PyPI license](https://img.shields.io/pypi/l/Quart-Session.svg) ![pyversions](https://img.shields.io/pypi/pyversions/Quart-Session.svg) [![pypiversion](https://badge.fury.io/py/Quart-Session.svg)](https://pypi.org/project/Quart-Session/) ![PyPI license](https://img.shields.io/pypi/l/Quart-Session.svg)
Quart-Session is an extension for [Quart](https://gitlab.com/pgjones/quart/blob/master/README.rst) that adds support for Quart-Session is an extension for Quart that adds support for
server-side sessions to your application. server-side sessions to your application.
Based on [flask-session](https://pypi.org/project/Flask-Session/). Based on [flask-session](https://pypi.org/project/Flask-Session/).
## Quick start ## Quick start
Quart-Session can be installed via pipenv or pip,
Quart-Session can be installed via pipenv or
pip,
```bash ```bash
$ pipenv install quart-session $ pipenv install quart-session
$ pip install quart-session $ pip install quart-session
``` ```
and requires Python 3.15 or higher. A minimal Quart-Session example is: and requires Python 3.7.0 or higher. A fairly minimal Quart-Session example is,
```python3 ```python3
from quart import Quart, session from quart import Quart, session
@@ -26,20 +29,18 @@ Session(app)
@app.route('/') @app.route('/')
async def hello(): async def hello():
session["foo"] = "bar" session["foo"] = "bar"
return "session key 'foo' set" return 'hello'
@app.route('/foo')
async def foo():
return session.get("foo", "session key 'foo' not found")
app.run() app.run()
``` ```
## Features ## Features
### Redis
via `redis>=4.4.0`. ### Redis support
via `aioredis`.
```python3 ```python3
app = Quart(__name__) app = Quart(__name__)
@@ -47,16 +48,8 @@ app.config['SESSION_TYPE'] = 'redis'
Session(app) Session(app)
``` ```
By default, Quart-session connects to Redis at `127.0.0.1:6379`. If you If you already have a `aioredis.Client` instance and you'd like to share
have a different location, use `SESSION_URI` it with the session interface,
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_URI'] = 'redis://:password@localhost:6379'
```
Alternatively, for extra control, you may provide your own `aioredis.Client` instance altogether.
```python3 ```python3
app = Quart(__name__) app = Quart(__name__)
@@ -64,42 +57,16 @@ app.config['SESSION_TYPE'] = 'redis'
@app.before_serving @app.before_serving
async def setup(): async def setup():
cache = await aioredis.Redis( cache = await aioredis.create_redis_pool(...)
host="foobar.com", app.config['SESSION_REDIS'] = cache
port=6379, Session(app)
password="foobar"
)
app.config['SESSION_REDIS'] = cache
Session(app)
``` ```
#### Native JSON storage (Redis / Valkey) By default, Quart-session creates a single connection to Redis, while
By default, session values are JSON-encoded **strings**. To store `session:*` the example above sets up a connection pool.
keys as native JSON documents instead:
```python3 #### Trio support
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS_JSON'] = True
```
This requires a Redis or Valkey server with `JSON.GET` and `JSON.SET` support
(e.g. Redis with JSON support or Valkey with a compatible JSON module).
It is opt-in and supported only by the asyncio Redis backend. Unsupported
servers fail during startup rather than silently falling back to strings.
The Redis account needs permission for the JSON commands, `COMMAND INFO`,
`GET`, `DEL`, `EXPIRE`, and transactions.
Existing string sessions remain readable and are converted on their next save;
untouched keys are not migrated. Expiry is applied in the same transaction as
the JSON write. The tagged JSON serializer is retained, including support for
extended Python types. Custom serializers must produce JSON object strings.
Disabling this option does not convert JSON keys back into strings.
For example, inspect the stored object with `JSON.GET session:<sid> $` rather
than `GET session:<sid>`.
#### Trio
Quart-Session comes with [an (experimental) Redis client](quart_session/redis_trio) for use with the [Trio](https://trio.readthedocs.io/en/stable/) eventloop. Quart-Session comes with [an (experimental) Redis client](quart_session/redis_trio) for use with the [Trio](https://trio.readthedocs.io/en/stable/) eventloop.
```python3 ```python3
@@ -111,7 +78,8 @@ app.config['SESSION_TYPE'] = 'redis'
Session(app) Session(app)
``` ```
### Memcached ### Memcached support
via `aiomcache`. via `aiomcache`.
```python3 ```python3
@@ -120,21 +88,11 @@ app.config['SESSION_TYPE'] = 'memcached'
Session(app) Session(app)
``` ```
### MongoDB
via `motor`.
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'mongodb'
app.config['SESSION_MONGODB_URI'] = 'mongodb://localhost:27017/my_database'
app.config['SESSION_MONGODB_COLLECTION'] = 'sessions'
Session(app)
```
### JSON serializer ### JSON serializer
[flask-session](https://pypi.org/project/Flask-Session/) uses `pickle` [flask-session](https://pypi.org/project/Flask-Session/) uses `pickle`
for session data while Quart-Session uses [a JSON serializer](https://gitlab.com/pgjones/quart/blob/37e249b9b146824a8668eaa1daa12392aeb00256/src/quart/json/tag.py#L141) for session data, Quart-Session opts for a JSON serializer capable of
capable of serializing the usual JSON types, as well as: `Tuple`, `Bytes`, (de)serializing the usual JSON types, as well as: `Tuple`, `Bytes`,
`Markup`, `UUID`, and `DateTime`. `Markup`, `UUID`, and `DateTime`.
JSON as session data allows for greater interoperability with other JSON as session data allows for greater interoperability with other
@@ -149,46 +107,59 @@ app.config['SESSION_TYPE'] = 'redis'
Session(app) Session(app)
try: try:
import cPickle as pickle import cPickle as pickle
except ImportError: except ImportError:
import pickle import pickle
app.session_interface.serialize = pickle app.session_interface.serialize = pickle
``` ```
### Back-end usage ### Session control
At any point you may interface with the session back-end directly:
```python3
from quart_session.sessions import SessionInterface
@app.route("/") By default, [flask-session](https://pypi.org/project/Flask-Session/) sets a
async def hello():
cache: SessionInterface = app.session_interface
await cache.set("random_key", "val", expiry=3600)
data = await cache.get("random_key")
```
The interface will have the `get`, `set`, and `delete` methods available (regardless of
back-end - similar to how [aiocache](https://github.com/argaen/aiocache) works).
### Performance
[flask-session](https://pypi.org/project/Flask-Session/) sets a
session for each incoming request, including static files. From experience, session for each incoming request, including static files. From experience,
this often puts unneeded load on underlying session infrastructure, this approach can put unneeded load on underlying session infrastructure,
especially in high-traffic environments. especially in high-traffic environments.
Quart-Session only contacts the back-end when a session changed (or created). In addition, Quart-Session offers control over the session creation. For example, often you'll only need to create a session when
static file serves never emit a `Set-Cookie` header. If you'd like to enable a user successfully logs in.
this though, set `SESSION_STATIC_FILE` to `True`.
### Session pinning To enable this behaviour, set `SESSION_EXPLICIT` to `True`.
Associates an user's session to his/her IP address. This mitigates cookie stealing via XSS etc, and is handy
for web applications that require extra security.
```python3 ```python3
app = Quart(__name__) app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis' app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_PROTECTION'] = True app.config['SESSION_EXPLICIT'] = True
Session(app)
@app.route('/')
async def root():
if session.get('authenticated'):
return "Welcome back!"
return "Welcome anonymous!"
@app.route('/login')
async def login():
session["authenticated"] = True
session.dirty() # mark session for saving
return 'Logged in!'
app.run()
```
To re-gain the old behaviour of always emitting a `Set-Cookie` header on static file serves,
set `SESSION_STATIC_FILE` to `True`.
### Session pinning
Associates an user's session to his/her IP address. This mitigates cookie stealing via XSS etc, and is handy
for paranoid web applications.
```python3
app = Quart(__name__)
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_HIJACK_PROTECTION'] = True
Session(app) Session(app)
``` ```
@@ -196,25 +167,37 @@ Session reuse from a different IP will now result in the creation of a new sessi
**Important:** If your application is behind a reverse proxy, it most **Important:** If your application is behind a reverse proxy, it most
likely provides the `X-Forwarded-For` header which you **must** make use of likely provides the `X-Forwarded-For` header which you **must** make use of
by explicitly setting `SESSION_REVERSE_PROXY` to `True`. by explicitly setting `SESSION_HIJACK_REVERSE_PROXY` to `True`.
## Future development ## Future development
The following session interfaces would be nice to have:
- `MongoDBSessionInterface`
- `FileSystemSessionInterface` - `FileSystemSessionInterface`
- `GoogleCloudDatastoreSessionInterface` - `GoogleCloudDatastoreSessionInterface`
- Pytest
## Flask-Session Other to-do's:
- Unit testing
- Documentation (Sphinx)
## Migrating from Flask
This library works very similarly to [flask-session](https://pypi.org/project/Flask-Session/). This library works very similarly to [flask-session](https://pypi.org/project/Flask-Session/).
The changes are specified below: The `quart_session.sessions` APIs are not 100% the same, but unless you
- Quart-Session does not emit a `Set-Cookie` on every request. are embedded in Flask-Session's internals, a migration should be fairly
- Quart-Session does not emit a `Set-Cookie` on static file serves. straightforward. The distinct changes are specified below:
- Quart-Session does not `Set-Cookie` on (static) files by default.
- Quart-Session might not have all the back-end interfaces implemented (yet), such as "filesystem".
- Quart-Session uses a different serializer: `quart.json.tag.TaggedJSONSerializer` instead of `pickle`. - Quart-Session uses a different serializer: `quart.json.tag.TaggedJSONSerializer` instead of `pickle`.
- Quart-Session disallows the client to supply their own made up `sid` cookie value. - Quart-Session disallows the client to supply their own made up `sid` cookie value.
- Quart-Session can do session protection.
- Quart-Session might not have all the back-end interfaces implemented (yet), such as "filesystem".
## Help ## Help
Find the Quart folk on [gitter](https://gitter.im/python-quart/lobby) or open an issue. Find the Quart folk on [gitter](https://gitter.im/python-quart/lobby) or open an issue.
## License ## License
BSD BSD
+16 -11
View File
@@ -1,30 +1,35 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Hello Hello
~~~~~ ~~~~~
Quart-Session demo. Quart-Session demo.
:copyright: (c) 2020 by Kroket Ltd. :copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
from quart import Quart, session from quart import Quart, session
from quart_session import Session from quart_session import Session
SESSION_TYPE = 'redis' SESSION_TYPE = 'redis'
app = Quart(__name__) app = Quart(__name__)
app.config.from_object(__name__) app.config.from_object(__name__)
Session(app) Session(app)
@app.route('/set/') @app.route('/set/')
async def set(): def set():
session['key'] = 'value' session['key'] = 'value'
return 'ok' return 'ok'
@app.route('/get/') @app.route('/get/')
async def get(): def get():
return session.get('key', 'not set') return session.get('key', 'not set')
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True) app.run(debug=True)
-50
View File
@@ -1,50 +0,0 @@
[project]
name = "quart-session"
version = "4.1.0"
description = "Server-side sessions for Quart"
readme = "README.md"
requires-python = ">=3.15"
license = "BSD-3-Clause"
license-files = ["LICENSE"]
authors = [{ name = "Kroket Ltd.", email = "code@kroket.io" }]
dependencies = [
"Quart>=0.19.0",
]
classifiers = [
"Environment :: Web Environment",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.15",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Topic :: Software Development :: Libraries :: Python Modules",
]
[project.urls]
Homepage = "https://git.yiprawr.dev/daniel156161/quart-session"
Repository = "https://git.yiprawr.dev/daniel156161/quart-session"
Issues = "https://git.yiprawr.dev/daniel156161/quart-session/issues"
[project.optional-dependencies]
dotenv = ["python-dotenv"]
mongodb = ["motor>=2.5.1"]
redis = ["redis>=4.4.0"]
redis-trio = ["trio>=0.22"]
[dependency-groups]
dev = [
"build>=1.3.0",
"pytest>=8.0",
"pytest-asyncio>=0.24",
]
[build-system]
requires = ["setuptools>=80", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["quart_session*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
+98 -137
View File
@@ -1,167 +1,128 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
quart_session quart_session
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
Adds server session support to your application. Adds server session support to your application.
:copyright: (c) 2014 by Shipeng Feng. :copyright: (c) 2014 by Shipeng Feng.
:copyright: (c) 2020 by Kroket Ltd. :copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
import os __version__ = '0.0.1'
from importlib.metadata import PackageNotFoundError, version as _pkg_version
try: import os
__version__ = _pkg_version("quart-session") from typing import Optional
except PackageNotFoundError: # running from a source tree without install
__version__ = "unknown"
from quart import Quart from quart import Quart
from .sessions import ( from .sessions import RedisSessionInterface, RedisTrioSessionInterface, MemcachedSessionInterface, NullSessionInterface
RedisSessionInterface,
RedisTrioSessionInterface,
MemcachedSessionInterface,
MongoDBSessionInterface,
NullSessionInterface
)
class Session(object): class Session(object):
"""This class is used to add Server-side Session to one or more Quart """This class is used to add Server-side Session to one or more Quart
applications. applications.
There are two usage modes. One is initialize the instance with a very There are two usage modes. One is initialize the instance with a very
specific Quart application:: specific Quart application::
app = Quart(__name__) app = Quart(__name__)
Session(app) Session(app)
The second possibility is to create the object once and configure the The second possibility is to create the object once and configure the
application later:: application later::
sess = Session() sess = Session()
def create_app(): def create_app():
app = Quart(__name__) app = Quart(__name__)
sess.init_app(app) sess.init_app(app)
return app return app
By default Quart-Session will use :class:`NullSessionInterface`, you By default Quart-Session will use :class:`NullSessionInterface`, you
really should configure your app to use a different SessionInterface. really should configure your app to use a different SessionInterface.
.. note:: .. note::
You can not use ``Session`` instance directly, what ``Session`` does You can not use ``Session`` instance directly, what ``Session`` does
is just change the :attr:`~quart.Quart.session_interface` attribute on is just change the :attr:`~quart.Quart.session_interface` attribute on
your Quart applications. your Quart applications.
"""
def __init__(self, app: Quart = None) -> None:
self._current_async_library = "asyncio"
self.app = app
if app is not None:
self.init_app(app)
def init_app(self, app: Quart) -> None:
"""This is used to set up session for your app object.
:param app: the Quart app object with proper configuration.
""" """
try:
import quart_trio
if isinstance(app, quart_trio.QuartTrio):
self._current_async_library = "trio"
except ImportError:
pass
app.session_interface = self._get_interface(app)
@app.before_serving def __init__(self, app: Quart = None) -> None:
async def setup(): self._current_async_library = "asyncio"
await app.session_interface.create(app) self.app = app
if app is not None:
self.init_app(app)
def _get_interface(self, app: Quart): def init_app(self, app: Quart) -> None:
config = app.config.copy() """This is used to set up session for your app object.
config.setdefault('SESSION_TYPE', 'null')
config.setdefault('SESSION_PERMANENT', True)
config.setdefault('SESSION_USE_SIGNER', False)
config.setdefault('SESSION_KEY_PREFIX', 'session:')
config.setdefault('SESSION_PROTECTION', False)
config.setdefault('SESSION_REVERSE_PROXY', False)
config.setdefault('SESSION_STATIC_FILE', False)
config.setdefault('SESSION_EXPLICIT', False)
config.setdefault('SESSION_REDIS', None)
config.setdefault('SESSION_REDIS_JSON', False)
config.setdefault('SESSION_MEMCACHED', None)
config.setdefault('SESSION_FILE_DIR',
os.path.join(os.getcwd(), 'quart_session'))
config.setdefault('SESSION_FILE_THRESHOLD', 500)
config.setdefault('SESSION_FILE_MODE', 384)
config = {k: v for k, v in config.items() if k.startswith('SESSION_')}
if isinstance(config.get("SESSION_HIJACK_PROTECTION"), bool): :param app: the Quart app object with proper configuration.
app.logger.warning("Deprecation: `SESSION_HIJACK_PROTECTION` " """
"has been renamed to `SESSION_PROTECTION`") try:
import quart_trio
if isinstance(app, quart_trio.QuartTrio):
self._current_async_library = "trio"
except ImportError:
pass
app.session_interface = self._get_interface(app)
if isinstance(config.get("SESSION_HIJACK_REVERSE_PROXY"), str): @app.before_serving
app.logger.warning("Deprecation: `SESSION_HIJACK_REVERSE_PROXY` " async def setup():
"has been renamed to `SESSION_REVERSE_PROXY`") await app.session_interface.create(app)
backend_warning = f"Please specify a session backend. " \ def _get_interface(self, app: Quart):
f"Available interfaces: redis, redis+trio, " \ config = app.config.copy()
f"memcached, null. e.g: app.config['SESSION_TYPE'] = 'redis'" config.setdefault('SESSION_TYPE', 'null')
config.setdefault('SESSION_PERMANENT', True)
config.setdefault('SESSION_USE_SIGNER', False)
config.setdefault('SESSION_KEY_PREFIX', 'session:')
config.setdefault('SESSION_HIJACK_PROTECTION', False)
config.setdefault('SESSION_HIJACK_REVERSE_PROXY', False)
config.setdefault('SESSION_STATIC_FILE', False)
config.setdefault('SESSION_EXPLICIT', False)
config.setdefault('SESSION_REDIS', None)
config.setdefault('SESSION_MEMCACHED', None)
config.setdefault('SESSION_FILE_DIR',
os.path.join(os.getcwd(), 'quart_session'))
config.setdefault('SESSION_FILE_THRESHOLD', 500)
config.setdefault('SESSION_FILE_MODE', 384)
config = {k: v for k, v in config.items() if k.startswith('SESSION_')}
if config['SESSION_TYPE'] == 'redis': if config['SESSION_TYPE'] == 'redis':
options = { options = {
"redis": config['SESSION_REDIS'], "redis": config['SESSION_REDIS'],
"key_prefix": config['SESSION_KEY_PREFIX'], "key_prefix": config['SESSION_KEY_PREFIX'],
"use_signer": config['SESSION_USE_SIGNER'], "use_signer": config['SESSION_USE_SIGNER'],
"permanent": config['SESSION_PERMANENT'], "permanent": config['SESSION_PERMANENT'],
**config **config
} }
if self._current_async_library == "asyncio": if self._current_async_library == "asyncio":
session_interface = RedisSessionInterface(**options) session_interface = RedisSessionInterface(**options)
elif self._current_async_library == "trio": elif self._current_async_library == "trio":
session_interface = RedisTrioSessionInterface(**options) session_interface = RedisTrioSessionInterface(**options)
else: else:
raise NotImplementedError("Unknown eventloop") raise NotImplementedError("Unknown eventloop")
elif config['SESSION_TYPE'] == 'redis+trio': elif config['SESSION_TYPE'] == 'redis+trio':
session_interface = RedisTrioSessionInterface( session_interface = RedisTrioSessionInterface(
redis=config['SESSION_REDIS'], redis=config['SESSION_REDIS'],
key_prefix=config['SESSION_KEY_PREFIX'], key_prefix=config['SESSION_KEY_PREFIX'],
use_signer=config['SESSION_USE_SIGNER'], use_signer=config['SESSION_USE_SIGNER'],
premanent=config['SESSION_PERMANENT'], premanent=config['SESSION_PERMANENT'],
**config **config
) )
elif config['SESSION_TYPE'] == 'memcached': elif config['SESSION_TYPE'] == 'memcached':
session_interface = MemcachedSessionInterface( session_interface = MemcachedSessionInterface(
memcached=config['SESSION_MEMCACHED'], memcached=config['SESSION_MEMCACHED'],
key_prefix=config['SESSION_KEY_PREFIX'], key_prefix=config['SESSION_KEY_PREFIX'],
use_signer=config['SESSION_USE_SIGNER'], use_signer=config['SESSION_USE_SIGNER'],
permanent=config['SESSION_PERMANENT'], permanent=config['SESSION_PERMANENT'],
**config) **config)
elif config['SESSION_TYPE'] == 'mongodb': else:
session_interface = MongoDBSessionInterface( session_interface = NullSessionInterface()
mongodb_uri=config['SESSION_MONGODB_URI'],
collection=config['SESSION_MONGODB_COLLECTION'],
client_kwargs=config.get('SESSION_MONGODB_CLIENT_KWARGS', {}),
set_callback=config.get('SESSION_MONGODB_SET_CALLBACK'),
key_prefix=config['SESSION_KEY_PREFIX'],
use_signer=config['SESSION_USE_SIGNER'],
permanent=config['SESSION_PERMANENT'],
**config)
elif config['SESSION_TYPE'] == 'null':
app.logger.warning(f"{backend_warning}. Currently using: null")
session_interface = NullSessionInterface(
key_prefix=config['SESSION_KEY_PREFIX'],
use_signer=config['SESSION_USE_SIGNER'],
permanent=config['SESSION_PERMANENT'],
**config)
else:
raise NotImplementedError(f"No such session interface "
f"\"{config['SESSION_TYPE']}\". {backend_warning}")
return session_interface return session_interface
-37
View File
@@ -1,37 +0,0 @@
"""Native JSON storage for the asyncio Redis session backend."""
import json
async def check_support(backend):
"""Fail at startup rather than silently falling back to string storage."""
commands = await backend.execute_command("COMMAND", "INFO", "JSON.GET", "JSON.SET")
entries = list(commands.values()) if isinstance(commands, dict) else commands
if len(entries) != 2 or not all(entries):
raise RuntimeError("SESSION_REDIS_JSON requires JSON.GET and JSON.SET support")
async def get(backend, key):
"""Read legacy strings as well as native JSON documents."""
from redis.exceptions import ResponseError
try:
return await backend.get(key)
except ResponseError as error:
if not str(error).startswith("WRONGTYPE"):
raise
# A single root path returns the object, not a JSONPath result array.
return await backend.execute_command("JSON.GET", key, ".")
async def set(backend, key, value, expiry):
"""Replace a string or JSON key and apply expiry in one transaction."""
# Validate before deleting an existing key. Preserve TaggedJSONSerializer's
# representation so tuples, datetimes, etc. still round-trip normally.
document = json.loads(value)
if not isinstance(document, dict):
raise ValueError("Native Redis JSON sessions require a JSON object")
async with backend.pipeline(transaction=True) as pipeline:
pipeline.delete(key)
pipeline.execute_command("JSON.SET", key, "$", value)
if expiry is not None:
pipeline.expire(key, expiry)
results = await pipeline.execute()
return results[1]
+5 -3
View File
@@ -1,3 +1,5 @@
Code here is borrowed from [alekseyev/trio-redis](https://github.com/alekseyev/trio-redis), which was originally developed over at [Bogdanp/trio-redis](https://github.com/Bogdanp/trio-redis). Code here is borrowed from [alekseyev/trio-redis](https://github.com/alekseyev/trio-redis), which was originally developed over at [Bogdanp/trio-redis](https://github.com/Bogdanp/trio-redis).
Since it has no active maintainers and no PyPI package - I am including it as-is. Since it has no active maintainers and no PyPI package - I am including it as-is.
@@ -7,7 +9,7 @@ Since it has no active maintainers and no PyPI package - I am including it as-is
```python3 ```python3
from quart_session.redis_trio import RedisTrio from quart_session.redis_trio import RedisTrio
cache = RedisTrio( cache = RedisTrio(
addr=b"10.0.0.3", port=6379, password=b"foo") addr=b"10.0.0.3", port=6379, password=b"foo")
await cache.connect() await cache.connect()
await cache.setex(key="foo", value=42, seconds=300) await cache.setex(key="foo", value=42, seconds=300)
@@ -18,8 +20,8 @@ Or,
```python3 ```python3
async with RedisTrio() as cache: async with RedisTrio() as cache:
await cache.set("foo", 42) await cache.set("foo", 42)
await cache.get("foo") await cache.get("foo")
``` ```
## Future work ## Future work
+7 -7
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
quart_session.redis_trio quart_session.redis_trio
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~
A simple Redis Trio client. A simple Redis Trio client.
:copyright: (c) 2017 by Bogdan Paul Popa. :copyright: (c) 2017 by Bogdan Paul Popa.
:copyright: (c) 2019 by Oleksii Aleksieiev. :copyright: (c) 2019 by Oleksii Aleksieiev.
:copyright: (c) 2020 by dsc. :copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
from quart_session.redis_trio.client import RedisTrio from quart_session.redis_trio.client import RedisTrio
+62 -61
View File
@@ -1,87 +1,88 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
quart_session.redis_trio quart_session.redis_trio
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~
A simple Redis Trio client. A simple Redis Trio client.
:copyright: (c) 2017 by Bogdan Paul Popa. :copyright: (c) 2017 by Bogdan Paul Popa.
:copyright: (c) 2019 by Oleksii Aleksieiev. :copyright: (c) 2019 by Oleksii Aleksieiev.
:copyright: (c) 2020 by dsc. :copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
from typing import Union from typing import Union
from .connection import RedisConnection from .connection import RedisConnection
class RedisTrio: class RedisTrio:
"""A simple Redis+Trio client. """A simple Redis+Trio client.
Parameters: Parameters:
addr(str): The IP address the Redis server is listening on. addr(str): The IP address the Redis server is listening on.
port(int): The port the Redis server is listening on. port(int): The port the Redis server is listening on.
Examples: Examples:
>>> async with RedisTrio() as redis: >>> async with RedisTrio() as redis:
... await redis.set("foo", 42) ... await redis.set("foo", 42)
... await redis.get("foo") ... await redis.get("foo")
b'42' b'42'
"""
def __init__(self, addr: Union[bytes, str] = b"127.0.0.1", port: int = 6379, password: bytes = b""):
self.conn = RedisConnection(addr, port)
self.password = password
async def connect(self):
"""Open a connection to the Redis server.
Returns:
RedisTrio: This instance.
""" """
await self.conn.connect()
if self.password:
await self.auth(self.password)
return self
async def close(self): def __init__(self, addr: Union[bytes, str] = b"127.0.0.1", port: int = 6379, password: bytes = b""):
"""Close the connection to the Redis server. self.conn = RedisConnection(addr, port)
""" self.password = password
await self.quit()
self.conn.close()
async def auth(self, password): async def connect(self):
return await self.conn.process_command_ok(b"AUTH", password) """Open a connection to the Redis server.
async def delete(self, *keys): Returns:
return await self.conn.process_command(b"DEL", *keys) RedisTrio: This instance.
"""
await self.conn.connect()
if self.password:
await self.auth(self.password)
return self
async def echo(self, message): async def close(self):
return await self.conn.process_command(b"ECHO", message) """Close the connection to the Redis server.
"""
await self.quit()
self.conn.close()
async def flushall(self): async def auth(self, password):
return await self.conn.process_command_ok(b"FLUSHALL") return await self.conn.process_command_ok(b"AUTH", password)
async def get(self, key) -> bytes: async def delete(self, *keys):
return await self.conn.process_command(b"GET", key) return await self.conn.process_command(b"DEL", *keys)
async def quit(self): async def echo(self, message):
return await self.conn.process_command(b"QUIT") return await self.conn.process_command(b"ECHO", message)
async def set(self, key, value): async def flushall(self):
return await self.conn.process_command_ok(b"SET", key, value) return await self.conn.process_command_ok(b"FLUSHALL")
async def setex(self, key: str, value: str, seconds: int): async def get(self, key) -> bytes:
"""Set the value and expiration of a key. return await self.conn.process_command(b"GET", key)
:raises TypeError: if seconds is not int
"""
if not isinstance(seconds, int):
raise TypeError("milliseconds argument must be int")
return await self.conn.process_command_ok(b"SETEX", key, seconds, value) async def quit(self):
return await self.conn.process_command(b"QUIT")
async def __aenter__(self): async def set(self, key, value):
return await self.connect() return await self.conn.process_command_ok(b"SET", key, value)
async def __aexit__(self, exc_type, exc_value, traceback): async def setex(self, key: str, value: str, seconds: int):
self.close() """Set the value and expiration of a key.
:raises TypeError: if seconds is not int
"""
if not isinstance(seconds, int):
raise TypeError("milliseconds argument must be int")
return await self.conn.process_command_ok(b"SETEX", key, seconds, value)
async def __aenter__(self):
return await self.connect()
async def __aexit__(self, exc_type, exc_value, traceback):
self.close()
+88 -85
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
quart_session.redis_trio.connection quart_session.redis_trio.connection
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~
A simple Redis Trio client. A simple Redis Trio client.
:copyright: (c) 2017 by Bogdan Paul Popa. :copyright: (c) 2017 by Bogdan Paul Popa.
:copyright: (c) 2019 by Oleksii Aleksieiev. :copyright: (c) 2019 by Oleksii Aleksieiev.
:copyright: (c) 2020 by dsc. :copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
from typing import Union from typing import Union
@@ -18,6 +18,7 @@ import trio
from .serialization import atom, serialize from .serialization import atom, serialize
from .errors import ProtocolError, ResponseError, ResponseTypeError from .errors import ProtocolError, ResponseError, ResponseTypeError
SP = ord("+") SP = ord("+")
EP = ord("-") EP = ord("-")
IP = ord(":") IP = ord(":")
@@ -27,107 +28,109 @@ AP = ord("*")
#: The set of known Redis response prefixes. #: The set of known Redis response prefixes.
known_prefixes = {SP, EP, IP, BP, AP} known_prefixes = {SP, EP, IP, BP, AP}
class ReadMore(Exception): class ReadMore(Exception):
"""Raised by parse to signal that it needs more data. """Raised by parse to signal that it needs more data.
""" """
class RedisConnection: class RedisConnection:
"""This class facilitates all communication with Redis via a trio socket. """This class facilitates all communication with Redis via a trio socket.
Warning: Warning:
The interface of this class may change at any time, without The interface of this class may change at any time, without
notice, due to the experimental nature of Trio. notice, due to the experimental nature of Trio.
""" """
def __init__(self, addr: Union[bytes, str], port: int, bufsize: int = 16384): def __init__(self, addr: Union[bytes, str], port: int, bufsize: int = 16384):
self.addr = (addr, port) self.addr = (addr, port)
self.sock = trio.socket.socket() self.sock = trio.socket.socket()
self.bufsize = bufsize self.bufsize = bufsize
async def connect(self): async def connect(self):
await self.sock.connect(self.addr) await self.sock.connect(self.addr)
def close(self): def close(self):
self.sock.close() self.sock.close()
async def send_command(self, command, *args): async def send_command(self, command, *args):
command_and_args = (serialize(arg) for arg in (atom(command),) + args) command_and_args = (serialize(arg) for arg in (atom(command),) + args)
data = b" ".join(command_and_args) + b"\r\n" data = b" ".join(command_and_args) + b"\r\n"
await self.sock.send(data) await self.sock.send(data)
async def process_command(self, *command_and_args): async def process_command(self, *command_and_args):
await self.send_command(*command_and_args) await self.send_command(*command_and_args)
return await self.process_response() return await self.process_response()
async def process_command_ok(self, *command_and_args): async def process_command_ok(self, *command_and_args):
await self.send_command(*command_and_args) await self.send_command(*command_and_args)
return await self.process_response() == b"OK" return await self.process_response() == b"OK"
async def process_response(self): async def process_response(self):
data = await self.sock.recv(self.bufsize) data = await self.sock.recv(self.bufsize)
while True: while True:
try: try:
item, _ = await self.parse(data) item, _ = await self.parse(data)
return item return item
except ReadMore: except ReadMore:
data += await self.sock.recv(self.bufsize) data += await self.sock.recv(self.bufsize)
async def parse(self, data): async def parse(self, data):
try: try:
index = data.index(b"\r\n") index = data.index(b"\r\n")
except ValueError: except ValueError:
raise ReadMore() raise ReadMore()
if data[0] not in known_prefixes: if data[0] not in known_prefixes:
raise ProtocolError(f"Unexpected data in response: {data!r}.") raise ProtocolError(f"Unexpected data in response: {data!r}.")
elif data[0] == SP: elif data[0] == SP:
return data[1:index], data[index + 2:] return data[1:index], data[index + 2:]
elif data[0] == EP: elif data[0] == EP:
error = data[1:index].decode("ascii") error = data[1:index].decode("ascii")
if error.startswith("WRONGTYPE"): if error.startswith("WRONGTYPE"):
raise ResponseTypeError(error[len("WRONGTYPE "):]) raise ResponseTypeError(error[len("WRONGTYPE "):])
elif error.startswith("ERR"): elif error.startswith("ERR"):
raise ResponseError(error[len("ERR "):]) raise ResponseError(error[len("ERR "):])
else: else:
raise ResponseError(error) raise ResponseError(error)
elif data[0] == IP: elif data[0] == IP:
return int(data[1:index]), data[index + 2:] return int(data[1:index]), data[index + 2:]
elif data[0] == BP: elif data[0] == BP:
length, data = int(data[1:index]), data[index + 2:] length, data = int(data[1:index]), data[index + 2:]
if length == -1: if length == -1:
return None, data return None, data
elif len(data) < length + 2: elif len(data) < length + 2:
raise ReadMore() raise ReadMore()
return data[:length], data[length + 2:] return data[:length], data[length + 2:]
elif data[0] == AP: elif data[0] == AP:
length, data = int(data[1:index]), data[index + 2:] length, data = int(data[1:index]), data[index + 2:]
if length == -1: if length == -1:
return None, data return None, data
return await self.parse_array(length, data) return await self.parse_array(length, data)
else: # pragma: no cover else: # pragma: no cover
assert False, "unreachable" assert False, "unreachable"
async def parse_array(self, length, data): async def parse_array(self, length, data):
items = [] items = []
while len(items) < length: while len(items) < length:
if not data: if not data:
data += await self.sock.recv(self.bufsize) data += await self.sock.recv(self.bufsize)
continue continue
try: try:
item, data = await self.parse(data) item, data = await self.parse(data)
items.append(item) items.append(item)
except ReadMore: except ReadMore:
data += await self.sock.recv(self.bufsize) data += await self.sock.recv(self.bufsize)
return items, data return items, data
+20 -16
View File
@@ -1,29 +1,33 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
quart_session.redis_trio.errors quart_session.redis_trio.errors
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~
A simple Redis Trio client. A simple Redis Trio client.
:copyright: (c) 2017 by Bogdan Paul Popa. :copyright: (c) 2017 by Bogdan Paul Popa.
:copyright: (c) 2019 by Oleksii Aleksieiev. :copyright: (c) 2019 by Oleksii Aleksieiev.
:copyright: (c) 2020 by dsc. :copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
class RedisError(Exception): class RedisError(Exception):
"""Base class for all Redis-related errors. """Base class for all Redis-related errors.
""" """
class ProtocolError(RedisError): class ProtocolError(RedisError):
"""Raised when Redis responds with something that doesn't conform """Raised when Redis responds with something that doesn't conform
to the protocol. to the protocol.
""" """
class ResponseError(RedisError): class ResponseError(RedisError):
"""Raised when Redis returns an error response. """Raised when Redis returns an error response.
""" """
class ResponseTypeError(ResponseError): class ResponseTypeError(ResponseError):
"""Raised when Redis returns an error response with a `WRONGTYPE` prefix. """Raised when Redis returns an error response with a `WRONGTYPE` prefix.
""" """
+37 -34
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
quart_session.redis_trio.serialization quart_session.redis_trio.serialization
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~
A simple Redis Trio client. A simple Redis Trio client.
:copyright: (c) 2017 by Bogdan Paul Popa. :copyright: (c) 2017 by Bogdan Paul Popa.
:copyright: (c) 2019 by Oleksii Aleksieiev. :copyright: (c) 2019 by Oleksii Aleksieiev.
:copyright: (c) 2020 by dsc. :copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
from collections import namedtuple from collections import namedtuple
@@ -19,39 +19,42 @@ atom = namedtuple("atom", ("value",))
#: The set of characters that must be escaped before being sent as #: The set of characters that must be escaped before being sent as
#: Redis strings. #: Redis strings.
escapes = { escapes = {
ord(b"\0"): rb"\x00", ord(b"\0"): rb"\x00",
ord(b"\n"): rb"\n", ord(b"\n"): rb"\n",
ord(b"\r"): rb"\r", ord(b"\r"): rb"\r",
ord(b"\\"): rb"\\", ord(b"\\"): rb"\\",
ord(b'"'): rb'\"', ord(b'"'): rb'\"',
} }
def serialize(x): def serialize(x):
"""Serialize `x` so that it can safely be sent to Redis. """Serialize `x` so that it can safely be sent to Redis.
Parameters: Parameters:
x(object): The value to serialize. x(object): The value to serialize.
Returns:
bytes: The serialized value.
"""
if isinstance(x, atom):
return x.value
elif isinstance(x, bytes):
return quote(x)
elif isinstance(x, str):
return quote(x.encode("utf-8"))
elif isinstance(x, (float, int)):
return str(x).encode("ascii")
else:
return serialize(str(x))
Returns:
bytes: The serialized value.
"""
if isinstance(x, atom):
return x.value
elif isinstance(x, bytes):
return quote(x)
elif isinstance(x, str):
return quote(x.encode("utf-8"))
elif isinstance(x, (float, int)):
return str(x).encode("ascii")
else:
return serialize(str(x))
def quote(bs): def quote(bs):
return b'"' + bytes(escape(bs)) + b'"' return b'"' + bytes(escape(bs)) + b'"'
def escape(bs): def escape(bs):
for c in bs: for c in bs:
if c in escapes: if c in escapes:
yield from escapes[c] yield from escapes[c]
else: else:
yield c yield c
+286 -397
View File
@@ -1,474 +1,363 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
quart_session.sessions quart_session.sessions
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~
Server-side Sessions and SessionInterfaces. Server-side Sessions and SessionInterfaces.
:copyright: (c) 2014 by Shipeng Feng. :copyright: (c) 2014 by Shipeng Feng.
:copyright: (c) 2020 by Kroket Ltd. :copyright: (c) 2020 by dsc.
:license: BSD, see LICENSE for more details. :license: BSD, see LICENSE for more details.
""" """
import time import time
from typing import Optional from typing import Any, Callable, Optional, TYPE_CHECKING
from uuid import uuid4, UUID from uuid import uuid4
import asyncio import asyncio
import functools
from quart import Quart, current_app from quart import Quart
from quart.wrappers import BaseRequestWebsocket, Response from quart.wrappers import BaseRequestWebsocket, Response
from quart.wrappers.response import FileBody from quart.wrappers.response import FileBody
from quart.sessions import SessionInterface as QuartSessionInterface, SecureCookieSession from quart.sessions import SessionInterface as QuartSessionInterface, SecureCookieSession
from quart.json.tag import TaggedJSONSerializer from quart.json.tag import TaggedJSONSerializer
from itsdangerous import Signer, BadSignature, want_bytes from itsdangerous import Signer, BadSignature, want_bytes
def total_seconds(td): def total_seconds(td):
return td.days * 60 * 60 * 24 + td.seconds return td.days * 60 * 60 * 24 + td.seconds
class ServerSideSession(SecureCookieSession): class ServerSideSession(SecureCookieSession):
"""Baseclass for server-side based sessions.""" """Baseclass for server-side based sessions."""
def __init__(self, initial=None, sid=None, permanent=None, addr=None): def __init__(self, initial=None, sid=None, permanent=None, addr=None):
super(ServerSideSession, self).__init__(initial or {}) super(ServerSideSession, self).__init__(**initial or {})
self.sid = sid self.sid = sid
if permanent: if permanent:
self.permanent = permanent self.permanent = permanent
if addr: if addr:
self.addr = addr self.addr = addr
self.modified = False self._dirty = False
def dirty(self): @property
current_app.logger.warning("Deprecation: `dirty()` has " def addr(self) -> str:
"been made obsolete. Will be " return self.get('_addr', False) # type: ignore
"removed soon^tm.")
@property @addr.setter
def addr(self) -> str: def addr(self, value: str) -> None:
return self.get('_addr', False) # type: ignore self['_addr'] = value # type: ignore
def dirty(self):
"""Marks the session to be written/saved.
.. note::
This feature only works if you have set ``SESSION_EXPLICIT``
to ``True``, at which point you'll have to explicitly mark
each session before they'll get processed and saved.
Example::
app.config['SESSION_EXPLICIT'] = True
Session(app)
@app.route('/')
def root():
session['foo'] = 'bar'
session.dirty()
return "Hello World!"
"""
self._dirty = True
@addr.setter
def addr(self, value: str) -> None:
self['_addr'] = value # type: ignore
class RedisSession(ServerSideSession): class RedisSession(ServerSideSession):
pass pass
class MemcachedSession(ServerSideSession): class MemcachedSession(ServerSideSession):
pass pass
class MongoDBSession(ServerSideSession):
pass
class NullSession(ServerSideSession): class NullSession(ServerSideSession):
pass pass
class SessionInterface(QuartSessionInterface): class SessionInterface(QuartSessionInterface):
"""Baseclass for session interfaces""" """Baseclass for session interfaces"""
serializer = TaggedJSONSerializer() serializer = TaggedJSONSerializer()
session_class = None session_class = None
def __init__( def __init__(
self, self,
key_prefix: str, key_prefix: str,
use_signer: bool = False, use_signer: bool = False,
permanent: bool = True, permanent: bool = True,
**kwargs **kwargs
) -> None: ) -> None:
self.key_prefix = key_prefix self.key_prefix = key_prefix
self.use_signer = use_signer self.use_signer = use_signer
self.permanent = permanent self.permanent = permanent
self._config = kwargs self._config = kwargs
async def open_session( async def open_session(
self, self,
app: Quart, app: Quart,
request: BaseRequestWebsocket request: BaseRequestWebsocket
) -> Optional[SecureCookieSession]: ) -> Optional[SecureCookieSession]:
cname = app.config.get('SESSION_COOKIE_NAME', 'session') sid = request.cookies.get(app.session_cookie_name)
sid = request.cookies.get(cname) addr = request.headers.get('X-Forwarded-For', request.remote_addr) if \
if self._config['SESSION_REVERSE_PROXY'] is True: self._config['SESSION_HIJACK_PROTECTION'] else None
# and no, you cannot define your own incoming options = {"sid": sid, "permanent": self.permanent, "addr": addr}
# header, stick to standards :-)
addr = request.headers.get('X-Forwarded-For')
if not addr:
app.logger.error("Could not grab IP from reverse proxy, "
"session protection is DISABLED!")
else:
addr = request.remote_addr
options = {"sid": sid, "permanent": self.permanent, "addr": addr}
if not sid: if not sid:
options['sid'] = self._generate_sid() options['sid'] = self._generate_sid()
return self.session_class(**options) return self.session_class(**options)
if self.use_signer: if self.use_signer:
signer = self._get_signer(app) signer = self._get_signer(app)
if signer is None: if signer is None:
app.logger.warning("Failed to obtain a valid signer.") app.logger.warning("Failed to obtain a valid signer.")
return None return None
try: try:
sid_as_bytes = signer.unsign(sid) sid_as_bytes = signer.unsign(sid)
sid = sid_as_bytes.decode() sid = sid_as_bytes.decode()
except BadSignature: except BadSignature:
app.logger.warning(f"Bad signature for sid: {sid}.") app.logger.warning(f"Bad signature for sid: {sid}.")
options['sid'] = self._generate_sid() options['sid'] = self._generate_sid()
return self.session_class(**options) return self.session_class(**options)
val = await self.get(key=self.key_prefix + sid, app=app) val = await self._backend_get(app, self.key_prefix + sid)
if val is None: if val is None:
options['sid'] = self._generate_sid() options['sid'] = self._generate_sid()
return self.session_class(**options) return self.session_class(**options)
if self.serializer is None: try:
data = val data = self.serializer.loads(val)
else: except:
try: app.logger.warning(f"Failed to deserialize session "
data = self.serializer.loads(val) f"data for sid: {sid}. Generating new sid.")
except: options['sid'] = self._generate_sid()
app.logger.warning(f"Failed to deserialize session " return self.session_class(**options)
f"data for sid: {sid}. Generating new sid.")
app.logger.debug(f"data: {val}")
options['sid'] = self._generate_sid()
return self.session_class(**options)
protection = self._config['SESSION_PROTECTION'] prevent_hijack = self._config['SESSION_HIJACK_PROTECTION']
if protection is True and addr is not None and \ if prevent_hijack is True:
data.get('_addr', addr) != addr: if self._config['SESSION_HIJACK_REVERSE_PROXY'] is True:
await self.delete(key=self.key_prefix + sid, app=app) addr = request.headers.get('X-Forwarded-For', request.remote_addr)
options['sid'] = self._generate_sid() else:
return self.session_class(**options) addr = request.remote_addr
if data.get('_addr', addr) != addr:
await self._backend_delete(app, self.key_prefix + sid)
options['sid'] = self._generate_sid()
return self.session_class(**options)
res = self.session_class(data, sid) res = self.session_class(data, sid)
return res return res
async def save_session( # type: ignore async def save_session( # type: ignore
self, self,
app: "Quart", app: "Quart",
session: SecureCookieSession, session: SecureCookieSession,
response: Response response: Response
) -> None: ) -> None:
# prevent set-cookie on unmodified session objects # prevent set-cookie
if not session.modified: if self._config['SESSION_EXPLICIT'] is True and \
return not session._dirty:
return
# prevent set-cookie on (static) file responses # prevent set-cookie on (static) file responses
# https://github.com/fengsp/flask-session/pull/70 # https://github.com/fengsp/flask-session/pull/70
if self._config['SESSION_STATIC_FILE'] is False and \ if self._config['SESSION_STATIC_FILE'] is False and \
isinstance(response.response, FileBody): isinstance(response.response, FileBody):
return return
session_key = self.key_prefix + session.sid
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
if not session:
if session.modified:
await self._backend_delete(app=app, key=session_key)
response.delete_cookie(app.session_cookie_name,
domain=domain, path=path)
return
httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session)
cname = app.config.get('SESSION_COOKIE_NAME', 'session') val = self.serializer.dumps(dict(session))
session_key = self.key_prefix + session.sid await self._backend_set(app=app, key=session_key, value=val)
domain = self.get_cookie_domain(app) if self.use_signer:
path = self.get_cookie_path(app) session_id = self._get_signer(app).sign(want_bytes(session.sid))
if not session: else:
if session.modified: session_id = session.sid
await self.delete(key=session_key, app=app) response.set_cookie(app.session_cookie_name, session_id,
response.delete_cookie(cname, expires=expires, httponly=httponly,
domain=domain, path=path) domain=domain, path=path, secure=secure)
return
httponly = self.get_cookie_httponly(app)
samesite = self.get_cookie_samesite(app)
secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session)
if self.serializer is None: async def create(self, app: Quart):
val = dict(session) raise NotImplementedError()
else:
val = self.serializer.dumps(dict(session))
await self.set(key=session_key, value=val, app=app) async def _backend_get(self, app: Quart, key: str):
if self.use_signer: raise NotImplementedError()
signer = self._get_signer(app)
if signer is None:
app.logger.warning("Failed to obtain a valid signer, "
"not setting a session cookie.")
return
session_id = signer.sign(want_bytes(session.sid)).decode('utf-8')
else:
session_id = session.sid
response.set_cookie(cname, session_id,
expires=expires, httponly=httponly,
domain=domain, path=path, secure=secure, samesite=samesite)
async def create(self, app: Quart): async def _backend_set(self, app: Quart, key: str, value):
raise NotImplementedError() raise NotImplementedError()
async def get(self, key: str, app: Quart = None): async def _backend_delete(self, app: Quart, key: str):
raise NotImplementedError() raise NotImplementedError()
async def set(self, key: str, value, expiry: int = None, def _generate_sid(self) -> str:
app: Quart = None): return str(uuid4())
raise NotImplementedError()
async def delete(self, key: str, app: Quart = None): def _get_signer(self, app) -> Optional[Signer]:
raise NotImplementedError() if not app.secret_key:
return None
return Signer(app.secret_key, salt='quart-session',
key_derivation='hmac')
def _generate_sid(self) -> str:
return str(uuid4())
def _get_signer(self, app) -> Optional[Signer]:
if not app.secret_key:
return None
return Signer(app.secret_key, salt='quart-session',
key_derivation='hmac')
class RedisSessionInterface(SessionInterface): class RedisSessionInterface(SessionInterface):
"""Uses the Redis key-value store as a session backend. """Uses the Redis key-value store as a session backend.
:param redis: ``aioredis.Redis`` instance. :param redis: ``aioredis.Redis`` instance.
:param key_prefix: A prefix that is added to all Redis store keys. :param key_prefix: A prefix that is added to all Redis store keys.
:param use_signer: Whether to sign the session id cookie or not. :param use_signer: Whether to sign the session id cookie or not.
:param permanent: Whether to use permanent session or not. :param permanent: Whether to use permanent session or not.
:param kwargs: Quart-session config, used internally. :param kwargs: Quart-session config, used internally.
"""
session_class = RedisSession
def __init__(self, redis, **kwargs):
super(RedisSessionInterface, self).__init__(**kwargs)
self.backend = redis
self.native_json = self._config.get('SESSION_REDIS_JSON', False)
async def create(self, app: Quart) -> None:
"""Creates ``aioredis.Redis`` instance.
.. note::
Creates a single Redis connection, you might prefer
pooling instead (see the `aioredis` documentation
for connection pool examples).
""" """
if self.backend is None:
from redis import asyncio as aioredis
uri = self._config.get('SESSION_URI', 'redis://localhost')
self.backend = await aioredis.from_url(
uri, encoding="utf-8", decode_responses=True
)
if self.native_json:
from .redis_json import check_support
await check_support(self.backend)
async def get(self, key: str, app: Quart = None): session_class = RedisSession
if self.native_json:
from .redis_json import get
return await get(self.backend, key)
return await self.backend.get(key)
async def set(self, key: str, value, expiry: int = None, def __init__(self, redis, **kwargs):
app: Quart = None): super(RedisSessionInterface, self).__init__(**kwargs)
if app and not expiry: self.redis = redis
expiry = total_seconds(app.permanent_session_lifetime)
if self.native_json: async def create(self, app: Quart) -> None:
from .redis_json import set """Creates ``aioredis.Redis`` instance.
return await set(self.backend, key, value, expiry)
return await self.backend.setex( .. note::
name=key, value=value,
time=expiry) Creates a single Redis connection, you might prefer
pooling instead (see ``aioredis.Redis.create_redis_pool``)
"""
if self.redis is None:
import aioredis
self.redis = await aioredis.create_redis("redis://localhost")
async def _backend_get(self, app: Quart, key: str):
return await self.redis.get(key)
async def _backend_set(self, app: Quart, key: str, value):
return await self.redis.setex(
key=key, value=value,
seconds=total_seconds(app.permanent_session_lifetime))
async def _backend_delete(self, app: Quart, key: str):
return await self.redis.delete(key)
async def delete(self, key: str, app: Quart = None):
return await self.backend.delete(key)
class RedisTrioSessionInterface(SessionInterface): class RedisTrioSessionInterface(SessionInterface):
"""Uses the Redis+Trio key-value store as a session backend. """Uses the Redis+Trio key-value store as a session backend.
:param redis: ``quart_session.redis_trio.RedisTrio`` instance. :param redis: ``quart_session.redis_trio.RedisTrio`` instance.
:param key_prefix: A prefix that is added to all Redis store keys. :param key_prefix: A prefix that is added to all Redis store keys.
:param use_signer: Whether to sign the session id cookie or not. :param use_signer: Whether to sign the session id cookie or not.
:param permanent: Whether to use permanent session or not. :param permanent: Whether to use permanent session or not.
:param kwargs: Quart-session config, used internally. :param kwargs: Quart-session config, used internally.
"""
session_class = RedisSession
def __init__(self, redis, **kwargs):
super(RedisTrioSessionInterface, self).__init__(**kwargs)
if self._config.get('SESSION_REDIS_JSON', False):
raise ValueError('SESSION_REDIS_JSON requires the asyncio Redis backend')
self.backend = redis
async def create(self, app: Quart) -> None:
"""Creates ``aioredis.Redis`` instance.
.. note::
Creates a single Redis connection. Pooling not
supported yet for ``RedisTrio``.
""" """
if self.backend is None:
from quart_session.redis_trio import RedisTrio
self.backend = RedisTrio()
await self.backend.connect()
async def get(self, key: str, app: Quart = None): session_class = RedisSession
data = await self.backend.get(key)
if data:
return data.decode()
async def set(self, key: str, value, expiry: int = None, def __init__(self, redis, **kwargs):
app: Quart = None): super(RedisTrioSessionInterface, self).__init__(**kwargs)
if app and not expiry: self.redis_trio = redis
expiry = total_seconds(app.permanent_session_lifetime)
return await self.backend.setex( async def create(self, app: Quart) -> None:
key=key, value=value, """Creates ``aioredis.Redis`` instance.
seconds=expiry)
.. note::
Creates a single Redis connection. Pooling not
supported yet for ``RedisTrio``.
"""
if self.redis_trio is None:
from quart_session.redis_trio import RedisTrio
self.redis_trio = RedisTrio()
await self.redis_trio.connect()
async def _backend_get(self, app: Quart, key: str):
data = await self.redis_trio.get(key)
if data:
return data.decode()
async def _backend_set(self, app: Quart, key: str, value):
return await self.redis_trio.setex(
key=key, value=value,
seconds=total_seconds(app.permanent_session_lifetime))
async def _backend_delete(self, app: Quart, key: str):
return await self.redis_trio.delete(key)
async def delete(self, key: str, app: Quart = None):
return await self.backend.delete(key)
class MemcachedSessionInterface(SessionInterface): class MemcachedSessionInterface(SessionInterface):
"""Uses the Memcached key-value store as a session backend. """Uses the Memcached key-value store as a session backend.
:param client: ``aiomcache.Client`` instance. :param client: ``aiomcache.Client`` instance.
:param key_prefix: A prefix that is added to all Redis store keys. :param key_prefix: A prefix that is added to all Redis store keys.
:param use_signer: Whether to sign the session id cookie or not. :param use_signer: Whether to sign the session id cookie or not.
:param permanent: Whether to use permanent session or not. :param permanent: Whether to use permanent session or not.
:param kwargs: Quart-session config, used internally. :param kwargs: Quart-session config, used internally.
"""
session_class = MemcachedSession
def __init__(
self, memcached, key_prefix: str, use_signer: bool = False,
permanent: bool = True, **kwargs):
super(MemcachedSessionInterface, self).__init__(
key_prefix=key_prefix, use_signer=use_signer,
permanent=permanent, **kwargs)
self.backend = memcached
async def create(self, app: Quart) -> None:
if self.backend is None:
import aiomcache
# self.backend = aiomcache.Client("127.0.0.1", 11211)
self.backend = aiomcache.Client(self._config.get('SESSION_MEMCACHED_HOST', '127.0.0.1'),
self._config.get('SESSION_MEMCACHED_PORT', 11211))
def _get_memcache_timeout(self, timeout):
""" """
Memcached deals with long (> 30 days) timeouts in a special
way. Call this function to obtain a safe value for your timeout.
"""
if timeout > 2592000: # 60*60*24*30, 30 days
# See http://code.google.com/p/memcached/wiki/FAQ
# "You can set expire times up to 30 days in the future. After that
# memcached interprets it as a date, and will expire the item after
# said date. This is a simple (but obscure) mechanic."
#
# This means that we have to switch to absolute timestamps.
timeout += int(time.time())
return timeout
async def get(self, key: str, app: Quart = None): session_class = MemcachedSession
key = key.encode()
return await self.backend.get(key)
async def set(self, key: str, value, expiry: int = None, def __init__(
app: Quart = None): self, memcached, key_prefix: str, use_signer: bool = False,
if app and not expiry: permanent: bool = True, **kwargs):
expiry = self._get_memcache_timeout( super(MemcachedSessionInterface, self).__init__(
total_seconds(app.permanent_session_lifetime)) key_prefix=key_prefix, use_signer=use_signer,
permanent=permanent, **kwargs)
self.memcached = memcached
key = key.encode() @asyncio.coroutine
value = value.encode() def create(self, app: Quart) -> None:
return await self.backend.set(key=key, value=value, if self.memcached is None:
exptime=expiry) import aiomcache
loop = asyncio.get_running_loop()
self.memcached = aiomcache.Client("127.0.0.1", 11211, loop=loop)
async def delete(self, key: str, app: Quart = None): def _get_memcache_timeout(self, timeout):
key = key.encode() """
return await self.backend.delete(key) Memcached deals with long (> 30 days) timeouts in a special
way. Call this function to obtain a safe value for your timeout.
"""
if timeout > 2592000: # 60*60*24*30, 30 days
# See http://code.google.com/p/memcached/wiki/FAQ
# "You can set expire times up to 30 days in the future. After that
# memcached interprets it as a date, and will expire the item after
# said date. This is a simple (but obscure) mechanic."
#
# This means that we have to switch to absolute timestamps.
timeout += int(time.time())
return timeout
def _convert_key_to_uuid(func): async def _backend_get(self, app: Quart, key: str):
""" key = key.encode()
convert the session UUID to a UUID object for mongodb return await self.memcached.get(key)
example: async def _backend_set(self, app: Quart, key: str, value):
"session:b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8" -> key = key.encode()
UUID("b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8") value = value.encode()
expiry = self._get_memcache_timeout(total_seconds(
app.permanent_session_lifetime))
return await self.memcached.set(key=key, value=value,
exptime=expiry)
""" async def _backend_delete(self, app: Quart, key: str):
key = key.encode()
return await self.memcached.delete(key)
@functools.wraps(func)
async def wrapper(*args, **kwargs):
if 'key' in kwargs:
key = kwargs['key']
try:
if key.startswith('session:'):
_, _uuid = tuple(key.split(':'))
kwargs['key'] = UUID(_uuid)
except Exception as e:
current_app.logger.warning(
f"session could not be converted to a uuid object: {key}"
)
return await func(*args, **kwargs)
return wrapper
class MongoDBSessionInterface(SessionInterface):
# mongodb does not a serializer as many object types are properly handled by the connector
serializer = None
session_class = MongoDBSession
def __init__(self, mongodb_uri, collection, client_kwargs={}, set_callback=None, **kwargs):
from motor.motor_asyncio import AsyncIOMotorClient
super().__init__(**kwargs)
self.mongodb_uri = mongodb_uri
self.client_kwargs = client_kwargs
self.set_callback = set_callback
self._collection = collection
self._client = AsyncIOMotorClient(self.mongodb_uri, uuidRepresentation='standard', **self.client_kwargs)
self._database = self._client.get_database()
async def create(self, app: Quart) -> None:
pass
@_convert_key_to_uuid
async def get(self, key, app):
value = await self.collection.find_one({'_id': key}, {'data': True})
if value:
return value.get('data', {})
else:
return None
@_convert_key_to_uuid
async def set(self, key, value, expiry=None, app=None):
doc = {
'data': value,
}
# allows the document to be modified prior upsert
if callable(self.set_callback):
self.set_callback(doc)
await self.collection.update_one({
'_id': key
}, {
'$set': doc
},
upsert=True
)
@property
def collection(self):
return self._database.get_collection(self._collection)
class NullSessionInterface(SessionInterface): class NullSessionInterface(SessionInterface):
"""This class does absolutely nothing""" """Used to open a :class:`quart.sessions.NullSession` instance.
session_class = NullSession """
def __init__( def open_session(self, app: Quart, request: BaseRequestWebsocket):
self, key_prefix: str, use_signer: bool = False, return None
permanent: bool = True, **kwargs):
super(NullSessionInterface, self).__init__(
key_prefix=key_prefix, use_signer=use_signer,
permanent=permanent, **kwargs)
self.backend = None
async def create(self, app: Quart) -> None:
pass
async def get(self, key: str, app: Quart = None) -> None:
pass
async def set(self, key: str, value, expiry: int = None,
app: Quart = None) -> None:
pass
+2 -2
View File
@@ -1,2 +1,2 @@
[bdist_wheel] [wheel]
universal = 0 universal = 1
+51 -1
View File
@@ -1,3 +1,53 @@
"""
Quart-Session
-------------
Quart-Session is an extension for Quart that adds support for
Server-side Session to your application.
Links
`````
* `Github
<https://github.com/xmrdsc/quart-session>`_
"""
from setuptools import setup from setuptools import setup
setup() with open('README.md') as f:
long_description = f.read()
INSTALL_REQUIRES = [
"Quart>=0.10.0"
]
setup(
name='Quart-Session',
version='0.0.1',
url='https://github.com/xmrdsc/quart-session',
license='BSD',
author='dsc',
author_email='dsc@xmr.pm',
description='Adds server-side session support to your Quart application',
long_description=long_description,
long_description_content_type='text/markdown',
packages=['quart_session'],
zip_safe=False,
include_package_data=True,
platforms='any',
install_requires=INSTALL_REQUIRES,
tests_require=INSTALL_REQUIRES + ["asynctest", "hypothesis", "pytest", "pytest-asyncio"],
extras_require={"dotenv": ["python-dotenv"]},
classifiers=[
'Environment :: Web Environment',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
'Topic :: Software Development :: Libraries :: Python Modules'
]
)
-45
View File
@@ -1,45 +0,0 @@
from typing import Optional
import pytest
from quart import Quart, session
from quart_session import Session
@pytest.fixture
def app_factory():
"""Builds a Quart app backed by an in-memory session store."""
def _factory(**config) -> Quart:
app = Quart(__name__)
app.secret_key = "test-secret-key"
app.config["SESSION_TYPE"] = "null"
app.config.update(config)
Session(app)
# 'null' interface is a no-op store, give it a dict so that
# open_session() can actually find data again.
store: dict = {}
interface = app.session_interface
async def _get(key: str, app: Optional[Quart] = None):
return store.get(key)
async def _set(key: str, value, expiry: Optional[int] = None,
app: Optional[Quart] = None):
store[key] = value
async def _delete(key: str, app: Optional[Quart] = None):
store.pop(key, None)
interface.get = _get
interface.set = _set
interface.delete = _delete
@app.route("/")
async def index():
session["hits"] = session.get("hits", 0) + 1
return {"hits": session["hits"], "sid": session.sid}
return app
return _factory
-150
View File
@@ -1,150 +0,0 @@
"""Native JSON command contracts; no external Redis server required."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from quart import Quart, session
pytest.importorskip("redis")
from redis.exceptions import ResponseError
from quart_session import Session
from quart_session.sessions import RedisSessionInterface, RedisTrioSessionInterface
def make_interface(backend, enabled=True):
return RedisSessionInterface(
redis=backend, key_prefix="session:", SESSION_REDIS_JSON=enabled,
SESSION_REVERSE_PROXY=False, SESSION_PROTECTION=False,
SESSION_STATIC_FILE=False,
)
def pipeline_backend():
backend = MagicMock()
pipe = MagicMock()
backend.pipeline.return_value.__aenter__ = AsyncMock(return_value=pipe)
backend.pipeline.return_value.__aexit__ = AsyncMock(return_value=False)
pipe.execute = AsyncMock(return_value=[1, "OK", True])
return backend, pipe
@pytest.mark.asyncio
@pytest.mark.parametrize("value", ['{"hits":1}', b'{"hits":1}', None])
async def test_read_legacy_string_and_missing_key(value):
backend = AsyncMock()
backend.get.return_value = value
interface = make_interface(backend)
assert await interface.get("session:test") == value
backend.execute_command.assert_not_called()
@pytest.mark.asyncio
async def test_read_native_document():
backend = AsyncMock()
backend.get.side_effect = ResponseError("WRONGTYPE wrong kind of value")
backend.execute_command.return_value = '{"hits":2}'
assert await make_interface(backend).get("session:test") == '{"hits":2}'
backend.execute_command.assert_awaited_once_with("JSON.GET", "session:test", ".")
@pytest.mark.asyncio
async def test_read_does_not_hide_server_errors():
backend = AsyncMock()
backend.get.side_effect = ResponseError("NOPERM permission denied")
with pytest.raises(ResponseError, match="NOPERM"):
await make_interface(backend).get("session:test")
backend.execute_command.assert_not_called()
@pytest.mark.asyncio
async def test_write_json_and_expiry_in_transaction():
backend, pipe = pipeline_backend()
value = '{"_permanent":true,"user":{"name":"Example User"}}'
assert await make_interface(backend).set("session:test", value, expiry=60) == "OK"
backend.pipeline.assert_called_once_with(transaction=True)
assert [(call[0], call.args) for call in pipe.mock_calls] == [
("delete", ("session:test",)),
("execute_command", ("JSON.SET", "session:test", "$", value)),
("expire", ("session:test", 60)),
("execute", ()),
]
@pytest.mark.asyncio
async def test_invalid_json_leaves_existing_key_untouched():
backend, _ = pipeline_backend()
for value in ("not json", '"string"', "[]"):
with pytest.raises(ValueError):
await make_interface(backend).set("session:test", value, expiry=60)
backend.pipeline.assert_not_called()
@pytest.mark.asyncio
async def test_default_still_uses_string_storage():
backend = AsyncMock()
interface = make_interface(backend, enabled=False)
await interface.set("session:test", "value", expiry=60)
backend.setex.assert_awaited_once_with(name="session:test", value="value", time=60)
@pytest.mark.asyncio
@pytest.mark.parametrize("commands", [
[[b"json.get"], [b"json.set"]],
{"json.get": {"name": "json.get"}, "json.set": {"name": "json.set"}},
])
async def test_startup_checks_server_support(commands):
backend = AsyncMock()
backend.execute_command.return_value = commands
await make_interface(backend).create(Quart(__name__))
backend.execute_command.assert_awaited_once_with(
"COMMAND", "INFO", "JSON.GET", "JSON.SET"
)
@pytest.mark.asyncio
async def test_startup_rejects_missing_json_support():
backend = AsyncMock()
backend.execute_command.return_value = [None, None]
with pytest.raises(RuntimeError, match="JSON.GET and JSON.SET"):
await make_interface(backend).create(Quart(__name__))
def test_trio_rejects_json_mode():
with pytest.raises(ValueError, match="asyncio"):
RedisTrioSessionInterface(
redis=MagicMock(), key_prefix="session:", SESSION_REDIS_JSON=True,
)
@pytest.mark.asyncio
async def test_quart_session_roundtrip_and_legacy_migration():
backend, pipe = pipeline_backend()
backend.get = AsyncMock(return_value=None)
backend.execute_command = AsyncMock()
backend.delete = AsyncMock()
app = Quart(__name__)
app.config.update(SESSION_TYPE="redis", SESSION_REDIS=backend, SESSION_REDIS_JSON=True)
Session(app)
when = datetime(2026, 1, 1, tzinfo=timezone.utc)
@app.route("/")
async def index():
if "when" in session:
assert session["when"] == when
assert session["pair"] == (1, 2)
session["when"] = when
session["pair"] = (1, 2)
session["hits"] = session.get("hits", 0) + 1
return {"hits": session["hits"], "sid": session.sid}
client = app.test_client()
first = await client.get("/")
assert first.status_code == 200
key = "session:" + (await first.get_json())["sid"]
document = pipe.execute_command.call_args.args[3]
pipe.expire.assert_called_with(key, int(app.permanent_session_lifetime.total_seconds()))
# A pre-existing string remains readable and is rewritten using JSON.SET.
backend.get.return_value = document
second = await client.get("/")
assert (await second.get_json())["hits"] == 2
assert pipe.execute_command.call_args.args[:3] == ("JSON.SET", key, "$")
# A native document follows the same serializer path on the next request.
backend.get.side_effect = ResponseError("WRONGTYPE wrong kind of value")
backend.execute_command.return_value = pipe.execute_command.call_args.args[3]
third = await client.get("/")
assert (await third.get_json())["hits"] == 3
assert (await third.get_json())["sid"] == (await first.get_json())["sid"]
await app.session_interface.delete(key)
backend.delete.assert_awaited_once_with(key)
-60
View File
@@ -1,60 +0,0 @@
import pytest
from itsdangerous import Signer
def _cookie_value(response, name="session"):
header = response.headers.get("Set-Cookie")
assert header is not None, "no Set-Cookie header was sent"
assert header.startswith(f"{name}=")
return header[len(name) + 1:].split(";", 1)[0]
@pytest.mark.asyncio
async def test_unsigned_cookie_is_plain_sid(app_factory):
"""Regression: str sid used to blow up with AttributeError on .decode()."""
app = app_factory(SESSION_USE_SIGNER=False)
client = app.test_client()
response = await client.get("/")
assert response.status_code == 200
data = await response.get_json()
assert data["hits"] == 1
assert _cookie_value(response) == data["sid"]
@pytest.mark.asyncio
async def test_signed_cookie_is_signed_sid(app_factory):
app = app_factory(SESSION_USE_SIGNER=True)
client = app.test_client()
response = await client.get("/")
assert response.status_code == 200
data = await response.get_json()
cookie = _cookie_value(response)
assert cookie != data["sid"]
signer = Signer(app.secret_key, salt="quart-session",
key_derivation="hmac")
assert signer.unsign(cookie).decode() == data["sid"]
@pytest.mark.asyncio
@pytest.mark.parametrize("use_signer", [False, True])
async def test_session_survives_next_request(app_factory, use_signer):
app = app_factory(SESSION_USE_SIGNER=use_signer)
client = app.test_client()
first = await client.get("/")
second = await client.get("/")
assert (await first.get_json())["hits"] == 1
assert (await second.get_json())["hits"] == 2
@pytest.mark.asyncio
async def test_signer_without_secret_key_sets_no_cookie(app_factory):
app = app_factory(SESSION_USE_SIGNER=True)
app.secret_key = None
client = app.test_client()
response = await client.get("/")
assert response.status_code == 200
assert response.headers.get("Set-Cookie") is None
Generated
-524
View File
@@ -1,524 +0,0 @@
version = 1
revision = 5
requires-python = ">=3.15"
[[package]]
name = "aiofiles"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "build"
version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "os_name == 'nt'" },
{ name = "packaging" },
{ name = "pyproject-hooks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz", hash = "sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53", size = 113427, upload-time = "2026-09-10T07:56:00.417Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl", hash = "sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7", size = 31179, upload-time = "2026-09-10T07:55:59.148Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dnspython"
version = "2.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
]
[[package]]
name = "hpack"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
name = "hypercorn"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "h2" },
{ name = "priority" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" },
{ url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" },
{ url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" },
{ url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" },
{ url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" },
{ url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" },
{ url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" },
{ url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" },
{ url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" },
{ url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" },
{ url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" },
{ url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" },
{ url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" },
{ url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" },
{ url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" },
{ url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" },
{ url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" },
{ url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" },
{ url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" },
{ url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" },
{ url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" },
{ url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" },
{ url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" },
{ url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" },
{ url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" },
]
[[package]]
name = "motor"
version = "3.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pymongo" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/ae/96b88362d6a84cb372f7977750ac2a8aed7b2053eed260615df08d5c84f4/motor-3.7.1.tar.gz", hash = "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526", size = 280997, upload-time = "2025-05-14T18:56:33.653Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" },
]
[[package]]
name = "outcome"
version = "1.3.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "priority"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" },
]
[[package]]
name = "pycparser"
version = "3.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pymongo"
version = "4.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/8b/a9d214044153cb7d9141229d3e1b171cdf4f460fa07cade9354c4ce2f84d/pymongo-4.19.0.tar.gz", hash = "sha256:3c510dd3c5d9b392d3b33bb5d2a594758acfe8f026fca654253f947ce0af9d40", size = 2689381, upload-time = "2026-10-14T19:48:19.629Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/6c/af80cf714a91b41441e9ad0aeac1af2000d902dfef7bac31388ba05bbfe7/pymongo-4.19.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cc81d7ceeb7766254bce7ad7644dddb44241fb57555cd7c71de305b6903493b8", size = 826913, upload-time = "2026-10-14T19:47:35.317Z" },
{ url = "https://files.pythonhosted.org/packages/95/14/2ed9ee6c83fd05a36d310100562b599ea987d2339c57955b1afba80d07ec/pymongo-4.19.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b602baef46ec5cd876fdf45dfdf864a58f5a507129393b93b8248249008f9a70", size = 827513, upload-time = "2026-10-14T19:47:37.463Z" },
{ url = "https://files.pythonhosted.org/packages/78/78/cd65885104e7b37f8cb7dd7e33d0b2c2415270afc2644ed643b52f526214/pymongo-4.19.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:179bc536b73fc76ae3d227114123ffc804f002fb45ddd996a81b233e806a0d2d", size = 1052003, upload-time = "2026-10-14T19:47:39.539Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ed/99fc74ed08dded2351818bf374303ddc400bd2e8b5ab297dac352aa0df56/pymongo-4.19.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bd5e3ecd44d94b4eeef51f7e20a513206f2fceeab9534e9299c31133cc2e42", size = 1061762, upload-time = "2026-10-14T19:47:41.601Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8b/ded0ef32a2c4032cbec796f29b7b6067e76ac27714fbcfe06ce9a969b415/pymongo-4.19.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a38cfd2d81daef820a099c28065c6dc2ec9254ae80fefcf7981ea27e5381159", size = 1084314, upload-time = "2026-10-14T19:47:43.874Z" },
{ url = "https://files.pythonhosted.org/packages/52/64/82099393a7178c80fe1b16cc5dca94f388dec3df7a3f059a7b831bbf10dd/pymongo-4.19.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:567e509e1e01c956bfd5e60805b7d582aae45eeba34e9690d0da6f09560afb4f", size = 1075560, upload-time = "2026-10-14T19:47:45.904Z" },
{ url = "https://files.pythonhosted.org/packages/09/d2/1eab760f5dc3d09fbc8fec7ad2474def3c8d2efbeb8550bff12fed61f863/pymongo-4.19.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c3a47a6b325ac605352e9825ef658e6cca4f612e3a09838a564859f7d5435ea", size = 1060541, upload-time = "2026-10-14T19:47:47.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/7d/426c1b661e8b4bd78671ea063ee66005faff0dfe6731ebdce0fb0000c339/pymongo-4.19.0-cp315-cp315-win32.whl", hash = "sha256:5d684e289cdb687f1508b15a44d3c0268f974c92ba129f658c1ef1fd196854e7", size = 823985, upload-time = "2026-10-14T19:47:50.253Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e9/f2ece0253d82d34fad0a316ffec848ac4e85357cae849cd5ea29def72ae4/pymongo-4.19.0-cp315-cp315-win_amd64.whl", hash = "sha256:546350d196b01b7feff7f8e6d140b6d4ab47486d5ae70dab858605cdfc2ffe1d", size = 831173, upload-time = "2026-10-14T19:47:52.418Z" },
{ url = "https://files.pythonhosted.org/packages/a2/e0/be46ba1676cd04f831a9d4f6f8dbe0d3f816034788b8e3157762139f7aa8/pymongo-4.19.0-cp315-cp315-win_arm64.whl", hash = "sha256:d29ea47eebbeec81b67809fbb3440ffc53628d28f5b9f21624eed0038d9fddaa", size = 826603, upload-time = "2026-10-14T19:47:54.538Z" },
{ url = "https://files.pythonhosted.org/packages/ab/20/3e04d21eab4844372ef141d5cc4f5e03d4fb9ebda057dd5e5ef1db562433/pymongo-4.19.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:b7e8b5b546e31ac63255650b0bf764383885a6c657b3269e83b9e1e5de3ed129", size = 829820, upload-time = "2026-10-14T19:47:56.428Z" },
{ url = "https://files.pythonhosted.org/packages/44/9c/dbad3291c3614a884285d10e2cc123567386d682bf8a08caf5e0a630bf3e/pymongo-4.19.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f21109534f5555cf77689ad323a21fbc07e8a397b34f157938a347725d83b7b5", size = 830313, upload-time = "2026-10-14T19:47:58.457Z" },
{ url = "https://files.pythonhosted.org/packages/68/2d/17e783859c89e749fe63803a08ab5e85ca0ee8416f0cbe84d5fe6efa2981/pymongo-4.19.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3af5ab5a9e490580d3f40660665f0f4d579a324e25acee6372e1508e4b7c7b7a", size = 1117096, upload-time = "2026-10-14T19:48:00.917Z" },
{ url = "https://files.pythonhosted.org/packages/34/cf/0b23e363eb5856ecfdf3b7edbdfea7f964da664eb78e507bb8375820c7e5/pymongo-4.19.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb9d9bff4f666405cd9d7a17b6127294394847dce60ca38d8ba45f4879ada6c9", size = 1134854, upload-time = "2026-10-14T19:48:03.05Z" },
{ url = "https://files.pythonhosted.org/packages/23/b8/60758f35a90729d77fdfd36eeff5ddf191d9f198528074884d816865d942/pymongo-4.19.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be75840640e98ea4b5f150bceda8a55f1085e395732e21da028195da30ae79b5", size = 1154121, upload-time = "2026-10-14T19:48:05.638Z" },
{ url = "https://files.pythonhosted.org/packages/5a/b0/e2b56cf154bf1dff7deca641de160215f9163253609a8beb780dc35f007b/pymongo-4.19.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa39c6ddaf987a48ef073ff7fc225b84282079a46fbabaea9c5fcb6f89476e44", size = 1143786, upload-time = "2026-10-14T19:48:07.734Z" },
{ url = "https://files.pythonhosted.org/packages/d1/88/39b61ede07785568d47229a01e7e82fc3903f5cac55ad377e0e64a0d324a/pymongo-4.19.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b92aa4cc4b0bf67a18e3c73062ef70e00ca6921c742aa4d0f4770a493193c661", size = 1126770, upload-time = "2026-10-14T19:48:09.891Z" },
{ url = "https://files.pythonhosted.org/packages/40/f2/391d41d24384545b2a6ed09694b2444f765a6e20932c75ed4eb507c9ef36/pymongo-4.19.0-cp315-cp315t-win32.whl", hash = "sha256:eececca812e8f5b3c12ad33dc90201ac20f5f193da446f7719f4321a0841387b", size = 826638, upload-time = "2026-10-14T19:48:11.962Z" },
{ url = "https://files.pythonhosted.org/packages/d1/48/96b923a2d29456896c7f11f8e6104339818112f5a8621f42ba51f131a510/pymongo-4.19.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f17b100fdc16b65c12997ec4fcc78eecc0a6395254c7ec92a4596e855ff1f33a", size = 835209, upload-time = "2026-10-14T19:48:14.063Z" },
{ url = "https://files.pythonhosted.org/packages/46/6b/2ede9f64d96393e8111d250620f5340d64e62f4617322a43800516027ce9/pymongo-4.19.0-cp315-cp315t-win_arm64.whl", hash = "sha256:bfcb5f8912edd9714a52564ad41c0dcd72e5408d1d3d67b41f6145df4a516318", size = 827452, upload-time = "2026-10-14T19:48:17.534Z" },
]
[[package]]
name = "pyproject-hooks"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/5d/f2ddeef4a855a102aaae5e97826a0260007522ab504421b75addfdb1517c/pyproject_hooks-1.3.3.tar.gz", hash = "sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43", size = 21013, upload-time = "2026-09-16T08:58:03.999Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/11/044d1ae1b4ec0d7af88ee5bc91e081be1022533032b906a7bdabdbb60977/pyproject_hooks-1.3.3-py3-none-any.whl", hash = "sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b", size = 10724, upload-time = "2026-09-16T08:58:02.96Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "quart"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "blinker" },
{ name = "click" },
{ name = "flask" },
{ name = "hypercorn" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/81/34396f67e09e7a0609261f1ef0f43b26f5d67e8f2dc4d34b4953061560f2/quart-0.23.1.tar.gz", hash = "sha256:1ca848415910bd2eb75e9d9b452388f892a37be222602a373622e6c633d1efbf", size = 65636, upload-time = "2026-08-29T15:58:35.767Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/c1/26dca56249da1a889ebb946000ab272712476209234f714ad3e8013ee005/quart-0.23.1-py3-none-any.whl", hash = "sha256:78cf3a7249ab09f9e03d78b0b5e2472c4c09ce4615a99c2b1aa9a35261243b66", size = 79388, upload-time = "2026-08-29T15:58:34.147Z" },
]
[[package]]
name = "quart-session"
version = "4.1.0"
source = { editable = "." }
dependencies = [
{ name = "quart" },
]
[package.optional-dependencies]
dotenv = [
{ name = "python-dotenv" },
]
mongodb = [
{ name = "motor" },
]
redis = [
{ name = "redis" },
]
redis-trio = [
{ name = "trio" },
]
[package.dev-dependencies]
dev = [
{ name = "build" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=2.5.1" },
{ name = "python-dotenv", marker = "extra == 'dotenv'" },
{ name = "quart", specifier = ">=0.19.0" },
{ name = "redis", marker = "extra == 'redis'", specifier = ">=4.4.0" },
{ name = "trio", marker = "extra == 'redis-trio'", specifier = ">=0.22" },
]
provides-extras = ["dotenv", "mongodb", "redis", "redis-trio"]
[package.metadata.requires-dev]
dev = [
{ name = "build", specifier = ">=1.3.0" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
]
[[package]]
name = "redis"
version = "8.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "trio"
version = "0.34.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" },
{ name = "idna" },
{ name = "outcome" },
{ name = "sniffio" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/dc/a2d25ed73ad49cfd79bf18d262577c3731c98e382284e28d522f49a0df35/trio-0.34.0.tar.gz", hash = "sha256:63b9485408bdfdde544fced107045a8c0086cdc4bd0ef2f797b9e0dd111b964b", size = 607457, upload-time = "2026-08-11T00:33:42.198Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/1f/555f1364bed52a92a864181962b77f1b15adadeacf23b86105324363e461/trio-0.34.0-py3-none-any.whl", hash = "sha256:6c7c9f49917694dcdcd5f67abd168df5599eca480d61f29854d17a61a75c2f05", size = 511840, upload-time = "2026-08-11T00:33:40.552Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" },
]
[[package]]
name = "wsproto"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
]