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:
+7
-1
@@ -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
|
### 4.0.0
|
||||||
|
|
||||||
- **Breaking:** require Python 3.15 or newer (`requires-python = ">=3.15"`, classifier, `.python-version`).
|
- **Breaking:** require Python 3.15 or newer (`requires-python = ">=3.15"`, classifier, `.python-version`).
|
||||||
@@ -41,7 +47,7 @@
|
|||||||
### 1.0.0 2020-01-15
|
### 1.0.0 2020-01-15
|
||||||
|
|
||||||
- Added support for arbitrary usage of caching backends.
|
- 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_REVERSE_PROXY` to `SESSION_REVERSE_PROXY`.
|
||||||
- Renamed `SESSION_HIJACK_PROTECTION` to `SESSION_PROTECTION`.
|
- Renamed `SESSION_HIJACK_PROTECTION` to `SESSION_PROTECTION`.
|
||||||
- Removed fallback when `X-Forwarded-For` is not present whilst USING `SESSION_REVERSE_PROXY`, emit error instead.
|
- Removed fallback when `X-Forwarded-For` is not present whilst USING `SESSION_REVERSE_PROXY`, emit error instead.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
# Quart-Session
|
# Quart-Session
|
||||||
|
|
||||||
 [](https://pypi.org/project/Quart-Session/) 
|
 [](https://pypi.org/project/Quart-Session/) 
|
||||||
|
|
||||||
Quart-Session is an extension for [Quart](https://gitlab.com/pgjones/quart/blob/master/README.rst) that adds support for
|
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/).
|
Based on [flask-session](https://pypi.org/project/Flask-Session/).
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Quart-Session can be installed via pipenv or pip,
|
Quart-Session can be installed via pipenv or pip,
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -28,21 +26,19 @@ Session(app)
|
|||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
async def hello():
|
async def hello():
|
||||||
session["foo"] = "bar"
|
session["foo"] = "bar"
|
||||||
return "session key 'foo' set"
|
return "session key 'foo' set"
|
||||||
|
|
||||||
@app.route('/foo')
|
@app.route('/foo')
|
||||||
async def foo():
|
async def foo():
|
||||||
return session.get("foo", "session key 'foo' not found")
|
return session.get("foo", "session key 'foo' not found")
|
||||||
|
|
||||||
app.run()
|
app.run()
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
||||||
### Redis
|
### Redis
|
||||||
|
|
||||||
via `redis>=4.4.0`.
|
via `redis>=4.4.0`.
|
||||||
|
|
||||||
```python3
|
```python3
|
||||||
@@ -68,18 +64,42 @@ app.config['SESSION_TYPE'] = 'redis'
|
|||||||
|
|
||||||
@app.before_serving
|
@app.before_serving
|
||||||
async def setup():
|
async def setup():
|
||||||
cache = await aioredis.Redis(
|
cache = await aioredis.Redis(
|
||||||
host="foobar.com",
|
host="foobar.com",
|
||||||
port=6379,
|
port=6379,
|
||||||
password="foobar"
|
password="foobar"
|
||||||
)
|
)
|
||||||
|
|
||||||
app.config['SESSION_REDIS'] = cache
|
app.config['SESSION_REDIS'] = cache
|
||||||
Session(app)
|
Session(app)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Trio
|
#### Native JSON storage (Redis / Valkey)
|
||||||
|
By default, session values are JSON-encoded **strings**. To store `session:*`
|
||||||
|
keys as native JSON documents instead:
|
||||||
|
|
||||||
|
```python3
|
||||||
|
app.config['SESSION_TYPE'] = 'redis'
|
||||||
|
app.config['SESSION_REDIS_JSON'] = True
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires a Redis or Valkey server with `JSON.GET` and `JSON.SET` support
|
||||||
|
(e.g. Redis with JSON support or Valkey with a compatible JSON module).
|
||||||
|
It is opt-in and supported only by the asyncio Redis backend. Unsupported
|
||||||
|
servers fail during startup rather than silently falling back to strings.
|
||||||
|
The Redis account needs permission for the JSON commands, `COMMAND INFO`,
|
||||||
|
`GET`, `DEL`, `EXPIRE`, and transactions.
|
||||||
|
|
||||||
|
Existing string sessions remain readable and are converted on their next save;
|
||||||
|
untouched keys are not migrated. Expiry is applied in the same transaction as
|
||||||
|
the JSON write. The tagged JSON serializer is retained, including support for
|
||||||
|
extended Python types. Custom serializers must produce JSON object strings.
|
||||||
|
Disabling this option does not convert JSON keys back into strings.
|
||||||
|
|
||||||
|
For example, inspect the stored object with `JSON.GET session:<sid> $` rather
|
||||||
|
than `GET session:<sid>`.
|
||||||
|
|
||||||
|
#### Trio
|
||||||
Quart-Session comes with [an (experimental) Redis client](quart_session/redis_trio) for use with the [Trio](https://trio.readthedocs.io/en/stable/) eventloop.
|
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
|
```python3
|
||||||
@@ -92,7 +112,6 @@ Session(app)
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Memcached
|
### Memcached
|
||||||
|
|
||||||
via `aiomcache`.
|
via `aiomcache`.
|
||||||
|
|
||||||
```python3
|
```python3
|
||||||
@@ -102,7 +121,6 @@ Session(app)
|
|||||||
```
|
```
|
||||||
|
|
||||||
### MongoDB
|
### MongoDB
|
||||||
|
|
||||||
via `motor`.
|
via `motor`.
|
||||||
|
|
||||||
```python3
|
```python3
|
||||||
@@ -114,7 +132,6 @@ Session(app)
|
|||||||
```
|
```
|
||||||
|
|
||||||
### JSON serializer
|
### JSON serializer
|
||||||
|
|
||||||
[flask-session](https://pypi.org/project/Flask-Session/) uses `pickle`
|
[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)
|
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`,
|
capable of serializing the usual JSON types, as well as: `Tuple`, `Bytes`,
|
||||||
@@ -132,32 +149,29 @@ app.config['SESSION_TYPE'] = 'redis'
|
|||||||
Session(app)
|
Session(app)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import cPickle as pickle
|
import cPickle as pickle
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
app.session_interface.serialize = pickle
|
app.session_interface.serialize = pickle
|
||||||
```
|
```
|
||||||
|
|
||||||
### Back-end usage
|
### Back-end usage
|
||||||
|
|
||||||
At any point you may interface with the session back-end directly:
|
At any point you may interface with the session back-end directly:
|
||||||
|
|
||||||
```python3
|
```python3
|
||||||
from quart_session.sessions import SessionInterface
|
from quart_session.sessions import SessionInterface
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
async def hello():
|
async def hello():
|
||||||
cache: SessionInterface = app.session_interface
|
cache: SessionInterface = app.session_interface
|
||||||
await cache.set("random_key", "val", expiry=3600)
|
await cache.set("random_key", "val", expiry=3600)
|
||||||
data = await cache.get("random_key")
|
data = await cache.get("random_key")
|
||||||
```
|
```
|
||||||
|
|
||||||
The interface will have the `get`, `set`, and `delete` methods available (regardless of
|
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).
|
back-end - similar to how [aiocache](https://github.com/argaen/aiocache) works).
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
[flask-session](https://pypi.org/project/Flask-Session/) sets a
|
[flask-session](https://pypi.org/project/Flask-Session/) sets a
|
||||||
session for each incoming request, including static files. From experience,
|
session for each incoming request, including static files. From experience,
|
||||||
this often puts unneeded load on underlying session infrastructure,
|
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
|
static file serves never emit a `Set-Cookie` header. If you'd like to enable
|
||||||
this though, set `SESSION_STATIC_FILE` to `True`.
|
this though, set `SESSION_STATIC_FILE` to `True`.
|
||||||
|
|
||||||
|
|
||||||
### Session pinning
|
### Session pinning
|
||||||
|
|
||||||
Associates an user's session to his/her IP address. This mitigates cookie stealing via XSS etc, and is handy
|
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.
|
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`.
|
by explicitly setting `SESSION_REVERSE_PROXY` to `True`.
|
||||||
|
|
||||||
## Future development
|
## Future development
|
||||||
|
|
||||||
- `FileSystemSessionInterface`
|
- `FileSystemSessionInterface`
|
||||||
- `GoogleCloudDatastoreSessionInterface`
|
- `GoogleCloudDatastoreSessionInterface`
|
||||||
- Pytest
|
- Pytest
|
||||||
|
|
||||||
## Flask-Session
|
## Flask-Session
|
||||||
|
|
||||||
This library works very similarly to [flask-session](https://pypi.org/project/Flask-Session/).
|
This library works very similarly to [flask-session](https://pypi.org/project/Flask-Session/).
|
||||||
The changes are specified below:
|
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 every request.
|
||||||
- Quart-Session does not emit a `Set-Cookie` on static file serves.
|
- 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`.
|
- 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".
|
- Quart-Session might not have all the back-end interfaces implemented (yet), such as "filesystem".
|
||||||
|
|
||||||
## Help
|
## Help
|
||||||
|
|
||||||
Find the Quart folk on [gitter](https://gitter.im/python-quart/lobby) or open an issue.
|
Find the Quart folk on [gitter](https://gitter.im/python-quart/lobby) or open an issue.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
BSD
|
BSD
|
||||||
|
|||||||
+9
-14
@@ -1,35 +1,30 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Hello
|
Hello
|
||||||
~~~~~
|
~~~~~
|
||||||
|
|
||||||
Quart-Session demo.
|
Quart-Session demo.
|
||||||
|
|
||||||
:copyright: (c) 2020 by Kroket Ltd.
|
:copyright: (c) 2020 by Kroket Ltd.
|
||||||
:license: BSD, see LICENSE for more details.
|
:license: BSD, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
from quart import Quart, session
|
from quart import Quart, session
|
||||||
from quart_session import Session
|
from quart_session import Session
|
||||||
|
|
||||||
|
|
||||||
SESSION_TYPE = 'redis'
|
SESSION_TYPE = 'redis'
|
||||||
|
|
||||||
|
|
||||||
app = Quart(__name__)
|
app = Quart(__name__)
|
||||||
app.config.from_object(__name__)
|
app.config.from_object(__name__)
|
||||||
Session(app)
|
Session(app)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/set/')
|
@app.route('/set/')
|
||||||
async def set():
|
async def set():
|
||||||
session['key'] = 'value'
|
session['key'] = 'value'
|
||||||
return 'ok'
|
return 'ok'
|
||||||
|
|
||||||
|
|
||||||
@app.route('/get/')
|
@app.route('/get/')
|
||||||
async def get():
|
async def get():
|
||||||
return session.get('key', 'not set')
|
return session.get('key', 'not set')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "quart-session"
|
name = "quart-session"
|
||||||
version = "4.0.0"
|
version = "4.1.0"
|
||||||
description = "Server-side sessions for Quart"
|
description = "Server-side sessions for Quart"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.15"
|
requires-python = ">=3.15"
|
||||||
|
|||||||
+131
-131
@@ -1,167 +1,167 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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) 2014 by Shipeng Feng.
|
||||||
:copyright: (c) 2020 by Kroket Ltd.
|
:copyright: (c) 2020 by Kroket Ltd.
|
||||||
:license: BSD, see LICENSE for more details.
|
:license: BSD, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
||||||
|
|
||||||
try:
|
try:
|
||||||
__version__ = _pkg_version("quart-session")
|
__version__ = _pkg_version("quart-session")
|
||||||
except PackageNotFoundError: # running from a source tree without install
|
except PackageNotFoundError: # running from a source tree without install
|
||||||
__version__ = "unknown"
|
__version__ = "unknown"
|
||||||
|
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
|
|
||||||
from .sessions import (
|
from .sessions import (
|
||||||
RedisSessionInterface,
|
RedisSessionInterface,
|
||||||
RedisTrioSessionInterface,
|
RedisTrioSessionInterface,
|
||||||
MemcachedSessionInterface,
|
MemcachedSessionInterface,
|
||||||
MongoDBSessionInterface,
|
MongoDBSessionInterface,
|
||||||
NullSessionInterface
|
NullSessionInterface
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Session(object):
|
class Session(object):
|
||||||
"""This class is used to add Server-side Session to one or more Quart
|
"""This class is used to add Server-side Session to one or more Quart
|
||||||
applications.
|
applications.
|
||||||
|
|
||||||
There are two usage modes. One is initialize the instance with a very
|
There are two usage modes. One is initialize the instance with a very
|
||||||
specific Quart application::
|
specific Quart application::
|
||||||
|
|
||||||
app = Quart(__name__)
|
app = Quart(__name__)
|
||||||
Session(app)
|
Session(app)
|
||||||
|
|
||||||
The second possibility is to create the object once and configure the
|
The second possibility is to create the object once and configure the
|
||||||
application later::
|
application later::
|
||||||
|
|
||||||
sess = Session()
|
sess = Session()
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Quart(__name__)
|
app = Quart(__name__)
|
||||||
sess.init_app(app)
|
sess.init_app(app)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
By default Quart-Session will use :class:`NullSessionInterface`, you
|
By default Quart-Session will use :class:`NullSessionInterface`, you
|
||||||
really should configure your app to use a different SessionInterface.
|
really should configure your app to use a different SessionInterface.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
You can not use ``Session`` instance directly, what ``Session`` does
|
You can not use ``Session`` instance directly, what ``Session`` does
|
||||||
is just change the :attr:`~quart.Quart.session_interface` attribute on
|
is just change the :attr:`~quart.Quart.session_interface` attribute on
|
||||||
your Quart applications.
|
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:
|
@app.before_serving
|
||||||
self._current_async_library = "asyncio"
|
async def setup():
|
||||||
self.app = app
|
await app.session_interface.create(app)
|
||||||
if app is not None:
|
|
||||||
self.init_app(app)
|
|
||||||
|
|
||||||
def init_app(self, app: Quart) -> None:
|
def _get_interface(self, app: Quart):
|
||||||
"""This is used to set up session for your app object.
|
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.
|
if isinstance(config.get("SESSION_HIJACK_PROTECTION"), bool):
|
||||||
"""
|
app.logger.warning("Deprecation: `SESSION_HIJACK_PROTECTION` "
|
||||||
try:
|
"has been renamed to `SESSION_PROTECTION`")
|
||||||
import quart_trio
|
|
||||||
if isinstance(app, quart_trio.QuartTrio):
|
|
||||||
self._current_async_library = "trio"
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
app.session_interface = self._get_interface(app)
|
|
||||||
|
|
||||||
@app.before_serving
|
if isinstance(config.get("SESSION_HIJACK_REVERSE_PROXY"), str):
|
||||||
async def setup():
|
app.logger.warning("Deprecation: `SESSION_HIJACK_REVERSE_PROXY` "
|
||||||
await app.session_interface.create(app)
|
"has been renamed to `SESSION_REVERSE_PROXY`")
|
||||||
|
|
||||||
def _get_interface(self, app: Quart):
|
backend_warning = f"Please specify a session backend. " \
|
||||||
config = app.config.copy()
|
f"Available interfaces: redis, redis+trio, " \
|
||||||
config.setdefault('SESSION_TYPE', 'null')
|
f"memcached, null. e.g: app.config['SESSION_TYPE'] = 'redis'"
|
||||||
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_')}
|
|
||||||
|
|
||||||
if isinstance(config.get("SESSION_HIJACK_PROTECTION"), bool):
|
if config['SESSION_TYPE'] == 'redis':
|
||||||
app.logger.warning("Deprecation: `SESSION_HIJACK_PROTECTION` "
|
options = {
|
||||||
"has been renamed to `SESSION_PROTECTION`")
|
"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):
|
if self._current_async_library == "asyncio":
|
||||||
app.logger.warning("Deprecation: `SESSION_HIJACK_REVERSE_PROXY` "
|
session_interface = RedisSessionInterface(**options)
|
||||||
"has been renamed to `SESSION_REVERSE_PROXY`")
|
elif self._current_async_library == "trio":
|
||||||
|
session_interface = RedisTrioSessionInterface(**options)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError("Unknown eventloop")
|
||||||
|
|
||||||
backend_warning = f"Please specify a session backend. " \
|
elif config['SESSION_TYPE'] == 'redis+trio':
|
||||||
f"Available interfaces: redis, redis+trio, " \
|
session_interface = RedisTrioSessionInterface(
|
||||||
f"memcached, null. e.g: app.config['SESSION_TYPE'] = 'redis'"
|
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':
|
return session_interface
|
||||||
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
|
|
||||||
|
|||||||
@@ -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).
|
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.
|
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
|
```python3
|
||||||
from quart_session.redis_trio import RedisTrio
|
from quart_session.redis_trio import RedisTrio
|
||||||
cache = 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.connect()
|
||||||
|
|
||||||
await cache.setex(key="foo", value=42, seconds=300)
|
await cache.setex(key="foo", value=42, seconds=300)
|
||||||
@@ -20,8 +18,8 @@ Or,
|
|||||||
|
|
||||||
```python3
|
```python3
|
||||||
async with RedisTrio() as cache:
|
async with RedisTrio() as cache:
|
||||||
await cache.set("foo", 42)
|
await cache.set("foo", 42)
|
||||||
await cache.get("foo")
|
await cache.get("foo")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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) 2017 by Bogdan Paul Popa.
|
||||||
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
||||||
:copyright: (c) 2020 by dsc.
|
:copyright: (c) 2020 by dsc.
|
||||||
:license: BSD, see LICENSE for more details.
|
:license: BSD, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from quart_session.redis_trio.client import RedisTrio
|
from quart_session.redis_trio.client import RedisTrio
|
||||||
|
|||||||
@@ -1,88 +1,87 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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) 2017 by Bogdan Paul Popa.
|
||||||
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
||||||
:copyright: (c) 2020 by dsc.
|
:copyright: (c) 2020 by dsc.
|
||||||
:license: BSD, see LICENSE for more details.
|
:license: BSD, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
from .connection import RedisConnection
|
from .connection import RedisConnection
|
||||||
|
|
||||||
|
|
||||||
class RedisTrio:
|
class RedisTrio:
|
||||||
"""A simple Redis+Trio client.
|
"""A simple Redis+Trio client.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
addr(str): The IP address the Redis server is listening on.
|
addr(str): The IP address the Redis server is listening on.
|
||||||
port(int): The port the Redis server is listening on.
|
port(int): The port the Redis server is listening on.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
>>> async with RedisTrio() as redis:
|
>>> async with RedisTrio() as redis:
|
||||||
... await redis.set("foo", 42)
|
... await redis.set("foo", 42)
|
||||||
... await redis.get("foo")
|
... await redis.get("foo")
|
||||||
b'42'
|
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""):
|
async def close(self):
|
||||||
self.conn = RedisConnection(addr, port)
|
"""Close the connection to the Redis server.
|
||||||
self.password = password
|
"""
|
||||||
|
await self.quit()
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
async def connect(self):
|
async def auth(self, password):
|
||||||
"""Open a connection to the Redis server.
|
return await self.conn.process_command_ok(b"AUTH", password)
|
||||||
|
|
||||||
Returns:
|
async def delete(self, *keys):
|
||||||
RedisTrio: This instance.
|
return await self.conn.process_command(b"DEL", *keys)
|
||||||
"""
|
|
||||||
await self.conn.connect()
|
|
||||||
if self.password:
|
|
||||||
await self.auth(self.password)
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def close(self):
|
async def echo(self, message):
|
||||||
"""Close the connection to the Redis server.
|
return await self.conn.process_command(b"ECHO", message)
|
||||||
"""
|
|
||||||
await self.quit()
|
|
||||||
self.conn.close()
|
|
||||||
|
|
||||||
async def auth(self, password):
|
async def flushall(self):
|
||||||
return await self.conn.process_command_ok(b"AUTH", password)
|
return await self.conn.process_command_ok(b"FLUSHALL")
|
||||||
|
|
||||||
async def delete(self, *keys):
|
async def get(self, key) -> bytes:
|
||||||
return await self.conn.process_command(b"DEL", *keys)
|
return await self.conn.process_command(b"GET", key)
|
||||||
|
|
||||||
async def echo(self, message):
|
async def quit(self):
|
||||||
return await self.conn.process_command(b"ECHO", message)
|
return await self.conn.process_command(b"QUIT")
|
||||||
|
|
||||||
async def flushall(self):
|
async def set(self, key, value):
|
||||||
return await self.conn.process_command_ok(b"FLUSHALL")
|
return await self.conn.process_command_ok(b"SET", key, value)
|
||||||
|
|
||||||
async def get(self, key) -> bytes:
|
async def setex(self, key: str, value: str, seconds: int):
|
||||||
return await self.conn.process_command(b"GET", key)
|
"""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_ok(b"SETEX", key, seconds, value)
|
||||||
return await self.conn.process_command(b"QUIT")
|
|
||||||
|
|
||||||
async def set(self, key, value):
|
async def __aenter__(self):
|
||||||
return await self.conn.process_command_ok(b"SET", key, value)
|
return await self.connect()
|
||||||
|
|
||||||
async def setex(self, key: str, value: str, seconds: int):
|
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||||
"""Set the value and expiration of a key.
|
self.close()
|
||||||
: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()
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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) 2017 by Bogdan Paul Popa.
|
||||||
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
||||||
:copyright: (c) 2020 by dsc.
|
:copyright: (c) 2020 by dsc.
|
||||||
:license: BSD, see LICENSE for more details.
|
:license: BSD, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Union
|
from typing import Union
|
||||||
@@ -18,7 +18,6 @@ import trio
|
|||||||
from .serialization import atom, serialize
|
from .serialization import atom, serialize
|
||||||
from .errors import ProtocolError, ResponseError, ResponseTypeError
|
from .errors import ProtocolError, ResponseError, ResponseTypeError
|
||||||
|
|
||||||
|
|
||||||
SP = ord("+")
|
SP = ord("+")
|
||||||
EP = ord("-")
|
EP = ord("-")
|
||||||
IP = ord(":")
|
IP = ord(":")
|
||||||
@@ -28,109 +27,107 @@ AP = ord("*")
|
|||||||
#: The set of known Redis response prefixes.
|
#: The set of known Redis response prefixes.
|
||||||
known_prefixes = {SP, EP, IP, BP, AP}
|
known_prefixes = {SP, EP, IP, BP, AP}
|
||||||
|
|
||||||
|
|
||||||
class ReadMore(Exception):
|
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:
|
class RedisConnection:
|
||||||
"""This class facilitates all communication with Redis via a trio socket.
|
"""This class facilitates all communication with Redis via a trio socket.
|
||||||
Warning:
|
Warning:
|
||||||
The interface of this class may change at any time, without
|
The interface of this class may change at any time, without
|
||||||
notice, due to the experimental nature of Trio.
|
notice, due to the experimental nature of Trio.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, addr: Union[bytes, str], port: int, bufsize: int = 16384):
|
def __init__(self, addr: Union[bytes, str], port: int, bufsize: int = 16384):
|
||||||
self.addr = (addr, port)
|
self.addr = (addr, port)
|
||||||
self.sock = trio.socket.socket()
|
self.sock = trio.socket.socket()
|
||||||
self.bufsize = bufsize
|
self.bufsize = bufsize
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
await self.sock.connect(self.addr)
|
await self.sock.connect(self.addr)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.sock.close()
|
self.sock.close()
|
||||||
|
|
||||||
async def send_command(self, command, *args):
|
async def send_command(self, command, *args):
|
||||||
command_and_args = (serialize(arg) for arg in (atom(command),) + args)
|
command_and_args = (serialize(arg) for arg in (atom(command),) + args)
|
||||||
data = b" ".join(command_and_args) + b"\r\n"
|
data = b" ".join(command_and_args) + b"\r\n"
|
||||||
await self.sock.send(data)
|
await self.sock.send(data)
|
||||||
|
|
||||||
async def process_command(self, *command_and_args):
|
async def process_command(self, *command_and_args):
|
||||||
await self.send_command(*command_and_args)
|
await self.send_command(*command_and_args)
|
||||||
return await self.process_response()
|
return await self.process_response()
|
||||||
|
|
||||||
async def process_command_ok(self, *command_and_args):
|
async def process_command_ok(self, *command_and_args):
|
||||||
await self.send_command(*command_and_args)
|
await self.send_command(*command_and_args)
|
||||||
return await self.process_response() == b"OK"
|
return await self.process_response() == b"OK"
|
||||||
|
|
||||||
async def process_response(self):
|
async def process_response(self):
|
||||||
data = await self.sock.recv(self.bufsize)
|
data = await self.sock.recv(self.bufsize)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
item, _ = await self.parse(data)
|
item, _ = await self.parse(data)
|
||||||
return item
|
return item
|
||||||
except ReadMore:
|
except ReadMore:
|
||||||
data += await self.sock.recv(self.bufsize)
|
data += await self.sock.recv(self.bufsize)
|
||||||
|
|
||||||
async def parse(self, data):
|
async def parse(self, data):
|
||||||
try:
|
try:
|
||||||
index = data.index(b"\r\n")
|
index = data.index(b"\r\n")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ReadMore()
|
raise ReadMore()
|
||||||
|
|
||||||
if data[0] not in known_prefixes:
|
if data[0] not in known_prefixes:
|
||||||
raise ProtocolError(f"Unexpected data in response: {data!r}.")
|
raise ProtocolError(f"Unexpected data in response: {data!r}.")
|
||||||
|
|
||||||
elif data[0] == SP:
|
elif data[0] == SP:
|
||||||
return data[1:index], data[index + 2:]
|
return data[1:index], data[index + 2:]
|
||||||
|
|
||||||
elif data[0] == EP:
|
elif data[0] == EP:
|
||||||
error = data[1:index].decode("ascii")
|
error = data[1:index].decode("ascii")
|
||||||
if error.startswith("WRONGTYPE"):
|
if error.startswith("WRONGTYPE"):
|
||||||
raise ResponseTypeError(error[len("WRONGTYPE "):])
|
raise ResponseTypeError(error[len("WRONGTYPE "):])
|
||||||
|
|
||||||
elif error.startswith("ERR"):
|
elif error.startswith("ERR"):
|
||||||
raise ResponseError(error[len("ERR "):])
|
raise ResponseError(error[len("ERR "):])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ResponseError(error)
|
raise ResponseError(error)
|
||||||
|
|
||||||
elif data[0] == IP:
|
elif data[0] == IP:
|
||||||
return int(data[1:index]), data[index + 2:]
|
return int(data[1:index]), data[index + 2:]
|
||||||
|
|
||||||
elif data[0] == BP:
|
elif data[0] == BP:
|
||||||
length, data = int(data[1:index]), data[index + 2:]
|
length, data = int(data[1:index]), data[index + 2:]
|
||||||
if length == -1:
|
if length == -1:
|
||||||
return None, data
|
return None, data
|
||||||
|
|
||||||
elif len(data) < length + 2:
|
elif len(data) < length + 2:
|
||||||
raise ReadMore()
|
raise ReadMore()
|
||||||
|
|
||||||
return data[:length], data[length + 2:]
|
return data[:length], data[length + 2:]
|
||||||
|
|
||||||
elif data[0] == AP:
|
elif data[0] == AP:
|
||||||
length, data = int(data[1:index]), data[index + 2:]
|
length, data = int(data[1:index]), data[index + 2:]
|
||||||
if length == -1:
|
if length == -1:
|
||||||
return None, data
|
return None, data
|
||||||
|
|
||||||
return await self.parse_array(length, data)
|
return await self.parse_array(length, data)
|
||||||
|
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
assert False, "unreachable"
|
assert False, "unreachable"
|
||||||
|
|
||||||
async def parse_array(self, length, data):
|
async def parse_array(self, length, data):
|
||||||
items = []
|
items = []
|
||||||
while len(items) < length:
|
while len(items) < length:
|
||||||
if not data:
|
if not data:
|
||||||
data += await self.sock.recv(self.bufsize)
|
data += await self.sock.recv(self.bufsize)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item, data = await self.parse(data)
|
item, data = await self.parse(data)
|
||||||
items.append(item)
|
items.append(item)
|
||||||
except ReadMore:
|
except ReadMore:
|
||||||
data += await self.sock.recv(self.bufsize)
|
data += await self.sock.recv(self.bufsize)
|
||||||
|
|
||||||
return items, data
|
return items, data
|
||||||
|
|||||||
@@ -1,33 +1,29 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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) 2017 by Bogdan Paul Popa.
|
||||||
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
||||||
:copyright: (c) 2020 by dsc.
|
:copyright: (c) 2020 by dsc.
|
||||||
:license: BSD, see LICENSE for more details.
|
:license: BSD, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class RedisError(Exception):
|
class RedisError(Exception):
|
||||||
"""Base class for all Redis-related errors.
|
"""Base class for all Redis-related errors.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class ProtocolError(RedisError):
|
class ProtocolError(RedisError):
|
||||||
"""Raised when Redis responds with something that doesn't conform
|
"""Raised when Redis responds with something that doesn't conform
|
||||||
to the protocol.
|
to the protocol.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class ResponseError(RedisError):
|
class ResponseError(RedisError):
|
||||||
"""Raised when Redis returns an error response.
|
"""Raised when Redis returns an error response.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class ResponseTypeError(ResponseError):
|
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 -*-
|
# -*- 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) 2017 by Bogdan Paul Popa.
|
||||||
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
:copyright: (c) 2019 by Oleksii Aleksieiev.
|
||||||
:copyright: (c) 2020 by dsc.
|
:copyright: (c) 2020 by dsc.
|
||||||
:license: BSD, see LICENSE for more details.
|
:license: BSD, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
@@ -19,42 +19,39 @@ atom = namedtuple("atom", ("value",))
|
|||||||
#: The set of characters that must be escaped before being sent as
|
#: The set of characters that must be escaped before being sent as
|
||||||
#: Redis strings.
|
#: Redis strings.
|
||||||
escapes = {
|
escapes = {
|
||||||
ord(b"\0"): rb"\x00",
|
ord(b"\0"): rb"\x00",
|
||||||
ord(b"\n"): rb"\n",
|
ord(b"\n"): rb"\n",
|
||||||
ord(b"\r"): rb"\r",
|
ord(b"\r"): rb"\r",
|
||||||
ord(b"\\"): rb"\\",
|
ord(b"\\"): rb"\\",
|
||||||
ord(b'"'): rb'\"',
|
ord(b'"'): rb'\"',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize(x):
|
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:
|
Parameters:
|
||||||
x(object): The value to serialize.
|
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))
|
|
||||||
|
|
||||||
|
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):
|
def quote(bs):
|
||||||
return b'"' + bytes(escape(bs)) + b'"'
|
return b'"' + bytes(escape(bs)) + b'"'
|
||||||
|
|
||||||
|
|
||||||
def escape(bs):
|
def escape(bs):
|
||||||
for c in bs:
|
for c in bs:
|
||||||
if c in escapes:
|
if c in escapes:
|
||||||
yield from escapes[c]
|
yield from escapes[c]
|
||||||
else:
|
else:
|
||||||
yield c
|
yield c
|
||||||
|
|||||||
+377
-378
@@ -1,13 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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) 2014 by Shipeng Feng.
|
||||||
:copyright: (c) 2020 by Kroket Ltd.
|
:copyright: (c) 2020 by Kroket Ltd.
|
||||||
:license: BSD, see LICENSE for more details.
|
:license: BSD, see LICENSE for more details.
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -22,454 +22,453 @@ from quart.sessions import SessionInterface as QuartSessionInterface, SecureCook
|
|||||||
from quart.json.tag import TaggedJSONSerializer
|
from quart.json.tag import TaggedJSONSerializer
|
||||||
from itsdangerous import Signer, BadSignature, want_bytes
|
from itsdangerous import Signer, BadSignature, want_bytes
|
||||||
|
|
||||||
|
|
||||||
def total_seconds(td):
|
def total_seconds(td):
|
||||||
return td.days * 60 * 60 * 24 + td.seconds
|
return td.days * 60 * 60 * 24 + td.seconds
|
||||||
|
|
||||||
|
|
||||||
class ServerSideSession(SecureCookieSession):
|
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):
|
def __init__(self, initial=None, sid=None, permanent=None, addr=None):
|
||||||
super(ServerSideSession, self).__init__(initial or {})
|
super(ServerSideSession, self).__init__(initial or {})
|
||||||
self.sid = sid
|
self.sid = sid
|
||||||
if permanent:
|
if permanent:
|
||||||
self.permanent = permanent
|
self.permanent = permanent
|
||||||
if addr:
|
if addr:
|
||||||
self.addr = addr
|
self.addr = addr
|
||||||
self.modified = False
|
self.modified = False
|
||||||
|
|
||||||
def dirty(self):
|
def dirty(self):
|
||||||
current_app.logger.warning("Deprecation: `dirty()` has "
|
current_app.logger.warning("Deprecation: `dirty()` has "
|
||||||
"been made obsolete. Will be "
|
"been made obsolete. Will be "
|
||||||
"removed soon^tm.")
|
"removed soon^tm.")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def addr(self) -> str:
|
def addr(self) -> str:
|
||||||
return self.get('_addr', False) # type: ignore
|
return self.get('_addr', False) # type: ignore
|
||||||
|
|
||||||
@addr.setter
|
|
||||||
def addr(self, value: str) -> None:
|
|
||||||
self['_addr'] = value # type: ignore
|
|
||||||
|
|
||||||
|
@addr.setter
|
||||||
|
def addr(self, value: str) -> None:
|
||||||
|
self['_addr'] = value # type: ignore
|
||||||
|
|
||||||
class RedisSession(ServerSideSession):
|
class RedisSession(ServerSideSession):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MemcachedSession(ServerSideSession):
|
class MemcachedSession(ServerSideSession):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MongoDBSession(ServerSideSession):
|
class MongoDBSession(ServerSideSession):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NullSession(ServerSideSession):
|
class NullSession(ServerSideSession):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SessionInterface(QuartSessionInterface):
|
class SessionInterface(QuartSessionInterface):
|
||||||
"""Baseclass for session interfaces"""
|
"""Baseclass for session interfaces"""
|
||||||
|
|
||||||
serializer = TaggedJSONSerializer()
|
serializer = TaggedJSONSerializer()
|
||||||
session_class = None
|
session_class = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
key_prefix: str,
|
key_prefix: str,
|
||||||
use_signer: bool = False,
|
use_signer: bool = False,
|
||||||
permanent: bool = True,
|
permanent: bool = True,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> None:
|
) -> None:
|
||||||
self.key_prefix = key_prefix
|
self.key_prefix = key_prefix
|
||||||
self.use_signer = use_signer
|
self.use_signer = use_signer
|
||||||
self.permanent = permanent
|
self.permanent = permanent
|
||||||
self._config = kwargs
|
self._config = kwargs
|
||||||
|
|
||||||
async def open_session(
|
async def open_session(
|
||||||
self,
|
self,
|
||||||
app: Quart,
|
app: Quart,
|
||||||
request: BaseRequestWebsocket
|
request: BaseRequestWebsocket
|
||||||
) -> Optional[SecureCookieSession]:
|
) -> Optional[SecureCookieSession]:
|
||||||
cname = app.config.get('SESSION_COOKIE_NAME', 'session')
|
cname = app.config.get('SESSION_COOKIE_NAME', 'session')
|
||||||
sid = request.cookies.get(cname)
|
sid = request.cookies.get(cname)
|
||||||
if self._config['SESSION_REVERSE_PROXY'] is True:
|
if self._config['SESSION_REVERSE_PROXY'] is True:
|
||||||
# and no, you cannot define your own incoming
|
# and no, you cannot define your own incoming
|
||||||
# header, stick to standards :-)
|
# header, stick to standards :-)
|
||||||
addr = request.headers.get('X-Forwarded-For')
|
addr = request.headers.get('X-Forwarded-For')
|
||||||
if not addr:
|
if not addr:
|
||||||
app.logger.error("Could not grab IP from reverse proxy, "
|
app.logger.error("Could not grab IP from reverse proxy, "
|
||||||
"session protection is DISABLED!")
|
"session protection is DISABLED!")
|
||||||
else:
|
else:
|
||||||
addr = request.remote_addr
|
addr = request.remote_addr
|
||||||
options = {"sid": sid, "permanent": self.permanent, "addr": addr}
|
options = {"sid": sid, "permanent": self.permanent, "addr": addr}
|
||||||
|
|
||||||
if not sid:
|
if not sid:
|
||||||
options['sid'] = self._generate_sid()
|
options['sid'] = self._generate_sid()
|
||||||
return self.session_class(**options)
|
return self.session_class(**options)
|
||||||
if self.use_signer:
|
if self.use_signer:
|
||||||
signer = self._get_signer(app)
|
signer = self._get_signer(app)
|
||||||
if signer is None:
|
if signer is None:
|
||||||
app.logger.warning("Failed to obtain a valid signer.")
|
app.logger.warning("Failed to obtain a valid signer.")
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
sid_as_bytes = signer.unsign(sid)
|
sid_as_bytes = signer.unsign(sid)
|
||||||
sid = sid_as_bytes.decode()
|
sid = sid_as_bytes.decode()
|
||||||
except BadSignature:
|
except BadSignature:
|
||||||
app.logger.warning(f"Bad signature for sid: {sid}.")
|
app.logger.warning(f"Bad signature for sid: {sid}.")
|
||||||
options['sid'] = self._generate_sid()
|
options['sid'] = self._generate_sid()
|
||||||
return self.session_class(**options)
|
return self.session_class(**options)
|
||||||
|
|
||||||
val = await self.get(key=self.key_prefix + sid, app=app)
|
val = await self.get(key=self.key_prefix + sid, app=app)
|
||||||
if val is None:
|
if val is None:
|
||||||
options['sid'] = self._generate_sid()
|
options['sid'] = self._generate_sid()
|
||||||
return self.session_class(**options)
|
return self.session_class(**options)
|
||||||
|
|
||||||
if self.serializer is None:
|
if self.serializer is None:
|
||||||
data = val
|
data = val
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
data = self.serializer.loads(val)
|
data = self.serializer.loads(val)
|
||||||
except:
|
except:
|
||||||
app.logger.warning(f"Failed to deserialize session "
|
app.logger.warning(f"Failed to deserialize session "
|
||||||
f"data for sid: {sid}. Generating new sid.")
|
f"data for sid: {sid}. Generating new sid.")
|
||||||
app.logger.debug(f"data: {val}")
|
app.logger.debug(f"data: {val}")
|
||||||
options['sid'] = self._generate_sid()
|
options['sid'] = self._generate_sid()
|
||||||
return self.session_class(**options)
|
return self.session_class(**options)
|
||||||
|
|
||||||
protection = self._config['SESSION_PROTECTION']
|
protection = self._config['SESSION_PROTECTION']
|
||||||
if protection is True and addr is not None and \
|
if protection is True and addr is not None and \
|
||||||
data.get('_addr', addr) != addr:
|
data.get('_addr', addr) != addr:
|
||||||
await self.delete(key=self.key_prefix + sid, app=app)
|
await self.delete(key=self.key_prefix + sid, app=app)
|
||||||
options['sid'] = self._generate_sid()
|
options['sid'] = self._generate_sid()
|
||||||
return self.session_class(**options)
|
return self.session_class(**options)
|
||||||
|
|
||||||
res = self.session_class(data, sid)
|
res = self.session_class(data, sid)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def save_session( # type: ignore
|
async def save_session( # type: ignore
|
||||||
self,
|
self,
|
||||||
app: "Quart",
|
app: "Quart",
|
||||||
session: SecureCookieSession,
|
session: SecureCookieSession,
|
||||||
response: Response
|
response: Response
|
||||||
) -> None:
|
) -> None:
|
||||||
# prevent set-cookie on unmodified session objects
|
# prevent set-cookie on unmodified session objects
|
||||||
if not session.modified:
|
if not session.modified:
|
||||||
return
|
return
|
||||||
|
|
||||||
# prevent set-cookie on (static) file responses
|
# prevent set-cookie on (static) file responses
|
||||||
# https://github.com/fengsp/flask-session/pull/70
|
# https://github.com/fengsp/flask-session/pull/70
|
||||||
if self._config['SESSION_STATIC_FILE'] is False and \
|
if self._config['SESSION_STATIC_FILE'] is False and \
|
||||||
isinstance(response.response, FileBody):
|
isinstance(response.response, FileBody):
|
||||||
return
|
return
|
||||||
|
|
||||||
cname = app.config.get('SESSION_COOKIE_NAME', 'session')
|
cname = app.config.get('SESSION_COOKIE_NAME', 'session')
|
||||||
session_key = self.key_prefix + session.sid
|
session_key = self.key_prefix + session.sid
|
||||||
domain = self.get_cookie_domain(app)
|
domain = self.get_cookie_domain(app)
|
||||||
path = self.get_cookie_path(app)
|
path = self.get_cookie_path(app)
|
||||||
if not session:
|
if not session:
|
||||||
if session.modified:
|
if session.modified:
|
||||||
await self.delete(key=session_key, app=app)
|
await self.delete(key=session_key, app=app)
|
||||||
response.delete_cookie(cname,
|
response.delete_cookie(cname,
|
||||||
domain=domain, path=path)
|
domain=domain, path=path)
|
||||||
return
|
return
|
||||||
httponly = self.get_cookie_httponly(app)
|
httponly = self.get_cookie_httponly(app)
|
||||||
samesite = self.get_cookie_samesite(app)
|
samesite = self.get_cookie_samesite(app)
|
||||||
secure = self.get_cookie_secure(app)
|
secure = self.get_cookie_secure(app)
|
||||||
expires = self.get_expiration_time(app, session)
|
expires = self.get_expiration_time(app, session)
|
||||||
|
|
||||||
if self.serializer is None:
|
if self.serializer is None:
|
||||||
val = dict(session)
|
val = dict(session)
|
||||||
else:
|
else:
|
||||||
val = self.serializer.dumps(dict(session))
|
val = self.serializer.dumps(dict(session))
|
||||||
|
|
||||||
await self.set(key=session_key, value=val, app=app)
|
await self.set(key=session_key, value=val, app=app)
|
||||||
if self.use_signer:
|
if self.use_signer:
|
||||||
signer = self._get_signer(app)
|
signer = self._get_signer(app)
|
||||||
if signer is None:
|
if signer is None:
|
||||||
app.logger.warning("Failed to obtain a valid signer, "
|
app.logger.warning("Failed to obtain a valid signer, "
|
||||||
"not setting a session cookie.")
|
"not setting a session cookie.")
|
||||||
return
|
return
|
||||||
session_id = signer.sign(want_bytes(session.sid)).decode('utf-8')
|
session_id = signer.sign(want_bytes(session.sid)).decode('utf-8')
|
||||||
else:
|
else:
|
||||||
session_id = session.sid
|
session_id = session.sid
|
||||||
response.set_cookie(cname, session_id,
|
response.set_cookie(cname, session_id,
|
||||||
expires=expires, httponly=httponly,
|
expires=expires, httponly=httponly,
|
||||||
domain=domain, path=path, secure=secure, samesite=samesite)
|
domain=domain, path=path, secure=secure, samesite=samesite)
|
||||||
|
|
||||||
async def create(self, app: Quart):
|
async def create(self, app: Quart):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
async def get(self, key: str, app: Quart = None):
|
async def get(self, key: str, app: Quart = None):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
async def set(self, key: str, value, expiry: int = None,
|
async def set(self, key: str, value, expiry: int = None,
|
||||||
app: Quart = None):
|
app: Quart = None):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
async def delete(self, key: str, app: Quart = None):
|
async def delete(self, key: str, app: Quart = None):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def _generate_sid(self) -> str:
|
def _generate_sid(self) -> str:
|
||||||
return str(uuid4())
|
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 _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):
|
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 redis: ``aioredis.Redis`` instance.
|
||||||
:param key_prefix: A prefix that is added to all Redis store keys.
|
: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 use_signer: Whether to sign the session id cookie or not.
|
||||||
:param permanent: Whether to use permanent session or not.
|
:param permanent: Whether to use permanent session or not.
|
||||||
:param kwargs: Quart-session config, used internally.
|
: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):
|
async def set(self, key: str, value, expiry: int = None,
|
||||||
super(RedisSessionInterface, self).__init__(**kwargs)
|
app: Quart = None):
|
||||||
self.backend = redis
|
if app and not expiry:
|
||||||
|
expiry = total_seconds(app.permanent_session_lifetime)
|
||||||
async def create(self, app: Quart) -> None:
|
if self.native_json:
|
||||||
"""Creates ``aioredis.Redis`` instance.
|
from .redis_json import set
|
||||||
|
return await set(self.backend, key, value, expiry)
|
||||||
.. note::
|
return await self.backend.setex(
|
||||||
|
name=key, value=value,
|
||||||
Creates a single Redis connection, you might prefer
|
time=expiry)
|
||||||
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 delete(self, key: str, app: Quart = None):
|
||||||
|
return await self.backend.delete(key)
|
||||||
|
|
||||||
class RedisTrioSessionInterface(SessionInterface):
|
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 redis: ``quart_session.redis_trio.RedisTrio`` instance.
|
||||||
:param key_prefix: A prefix that is added to all Redis store keys.
|
: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 use_signer: Whether to sign the session id cookie or not.
|
||||||
:param permanent: Whether to use permanent session or not.
|
:param permanent: Whether to use permanent session or not.
|
||||||
:param kwargs: Quart-session config, used internally.
|
: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):
|
async def set(self, key: str, value, expiry: int = None,
|
||||||
super(RedisTrioSessionInterface, self).__init__(**kwargs)
|
app: Quart = None):
|
||||||
self.backend = redis
|
if app and not expiry:
|
||||||
|
expiry = total_seconds(app.permanent_session_lifetime)
|
||||||
async def create(self, app: Quart) -> None:
|
return await self.backend.setex(
|
||||||
"""Creates ``aioredis.Redis`` instance.
|
key=key, value=value,
|
||||||
|
seconds=expiry)
|
||||||
.. 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 delete(self, key: str, app: Quart = None):
|
||||||
|
return await self.backend.delete(key)
|
||||||
|
|
||||||
class MemcachedSessionInterface(SessionInterface):
|
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 client: ``aiomcache.Client`` instance.
|
||||||
:param key_prefix: A prefix that is added to all Redis store keys.
|
: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 use_signer: Whether to sign the session id cookie or not.
|
||||||
:param permanent: Whether to use permanent session or not.
|
:param permanent: Whether to use permanent session or not.
|
||||||
:param kwargs: Quart-session config, used internally.
|
: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__(
|
async def set(self, key: str, value, expiry: int = None,
|
||||||
self, memcached, key_prefix: str, use_signer: bool = False,
|
app: Quart = None):
|
||||||
permanent: bool = True, **kwargs):
|
if app and not expiry:
|
||||||
super(MemcachedSessionInterface, self).__init__(
|
expiry = self._get_memcache_timeout(
|
||||||
key_prefix=key_prefix, use_signer=use_signer,
|
total_seconds(app.permanent_session_lifetime))
|
||||||
permanent=permanent, **kwargs)
|
|
||||||
self.backend = memcached
|
|
||||||
|
|
||||||
async def create(self, app: Quart) -> None:
|
key = key.encode()
|
||||||
if self.backend is None:
|
value = value.encode()
|
||||||
import aiomcache
|
return await self.backend.set(key=key, value=value,
|
||||||
# self.backend = aiomcache.Client("127.0.0.1", 11211)
|
exptime=expiry)
|
||||||
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)
|
|
||||||
|
|
||||||
|
async def delete(self, key: str, app: Quart = None):
|
||||||
|
key = key.encode()
|
||||||
|
return await self.backend.delete(key)
|
||||||
|
|
||||||
def _convert_key_to_uuid(func):
|
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:
|
example:
|
||||||
"session:b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8" ->
|
"session:b8ebbf02-cc7a-4b0b-824f-22a984c8c0b8" ->
|
||||||
UUID("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):
|
class MongoDBSessionInterface(SessionInterface):
|
||||||
# mongodb does not a serializer as many object types are properly handled by the connector
|
# mongodb does not a serializer as many object types are properly handled by the connector
|
||||||
serializer = None
|
serializer = None
|
||||||
session_class = MongoDBSession
|
session_class = MongoDBSession
|
||||||
|
|
||||||
def __init__(self, mongodb_uri, collection, client_kwargs={}, set_callback=None, **kwargs):
|
def __init__(self, mongodb_uri, collection, client_kwargs={}, set_callback=None, **kwargs):
|
||||||
from motor.motor_asyncio import AsyncIOMotorClient
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.mongodb_uri = mongodb_uri
|
self.mongodb_uri = mongodb_uri
|
||||||
self.client_kwargs = client_kwargs
|
self.client_kwargs = client_kwargs
|
||||||
self.set_callback = set_callback
|
self.set_callback = set_callback
|
||||||
self._collection = collection
|
self._collection = collection
|
||||||
self._client = AsyncIOMotorClient(self.mongodb_uri, uuidRepresentation='standard', **self.client_kwargs)
|
self._client = AsyncIOMotorClient(self.mongodb_uri, uuidRepresentation='standard', **self.client_kwargs)
|
||||||
self._database = self._client.get_database()
|
self._database = self._client.get_database()
|
||||||
|
|
||||||
async def create(self, app: Quart) -> None:
|
async def create(self, app: Quart) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@_convert_key_to_uuid
|
@_convert_key_to_uuid
|
||||||
async def get(self, key, app):
|
async def get(self, key, app):
|
||||||
value = await self.collection.find_one({'_id': key}, {'data': True})
|
value = await self.collection.find_one({'_id': key}, {'data': True})
|
||||||
if value:
|
if value:
|
||||||
return value.get('data', {})
|
return value.get('data', {})
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@_convert_key_to_uuid
|
@_convert_key_to_uuid
|
||||||
async def set(self, key, value, expiry=None, app=None):
|
async def set(self, key, value, expiry=None, app=None):
|
||||||
doc = {
|
doc = {
|
||||||
'data': value,
|
'data': value,
|
||||||
}
|
}
|
||||||
|
|
||||||
# allows the document to be modified prior upsert
|
# allows the document to be modified prior upsert
|
||||||
if callable(self.set_callback):
|
if callable(self.set_callback):
|
||||||
self.set_callback(doc)
|
self.set_callback(doc)
|
||||||
|
|
||||||
await self.collection.update_one({
|
await self.collection.update_one({
|
||||||
'_id': key
|
'_id': key
|
||||||
}, {
|
}, {
|
||||||
'$set': doc
|
'$set': doc
|
||||||
},
|
},
|
||||||
upsert=True
|
upsert=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def collection(self):
|
|
||||||
return self._database.get_collection(self._collection)
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def collection(self):
|
||||||
|
return self._database.get_collection(self._collection)
|
||||||
|
|
||||||
class NullSessionInterface(SessionInterface):
|
class NullSessionInterface(SessionInterface):
|
||||||
"""This class does absolutely nothing"""
|
"""This class does absolutely nothing"""
|
||||||
session_class = NullSession
|
session_class = NullSession
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, key_prefix: str, use_signer: bool = False,
|
self, key_prefix: str, use_signer: bool = False,
|
||||||
permanent: bool = True, **kwargs):
|
permanent: bool = True, **kwargs):
|
||||||
super(NullSessionInterface, self).__init__(
|
super(NullSessionInterface, self).__init__(
|
||||||
key_prefix=key_prefix, use_signer=use_signer,
|
key_prefix=key_prefix, use_signer=use_signer,
|
||||||
permanent=permanent, **kwargs)
|
permanent=permanent, **kwargs)
|
||||||
self.backend = None
|
self.backend = None
|
||||||
|
|
||||||
async def create(self, app: Quart) -> None:
|
async def create(self, app: Quart) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def get(self, key: str, app: Quart = None) -> None:
|
async def get(self, key: str, app: Quart = None) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def set(self, key: str, value, expiry: int = None,
|
async def set(self, key: str, value, expiry: int = None,
|
||||||
app: Quart = None) -> None:
|
app: Quart = None) -> None:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+27
-27
@@ -7,39 +7,39 @@ from quart_session import Session
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app_factory():
|
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:
|
def _factory(**config) -> Quart:
|
||||||
app = Quart(__name__)
|
app = Quart(__name__)
|
||||||
app.secret_key = "test-secret-key"
|
app.secret_key = "test-secret-key"
|
||||||
app.config["SESSION_TYPE"] = "null"
|
app.config["SESSION_TYPE"] = "null"
|
||||||
app.config.update(config)
|
app.config.update(config)
|
||||||
Session(app)
|
Session(app)
|
||||||
|
|
||||||
# 'null' interface is a no-op store, give it a dict so that
|
# 'null' interface is a no-op store, give it a dict so that
|
||||||
# open_session() can actually find data again.
|
# open_session() can actually find data again.
|
||||||
store: dict = {}
|
store: dict = {}
|
||||||
interface = app.session_interface
|
interface = app.session_interface
|
||||||
|
|
||||||
async def _get(key: str, app: Optional[Quart] = None):
|
async def _get(key: str, app: Optional[Quart] = None):
|
||||||
return store.get(key)
|
return store.get(key)
|
||||||
|
|
||||||
async def _set(key: str, value, expiry: Optional[int] = None,
|
async def _set(key: str, value, expiry: Optional[int] = None,
|
||||||
app: Optional[Quart] = None):
|
app: Optional[Quart] = None):
|
||||||
store[key] = value
|
store[key] = value
|
||||||
|
|
||||||
async def _delete(key: str, app: Optional[Quart] = None):
|
async def _delete(key: str, app: Optional[Quart] = None):
|
||||||
store.pop(key, None)
|
store.pop(key, None)
|
||||||
|
|
||||||
interface.get = _get
|
interface.get = _get
|
||||||
interface.set = _set
|
interface.set = _set
|
||||||
interface.delete = _delete
|
interface.delete = _delete
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
async def index():
|
async def index():
|
||||||
session["hits"] = session.get("hits", 0) + 1
|
session["hits"] = session.get("hits", 0) + 1
|
||||||
return {"hits": session["hits"], "sid": session.sid}
|
return {"hits": session["hits"], "sid": session.sid}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
return _factory
|
return _factory
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -2,59 +2,59 @@ import pytest
|
|||||||
from itsdangerous import Signer
|
from itsdangerous import Signer
|
||||||
|
|
||||||
def _cookie_value(response, name="session"):
|
def _cookie_value(response, name="session"):
|
||||||
header = response.headers.get("Set-Cookie")
|
header = response.headers.get("Set-Cookie")
|
||||||
assert header is not None, "no Set-Cookie header was sent"
|
assert header is not None, "no Set-Cookie header was sent"
|
||||||
assert header.startswith(f"{name}=")
|
assert header.startswith(f"{name}=")
|
||||||
return header[len(name) + 1:].split(";", 1)[0]
|
return header[len(name) + 1:].split(";", 1)[0]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unsigned_cookie_is_plain_sid(app_factory):
|
async def test_unsigned_cookie_is_plain_sid(app_factory):
|
||||||
"""Regression: str sid used to blow up with AttributeError on .decode()."""
|
"""Regression: str sid used to blow up with AttributeError on .decode()."""
|
||||||
app = app_factory(SESSION_USE_SIGNER=False)
|
app = app_factory(SESSION_USE_SIGNER=False)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.get("/")
|
response = await client.get("/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
assert data["hits"] == 1
|
assert data["hits"] == 1
|
||||||
assert _cookie_value(response) == data["sid"]
|
assert _cookie_value(response) == data["sid"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_signed_cookie_is_signed_sid(app_factory):
|
async def test_signed_cookie_is_signed_sid(app_factory):
|
||||||
app = app_factory(SESSION_USE_SIGNER=True)
|
app = app_factory(SESSION_USE_SIGNER=True)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.get("/")
|
response = await client.get("/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = await response.get_json()
|
data = await response.get_json()
|
||||||
cookie = _cookie_value(response)
|
cookie = _cookie_value(response)
|
||||||
assert cookie != data["sid"]
|
assert cookie != data["sid"]
|
||||||
|
|
||||||
signer = Signer(app.secret_key, salt="quart-session",
|
signer = Signer(app.secret_key, salt="quart-session",
|
||||||
key_derivation="hmac")
|
key_derivation="hmac")
|
||||||
assert signer.unsign(cookie).decode() == data["sid"]
|
assert signer.unsign(cookie).decode() == data["sid"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("use_signer", [False, True])
|
@pytest.mark.parametrize("use_signer", [False, True])
|
||||||
async def test_session_survives_next_request(app_factory, use_signer):
|
async def test_session_survives_next_request(app_factory, use_signer):
|
||||||
app = app_factory(SESSION_USE_SIGNER=use_signer)
|
app = app_factory(SESSION_USE_SIGNER=use_signer)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
first = await client.get("/")
|
first = await client.get("/")
|
||||||
second = await client.get("/")
|
second = await client.get("/")
|
||||||
|
|
||||||
assert (await first.get_json())["hits"] == 1
|
assert (await first.get_json())["hits"] == 1
|
||||||
assert (await second.get_json())["hits"] == 2
|
assert (await second.get_json())["hits"] == 2
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_signer_without_secret_key_sets_no_cookie(app_factory):
|
async def test_signer_without_secret_key_sets_no_cookie(app_factory):
|
||||||
app = app_factory(SESSION_USE_SIGNER=True)
|
app = app_factory(SESSION_USE_SIGNER=True)
|
||||||
app.secret_key = None
|
app.secret_key = None
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
response = await client.get("/")
|
response = await client.get("/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers.get("Set-Cookie") is None
|
assert response.headers.get("Set-Cookie") is None
|
||||||
|
|||||||
@@ -296,11 +296,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "3.11"
|
version = "3.12"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
@@ -411,7 +411,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quart-session"
|
name = "quart-session"
|
||||||
version = "4.0.0"
|
version = "4.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "quart" },
|
{ name = "quart" },
|
||||||
|
|||||||
Reference in New Issue
Block a user