Skip to content

Commit f17afae

Browse files
DMRobertsonclokep
andauthored
Decouple frozendict support from the library (#59)
* Rename `_default` and add docstring * Remove frozendict support * Add serisalisation registration hook * Tests Co-authored-by: Patrick Cloke <clokep@users.noreply.github.com>
1 parent 2aa9eb5 commit f17afae

File tree

5 files changed

+99
-38
lines changed

5 files changed

+99
-38
lines changed

README.rst

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Features
1515
U+0056, to keep the output as small as possible.
1616
* Uses the shortest escape sequence for each escaped character.
1717
* Encodes the JSON as UTF-8.
18-
* Can encode ``frozendict`` immutable dictionaries.
18+
* Can be configured to encode custom types unknown to the stdlib JSON encoder.
1919

2020
Supports Python versions 3.7 and newer.
2121

@@ -59,3 +59,20 @@ The underlying JSON implementation can be chosen with the following:
5959
which uses the standard library json module).
6060

6161
.. _simplejson: https://simplejson.readthedocs.io/
62+
63+
A preserialisation hook allows you to encode objects which aren't encodable by the
64+
standard library ``JSONEncoder``.
65+
66+
.. code:: python
67+
68+
import canonicaljson
69+
from typing import Dict
70+
71+
class CustomType:
72+
pass
73+
74+
def callback(c: CustomType) -> Dict[str, str]:
75+
return {"Hello": "world!"}
76+
77+
canonicaljson.register_preserialisation_callback(CustomType, callback)
78+
assert canonicaljson.encode_canonical_json(CustomType()) == b'{"Hello":"world!"}'

setup.cfg

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,6 @@ install_requires =
3434
typing_extensions>=4.0.0; python_version < '3.8'
3535

3636

37-
[options.extras_require]
38-
# frozendict support can be enabled using the `canonicaljson[frozendict]` syntax
39-
frozendict =
40-
frozendict>=1.0
41-
42-
4337
[options.package_data]
4438
canonicaljson = py.typed
4539

src/canonicaljson/__init__.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,56 @@
1313
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
16-
16+
import functools
1717
import platform
18-
from typing import Any, Generator, Iterator, Optional, Type
18+
from typing import Any, Callable, Generator, Iterator, Type, TypeVar
1919

2020
try:
2121
from typing import Protocol
2222
except ImportError: # pragma: no cover
2323
from typing_extensions import Protocol # type: ignore[assignment]
2424

25-
frozendict_type: Optional[Type[Any]]
26-
try:
27-
from frozendict import frozendict as frozendict_type
28-
except ImportError:
29-
frozendict_type = None # pragma: no cover
3025

3126
__version__ = "1.6.5"
3227

3328

34-
def _default(obj: object) -> object: # pragma: no cover
35-
if type(obj) is frozendict_type:
36-
# If frozendict is available and used, cast `obj` into a dict
37-
return dict(obj) # type: ignore[call-overload]
29+
@functools.singledispatch
30+
def _preprocess_for_serialisation(obj: object) -> object: # pragma: no cover
31+
"""Transform an `obj` into something the JSON library knows how to encode.
32+
33+
This is only called for types that the JSON library does not recognise.
34+
"""
3835
raise TypeError(
3936
"Object of type %s is not JSON serializable" % obj.__class__.__name__
4037
)
4138

4239

40+
T = TypeVar("T")
41+
42+
43+
def register_preserialisation_callback(
44+
data_type: Type[T], callback: Callable[[T], object]
45+
) -> None:
46+
"""
47+
Register a `callback` to preprocess `data_type` objects unknown to the JSON encoder.
48+
49+
When canonicaljson encodes an object `x` at runtime that its JSON library does not
50+
know how to encode, it will
51+
- select a `callback`,
52+
- compute `y = callback(x)`, then
53+
- JSON-encode `y` and return the result.
54+
55+
The `callback` should return an object that is JSON-serialisable by the stdlib
56+
json module.
57+
58+
If this is called multiple times with the same `data_type`, the most recently
59+
registered callback is used when serialising that `data_type`.
60+
"""
61+
if data_type is object:
62+
raise ValueError("Cannot register callback for the `object` type")
63+
_preprocess_for_serialisation.register(data_type, callback)
64+
65+
4366
class Encoder(Protocol): # pragma: no cover
4467
def encode(self, data: object) -> str:
4568
pass
@@ -77,7 +100,7 @@ def set_json_library(json_lib: JsonLibrary) -> None:
77100
allow_nan=False,
78101
separators=(",", ":"),
79102
sort_keys=True,
80-
default=_default,
103+
default=_preprocess_for_serialisation,
81104
)
82105

