Skip to content

Commit bd44587

Browse files
committed
Use typehints to cast attributes to the correct type if possible.
1 parent 7e86760 commit bd44587

File tree

4 files changed

+130
-24
lines changed

4 files changed

+130
-24
lines changed

django_unicorn/views.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import orjson
1313
from bs4 import BeautifulSoup
14+
from cachetools.lru import LRUCache
1415

1516
from .call_method_parser import InvalidKwarg, parse_call_method_name, parse_kwarg
1617
from .components import UnicornField, UnicornView
@@ -26,7 +27,14 @@
2627
logger.setLevel(logging.DEBUG)
2728

2829

30+
type_hints_cache = LRUCache(maxsize=100)
31+
32+
2933
def handle_error(view_func):
34+
"""
35+
Returns a JSON response with an error if necessary.
36+
"""
37+
3038
def wrapped_view(*args, **kwargs):
3139
try:
3240
return view_func(*args, **kwargs)
@@ -38,6 +46,34 @@ def wrapped_view(*args, **kwargs):
3846
return wraps(view_func)(wrapped_view)
3947

4048

49+
def _get_type_hints(obj):
50+
"""
51+
Get type hints from an object. These get cached in a local memory cache for quicker look-up later.
52+
53+
Returns:
54+
An empty dictionary if no type hints can be retrieved.
55+
"""
56+
try:
57+
if obj in type_hints_cache:
58+
return type_hints_cache[obj]
59+
except TypeError:
60+
# Ignore issues with checking for an object in the cache, e.g. when a Django model is missing a PK
61+
pass
62+
63+
try:
64+
type_hints = get_type_hints(obj)
65+
66+
# Cache the type hints just in case
67+
type_hints_cache[obj] = type_hints
68+
69+
return type_hints
70+
except TypeError:
71+
# Return an empty dictionary when there is a TypeError. From `get_type_hints`: "TypeError is
72+
# raised if the argument is not of a type that can contain annotations, and an empty dictionary
73+
# is returned if no annotations are present"
74+
return {}
75+
76+
4177
@timed
4278
def _is_component_field_model_or_unicorn_field(
4379
component_or_field: Union[UnicornView, UnicornField, Model], name: str,
@@ -66,7 +102,7 @@ def _is_component_field_model_or_unicorn_field(
66102
component_type_hints = {}
67103

68104
try:
69-
component_type_hints = get_type_hints(component_or_field)
105+
component_type_hints = _get_type_hints(component_or_field)
70106

71107
if name in component_type_hints:
72108
is_subclass_of_model = issubclass(component_type_hints[name], Model)
@@ -81,7 +117,7 @@ def _is_component_field_model_or_unicorn_field(
81117
if is_subclass_of_model or is_subclass_of_unicorn_field:
82118
field = component_type_hints[name]()
83119
setattr(component_or_field, name, field)
84-
except TypeError as e:
120+
except TypeError:
85121
pass
86122

87123
return is_subclass_of_model or is_subclass_of_unicorn_field
@@ -115,6 +151,15 @@ def _set_property_from_data(
115151
else:
116152
_set_property_from_data(field, field.name, value)
117153
else:
154+
type_hints = _get_type_hints(component_or_field)
155+
156+
if name in type_hints:
157+
# Construct the specified type by passing the value in
158+
# Usually the value will be a string (because it is coming from JSON)
159+
# and basic types can be constructed by passing in a string,
160+
# i.e. int("1") or float("1.1")
161+
value = type_hints[name](value)
162+
118163
if hasattr(component_or_field, "_set_property"):
119164
# Can assume that `component_or_field` is a component
120165
component_or_field._set_property(name, value)

tests/views/message/test_sync_input.py

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,18 @@
1-
import time
2-
31
import orjson
4-
import shortuuid
52

6-
from django_unicorn.utils import generate_checksum
3+
from tests.views.message.utils import post_and_get_response
74

85

96
def test_message_nested_sync_input(client):
107
data = {"dictionary": {"name": "test"}}
11-
message = {
12-
"actionQueue": [
13-
{
14-
"payload": {"name": "dictionary.name", "value": "test1"},
15-
"type": "syncInput",
16-
}
17-
],
18-
"data": data,
19-
"checksum": generate_checksum(orjson.dumps(data)),
20-
"id": shortuuid.uuid()[:8],
21-
"epoch": time.time(),
22-
}
23-
24-
response = client.post(
25-
"/message/tests.views.fake_components.FakeComponent",
26-
message,
27-
content_type="application/json",
8+
action_queue = [
9+
{"payload": {"name": "dictionary.name", "value": "test1"}, "type": "syncInput",}
10+
]
11+
response = post_and_get_response(
12+
client,
13+
url="/message/tests.views.fake_components.FakeComponent",
14+
data=data,
15+
action_queue=action_queue,
2816
)
2917

3018
body = orjson.loads(response.content)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from decimal import Decimal
2+
3+
import orjson
4+
5+
from django_unicorn.components import UnicornView
6+
from tests.views.message.utils import post_and_get_response
7+
8+
9+
class FakeObjectsComponent(UnicornView):
10+
template_name = "templates/test_component.html"
11+
12+
decimal_example: Decimal = Decimal(1.1)
13+
float_example: float = 1.2
14+
int_example: int = 3
15+
16+
def assert_int(self):
17+
assert self.int_example == 4
18+
19+
def assert_float(self):
20+
assert self.float_example == 1.3
21+
22+
def assert_decimal(self):
23+
assert self.decimal_example == Decimal(1.5)
24+
25+
26+
FAKE_OBJECTS_COMPONENT_URL = (
27+
"/message/tests.views.message.test_type_hints.FakeObjectsComponent"
28+
)
29+
30+
31+
def test_message_int(client):
32+
data = {"int_example": "4"}
33+
action_queue = [{"payload": {"name": "assert_int"}, "type": "callMethod",}]
34+
response = post_and_get_response(
35+
client, url=FAKE_OBJECTS_COMPONENT_URL, data=data, action_queue=action_queue,
36+
)
37+
38+
body = orjson.loads(response.content)
39+
40+
assert not body.get(
41+
"error"
42+
) # UnicornViewError/AssertionError returns a a JsonResponse with "error" key
43+
assert not body["errors"]
44+
45+
46+
def test_message_float(client):
47+
data = {"float_example": "1.3"}
48+
action_queue = [{"payload": {"name": "assert_float"}, "type": "callMethod",}]
49+
response = post_and_get_response(
50+
client, url=FAKE_OBJECTS_COMPONENT_URL, data=data, action_queue=action_queue,
51+
)
52+
53+
body = orjson.loads(response.content)
54+
55+
assert not body.get(
56+
"error"
57+
) # UnicornViewError/AssertionError returns a a JsonResponse with "error" key
58+
assert not body["errors"]
59+
60+
61+
def test_message_decimal(client):
62+
data = {"decimal_example": "1.5"}
63+
action_queue = [{"payload": {"name": "assert_decimal"}, "type": "callMethod",}]
64+
response = post_and_get_response(
65+
client, url=FAKE_OBJECTS_COMPONENT_URL, data=data, action_queue=action_queue,
66+
)
67+
68+
body = orjson.loads(response.content)
69+
70+
assert not body.get(
71+
"error"
72+
) # UnicornViewError/AssertionError returns a a JsonResponse with "error" key
73+
assert not body["errors"]
74+

tests/views/message/utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88

99
def post_and_get_response(client, url="", data={}, action_queue=[]):
10-
data = {}
1110
message = {
1211
"actionQueue": action_queue,
1312
"data": data,

0 commit comments

Comments
 (0)