feat(redis): add native JSON session storage for 4.1.0
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:
2026-10-23 20:26:24 +02:00
parent 5dc4a8f6cb
commit 4939bbe987
17 changed files with 1024 additions and 843 deletions
+6
View File
@@ -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`).
+25 -18
View File
@@ -1,5 +1,4 @@
# Quart-Session # Quart-Session
![pyversions](https://img.shields.io/pypi/pyversions/Quart-Session.svg) [![pypiversion](https://badge.fury.io/py/Quart-Session.svg)](https://pypi.org/project/Quart-Session/) ![PyPI license](https://img.shields.io/pypi/l/Quart-Session.svg) ![pyversions](https://img.shields.io/pypi/pyversions/Quart-Session.svg) [![pypiversion](https://badge.fury.io/py/Quart-Session.svg)](https://pypi.org/project/Quart-Session/) ![PyPI license](https://img.shields.io/pypi/l/Quart-Session.svg)
Quart-Session is an extension for [Quart](https://gitlab.com/pgjones/quart/blob/master/README.rst) that adds support for Quart-Session is an extension for [Quart](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
-5
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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'))
+37
View File
@@ -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]
-2
View File
@@ -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.
-1
View File
@@ -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.
-3
View File
@@ -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:
-4
View File
@@ -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
View File
@@ -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
+150
View File
@@ -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)
Generated
+4 -4
View File
@@ -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" },