Skip to content

Commit

Permalink
Add msgspec
Browse files Browse the repository at this point in the history
  • Loading branch information
Lxstr committed Feb 15, 2024
1 parent dbf5387 commit c7f8ced
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 38 deletions.
6 changes: 4 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
Version 0.7.0
------------------

- Use msgpack for serialization, along with ``SESSION_SERIALIZATION_FORMAT``.
- Prevent sid reuse on storage miss.
- Add time-to-live expiration for MongoDB.
- Abstraction to improve consistency between backends.
- Enforce PERMANENT_SESSION_LIFETIME as expiration consistently for all backends.
- Add logo and additional Documentation.
- Add ``flask session_cleanup`` command and alternatively, SESSION_CLEANUP_N_REQUESTS for SQLAlchemy
- Add logo and additional documentation.
- Add ``flask session_cleanup`` command and alternatively, ``SESSION_CLEANUP_N_REQUESTS`` for SQLAlchemy
- Use Vary cookie header
- Type hints
- Remove null session in favour of specific exception messages.
- Deprecate ``SESSION_USE_SIGNER``.


Version 0.6.0
Expand Down
9 changes: 9 additions & 0 deletions docs/config_flask_session.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,14 @@ These are specific to Flask-Session.

Default: ``32``

.. py:data:: SESSION_SERIALIZATION_FORMAT
The serialization format to use. Can be 'msgpack' or 'json'. Set to 'msgpack' for a more efficient serialization format. Set to 'json' for a human-readable format.

Default: ``'msgpack'``

.. versionadded:: 0.7.0
``SESSION_SERIALIZATION_FORMAT``

.. versionadded:: 0.6
``SESSION_ID_LENGTH``
6 changes: 5 additions & 1 deletion docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ Install from PyPI using an installer such as pip:
$ pip install Flask-Session
Flask-Session has no dependencies other than those included with Flask. However, unless you are using the FileSystemCache, you need to choose and a backend and install an appropriate client library.
Flask-Session's only required dependency is msgspec for serialization, which has no sub-dependencies.

.. note::

You need to choose and a backend and install an appropriate client library, unless you are using the FileSystemCache.

For example, if you want to use Redis as your backend, you will need to install the redis-py client library:

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
requires-python = ">=3.7"
dependencies = [
"flask>=2.2",
"cachelib",
"msgspec>=0.18.6",
]
dynamic = ["version"]

Expand Down
4 changes: 2 additions & 2 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Core
flask>=2.2
cachelib
msgspec

# Linting
ruff
Expand All @@ -14,4 +14,4 @@ redis
python-memcached
Flask-SQLAlchemy
pymongo

cachelib
6 changes: 5 additions & 1 deletion src/flask_session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ def _get_interface(self, app):
SESSION_SID_LENGTH = config.get(
"SESSION_ID_LENGTH", Defaults.SESSION_SID_LENGTH
)
SESSION_SERIALIZATION_FORMAT = config.get(
"SESSION_SERIALIZATION_FORMAT", Defaults.SESSION_SERIALIZATION_FORMAT
)

# Redis settings
SESSION_REDIS = config.get("SESSION_REDIS", Defaults.SESSION_REDIS)
Expand Down Expand Up @@ -116,6 +119,7 @@ def _get_interface(self, app):
"use_signer": SESSION_USE_SIGNER,
"permanent": SESSION_PERMANENT,
"sid_length": SESSION_SID_LENGTH,
"serialization_format": SESSION_SERIALIZATION_FORMAT,
}

if SESSION_TYPE == "redis":
Expand Down Expand Up @@ -153,6 +157,6 @@ def _get_interface(self, app):
cleanup_n_requests=SESSION_CLEANUP_N_REQUESTS,
)
else:
raise RuntimeError(f"Unrecognized value for SESSION_TYPE: {SESSION_TYPE}")
raise ValueError(f"Unrecognized value for SESSION_TYPE: {SESSION_TYPE}")

return session_interface
1 change: 1 addition & 0 deletions src/flask_session/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Defaults:
SESSION_USE_SIGNER = False
SESSION_PERMANENT = True
SESSION_SID_LENGTH = 32
SESSION_SERIALIZATION_FORMAT = "msgpack"

# Clean up settings for non TTL backends (SQL, PostgreSQL, etc.)
SESSION_CLEANUP_N_REQUESTS = None
Expand Down
97 changes: 66 additions & 31 deletions src/flask_session/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@
import time
import warnings
from abc import ABC

try:
import cPickle as pickle
except ImportError:
import pickle
import random
import msgspec
from datetime import datetime
from datetime import timedelta as TimeDelta
from typing import Any, Optional
Expand Down Expand Up @@ -89,6 +85,12 @@ def _sign(self, app, sid: str) -> str:
sid_as_bytes = want_bytes(sid)
return signer.sign(sid_as_bytes).decode("utf-8")

