Skip to content

Commit

Permalink
Replace pickle with JSON (#564)
Browse files Browse the repository at this point in the history
* Replace pickle with JSON

Co-authored-by: Ivan Klass <klass.ivanklass@gmail.com>
  • Loading branch information
Mogost and ivan-klass authored Aug 20, 2024
1 parent ce957ac commit 3640eb2
Show file tree
Hide file tree
Showing 13 changed files with 260 additions and 54 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ source = constance
branch = 1
omit =
*/pytest.py
*/tests/*

[report]
omit = *tests*,*migrations*,.tox/*,setup.py,*settings.py
11 changes: 6 additions & 5 deletions AUTHORS
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
Ales Zoulek <ales.zoulek@gmail.com>
Alexander frenzel <alex@relatedworks.com>
Alexander Frenzel <alex@relatedworks.com>
Alexandr Artemyev <mogost@gmail.com>
Bouke Haarsma <bouke@webatoom.nl>
Camilo Nova <camilo.nova@gmail.com>
Charlie Hornsby <charlie.hornsby@hotmail.co.uk>
Curtis Maloney <curtis@tinbrain.net>
Dan Poirier <dpoirier@caktusgroup.com>
David Burke <dmbst32@gmail.com>
Dmitriy Tatarkin <mail@dtatarkin.ru>
Elisey Zanko <elisey.zanko@gmail.com>
Florian Apolloner <florian@apolloner.eu>
Igor Támara <igor@axiacore.com>
Ilya Chichak <ilyachch@gmail.com>
Ivan Klass <klass.ivanklass@gmail.com>
Jake Merdich <jmerdich@users.noreply.github.com>
Jannis Leidel <jannis@leidel.info>
Janusz Harkot <janusz.harkot@gmail.com>
Expand All @@ -32,6 +36,7 @@ Pierre-Olivier Marec <pomarec@free.fr>
Roman Krejcik <farin@farin.cz>
Silvan Spross <silvan.spross@gmail.com>
Sławek Ehlert <slafs@op.pl>
Vladas Tamoshaitis <amd.vladas@gmail.com>
Vojtech Jasny <voy@voy.cz>
Yin Jifeng <jifeng.yin@gmail.com>
illumin-us-r3v0lution <luminaries@riseup.net>
Expand All @@ -40,7 +45,3 @@ saw2th <stephen@saw2th.co.uk>
trbs <trbs@trbs.net>
vl <1844144@gmail.com>
vl <vl@u64.(none)>
Vladas Tamoshaitis <amd.vladas@gmail.com>
Dmitriy Tatarkin <mail@dtatarkin.ru>
Alexandr Artemyev <mogost@gmail.com>
Elisey Zanko <elisey.zanko@gmail.com>
14 changes: 8 additions & 6 deletions constance/backends/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from constance import settings
from constance import signals
from constance.backends import Backend
from constance.codecs import dumps
from constance.codecs import loads


class DatabaseBackend(Backend):
Expand Down Expand Up @@ -64,7 +66,7 @@ def mget(self, keys):
try:
stored = self._model._default_manager.filter(key__in=keys)
for const in stored:
yield keys[const.key], const.value
yield keys[const.key], loads(const.value)
except (OperationalError, ProgrammingError):
pass

Expand All @@ -79,7 +81,7 @@ def get(self, key):
if value is None:
match = self._model._default_manager.filter(key=key).first()
if match:
value = match.value
value = loads(match.value)
if self._cache:
self._cache.add(key, value)
return value
Expand All @@ -100,16 +102,16 @@ def set(self, key, value):
except self._model.DoesNotExist:
try:
with transaction.atomic(using=queryset.db):
queryset.create(key=key, value=value)
queryset.create(key=key, value=dumps(value))
created = True
except IntegrityError:
# Allow concurrent writes
constance = queryset.get(key=key)

if not created:
old_value = constance.value
constance.value = value
constance.save()
old_value = loads(constance.value)
constance.value = dumps(value)
constance.save(update_fields=['value'])
else:
old_value = None

Expand Down
13 changes: 6 additions & 7 deletions constance/backends/redisd.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from pickle import dumps
from pickle import loads
from threading import RLock
from time import monotonic

Expand All @@ -9,8 +7,9 @@
from constance import settings
from constance import signals
from constance import utils

from . import Backend
from constance.backends import Backend
from constance.codecs import dumps
from constance.codecs import loads


class RedisBackend(Backend):
Expand All @@ -36,7 +35,7 @@ def add_prefix(self, key):
def get(self, key):
value = self._rd.get(self.add_prefix(key))
if value:
return loads(value) # noqa: S301
return loads(value)
return None

def mget(self, keys):
Expand All @@ -45,11 +44,11 @@ def mget(self, keys):
prefixed_keys = [self.add_prefix(key) for key in keys]
for key, value in zip(keys, self._rd.mget(prefixed_keys)):
if value:
yield key, loads(value) # noqa: S301
yield key, loads(value)

def set(self, key, value):
old_value = self.get(key)
self._rd.set(self.add_prefix(key), dumps(value, protocol=settings.REDIS_PICKLE_VERSION))
self._rd.set(self.add_prefix(key), dumps(value))
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)


Expand Down
93 changes: 93 additions & 0 deletions constance/codecs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from __future__ import annotations

import json
import logging
import uuid
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from decimal import Decimal
from typing import Any
from typing import Protocol
from typing import TypeVar

logger = logging.getLogger(__name__)

DEFAULT_DISCRIMINATOR = 'default'


class JSONEncoder(json.JSONEncoder):
"""Django-constance custom json encoder."""

def default(self, o):
for discriminator, (t, _, encoder) in _codecs.items():
if isinstance(o, t):
return _as(discriminator, encoder(o))
raise TypeError(f'Object of type {o.__class__.__name__} is not JSON serializable')


def _as(discriminator: str, v: Any) -> dict[str, Any]:
return {'__type__': discriminator, '__value__': v}


def dumps(obj, _dumps=json.dumps, cls=JSONEncoder, default_kwargs=None, **kwargs):
"""Serialize object to json string."""
default_kwargs = default_kwargs or {}
is_default_type = isinstance(obj, (str, int, bool, float, type(None)))
return _dumps(
_as(DEFAULT_DISCRIMINATOR, obj) if is_default_type else obj, cls=cls, **dict(default_kwargs, **kwargs)
)


def loads(s, _loads=json.loads, **kwargs):
"""Deserialize json string to object."""
return _loads(s, object_hook=object_hook, **kwargs)


def object_hook(o: dict) -> Any:
"""Hook function to perform custom deserialization."""
if o.keys() == {'__type__', '__value__'}:
if o['__type__'] == DEFAULT_DISCRIMINATOR:
return o['__value__']
codec = _codecs.get(o['__type__'])
if not codec:
raise ValueError(f'Unsupported type: {o["__type__"]}')
return codec[1](o['__value__'])
logger.error('Cannot deserialize object: %s', o)
raise ValueError(f'Invalid object: {o}')


T = TypeVar('T')


class Encoder(Protocol[T]):
def __call__(self, value: T, /) -> str: ... # pragma: no cover


class Decoder(Protocol[T]):
def __call__(self, value: str, /) -> T: ... # pragma: no cover


def register_type(t: type[T], discriminator: str, encoder: Encoder[T], decoder: Decoder[T]):
if not discriminator:
raise ValueError('Discriminator must be specified')
if _codecs.get(discriminator) or discriminator == DEFAULT_DISCRIMINATOR:
raise ValueError(f'Type with discriminator {discriminator} is already registered')
_codecs[discriminator] = (t, decoder, encoder)


_codecs: dict[str, tuple[type, Decoder, Encoder]] = {}


def _register_default_types():
# NOTE: datetime should be registered before date, because datetime is also instance of date.
register_type(datetime, 'datetime', datetime.isoformat, datetime.fromisoformat)
register_type(date, 'date', lambda o: o.isoformat(), lambda o: datetime.fromisoformat(o).date())
register_type(time, 'time', lambda o: o.isoformat(), time.fromisoformat)
register_type(Decimal, 'decimal', str, Decimal)
register_type(uuid.UUID, 'uuid', lambda o: o.hex, uuid.UUID)
register_type(timedelta, 'timedelta', lambda o: o.total_seconds(), lambda o: timedelta(seconds=o))


_register_default_types()
3 changes: 1 addition & 2 deletions constance/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import picklefield.fields
from django.db import migrations
from django.db import models

Expand All @@ -14,7 +13,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255, unique=True)),
('value', picklefield.fields.PickledObjectField(blank=True, editable=False, null=True)),
('value', models.TextField(blank=True, editable=False, null=True)),
],
options={
'verbose_name': 'constance',
Expand Down
53 changes: 53 additions & 0 deletions constance/migrations/0003_drop_pickle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import logging
import pickle
from base64 import b64decode
from importlib import import_module

from django.db import migrations

from constance import settings
from constance.codecs import dumps

logger = logging.getLogger(__name__)


def import_module_attr(path):
package, module = path.rsplit('.', 1)
return getattr(import_module(package), module)


def migrate_pickled_data(apps, schema_editor) -> None: # pragma: no cover
Constance = apps.get_model('constance', 'Constance')

for constance in Constance.objects.exclude(value=None):
constance.value = dumps(pickle.loads(b64decode(constance.value.encode()))) # noqa: S301
constance.save(update_fields=['value'])

if settings.BACKEND in ('constance.backends.redisd.RedisBackend', 'constance.backends.redisd.CachingRedisBackend'):
import redis

_prefix = settings.REDIS_PREFIX
connection_cls = settings.REDIS_CONNECTION_CLASS
if connection_cls is not None:
_rd = import_module_attr(connection_cls)()
else:
if isinstance(settings.REDIS_CONNECTION, str):
_rd = redis.from_url(settings.REDIS_CONNECTION)
else:
_rd = redis.Redis(**settings.REDIS_CONNECTION)
redis_migrated_data = {}
for key in settings.CONFIG:
prefixed_key = f'{_prefix}{key}'
value = _rd.get(prefixed_key)
if value is not None:
redis_migrated_data[prefixed_key] = dumps(pickle.loads(value)) # noqa: S301
for prefixed_key, value in redis_migrated_data.items():
_rd.set(prefixed_key, value)


class Migration(migrations.Migration):
dependencies = [('constance', '0002_migrate_from_old_table')]

operations = [
migrations.RunPython(migrate_pickled_data),
]
12 changes: 1 addition & 11 deletions constance/models.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.utils.translation import gettext_lazy as _

try:
from picklefield import PickledObjectField
except ImportError:
raise ImproperlyConfigured(
"Couldn't find the the 3rd party app "
'django-picklefield which is required for '
'the constance database backend.'
) from None


class Constance(models.Model):
key = models.CharField(max_length=255, unique=True)
value = PickledObjectField(null=True, blank=True)
value = models.TextField(null=True, blank=True)

class Meta:
verbose_name = _('constance')
Expand Down
4 changes: 0 additions & 4 deletions constance/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import pickle

from django.conf import settings

BACKEND = getattr(settings, 'CONSTANCE_BACKEND', 'constance.backends.redisd.RedisBackend')
Expand All @@ -26,8 +24,6 @@

REDIS_CONNECTION = getattr(settings, 'CONSTANCE_REDIS_CONNECTION', {})

REDIS_PICKLE_VERSION = getattr(settings, 'CONSTANCE_REDIS_PICKLE_VERSION', pickle.DEFAULT_PROTOCOL)

SUPERUSER_ONLY = getattr(settings, 'CONSTANCE_SUPERUSER_ONLY', True)

IGNORE_ADMIN_VERSION_CHECK = getattr(settings, 'CONSTANCE_IGNORE_ADMIN_VERSION_CHECK', False)
16 changes: 1 addition & 15 deletions docs/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,6 @@ database. Defaults to ``'constance:'``. E.g.::

CONSTANCE_REDIS_PREFIX = 'constance:myproject:'

``CONSTANCE_REDIS_PICKLE_VERSION``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The (optional) protocol version of pickle you want to use to serialize your python
objects when storing in the Redis database. Defaults to ``pickle.DEFAULT_PROTOCOL``. E.g.::

CONSTANCE_REDIS_PICKLE_VERSION = pickle.DEFAULT_PROTOCOL

You might want to pin this value to a specific protocol number, since ``pickle.DEFAULT_PROTOCOL``
means different things between versions of Python.

``CONSTANCE_REDIS_CACHE_TIMEOUT``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand All @@ -94,9 +84,7 @@ Defaults to `60` seconds.
Database
--------

Database backend stores configuration values in a
standard Django model. It requires the package `django-picklefield`_ for
storing those values.
Database backend stores configuration values in a standard Django model.

You must set the ``CONSTANCE_BACKEND`` Django setting to::

Expand Down Expand Up @@ -161,8 +149,6 @@ configured cache backend to enable this feature, e.g. "default"::
simply set the :setting:`CONSTANCE_DATABASE_CACHE_AUTOFILL_TIMEOUT`
setting to ``None``.

.. _django-picklefield: https://pypi.org/project/django-picklefield/

Memory
------

Expand Down
3 changes: 0 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ classifiers = [
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Utilities",
]
dependencies = [
"django-picklefield",
]

[project.optional-dependencies]
redis = [
Expand Down
Loading

0 comments on commit 3640eb2

Please sign in to comment.