feat(redis): add native JSON session storage for 4.1.0
Build & Publish Package / publish (push) Successful in 49s
Build & Publish Package / publish (push) Successful in 49s
- Add opt-in SESSION_REDIS_JSON storage for asyncio Redis backends. - Check JSON command support at startup and reject JSON mode on Trio. - Read legacy strings and migrate sessions on their next save. - Validate JSON objects before transactional replacement and expiry. - Preserve tagged serialization for datetime, tuple, and other types. - Cover command contracts, error handling, and session round-trips. - Document server requirements, permissions, and migration limits. - Normalize indentation and whitespace across code, tests, and examples. - Bump the package and lockfile to 4.1.0 and update the changelog. - Refresh the locked pycparser dependency to 3.12.
This commit is contained in:
@@ -1,3 +1,9 @@
|
|||||||
|
### 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
|
### 4.0.0
|
||||||
|
|
||||||
- **Breaking:** require Python 3.15 or newer (`requires-python = ">=3.15"`, classifier, `.python-version`).
|
- **Breaking:** require Python 3.15 or newer (`requires-python = ">=3.15"`, classifier, `.python-version`).
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
# 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](https://gitlab.com/pgjones/quart/blob/master/README.rst) that adds support for
|
||||||
@@ -8,7 +7,6 @@ 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
|
||||||
@@ -40,9 +38,7 @@ app.run()
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
||||||
### Redis
|
### Redis
|
||||||
|
|
||||||
via `redis>=4.4.0`.
|
via `redis>=4.4.0`.
|
||||||
|
|
||||||
```python3
|
```python3
|
||||||
@@ -78,8 +74,32 @@ async def setup():
|
|||||||
Session(app)
|
Session(app)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Trio
|
#### Native JSON storage (Redis / Valkey)
|
||||||
|
By default, session values are JSON-encoded **strings**. To store `session:*`
|
||||||
|
keys as native JSON documents instead:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
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
|
||||||
@@ -92,7 +112,6 @@ Session(app)
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Memcached
|
### Memcached
|
||||||
|
|
||||||
via `aiomcache`.
|
via `aiomcache`.
|
||||||
|
|
||||||
```python3
|
```python3
|
||||||
@@ -102,7 +121,6 @@ Session(app)
|
|||||||
```
|
```
|
||||||
|
|
||||||
### MongoDB
|
### MongoDB
|
||||||
|
|
||||||
via `motor`.
|
via `motor`.
|
||||||
|
|
||||||
```python3
|
```python3
|
||||||
@@ -114,7 +132,6 @@ 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 while Quart-Session uses [a JSON serializer](https://gitlab.com/pgjones/quart/blob/37e249b9b146824a8668eaa1daa12392aeb00256/src/quart/json/tag.py#L141)
|
||||||
capable of serializing the usual JSON types, as well as: `Tuple`, `Bytes`,
|
capable of serializing the usual JSON types, as well as: `Tuple`, `Bytes`,
|
||||||
@@ -140,9 +157,7 @@ app.session_interface.serialize = pickle
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Back-end usage
|
### Back-end usage
|
||||||
|
|
||||||
At any point you may interface with the session back-end directly:
|
At any point you may interface with the session back-end directly:
|
||||||
|
|
||||||
```python3
|
```python3
|
||||||
from quart_session.sessions import SessionInterface
|
from quart_session.sessions import SessionInterface
|
||||||
|
|
||||||
@@ -157,7 +172,6 @@ The interface will have the `get`, `set`, and `delete` methods available (regard
|
|||||||
back-end - similar to how [aiocache](https://github.com/argaen/aiocache) works).
|
back-end - similar to how [aiocache](https://github.com/argaen/aiocache) works).
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
[flask-session](https://pypi.org/project/Flask-Session/) sets a
|
[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 often puts unneeded load on underlying session infrastructure,
|
||||||
@@ -167,9 +181,7 @@ Quart-Session only contacts the back-end when a session changed (or created). In
|
|||||||
static file serves never emit a `Set-Cookie` header. If you'd like to enable
|
static file serves never emit a `Set-Cookie` header. If you'd like to enable
|
||||||
this though, set `SESSION_STATIC_FILE` to `True`.
|
this though, set `SESSION_STATIC_FILE` to `True`.
|
||||||
|
|
||||||
|
|
||||||
### Session pinning
|
### Session pinning
|
||||||
|
|
||||||
Associates an user's session to his/her IP address. This mitigates cookie stealing via XSS etc, and is handy
|
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.
|
for web applications that require extra security.
|
||||||
|
|
||||||
@@ -187,16 +199,13 @@ 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_REVERSE_PROXY` to `True`.
|
||||||
|
|
||||||
## Future development
|
## Future development
|
||||||
|
|
||||||
- `FileSystemSessionInterface`
|
- `FileSystemSessionInterface`
|
||||||
- `GoogleCloudDatastoreSessionInterface`
|
- `GoogleCloudDatastoreSessionInterface`
|
||||||
- Pytest
|
- Pytest
|
||||||
|
|
||||||
## Flask-Session
|
## Flask-Session
|
||||||
|
|
||||||
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 changes are specified below:
|
||||||
|
|
||||||
- Quart-Session does not emit a `Set-Cookie` on every request.
|
- Quart-Session does not emit a `Set-Cookie` on every request.
|
||||||
- Quart-Session does not emit a `Set-Cookie` on static file serves.
|
- Quart-Session does not emit a `Set-Cookie` on static file serves.
|
||||||
- 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`.
|
||||||
@@ -205,9 +214,7 @@ The changes are specified below:
|
|||||||
- Quart-Session might not have all the back-end interfaces implemented (yet), such as "filesystem".
|
- 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
|
||||||
|
|||||||
@@ -11,25 +11,20 @@
|
|||||||
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():
|
async def set():
|
||||||
session['key'] = 'value'
|
session['key'] = 'value'
|
||||||
return 'ok'
|
return 'ok'
|
||||||
|
|
||||||
|
|
||||||
@app.route('/get/')
|
@app.route('/get/')
|
||||||
async def get():
|
async 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
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "quart-session"
|
name = "quart-session"
|
||||||
version = "4.0.0"
|
version = "4.1.0"
|
||||||
description = "Server-side sessions for Quart"
|
description = "Server-side sessions for Quart"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.15"
|
requires-python = ">=3.15"
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ from .sessions import (
|
|||||||
NullSessionInterface
|
NullSessionInterface
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Session(object):
|
class Session(object):
|
||||||
"""This class is used to add Server-side Session to one or more Quart
|
"""This class is used to add Server-side Session to one or more Quart
|
||||||
applications.
|
applications.
|
||||||
@@ -93,6 +92,7 @@ class Session(object):
|
|||||||
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'))
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""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,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
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,7 +14,6 @@ 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,7 +18,6 @@ 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(":")
|
||||||
@@ -28,12 +27,10 @@ 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,23 +11,19 @@
|
|||||||
: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,7 +26,6 @@ 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.
|
||||||
|
|
||||||
@@ -47,11 +46,9 @@ 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:
|
||||||
|
|||||||
+12
-13
@@ -22,11 +22,9 @@ from quart.sessions import SessionInterface as QuartSessionInterface, SecureCook
|
|||||||
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."""
|
||||||
|
|
||||||
@@ -52,23 +50,18 @@ 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
|
||||||
|
|
||||||
|
|
||||||
class RedisSession(ServerSideSession):
|
class RedisSession(ServerSideSession):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MemcachedSession(ServerSideSession):
|
class MemcachedSession(ServerSideSession):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MongoDBSession(ServerSideSession):
|
class MongoDBSession(ServerSideSession):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NullSession(ServerSideSession):
|
class NullSession(ServerSideSession):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SessionInterface(QuartSessionInterface):
|
class SessionInterface(QuartSessionInterface):
|
||||||
"""Baseclass for session interfaces"""
|
"""Baseclass for session interfaces"""
|
||||||
|
|
||||||
@@ -220,7 +213,6 @@ 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.
|
||||||
|
|
||||||
@@ -236,6 +228,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.backend = 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.
|
||||||
@@ -252,14 +245,23 @@ class RedisSessionInterface(SessionInterface):
|
|||||||
self.backend = await aioredis.from_url(
|
self.backend = await aioredis.from_url(
|
||||||
uri, encoding="utf-8", decode_responses=True
|
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 get(self, key: str, app: Quart = None):
|
||||||
|
if self.native_json:
|
||||||
|
from .redis_json import get
|
||||||
|
return await get(self.backend, key)
|
||||||
return await self.backend.get(key)
|
return await self.backend.get(key)
|
||||||
|
|
||||||
async def set(self, key: str, value, expiry: int = None,
|
async def set(self, key: str, value, expiry: int = None,
|
||||||
app: Quart = None):
|
app: Quart = None):
|
||||||
if app and not expiry:
|
if app and not expiry:
|
||||||
expiry = total_seconds(app.permanent_session_lifetime)
|
expiry = total_seconds(app.permanent_session_lifetime)
|
||||||
|
if self.native_json:
|
||||||
|
from .redis_json import set
|
||||||
|
return await set(self.backend, key, value, expiry)
|
||||||
return await self.backend.setex(
|
return await self.backend.setex(
|
||||||
name=key, value=value,
|
name=key, value=value,
|
||||||
time=expiry)
|
time=expiry)
|
||||||
@@ -267,7 +269,6 @@ class RedisSessionInterface(SessionInterface):
|
|||||||
async def delete(self, key: str, app: Quart = None):
|
async def delete(self, key: str, app: Quart = None):
|
||||||
return await self.backend.delete(key)
|
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.
|
||||||
|
|
||||||
@@ -282,6 +283,8 @@ 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):
|
||||||
|
raise ValueError('SESSION_REDIS_JSON requires the asyncio Redis backend')
|
||||||
self.backend = redis
|
self.backend = redis
|
||||||
|
|
||||||
async def create(self, app: Quart) -> None:
|
async def create(self, app: Quart) -> None:
|
||||||
@@ -313,7 +316,6 @@ class RedisTrioSessionInterface(SessionInterface):
|
|||||||
async def delete(self, key: str, app: Quart = None):
|
async def delete(self, key: str, app: Quart = None):
|
||||||
return await self.backend.delete(key)
|
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.
|
||||||
|
|
||||||
@@ -375,7 +377,6 @@ class MemcachedSessionInterface(SessionInterface):
|
|||||||
key = key.encode()
|
key = key.encode()
|
||||||
return await self.backend.delete(key)
|
return await self.backend.delete(key)
|
||||||
|
|
||||||
|
|
||||||
def _convert_key_to_uuid(func):
|
def _convert_key_to_uuid(func):
|
||||||
"""
|
"""
|
||||||
convert the session UUID to a UUID object for mongodb
|
convert the session UUID to a UUID object for mongodb
|
||||||
@@ -401,7 +402,6 @@ def _convert_key_to_uuid(func):
|
|||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class MongoDBSessionInterface(SessionInterface):
|
class MongoDBSessionInterface(SessionInterface):
|
||||||
# mongodb does not a serializer as many object types are properly handled by the connector
|
# mongodb does not a serializer as many object types are properly handled by the connector
|
||||||
serializer = None
|
serializer = None
|
||||||
@@ -451,7 +451,6 @@ class MongoDBSessionInterface(SessionInterface):
|
|||||||
def collection(self):
|
def collection(self):
|
||||||
return self._database.get_collection(self._collection)
|
return self._database.get_collection(self._collection)
|
||||||
|
|
||||||
|
|
||||||
class NullSessionInterface(SessionInterface):
|
class NullSessionInterface(SessionInterface):
|
||||||
"""This class does absolutely nothing"""
|
"""This class does absolutely nothing"""
|
||||||
session_class = NullSession
|
session_class = NullSession
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""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)
|
||||||
@@ -296,11 +296,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "3.11"
|
version = "3.12"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" }
|
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 = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" },
|
{ 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]]
|
[[package]]
|
||||||
@@ -411,7 +411,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quart-session"
|
name = "quart-session"
|
||||||
version = "4.0.0"
|
version = "4.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "quart" },
|
{ name = "quart" },
|
||||||
|
|||||||
Reference in New Issue
Block a user