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
+27 -27
View File
@@ -7,39 +7,39 @@ from quart_session import Session
@pytest.fixture
def app_factory():
"""Builds a Quart app backed by an in-memory session store."""
"""Builds a Quart app backed by an in-memory session store."""
def _factory(**config) -> Quart:
app = Quart(__name__)
app.secret_key = "test-secret-key"
app.config["SESSION_TYPE"] = "null"
app.config.update(config)
Session(app)
def _factory(**config) -> Quart:
app = Quart(__name__)
app.secret_key = "test-secret-key"
app.config["SESSION_TYPE"] = "null"
app.config.update(config)
Session(app)
# 'null' interface is a no-op store, give it a dict so that
# open_session() can actually find data again.
store: dict = {}
interface = app.session_interface
# 'null' interface is a no-op store, give it a dict so that
# open_session() can actually find data again.
store: dict = {}
interface = app.session_interface
async def _get(key: str, app: Optional[Quart] = None):
return store.get(key)
async def _get(key: str, app: Optional[Quart] = None):
return store.get(key)
async def _set(key: str, value, expiry: Optional[int] = None,
app: Optional[Quart] = None):
store[key] = value
async def _set(key: str, value, expiry: Optional[int] = None,
app: Optional[Quart] = None):
store[key] = value
async def _delete(key: str, app: Optional[Quart] = None):
store.pop(key, None)
async def _delete(key: str, app: Optional[Quart] = None):
store.pop(key, None)
interface.get = _get
interface.set = _set
interface.delete = _delete
interface.get = _get
interface.set = _set
interface.delete = _delete
@app.route("/")
async def index():
session["hits"] = session.get("hits", 0) + 1
return {"hits": session["hits"], "sid": session.sid}
@app.route("/")
async def index():
session["hits"] = session.get("hits", 0) + 1
return {"hits": session["hits"], "sid": session.sid}
return app
return app
return _factory
return _factory
+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)
+34 -34
View File
@@ -2,59 +2,59 @@ import pytest
from itsdangerous import Signer
def _cookie_value(response, name="session"):
header = response.headers.get("Set-Cookie")
assert header is not None, "no Set-Cookie header was sent"
assert header.startswith(f"{name}=")
return header[len(name) + 1:].split(";", 1)[0]
header = response.headers.get("Set-Cookie")
assert header is not None, "no Set-Cookie header was sent"
assert header.startswith(f"{name}=")
return header[len(name) + 1:].split(";", 1)[0]
@pytest.mark.asyncio
async def test_unsigned_cookie_is_plain_sid(app_factory):
"""Regression: str sid used to blow up with AttributeError on .decode()."""
app = app_factory(SESSION_USE_SIGNER=False)
client = app.test_client()
"""Regression: str sid used to blow up with AttributeError on .decode()."""
app = app_factory(SESSION_USE_SIGNER=False)
client = app.test_client()
response = await client.get("/")
response = await client.get("/")
assert response.status_code == 200
data = await response.get_json()
assert data["hits"] == 1
assert _cookie_value(response) == data["sid"]
assert response.status_code == 200
data = await response.get_json()
assert data["hits"] == 1
assert _cookie_value(response) == data["sid"]
@pytest.mark.asyncio
async def test_signed_cookie_is_signed_sid(app_factory):
app = app_factory(SESSION_USE_SIGNER=True)
client = app.test_client()
app = app_factory(SESSION_USE_SIGNER=True)
client = app.test_client()
response = await client.get("/")
response = await client.get("/")
assert response.status_code == 200
data = await response.get_json()
cookie = _cookie_value(response)
assert cookie != data["sid"]
assert response.status_code == 200
data = await response.get_json()
cookie = _cookie_value(response)
assert cookie != data["sid"]
signer = Signer(app.secret_key, salt="quart-session",
key_derivation="hmac")
assert signer.unsign(cookie).decode() == data["sid"]
signer = Signer(app.secret_key, salt="quart-session",
key_derivation="hmac")
assert signer.unsign(cookie).decode() == data["sid"]
@pytest.mark.asyncio
@pytest.mark.parametrize("use_signer", [False, True])
async def test_session_survives_next_request(app_factory, use_signer):
app = app_factory(SESSION_USE_SIGNER=use_signer)
client = app.test_client()
app = app_factory(SESSION_USE_SIGNER=use_signer)
client = app.test_client()
first = await client.get("/")
second = await client.get("/")
first = await client.get("/")
second = await client.get("/")
assert (await first.get_json())["hits"] == 1
assert (await second.get_json())["hits"] == 2
assert (await first.get_json())["hits"] == 1
assert (await second.get_json())["hits"] == 2
@pytest.mark.asyncio
async def test_signer_without_secret_key_sets_no_cookie(app_factory):
app = app_factory(SESSION_USE_SIGNER=True)
app.secret_key = None
client = app.test_client()
app = app_factory(SESSION_USE_SIGNER=True)
app.secret_key = None
client = app.test_client()
response = await client.get("/")
response = await client.get("/")
assert response.status_code == 200
assert response.headers.get("Set-Cookie") is None
assert response.status_code == 200
assert response.headers.get("Set-Cookie") is None