Skip to content

Commit

Permalink
feat(python): add dynamic type checking (#3660)
Browse files Browse the repository at this point in the history
Use `typeguard` to perform runtime type checking of arguments passed
into methods (static or instance), setters, and constructors. This
ensures a pythonic error message is produced (and raised as a
`TypeError`), to help developers identify bugs in their code and fix
them.

These checks are disabled when running Python in optimized mode (via
`python3 -O`, which sets `__debug__` to false).

Fixes #3639 

---

By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license].

[Apache 2.0 license]: https://www.apache.org/licenses/LICENSE-2.0
  • Loading branch information
RomainMuller committed Jul 21, 2022
1 parent 68a80d9 commit 6c4b773
Show file tree
Hide file tree
Showing 8 changed files with 1,283 additions and 63 deletions.
2 changes: 2 additions & 0 deletions packages/@jsii/python-runtime/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
install_requires=[
"attrs~=21.2",
"cattrs>=1.8,<22.2",
"publication>=0.0.3", # This is used by all generated code.
"typeguard~=2.13.3", # This is used by all generated code.
"python-dateutil",
"typing_extensions>=3.7,<5.0",
],
Expand Down
76 changes: 33 additions & 43 deletions packages/@jsii/python-runtime/src/jsii/_reference_map.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# This module exists to break an import cycle between jsii.runtime and jsii.kernel
import inspect

from typing import Any, MutableMapping, Type
from typing import Any, Iterable, Mapping, MutableMapping, Type


_types = {}
Expand Down Expand Up @@ -108,18 +108,20 @@ def resolve(self, kernel, ref):

structs = [_data_types[fqn] for fqn in ref.interfaces]
remote_struct = _FakeReference(ref)
insts = [
struct(
**{
python_name: kernel.get(remote_struct, jsii_name)
for python_name, jsii_name in python_jsii_mapping(
struct
).items()
}
)
for struct in structs
]
return StructDynamicProxy(insts)

if len(structs) == 1:
struct = structs[0]
else:
struct = new_combined_struct(structs)

return struct(
**{
python_name: kernel.get(remote_struct, jsii_name)
for python_name, jsii_name in python_jsii_mapping(
struct
).items()
}
)
else:
return InterfaceDynamicProxy(self.build_interface_proxies_for_ref(ref))
else:
Expand Down Expand Up @@ -158,43 +160,31 @@ def __setattr__(self, name, value):
raise AttributeError(f"'%s' object has no attribute '%s'" % (type_info, name))


class StructDynamicProxy(object):
def __init__(self, delegates):
self._delegates = delegates

def __getattr__(self, name):
for delegate in self._delegates:
if hasattr(delegate, name):
return getattr(delegate, name)
type_info = "+".join([str(delegate.__class__) for delegate in self._delegates])
raise AttributeError("'%s' object has no attribute '%s'" % (type_info, name))
def new_combined_struct(structs: Iterable[Type]) -> Type:
label = " + ".join(struct.__name__ for struct in structs)

def __setattr__(self, name, value):
if name == "_delegates":
return super.__setattr__(self, name, value)
for delegate in self._delegates:
if hasattr(delegate, name):
return setattr(delegate, name, value)
type_info = "+".join([str(delegate.__class__) for delegate in self._delegates])
raise AttributeError(f"'%s' object has no attribute '%s'" % (type_info, name))
def __init__(self, **kwargs):
self._values: Mapping[str, Any] = kwargs

def __eq__(self, rhs) -> bool:
if len(self._delegates) == 1:
return rhs == self._delegates[0]
def __eq__(self, rhs: Any) -> bool:
return isinstance(rhs, self.__class__) and rhs._values == self._values

def __ne__(self, rhs) -> bool:
def __ne__(self, rhs: Any) -> bool:
return not (rhs == self)

def __repr__(self) -> str:
if len(self._delegates) == 1:
return self._delegates[0].__repr__()
return "%s(%s)" % (
" & ".join(
[delegate.__class__.__jsii_type__ for delegate in self._delegates]
),
", ".join(k + "=" + repr(v) for k, v in self._values.items()),
)
return f"<{label}>({', '.join(k + '=' + repr(v) for k, v in self._values.items())})"

return type(
label,
(*structs,),
{
"__init__": __init__,
"__eq__": __eq__,
"__ne__": __ne__,
"__repr__": __repr__,
},
)


