Skip to content

Commit

Permalink
Add type_checked_constructor to preserve meta-information of classes
Browse files Browse the repository at this point in the history
  • Loading branch information
Dobiasd committed Sep 9, 2018
1 parent 647a194 commit 15a76cc
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 37 deletions.
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,6 @@ class Human(NamedTuple):
friend_ids: List[int]
```

(They do not need to be derived from `NamedTuple`. A normal class with a custom `__init__` function or a `@dataclass` works too.)

Having the safety provided by the static type annotations (and probably checking your code with `mypy`) is a great because of all the:
- bugs that don't make it into PROD
- manual type checks (and matching unit tests) that you don't have to write
Expand All @@ -177,17 +175,18 @@ So you decide to use a library that does JSON schema validation for you.
But now you have to manually adjust the schema every time your entity structure changes, which still is not DRY, and thus also brings with it all the typical possibilities to make mistakes.

Undictify can help here too!
Initialization of a `NamedTuple` is just a call to its constructor.
So you simply need to annotate the classes with `type_cheked_call` and you are done:
Annotate the classes `@type_checked_constructor` and their constructors will be wrapped in type-checked calls.
```python
@type_checked_call()
@type_checked_constructor()
class Heart(NamedTuple):
...
@type_checked_call()
@type_checked_constructor()
class Human(NamedTuple):
...
```

(They do not need to be derived from `NamedTuple`. A normal class with a custom `__init__` function or a `@dataclass` works too.)

Undictify will type-check the construction of objects of type `Heart` and `Human` automatically.
(This works for normal classes with a manually written `__init__` function too.
You just need to provide the type annotations to its parameters.) So you can use the usual dictionary unpacking syntax, to safely convert your untyped dictionary (i.e., `Dict[str, Any]`) resulting from the JSON string into your statically typed class:
Expand Down
6 changes: 3 additions & 3 deletions examples/readme_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import json
from typing import List, NamedTuple, Optional, Any

from undictify import type_checked_call
from undictify import type_checked_constructor

__author__ = "Tobias Hermann"
__copyright__ = "Copyright 2018, Tobias Hermann"
Expand Down Expand Up @@ -45,13 +45,13 @@ def get_value() -> Any:
print(f'{value} * 2 == {result}')


@type_checked_call(skip=True)
@type_checked_constructor(skip=True)
class Heart(NamedTuple):
weight_in_kg: float
pulse_at_rest: int


@type_checked_call(skip=True)
@type_checked_constructor(skip=True)
class Human(NamedTuple):
id: int
name: str
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setuptools.setup(
name="undictify",
version="0.3.3",
version="0.4.0",
author="Tobias Hermann",
author_email="[email protected]",
description="Type-checked function calls at runtime",
Expand Down
2 changes: 1 addition & 1 deletion undictify/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from ._unpack import type_checked_call
from ._unpack import type_checked_call, type_checked_constructor

name = "undictify"

Expand Down
103 changes: 91 additions & 12 deletions undictify/_unpack.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import inspect
import sys
from functools import wraps
from typing import Any, Callable, Dict, List, Type, TypeVar, Union

VER_3_7_AND_UP = sys.version_info[:3] >= (3, 7, 0) # PEP 560
Expand All @@ -18,61 +19,134 @@
TypeT = TypeVar('TypeT')


def type_checked_constructor(skip: bool = False,
convert: bool = False) -> Callable[[Callable[..., TypeT]],
Callable[..., TypeT]]:
"""Replaces the constructor of the given class (in-place)
with type-checked calls."""

def call_decorator(func: Callable[..., TypeT]) -> Callable[..., TypeT]:
if not inspect.isclass(func):
raise TypeError('@_type_checked_constructor may only be used for classes.')

if _is_wrapped_func(func):
raise TypeError('Class is already wrapped by undictify.')

# Ideally we could prevent type_checked_constructor to be used
# as a normal function instead of a decorator.
# However this turns out to be very tricky,
# and given solutions break down on some corner cases.
# https://stackoverflow.com/questions/52191968/check-if-a-function-was-called-as-a-decorator

func_name = _get_log_name(func)

signature_new = inspect.signature(func.__new__)
signature_new_param_names = [param.name for param in signature_new.parameters.values()]
if signature_new_param_names != ['args', 'kwargs']:
signature_ctor = signature_new
replace_init = False
original_ctor = func.__new__
else:
original_ctor = func.__init__ # type: ignore
signature_ctor = inspect.signature(original_ctor)
replace_init = True

@wraps(original_ctor)
def wrapper(first_arg: Any, *args: Any, **kwargs: Any) -> TypeT:
kwargs_dict = _merge_args_and_kwargs(
signature_ctor, func_name, [first_arg] + list(args),
kwargs)
return _unpack_dict( # type: ignore
original_ctor,
signature_ctor,
first_arg,
kwargs_dict,
skip,
convert)

if replace_init:
func.__init__ = wrapper # type: ignore
else:
func.__new__ = wrapper # type: ignore
setattr(func, '__undictify_wrapped_func__', func)
return func

return call_decorator


def type_checked_call(skip: bool = False,
convert: bool = False) -> Callable[[Callable[..., TypeT]],
Callable[..., TypeT]]:
"""Decorator that type checks arguments to every call of a function."""
"""Wrap function with type checks."""

def call_decorator(func: Callable[..., TypeT]) -> Callable[..., TypeT]:
if _is_wrapped_func(func):
raise TypeError('Function is already wrapped by undictify.')

signature = inspect.signature(func)
func_name = _get_log_name(func)

@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> TypeT:
return _unpack_dict(func, # type: ignore
_merge_args_and_kwargs(func, *args, **kwargs),
skip, convert)
kwargs_dict = _merge_args_and_kwargs(signature, func_name,
args, kwargs)
return _unpack_dict( # type: ignore
func,
signature,
None,
kwargs_dict,
skip,
convert)

setattr(wrapper, '__undictify_wrapped_func__', func)
return wrapper

return call_decorator


def _get_log_name(var: Any) -> str:
"""Return var.__name__ if available, 'this object' otherwise."""
try:
return str(var.__name__)
except AttributeError:
return 'this object'


WrappedOrFunc = Callable[..., TypeT]


def _is_wrapped_func(func: WrappedOrFunc) -> bool:
return hasattr(func, '__undictify_wrapped_func__')


def _merge_args_and_kwargs(func: Callable[..., TypeT],
*args: Any, **kwargs: Any) -> Dict[str, Any]:
def _merge_args_and_kwargs(signature: inspect.Signature, name: str,
args: Any, kwargs: Any) -> Dict[str, Any]:
"""Returns one kwargs dictionary or
raises an exeption in case of overlapping-name problems."""
signature = inspect.signature(func)
param_names = [param.name for param in signature.parameters.values()]
if len(args) > len(param_names):
raise TypeError(f'Too many parameters for {func.__name__}.')
raise TypeError(f'Too many parameters for {name}.')
args_as_kwargs = dict(zip(param_names, list(args)))
keys_in_args_and_kwargs = set.intersection(set(args_as_kwargs.keys()),
set(kwargs.keys()))
if keys_in_args_and_kwargs:
raise TypeError(f'The following parameters are given as '
f'arg and kwarg in call of {func.__name__}: '
f'arg and kwarg in call of {name}: '
f'{keys_in_args_and_kwargs}')

return {**args_as_kwargs, **kwargs}


def _unpack_dict(func: WrappedOrFunc,
def _unpack_dict(func: WrappedOrFunc, # pylint: disable=too-many-arguments
signature: inspect.Signature,
first_arg: Any,
data: Dict[str, Any],
skip_superfluous: bool,
convert_types: bool) -> Any:
"""Constructs an object in a type-safe way from a dictionary."""

assert _is_dict(data), 'Argument data needs to be a dictionary.'

signature = inspect.signature(_unwrap_decorator_type(func))
ctor_params: Dict[str, Any] = {}

if not skip_superfluous:
Expand All @@ -82,7 +156,10 @@ def _unpack_dict(func: WrappedOrFunc,
if superfluous:
raise TypeError(f'Superfluous parameters in call: {superfluous}')

for param in signature.parameters.values():
parameter_values = list(signature.parameters.values())
if first_arg is not None:
parameter_values = parameter_values[1:]
for param in parameter_values:
if param.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD:
raise TypeError('Only parameters of kind POSITIONAL_OR_KEYWORD '
'supported in target functions.')
Expand All @@ -104,6 +181,8 @@ def _unpack_dict(func: WrappedOrFunc,
skip_superfluous,
convert_types)

if first_arg is not None:
return _unwrap_decorator_type(func)(first_arg, **ctor_params)
return _unwrap_decorator_type(func)(**ctor_params)


Expand Down
Loading

0 comments on commit 15a76cc

Please sign in to comment.