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:
+131
-131
@@ -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
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Native JSON storage for the asyncio Redis session backend."""
|
||||
|
||||
import json
|
||||
|
||||
async def check_support(backend):
|
||||
"""Fail at startup rather than silently falling back to string storage."""
|
||||
commands = await backend.execute_command("COMMAND", "INFO", "JSON.GET", "JSON.SET")
|
||||
entries = list(commands.values()) if isinstance(commands, dict) else commands
|
||||
if len(entries) != 2 or not all(entries):
|
||||
raise RuntimeError("SESSION_REDIS_JSON requires JSON.GET and JSON.SET support")
|
||||
|
||||
async def get(backend, key):
|
||||
"""Read legacy strings as well as native JSON documents."""
|
||||
from redis.exceptions import ResponseError
|
||||
|
||||
try:
|
||||
return await backend.get(key)
|
||||
except ResponseError as error:
|
||||
if not str(error).startswith("WRONGTYPE"):
|
||||
raise
|
||||
# A single root path returns the object, not a JSONPath result array.
|
||||
return await backend.execute_command("JSON.GET", key, ".")
|
||||
|
||||
async def set(backend, key, value, expiry):
|
||||
"""Replace a string or JSON key and apply expiry in one transaction."""
|
||||
# Validate before deleting an existing key. Preserve TaggedJSONSerializer's
|
||||
# representation so tuples, datetimes, etc. still round-trip normally.
|
||||
document = json.loads(value)
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("Native Redis JSON sessions require a JSON object")
|
||||
async with backend.pipeline(transaction=True) as pipeline:
|
||||
pipeline.delete(key)
|
||||
pipeline.execute_command("JSON.SET", key, "$", value)
|
||||
if expiry is not None:
|
||||
pipeline.expire(key, expiry)
|
||||
results = await pipeline.execute()
|
||||
return results[1]
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
Code here is borrowed from [alekseyev/trio-redis](https://github.com/alekseyev/trio-redis), which was originally developed over at [Bogdanp/trio-redis](https://github.com/Bogdanp/trio-redis).
|
||||
|
||||
Since it has no active maintainers and no PyPI package - I am including it as-is.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
+377
-378
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user