diff --git a/src/lob_hlpr/hlpr.py b/src/lob_hlpr/hlpr.py index 16b8692..2fb976b 100644 --- a/src/lob_hlpr/hlpr.py +++ b/src/lob_hlpr/hlpr.py @@ -213,36 +213,54 @@ def _keep(v) -> bool: return False return True - def _convert(obj: object) -> Any: - if is_dataclass(obj) and not isinstance(obj, type): - result = {} - for f in fields(obj): - converted = _convert(getattr(obj, f.name)) - if _keep(converted): - result[f.name] = converted - return result - if isinstance(obj, dict): - result = {} - for k, v in obj.items(): - converted = _convert(v) - if _keep(converted): - key = ( - str(k) - if json_serializable and not isinstance(k, str) - else k - ) - result[key] = converted - return result - if isinstance(obj, (list, tuple)): - items = [_convert(item) for item in obj] - items = [item for item in items if _keep(item)] - return tuple(items) if isinstance(obj, tuple) else items - if json_serializable: - try: - json.dumps(obj) - except (TypeError, OverflowError): - return str(obj) - return obj + def _convert(obj: object, _seen: set | None = None) -> Any: + is_container = isinstance(obj, (dict, list)) or ( + is_dataclass(obj) and not isinstance(obj, type) + ) + if is_container: + if _seen is None: + _seen = set() + obj_id = id(obj) + if obj_id in _seen: + logging.getLogger(__name__).warning( + "ascleandict: circular reference detected in %s, skipping", + type(obj).__name__, + ) + return f"" + _seen.add(obj_id) + try: + if is_dataclass(obj) and not isinstance(obj, type): + result = {} + for f in fields(obj): + converted = _convert(getattr(obj, f.name), _seen) + if _keep(converted): + result[f.name] = converted + return result + if isinstance(obj, dict): + result = {} + for k, v in obj.items(): + converted = _convert(v, _seen) + if _keep(converted): + key = ( + str(k) + if json_serializable and not isinstance(k, str) + else k + ) + result[key] = converted + return result + if isinstance(obj, (list, tuple)): + items = [_convert(item, _seen) for item in obj] + items = [item for item in items if _keep(item)] + return tuple(items) if isinstance(obj, tuple) else items + if json_serializable: + try: + json.dumps(obj) + except (TypeError, OverflowError): + return str(obj) + return obj + finally: + if is_container: + _seen.discard(obj_id) return _convert(dclass) diff --git a/tests/test_lob_hlpr.py b/tests/test_lob_hlpr.py index a978ae9..769489a 100644 --- a/tests/test_lob_hlpr.py +++ b/tests/test_lob_hlpr.py @@ -415,6 +415,37 @@ class TopLevel: assert result == {"name": "test"} +def test_ascleandict_circular_reference_in_list(): + """Test that ascleandict handles a circular list reference without crashing.""" + + @dataclass + class Node: + items: list + + circular_list: list = [] + circular_list.append(circular_list) # List references itself + node = Node(items=circular_list) + + result = hlp.ascleandict(node) + # The circular entry is replaced with a sentinel string, not empty, so kept + assert result["items"] == [""] + + +def test_ascleandict_circular_reference_in_dict(): + """Test that ascleandict handles a circular dict reference without crashing.""" + + @dataclass + class Container: + data: dict + + circular_dict: dict = {} + circular_dict["self"] = circular_dict # Dict references itself + container = Container(data=circular_dict) + + result = hlp.ascleandict(container) + assert result["data"]["self"] == "" + + def test_unix_timestamp(): """Test unix_timestamp function.""" timestamp = hlp.unix_timestamp()