Skip to content

Commit

Permalink
feat: Implement strict type checking
Browse files Browse the repository at this point in the history
  • Loading branch information
yukinarit committed Jul 5, 2022
1 parent e313fa8 commit 0273b9a
Show file tree
Hide file tree
Showing 22 changed files with 622 additions and 189 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Foo(i=10, s='foo', f=100.0, b=True)
- [`numpy`](https://github.com/numpy/numpy) types
- [Attributes](docs/features/attributes.md)
- [Decorators](docs/features/decorators.md)
- [TypeCheck](docs/features/type-check.md)
- [Union Representation](docs/features/union.md)
- [Python 3.10 Union operator](docs/features/union-operator.md)
- [Python 3.9 type hinting](docs/features/python3.9-type-hinting.md)
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- [Features](features/summary.md)
- [Attributes](features/attributes.md)
- [Decorators](features/decorators.md)
- [TypeCheck](features/type-check.md)
- [Union Representation](features/union.md)
- [Python 3.10 Union operator](features/union-operator.md)
- [Python 3.9 type hinting](features/python3.9-type-hinting.md)
Expand Down
43 changes: 43 additions & 0 deletions docs/features/type-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Type Checking

`pyserde` provides 3 kinds of type checks.

### `NoCheck`

This is the default behavior until pyserde v0.8.3. No type coercion or checks are applied. If a user puts a wrong value, pyserde doesn't complain anything.

```python
@serde
@dataclass
class Foo
s: str

foo = Foo(10)
# pyserde doesn't complain anything. {"s": 10} will be printed.
print(to_json(foo, type_check=NoCheck))

```

### `Coerce`

Type coercion is not yet implemented but this will be the default behavior in pyserde v0.9.0 onward.

```python
foo = Foo(10)
# pyserde automatically coerce the int value 10 into "10".
# {"s": "10"} will be printed.
print(to_json(foo, type_check=Coerce))
```

Not yet implemented.

### `Strict`

Strict type checking is to check every value against the declared type.

```python
foo = Foo(10)
# pyserde checks the value 10 is instance of `str`.
# SerdeError will be raised in this case because of the type mismatch.
print(to_json(foo, type_check=Strict))
```
1 change: 1 addition & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Foo(i=10, s='foo', f=100.0, b=True)
- [`ipaddress`](https://docs.python.org/3/library/ipaddress.html)
- [Attributes](features/attributes.md)
- [Decorators](features/decorators.md)
- [TypeCheck](features/type-check.md)
- [Union Representation](features/union.md)
- [Python 3.10 Union operator](features/union-operator.md)
- [Python 3.9 type hinting](features/python3.9-type-hinting.md)
Expand Down
6 changes: 3 additions & 3 deletions examples/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
class Foo:
l: List[str]
t: Tuple[str, bool]
d: Dict[str, List[str]]
d: Dict[str, List[int]]


# For python >= 3.9, you can use [PEP585](https://www.python.org/dev/peps/pep-0585/)
Expand All @@ -31,10 +31,10 @@ class FooPy39:
def main():
cls = Foo if not PY39 else FooPy39

h = cls([1, 2], ('foo', True), {'bar': [10, 20]})
h = cls(["1", "2"], ('foo', True), {'bar': [10, 20]})
print(f"Into Json: {to_json(h)}")

s = '{"l": [1, 2], "t": ["foo", true], "d": {"bar": [10, 20]}}'
s = '{"l": ["1", "2"], "t": ["foo", true], "d": {"bar": [10, 20]}}'
print(f"From Json: {from_json(cls, s)}")


Expand Down
2 changes: 2 additions & 0 deletions examples/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import simple
import skip
import tomlfile
import type_check_strict
import type_datetime
import type_decimal
import union
Expand Down Expand Up @@ -53,6 +54,7 @@ def run_all():
run(lazy_type_evaluation)
run(literal)
run(user_exception)
run(type_check_strict)
if PY310:
import union_operator

Expand Down
31 changes: 31 additions & 0 deletions examples/type_check_strict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from dataclasses import dataclass
from typing import Dict, List

from serde import SerdeError, Strict, serde
from serde.json import from_json, to_json


@serde
@dataclass
class Foo:
a: int
b: List[int]
c: List[Dict[str, int]]


def main():
f = Foo(a=1.0, b=[1.0], c=[{"k": 1.0}])
try:
print(f"Into Json: {to_json(f, type_check=Strict)}")
except Exception as e:
print(e)

s = '{"a": 1, "b": [1], "c": [{"k": 1.0}]}'
try:
print(f"From Json: {from_json(Foo, s, type_check=Strict)}")
except Exception as e:
print(e)


if __name__ == '__main__':
main()
5 changes: 5 additions & 0 deletions serde/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@
from .compat import SerdeError, SerdeSkip
from .core import (
AdjacentTagging,
Coerce,
ExternalTagging,
InternalTagging,
Strict,
Untagged,
field,
init,
Expand All @@ -69,6 +71,9 @@
"ExternalTagging",
"InternalTagging",
"Untagged",
"NoCheck",
"Strict",
"Coerce",
"field",
"default_deserializer",
"deserialize",
Expand Down
122 changes: 96 additions & 26 deletions serde/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@
Module for compatibility.
"""
import dataclasses
import datetime
import decimal
import enum
import functools
import ipaddress
import itertools
import pathlib
import sys
import types
import typing
import uuid
from dataclasses import is_dataclass
from typing import Any, ClassVar, Dict, Generic, Iterator, List, Optional, Set, Tuple, Type, TypeVar, Union

Expand All @@ -15,6 +21,10 @@
if sys.version_info[:2] == (3, 7):
import typing_extensions

Literal = typing_extensions.Literal
else:
Literal = typing.Literal

try:
if sys.version_info[:2] <= (3, 8):
import numpy.typing as npt
Expand Down Expand Up @@ -53,6 +63,28 @@ def get_np_args(tp):
T = TypeVar('T')


StrSerializableTypes = (
decimal.Decimal,
pathlib.Path,
pathlib.PosixPath,
pathlib.WindowsPath,
pathlib.PurePath,
pathlib.PurePosixPath,
pathlib.PureWindowsPath,
uuid.UUID,
ipaddress.IPv4Address,
ipaddress.IPv6Address,
ipaddress.IPv4Network,
ipaddress.IPv6Network,
ipaddress.IPv4Interface,
ipaddress.IPv6Interface,
)
""" List of standard types (de)serializable to str """

DateTimeTypes = (datetime.date, datetime.time, datetime.datetime)
""" List of datetime types """


class SerdeError(Exception):
"""
Serde error class.
Expand Down Expand Up @@ -94,7 +126,7 @@ def get_args(typ):
return typing_inspect.get_args(typ) or get_np_args(typ)


def typename(typ) -> str:
def typename(typ, with_typing_module: bool = False) -> str:
"""
>>> from typing import List, Dict, Set, Any
>>> typename(int)
Expand All @@ -117,61 +149,63 @@ def typename(typ) -> str:
>>> typename(Any)
'Any'
"""
mod = "typing." if with_typing_module else ""
thisfunc = functools.partial(typename, with_typing_module=with_typing_module)
if is_opt(typ):
args = type_args(typ)
if args:
return f'Optional[{typename(type_args(typ)[0])}]'
return f'{mod}Optional[{thisfunc(type_args(typ)[0])}]'
else:
return 'Optional'
return f'{mod}Optional'
elif is_union(typ):
args = union_args(typ)
if args:
return f'Union[{", ".join([typename(e) for e in args])}]'
return f'{mod}Union[{", ".join([thisfunc(e) for e in args])}]'
else:
return 'Union'
return f'{mod}Union'
elif is_list(typ):
# Workaround for python 3.7.
# get_args for the bare List returns parameter T.
if typ is List:
return 'List'
return f'{mod}List'

args = type_args(typ)
if args:
et = typename(args[0])
return f'List[{et}]'
et = thisfunc(args[0])
return f'{mod}List[{et}]'
else:
return 'List'
return f'{mod}List'
elif is_set(typ):
# Workaround for python 3.7.
# get_args for the bare Set returns parameter T.
if typ is Set:
return 'Set'
return f'{mod}Set'

args = type_args(typ)
if args:
et = typename(args[0])
return f'Set[{et}]'
et = thisfunc(args[0])
return f'{mod}Set[{et}]'
else:
return 'Set'
return f'{mod}Set'
elif is_dict(typ):
# Workaround for python 3.7.
# get_args for the bare Dict returns parameter K, V.
if typ is Dict:
return 'Dict'
return f'{mod}Dict'

args = type_args(typ)
if args and len(args) == 2:
kt = typename(args[0])
vt = typename(args[1])
return f'Dict[{kt}, {vt}]'
kt = thisfunc(args[0])
vt = thisfunc(args[1])
return f'{mod}Dict[{kt}, {vt}]'
else:
return 'Dict'
return f'{mod}Dict'
elif is_tuple(typ):
args = type_args(typ)
if args:
return f'Tuple[{", ".join([typename(e) for e in args])}]'
return f'{mod}Tuple[{", ".join([thisfunc(e) for e in args])}]'
else:
return 'Tuple'
return f'{mod}Tuple'
elif is_generic(typ):
return get_origin(typ).__name__
elif is_literal(typ):
Expand All @@ -180,7 +214,7 @@ def typename(typ) -> str:
raise TypeError("Literal type requires at least one literal argument")
return f'Literal[{", ".join(repr(e) for e in args)}]'
elif typ is Any:
return 'Any'
return f'{mod}Any'
else:
name = getattr(typ, '_name', None)
if name:
Expand Down Expand Up @@ -568,11 +602,18 @@ def is_primitive(typ) -> bool:
try:
return any(issubclass(typ, ty) for ty in PRIMITIVES)
except TypeError:
inner = getattr(typ, '__supertype__', None)
if inner:
return is_primitive(inner)
else:
return any(isinstance(typ, ty) for ty in PRIMITIVES)
return is_new_type_primitive(typ)


def is_new_type_primitive(typ) -> bool:
"""
Test if the type is a NewType of primitives.
"""
inner = getattr(typ, '__supertype__', None)
if inner:
return is_primitive(inner)
else:
return any(isinstance(typ, ty) for ty in PRIMITIVES)


def is_generic(typ) -> bool:
Expand Down Expand Up @@ -609,6 +650,35 @@ def is_literal(typ) -> bool:
return origin is typing.Literal


def is_any(typ) -> bool:
"""
Test if the type is `typing.Any`.
"""
return typ is Any


def is_str_serializable(typ) -> bool:
"""
Test if the type is serializable to `str`.
"""
return typ in StrSerializableTypes


def is_datetime(typ) -> bool:
"""
Test if the type is any of the datetime types..
"""
return typ in DateTimeTypes


def is_str_serializable_instance(obj) -> bool:
return isinstance(obj, StrSerializableTypes)


def is_datetime_instance(obj) -> bool:
return isinstance(obj, DateTimeTypes)


def find_generic_arg(cls, field) -> int:
"""
Find a type in generic parameters.
Expand Down
Loading

0 comments on commit 0273b9a

Please sign in to comment.