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): def dumps(obj, _dumps=json.dumps, cls=JSONEncoder, default_kwargs=None, **kwargs):
"""Serialize object to json string.""" """Serialize object to json string."""
default_kwargs = default_kwargs or {} 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( return _dumps(
_as(DEFAULT_DISCRIMINATOR, obj) if is_default_type else obj, cls=cls, **dict(default_kwargs, **kwargs) _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.""" """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) return _loads(s, object_hook=object_hook, **kwargs)
@ -54,6 +60,8 @@ def object_hook(o: dict) -> Any:
if not codec: if not codec:
raise ValueError(f'Unsupported type: {o["__type__"]}') raise ValueError(f'Unsupported type: {o["__type__"]}')
return codec[1](o['__value__']) return codec[1](o['__value__'])
if '__type__' not in o and '__value__' not in o:
return o
logger.error('Cannot deserialize object: %s', o) logger.error('Cannot deserialize object: %s', o)
raise ValueError(f'Invalid object: {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 the default please set the :setting:`CONSTANCE_BACKEND` setting to the appropriate
dotted path. dotted path.
Configuration values are stored in JSON format and automatically serialized/deserialized
on access.
Redis Redis
----- -----

View file

@ -101,6 +101,8 @@ The supported types are:
* ``datetime`` * ``datetime``
* ``date`` * ``date``
* ``time`` * ``time``
* ``list``
* ``dict``
For example, to force a value to be handled as a string: 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 # note this intentionally uses a tuple so that we can test immutable
'email': ('django.forms.fields.EmailField',), '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 USE_TZ = True
@ -68,6 +70,19 @@ CONSTANCE_CONFIG = {
'CHOICE_VALUE': ('yes', 'select yes or no', 'yes_no_null_select'), 'CHOICE_VALUE': ('yes', 'select yes or no', 'yes_no_null_select'),
'LINEBREAK_VALUE': ('Spam spam', 'eggs\neggs'), 'LINEBREAK_VALUE': ('Spam spam', 'eggs\neggs'),
'EMAIL_VALUE': ('test@example.com', 'An email', 'email'), '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 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.TIMEDELTA_VALUE, timedelta(days=1, hours=2, minutes=3))
self.assertEqual(self.config.CHOICE_VALUE, 'yes') self.assertEqual(self.config.CHOICE_VALUE, 'yes')
self.assertEqual(self.config.EMAIL_VALUE, 'test@example.com') 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 # set values
self.config.INT_VALUE = 100 self.config.INT_VALUE = 100
@ -38,6 +50,8 @@ class StorageTestsMixin:
self.config.TIMEDELTA_VALUE = timedelta(days=2, hours=3, minutes=4) self.config.TIMEDELTA_VALUE = timedelta(days=2, hours=3, minutes=4)
self.config.CHOICE_VALUE = 'no' self.config.CHOICE_VALUE = 'no'
self.config.EMAIL_VALUE = 'foo@bar.com' self.config.EMAIL_VALUE = 'foo@bar.com'
self.config.LIST_VALUE = [1, date(2020, 2, 2)]
self.config.JSON_VALUE = {'key': 'OK'}
# read again # read again
self.assertEqual(self.config.INT_VALUE, 100) 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.TIMEDELTA_VALUE, timedelta(days=2, hours=3, minutes=4))
self.assertEqual(self.config.CHOICE_VALUE, 'no') self.assertEqual(self.config.CHOICE_VALUE, 'no')
self.assertEqual(self.config.EMAIL_VALUE, 'foo@bar.com') 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): def test_nonexistent(self):
self.assertRaises(AttributeError, getattr, self.config, 'NON_EXISTENT') self.assertRaises(AttributeError, getattr, self.config, 'NON_EXISTENT')

View file

@ -30,19 +30,21 @@ class CliTestCase(TransactionTestCase):
set( set(
dedent( dedent(
smart_str( smart_str(
""" BOOL_VALUE True """ BOOL_VALUE\tTrue
EMAIL_VALUE test@example.com EMAIL_VALUE\ttest@example.com
INT_VALUE 1 INT_VALUE\t1
LINEBREAK_VALUE Spam spam LINEBREAK_VALUE\tSpam spam
DATE_VALUE 2010-12-24 DATE_VALUE\t2010-12-24
TIME_VALUE 23:59:59 TIME_VALUE\t23:59:59
TIMEDELTA_VALUE 1 day, 2:03:00 TIMEDELTA_VALUE\t1 day, 2:03:00
STRING_VALUE Hello world STRING_VALUE\tHello world
CHOICE_VALUE yes CHOICE_VALUE\tyes
DECIMAL_VALUE 0.1 DECIMAL_VALUE\t0.1
DATETIME_VALUE 2010-08-23 11:29:24 DATETIME_VALUE\t2010-08-23 11:29:24
FLOAT_VALUE 3.1415926536 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() ).splitlines()
), ),

View file

@ -24,6 +24,8 @@ class TestJSONSerialization(TestCase):
self.boolean = True self.boolean = True
self.none = None self.none = None
self.timedelta = timedelta(days=1, hours=2, minutes=3) 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): def test_serializes_and_deserializes_default_types(self):
self.assertEqual(dumps(self.datetime), '{"__type__": "datetime", "__value__": "2023-10-05T15:30:00"}') 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.boolean), '{"__type__": "default", "__value__": true}')
self.assertEqual(dumps(self.none), '{"__type__": "default", "__value__": null}') self.assertEqual(dumps(self.none), '{"__type__": "default", "__value__": null}')
self.assertEqual(dumps(self.timedelta), '{"__type__": "timedelta", "__value__": 93780.0}') 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 ( for t in (
self.datetime, self.datetime,
self.date, self.date,
@ -49,6 +59,8 @@ class TestJSONSerialization(TestCase):
self.boolean, self.boolean,
self.none, self.none,
self.timedelta, self.timedelta,
self.dict,
self.list,
): ):
self.assertEqual(t, loads(dumps(t))) 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)) 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'): 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)) 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'), 'DECIMAL_VALUE': Decimal('0.1'),
'STRING_VALUE': 'Hello world', 'STRING_VALUE': 'Hello world',
'DATETIME_VALUE': datetime.datetime(2010, 8, 23, 11, 29, 24), '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,
},
}, },
) )