From c06c5994d4091bb7d651d6f9ef86a29c4ab543f1 Mon Sep 17 00:00:00 2001 From: Alexandr Artemyev Date: Thu, 29 Aug 2024 22:56:08 +0500 Subject: [PATCH] Collections support --- constance/codecs.py | 12 ++++++++++-- tests/settings.py | 15 +++++++++++++++ tests/storage.py | 16 ++++++++++++++++ tests/test_cli.py | 28 +++++++++++++++------------- tests/test_codecs.py | 23 +++++++++++++++++++++++ tests/test_utils.py | 9 +++++++++ 6 files changed, 88 insertions(+), 15 deletions(-) diff --git a/constance/codecs.py b/constance/codecs.py index 5996e344..9219a946 100644 --- a/constance/codecs.py +++ b/constance/codecs.py @@ -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}') diff --git a/tests/settings.py b/tests/settings.py index 19a9e763..2b5b7e0c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -51,6 +51,8 @@ ], # 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 @@ '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 diff --git a/tests/storage.py b/tests/storage.py index 46bb51d6..13e7e997 100644 --- a/tests/storage.py +++ b/tests/storage.py @@ -25,6 +25,18 @@ def test_store(self): 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 @@ def test_store(self): 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 @@ def test_store(self): 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') diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d1b29c2..68d8daf7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,19 +30,21 @@ def test_list(self): 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() ), diff --git a/tests/test_codecs.py b/tests/test_codecs.py index 27b741ba..cf5f67b8 100644 --- a/tests/test_codecs.py +++ b/tests/test_codecs.py @@ -24,6 +24,8 @@ def setUp(self): 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 @@ def test_serializes_and_deserializes_default_types(self): 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 @@ def test_serializes_and_deserializes_default_types(self): self.boolean, self.none, self.timedelta, + self.dict, + self.list, ): self.assertEqual(t, loads(dumps(t))) @@ -88,3 +100,14 @@ def test_register_known_type(self): 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))) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8c80187e..a9c1ba47 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -51,5 +51,14 @@ def test_get_values(self): '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, + }, }, )