Skip to content

Commit 38b610c

Browse files
authored
Merge pull request #6 from lobaro/pr/fix/ascleandict
fix(ascleandict): recurse to clean the dict
2 parents a3f6156 + 5fca136 commit 38b610c

File tree

2 files changed

+102
-3
lines changed

2 files changed

+102
-3
lines changed

src/lob_hlpr/hlpr.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,53 @@ def lob_print(log_path: str, *args, **kwargs):
166166

167167
@staticmethod
168168
def ascleandict(dclass, remove_false=False):
169-
"""Convert a dataclass to a dictionary and remove None values."""
170-
return asdict(
169+
"""Convert a dataclass to a dictionary and remove None values.
170+
171+
Largely generated by AI...
172+
173+
Args:
174+
dclass: The dataclass instance to convert.
175+
remove_false: If True, also remove boolean fields that are False.
176+
177+
Returns:
178+
dict: The cleaned dictionary without empty values.
179+
"""
180+
181+
def clean_value(v):
182+
"""Recursively clean nested structures."""
183+
if isinstance(v, dict):
184+
cleaned = {
185+
k: clean_value(val)
186+
for (k, val) in v.items()
187+
if (val is not None)
188+
and not (isinstance(val, list) and len(val) == 0)
189+
and not (isinstance(val, dict) and len(val) == 0)
190+
and not (remove_false and (isinstance(val, bool) and val is False))
191+
}
192+
# Keep removing empty dicts/lists until nothing changes
193+
while True:
194+
filtered = {
195+
k: v
196+
for (k, v) in cleaned.items()
197+
if not (isinstance(v, dict) and len(v) == 0)
198+
and not (isinstance(v, list) and len(v) == 0)
199+
}
200+
if len(filtered) == len(cleaned):
201+
break
202+
cleaned = filtered
203+
return cleaned
204+
elif isinstance(v, list):
205+
cleaned_items = [clean_value(item) for item in v]
206+
# Filter out empty dicts and lists from the result
207+
return [
208+
item
209+
for item in cleaned_items
210+
if not (isinstance(item, dict) and len(item) == 0)
211+
and not (isinstance(item, list) and len(item) == 0)
212+
]
213+
return v
214+
215+
result = asdict(
171216
dclass,
172217
dict_factory=lambda x: {
173218
k: v
@@ -178,6 +223,7 @@ def ascleandict(dclass, remove_false=False):
178223
and not (remove_false and (isinstance(v, bool) and v is False))
179224
},
180225
)
226+
return clean_value(result)
181227

182228
@staticmethod
183229
def unix_timestamp() -> int:

tests/test_lob_hlpr.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,14 @@ def test_log_print_passes(tmp_path, capsys):
161161
def test_as_clean_dict_passes():
162162
"""Test as_clean_dict function with valid inputs."""
163163

164+
@dataclass
165+
class MoreNestedTestClass:
166+
listkey: list | None = None
167+
dictkey: dict | None = None
168+
164169
@dataclass
165170
class NestedTestClass:
166-
nested_key: str | None = None
171+
more_nested: MoreNestedTestClass | None = None
167172

168173
@dataclass
169174
class TestClass:
@@ -184,10 +189,58 @@ class TestClass:
184189
dclass.dictkey = {}
185190
dclass.boolkey = False
186191
dclass.nested = NestedTestClass()
192+
dclass.nested.more_nested = MoreNestedTestClass(dictkey={}, listkey=[])
187193
assert hlp.ascleandict(dclass) == {"intkey": 0, "strkey": "", "boolkey": False}
194+
assert "nested" not in hlp.ascleandict(dclass)
188195

189196
assert hlp.ascleandict(dclass, remove_false=True) == {"intkey": 0, "strkey": ""}
190197

198+
@dataclass
199+
class DataWithList:
200+
items: list
201+
name: str
202+
203+
data2 = DataWithList(
204+
items=[{"value": 1, "empty_dict": {}}, {"value": 2, "none_val": None}, [], {}],
205+
name="test",
206+
)
207+
result2 = hlp.ascleandict(data2)
208+
assert result2 == {"name": "test", "items": [{"value": 1}, {"value": 2}]}
209+
210+
211+
def test_ascleandict_nested_cleanup_multiple_passes():
212+
"""Test that ascleandict removes cascading empty nested structures."""
213+
214+
@dataclass
215+
class DeepNested:
216+
value: str | None = None
217+
218+
@dataclass
219+
class MidLevel:
220+
deep: DeepNested | None = None
221+
data: dict | None = None
222+
223+
@dataclass
224+
class TopLevel:
225+
mid: MidLevel | None = None
226+
name: str = "test"
227+
228+
# Create data where cleaning cascades:
229+
# - DeepNested.value = None gets removed
230+
# - MidLevel.deep becomes {} and gets removed
231+
# - MidLevel.data is already {} and gets removed
232+
# - MidLevel itself might become {} and gets removed
233+
data = TopLevel(
234+
name="test",
235+
mid=MidLevel(
236+
deep=DeepNested(value=None), # Becomes {}
237+
data={"inner": None}, # Becomes {}
238+
),
239+
)
240+
result = hlp.ascleandict(data)
241+
# The while loop should run multiple times, executing line 190
242+
assert result == {"name": "test"}
243+
191244

192245
def test_unix_timestamp():
193246
"""Test unix_timestamp function."""

0 commit comments

Comments
 (0)