Skip to content

Commit bb0dc46

Browse files
Collections support (#581)
--------- Co-authored-by: Sebastian Manger <[email protected]>
1 parent 31c9e8d commit bb0dc46

File tree

8 files changed

+93
-15
lines changed

8 files changed

+93
-15
lines changed

constance/codecs.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,20 @@ def _as(discriminator: str, v: Any) -> dict[str, Any]:
3434
def dumps(obj, _dumps=json.dumps, cls=JSONEncoder, default_kwargs=None, **kwargs):
3535
"""Serialize object to json string."""
3636
default_kwargs = default_kwargs or {}
37-
is_default_type = isinstance(obj, (str, int, bool, float, type(None)))
37+
is_default_type = isinstance(obj, (list, dict, str, int, bool, float, type(None)))
3838
return _dumps(
3939
_as(DEFAULT_DISCRIMINATOR, obj) if is_default_type else obj, cls=cls, **dict(default_kwargs, **kwargs)
4040
)
4141

4242

43-
def loads(s, _loads=json.loads, **kwargs):
43+
def loads(s, _loads=json.loads, *, first_level=True, **kwargs):
4444
"""Deserialize json string to object."""
45+
if first_level:
46+
return _loads(s, object_hook=object_hook, **kwargs)
47+
if isinstance(s, dict) and '__type__' not in s and '__value__' not in s:
48+
return {k: loads(v, first_level=False) for k, v in s.items()}
49+
if isinstance(s, list):
50+
return list(loads(v, first_level=False) for v in s)
4551
return _loads(s, object_hook=object_hook, **kwargs)
4652

4753

@@ -54,6 +60,8 @@ def object_hook(o: dict) -> Any:
5460
if not codec:
5561
raise ValueError(f'Unsupported type: {o["__type__"]}')
5662
return codec[1](o['__value__'])
63+
if '__type__' not in o and '__value__' not in o:
64+
return o
5765
logger.error('Cannot deserialize object: %s', o)
5866
raise ValueError(f'Invalid object: {o}')
5967

docs/backends.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ configuration values. By default it uses the Redis backend. To override
1010
the default please set the :setting:`CONSTANCE_BACKEND` setting to the appropriate
1111
dotted path.
1212

13+
Configuration values are stored in JSON format and automatically serialized/deserialized
14+
on access.
15+
1316
Redis
1417
-----
1518

docs/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ The supported types are:
101101
* ``datetime``
102102
* ``date``
103103
* ``time``
104+
* ``list``
105+
* ``dict``
104106

105107
For example, to force a value to be handled as a string:
106108

tests/settings.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
],
5252
# note this intentionally uses a tuple so that we can test immutable
5353
'email': ('django.forms.fields.EmailField',),
54+
'array': ['django.forms.fields.CharField', {'widget': 'django.forms.Textarea'}],
55+
'json': ['django.forms.fields.CharField', {'widget': 'django.forms.Textarea'}],
5456
}
5557

5658
USE_TZ = True
@@ -68,6 +70,19 @@
6870
'CHOICE_VALUE': ('yes', 'select yes or no', 'yes_no_null_select'),
6971
'LINEBREAK_VALUE': ('Spam spam', 'eggs\neggs'),
7072
'EMAIL_VALUE': ('[email protected]', 'An email', 'email'),
73+
'LIST_VALUE': ([1, '1', date(2019, 1, 1)], 'A list', 'array'),
74+
'JSON_VALUE': (
75+
{
76+
'key': 'value',
77+
'key2': 2,
78+
'key3': [1, 2, 3],
79+
'key4': {'key': 'value'},
80+
'key5': date(2019, 1, 1),
81+
'key6': None,
82+
},
83+
'A JSON object',
84+
'json',
85+
),
7186
}
7287

7388
DEBUG = True

tests/storage.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ def test_store(self):
2525
self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=1, hours=2, minutes=3))
2626
self.assertEqual(self.config.CHOICE_VALUE, 'yes')
2727
self.assertEqual(self.config.EMAIL_VALUE, '[email protected]')
28+
self.assertEqual(self.config.LIST_VALUE, [1, '1', date(2019, 1, 1)])
29+
self.assertEqual(
30+
self.config.JSON_VALUE,
31+
{
32+
'key': 'value',
33+
'key2': 2,
34+
'key3': [1, 2, 3],
35+
'key4': {'key': 'value'},
36+
'key5': date(2019, 1, 1),
37+
'key6': None,
38+
},
39+
)
2840

2941
# set values
3042
self.config.INT_VALUE = 100
@@ -38,6 +50,8 @@ def test_store(self):
3850
self.config.TIMEDELTA_VALUE = timedelta(days=2, hours=3, minutes=4)
3951
self.config.CHOICE_VALUE = 'no'
4052
self.config.EMAIL_VALUE = '[email protected]'
53+
self.config.LIST_VALUE = [1, date(2020, 2, 2)]
54+
self.config.JSON_VALUE = {'key': 'OK'}
4155

