Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08ee20ac07 | ||
|
|
6a51c15dbf | ||
|
|
df20bbff18 | ||
|
|
499e46c93d |
@@ -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 +0,0 @@
|
|||||||
3.15
|
|
||||||
@@ -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,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
# Quart-Session
|
# Quart-Session
|
||||||
|
|
||||||
 [](https://pypi.org/project/Quart-Session/) 
|
 [](https://pypi.org/project/Quart-Session/) 
|
||||||
|
|
||||||
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
|
||||||
@@ -27,19 +30,17 @@ 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",
|
|
||||||
port=6379,
|
|
||||||
password="foobar"
|
|
||||||
)
|
|
||||||
|
|
||||||
app.config['SESSION_REDIS'] = cache
|
app.config['SESSION_REDIS'] = cache
|
||||||
Session(app)
|
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
|
||||||
@@ -156,39 +114,52 @@ except ImportError:
|
|||||||
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
|
||||||
+8
-3
@@ -5,26 +5,31 @@
|
|||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -6,27 +6,19 @@
|
|||||||
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
|
||||||
@@ -87,12 +79,11 @@ class Session(object):
|
|||||||
config.setdefault('SESSION_PERMANENT', True)
|
config.setdefault('SESSION_PERMANENT', True)
|
||||||
config.setdefault('SESSION_USE_SIGNER', False)
|
config.setdefault('SESSION_USE_SIGNER', False)
|
||||||
config.setdefault('SESSION_KEY_PREFIX', 'session:')
|
config.setdefault('SESSION_KEY_PREFIX', 'session:')
|
||||||
config.setdefault('SESSION_PROTECTION', False)
|
config.setdefault('SESSION_HIJACK_PROTECTION', False)
|
||||||
config.setdefault('SESSION_REVERSE_PROXY', False)
|
config.setdefault('SESSION_HIJACK_REVERSE_PROXY', False)
|
||||||
config.setdefault('SESSION_STATIC_FILE', False)
|
config.setdefault('SESSION_STATIC_FILE', False)
|
||||||
config.setdefault('SESSION_EXPLICIT', False)
|
config.setdefault('SESSION_EXPLICIT', False)
|
||||||
config.setdefault('SESSION_REDIS', None)
|
config.setdefault('SESSION_REDIS', None)
|
||||||
config.setdefault('SESSION_REDIS_JSON', False)
|
|
||||||
config.setdefault('SESSION_MEMCACHED', None)
|
config.setdefault('SESSION_MEMCACHED', None)
|
||||||
config.setdefault('SESSION_FILE_DIR',
|
config.setdefault('SESSION_FILE_DIR',
|
||||||
os.path.join(os.getcwd(), 'quart_session'))
|
os.path.join(os.getcwd(), 'quart_session'))
|
||||||
@@ -100,18 +91,6 @@ class Session(object):
|
|||||||
config.setdefault('SESSION_FILE_MODE', 384)
|
config.setdefault('SESSION_FILE_MODE', 384)
|
||||||
config = {k: v for k, v in config.items() if k.startswith('SESSION_')}
|
config = {k: v for k, v in config.items() if k.startswith('SESSION_')}
|
||||||
|
|
||||||
if isinstance(config.get("SESSION_HIJACK_PROTECTION"), bool):
|
|
||||||
app.logger.warning("Deprecation: `SESSION_HIJACK_PROTECTION` "
|
|
||||||
"has been renamed to `SESSION_PROTECTION`")
|
|
||||||
|
|
||||||
if isinstance(config.get("SESSION_HIJACK_REVERSE_PROXY"), str):
|
|
||||||
app.logger.warning("Deprecation: `SESSION_HIJACK_REVERSE_PROXY` "
|
|
||||||
"has been renamed to `SESSION_REVERSE_PROXY`")
|
|
||||||
|
|
||||||
backend_warning = f"Please specify a session backend. " \
|
|
||||||
f"Available interfaces: redis, redis+trio, " \
|
|
||||||
f"memcached, null. e.g: app.config['SESSION_TYPE'] = 'redis'"
|
|
||||||
|
|
||||||
if config['SESSION_TYPE'] == 'redis':
|
if config['SESSION_TYPE'] == 'redis':
|
||||||
options = {
|
options = {
|
||||||
"redis": config['SESSION_REDIS'],
|
"redis": config['SESSION_REDIS'],
|
||||||
@@ -143,25 +122,7 @@ class Session(object):
|
|||||||
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':
|
|
||||||
session_interface = MongoDBSessionInterface(
|
|
||||||
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:
|
else:
|
||||||
raise NotImplementedError(f"No such session interface "
|
session_interface = NullSessionInterface()
|
||||||
f"\"{config['SESSION_TYPE']}\". {backend_warning}")
|
|
||||||
|
|
||||||
return session_interface
|
return session_interface
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +28,12 @@ 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:
|
||||||
|
|||||||
@@ -11,19 +11,23 @@
|
|||||||
: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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ escapes = {
|
|||||||
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.
|
||||||
|
|
||||||
@@ -46,9 +47,11 @@ def serialize(x):
|
|||||||
else:
|
else:
|
||||||
return serialize(str(x))
|
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:
|
||||||
|
|||||||
+102
-213
@@ -6,41 +6,37 @@
|
|||||||
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):
|
|
||||||
current_app.logger.warning("Deprecation: `dirty()` has "
|
|
||||||
"been made obsolete. Will be "
|
|
||||||
"removed soon^tm.")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def addr(self) -> str:
|
def addr(self) -> str:
|
||||||
@@ -50,18 +46,41 @@ class ServerSideSession(SecureCookieSession):
|
|||||||
def addr(self, value: str) -> None:
|
def addr(self, value: str) -> None:
|
||||||
self['_addr'] = value # 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
|
||||||
|
|
||||||
|
|
||||||
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"""
|
||||||
|
|
||||||
@@ -85,17 +104,9 @@ class SessionInterface(QuartSessionInterface):
|
|||||||
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
|
|
||||||
# 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}
|
options = {"sid": sid, "permanent": self.permanent, "addr": addr}
|
||||||
|
|
||||||
if not sid:
|
if not sid:
|
||||||
@@ -114,27 +125,27 @@ class SessionInterface(QuartSessionInterface):
|
|||||||
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:
|
|
||||||
data = val
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
data = self.serializer.loads(val)
|
data = self.serializer.loads(val)
|
||||||
except:
|
except:
|
||||||
app.logger.warning(f"Failed to deserialize session "
|
app.logger.warning(f"Failed to deserialize session "
|
||||||
f"data for sid: {sid}. Generating new sid.")
|
f"data for sid: {sid}. Generating new sid.")
|
||||||
app.logger.debug(f"data: {val}")
|
|
||||||
options['sid'] = self._generate_sid()
|
options['sid'] = self._generate_sid()
|
||||||
return self.session_class(**options)
|
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)
|
||||||
|
else:
|
||||||
|
addr = request.remote_addr
|
||||||
|
if data.get('_addr', addr) != addr:
|
||||||
|
await self._backend_delete(app, self.key_prefix + sid)
|
||||||
options['sid'] = self._generate_sid()
|
options['sid'] = self._generate_sid()
|
||||||
return self.session_class(**options)
|
return self.session_class(**options)
|
||||||
|
|
||||||
@@ -147,8 +158,9 @@ class SessionInterface(QuartSessionInterface):
|
|||||||
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 \
|
||||||
|
not session._dirty:
|
||||||
return
|
return
|
||||||
|
|
||||||
# prevent set-cookie on (static) file responses
|
# prevent set-cookie on (static) file responses
|
||||||
@@ -156,52 +168,39 @@ class SessionInterface(QuartSessionInterface):
|
|||||||
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
|
||||||
|
|
||||||
cname = app.config.get('SESSION_COOKIE_NAME', 'session')
|
|
||||||
session_key = self.key_prefix + session.sid
|
session_key = self.key_prefix + session.sid
|
||||||
domain = self.get_cookie_domain(app)
|
domain = self.get_cookie_domain(app)
|
||||||
path = self.get_cookie_path(app)
|
path = self.get_cookie_path(app)
|
||||||
if not session:
|
if not session:
|
||||||
if session.modified:
|
if session.modified:
|
||||||
await self.delete(key=session_key, app=app)
|
await self._backend_delete(app=app, key=session_key)
|
||||||
response.delete_cookie(cname,
|
response.delete_cookie(app.session_cookie_name,
|
||||||
domain=domain, path=path)
|
domain=domain, path=path)
|
||||||
return
|
return
|
||||||
httponly = self.get_cookie_httponly(app)
|
httponly = self.get_cookie_httponly(app)
|
||||||
samesite = self.get_cookie_samesite(app)
|
|
||||||
secure = self.get_cookie_secure(app)
|
secure = self.get_cookie_secure(app)
|
||||||
expires = self.get_expiration_time(app, session)
|
expires = self.get_expiration_time(app, session)
|
||||||
|
|
||||||
if self.serializer is None:
|
|
||||||
val = dict(session)
|
|
||||||
else:
|
|
||||||
val = self.serializer.dumps(dict(session))
|
val = self.serializer.dumps(dict(session))
|
||||||
|
await self._backend_set(app=app, key=session_key, value=val)
|
||||||
await self.set(key=session_key, value=val, app=app)
|
|
||||||
if self.use_signer:
|
if self.use_signer:
|
||||||
signer = self._get_signer(app)
|
session_id = self._get_signer(app).sign(want_bytes(session.sid))
|
||||||
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:
|
else:
|
||||||
session_id = session.sid
|
session_id = session.sid
|
||||||
response.set_cookie(cname, session_id,
|
response.set_cookie(app.session_cookie_name, session_id,
|
||||||
expires=expires, httponly=httponly,
|
expires=expires, httponly=httponly,
|
||||||
domain=domain, path=path, secure=secure, samesite=samesite)
|
domain=domain, path=path, secure=secure)
|
||||||
|
|
||||||
async def create(self, app: Quart):
|
async def create(self, app: Quart):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
async def get(self, key: str, app: Quart = None):
|
async def _backend_get(self, app: Quart, key: str):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
async def set(self, key: str, value, expiry: int = None,
|
async def _backend_set(self, app: Quart, key: str, value):
|
||||||
app: Quart = None):
|
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
async def delete(self, key: str, app: Quart = None):
|
async def _backend_delete(self, app: Quart, key: str):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def _generate_sid(self) -> str:
|
def _generate_sid(self) -> str:
|
||||||
@@ -213,6 +212,7 @@ class SessionInterface(QuartSessionInterface):
|
|||||||
return Signer(app.secret_key, salt='quart-session',
|
return Signer(app.secret_key, salt='quart-session',
|
||||||
key_derivation='hmac')
|
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.
|
||||||
|
|
||||||
@@ -227,8 +227,7 @@ class RedisSessionInterface(SessionInterface):
|
|||||||
|
|
||||||
def __init__(self, redis, **kwargs):
|
def __init__(self, redis, **kwargs):
|
||||||
super(RedisSessionInterface, self).__init__(**kwargs)
|
super(RedisSessionInterface, self).__init__(**kwargs)
|
||||||
self.backend = redis
|
self.redis = redis
|
||||||
self.native_json = self._config.get('SESSION_REDIS_JSON', False)
|
|
||||||
|
|
||||||
async def create(self, app: Quart) -> None:
|
async def create(self, app: Quart) -> None:
|
||||||
"""Creates ``aioredis.Redis`` instance.
|
"""Creates ``aioredis.Redis`` instance.
|
||||||
@@ -236,38 +235,23 @@ class RedisSessionInterface(SessionInterface):
|
|||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Creates a single Redis connection, you might prefer
|
Creates a single Redis connection, you might prefer
|
||||||
pooling instead (see the `aioredis` documentation
|
pooling instead (see ``aioredis.Redis.create_redis_pool``)
|
||||||
for connection pool examples).
|
|
||||||
"""
|
"""
|
||||||
if self.backend is None:
|
if self.redis is None:
|
||||||
from redis import asyncio as aioredis
|
import aioredis
|
||||||
uri = self._config.get('SESSION_URI', 'redis://localhost')
|
self.redis = await aioredis.create_redis("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):
|
async def _backend_get(self, app: Quart, key: str):
|
||||||
if self.native_json:
|
return await self.redis.get(key)
|
||||||
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,
|
async def _backend_set(self, app: Quart, key: str, value):
|
||||||
app: Quart = None):
|
return await self.redis.setex(
|
||||||
if app and not expiry:
|
key=key, value=value,
|
||||||
expiry = total_seconds(app.permanent_session_lifetime)
|
seconds=total_seconds(app.permanent_session_lifetime))
|
||||||
if self.native_json:
|
|
||||||
from .redis_json import set
|
async def _backend_delete(self, app: Quart, key: str):
|
||||||
return await set(self.backend, key, value, expiry)
|
return await self.redis.delete(key)
|
||||||
return await self.backend.setex(
|
|
||||||
name=key, value=value,
|
|
||||||
time=expiry)
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -283,9 +267,7 @@ class RedisTrioSessionInterface(SessionInterface):
|
|||||||
|
|
||||||
def __init__(self, redis, **kwargs):
|
def __init__(self, redis, **kwargs):
|
||||||
super(RedisTrioSessionInterface, self).__init__(**kwargs)
|
super(RedisTrioSessionInterface, self).__init__(**kwargs)
|
||||||
if self._config.get('SESSION_REDIS_JSON', False):
|
self.redis_trio = redis
|
||||||
raise ValueError('SESSION_REDIS_JSON requires the asyncio Redis backend')
|
|
||||||
self.backend = redis
|
|
||||||
|
|
||||||
async def create(self, app: Quart) -> None:
|
async def create(self, app: Quart) -> None:
|
||||||
"""Creates ``aioredis.Redis`` instance.
|
"""Creates ``aioredis.Redis`` instance.
|
||||||
@@ -295,26 +277,24 @@ class RedisTrioSessionInterface(SessionInterface):
|
|||||||
Creates a single Redis connection. Pooling not
|
Creates a single Redis connection. Pooling not
|
||||||
supported yet for ``RedisTrio``.
|
supported yet for ``RedisTrio``.
|
||||||
"""
|
"""
|
||||||
if self.backend is None:
|
if self.redis_trio is None:
|
||||||
from quart_session.redis_trio import RedisTrio
|
from quart_session.redis_trio import RedisTrio
|
||||||
self.backend = RedisTrio()
|
self.redis_trio = RedisTrio()
|
||||||
await self.backend.connect()
|
await self.redis_trio.connect()
|
||||||
|
|
||||||
async def get(self, key: str, app: Quart = None):
|
async def _backend_get(self, app: Quart, key: str):
|
||||||
data = await self.backend.get(key)
|
data = await self.redis_trio.get(key)
|
||||||
if data:
|
if data:
|
||||||
return data.decode()
|
return data.decode()
|
||||||
|
|
||||||
async def set(self, key: str, value, expiry: int = None,
|
async def _backend_set(self, app: Quart, key: str, value):
|
||||||
app: Quart = None):
|
return await self.redis_trio.setex(
|
||||||
if app and not expiry:
|
|
||||||
expiry = total_seconds(app.permanent_session_lifetime)
|
|
||||||
return await self.backend.setex(
|
|
||||||
key=key, value=value,
|
key=key, value=value,
|
||||||
seconds=expiry)
|
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.
|
||||||
@@ -334,14 +314,14 @@ class MemcachedSessionInterface(SessionInterface):
|
|||||||
super(MemcachedSessionInterface, self).__init__(
|
super(MemcachedSessionInterface, self).__init__(
|
||||||
key_prefix=key_prefix, use_signer=use_signer,
|
key_prefix=key_prefix, use_signer=use_signer,
|
||||||
permanent=permanent, **kwargs)
|
permanent=permanent, **kwargs)
|
||||||
self.backend = memcached
|
self.memcached = memcached
|
||||||
|
|
||||||
async def create(self, app: Quart) -> None:
|
@asyncio.coroutine
|
||||||
if self.backend is None:
|
def create(self, app: Quart) -> None:
|
||||||
|
if self.memcached is None:
|
||||||
import aiomcache
|
import aiomcache
|
||||||
# self.backend = aiomcache.Client("127.0.0.1", 11211)
|
loop = asyncio.get_running_loop()
|
||||||
self.backend = aiomcache.Client(self._config.get('SESSION_MEMCACHED_HOST', '127.0.0.1'),
|
self.memcached = aiomcache.Client("127.0.0.1", 11211, loop=loop)
|
||||||
self._config.get('SESSION_MEMCACHED_PORT', 11211))
|
|
||||||
|
|
||||||
def _get_memcache_timeout(self, timeout):
|
def _get_memcache_timeout(self, timeout):
|
||||||
"""
|
"""
|
||||||
@@ -358,117 +338,26 @@ class MemcachedSessionInterface(SessionInterface):
|
|||||||
timeout += int(time.time())
|
timeout += int(time.time())
|
||||||
return timeout
|
return timeout
|
||||||
|
|
||||||
async def get(self, key: str, app: Quart = None):
|
async def _backend_get(self, app: Quart, key: str):
|
||||||
key = key.encode()
|
key = key.encode()
|
||||||
return await self.backend.get(key)
|
return await self.memcached.get(key)
|
||||||
|
|
||||||
async def set(self, key: str, value, expiry: int = None,
|
|
||||||
app: Quart = None):
|
|
||||||
if app and not expiry:
|
|
||||||
expiry = self._get_memcache_timeout(
|
|
||||||
total_seconds(app.permanent_session_lifetime))
|
|
||||||
|
|
||||||
|
async def _backend_set(self, app: Quart, key: str, value):
|
||||||
key = key.encode()
|
key = key.encode()
|
||||||
value = value.encode()
|
value = value.encode()
|
||||||
return await self.backend.set(key=key, value=value,
|
expiry = self._get_memcache_timeout(total_seconds(
|
||||||
|
app.permanent_session_lifetime))
|
||||||
|
return await self.memcached.set(key=key, value=value,
|
||||||
exptime=expiry)
|
exptime=expiry)
|
||||||
|
|
||||||
async def delete(self, key: str, app: Quart = None):
|
async def _backend_delete(self, app: Quart, key: str):
|
||||||
key = key.encode()
|
key = key.encode()
|
||||||
return await self.backend.delete(key)
|
return await self.memcached.delete(key)
|
||||||
|
|
||||||
def _convert_key_to_uuid(func):
|
|
||||||
"""
|
|
||||||
convert the session UUID to a UUID object for mongodb
|
|
||||||
|
|
||||||
example:
|
|
||||||
"session:b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8" ->
|
|
||||||
UUID("b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8")
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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" },
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user