Skip to content

Commit d3bdefd

Browse files
committed
Internal refactoring to support protocols.
1 parent ae33cbf commit d3bdefd

File tree

7 files changed

+267
-31
lines changed

7 files changed

+267
-31
lines changed

.travis.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ install:
2121
- pip install coveralls coverage_pyver_pragma
2222

2323
script:
24-
- tox
24+
- tox -vv
2525
after_success:
2626
- coveralls
2727

@@ -46,7 +46,6 @@ jobs:
4646
- python: 'pypy3'
4747
arch: arm64
4848

49-
5049
include:
5150
- stage: test
5251
dist: bionic

doc-source/index.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ sdjson
88

99
.. end short_desc
1010
11-
Based on https://treyhunner.com/2013/09/singledispatch-json-serializer/ and Python's ``json`` module.
11+
Based on https://treyhunner.com/2013/09/singledispatch-json-serializer/
12+
and Python's :mod:`json` module.
1213

1314
.. start shields
1415

sdjson/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
the given class.
6464
6565
66-
TODO: This module does not currently support custom decoders, but might in the future.
66+
.. TODO:: This module does not currently support custom decoders, but might in the future.
6767
"""
6868
#
6969
# Copyright © 2020 Dominic Davis-Foster <dominic@davis-foster.co.uk>
@@ -75,7 +75,7 @@
7575
#
7676
# This program is distributed in the hope that it will be useful,
7777
# but WITHOUT ANY WARRANTY; without even the implied warranty of
78-
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
78+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
7979
# GNU Lesser General Public License for more details.
8080
#
8181
# You should have received a copy of the GNU Lesser General Public License
@@ -126,6 +126,7 @@
126126
__license__ = "LGPLv3+"
127127
__version__ = "0.2.6"
128128
__email__ = "dominic@davis-foster.co.uk"
129+
129130
# this package
130131
from sdjson.core import (
131132
JSONDecodeError,

sdjson/core.py

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,20 @@
101101

102102
# stdlib
103103
import json
104+
import sys
104105
from functools import singledispatch
105-
from typing import IO, Any, Callable, Dict, Iterator, Optional, Tuple, Type, Union
106+
from typing import IO, Any, Callable, Iterator, Optional, Tuple, Type, TypeVar, Union
106107

107108
# 3rd party
108109
from domdf_python_tools.doctools import append_docstring_from, is_documented_by, make_sphinx_links
109110

111+
if sys.version_info < (3, 8): # pragma: no cover (>=py38)
112+
# 3rd party
113+
from typing_extensions import _ProtocolMeta # type: ignore
114+
else: # pragma: no cover (<py38)
115+
# stdlib
116+
from typing import _ProtocolMeta # type: ignore
117+
110118
__all__ = [
111119
"load",
112120
"loads",
@@ -134,13 +142,13 @@ def allow_unregister(func) -> Callable:
134142
"""
135143
Decorator to allow removal of custom encoders with ``<sdjson.encoders.unregister(<type>)``,
136144
where <type> is the custom type you wish to remove the encoder for.
137-
138-
From https://stackoverflow.com/a/25951784/3092681
139-
Copyright © 2014 Martijn Pieters
140-
https://stackoverflow.com/users/100297/martijn-pieters
141-
Licensed under CC BY-SA 4.0
142145
"""
143146

147+
# From https://stackoverflow.com/a/25951784/3092681
148+
# Copyright © 2014 Martijn Pieters
149+
# https://stackoverflow.com/users/100297/martijn-pieters
150+
# Licensed under CC BY-SA 4.0
151+
144152
# build a dictionary mapping names to closure cells
145153
closure = dict(zip(func.register.__code__.co_freevars, func.register.__closure__))
146154
registry = closure["registry"].cell_contents
@@ -171,17 +179,91 @@ def wrapper(target):
171179
return wrapper
172180

173181

174-
@allow_unregister
175-
@singledispatch
176182
class _Encoders:
177-
register: Callable
178-
unregister: Callable
179-
registry: Dict # TODO
180183

184+
def __init__(self):
185+
self._registry = allow_unregister(singledispatch(lambda x: None))
186+
self._protocol_registry = {}
187+
self.registry = self._registry.registry
188+
189+
def register(self, cls: Type, func: Optional[Callable] = None) -> Callable:
190+
"""
191+
Registers a new handler for the given type.
192+
193+
Can be used as a decorator or a regular function:
194+
195+
.. code-block:: python
196+
197+
@register_encoder(bytes)
198+
def bytes_encoder(obj):
199+
return obj.decode("UTF-8")
200+
201+
def int_encoder(obj):
202+
return int(obj)
203+
204+
register_encoder(int, int_encoder)
205+
206+
207+
:param cls:
208+
:param func:
209+
"""
210+
211+
if func is None:
212+
return lambda f: self.register(cls, f)
213+
214+
if isinstance(cls, _ProtocolMeta):
215+
if getattr(cls, "_is_runtime_protocol", False):
216+
self._protocol_registry[cls] = func
217+
else:
218+
raise TypeError("Protocols must be @runtime_checkable")
219+
return func
220+
else:
221+
return self._registry.register(cls, func)
181222

182-
encoders = _Encoders
183-
register_encoder = _Encoders.register # type: ignore
184-
unregister_encoder = _Encoders.unregister # type: ignore
223+
def dispatch(self, cls: object) -> Optional[Callable]:
224+
"""
225+
Returns the best available implementation for the given object.
226+
227+
:param cls:
228+
"""
229+
230+
if object in self.registry:
231+
self.unregister(object)
232+
233+
handler = self._registry.dispatch(type(cls))
234+
if handler is not None:
235+
return handler
236+
else:
237+
for protocol, handler in self._protocol_registry.items():
238+
if isinstance(cls, protocol):
239+
return handler
240+
241+
return None
242+
243+
def unregister(self, cls: Type):
244+
"""
245+
Unregister the handler for the given type.
246+
247+
.. code-block:: python
248+
249+
unregister_encoder(int)
250+
251+
:param cls:
252+
253+
:raise KeyError: if no handler is found.
254+
"""
255+
256+
if cls in self.registry:
257+
self._registry.unregister(cls)
258+
elif cls in self._protocol_registry:
259+
del self._protocol_registry[cls]
260+
else:
261+
raise KeyError
262+
263+
264+
encoders = _Encoders()
265+
register_encoder = encoders.register # type: ignore
266+
unregister_encoder = encoders.unregister # type: ignore
185267

186268

187269
@sphinxify_json_docstring()
@@ -314,9 +396,10 @@ def raw_decode(self, *args, **kwargs):
314396
class _CustomEncoder(JSONEncoder):
315397

316398
def default(self, obj):
317-
for type_, handler in encoders.registry.items():
318-
if isinstance(obj, type_) and type_ is not object:
319-
return handler(obj)
399+
handler = encoders.dispatch(obj)
400+
if handler is not None:
401+
return handler(obj)
402+
320403
return super().default(obj)
321404

322405

sdjson/core.pyi

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
# stdlib
3939
import json
4040
from functools import singledispatch
41-
from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Protocol, Tuple, Type, TypeVar, Union
41+
from typing import (
42+
IO, Any, Callable, Dict, Iterator, List, Mapping, Optional, overload, Protocol, Tuple, Type, TypeVar,
43+
Union,
44+
)
4245

4346
# 3rd party
4447
from domdf_python_tools.doctools import append_docstring_from, is_documented_by
@@ -52,31 +55,63 @@ __email__: str
5255

5356
_T_co = TypeVar("_T_co", covariant=True)
5457
_LoadsString = Union[str, bytes]
58+
_T = TypeVar("_T")
59+
60+
61+
class SingleDispatch(Protocol):
62+
"""
63+
:class:`~typing.Protocol` representing a function decorated with :func:`functools.singledispatch`.
64+
"""
65+
66+
@overload
67+
def register(self, cls: Any) -> Callable[[Callable[..., _T]], Callable[..., _T]]: ...
68+
69+
@overload
70+
def register(self, cls: Any, func: Callable[..., _T]) -> Callable[..., _T]: ...
71+
72+
def dispatch(self, cls: Any) -> Callable[..., _T]: ...
73+
74+
def unregister(self, cls: Type) -> Any: ...
75+
76+
registry: Mapping[Any, Callable[..., _T]]
77+
78+
def _clear_cache(self) -> None: ...
79+
80+
def __call__(self, *args: Any, **kwargs: Any) -> _T: ...
81+
5582

5683

5784
class SupportsRead(Protocol[_T_co]):
5885
def read(self, __length: int = ...) -> _T_co: ...
5986

6087

61-
def allow_unregister(func) -> Callable: ...
88+
def allow_unregister(func: SingleDispatch) -> SingleDispatch: ...
6289

6390

6491
def sphinxify_json_docstring() -> Callable: ...
6592

6693

67-
@allow_unregister
68-
@singledispatch
6994
class _Encoders:
70-
register: Callable
71-
unregister: Callable
95+
_registry: SingleDispatch
96+
_protocol_registry: Mapping[Any, Callable[..., _T]]
97+
registry: Mapping[Any, Callable[..., _T]]
7298

99+
@overload
100+
def register(self, cls: Any) -> Callable[[Callable[..., _T]], Callable[..., _T]]: ...
73101

74-
encoders = _Encoders
75-
register_encoder = _Encoders.register # type: ignore
76-
unregister_encoder = _Encoders.unregister # type: ignore
102+
@overload
103+
def register(self, cls: Any, func: Callable[..., _T]) -> Callable[..., _T]: ...
77104

105+
def dispatch(self, cls: Any) -> Callable[..., _T]: ...
78106

107+
def unregister(self, cls: Type) -> Any: ...
108+
109+
110+
encoders = _Encoders()
111+
register_encoder = encoders.register # type: ignore
112+
unregister_encoder = encoders.unregister # type: ignore
79113
def dump(
114+
80115
obj: Any,
81116
fp: IO[str],
82117
*,

0 commit comments

Comments
 (0)