Collections support (#581)

---------

Co-authored-by: Sebastian Manger <manger@netcloud.ch>
This commit is contained in:
Alexandr Artemyev 2024-09-04 14:41:53 +05:00 committed by GitHub
parent 31c9e8d043
commit bb0dc4676f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 93 additions and 15 deletions

View file

@ -34,14 +34,20 @@ def _as(discriminator: str, v: Any) -> dict[str, Any]:
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)))
is_default_type = isinstance(obj, (list, dict, 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):
def loads(s, _loads=json.loads, *, first_level=True, **kwargs):
"""Deserialize json string to object."""
if first_level:
return _loads(s, object_hook=object_hook, **kwargs)
if isinstance(s, dict) and '__type__' not in s and '__value__' not in s:
return {k: loads(v, first_level=False) for k, v in s.items()}
if isinstance(s, list):
return list(loads(v, first_level=False) for v in s)
return _loads(s, object_hook=object_hook, **kwargs)
@ -54,6 +60,8 @@ def object_hook(o: dict) -> Any:
if not codec:
raise ValueError(f'Unsupported type: {o["__type__"]}')
return codec[1](o['__value__'])
if '__type__' not in o and '__value__' not in o:
return o
logger.error('Cannot deserialize object: %s', o)
raise ValueError(f'Invalid object: {o}')

View file

@ -10,6 +10,9 @@ configuration values. By default it uses the Redis backend. To override
the default please set the :setting:`CONSTANCE_BACKEND` setting to the appropriate
dotted path.
Configuration values are stored in JSON format and automatically serialized/deserialized
on access.
Redis
-----

View file

@ -101,6 +101,8 @@ The supported types are:
* ``datetime``
* ``date``
* ``time``
* ``list``
* ``dict``
For example, to force a value to be handled as a string:

View file

@ -51,6 +51,8 @@ CONSTANCE_ADDITIONAL_FIELDS = {
],
# note this intentionally uses a tuple so that we can test immutable
'email': ('django.forms.fields.EmailField',),
'array': ['django.forms.fields.CharField', {'widget': 'django.forms.Textarea'}],
'json': ['django.forms.fields.CharField', {'widget': 'django.forms.Textarea'}],
}
USE_TZ = True
@ -68,6 +70,19 @@ CONSTANCE_CONFIG = {
'CHOICE_VALUE': ('yes', 'select yes or no', 'yes_no_null_select'),
'LINEBREAK_VALUE': ('Spam spam', 'eggs\neggs'),
'EMAIL_VALUE': ('test@example.com', 'An email', 'email'),
'LIST_VALUE': ([1, '1', date(2019, 1, 1)], 'A list', 'array'),
'JSON_VALUE': (
{
'key': 'value',
'key2': 2,
'key3': [1, 2, 3],
'key4': {'key': 'value'},
'key5': date(2019, 1, 1),
'key6': None,
},
'A JSON object',
'json',
),
}
DEBUG = True

View file

@ -25,6 +25,18 @@ class StorageTestsMixin:
self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=1, hours=2, minutes=3))
self.assertEqual(self.config.CHOICE_VALUE, 'yes')
self.assertEqual(self.config.EMAIL_VALUE, 'test@example.com')
self.assertEqual(self.config.LIST_VALUE, [1, '1', date(2019, 1, 1)])
self.assertEqual(
self.config.JSON_VALUE,
{
'key': 'value',
'key2': 2,
'key3': [1, 2, 3],
'key4': {'key': 'value'},
'key5': date(2019, 1, 1),
'key6': None,
},
)
# set values
self.config.INT_VALUE = 100
@ -38,6 +50,8 @@ class StorageTestsMixin:
self.config.TIMEDELTA_VALUE = timedelta(days=2, hours=3, minutes=4)
self.config.CHOICE_VALUE = 'no'
self.config.EMAIL_VALUE = 'foo@bar.com'
self.config.LIST_VALUE = [1, date(2020, 2, 2)]
self.config.JSON_VALUE = {'key': 'OK'}
# read again
self.assertEqual(self.config.INT_VALUE, 100)
@ -51,6 +65,8 @@ class StorageTestsMixin:
self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=2, hours=3, minutes=4))
self.assertEqual(self.config.CHOICE_VALUE, 'no')
self.assertEqual(self.config.EMAIL_VALUE, 'foo@bar.com')
self.assertEqual(self.config.LIST_VALUE, [1, date(2020, 2, 2)])
self.assertEqual(self.config.JSON_VALUE, {'key': 'OK'})
def test_nonexistent(self):
self.assertRaises(AttributeError, getattr, self.config, 'NON_EXISTENT')

