Skip to content

Commit 92351f3

Browse files
authored
Return early in is_jsonable if circular reference (#3348)
* Return early in is_jsonable if circular reference * add test
1 parent 22d9c55 commit 92351f3

File tree

2 files changed

+28
-4
lines changed

2 files changed

+28
-4
lines changed

src/huggingface_hub/utils/_typing.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"""Handle typing imports based on system compatibility."""
1616

1717
import sys
18-
from typing import Any, Callable, List, Literal, Type, TypeVar, Union, get_args, get_origin
18+
from typing import Any, Callable, List, Literal, Optional, Set, Type, TypeVar, Union, get_args, get_origin
1919

2020

2121
UNION_TYPES: List[Any] = [Union]
@@ -33,7 +33,7 @@
3333
_JSON_SERIALIZABLE_TYPES = (int, float, str, bool, type(None))
3434

3535

36-
def is_jsonable(obj: Any) -> bool:
36+
def is_jsonable(obj: Any, _visited: Optional[Set[int]] = None) -> bool:
3737
"""Check if an object is JSON serializable.
3838
3939
This is a weak check, as it does not check for the actual JSON serialization, but only for the types of the object.
@@ -43,19 +43,39 @@ def is_jsonable(obj: Any) -> bool:
4343
- it is an instance of int, float, str, bool, or NoneType
4444
- it is a list or tuple and all its items are json serializable
4545
- it is a dict and all its keys are strings and all its values are json serializable
46+
47+
Uses a visited set to avoid infinite recursion on circular references. If object has already been visited, it is
48+
considered not json serializable.
4649
"""
50+
# Initialize visited set to track object ids and detect circular references
51+
if _visited is None:
52+
_visited = set()
53+
54+
# Detect circular reference
55+
obj_id = id(obj)
56+
if obj_id in _visited:
57+
return False
58+
59+
# Add current object to visited before recursive checks
60+
_visited.add(obj_id)
4761
try:
4862
if isinstance(obj, _JSON_SERIALIZABLE_TYPES):
4963
return True
5064
if isinstance(obj, (list, tuple)):
51-
return all(is_jsonable(item) for item in obj)
65+
return all(is_jsonable(item, _visited) for item in obj)
5266
if isinstance(obj, dict):
53-
return all(isinstance(key, _JSON_SERIALIZABLE_TYPES) and is_jsonable(value) for key, value in obj.items())
67+
return all(
68+
isinstance(key, _JSON_SERIALIZABLE_TYPES) and is_jsonable(value, _visited)
69+
for key, value in obj.items()
70+
)
5471
if hasattr(obj, "__json__"):
5572
return True
5673
return False
5774
except RecursionError:
5875
return False
76+
finally:
77+
# Remove the object id from visited to avoid side‑effects for other branches
78+
_visited.discard(obj_id)
5979

6080

6181
def is_simple_optional_type(type_: Type) -> bool:

tests/test_utils_typing.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ class CustomType:
1818
OBJ_WITH_CIRCULAR_REF = {"hello": "world"}
1919
OBJ_WITH_CIRCULAR_REF["recursive"] = OBJ_WITH_CIRCULAR_REF
2020

21+
_nested = {"hello": "world"}
22+
OBJ_WITHOUT_CIRCULAR_REF = {"hello": _nested, "world": [_nested]}
23+
2124

2225
@pytest.mark.parametrize(
2326
"data",
@@ -33,6 +36,7 @@ class CustomType:
3336
{},
3437
{"name": "Alice", "age": 30},
3538
{0: "LABEL_0", 1.0: "LABEL_1"},
39+
OBJ_WITHOUT_CIRCULAR_REF,
3640
],
3741
)
3842
def test_is_jsonable_success(data):

0 commit comments

Comments
 (0)