From 5fca1362393b26b291148d339dbff90ea8e04443 Mon Sep 17 00:00:00 2001 From: Kevin Weiss Date: Thu, 4 Dec 2025 13:56:16 +0100 Subject: [PATCH] fix(ascleandict): recurse to clean the dict It seems like some deeply nested cases do not get caught. This should make sure we get rid of every empty object, even if the object become empty after a pass. --- src/lob_hlpr/hlpr.py | 50 ++++++++++++++++++++++++++++++++++++-- tests/test_lob_hlpr.py | 55 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/lob_hlpr/hlpr.py b/src/lob_hlpr/hlpr.py index 1851499..087d914 100644 --- a/src/lob_hlpr/hlpr.py +++ b/src/lob_hlpr/hlpr.py @@ -166,8 +166,53 @@ def lob_print(log_path: str, *args, **kwargs): @staticmethod def ascleandict(dclass, remove_false=False): - """Convert a dataclass to a dictionary and remove None values.""" - return asdict( + """Convert a dataclass to a dictionary and remove None values. + + Largely generated by AI... + + Args: + dclass: The dataclass instance to convert. + remove_false: If True, also remove boolean fields that are False. + + Returns: + dict: The cleaned dictionary without empty values. + """ + + def clean_value(v): + """Recursively clean nested structures.""" + if isinstance(v, dict): + cleaned = { + k: clean_value(val) + for (k, val) in v.items() + if (val is not None) + and not (isinstance(val, list) and len(val) == 0) + and not (isinstance(val, dict) and len(val) == 0) + and not (remove_false and (isinstance(val, bool) and val is False)) + } + # Keep removing empty dicts/lists until nothing changes + while True: + filtered = { + k: v + for (k, v) in cleaned.items() + if not (isinstance(v, dict) and len(v) == 0) + and not (isinstance(v, list) and len(v) == 0) + } + if len(filtered) == len(cleaned): + break + cleaned = filtered + return cleaned + elif isinstance(v, list): + cleaned_items = [clean_value(item) for item in v] + # Filter out empty dicts and lists from the result + return [ + item + for item in cleaned_items + if not (isinstance(item, dict) and len(item) == 0) + and not (isinstance(item, list) and len(item) == 0) + ] + return v + + result = asdict( dclass, dict_factory=lambda x: { k: v @@ -178,6 +223,7 @@ def ascleandict(dclass, remove_false=False): and not (remove_false and (isinstance(v, bool) and v is False)) }, ) + return clean_value(result) @staticmethod def unix_timestamp() -> int: diff --git a/tests/test_lob_hlpr.py b/tests/test_lob_hlpr.py index 8d6570c..2256734 100644 --- a/tests/test_lob_hlpr.py +++ b/tests/test_lob_hlpr.py @@ -161,9 +161,14 @@ def test_log_print_passes(tmp_path, capsys): def test_as_clean_dict_passes(): """Test as_clean_dict function with valid inputs.""" + @dataclass + class MoreNestedTestClass: + listkey: list | None = None + dictkey: dict | None = None + @dataclass class NestedTestClass: - nested_key: str | None = None + more_nested: MoreNestedTestClass | None = None @dataclass class TestClass: @@ -184,10 +189,58 @@ class TestClass: dclass.dictkey = {} dclass.boolkey = False dclass.nested = NestedTestClass() + dclass.nested.more_nested = MoreNestedTestClass(dictkey={}, listkey=[]) assert hlp.ascleandict(dclass) == {"intkey": 0, "strkey": "", "boolkey": False} + assert "nested" not in hlp.ascleandict(dclass) assert hlp.ascleandict(dclass, remove_false=True) == {"intkey": 0, "strkey": ""} + @dataclass + class DataWithList: + items: list + name: str + + data2 = DataWithList( + items=[{"value": 1, "empty_dict": {}}, {"value": 2, "none_val": None}, [], {}], + name="test", + ) + result2 = hlp.ascleandict(data2) + assert result2 == {"name": "test", "items": [{"value": 1}, {"value": 2}]} + + +def test_ascleandict_nested_cleanup_multiple_passes(): + """Test that ascleandict removes cascading empty nested structures.""" + + @dataclass + class DeepNested: + value: str | None = None + + @dataclass + class MidLevel: + deep: DeepNested | None = None + data: dict | None = None + + @dataclass + class TopLevel: + mid: MidLevel | None = None + name: str = "test" + + # Create data where cleaning cascades: + # - DeepNested.value = None gets removed + # - MidLevel.deep becomes {} and gets removed + # - MidLevel.data is already {} and gets removed + # - MidLevel itself might become {} and gets removed + data = TopLevel( + name="test", + mid=MidLevel( + deep=DeepNested(value=None), # Becomes {} + data={"inner": None}, # Becomes {} + ), + ) + result = hlp.ascleandict(data) + # The while loop should run multiple times, executing line 190 + assert result == {"name": "test"} + def test_unix_timestamp(): """Test unix_timestamp function."""