83106
global _pretty_encoder
@@ -86,7 +109,7 @@ def set_json_library(json_lib: JsonLibrary) -> None:
86109
allow_nan=False,
87110
indent=4,
88111
sort_keys=True,
89-
default=_default,
112+
default=_preprocess_for_serialisation,
90113
)
91114

92115

tests/test_canonicaljson.py

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,17 @@
1313
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
16+
from unittest.mock import Mock
1617

1718
from math import inf, nan
1819

1920
from canonicaljson import (
2021
encode_canonical_json,
2122
encode_pretty_printed_json,
22-
frozendict_type,
2323
iterencode_canonical_json,
2424
iterencode_pretty_printed_json,
2525
set_json_library,
26+
register_preserialisation_callback,
2627
)
2728

2829
import unittest
@@ -107,22 +108,6 @@ def test_encode_pretty_printed(self) -> None:
107108
b'{\n "la merde amus\xc3\xa9e": "\xF0\x9F\x92\xA9"\n}',
108109
)
109110

110-
@unittest.skipIf(
111-
frozendict_type is None,
112-
"If `frozendict` is not available, skip test",
113-
)
114-
def test_frozen_dict(self) -> None:
115-
# For mypy's benefit:
116-
assert frozendict_type is not None
117-
self.assertEqual(
118-
encode_canonical_json(frozendict_type({"a": 1})),
119-
b'{"a":1}',
120-
)
121-
self.assertEqual(
122-
encode_pretty_printed_json(frozendict_type({"a": 1})),
123-
b'{\n "a": 1\n}',
124-
)
125-
126111
def test_unknown_type(self) -> None:
127112
class Unknown(object):
128113
pass
@@ -167,3 +152,46 @@ def test_set_json(self) -> None:
167152
from canonicaljson import json # type: ignore[attr-defined]
168153

169154
set_json_library(json)
155+
156+
def test_encode_unknown_class_raises(self) -> None:
157+
class C:
158+
pass
159+
160+
with self.assertRaises(Exception):
161+
encode_canonical_json(C())
162+
163+
def test_preserialisation_callback(self) -> None:
164+
class C:
165+
pass
166+
167+
# Naughty: this alters the global state of the module. However this
168+
# `C` class is limited to this test only, so this shouldn't affect
169+
# other types and other tests.
170+
register_preserialisation_callback(C, lambda c: "I am a C instance")
171+
172+
result = encode_canonical_json(C())
173+
self.assertEqual(result, b'"I am a C instance"')
174+
175+
def test_cannot_register_preserialisation_callback_for_object(self) -> None:
176+
with self.assertRaises(Exception):
177+
register_preserialisation_callback(
178+
object, lambda c: "shouldn't be able to do this"
179+
)
180+
181+
def test_most_recent_preserialisation_callback_called(self) -> None:
182+
class C:
183+
pass
184+
185+
callback1 = Mock(return_value="callback 1 was called")
186+
callback2 = Mock(return_value="callback 2 was called")
187+
188+
# Naughty: this alters the global state of the module. However this
189+
# `C` class is limited to this test only, so this shouldn't affect
190+
# other types and other tests.
191+
register_preserialisation_callback(C, callback1)
192+
register_preserialisation_callback(C, callback2)
193+
194+
encode_canonical_json(C())
195+
196+
callback1.assert_not_called()
197+
callback2.assert_called_once()

tox.ini

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ commands = python -m black --check --diff src tests
3333
[testenv:mypy]
3434
deps =
3535
mypy==1.0
36-
types-frozendict==2.0.8
3736
types-simplejson==3.17.5
3837
types-setuptools==57.4.14
3938
commands = mypy src tests

0 commit comments

Comments
 (0)