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:
@@ -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)
|
||||
Reference in New Issue
Block a user