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."""