def _serialize(self, session: ServerSideSession) -> bytes:
return self.encoder.encode(dict(session))

def _deserialize(self, serialized_data):
return self.decoder.decode(serialized_data)

def _get_store_id(self, sid: str) -> str:
return self.key_prefix + sid

Expand All @@ -107,6 +109,7 @@ def __init__(
use_signer: bool = Defaults.SESSION_USE_SIGNER,
permanent: bool = Defaults.SESSION_PERMANENT,
sid_length: int = Defaults.SESSION_SID_LENGTH,
serialization_format: str = Defaults.SESSION_SERIALIZATION_FORMAT,
cleanup_n_requests: Optional[int] = Defaults.SESSION_CLEANUP_N_REQUESTS,
):
self.app = app
Expand All @@ -131,6 +134,18 @@ def __init__(
else:
self._register_cleanup_app_command()

# Set the serialization format
if serialization_format == "msgpack":
self.decoder = msgspec.msgpack.Decoder()
self.encoder = msgspec.msgpack.Encoder()
elif serialization_format == "json":
self.decoder = msgspec.json.Decoder()
self.encoder = msgspec.json.Encoder()
else:
raise ValueError(
"Unrecognized value for SESSION_SERIALIZATION_FORMAT: {SESSION_SERIALIZATION_FORMAT}"
)

def save_session(
self, app: Flask, session: ServerSideSession, response: Response
) -> None:
Expand Down Expand Up @@ -264,7 +279,6 @@ class RedisSessionInterface(ServerSideSessionInterface):
The `use_signer` parameter was added.
"""

serializer = pickle
session_class = RedisSession
ttl = True

Expand All @@ -275,25 +289,30 @@ def __init__(
use_signer: bool,
permanent: bool,
sid_length: int,
serialization_format: str,
redis: Any = Defaults.SESSION_REDIS,
):
if redis is None:
from redis import Redis

redis = Redis()
self.redis = redis
super().__init__(app, key_prefix, use_signer, permanent, sid_length)
super().__init__(
app, key_prefix, use_signer, permanent, sid_length, serialization_format
)

@retry_query()
def _retrieve_session_data(self, store_id: str) -> Optional[dict]:
# Get the saved session (value) from the database
serialized_session_data = self.redis.get(store_id)
if serialized_session_data:
try:
session_data = self.serializer.loads(serialized_session_data)
session_data = self._deserialize(serialized_session_data)
return session_data
except pickle.UnpicklingError:
self.app.logger.error("Failed to unpickle session data", exc_info=True)
except msgspec.DecodeError:
self.app.logger.error(
"Failed to deserialize session data", exc_info=True
)
return None

@retry_query()
Expand All @@ -307,7 +326,7 @@ def _upsert_session(
storage_time_to_live = total_seconds(session_lifetime)

# Serialize the session data
serialized_session_data = self.serializer.dumps(dict(session))
serialized_session_data = self._serialize(dict(session))

# Update existing or create new session in the database
self.redis.set(
Expand All @@ -333,7 +352,6 @@ class MemcachedSessionInterface(ServerSideSessionInterface):
The `use_signer` parameter was added.
"""

serializer = pickle
session_class = MemcachedSession
ttl = True

Expand All @@ -344,12 +362,15 @@ def __init__(
use_signer: bool,
permanent: bool,
sid_length: int,
serialization_format: str,
client: Any = Defaults.SESSION_MEMCACHED,
):
if client is None:
client = self._get_preferred_memcache_client()
self.client = client
super().__init__(app, key_prefix, use_signer, permanent, sid_length)
super().__init__(
app, key_prefix, use_signer, permanent, sid_length, serialization_format
)

def _get_preferred_memcache_client(self):
clients = [
Expand Down Expand Up @@ -384,10 +405,12 @@ def _retrieve_session_data(self, store_id: str) -> Optional[dict]:
serialized_session_data = self.client.get(store_id)
if serialized_session_data:
try:
session_data = self.serializer.loads(serialized_session_data)
session_data = self._deserialize(serialized_session_data)
return session_data
except pickle.UnpicklingError:
self.app.logger.error("Failed to unpickle session data", exc_info=True)
except msgspec.DecodeError:
self.app.logger.error(
"Failed to deserialize session data", exc_info=True
)
return None

@retry_query()
Expand All @@ -401,7 +424,7 @@ def _upsert_session(
storage_time_to_live = total_seconds(session_lifetime)

# Serialize the session data
serialized_session_data = self.serializer.dumps(dict(session))
serialized_session_data = self._serialize(dict(session))

# Update existing or create new session in the database
self.client.set(
Expand Down Expand Up @@ -431,7 +454,6 @@ class FileSystemSessionInterface(ServerSideSessionInterface):
"""

session_class = FileSystemSession
serializer = None
ttl = True

def __init__(
Expand All @@ -441,14 +463,17 @@ def __init__(
use_signer: bool,
permanent: bool,
sid_length: int,
serialization_format: str,
cache_dir: str = Defaults.SESSION_FILE_DIR,
threshold: int = Defaults.SESSION_FILE_THRESHOLD,
mode: int = Defaults.SESSION_FILE_MODE,
):
from cachelib.file import FileSystemCache

self.cache = FileSystemCache(cache_dir, threshold=threshold, mode=mode)
super().__init__(app, key_prefix, use_signer, permanent, sid_length)
super().__init__(
app, key_prefix, use_signer, permanent, sid_length, serialization_format
)

@retry_query()
def _retrieve_session_data(self, store_id: str) -> Optional[dict]:
Expand Down Expand Up @@ -494,7 +519,6 @@ class MongoDBSessionInterface(ServerSideSessionInterface):
The `use_signer` parameter was added.
"""

serializer = pickle
session_class = MongoDBSession
ttl = True

Expand All @@ -505,6 +529,7 @@ def __init__(
use_signer: bool,
permanent: bool,
sid_length: int,
serialization_format: str,
client: Any = Defaults.SESSION_MONGODB,
db: str = Defaults.SESSION_MONGODB_DB,
collection: str = Defaults.SESSION_MONGODB_COLLECT,
Expand All @@ -521,7 +546,9 @@ def __init__(
# Create a TTL index on the expiration time, so that mongo can automatically delete expired sessions
self.store.create_index("expiration", expireAfterSeconds=0)

super().__init__(app, key_prefix, use_signer, permanent, sid_length)
super().__init__(
app, key_prefix, use_signer, permanent, sid_length, serialization_format
)

@retry_query()
def _retrieve_session_data(self, store_id: str) -> Optional[dict]:
Expand All @@ -530,10 +557,12 @@ def _retrieve_session_data(self, store_id: str) -> Optional[dict]:
if document:
serialized_session_data = want_bytes(document["val"])
try:
session_data = self.serializer.loads(serialized_session_data)
session_data = self._deserialize(serialized_session_data)
return session_data
except pickle.UnpicklingError:
self.app.logger.error("Failed to unpickle session data", exc_info=True)
except msgspec.DecodeError:
self.app.logger.error(
"Failed to deserialize session data", exc_info=True
)
return None

@retry_query()
Expand All @@ -550,7 +579,7 @@ def _upsert_session(
storage_expiration_datetime = datetime.utcnow() + session_lifetime

# Serialize the session data
serialized_session_data = self.serializer.dumps(dict(session))
serialized_session_data = self._serialize(dict(session))

# Update existing or create new session in the database
if self.use_deprecated_method:
Expand Down Expand Up @@ -602,7 +631,6 @@ class SqlAlchemySessionInterface(ServerSideSessionInterface):
The `use_signer` parameter was added.
"""

serializer = pickle
session_class = SqlAlchemySession
ttl = False

Expand All @@ -613,6 +641,7 @@ def __init__(
use_signer: bool,
permanent: bool,
sid_length: int,
serialization_format: str,
db: Any = Defaults.SESSION_SQLALCHEMY,
table: str = Defaults.SESSION_SQLALCHEMY_TABLE,
sequence: Optional[str] = Defaults.SESSION_SQLALCHEMY_SEQUENCE,
Expand All @@ -630,7 +659,13 @@ def __init__(
self.schema = schema
self.bind_key = bind_key
super().__init__(
app, key_prefix, use_signer, permanent, sid_length, cleanup_n_requests
app,
key_prefix,
use_signer,
permanent,
sid_length,
serialization_format,
cleanup_n_requests,
)

# Create the Session database model
Expand Down Expand Up @@ -698,11 +733,11 @@ def _retrieve_session_data(self, store_id: str) -> Optional[dict]:
if record:
serialized_session_data = want_bytes(record.data)
try:
session_data = self.serializer.loads(serialized_session_data)
session_data = self._deserialize(serialized_session_data)
return session_data
except pickle.UnpicklingError as e:
except msgspec.DecodeError as e:
self.app.logger.exception(
e, "Failed to unpickle session data", exc_info=True
e, "Failed to deserialize session data", exc_info=True
)
return None

Expand All @@ -722,7 +757,7 @@ def _upsert_session(
storage_expiration_datetime = datetime.utcnow() + session_lifetime

# Serialize session data
serialized_session_data = self.serializer.dumps(dict(session))
serialized_session_data = self._serialize(dict(session))

# Update existing or create new session in the database
try:
Expand Down

0 comments on commit c7f8ced

Please sign in to comment.