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
|
||||
|
||||
- **Breaking:** require Python 3.15 or newer (`requires-python = ">=3.15"`, classifier, `.python-version`).
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# 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
|
||||
@@ -8,7 +7,6 @@ server-side sessions to your application.
|
||||
Based on [flask-session](https://pypi.org/project/Flask-Session/).
|
||||
|
||||
## Quick start
|
||||
|
||||
Quart-Session can be installed via pipenv or pip,
|
||||
|
||||
```bash
|
||||
@@ -40,9 +38,7 @@ app.run()
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
### Redis
|
||||
|
||||
via `redis>=4.4.0`.
|
||||
|
||||
```python3
|
||||
@@ -78,8 +74,32 @@ async def setup():
|
||||
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.
|
||||
|
||||
```python3
|
||||
@@ -92,7 +112,6 @@ Session(app)
|
||||
```
|
||||
|
||||
### Memcached
|
||||
|
||||
via `aiomcache`.
|
||||
|
||||
```python3
|
||||
@@ -102,7 +121,6 @@ Session(app)
|
||||
```
|
||||
|
||||
### MongoDB
|
||||
|
||||
via `motor`.
|
||||
|
||||
```python3
|
||||
@@ -114,7 +132,6 @@ Session(app)
|
||||
```
|
||||
|
||||
### JSON serializer
|
||||
|
||||
[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)
|
||||
capable of serializing the usual JSON types, as well as: `Tuple`, `Bytes`,
|
||||
@@ -140,9 +157,7 @@ app.session_interface.serialize = pickle
|
||||
```
|
||||
|
||||
### Back-end usage
|
||||
|
||||
At any point you may interface with the session back-end directly:
|
||||
|
||||
```python3
|
||||
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).
|
||||
|
||||
### Performance
|
||||
|
||||
[flask-session](https://pypi.org/project/Flask-Session/) sets a
|
||||
session for each incoming request, including static files. From experience,
|
||||
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
|
||||
this though, 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 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`.
|
||||
|
||||
## Future development
|
||||
|
||||
- `FileSystemSessionInterface`
|
||||
- `GoogleCloudDatastoreSessionInterface`
|
||||
- Pytest
|
||||
|
||||
## Flask-Session
|
||||
|
||||
This library works very similarly to [flask-session](https://pypi.org/project/Flask-Session/).
|
||||
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 static file serves.
|
||||
- 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".
|
||||
|
||||
## Help
|
||||
|
||||
Find the Quart folk on [gitter](https://gitter.im/python-quart/lobby) or open an issue.
|
||||
|
||||
## License
|
||||
|
||||
BSD
|
||||
|
||||
@@ -11,25 +11,20 @@
|
||||
from quart import Quart, session
|
||||
from quart_session import Session
|
||||
|
||||
|
||||
SESSION_TYPE = 'redis'
|
||||
|
||||
|
||||
app = Quart(__name__)
|
||||
app.config.from_object(__name__)
|
||||
Session(app)
|
||||
|
||||
|
||||
@app.route('/set/')
|
||||
async def set():
|
||||
session['key'] = 'value'
|
||||
return 'ok'
|
||||
|
||||
|
||||
@app.route('/get/')
|
||||
async def get():
|
||||
return session.get('key', 'not set')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "quart-session"
|
||||
version = "4.0.0"
|
||||
version = "4.1.0"
|
||||
description = "Server-side sessions for Quart"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.15"
|
||||
|
||||
@@ -28,7 +28,6 @@ from .sessions import (
|
||||
NullSessionInterface
|
||||
)
|
||||
|
||||
|
||||
class Session(object):
|
||||
"""This class is used to add Server-side Session to one or more Quart
|
||||
applications.
|
||||
@@ -93,6 +92,7 @@ class Session(object):
|
||||
config.setdefault('SESSION_STATIC_FILE', False)
|
||||
config.setdefault('SESSION_EXPLICIT', False)
|
||||
config.setdefault('SESSION_REDIS', None)
|
||||
config.setdefault('SESSION_REDIS_JSON', False)
|
||||
config.setdefault('SESSION_MEMCACHED', None)
|
||||
config.setdefault('SESSION_FILE_DIR',
|
||||
os.path.join(os.getcwd(), 'quart_session'))
|
||||
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
|
||||
|
||||
class RedisTrio:
|
||||
"""A simple Redis+Trio client.
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import trio
|
||||
from .serialization import atom, serialize
|
||||
from .errors import ProtocolError, ResponseError, ResponseTypeError
|
||||
|
||||
|
||||
SP = ord("+")
|
||||
EP = ord("-")
|
||||
IP = ord(":")
|
||||
@@ -28,12 +27,10 @@ AP = ord("*")
|
||||
#: The set of known Redis response prefixes.
|
||||
known_prefixes = {SP, EP, IP, BP, AP}
|
||||
|
||||
|
||||
class ReadMore(Exception):
|
||||
"""Raised by parse to signal that it needs more data.
|
||||
"""
|
||||
|
||||
|
||||
class RedisConnection:
|
||||
"""This class facilitates all communication with Redis via a trio socket.
|
||||
Warning:
|
||||
|
||||
@@ -11,23 +11,19 @@
|
||||
:license: BSD, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
|
||||
class RedisError(Exception):
|
||||
"""Base class for all Redis-related errors.
|
||||
"""
|
||||
|
||||
|
||||
class ProtocolError(RedisError):
|
||||
"""Raised when Redis responds with something that doesn't conform
|
||||
to the protocol.
|
||||
"""
|
||||
|
||||
|
||||
class ResponseError(RedisError):
|
||||
"""Raised when Redis returns an error response.
|
||||
"""
|
||||
|
||||
|
||||
class ResponseTypeError(ResponseError):
|
||||
"""Raised when Redis returns an error response with a `WRONGTYPE` prefix.
|
||||
"""
|
||||
|
||||
@@ -26,7 +26,6 @@ escapes = {
|
||||
ord(b'"'): rb'\"',
|
||||
}
|
||||
|
||||
|
||||
def serialize(x):
|
||||
"""Serialize `x` so that it can safely be sent to Redis.
|
||||
|
||||
@@ -47,11 +46,9 @@ def serialize(x):
|
||||
else:
|
||||
return serialize(str(x))
|
||||
|
||||
|
||||
def quote(bs):
|
||||
return b'"' + bytes(escape(bs)) + b'"'
|
||||
|
||||
|
||||
def escape(bs):
|
||||
for c in bs:
|
||||
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 itsdangerous import Signer, BadSignature, want_bytes
|
||||
|
||||
|
||||
def total_seconds(td):
|
||||
return td.days * 60 * 60 * 24 + td.seconds
|
||||
|
||||
|
||||
class ServerSideSession(SecureCookieSession):
|
||||
"""Baseclass for server-side based sessions."""
|
||||
|
||||
@@ -52,23 +50,18 @@ class ServerSideSession(SecureCookieSession):
|
||||
def addr(self, value: str) -> None:
|
||||
self['_addr'] = value # type: ignore
|
||||
|
||||
|
||||
class RedisSession(ServerSideSession):
|
||||
pass
|
||||
|
||||
|
||||
class MemcachedSession(ServerSideSession):
|
||||
pass
|
||||
|
||||
|
||||
class MongoDBSession(ServerSideSession):
|
||||
pass
|
||||
|
||||
|
||||
class NullSession(ServerSideSession):
|
||||
pass
|
||||
|
||||
|
||||
class SessionInterface(QuartSessionInterface):
|
||||
"""Baseclass for session interfaces"""
|
||||
|
||||
@@ -220,7 +213,6 @@ class SessionInterface(QuartSessionInterface):
|
||||
return Signer(app.secret_key, salt='quart-session',
|
||||
key_derivation='hmac')
|
||||
|
||||
|
||||
class RedisSessionInterface(SessionInterface):
|
||||
"""Uses the Redis key-value store as a session backend.
|
||||
|
||||
@@ -236,6 +228,7 @@ class RedisSessionInterface(SessionInterface):
|
||||
def __init__(self, redis, **kwargs):
|
||||
super(RedisSessionInterface, self).__init__(**kwargs)
|
||||
self.backend = redis
|
||||
self.native_json = self._config.get('SESSION_REDIS_JSON', False)
|
||||
|
||||
async def create(self, app: Quart) -> None:
|
||||
"""Creates ``aioredis.Redis`` instance.
|
||||
@@ -252,14 +245,23 @@ class RedisSessionInterface(SessionInterface):
|
||||
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):
|
||||
if self.native_json:
|
||||
from .redis_json import get
|
||||
return await get(self.backend, key)
|
||||
return await self.backend.get(key)
|
||||
|
||||
async def set(self, key: str, value, expiry: int = None,
|
||||
app: Quart = None):
|
||||
if app and not expiry:
|
||||
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(
|
||||
name=key, value=value,
|
||||
time=expiry)
|
||||
@@ -267,7 +269,6 @@ class RedisSessionInterface(SessionInterface):
|
||||
async def delete(self, key: str, app: Quart = None):
|
||||
return await self.backend.delete(key)
|
||||
|
||||
|
||||
class RedisTrioSessionInterface(SessionInterface):
|
||||
"""Uses the Redis+Trio key-value store as a session backend.
|
||||
|
||||
@@ -282,6 +283,8 @@ class RedisTrioSessionInterface(SessionInterface):
|
||||
|
||||
def __init__(self, redis, **kwargs):
|
||||
super(RedisTrioSessionInterface, self).__init__(**kwargs)
|
||||
if self._config.get('SESSION_REDIS_JSON', False):
|
||||
raise ValueError('SESSION_REDIS_JSON requires the asyncio Redis backend')
|
||||
self.backend = redis
|
||||
|
||||
async def create(self, app: Quart) -> None:
|
||||
@@ -313,7 +316,6 @@ class RedisTrioSessionInterface(SessionInterface):
|
||||
async def delete(self, key: str, app: Quart = None):
|
||||
return await self.backend.delete(key)
|
||||
|
||||
|
||||
class MemcachedSessionInterface(SessionInterface):
|
||||
"""Uses the Memcached key-value store as a session backend.
|
||||
|
||||
@@ -375,7 +377,6 @@ class MemcachedSessionInterface(SessionInterface):
|
||||
key = key.encode()
|
||||
return await self.backend.delete(key)
|
||||
|
||||
|
||||
def _convert_key_to_uuid(func):
|
||||
"""
|
||||
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 wrapper
|
||||
|
||||
|
||||
class MongoDBSessionInterface(SessionInterface):
|
||||
# mongodb does not a serializer as many object types are properly handled by the connector
|
||||
serializer = None
|
||||
@@ -451,7 +451,6 @@ class MongoDBSessionInterface(SessionInterface):
|
||||
def collection(self):
|
||||
return self._database.get_collection(self._collection)
|
||||
|
||||
|
||||
class NullSessionInterface(SessionInterface):
|
||||
"""This class does absolutely nothing"""
|
||||
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]]
|
||||
name = "pycparser"
|
||||
version = "3.11"
|
||||
version = "3.12"
|
||||
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 = [
|
||||
{ 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]]
|
||||
@@ -411,7 +411,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "quart-session"
|
||||
version = "4.0.0"
|
||||
version = "4.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "quart" },
|
||||
|
||||
Reference in New Issue
Block a user