4256
# read again
4357
self.assertEqual(self.config.INT_VALUE, 100)
@@ -51,6 +65,8 @@ def test_store(self):
5165
self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=2, hours=3, minutes=4))
5266
self.assertEqual(self.config.CHOICE_VALUE, 'no')
5367
self.assertEqual(self.config.EMAIL_VALUE, '[email protected]')
68+
self.assertEqual(self.config.LIST_VALUE, [1, date(2020, 2, 2)])
69+
self.assertEqual(self.config.JSON_VALUE, {'key': 'OK'})
5470

5571
def test_nonexistent(self):
5672
self.assertRaises(AttributeError, getattr, self.config, 'NON_EXISTENT')

tests/test_cli.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,21 @@ def test_list(self):
3030
set(
3131
dedent(
3232
smart_str(
33-
""" BOOL_VALUE True
34-
EMAIL_VALUE [email protected]
35-
INT_VALUE 1
36-
LINEBREAK_VALUE Spam spam
37-
DATE_VALUE 2010-12-24
38-
TIME_VALUE 23:59:59
39-
TIMEDELTA_VALUE 1 day, 2:03:00
40-
STRING_VALUE Hello world
41-
CHOICE_VALUE yes
42-
DECIMAL_VALUE 0.1
43-
DATETIME_VALUE 2010-08-23 11:29:24
44-
FLOAT_VALUE 3.1415926536
45-
"""
33+
""" BOOL_VALUE\tTrue
34+
EMAIL_VALUE\t[email protected]
35+
INT_VALUE\t1
36+
LINEBREAK_VALUE\tSpam spam
37+
DATE_VALUE\t2010-12-24
38+
TIME_VALUE\t23:59:59
39+
TIMEDELTA_VALUE\t1 day, 2:03:00
40+
STRING_VALUE\tHello world
41+
CHOICE_VALUE\tyes
42+
DECIMAL_VALUE\t0.1
43+
DATETIME_VALUE\t2010-08-23 11:29:24
44+
FLOAT_VALUE\t3.1415926536
45+
JSON_VALUE\t{'key': 'value', 'key2': 2, 'key3': [1, 2, 3], 'key4': {'key': 'value'}, 'key5': datetime.date(2019, 1, 1), 'key6': None}
46+
LIST_VALUE\t[1, '1', datetime.date(2019, 1, 1)]
47+
""" # noqa: E501
4648
)
4749
).splitlines()
4850
),

tests/test_codecs.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ def setUp(self):
2424
self.boolean = True
2525
self.none = None
2626
self.timedelta = timedelta(days=1, hours=2, minutes=3)
27+
self.list = [1, 2, self.date]
28+
self.dict = {'key': self.date, 'key2': 1}
2729

2830
def test_serializes_and_deserializes_default_types(self):
2931
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):
3739
self.assertEqual(dumps(self.boolean), '{"__type__": "default", "__value__": true}')
3840
self.assertEqual(dumps(self.none), '{"__type__": "default", "__value__": null}')
3941
self.assertEqual(dumps(self.timedelta), '{"__type__": "timedelta", "__value__": 93780.0}')
42+
self.assertEqual(
43+
dumps(self.list),
44+
'{"__type__": "default", "__value__": [1, 2, {"__type__": "date", "__value__": "2023-10-05"}]}',
45+
)
46+
self.assertEqual(
47+
dumps(self.dict),
48+
'{"__type__": "default", "__value__": {"key": {"__type__": "date", "__value__": "2023-10-05"}, "key2": 1}}',
49+
)
4050
for t in (
4151
self.datetime,
4252
self.date,
@@ -49,6 +59,8 @@ def test_serializes_and_deserializes_default_types(self):
4959
self.boolean,
5060
self.none,
5161
self.timedelta,
62+
self.dict,
63+
self.list,
5264
):
5365
self.assertEqual(t, loads(dumps(t)))
5466

@@ -88,3 +100,14 @@ def test_register_known_type(self):
88100
register_type(int, 'new_custom_type', lambda o: o.value, lambda o: int(o))
89101
with self.assertRaisesRegex(ValueError, 'Type with discriminator new_custom_type is already registered'):
90102
register_type(int, 'new_custom_type', lambda o: o.value, lambda o: int(o))
103+
104+
def test_nested_collections(self):
105+
data = {'key': [[[[{'key': self.date}]]]]}
106+
self.assertEqual(
107+
dumps(data),
108+
(
109+
'{"__type__": "default", '
110+
'"__value__": {"key": [[[[{"key": {"__type__": "date", "__value__": "2023-10-05"}}]]]]}}'
111+
),
112+
)
113+
self.assertEqual(data, loads(dumps(data)))

tests/test_utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,14 @@ def test_get_values(self):
5151
'DECIMAL_VALUE': Decimal('0.1'),
5252
'STRING_VALUE': 'Hello world',
5353
'DATETIME_VALUE': datetime.datetime(2010, 8, 23, 11, 29, 24),
54+
'LIST_VALUE': [1, '1', datetime.date(2019, 1, 1)],
55+
'JSON_VALUE': {
56+
'key': 'value',
57+
'key2': 2,
58+
'key3': [1, 2, 3],
59+
'key4': {'key': 'value'},
60+
'key5': datetime.date(2019, 1, 1),
61+
'key6': None,
62+
},
5463
},
5564
)

0 commit comments

Comments
 (0)