Skip to content

Commit 9f62150

Browse files
fix: restore float values in nested tuple[dict] fields
1 parent f80b27e commit 9f62150

File tree

4 files changed

+121
-3
lines changed

4 files changed

+121
-3
lines changed

src/django_unicorn/serializer.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,17 @@ def _fix_floats(current: dict, data: dict | None = None, paths: list | None = No
275275
paths.append(key)
276276
_fix_floats(val, data, paths=paths)
277277
paths.pop()
278-
elif isinstance(current, list):
278+
elif isinstance(current, (list, tuple)):
279+
if isinstance(current, tuple) and paths:
280+
# Tuples are immutable; convert to list in the parent container so
281+
# we can mutate float values inside it.
282+
_piece = data
283+
for idx, path in enumerate(paths):
284+
if idx == len(paths) - 1:
285+
_piece[path] = list(current)
286+
else:
287+
_piece = _piece[path]
288+
current = _piece[paths[-1]]
279289
for idx, item in enumerate(current):
280290
paths.append(idx)
281291
_fix_floats(item, data, paths=paths)

src/django_unicorn/typer.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,25 @@ def cast_value(type_hint, value):
145145
# casting each item individually
146146
return [cast_value(arg, item) for item in value]
147147

148+
if get_origin(type_hint) is tuple:
149+
type_args = get_args(type_hint)
150+
if len(type_args) == 1:
151+
# Homogeneous tuple hint like tuple[dict[str, float|str]] —
152+
# the single arg applies to every element
153+
arg = type_args[0]
154+
return tuple(cast_value(arg, item) for item in value)
155+
elif len(type_args) >= 2 and type_args[-1] is not Ellipsis:
156+
# Fixed-length heterogeneous tuple: tuple[str, int, float]
157+
return tuple(cast_value(t, item) for t, item in zip(type_args, value))
158+
159+
if get_origin(type_hint) is dict:
160+
type_args = get_args(type_hint)
161+
if len(type_args) == 2 and isinstance(value, dict):
162+
# dict[K, V] — cast each value to the V type so that e.g.
163+
# dict[str, float|str] converts string "3.4" back to float 3.4
164+
value_type = type_args[1]
165+
return {k: cast_value(value_type, v) for k, v in value.items()}
166+
148167
# Handle Optional type hint and the value is None
149168
if type(None) in type_hints and value is None:
150169
return value
@@ -178,8 +197,13 @@ def cast_value(type_hint, value):
178197
value = _type_hint(**value)
179198
break
180199

181-
value = _type_hint(value)
182-
break
200+
try:
201+
value = _type_hint(value)
202+
break
203+
except ValueError:
204+
# float("abc") raises ValueError; continue to the next type in a
205+
# Union (e.g. the `str` in float|str) before giving up.
206+
continue
183207

184208
return value
185209

tests/serializer/test_dumps.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,3 +841,33 @@ def test_dictionary_with_int_keys_as_strings_no_sort():
841841
)
842842

843843
assert expected == actual
844+
845+
846+
def test_tuple_float():
847+
"""Float inside a tuple is stringified for safe JS transmission (#641)."""
848+
import json
849+
850+
actual = serializer.dumps({"ranks": ({"name": "abc", "score": 3.4},)})
851+
data = json.loads(actual)
852+
853+
assert data["ranks"][0]["score"] == "3.4"
854+
855+
856+
def test_tuple_of_dicts_mixed_types():
857+
"""Multiple floats and non-floats inside a tuple of dicts are handled (#641)."""
858+
import json
859+
860+
actual = serializer.dumps(
861+
{
862+
"ranks": (
863+
{"name": "abc", "score": 3.4},
864+
{"name": "def", "score": 1.0},
865+
{"name": "ghi", "score": 5},
866+
)
867+
}
868+
)
869+
data = json.loads(actual)
870+
871+
assert data["ranks"][0]["score"] == "3.4"
872+
assert data["ranks"][1]["score"] == "1.0"
873+
assert data["ranks"][2]["score"] == 5 # int stays as int

tests/test_typer.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,57 @@ def test_cast_value_list_pydantic():
170170
type_hint = type_hints["pydantic_list_data"]
171171
actual = cast_value(type_hint, [{"name": "foo"}])
172172
assert actual == [test_data]
173+
174+
class ComponentWithRanks:
175+
ranks: tuple[dict[str, float | str]]
176+
177+
178+
def test_cast_value_tuple_of_dicts_gh641():
179+
"""cast_value restores float values inside tuple[dict[str, float|str]] (#641)."""
180+
type_hints = typing_get_type_hints(ComponentWithRanks)
181+
type_hint = type_hints["ranks"]
182+
183+
# Simulates data arriving from browser: floats were stringified by _fix_floats
184+
value = [{"name": "abc", "score": "3.4"}]
185+
actual = cast_value(type_hint, value)
186+
187+
# Result must be a tuple (not a list)
188+
assert isinstance(actual, tuple)
189+
assert len(actual) == 1
190+
# String "3.4" must be cast back to float 3.4 via the float|str value type hint
191+
assert actual[0]["name"] == "abc"
192+
assert actual[0]["score"] == 3.4
193+
assert isinstance(actual[0]["score"], float)
194+
195+
196+
def test_cast_value_dict_float_str_gh641():
197+
"""cast_value casts string float values inside dict[str, float|str] (#641)."""
198+
from typing import get_type_hints as typing_get_type_hints
199+
200+
class ComponentWithDict:
201+
data: dict[str, float | str]
202+
203+
type_hints = typing_get_type_hints(ComponentWithDict)
204+
type_hint = type_hints["data"]
205+
206+
actual = cast_value(type_hint, {"name": "abc", "score": "3.4"})
207+
208+
assert actual["name"] == "abc"
209+
assert actual["score"] == 3.4
210+
assert isinstance(actual["score"], float)
211+
212+
213+
class ComponentWithTupleOfDataclasses:
214+
items: tuple[DataClass]
215+
216+
217+
def test_cast_value_tuple_of_dataclasses_gh641():
218+
"""cast_value recursively casts items inside a tuple[Dataclass] (#641)."""
219+
type_hints = typing_get_type_hints(ComponentWithTupleOfDataclasses)
220+
type_hint = type_hints["items"]
221+
222+
value = [{"name": "foo"}, {"name": "bar"}]
223+
actual = cast_value(type_hint, value)
224+
225+
assert isinstance(actual, tuple)
226+
assert actual == (DataClass(name="foo"), DataClass(name="bar"))

0 commit comments

Comments
 (0)