diff --git a/CHANGELOG.md b/CHANGELOG.md index 65295a2..46a0e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`). @@ -41,7 +47,7 @@ ### 1.0.0 2020-01-15 - Added support for arbitrary usage of caching backends. - - Exposed `get`, `set`, `delete` on the session interface for direct usage. + - Exposed `get`, `set`, `delete` on the session interface for direct usage. - Renamed `SESSION_HIJACK_REVERSE_PROXY` to `SESSION_REVERSE_PROXY`. - Renamed `SESSION_HIJACK_PROTECTION` to `SESSION_PROTECTION`. - Removed fallback when `X-Forwarded-For` is not present whilst USING `SESSION_REVERSE_PROXY`, emit error instead. diff --git a/README.md b/README.md index d6f8d92..64175c2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ # 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) 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 @@ -28,21 +26,19 @@ Session(app) @app.route('/') async def hello(): - session["foo"] = "bar" - return "session key 'foo' set" + session["foo"] = "bar" + return "session key 'foo' set" @app.route('/foo') async def foo(): - return session.get("foo", "session key 'foo' not found") + return session.get("foo", "session key 'foo' not found") app.run() ``` ## Features - ### Redis - via `redis>=4.4.0`. ```python3 @@ -68,18 +64,42 @@ app.config['SESSION_TYPE'] = 'redis' @app.before_serving async def setup(): - cache = await aioredis.Redis( - host="foobar.com", - port=6379, - password="foobar" - ) - - app.config['SESSION_REDIS'] = cache - Session(app) + cache = await aioredis.Redis( + host="foobar.com", + port=6379, + password="foobar" + ) + + app.config['SESSION_REDIS'] = cache + 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: $` rather +than `GET session:`. + +#### 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`, @@ -132,32 +149,29 @@ app.config['SESSION_TYPE'] = 'redis' Session(app) try: - import cPickle as pickle + import cPickle as pickle except ImportError: - import pickle + import pickle 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 @app.route("/") async def hello(): - cache: SessionInterface = app.session_interface - await cache.set("random_key", "val", expiry=3600) - data = await cache.get("random_key") + cache: SessionInterface = app.session_interface + await cache.set("random_key", "val", expiry=3600) + data = await cache.get("random_key") ``` The interface will have the `get`, `set`, and `delete` methods available (regardless of 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 diff --git a/examples/hello.py b/examples/hello.py index ff17590..c6c1ea3 100644 --- a/examples/hello.py +++ b/examples/hello.py @@ -1,35 +1,30 @@ # -*- coding: utf-8 -*- """ - Hello - ~~~~~ + Hello + ~~~~~ - Quart-Session demo. + Quart-Session demo. - :copyright: (c) 2020 by Kroket Ltd. - :license: BSD, see LICENSE for more details. + :copyright: (c) 2020 by Kroket Ltd. + :license: BSD, see LICENSE for more details. """ 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' - + session['key'] = 'value' + return 'ok' @app.route('/get/') async def get(): - return session.get('key', 'not set') - + return session.get('key', 'not set') if __name__ == "__main__": - app.run(debug=True) + app.run(debug=True) diff --git a/pyproject.toml b/pyproject.toml index d1b6e9d..d199d36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/quart_session/__init__.py b/quart_session/__init__.py index 9f372de..62593ca 100644 --- a/quart_session/__init__.py +++ b/quart_session/__init__.py @@ -1,167 +1,167 @@ # -*- coding: utf-8 -*- """ - quart_session - ~~~~~~~~~~~~~ + quart_session + ~~~~~~~~~~~~~ - Adds server session support to your application. + Adds server session support to your application. - :copyright: (c) 2014 by Shipeng Feng. - :copyright: (c) 2020 by Kroket Ltd. - :license: BSD, see LICENSE for more details. + :copyright: (c) 2014 by Shipeng Feng. + :copyright: (c) 2020 by Kroket Ltd. + :license: BSD, see LICENSE for more details. """ import os from importlib.metadata import PackageNotFoundError, version as _pkg_version try: - __version__ = _pkg_version("quart-session") + __version__ = _pkg_version("quart-session") except PackageNotFoundError: # running from a source tree without install - __version__ = "unknown" + __version__ = "unknown" from quart import Quart from .sessions import ( - RedisSessionInterface, - RedisTrioSessionInterface, - MemcachedSessionInterface, - MongoDBSessionInterface, - NullSessionInterface + RedisSessionInterface, + RedisTrioSessionInterface, + MemcachedSessionInterface, + MongoDBSessionInterface, + NullSessionInterface ) - class Session(object): - """This class is used to add Server-side Session to one or more Quart - applications. + """This class is used to add Server-side Session to one or more Quart + applications. - There are two usage modes. One is initialize the instance with a very - specific Quart application:: + There are two usage modes. One is initialize the instance with a very + specific Quart application:: - app = Quart(__name__) - Session(app) + app = Quart(__name__) + Session(app) - The second possibility is to create the object once and configure the - application later:: + The second possibility is to create the object once and configure the + application later:: - sess = Session() + sess = Session() - def create_app(): - app = Quart(__name__) - sess.init_app(app) - return app + def create_app(): + app = Quart(__name__) + sess.init_app(app) + return app - By default Quart-Session will use :class:`NullSessionInterface`, you - really should configure your app to use a different SessionInterface. + By default Quart-Session will use :class:`NullSessionInterface`, you + really should configure your app to use a different SessionInterface. - .. note:: + .. note:: - You can not use ``Session`` instance directly, what ``Session`` does - is just change the :attr:`~quart.Quart.session_interface` attribute on - your Quart applications. + You can not use ``Session`` instance directly, what ``Session`` does + is just change the :attr:`~quart.Quart.session_interface` attribute on + your Quart applications. + """ + + def __init__(self, app: Quart = None) -> None: + self._current_async_library = "asyncio" + self.app = app + if app is not None: + self.init_app(app) + + def init_app(self, app: Quart) -> None: + """This is used to set up session for your app object. + + :param app: the Quart app object with proper configuration. """ + try: + import quart_trio + if isinstance(app, quart_trio.QuartTrio): + self._current_async_library = "trio" + except ImportError: + pass + app.session_interface = self._get_interface(app) - def __init__(self, app: Quart = None) -> None: - self._current_async_library = "asyncio" - self.app = app - if app is not None: - self.init_app(app) + @app.before_serving + async def setup(): + await app.session_interface.create(app) - def init_app(self, app: Quart) -> None: - """This is used to set up session for your app object. + def _get_interface(self, app: Quart): + config = app.config.copy() + config.setdefault('SESSION_TYPE', 'null') + config.setdefault('SESSION_PERMANENT', True) + config.setdefault('SESSION_USE_SIGNER', False) + config.setdefault('SESSION_KEY_PREFIX', 'session:') + config.setdefault('SESSION_PROTECTION', False) + config.setdefault('SESSION_REVERSE_PROXY', False) + 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')) + config.setdefault('SESSION_FILE_THRESHOLD', 500) + config.setdefault('SESSION_FILE_MODE', 384) + config = {k: v for k, v in config.items() if k.startswith('SESSION_')} - :param app: the Quart app object with proper configuration. - """ - try: - import quart_trio - if isinstance(app, quart_trio.QuartTrio): - self._current_async_library = "trio" - except ImportError: - pass - app.session_interface = self._get_interface(app) + if isinstance(config.get("SESSION_HIJACK_PROTECTION"), bool): + app.logger.warning("Deprecation: `SESSION_HIJACK_PROTECTION` " + "has been renamed to `SESSION_PROTECTION`") - @app.before_serving - async def setup(): - await app.session_interface.create(app) + if isinstance(config.get("SESSION_HIJACK_REVERSE_PROXY"), str): + app.logger.warning("Deprecation: `SESSION_HIJACK_REVERSE_PROXY` " + "has been renamed to `SESSION_REVERSE_PROXY`") - def _get_interface(self, app: Quart): - config = app.config.copy() - config.setdefault('SESSION_TYPE', 'null') - config.setdefault('SESSION_PERMANENT', True) - config.setdefault('SESSION_USE_SIGNER', False) - config.setdefault('SESSION_KEY_PREFIX', 'session:') - config.setdefault('SESSION_PROTECTION', False) - config.setdefault('SESSION_REVERSE_PROXY', False) - config.setdefault('SESSION_STATIC_FILE', False) - config.setdefault('SESSION_EXPLICIT', False) - config.setdefault('SESSION_REDIS', None) - config.setdefault('SESSION_MEMCACHED', None) - config.setdefault('SESSION_FILE_DIR', - os.path.join(os.getcwd(), 'quart_session')) - config.setdefault('SESSION_FILE_THRESHOLD', 500) - config.setdefault('SESSION_FILE_MODE', 384) - config = {k: v for k, v in config.items() if k.startswith('SESSION_')} + backend_warning = f"Please specify a session backend. " \ + f"Available interfaces: redis, redis+trio, " \ + f"memcached, null. e.g: app.config['SESSION_TYPE'] = 'redis'" - if isinstance(config.get("SESSION_HIJACK_PROTECTION"), bool): - app.logger.warning("Deprecation: `SESSION_HIJACK_PROTECTION` " - "has been renamed to `SESSION_PROTECTION`") + if config['SESSION_TYPE'] == 'redis': + options = { + "redis": config['SESSION_REDIS'], + "key_prefix": config['SESSION_KEY_PREFIX'], + "use_signer": config['SESSION_USE_SIGNER'], + "permanent": config['SESSION_PERMANENT'], + **config + } - if isinstance(config.get("SESSION_HIJACK_REVERSE_PROXY"), str): - app.logger.warning("Deprecation: `SESSION_HIJACK_REVERSE_PROXY` " - "has been renamed to `SESSION_REVERSE_PROXY`") + if self._current_async_library == "asyncio": + session_interface = RedisSessionInterface(**options) + elif self._current_async_library == "trio": + session_interface = RedisTrioSessionInterface(**options) + else: + raise NotImplementedError("Unknown eventloop") - backend_warning = f"Please specify a session backend. " \ - f"Available interfaces: redis, redis+trio, " \ - f"memcached, null. e.g: app.config['SESSION_TYPE'] = 'redis'" + elif config['SESSION_TYPE'] == 'redis+trio': + session_interface = RedisTrioSessionInterface( + redis=config['SESSION_REDIS'], + key_prefix=config['SESSION_KEY_PREFIX'], + use_signer=config['SESSION_USE_SIGNER'], + premanent=config['SESSION_PERMANENT'], + **config + ) + elif config['SESSION_TYPE'] == 'memcached': + session_interface = MemcachedSessionInterface( + memcached=config['SESSION_MEMCACHED'], + key_prefix=config['SESSION_KEY_PREFIX'], + use_signer=config['SESSION_USE_SIGNER'], + permanent=config['SESSION_PERMANENT'], + **config) + elif config['SESSION_TYPE'] == 'mongodb': + session_interface = MongoDBSessionInterface( + mongodb_uri=config['SESSION_MONGODB_URI'], + collection=config['SESSION_MONGODB_COLLECTION'], + client_kwargs=config.get('SESSION_MONGODB_CLIENT_KWARGS', {}), + set_callback=config.get('SESSION_MONGODB_SET_CALLBACK'), + key_prefix=config['SESSION_KEY_PREFIX'], + use_signer=config['SESSION_USE_SIGNER'], + permanent=config['SESSION_PERMANENT'], + **config) + elif config['SESSION_TYPE'] == 'null': + app.logger.warning(f"{backend_warning}. Currently using: null") + session_interface = NullSessionInterface( + key_prefix=config['SESSION_KEY_PREFIX'], + use_signer=config['SESSION_USE_SIGNER'], + permanent=config['SESSION_PERMANENT'], + **config) + else: + raise NotImplementedError(f"No such session interface " + f"\"{config['SESSION_TYPE']}\". {backend_warning}") - if config['SESSION_TYPE'] == 'redis': - options = { - "redis": config['SESSION_REDIS'], - "key_prefix": config['SESSION_KEY_PREFIX'], - "use_signer": config['SESSION_USE_SIGNER'], - "permanent": config['SESSION_PERMANENT'], - **config - } - - if self._current_async_library == "asyncio": - session_interface = RedisSessionInterface(**options) - elif self._current_async_library == "trio": - session_interface = RedisTrioSessionInterface(**options) - else: - raise NotImplementedError("Unknown eventloop") - - elif config['SESSION_TYPE'] == 'redis+trio': - session_interface = RedisTrioSessionInterface( - redis=config['SESSION_REDIS'], - key_prefix=config['SESSION_KEY_PREFIX'], - use_signer=config['SESSION_USE_SIGNER'], - premanent=config['SESSION_PERMANENT'], - **config - ) - elif config['SESSION_TYPE'] == 'memcached': - session_interface = MemcachedSessionInterface( - memcached=config['SESSION_MEMCACHED'], - key_prefix=config['SESSION_KEY_PREFIX'], - use_signer=config['SESSION_USE_SIGNER'], - permanent=config['SESSION_PERMANENT'], - **config) - elif config['SESSION_TYPE'] == 'mongodb': - session_interface = MongoDBSessionInterface( - mongodb_uri=config['SESSION_MONGODB_URI'], - collection=config['SESSION_MONGODB_COLLECTION'], - client_kwargs=config.get('SESSION_MONGODB_CLIENT_KWARGS', {}), - set_callback=config.get('SESSION_MONGODB_SET_CALLBACK'), - key_prefix=config['SESSION_KEY_PREFIX'], - use_signer=config['SESSION_USE_SIGNER'], - permanent=config['SESSION_PERMANENT'], - **config) - elif config['SESSION_TYPE'] == 'null': - app.logger.warning(f"{backend_warning}. Currently using: null") - session_interface = NullSessionInterface( - key_prefix=config['SESSION_KEY_PREFIX'], - use_signer=config['SESSION_USE_SIGNER'], - permanent=config['SESSION_PERMANENT'], - **config) - else: - raise NotImplementedError(f"No such session interface " - f"\"{config['SESSION_TYPE']}\". {backend_warning}") - - return session_interface + return session_interface diff --git a/quart_session/redis_json.py b/quart_session/redis_json.py new file mode 100644 index 0000000..8d2d916 --- /dev/null +++ b/quart_session/redis_json.py @@ -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] diff --git a/quart_session/redis_trio/README.md b/quart_session/redis_trio/README.md index 77d7f19..eb0e32c 100644 --- a/quart_session/redis_trio/README.md +++ b/quart_session/redis_trio/README.md @@ -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. @@ -9,7 +7,7 @@ Since it has no active maintainers and no PyPI package - I am including it as-is ```python3 from quart_session.redis_trio import RedisTrio cache = RedisTrio( - addr=b"10.0.0.3", port=6379, password=b"foo") + addr=b"10.0.0.3", port=6379, password=b"foo") await cache.connect() await cache.setex(key="foo", value=42, seconds=300) @@ -20,8 +18,8 @@ Or, ```python3 async with RedisTrio() as cache: - await cache.set("foo", 42) - await cache.get("foo") + await cache.set("foo", 42) + await cache.get("foo") ``` ## Future work diff --git a/quart_session/redis_trio/__init__.py b/quart_session/redis_trio/__init__.py index 7c0aada..c6cf35c 100644 --- a/quart_session/redis_trio/__init__.py +++ b/quart_session/redis_trio/__init__.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- """ - quart_session.redis_trio - ~~~~~~~~~~~~~~~~~~~~~~ + quart_session.redis_trio + ~~~~~~~~~~~~~~~~~~~~~~ - A simple Redis Trio client. + A simple Redis Trio client. - :copyright: (c) 2017 by Bogdan Paul Popa. - :copyright: (c) 2019 by Oleksii Aleksieiev. - :copyright: (c) 2020 by dsc. - :license: BSD, see LICENSE for more details. + :copyright: (c) 2017 by Bogdan Paul Popa. + :copyright: (c) 2019 by Oleksii Aleksieiev. + :copyright: (c) 2020 by dsc. + :license: BSD, see LICENSE for more details. """ from quart_session.redis_trio.client import RedisTrio diff --git a/quart_session/redis_trio/client.py b/quart_session/redis_trio/client.py index 59d2b24..a0dbaf4 100644 --- a/quart_session/redis_trio/client.py +++ b/quart_session/redis_trio/client.py @@ -1,88 +1,87 @@ # -*- coding: utf-8 -*- """ - quart_session.redis_trio - ~~~~~~~~~~~~~~~~~~~~~~ + quart_session.redis_trio + ~~~~~~~~~~~~~~~~~~~~~~ - A simple Redis Trio client. + A simple Redis Trio client. - :copyright: (c) 2017 by Bogdan Paul Popa. - :copyright: (c) 2019 by Oleksii Aleksieiev. - :copyright: (c) 2020 by dsc. - :license: BSD, see LICENSE for more details. + :copyright: (c) 2017 by Bogdan Paul Popa. + :copyright: (c) 2019 by Oleksii Aleksieiev. + :copyright: (c) 2020 by dsc. + :license: BSD, see LICENSE for more details. """ from typing import Union from .connection import RedisConnection - class RedisTrio: - """A simple Redis+Trio client. + """A simple Redis+Trio client. - Parameters: - addr(str): The IP address the Redis server is listening on. - port(int): The port the Redis server is listening on. + Parameters: + addr(str): The IP address the Redis server is listening on. + port(int): The port the Redis server is listening on. - Examples: + Examples: - >>> async with RedisTrio() as redis: - ... await redis.set("foo", 42) - ... await redis.get("foo") - b'42' + >>> async with RedisTrio() as redis: + ... await redis.set("foo", 42) + ... await redis.get("foo") + b'42' + """ + + def __init__(self, addr: Union[bytes, str] = b"127.0.0.1", port: int = 6379, password: bytes = b""): + self.conn = RedisConnection(addr, port) + self.password = password + + async def connect(self): + """Open a connection to the Redis server. + + Returns: + RedisTrio: This instance. """ + await self.conn.connect() + if self.password: + await self.auth(self.password) + return self - def __init__(self, addr: Union[bytes, str] = b"127.0.0.1", port: int = 6379, password: bytes = b""): - self.conn = RedisConnection(addr, port) - self.password = password + async def close(self): + """Close the connection to the Redis server. + """ + await self.quit() + self.conn.close() - async def connect(self): - """Open a connection to the Redis server. + async def auth(self, password): + return await self.conn.process_command_ok(b"AUTH", password) - Returns: - RedisTrio: This instance. - """ - await self.conn.connect() - if self.password: - await self.auth(self.password) - return self + async def delete(self, *keys): + return await self.conn.process_command(b"DEL", *keys) - async def close(self): - """Close the connection to the Redis server. - """ - await self.quit() - self.conn.close() + async def echo(self, message): + return await self.conn.process_command(b"ECHO", message) - async def auth(self, password): - return await self.conn.process_command_ok(b"AUTH", password) + async def flushall(self): + return await self.conn.process_command_ok(b"FLUSHALL") - async def delete(self, *keys): - return await self.conn.process_command(b"DEL", *keys) + async def get(self, key) -> bytes: + return await self.conn.process_command(b"GET", key) - async def echo(self, message): - return await self.conn.process_command(b"ECHO", message) + async def quit(self): + return await self.conn.process_command(b"QUIT") - async def flushall(self): - return await self.conn.process_command_ok(b"FLUSHALL") + async def set(self, key, value): + return await self.conn.process_command_ok(b"SET", key, value) - async def get(self, key) -> bytes: - return await self.conn.process_command(b"GET", key) + async def setex(self, key: str, value: str, seconds: int): + """Set the value and expiration of a key. + :raises TypeError: if seconds is not int + """ + if not isinstance(seconds, int): + raise TypeError("milliseconds argument must be int") - async def quit(self): - return await self.conn.process_command(b"QUIT") + return await self.conn.process_command_ok(b"SETEX", key, seconds, value) - async def set(self, key, value): - return await self.conn.process_command_ok(b"SET", key, value) + async def __aenter__(self): + return await self.connect() - async def setex(self, key: str, value: str, seconds: int): - """Set the value and expiration of a key. - :raises TypeError: if seconds is not int - """ - if not isinstance(seconds, int): - raise TypeError("milliseconds argument must be int") - - return await self.conn.process_command_ok(b"SETEX", key, seconds, value) - - async def __aenter__(self): - return await self.connect() - - async def __aexit__(self, exc_type, exc_value, traceback): - self.close() + async def __aexit__(self, exc_type, exc_value, traceback): + self.close() diff --git a/quart_session/redis_trio/connection.py b/quart_session/redis_trio/connection.py index b94356c..109c651 100644 --- a/quart_session/redis_trio/connection.py +++ b/quart_session/redis_trio/connection.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- """ - quart_session.redis_trio.connection - ~~~~~~~~~~~~~~~~~~~~~~ + quart_session.redis_trio.connection + ~~~~~~~~~~~~~~~~~~~~~~ - A simple Redis Trio client. + A simple Redis Trio client. - :copyright: (c) 2017 by Bogdan Paul Popa. - :copyright: (c) 2019 by Oleksii Aleksieiev. - :copyright: (c) 2020 by dsc. - :license: BSD, see LICENSE for more details. + :copyright: (c) 2017 by Bogdan Paul Popa. + :copyright: (c) 2019 by Oleksii Aleksieiev. + :copyright: (c) 2020 by dsc. + :license: BSD, see LICENSE for more details. """ from typing import Union @@ -18,7 +18,6 @@ import trio from .serialization import atom, serialize from .errors import ProtocolError, ResponseError, ResponseTypeError - SP = ord("+") EP = ord("-") IP = ord(":") @@ -28,109 +27,107 @@ 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. - """ - + """Raised by parse to signal that it needs more data. + """ class RedisConnection: - """This class facilitates all communication with Redis via a trio socket. - Warning: - The interface of this class may change at any time, without - notice, due to the experimental nature of Trio. - """ + """This class facilitates all communication with Redis via a trio socket. + Warning: + The interface of this class may change at any time, without + notice, due to the experimental nature of Trio. + """ - def __init__(self, addr: Union[bytes, str], port: int, bufsize: int = 16384): - self.addr = (addr, port) - self.sock = trio.socket.socket() - self.bufsize = bufsize + def __init__(self, addr: Union[bytes, str], port: int, bufsize: int = 16384): + self.addr = (addr, port) + self.sock = trio.socket.socket() + self.bufsize = bufsize - async def connect(self): - await self.sock.connect(self.addr) + async def connect(self): + await self.sock.connect(self.addr) - def close(self): - self.sock.close() + def close(self): + self.sock.close() - async def send_command(self, command, *args): - command_and_args = (serialize(arg) for arg in (atom(command),) + args) - data = b" ".join(command_and_args) + b"\r\n" - await self.sock.send(data) + async def send_command(self, command, *args): + command_and_args = (serialize(arg) for arg in (atom(command),) + args) + data = b" ".join(command_and_args) + b"\r\n" + await self.sock.send(data) - async def process_command(self, *command_and_args): - await self.send_command(*command_and_args) - return await self.process_response() + async def process_command(self, *command_and_args): + await self.send_command(*command_and_args) + return await self.process_response() - async def process_command_ok(self, *command_and_args): - await self.send_command(*command_and_args) - return await self.process_response() == b"OK" + async def process_command_ok(self, *command_and_args): + await self.send_command(*command_and_args) + return await self.process_response() == b"OK" - async def process_response(self): - data = await self.sock.recv(self.bufsize) - while True: - try: - item, _ = await self.parse(data) - return item - except ReadMore: - data += await self.sock.recv(self.bufsize) + async def process_response(self): + data = await self.sock.recv(self.bufsize) + while True: + try: + item, _ = await self.parse(data) + return item + except ReadMore: + data += await self.sock.recv(self.bufsize) - async def parse(self, data): - try: - index = data.index(b"\r\n") - except ValueError: - raise ReadMore() + async def parse(self, data): + try: + index = data.index(b"\r\n") + except ValueError: + raise ReadMore() - if data[0] not in known_prefixes: - raise ProtocolError(f"Unexpected data in response: {data!r}.") + if data[0] not in known_prefixes: + raise ProtocolError(f"Unexpected data in response: {data!r}.") - elif data[0] == SP: - return data[1:index], data[index + 2:] + elif data[0] == SP: + return data[1:index], data[index + 2:] - elif data[0] == EP: - error = data[1:index].decode("ascii") - if error.startswith("WRONGTYPE"): - raise ResponseTypeError(error[len("WRONGTYPE "):]) + elif data[0] == EP: + error = data[1:index].decode("ascii") + if error.startswith("WRONGTYPE"): + raise ResponseTypeError(error[len("WRONGTYPE "):]) - elif error.startswith("ERR"): - raise ResponseError(error[len("ERR "):]) + elif error.startswith("ERR"): + raise ResponseError(error[len("ERR "):]) - else: - raise ResponseError(error) + else: + raise ResponseError(error) - elif data[0] == IP: - return int(data[1:index]), data[index + 2:] + elif data[0] == IP: + return int(data[1:index]), data[index + 2:] - elif data[0] == BP: - length, data = int(data[1:index]), data[index + 2:] - if length == -1: - return None, data + elif data[0] == BP: + length, data = int(data[1:index]), data[index + 2:] + if length == -1: + return None, data - elif len(data) < length + 2: - raise ReadMore() + elif len(data) < length + 2: + raise ReadMore() - return data[:length], data[length + 2:] + return data[:length], data[length + 2:] - elif data[0] == AP: - length, data = int(data[1:index]), data[index + 2:] - if length == -1: - return None, data + elif data[0] == AP: + length, data = int(data[1:index]), data[index + 2:] + if length == -1: + return None, data - return await self.parse_array(length, data) + return await self.parse_array(length, data) - else: # pragma: no cover - assert False, "unreachable" + else: # pragma: no cover + assert False, "unreachable" - async def parse_array(self, length, data): - items = [] - while len(items) < length: - if not data: - data += await self.sock.recv(self.bufsize) - continue + async def parse_array(self, length, data): + items = [] + while len(items) < length: + if not data: + data += await self.sock.recv(self.bufsize) + continue - try: - item, data = await self.parse(data) - items.append(item) - except ReadMore: - data += await self.sock.recv(self.bufsize) + try: + item, data = await self.parse(data) + items.append(item) + except ReadMore: + data += await self.sock.recv(self.bufsize) - return items, data + return items, data diff --git a/quart_session/redis_trio/errors.py b/quart_session/redis_trio/errors.py index 06aab9f..eb9bcb3 100644 --- a/quart_session/redis_trio/errors.py +++ b/quart_session/redis_trio/errors.py @@ -1,33 +1,29 @@ # -*- coding: utf-8 -*- """ - quart_session.redis_trio.errors - ~~~~~~~~~~~~~~~~~~~~~~ + quart_session.redis_trio.errors + ~~~~~~~~~~~~~~~~~~~~~~ - A simple Redis Trio client. + A simple Redis Trio client. - :copyright: (c) 2017 by Bogdan Paul Popa. - :copyright: (c) 2019 by Oleksii Aleksieiev. - :copyright: (c) 2020 by dsc. - :license: BSD, see LICENSE for more details. + :copyright: (c) 2017 by Bogdan Paul Popa. + :copyright: (c) 2019 by Oleksii Aleksieiev. + :copyright: (c) 2020 by dsc. + :license: BSD, see LICENSE for more details. """ - class RedisError(Exception): - """Base class for all Redis-related errors. - """ - + """Base class for all Redis-related errors. + """ class ProtocolError(RedisError): - """Raised when Redis responds with something that doesn't conform - to the protocol. - """ - + """Raised when Redis responds with something that doesn't conform + to the protocol. + """ class ResponseError(RedisError): - """Raised when Redis returns an error response. - """ - + """Raised when Redis returns an error response. + """ 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. + """ diff --git a/quart_session/redis_trio/serialization.py b/quart_session/redis_trio/serialization.py index 72f4891..cd1a085 100644 --- a/quart_session/redis_trio/serialization.py +++ b/quart_session/redis_trio/serialization.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- """ - quart_session.redis_trio.serialization - ~~~~~~~~~~~~~~~~~~~~~~ + quart_session.redis_trio.serialization + ~~~~~~~~~~~~~~~~~~~~~~ - A simple Redis Trio client. + A simple Redis Trio client. - :copyright: (c) 2017 by Bogdan Paul Popa. - :copyright: (c) 2019 by Oleksii Aleksieiev. - :copyright: (c) 2020 by dsc. - :license: BSD, see LICENSE for more details. + :copyright: (c) 2017 by Bogdan Paul Popa. + :copyright: (c) 2019 by Oleksii Aleksieiev. + :copyright: (c) 2020 by dsc. + :license: BSD, see LICENSE for more details. """ from collections import namedtuple @@ -19,42 +19,39 @@ atom = namedtuple("atom", ("value",)) #: The set of characters that must be escaped before being sent as #: Redis strings. escapes = { - ord(b"\0"): rb"\x00", - ord(b"\n"): rb"\n", - ord(b"\r"): rb"\r", - ord(b"\\"): rb"\\", - ord(b'"'): rb'\"', + ord(b"\0"): rb"\x00", + ord(b"\n"): rb"\n", + ord(b"\r"): rb"\r", + ord(b"\\"): rb"\\", + ord(b'"'): rb'\"', } - 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. - Parameters: - x(object): The value to serialize. - - Returns: - bytes: The serialized value. - """ - if isinstance(x, atom): - return x.value - elif isinstance(x, bytes): - return quote(x) - elif isinstance(x, str): - return quote(x.encode("utf-8")) - elif isinstance(x, (float, int)): - return str(x).encode("ascii") - else: - return serialize(str(x)) + Parameters: + x(object): The value to serialize. + Returns: + bytes: The serialized value. + """ + if isinstance(x, atom): + return x.value + elif isinstance(x, bytes): + return quote(x) + elif isinstance(x, str): + return quote(x.encode("utf-8")) + elif isinstance(x, (float, int)): + return str(x).encode("ascii") + else: + return serialize(str(x)) def quote(bs): - return b'"' + bytes(escape(bs)) + b'"' - + return b'"' + bytes(escape(bs)) + b'"' def escape(bs): - for c in bs: - if c in escapes: - yield from escapes[c] - else: - yield c + for c in bs: + if c in escapes: + yield from escapes[c] + else: + yield c diff --git a/quart_session/sessions.py b/quart_session/sessions.py index 33b0e05..88172e0 100644 --- a/quart_session/sessions.py +++ b/quart_session/sessions.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- """ - quart_session.sessions - ~~~~~~~~~~~~~~~~~~~~~~ + quart_session.sessions + ~~~~~~~~~~~~~~~~~~~~~~ - Server-side Sessions and SessionInterfaces. + Server-side Sessions and SessionInterfaces. - :copyright: (c) 2014 by Shipeng Feng. - :copyright: (c) 2020 by Kroket Ltd. - :license: BSD, see LICENSE for more details. + :copyright: (c) 2014 by Shipeng Feng. + :copyright: (c) 2020 by Kroket Ltd. + :license: BSD, see LICENSE for more details. """ import time from typing import Optional @@ -22,454 +22,453 @@ 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 - + return td.days * 60 * 60 * 24 + td.seconds class ServerSideSession(SecureCookieSession): - """Baseclass for server-side based sessions.""" + """Baseclass for server-side based sessions.""" - def __init__(self, initial=None, sid=None, permanent=None, addr=None): - super(ServerSideSession, self).__init__(initial or {}) - self.sid = sid - if permanent: - self.permanent = permanent - if addr: - self.addr = addr - self.modified = False + def __init__(self, initial=None, sid=None, permanent=None, addr=None): + super(ServerSideSession, self).__init__(initial or {}) + self.sid = sid + if permanent: + self.permanent = permanent + if addr: + self.addr = addr + self.modified = False - def dirty(self): - current_app.logger.warning("Deprecation: `dirty()` has " - "been made obsolete. Will be " - "removed soon^tm.") + def dirty(self): + current_app.logger.warning("Deprecation: `dirty()` has " + "been made obsolete. Will be " + "removed soon^tm.") - @property - def addr(self) -> str: - return self.get('_addr', False) # type: ignore - - @addr.setter - def addr(self, value: str) -> None: - self['_addr'] = value # type: ignore + @property + def addr(self) -> str: + return self.get('_addr', False) # type: ignore + @addr.setter + def addr(self, value: str) -> None: + self['_addr'] = value # type: ignore class RedisSession(ServerSideSession): - pass - + pass class MemcachedSession(ServerSideSession): - pass - + pass class MongoDBSession(ServerSideSession): - pass - + pass class NullSession(ServerSideSession): - pass - + pass class SessionInterface(QuartSessionInterface): - """Baseclass for session interfaces""" + """Baseclass for session interfaces""" - serializer = TaggedJSONSerializer() - session_class = None + serializer = TaggedJSONSerializer() + session_class = None - def __init__( - self, - key_prefix: str, - use_signer: bool = False, - permanent: bool = True, - **kwargs - ) -> None: - self.key_prefix = key_prefix - self.use_signer = use_signer - self.permanent = permanent - self._config = kwargs + def __init__( + self, + key_prefix: str, + use_signer: bool = False, + permanent: bool = True, + **kwargs + ) -> None: + self.key_prefix = key_prefix + self.use_signer = use_signer + self.permanent = permanent + self._config = kwargs - async def open_session( - self, - app: Quart, - request: BaseRequestWebsocket - ) -> Optional[SecureCookieSession]: - cname = app.config.get('SESSION_COOKIE_NAME', 'session') - sid = request.cookies.get(cname) - if self._config['SESSION_REVERSE_PROXY'] is True: - # and no, you cannot define your own incoming - # header, stick to standards :-) - addr = request.headers.get('X-Forwarded-For') - if not addr: - app.logger.error("Could not grab IP from reverse proxy, " - "session protection is DISABLED!") - else: - addr = request.remote_addr - options = {"sid": sid, "permanent": self.permanent, "addr": addr} + async def open_session( + self, + app: Quart, + request: BaseRequestWebsocket + ) -> Optional[SecureCookieSession]: + cname = app.config.get('SESSION_COOKIE_NAME', 'session') + sid = request.cookies.get(cname) + if self._config['SESSION_REVERSE_PROXY'] is True: + # and no, you cannot define your own incoming + # header, stick to standards :-) + addr = request.headers.get('X-Forwarded-For') + if not addr: + app.logger.error("Could not grab IP from reverse proxy, " + "session protection is DISABLED!") + else: + addr = request.remote_addr + options = {"sid": sid, "permanent": self.permanent, "addr": addr} - if not sid: - options['sid'] = self._generate_sid() - return self.session_class(**options) - if self.use_signer: - signer = self._get_signer(app) - if signer is None: - app.logger.warning("Failed to obtain a valid signer.") - return None - try: - sid_as_bytes = signer.unsign(sid) - sid = sid_as_bytes.decode() - except BadSignature: - app.logger.warning(f"Bad signature for sid: {sid}.") - options['sid'] = self._generate_sid() - return self.session_class(**options) + if not sid: + options['sid'] = self._generate_sid() + return self.session_class(**options) + if self.use_signer: + signer = self._get_signer(app) + if signer is None: + app.logger.warning("Failed to obtain a valid signer.") + return None + try: + sid_as_bytes = signer.unsign(sid) + sid = sid_as_bytes.decode() + except BadSignature: + app.logger.warning(f"Bad signature for sid: {sid}.") + options['sid'] = self._generate_sid() + return self.session_class(**options) - val = await self.get(key=self.key_prefix + sid, app=app) - if val is None: - options['sid'] = self._generate_sid() - return self.session_class(**options) + val = await self.get(key=self.key_prefix + sid, app=app) + if val is None: + options['sid'] = self._generate_sid() + return self.session_class(**options) - if self.serializer is None: - data = val - else: - try: - data = self.serializer.loads(val) - except: - app.logger.warning(f"Failed to deserialize session " - f"data for sid: {sid}. Generating new sid.") - app.logger.debug(f"data: {val}") - options['sid'] = self._generate_sid() - return self.session_class(**options) + if self.serializer is None: + data = val + else: + try: + data = self.serializer.loads(val) + except: + app.logger.warning(f"Failed to deserialize session " + f"data for sid: {sid}. Generating new sid.") + app.logger.debug(f"data: {val}") + options['sid'] = self._generate_sid() + return self.session_class(**options) - protection = self._config['SESSION_PROTECTION'] - if protection is True and addr is not None and \ - data.get('_addr', addr) != addr: - await self.delete(key=self.key_prefix + sid, app=app) - options['sid'] = self._generate_sid() - return self.session_class(**options) + protection = self._config['SESSION_PROTECTION'] + if protection is True and addr is not None and \ + data.get('_addr', addr) != addr: + await self.delete(key=self.key_prefix + sid, app=app) + options['sid'] = self._generate_sid() + return self.session_class(**options) - res = self.session_class(data, sid) - return res + res = self.session_class(data, sid) + return res - async def save_session( # type: ignore - self, - app: "Quart", - session: SecureCookieSession, - response: Response - ) -> None: - # prevent set-cookie on unmodified session objects - if not session.modified: - return + async def save_session( # type: ignore + self, + app: "Quart", + session: SecureCookieSession, + response: Response + ) -> None: + # prevent set-cookie on unmodified session objects + if not session.modified: + return - # prevent set-cookie on (static) file responses - # https://github.com/fengsp/flask-session/pull/70 - if self._config['SESSION_STATIC_FILE'] is False and \ - isinstance(response.response, FileBody): - return + # prevent set-cookie on (static) file responses + # https://github.com/fengsp/flask-session/pull/70 + if self._config['SESSION_STATIC_FILE'] is False and \ + isinstance(response.response, FileBody): + return - cname = app.config.get('SESSION_COOKIE_NAME', 'session') - session_key = self.key_prefix + session.sid - domain = self.get_cookie_domain(app) - path = self.get_cookie_path(app) - if not session: - if session.modified: - await self.delete(key=session_key, app=app) - response.delete_cookie(cname, - domain=domain, path=path) - return - httponly = self.get_cookie_httponly(app) - samesite = self.get_cookie_samesite(app) - secure = self.get_cookie_secure(app) - expires = self.get_expiration_time(app, session) + cname = app.config.get('SESSION_COOKIE_NAME', 'session') + session_key = self.key_prefix + session.sid + domain = self.get_cookie_domain(app) + path = self.get_cookie_path(app) + if not session: + if session.modified: + await self.delete(key=session_key, app=app) + response.delete_cookie(cname, + domain=domain, path=path) + return + httponly = self.get_cookie_httponly(app) + samesite = self.get_cookie_samesite(app) + secure = self.get_cookie_secure(app) + expires = self.get_expiration_time(app, session) - if self.serializer is None: - val = dict(session) - else: - val = self.serializer.dumps(dict(session)) + if self.serializer is None: + val = dict(session) + else: + val = self.serializer.dumps(dict(session)) - await self.set(key=session_key, value=val, app=app) - if self.use_signer: - signer = self._get_signer(app) - if signer is None: - app.logger.warning("Failed to obtain a valid signer, " - "not setting a session cookie.") - return - session_id = signer.sign(want_bytes(session.sid)).decode('utf-8') - else: - session_id = session.sid - response.set_cookie(cname, session_id, - expires=expires, httponly=httponly, - domain=domain, path=path, secure=secure, samesite=samesite) + await self.set(key=session_key, value=val, app=app) + if self.use_signer: + signer = self._get_signer(app) + if signer is None: + app.logger.warning("Failed to obtain a valid signer, " + "not setting a session cookie.") + return + session_id = signer.sign(want_bytes(session.sid)).decode('utf-8') + else: + session_id = session.sid + response.set_cookie(cname, session_id, + expires=expires, httponly=httponly, + domain=domain, path=path, secure=secure, samesite=samesite) - async def create(self, app: Quart): - raise NotImplementedError() + async def create(self, app: Quart): + raise NotImplementedError() - async def get(self, key: str, app: Quart = None): - raise NotImplementedError() + async def get(self, key: str, app: Quart = None): + raise NotImplementedError() - async def set(self, key: str, value, expiry: int = None, - app: Quart = None): - raise NotImplementedError() + async def set(self, key: str, value, expiry: int = None, + app: Quart = None): + raise NotImplementedError() - async def delete(self, key: str, app: Quart = None): - raise NotImplementedError() + async def delete(self, key: str, app: Quart = None): + raise NotImplementedError() - def _generate_sid(self) -> str: - return str(uuid4()) - - def _get_signer(self, app) -> Optional[Signer]: - if not app.secret_key: - return None - return Signer(app.secret_key, salt='quart-session', - key_derivation='hmac') + def _generate_sid(self) -> str: + return str(uuid4()) + def _get_signer(self, app) -> Optional[Signer]: + if not app.secret_key: + return None + return Signer(app.secret_key, salt='quart-session', + key_derivation='hmac') class RedisSessionInterface(SessionInterface): - """Uses the Redis key-value store as a session backend. + """Uses the Redis key-value store as a session backend. - :param redis: ``aioredis.Redis`` instance. - :param key_prefix: A prefix that is added to all Redis store keys. - :param use_signer: Whether to sign the session id cookie or not. - :param permanent: Whether to use permanent session or not. - :param kwargs: Quart-session config, used internally. + :param redis: ``aioredis.Redis`` instance. + :param key_prefix: A prefix that is added to all Redis store keys. + :param use_signer: Whether to sign the session id cookie or not. + :param permanent: Whether to use permanent session or not. + :param kwargs: Quart-session config, used internally. + """ + + session_class = RedisSession + + 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. + + .. note:: + + Creates a single Redis connection, you might prefer + pooling instead (see the `aioredis` documentation + for connection pool examples). """ + if self.backend is None: + from redis import asyncio as aioredis + uri = self._config.get('SESSION_URI', 'redis://localhost') + 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) - session_class = RedisSession + 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) - def __init__(self, redis, **kwargs): - super(RedisSessionInterface, self).__init__(**kwargs) - self.backend = redis - - async def create(self, app: Quart) -> None: - """Creates ``aioredis.Redis`` instance. - - .. note:: - - Creates a single Redis connection, you might prefer - pooling instead (see the `aioredis` documentation - for connection pool examples). - """ - if self.backend is None: - from redis import asyncio as aioredis - uri = self._config.get('SESSION_URI', 'redis://localhost') - self.backend = await aioredis.from_url( - uri, encoding="utf-8", decode_responses=True - ) - - async def get(self, key: str, app: Quart = None): - 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) - return await self.backend.setex( - name=key, value=value, - time=expiry) - - async def delete(self, key: str, app: Quart = None): - return await self.backend.delete(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) + 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. + """Uses the Redis+Trio key-value store as a session backend. - :param redis: ``quart_session.redis_trio.RedisTrio`` instance. - :param key_prefix: A prefix that is added to all Redis store keys. - :param use_signer: Whether to sign the session id cookie or not. - :param permanent: Whether to use permanent session or not. - :param kwargs: Quart-session config, used internally. + :param redis: ``quart_session.redis_trio.RedisTrio`` instance. + :param key_prefix: A prefix that is added to all Redis store keys. + :param use_signer: Whether to sign the session id cookie or not. + :param permanent: Whether to use permanent session or not. + :param kwargs: Quart-session config, used internally. + """ + + session_class = RedisSession + + 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: + """Creates ``aioredis.Redis`` instance. + + .. note:: + + Creates a single Redis connection. Pooling not + supported yet for ``RedisTrio``. """ + if self.backend is None: + from quart_session.redis_trio import RedisTrio + self.backend = RedisTrio() + await self.backend.connect() - session_class = RedisSession + async def get(self, key: str, app: Quart = None): + data = await self.backend.get(key) + if data: + return data.decode() - def __init__(self, redis, **kwargs): - super(RedisTrioSessionInterface, self).__init__(**kwargs) - self.backend = redis - - async def create(self, app: Quart) -> None: - """Creates ``aioredis.Redis`` instance. - - .. note:: - - Creates a single Redis connection. Pooling not - supported yet for ``RedisTrio``. - """ - if self.backend is None: - from quart_session.redis_trio import RedisTrio - self.backend = RedisTrio() - await self.backend.connect() - - async def get(self, key: str, app: Quart = None): - data = await self.backend.get(key) - if data: - return data.decode() - - 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) - return await self.backend.setex( - key=key, value=value, - seconds=expiry) - - async def delete(self, key: str, app: Quart = None): - return await self.backend.delete(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) + return await self.backend.setex( + key=key, value=value, + seconds=expiry) + 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. + """Uses the Memcached key-value store as a session backend. - :param client: ``aiomcache.Client`` instance. - :param key_prefix: A prefix that is added to all Redis store keys. - :param use_signer: Whether to sign the session id cookie or not. - :param permanent: Whether to use permanent session or not. - :param kwargs: Quart-session config, used internally. + :param client: ``aiomcache.Client`` instance. + :param key_prefix: A prefix that is added to all Redis store keys. + :param use_signer: Whether to sign the session id cookie or not. + :param permanent: Whether to use permanent session or not. + :param kwargs: Quart-session config, used internally. + """ + + session_class = MemcachedSession + + def __init__( + self, memcached, key_prefix: str, use_signer: bool = False, + permanent: bool = True, **kwargs): + super(MemcachedSessionInterface, self).__init__( + key_prefix=key_prefix, use_signer=use_signer, + permanent=permanent, **kwargs) + self.backend = memcached + + async def create(self, app: Quart) -> None: + if self.backend is None: + import aiomcache + # self.backend = aiomcache.Client("127.0.0.1", 11211) + self.backend = aiomcache.Client(self._config.get('SESSION_MEMCACHED_HOST', '127.0.0.1'), + self._config.get('SESSION_MEMCACHED_PORT', 11211)) + + def _get_memcache_timeout(self, timeout): """ + Memcached deals with long (> 30 days) timeouts in a special + way. Call this function to obtain a safe value for your timeout. + """ + if timeout > 2592000: # 60*60*24*30, 30 days + # See http://code.google.com/p/memcached/wiki/FAQ + # "You can set expire times up to 30 days in the future. After that + # memcached interprets it as a date, and will expire the item after + # said date. This is a simple (but obscure) mechanic." + # + # This means that we have to switch to absolute timestamps. + timeout += int(time.time()) + return timeout - session_class = MemcachedSession + async def get(self, key: str, app: Quart = None): + key = key.encode() + return await self.backend.get(key) - def __init__( - self, memcached, key_prefix: str, use_signer: bool = False, - permanent: bool = True, **kwargs): - super(MemcachedSessionInterface, self).__init__( - key_prefix=key_prefix, use_signer=use_signer, - permanent=permanent, **kwargs) - self.backend = memcached + async def set(self, key: str, value, expiry: int = None, + app: Quart = None): + if app and not expiry: + expiry = self._get_memcache_timeout( + total_seconds(app.permanent_session_lifetime)) - async def create(self, app: Quart) -> None: - if self.backend is None: - import aiomcache - # self.backend = aiomcache.Client("127.0.0.1", 11211) - self.backend = aiomcache.Client(self._config.get('SESSION_MEMCACHED_HOST', '127.0.0.1'), - self._config.get('SESSION_MEMCACHED_PORT', 11211)) - - def _get_memcache_timeout(self, timeout): - """ - Memcached deals with long (> 30 days) timeouts in a special - way. Call this function to obtain a safe value for your timeout. - """ - if timeout > 2592000: # 60*60*24*30, 30 days - # See http://code.google.com/p/memcached/wiki/FAQ - # "You can set expire times up to 30 days in the future. After that - # memcached interprets it as a date, and will expire the item after - # said date. This is a simple (but obscure) mechanic." - # - # This means that we have to switch to absolute timestamps. - timeout += int(time.time()) - return timeout - - async def get(self, key: str, app: Quart = None): - key = key.encode() - 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 = self._get_memcache_timeout( - total_seconds(app.permanent_session_lifetime)) - - key = key.encode() - value = value.encode() - return await self.backend.set(key=key, value=value, - exptime=expiry) - - async def delete(self, key: str, app: Quart = None): - key = key.encode() - return await self.backend.delete(key) + key = key.encode() + value = value.encode() + return await self.backend.set(key=key, value=value, + exptime=expiry) + async def delete(self, key: str, app: Quart = None): + 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 + """ + convert the session UUID to a UUID object for mongodb - example: - "session:b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8" -> - UUID("b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8") + example: + "session:b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8" -> + UUID("b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8") - """ - - @functools.wraps(func) - async def wrapper(*args, **kwargs): - if 'key' in kwargs: - key = kwargs['key'] - try: - if key.startswith('session:'): - _, _uuid = tuple(key.split(':')) - kwargs['key'] = UUID(_uuid) - except Exception as e: - current_app.logger.warning( - f"session could not be converted to a uuid object: {key}" - ) - return await func(*args, **kwargs) - return wrapper + """ + @functools.wraps(func) + async def wrapper(*args, **kwargs): + if 'key' in kwargs: + key = kwargs['key'] + try: + if key.startswith('session:'): + _, _uuid = tuple(key.split(':')) + kwargs['key'] = UUID(_uuid) + except Exception as e: + current_app.logger.warning( + f"session could not be converted to a uuid object: {key}" + ) + 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 - session_class = MongoDBSession + # mongodb does not a serializer as many object types are properly handled by the connector + serializer = None + session_class = MongoDBSession - def __init__(self, mongodb_uri, collection, client_kwargs={}, set_callback=None, **kwargs): - from motor.motor_asyncio import AsyncIOMotorClient + def __init__(self, mongodb_uri, collection, client_kwargs={}, set_callback=None, **kwargs): + from motor.motor_asyncio import AsyncIOMotorClient - super().__init__(**kwargs) - self.mongodb_uri = mongodb_uri - self.client_kwargs = client_kwargs - self.set_callback = set_callback - self._collection = collection - self._client = AsyncIOMotorClient(self.mongodb_uri, uuidRepresentation='standard', **self.client_kwargs) - self._database = self._client.get_database() + super().__init__(**kwargs) + self.mongodb_uri = mongodb_uri + self.client_kwargs = client_kwargs + self.set_callback = set_callback + self._collection = collection + self._client = AsyncIOMotorClient(self.mongodb_uri, uuidRepresentation='standard', **self.client_kwargs) + self._database = self._client.get_database() - async def create(self, app: Quart) -> None: - pass + async def create(self, app: Quart) -> None: + pass - @_convert_key_to_uuid - async def get(self, key, app): - value = await self.collection.find_one({'_id': key}, {'data': True}) - if value: - return value.get('data', {}) - else: - return None + @_convert_key_to_uuid + async def get(self, key, app): + value = await self.collection.find_one({'_id': key}, {'data': True}) + if value: + return value.get('data', {}) + else: + return None - @_convert_key_to_uuid - async def set(self, key, value, expiry=None, app=None): - doc = { - 'data': value, - } + @_convert_key_to_uuid + async def set(self, key, value, expiry=None, app=None): + doc = { + 'data': value, + } - # allows the document to be modified prior upsert - if callable(self.set_callback): - self.set_callback(doc) + # allows the document to be modified prior upsert + if callable(self.set_callback): + self.set_callback(doc) - await self.collection.update_one({ - '_id': key - }, { - '$set': doc - }, - upsert=True - ) - - @property - def collection(self): - return self._database.get_collection(self._collection) + await self.collection.update_one({ + '_id': key + }, { + '$set': doc + }, + upsert=True + ) + @property + def collection(self): + return self._database.get_collection(self._collection) class NullSessionInterface(SessionInterface): - """This class does absolutely nothing""" - session_class = NullSession + """This class does absolutely nothing""" + session_class = NullSession - def __init__( - self, key_prefix: str, use_signer: bool = False, - permanent: bool = True, **kwargs): - super(NullSessionInterface, self).__init__( - key_prefix=key_prefix, use_signer=use_signer, - permanent=permanent, **kwargs) - self.backend = None + def __init__( + self, key_prefix: str, use_signer: bool = False, + permanent: bool = True, **kwargs): + super(NullSessionInterface, self).__init__( + key_prefix=key_prefix, use_signer=use_signer, + permanent=permanent, **kwargs) + self.backend = None - async def create(self, app: Quart) -> None: - pass + async def create(self, app: Quart) -> None: + pass - async def get(self, key: str, app: Quart = None) -> None: - pass + async def get(self, key: str, app: Quart = None) -> None: + pass - async def set(self, key: str, value, expiry: int = None, - app: Quart = None) -> None: - pass + async def set(self, key: str, value, expiry: int = None, + app: Quart = None) -> None: + pass diff --git a/tests/conftest.py b/tests/conftest.py index 9823297..4c9bc14 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_redis_json.py b/tests/test_redis_json.py new file mode 100644 index 0000000..9ae9361 --- /dev/null +++ b/tests/test_redis_json.py @@ -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) diff --git a/tests/test_session_cookie.py b/tests/test_session_cookie.py index f82cc86..c9fe1cc 100644 --- a/tests/test_session_cookie.py +++ b/tests/test_session_cookie.py @@ -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 diff --git a/uv.lock b/uv.lock index c24cb34..2e9928f 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },