Skip to content

Commit 6c4b773

Browse files
authored
feat(python): add dynamic type checking (#3660)
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
1 parent 68a80d9 commit 6c4b773

File tree

8 files changed

+1283
-63
lines changed

8 files changed

+1283
-63
lines changed

packages/@jsii/python-runtime/setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
install_requires=[
3333
"attrs~=21.2",
3434
"cattrs>=1.8,<22.2",
35+
"publication>=0.0.3", # This is used by all generated code.
36+
"typeguard~=2.13.3", # This is used by all generated code.
3537
"python-dateutil",
3638
"typing_extensions>=3.7,<5.0",
3739
],

packages/@jsii/python-runtime/src/jsii/_reference_map.py

Lines changed: 33 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# This module exists to break an import cycle between jsii.runtime and jsii.kernel
22
import inspect
33

4-
from typing import Any, MutableMapping, Type
4+
from typing import Any, Iterable, Mapping, MutableMapping, Type
55

66

77
_types = {}
@@ -108,18 +108,20 @@ def resolve(self, kernel, ref):
108108

109109
structs = [_data_types[fqn] for fqn in ref.interfaces]
110110
remote_struct = _FakeReference(ref)
111-
insts = [
112-
struct(
113-
**{
114-
python_name: kernel.get(remote_struct, jsii_name)
115-
for python_name, jsii_name in python_jsii_mapping(
116-
struct
117-
).items()
118-
}
119-
)
120-
for struct in structs
121-
]
122-
return StructDynamicProxy(insts)
111+
112+
if len(structs) == 1:
113+
struct = structs[0]
114+
else:
115+
struct = new_combined_struct(structs)
116+
117+
return struct(
118+
**{
119+
python_name: kernel.get(remote_struct, jsii_name)
120+
for python_name, jsii_name in python_jsii_mapping(
121+
struct
122+
).items()
123+
}
124+
)
123125
else:
124126
return InterfaceDynamicProxy(self.build_interface_proxies_for_ref(ref))
125127
else:
@@ -158,43 +160,31 @@ def __setattr__(self, name, value):
158160
raise AttributeError(f"'%s' object has no attribute '%s'" % (type_info, name))
159161

160162

161-
class StructDynamicProxy(object):
162-
def __init__(self, delegates):
163-
self._delegates = delegates
164-
165-
def __getattr__(self, name):
166-
for delegate in self._delegates:
167-
if hasattr(delegate, name):
168-
return getattr(delegate, name)
169-
type_info = "+".join([str(delegate.__class__) for delegate in self._delegates])
170-
raise AttributeError("'%s' object has no attribute '%s'" % (type_info, name))
163+
def new_combined_struct(structs: Iterable[Type]) -> Type:
164+
label = " + ".join(struct.__name__ for struct in structs)
171165

172-
def __setattr__(self, name, value):
173-
if name == "_delegates":
174-
return super.__setattr__(self, name, value)
175-
for delegate in self._delegates:
176-
if hasattr(delegate, name):
177-
return setattr(delegate, name, value)
178-
type_info = "+".join([str(delegate.__class__) for delegate in self._delegates])
179-
raise AttributeError(f"'%s' object has no attribute '%s'" % (type_info, name))
166+
def __init__(self, **kwargs):
167+
self._values: Mapping[str, Any] = kwargs
180168

181-
def __eq__(self, rhs) -> bool:
182-
if len(self._delegates) == 1:
183-
return rhs == self._delegates[0]
169+
def __eq__(self, rhs: Any) -> bool:
184170
return isinstance(rhs, self.__class__) and rhs._values == self._values
185171

186-
def __ne__(self, rhs) -> bool:
172+
def __ne__(self, rhs: Any) -> bool:
187173
return not (rhs == self)
188174

189175
def __repr__(self) -> str:
190-
if len(self._delegates) == 1:
191-
return self._delegates[0].__repr__()
192-
return "%s(%s)" % (
193-
" & ".join(
194-
[delegate.__class__.__jsii_type__ for delegate in self._delegates]
195-
),
196-
", ".join(k + "=" + repr(v) for k, v in self._values.items()),
197-
)
176+
return f"<{label}>({', '.join(k + '=' + repr(v) for k, v in self._values.items())})"
177+
178+
return type(
179+
label,
180+
(*structs,),
181+
{
182+
"__init__": __init__,
183+
"__eq__": __eq__,
184+
"__ne__": __ne__,
185+
"__repr__": __repr__,
186+
},
187+
)
198188

199189

200190
_refs = _ReferenceMap(_types)

packages/@jsii/python-runtime/tests/test_python.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import jsii
22
import pytest
3-
from typing import Any, cast
3+
import re
44

55
from jsii.errors import JSIIError
66
import jsii_calc
@@ -28,7 +28,12 @@ def test_inheritance_maintained(self):
2828
def test_descriptive_error_when_passing_function(self):
2929
obj = jsii_calc.Calculator()
3030

31-
with pytest.raises(JSIIError, match="Cannot pass function as argument here.*"):
31+
with pytest.raises(
32+
TypeError,
33+
match=re.escape(
34+
"type of argument value must be one of (int, float); got method instead"
35+
),
36+
):
3237
# types: ignore
3338
obj.add(self.test_descriptive_error_when_passing_function)
3439

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import pytest
2+
import re
3+
4+
import jsii_calc
5+
6+
7+
class TestRuntimeTypeChecking:
8+
"""
9+
These tests verify that runtime type checking performs the necessary validations and produces error messages that
10+
are indicative of the error. There are #type:ignore annotations scattered everywhere as these tests are obviously
11+
attempting to demonstrate what happens when invalid calls are being made.
12+
"""
13+
14+
def test_constructor(self):
15+
with pytest.raises(
16+
TypeError,
17+
match=re.escape(
18+
"type of argument initial_value must be one of (int, float, NoneType); got str instead"
19+
),
20+
):
21+
jsii_calc.Calculator(initial_value="nope") # type:ignore
22+
23+
def test_struct(self):
24+
with pytest.raises(
25+
TypeError,
26+
match=re.escape(
27+
"type of argument foo must be jsii_calc.StringEnum; got int instead"
28+
),
29+
):
30+
jsii_calc.StructWithEnum(foo=1337) # type:ignore
31+
32+
def test_method_arg(self):
33+
subject = jsii_calc.Calculator()
34+
with pytest.raises(
35+
TypeError,
36+
match=re.escape(
37+
"type of argument value must be one of (int, float); got str instead"
38+
),
39+
):
40+
subject.mul("Not a Number") # type:ignore
41+
42+
def test_method_kwarg(self):
43+
subject = jsii_calc.DocumentedClass()
44+
with pytest.raises(
45+
TypeError,
46+
match=re.escape(
47+
"type of argument name must be one of (str, NoneType); got int instead"
48+
),
49+
):
50+
subject.greet(name=1337) # type:ignore
51+
52+
def test_method_vararg(self):
53+
subject = jsii_calc.StructPassing()
54+
with pytest.raises(
55+
TypeError,
56+
match=re.escape(
57+
"type of argument inputs[0] must be jsii_calc.TopLevelStruct; got int instead"
58+
),
59+
):
60+
subject.how_many_var_args_did_i_pass(1337, 42) # type:ignore
61+
62+
def test_setter_to_enum(self):
63+
subject = jsii_calc.AllTypes()
64+
with pytest.raises(
65+
TypeError,
66+
match=re.escape(
67+
"type of argument value must be jsii_calc.AllTypesEnum; got int instead"
68+
),
69+
):
70+
subject.enum_property = 1337 # type:ignore
71+
72+
def test_setter_to_primitive(self):
73+
subject = jsii_calc.AllTypes()
74+
with pytest.raises(
75+
TypeError,
76+
match=re.escape("type of argument value must be str; got int instead"),
77+
):
78+
subject.string_property = 1337 # type:ignore
79+
80+
def test_setter_to_map(self):
81+
subject = jsii_calc.AllTypes()
82+
with pytest.raises(
83+
TypeError,
84+
match=re.escape(
85+
"type of argument value must be collections.abc.Mapping; got jsii_calc.StructWithEnum instead"
86+
),
87+
):
88+
subject.map_property = jsii_calc.StructWithEnum( # type:ignore
89+
foo=jsii_calc.StringEnum.A
90+
)
91+
92+
def test_setter_to_list(self):
93+
subject = jsii_calc.AllTypes()
94+
with pytest.raises(
95+
TypeError,
96+
match=re.escape("type of argument value must be a list; got int instead"),
97+
):
98+
subject.array_property = 1337 # type:ignore
99+
100+
def test_setter_to_list_with_invalid_value(self):
101+
subject = jsii_calc.AllTypes()
102+
with pytest.raises(
103+
TypeError,
104+
match=re.escape("type of argument value[0] must be str; got int instead"),
105+
):
106+
subject.array_property = [1337] # type:ignore
107+
108+
def test_setter_to_union(self):
109+
subject = jsii_calc.AllTypes()
110+
with pytest.raises(
111+
TypeError,
112+
match=re.escape(
113+
"type of argument value must be one of (str, int, float, scope.jsii_calc_lib.Number, jsii_calc.Multiply); got jsii_calc.StringEnum instead"
114+
),
115+
):
116+
subject.union_property = jsii_calc.StringEnum.B # type:ignore

0 commit comments

Comments
 (0)