Skip to content

Commit f65e540

Browse files
authored
Merge pull request #17 from Dobiasd/taggedconverters
Distinguish between optional and mandatory converters
2 parents 6dbed39 + 264eb48 commit f65e540

File tree

6 files changed

+138
-31
lines changed

6 files changed

+138
-31
lines changed

README.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ from undictify import type_checked_constructor
257257
def parse_timestamp(datetime_repr: str) -> datetime:
258258
return datetime.strptime(datetime_repr, '%Y-%m-%dT%H:%M:%SZ')
259259

260-
@type_checked_constructor(converters={'some_timestamp': parse_timestamp})
260+
@type_checked_constructor(converters={'some_timestamp': optional_converter(parse_timestamp)})
261261
@dataclass
262262
class Foo:
263263
some_timestamp: datetime
@@ -266,6 +266,10 @@ json_repr = '{"some_timestamp": "2019-06-28T07:20:34Z"}'
266266
my_foo = Foo(**json.loads(json_repr))
267267
```
268268

269+
In case the converter should be applied even if the source type
270+
already matches the destination type, use `mandatory_converter`
271+
instead of `optional_converter`.
272+
269273

270274
Requirements and Installation
271275
-----------------------------

examples/readme_examples.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from datetime import datetime
99
from typing import List, NamedTuple, Optional, Any
1010

11-
from undictify import type_checked_constructor
11+
from undictify import type_checked_constructor, optional_converter
1212

1313
__author__ = "Tobias Hermann"
1414
__copyright__ = "Copyright 2018, Tobias Hermann"
@@ -80,7 +80,7 @@ def parse_timestamp(datetime_repr: str) -> datetime:
8080
return datetime.strptime(datetime_repr, '%Y-%m-%dT%H:%M:%SZ')
8181

8282

83-
@type_checked_constructor(converters={'some_timestamp': parse_timestamp})
83+
@type_checked_constructor(converters={'some_timestamp': optional_converter(parse_timestamp)})
8484
class Foo(NamedTuple):
8585
some_timestamp: datetime
8686

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setuptools.setup(
77
name="undictify",
8-
version="0.7.1",
8+
version="0.8.0",
99
author="Tobias Hermann",
1010
author_email="[email protected]",
1111
description="Type-checked function calls at runtime",

undictify/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from ._unpack import type_checked_call, type_checked_constructor
1+
from ._unpack import type_checked_call, type_checked_constructor, optional_converter, mandatory_converter
22

33
name = "undictify"
44

undictify/_unpack.py

+72-15
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
"""
44
import inspect
55
import sys
6+
from enum import Enum
67
from functools import wraps
7-
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union, get_type_hints
8+
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union, get_type_hints, Tuple
89

910
VER_3_7_AND_UP = sys.version_info[:3] >= (3, 7, 0) # PEP 560
1011

@@ -19,9 +20,32 @@
1920
TypeT = TypeVar('TypeT')
2021

2122

23+
class ConverterTag(Enum):
24+
"""When to apply a converter"""
25+
OPTIONAL = 1
26+
MANDATORY = 2
27+
28+
29+
Converter = Callable[[Any], Any]
30+
Converters = Dict[str, Converter]
31+
TaggedConverter = Tuple[Callable[[Any], Any], ConverterTag]
32+
TaggedConverters = Dict[str, TaggedConverter]
33+
OptionalTaggedConverters = Optional[TaggedConverters]
34+
35+
36+
def optional_converter(converter: Converter) -> TaggedConverter:
37+
"""Create an optional converter"""
38+
return converter, ConverterTag.OPTIONAL
39+
40+
41+
def mandatory_converter(converter: Converter) -> TaggedConverter:
42+
"""Create a mandatory converter"""
43+
return converter, ConverterTag.MANDATORY
44+
45+
2246
def type_checked_constructor(skip: bool = False,
2347
convert: bool = False,
24-
converters: Optional[Dict[str, Callable[[Any], Any]]] = None) \
48+
converters: OptionalTaggedConverters = None) \
2549
-> Callable[[Callable[..., TypeT]], Callable[..., TypeT]]:
2650
"""Replaces the constructor of the given class (in-place)
2751
with type-checked calls."""
@@ -57,7 +81,7 @@ def inner(first_arg: Any, *args: Any, **kwargs: Any) -> TypeT:
5781
kwargs_dict,
5882
skip,
5983
convert,
60-
converters)
84+
_optional_converters_to_converters(converters))
6185

6286
return inner
6387

@@ -79,7 +103,7 @@ def inner(first_arg: Any, *args: Any, **kwargs: Any) -> TypeT:
79103

80104
def type_checked_call(skip: bool = False,
81105
convert: bool = False,
82-
converters: Optional[Dict[str, Callable[[Any], Any]]] = None) \
106+
converters: OptionalTaggedConverters = None) \
83107
-> Callable[[Callable[..., TypeT]], Callable[..., TypeT]]:
84108
"""Wrap function with type checks."""
85109

@@ -101,7 +125,7 @@ def wrapper(*args: Any, **kwargs: Any) -> TypeT:
101125
kwargs_dict,
102126
skip,
103127
convert,
104-
converters)
128+
_optional_converters_to_converters(converters))
105129

106130
setattr(wrapper, '__undictify_wrapped_func__', func)
107131
return wrapper
@@ -148,7 +172,7 @@ def _unpack_dict(func: WrappedOrFunc[TypeT], # pylint: disable=too-many-argumen
148172
data: Dict[str, Any],
149173
skip_superfluous: bool,
150174
convert_types: bool,
151-
converters: Optional[Dict[str, Callable[[Any], Any]]]) -> Any:
175+
converters: TaggedConverters) -> Any:
152176
"""Constructs an object in a type-safe way from a dictionary."""
153177

154178
assert _is_dict(data), 'Argument data needs to be a dictionary.'
@@ -191,11 +215,35 @@ def _unpack_dict(func: WrappedOrFunc[TypeT], # pylint: disable=too-many-argumen
191215
return _unwrap_decorator_type(func)(**call_arguments)
192216

193217

218+
def _optional_converters_to_converters(converters: OptionalTaggedConverters) -> TaggedConverters:
219+
"""Convert None to empty dictionary"""
220+
if not converters:
221+
return {}
222+
return converters
223+
224+
225+
def _split_converters(converters: TaggedConverters) \
226+
-> Tuple[Converters, Converters]:
227+
"""Partition into mandatory and optional."""
228+
if not converters:
229+
return {}, {}
230+
mandatory: Converters = {}
231+
optional: Converters = {}
232+
for param_name, (converter, tag) in converters.items():
233+
if tag == ConverterTag.MANDATORY:
234+
mandatory[param_name] = converter
235+
elif tag == ConverterTag.OPTIONAL:
236+
optional[param_name] = converter
237+
else:
238+
assert False
239+
return mandatory, optional
240+
241+
194242
# pylint: disable=too-many-arguments,too-many-return-statements,too-many-branches
195243
def _get_value(func: WrappedOrFunc[TypeT],
196244
value: Any, param_name: str,
197245
skip_superfluous: bool, convert_types: bool,
198-
converters: Optional[Dict[str, Callable[[Any], Any]]]) -> Any:
246+
converters: TaggedConverters) -> Any:
199247
"""Convert a single value into target type if possible."""
200248
if _is_initvar_type(func):
201249
return value
@@ -218,13 +266,22 @@ def _get_value(func: WrappedOrFunc[TypeT],
218266
raise TypeError(f'Parameter {param_name} of target function '
219267
'is missing a type annotation.')
220268

221-
if Any not in allowed_types and param_name != 'self':
222-
if not _isinstanceofone(value, allowed_types):
223-
value_type = type(value)
224-
if converters and param_name in converters:
225-
result = converters[param_name](value)
269+
mandatory_converters, optional_converters = _split_converters(converters)
270+
271+
if param_name != 'self':
272+
value_type = type(value)
273+
if mandatory_converters and param_name in mandatory_converters:
274+
result = mandatory_converters[param_name](value)
275+
if not _isinstanceofone(result, allowed_types):
276+
raise TypeError(f'Mandatory custom conversion for {param_name} '
277+
f'yields incorrect target type: '
278+
f'{_get_type_name(type(result))}')
279+
return result
280+
if Any not in allowed_types and not _isinstanceofone(value, allowed_types):
281+
if optional_converters and param_name in optional_converters:
282+
result = optional_converters[param_name](value)
226283
if not _isinstanceofone(result, allowed_types):
227-
raise TypeError(f'Custom conversion for {param_name} '
284+
raise TypeError(f'Optional custom conversion for {param_name} '
228285
f'yields incorrect target type: '
229286
f'{_get_type_name(type(result))}')
230287
return result
@@ -269,7 +326,7 @@ def _string_to_bool(value: str) -> bool:
269326
def _get_list_value(func: Callable[..., TypeT], # pylint: disable=too-many-arguments
270327
value: Any, log_name: str,
271328
skip_superfluous: bool, convert_types: bool,
272-
converters: Optional[Dict[str, Callable[[Any], Any]]]) -> Any:
329+
converters: TaggedConverters) -> Any:
273330
if not _is_list_type(func) and \
274331
not _is_optional_list_type(func):
275332
raise TypeError(f'No list expected for {log_name}')
@@ -285,7 +342,7 @@ def _get_list_value(func: Callable[..., TypeT], # pylint: disable=too-many-argu
285342

286343
def _get_dict_value(func: Callable[..., TypeT], value: Any,
287344
skip_superfluous: bool, convert_types: bool,
288-
converters: Optional[Dict[str, Callable[[Any], Any]]]) -> Any:
345+
converters: TaggedConverters) -> Any:
289346
assert _is_dict(value)
290347
if _is_optional_type(func):
291348
return _get_optional_type(func)(**value) # type: ignore

undictify/tests.py

+57-11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Union, Tuple
1111
from typing import TypeVar
1212

13+
from ._unpack import optional_converter, mandatory_converter
1314
from ._unpack import type_checked_call, type_checked_constructor
1415

1516
TypeT = TypeVar('TypeT')
@@ -1149,6 +1150,11 @@ def parse_timestamp(datetime_repr: str) -> datetime:
11491150
return datetime.strptime(datetime_repr, '%b %d %Y %I:%M%p')
11501151

11511152

1153+
def concat_changed_to_string(value: str) -> str:
1154+
"""value + '_changed'"""
1155+
return value + '_changed'
1156+
1157+
11521158
def forward_str(str_repr: str) -> str:
11531159
"""Forward string argument."""
11541160
return str_repr
@@ -1175,34 +1181,74 @@ class HasMemberNeedingCustomConverter(NamedTuple):
11751181
class TestCustomConverter(unittest.TestCase):
11761182
"""Sometimes pythons default conversions are not enough."""
11771183

1178-
def test_datetime_ok(self) -> None:
1184+
def test_datetime_ok_optional(self) -> None:
11791185
"""Valid JSON string."""
11801186
object_repr = '{"msg": "hi", "timestamp": "Jun 1 2005 1:33PM"}'
1181-
obj = type_checked_call(converters={'timestamp': parse_timestamp})(NeedingCustomConverter)(
1182-
**json.loads(object_repr))
1187+
obj = type_checked_call(converters={
1188+
'timestamp': optional_converter(parse_timestamp)
1189+
})(NeedingCustomConverter)(**json.loads(object_repr))
11831190
self.assertEqual('hi', obj.msg)
11841191
self.assertEqual(datetime(2005, 6, 1, 13, 33), obj.timestamp)
11851192

1186-
def test_datetime_already_converted(self) -> None:
1193+
def test_datetime_already_converted_optional(self) -> None:
11871194
"""Valid JSON string."""
1188-
obj = type_checked_call(converters={'timestamp': parse_timestamp})(NeedingCustomConverter)(
1189-
**{"msg": "hi", "timestamp": datetime(2005, 6, 1, 13, 33)})
1195+
obj = type_checked_call(converters={
1196+
'timestamp': optional_converter(parse_timestamp)
1197+
})(NeedingCustomConverter)(**{"msg": "hi", "timestamp": datetime(2005, 6, 1, 13, 33)})
11901198
self.assertEqual('hi', obj.msg)
11911199
self.assertEqual(datetime(2005, 6, 1, 13, 33), obj.timestamp)
11921200

11931201
def test_converters_shall_not_be_forwarded(self) -> None:
11941202
"""Custom converters shall only be applied to the outer call."""
11951203
object_repr = '{"needs_conversion": {"msg": "hi", "timestamp": "Jun 1 2005 1:33PM"}}'
11961204
with self.assertRaises(TypeError):
1197-
type_checked_call(converters={'timestamp': parse_timestamp})(
1198-
HasMemberNeedingCustomConverter)(**json.loads(object_repr))
1205+
type_checked_call(converters={
1206+
'timestamp': optional_converter(parse_timestamp)
1207+
})(HasMemberNeedingCustomConverter)(**json.loads(object_repr))
11991208

1200-
def test_invalid_converter_result_type(self) -> None:
1209+
def test_invalid_converter_result_type_optional(self) -> None:
12011210
"""Valid JSON string, but incorrect converter."""
12021211
object_repr = '{"msg": "hi", "timestamp": "Jun 1 2005 1:33PM"}'
12031212
with self.assertRaises(TypeError):
1204-
type_checked_call(converters={'timestamp': forward_str})(NeedingCustomConverter)(
1205-
**json.loads(object_repr))
1213+
type_checked_call(converters={
1214+
'timestamp': optional_converter(forward_str)
1215+
})(NeedingCustomConverter)(**json.loads(object_repr))
1216+
1217+
def test_datetime_ok_mandatory(self) -> None:
1218+
"""Valid JSON string."""
1219+
object_repr = '{"msg": "hi", "timestamp": "Jun 1 2005 1:33PM"}'
1220+
obj = type_checked_call(converters={
1221+
'timestamp': mandatory_converter(parse_timestamp)
1222+
})(NeedingCustomConverter)(**json.loads(object_repr))
1223+
self.assertEqual('hi', obj.msg)
1224+
self.assertEqual(datetime(2005, 6, 1, 13, 33), obj.timestamp)
1225+
1226+
def test_datetime_already_converted_mandatory_ok(self) -> None:
1227+
"""Valid JSON string, but should fail."""
1228+
obj_unchanged = type_checked_call(converters={
1229+
'msg': optional_converter(concat_changed_to_string)
1230+
})(NeedingCustomConverter)(**{"msg": "hi", "timestamp": datetime(2005, 6, 1, 13, 33)})
1231+
self.assertEqual('hi', obj_unchanged.msg)
1232+
1233+
obj_changed = type_checked_call(converters={
1234+
'msg': mandatory_converter(concat_changed_to_string)
1235+
})(NeedingCustomConverter)(**{"msg": "hi", "timestamp": datetime(2005, 6, 1, 13, 33)})
1236+
self.assertEqual('hi_changed', obj_changed.msg)
1237+
1238+
def test_datetime_already_converted_mandatory_fail(self) -> None:
1239+
"""Valid JSON string, but should fail."""
1240+
with self.assertRaises(TypeError):
1241+
type_checked_call(converters={
1242+
'timestamp': mandatory_converter(parse_timestamp)
1243+
})(NeedingCustomConverter)(**{"msg": "hi", "timestamp": datetime(2005, 6, 1, 13, 33)})
1244+
1245+
def test_invalid_converter_result_type_mandatory(self) -> None:
1246+
"""Valid JSON string, but incorrect converter."""
1247+
object_repr = '{"msg": "hi", "timestamp": "Jun 1 2005 1:33PM"}'
1248+
with self.assertRaises(TypeError):
1249+
type_checked_call(converters={
1250+
'timestamp': mandatory_converter(forward_str)
1251+
})(NeedingCustomConverter)(**json.loads(object_repr))
12061252

12071253

12081254
@type_checked_constructor()

0 commit comments

Comments
 (0)