View file

@ -30,19 +30,21 @@ class CliTestCase(TransactionTestCase):
set(
dedent(
smart_str(
""" BOOL_VALUE True
EMAIL_VALUE test@example.com
INT_VALUE 1
LINEBREAK_VALUE Spam spam
DATE_VALUE 2010-12-24
TIME_VALUE 23:59:59
TIMEDELTA_VALUE 1 day, 2:03:00
STRING_VALUE Hello world
CHOICE_VALUE yes
DECIMAL_VALUE 0.1
DATETIME_VALUE 2010-08-23 11:29:24
FLOAT_VALUE 3.1415926536
"""
""" BOOL_VALUE\tTrue
EMAIL_VALUE\ttest@example.com
INT_VALUE\t1
LINEBREAK_VALUE\tSpam spam
DATE_VALUE\t2010-12-24
TIME_VALUE\t23:59:59
TIMEDELTA_VALUE\t1 day, 2:03:00
STRING_VALUE\tHello world
CHOICE_VALUE\tyes
DECIMAL_VALUE\t0.1
DATETIME_VALUE\t2010-08-23 11:29:24
FLOAT_VALUE\t3.1415926536
JSON_VALUE\t{'key': 'value', 'key2': 2, 'key3': [1, 2, 3], 'key4': {'key': 'value'}, 'key5': datetime.date(2019, 1, 1), 'key6': None}
LIST_VALUE\t[1, '1', datetime.date(2019, 1, 1)]
""" # noqa: E501
)
).splitlines()
),

View file

@ -24,6 +24,8 @@ class TestJSONSerialization(TestCase):
self.boolean = True
self.none = None
self.timedelta = timedelta(days=1, hours=2, minutes=3)
self.list = [1, 2, self.date]
self.dict = {'key': self.date, 'key2': 1}
def test_serializes_and_deserializes_default_types(self):
self.assertEqual(dumps(self.datetime), '{"__type__": "datetime", "__value__": "2023-10-05T15:30:00"}')
@ -37,6 +39,14 @@ class TestJSONSerialization(TestCase):
self.assertEqual(dumps(self.boolean), '{"__type__": "default", "__value__": true}')
self.assertEqual(dumps(self.none), '{"__type__": "default", "__value__": null}')
self.assertEqual(dumps(self.timedelta), '{"__type__": "timedelta", "__value__": 93780.0}')
self.assertEqual(
dumps(self.list),
'{"__type__": "default", "__value__": [1, 2, {"__type__": "date", "__value__": "2023-10-05"}]}',
)
self.assertEqual(
dumps(self.dict),
'{"__type__": "default", "__value__": {"key": {"__type__": "date", "__value__": "2023-10-05"}, "key2": 1}}',
)
for t in (
self.datetime,
self.date,
@ -49,6 +59,8 @@ class TestJSONSerialization(TestCase):
self.boolean,
self.none,
self.timedelta,
self.dict,
self.list,
):
self.assertEqual(t, loads(dumps(t)))
@ -88,3 +100,14 @@ class TestJSONSerialization(TestCase):
register_type(int, 'new_custom_type', lambda o: o.value, lambda o: int(o))
with self.assertRaisesRegex(ValueError, 'Type with discriminator new_custom_type is already registered'):
register_type(int, 'new_custom_type', lambda o: o.value, lambda o: int(o))
def test_nested_collections(self):
data = {'key': [[[[{'key': self.date}]]]]}
self.assertEqual(
dumps(data),
(
'{"__type__": "default", '
'"__value__": {"key": [[[[{"key": {"__type__": "date", "__value__": "2023-10-05"}}]]]]}}'
),
)
self.assertEqual(data, loads(dumps(data)))

View file

@ -51,5 +51,14 @@ class UtilsTestCase(TestCase):
'DECIMAL_VALUE': Decimal('0.1'),
'STRING_VALUE': 'Hello world',
'DATETIME_VALUE': datetime.datetime(2010, 8, 23, 11, 29, 24),
'LIST_VALUE': [1, '1', datetime.date(2019, 1, 1)],
'JSON_VALUE': {
'key': 'value',
'key2': 2,
'key3': [1, 2, 3],
'key4': {'key': 'value'},
'key5': datetime.date(2019, 1, 1),
'key6': None,
},
},
)