Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 48 additions & 30 deletions src/lob_hlpr/hlpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +216 to +220

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title mentions "validate dataclass exports", but the diff only changes ascleandict recursion handling and adds circular-reference tests. If export validation is intended, it looks missing; otherwise please update the PR title/description to match the actual changes to avoid confusion in release notes/history.

Copilot uses AI. Check for mistakes.
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"<circular ref: {type(obj).__name__}>"
Comment on lines +225 to +229

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning message says the circular reference is being "skipped", but the function actually returns a placeholder string ("<circular ref: …>") that is kept in the output. Consider adjusting the log message to reflect the real behavior (e.g., “replacing with placeholder”) so logs aren’t misleading. Also consider using a module-level logger instead of calling logging.getLogger(name) on every circular detection.

Copilot uses AI. Check for mistakes.
_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)

Expand Down
31 changes: 31 additions & 0 deletions tests/test_lob_hlpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] == ["<circular ref: list>"]


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"] == "<circular ref: dict>"


def test_unix_timestamp():
"""Test unix_timestamp function."""
timestamp = hlp.unix_timestamp()
Expand Down