Skip to content

Commit

Permalink
Replace pickle with JSON
Browse files Browse the repository at this point in the history
Co-authored-by: Ivan Klass <klass.ivanklass@gmail.com>
  • Loading branch information
Mogost and ivan-klass committed Aug 16, 2024
1 parent 8c6552f commit 197e833
Show file tree
Hide file tree
Showing 12 changed files with 249 additions and 54 deletions.
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('Unsupported type', type, o)

Check warning on line 55 in constance/codecs.py

View check run for this annotation

Codecov / codecov/patch

constance/codecs.py#L55

Added line #L55 was not covered by tests
return codec[1](o['__value__'])
logger.error('Cannot deserialize object: %s', o)
raise ValueError('Invalid object', o)

Check warning on line 58 in constance/codecs.py

View check run for this annotation

Codecov / codecov/patch

constance/codecs.py#L57-L58

Added lines #L57 - L58 were not covered by tests


T = TypeVar('T')


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


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


def register_type(t: type[T], discriminator: str, encoder: Encoder[T], decoder: Decoder[T]):
if not discriminator:
raise ValueError('Discriminator must be specified')

Check warning on line 74 in constance/codecs.py

View check run for this annotation

Codecov / codecov/patch

constance/codecs.py#L74

Added line #L74 was not covered by tests
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:
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'])

Check warning on line 24 in constance/migrations/0003_drop_pickle.py

View check run for this annotation

Codecov / codecov/patch

constance/migrations/0003_drop_pickle.py#L23-L24

Added lines #L23 - L24 were not covered by tests

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)

Check warning on line 35 in constance/migrations/0003_drop_pickle.py

View check run for this annotation

Codecov / codecov/patch

constance/migrations/0003_drop_pickle.py#L35

Added line #L35 was not covered by tests
else:
_rd = redis.Redis(**settings.REDIS_CONNECTION)

Check warning on line 37 in constance/migrations/0003_drop_pickle.py

View check run for this annotation

Codecov / codecov/patch

constance/migrations/0003_drop_pickle.py#L37

Added line #L37 was not covered by tests
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

Check warning on line 43 in constance/migrations/0003_drop_pickle.py

View check run for this annotation

Codecov / codecov/patch

constance/migrations/0003_drop_pickle.py#L43

Added line #L43 was not covered by tests
for prefixed_key, value in redis_migrated_data.items():
_rd.set(prefixed_key, value)

Check warning on line 45 in constance/migrations/0003_drop_pickle.py

View check run for this annotation

Codecov / codecov/patch

constance/migrations/0003_drop_pickle.py#L45

Added line #L45 was not covered by tests


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 197e833

Please sign in to comment.