_refs = _ReferenceMap(_types)
Expand Down
9 changes: 7 additions & 2 deletions packages/@jsii/python-runtime/tests/test_python.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import jsii
import pytest
from typing import Any, cast
import re

from jsii.errors import JSIIError
import jsii_calc
Expand Down Expand Up @@ -28,7 +28,12 @@ def test_inheritance_maintained(self):
def test_descriptive_error_when_passing_function(self):
obj = jsii_calc.Calculator()

with pytest.raises(JSIIError, match="Cannot pass function as argument here.*"):
with pytest.raises(
TypeError,
match=re.escape(
"type of argument value must be one of (int, float); got method instead"
),
):
# types: ignore
obj.add(self.test_descriptive_error_when_passing_function)

Expand Down
116 changes: 116 additions & 0 deletions packages/@jsii/python-runtime/tests/test_runtime_type_checking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import pytest
import re

import jsii_calc


class TestRuntimeTypeChecking:
"""
These tests verify that runtime type checking performs the necessary validations and produces error messages that
are indicative of the error. There are #type:ignore annotations scattered everywhere as these tests are obviously
attempting to demonstrate what happens when invalid calls are being made.
"""

def test_constructor(self):
with pytest.raises(
TypeError,
match=re.escape(
"type of argument initial_value must be one of (int, float, NoneType); got str instead"
),
):
jsii_calc.Calculator(initial_value="nope") # type:ignore

def test_struct(self):
with pytest.raises(
TypeError,
match=re.escape(
"type of argument foo must be jsii_calc.StringEnum; got int instead"
),
):
jsii_calc.StructWithEnum(foo=1337) # type:ignore

def test_method_arg(self):
subject = jsii_calc.Calculator()
with pytest.raises(
TypeError,
match=re.escape(
"type of argument value must be one of (int, float); got str instead"
),
):
subject.mul("Not a Number") # type:ignore

def test_method_kwarg(self):
subject = jsii_calc.DocumentedClass()
with pytest.raises(
TypeError,
match=re.escape(
"type of argument name must be one of (str, NoneType); got int instead"
),
):
subject.greet(name=1337) # type:ignore

def test_method_vararg(self):
subject = jsii_calc.StructPassing()
with pytest.raises(
TypeError,
match=re.escape(
"type of argument inputs[0] must be jsii_calc.TopLevelStruct; got int instead"
),
):
subject.how_many_var_args_did_i_pass(1337, 42) # type:ignore

def test_setter_to_enum(self):
subject = jsii_calc.AllTypes()
with pytest.raises(
TypeError,
match=re.escape(
"type of argument value must be jsii_calc.AllTypesEnum; got int instead"
),
):
subject.enum_property = 1337 # type:ignore

def test_setter_to_primitive(self):
subject = jsii_calc.AllTypes()
with pytest.raises(
TypeError,
match=re.escape("type of argument value must be str; got int instead"),
):
subject.string_property = 1337 # type:ignore

def test_setter_to_map(self):
subject = jsii_calc.AllTypes()
with pytest.raises(
TypeError,
match=re.escape(
"type of argument value must be collections.abc.Mapping; got jsii_calc.StructWithEnum instead"
),
):
subject.map_property = jsii_calc.StructWithEnum( # type:ignore
foo=jsii_calc.StringEnum.A
)

def test_setter_to_list(self):
subject = jsii_calc.AllTypes()
with pytest.raises(
TypeError,
match=re.escape("type of argument value must be a list; got int instead"),
):
subject.array_property = 1337 # type:ignore

def test_setter_to_list_with_invalid_value(self):
subject = jsii_calc.AllTypes()
with pytest.raises(
TypeError,
match=re.escape("type of argument value[0] must be str; got int instead"),
):
subject.array_property = [1337] # type:ignore

def test_setter_to_union(self):
subject = jsii_calc.AllTypes()
with pytest.raises(
TypeError,
match=re.escape(
"type of argument value must be one of (str, int, float, scope.jsii_calc_lib.Number, jsii_calc.Multiply); got jsii_calc.StringEnum instead"
),
):
subject.union_property = jsii_calc.StringEnum.B # type:ignore
Loading

0 comments on commit 6c4b773

Please sign in to comment.