Skip to content

Commit a46d048

Browse files
check nested dataclasses works with list dataclass as parameter (#939)
* test that should pass * bugfix nested dataclasses * bump version * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent e3815b2 commit a46d048

5 files changed

Lines changed: 259 additions & 51 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "zntrack"
3-
version = "0.8.9"
3+
version = "0.8.10"
44
description = "Create, Run and Benchmark DVC Pipelines in Python"
55
authors = [
66
{ name = "Fabian Zills", email = "fzills@icp.uni-stuttgart.de" },

tests/integration/test_nested_dataclass_deps.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,160 @@ def test_nested_dc_deps(proj_path):
5656
project.repro()
5757

5858
assert md.from_rev().result == "Berendsen thermostat '1.0'"
59+
60+
61+
# test nested lists
62+
63+
64+
@dataclasses.dataclass(frozen=True)
65+
class FuncOne:
66+
value: int = 1
67+
68+
def get_value(self):
69+
return self.value
70+
71+
72+
@dataclasses.dataclass
73+
class FuncCollector:
74+
funcs: list[FuncOne] = dataclasses.field(default_factory=list)
75+
76+
def get_values(self):
77+
return [func.get_value() for func in self.funcs]
78+
79+
80+
class FuncNode(zntrack.Node):
81+
collector: FuncCollector = zntrack.deps()
82+
result: list[int] = zntrack.outs()
83+
84+
def run(self):
85+
self.result = self.collector.get_values()
86+
87+
88+
def test_nested_list_deps(proj_path):
89+
project = zntrack.Project()
90+
collector = FuncCollector(funcs=[FuncOne(value=1), FuncOne(value=2)])
91+
92+
with project:
93+
node = FuncNode(collector=collector)
94+
95+
project.repro()
96+
assert node.from_rev().result == [1, 2]
97+
98+
collector.funcs.append(FuncOne(value=3))
99+
project.repro()
100+
101+
assert node.from_rev().result == [1, 2, 3]
102+
103+
104+
# Additional test classes for other collection types
105+
@dataclasses.dataclass
106+
class FuncTupleCollector:
107+
funcs: tuple[FuncOne, ...] = dataclasses.field(default_factory=tuple)
108+
109+
def get_values(self):
110+
return [func.get_value() for func in self.funcs]
111+
112+
113+
@dataclasses.dataclass
114+
class FuncSetCollector:
115+
funcs: set[FuncOne] = dataclasses.field(default_factory=set)
116+
117+
def get_values(self):
118+
# Sort for deterministic testing
119+
return sorted([func.get_value() for func in self.funcs])
120+
121+
122+
@dataclasses.dataclass
123+
class FuncDictCollector:
124+
funcs: dict[str, FuncOne] = dataclasses.field(default_factory=dict)
125+
126+
def get_values(self):
127+
# Return sorted list of values for deterministic testing
128+
return sorted([func.get_value() for func in self.funcs.values()])
129+
130+
131+
class FuncTupleNode(zntrack.Node):
132+
collector: FuncTupleCollector = zntrack.deps()
133+
result: list[int] = zntrack.outs()
134+
135+
def run(self):
136+
self.result = self.collector.get_values()
137+
138+
139+
class FuncSetNode(zntrack.Node):
140+
collector: FuncSetCollector = zntrack.deps()
141+
result: list[int] = zntrack.outs()
142+
143+
def run(self):
144+
self.result = self.collector.get_values()
145+
146+
147+
class FuncDictNode(zntrack.Node):
148+
collector: FuncDictCollector = zntrack.deps()
149+
result: list[int] = zntrack.outs()
150+
151+
def run(self):
152+
self.result = self.collector.get_values()
153+
154+
155+
def test_nested_tuple_deps(proj_path):
156+
project = zntrack.Project()
157+
collector = FuncTupleCollector(funcs=(FuncOne(value=1), FuncOne(value=2)))
158+
159+
with project:
160+
node = FuncTupleNode(collector=collector)
161+
162+
project.repro()
163+
assert node.from_rev().result == [1, 2]
164+
165+
# Test with updated tuple - need to update the same node instance
166+
node.collector = FuncTupleCollector(
167+
funcs=(FuncOne(value=1), FuncOne(value=2), FuncOne(value=3))
168+
)
169+
project.repro()
170+
171+
assert node.from_rev().result == [1, 2, 3]
172+
173+
174+
def test_nested_set_deps(proj_path):
175+
project = zntrack.Project()
176+
collector = FuncSetCollector(funcs={FuncOne(value=1), FuncOne(value=2)})
177+
178+
with project:
179+
node = FuncSetNode(collector=collector)
180+
181+
project.repro()
182+
assert node.from_rev().result == [1, 2]
183+
184+
# Test with updated set - need to update the same node instance
185+
node.collector = FuncSetCollector(
186+
funcs={FuncOne(value=1), FuncOne(value=2), FuncOne(value=3)}
187+
)
188+
project.repro()
189+
190+
assert node.from_rev().result == [1, 2, 3]
191+
192+
193+
def test_nested_dict_deps(proj_path):
194+
project = zntrack.Project()
195+
collector = FuncDictCollector(
196+
funcs={"first": FuncOne(value=1), "second": FuncOne(value=2)}
197+
)
198+
199+
with project:
200+
node = FuncDictNode(collector=collector)
201+
202+
project.repro()
203+
assert node.from_rev().result == [1, 2]
204+
205+
# Test with updated dict - need to update the same node instance
206+
node.collector = FuncDictCollector(
207+
funcs={
208+
"first": FuncOne(value=1),
209+
"second": FuncOne(value=2),
210+
"third": FuncOne(value=3),
211+
}
212+
)
213+
project.repro()
214+
215+
assert node.from_rev().result == [1, 2, 3]

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

zntrack/converter.py

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,42 @@
2323
from .utils import module_handler
2424

2525

26+
def _reconstruct_value_recursively(value):
27+
"""Recursively reconstruct values, handling dataclasses in collections."""
28+
if isinstance(value, dict) and "_cls" in value:
29+
# This is a nested dataclass, reconstruct it
30+
cls_path = value["_cls"]
31+
if not isinstance(cls_path, str) or "." not in cls_path:
32+
raise ValueError(f"Invalid class path format: {cls_path}")
33+
try:
34+
module_name, class_name = cls_path.rsplit(".", 1)
35+
module = importlib.import_module(module_name)
36+
nested_cls = getattr(module, class_name)
37+
except (ImportError, AttributeError, ValueError) as e:
38+
raise ImportError(f"Failed to import class {cls_path}: {e}") from e
39+
40+
if not dataclasses.is_dataclass(nested_cls):
41+
raise TypeError(f"Class {cls_path} is not a dataclass")
42+
43+
# Recursively process nested parameters
44+
nested_params = _reconstruct_nested_dataclasses(value)
45+
try:
46+
return nested_cls(**nested_params)
47+
except TypeError as e:
48+
raise TypeError(f"Failed to instantiate {cls_path}: {e}") from e
49+
elif isinstance(value, list):
50+
return [_reconstruct_value_recursively(item) for item in value]
51+
elif isinstance(value, tuple):
52+
return tuple(_reconstruct_value_recursively(item) for item in value)
53+
elif isinstance(value, set):
54+
# Note: Sets are serialized as lists, so this won't be called during normal flow
55+
return {_reconstruct_value_recursively(item) for item in value}
56+
elif isinstance(value, dict):
57+
return {k: _reconstruct_value_recursively(v) for k, v in value.items()}
58+
else:
59+
return value
60+
61+
2662
def _reconstruct_nested_dataclasses(params: dict) -> dict:
2763
"""Recursively reconstruct nested dataclasses from their dictionary representation."""
2864
if not isinstance(params, dict):
@@ -32,29 +68,7 @@ def _reconstruct_nested_dataclasses(params: dict) -> dict:
3268
for key, value in params.items():
3369
if key == "_cls":
3470
continue
35-
if isinstance(value, dict) and "_cls" in value:
36-
# This is a nested dataclass, reconstruct it
37-
cls_path = value["_cls"]
38-
if not isinstance(cls_path, str) or "." not in cls_path:
39-
raise ValueError(f"Invalid class path format: {cls_path}")
40-
try:
41-
module_name, class_name = cls_path.rsplit(".", 1)
42-
module = importlib.import_module(module_name)
43-
nested_cls = getattr(module, class_name)
44-
except (ImportError, AttributeError, ValueError) as e:
45-
raise ImportError(f"Failed to import class {cls_path}: {e}") from e
46-
47-
if not dataclasses.is_dataclass(nested_cls):
48-
raise TypeError(f"Class {cls_path} is not a dataclass")
49-
50-
# Recursively process nested parameters
51-
nested_params = _reconstruct_nested_dataclasses(value)
52-
try:
53-
reconstructed_params[key] = nested_cls(**nested_params)
54-
except TypeError as e:
55-
raise TypeError(f"Failed to instantiate {cls_path}: {e}") from e
56-
else:
57-
reconstructed_params[key] = value
71+
reconstructed_params[key] = _reconstruct_value_recursively(value)
5872
return reconstructed_params
5973

6074

zntrack/plugins/dvc_plugin/params.py

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,25 @@
1111
from zntrack.utils import module_handler
1212

1313

14+
def _convert_value_recursively(value):
15+
"""Recursively convert values, handling dataclasses in collections."""
16+
if dataclasses.is_dataclass(value) and not isinstance(
17+
value, (Node, znflow.Connection, znflow.CombinedConnections)
18+
):
19+
return _dataclass_to_dict(value)
20+
elif isinstance(value, list):
21+
return [_convert_value_recursively(item) for item in value]
22+
elif isinstance(value, tuple):
23+
return tuple(_convert_value_recursively(item) for item in value)
24+
elif isinstance(value, set):
25+
# Sets cannot contain dicts (unhashable), so convert to list
26+
return [_convert_value_recursively(item) for item in value]
27+
elif isinstance(value, dict):
28+
return {k: _convert_value_recursively(v) for k, v in value.items()}
29+
else:
30+
return copy.deepcopy(value)
31+
32+
1433
def _dataclass_to_dict(object) -> dict:
1534
"""Convert a dataclass to a dictionary excluding certain keys."""
1635
exclude_fields = []
@@ -35,13 +54,7 @@ def _dataclass_to_dict(object) -> dict:
3554
continue
3655

3756
value = getattr(object, field.name)
38-
if dataclasses.is_dataclass(value) and not isinstance(
39-
value, (Node, znflow.Connection, znflow.CombinedConnections)
40-
):
41-
# Recursively convert nested dataclasses
42-
dc_params[field.name] = _dataclass_to_dict(value)
43-
else:
44-
dc_params[field.name] = copy.deepcopy(value)
57+
dc_params[field.name] = _convert_value_recursively(value)
4558

4659
dc_params["_cls"] = f"{module_handler(object)}.{object.__class__.__name__}"
4760
return dc_params
@@ -51,24 +64,48 @@ def deps_to_params(self, field):
5164
if getattr(self.node, field.name) is None:
5265
return
5366
content = getattr(self.node, field.name)
54-
if isinstance(content, (list, tuple, dict)):
55-
new_content = []
56-
for val in content if isinstance(content, (list, tuple)) else content.values():
57-
if dataclasses.is_dataclass(val) and not isinstance(
58-
val, (Node, znflow.Connection, znflow.CombinedConnections)
59-
):
60-
# We save the values of the passed dataclasses
61-
# to the params.yaml file to be later used
62-
# by the DataclassContainer to recreate the
63-
# instance with the correct parameters.
64-
new_content.append(_dataclass_to_dict(val))
65-
elif isinstance(val, (znflow.Connection, znflow.CombinedConnections)):
66-
pass
67-
else:
68-
raise ValueError(
69-
f"Found unsupported type '{type(val)}' ({val}) for DEPS"
70-
f" field '{field.name}' in list"
71-
)
67+
if isinstance(content, (list, tuple, set, dict)):
68+
if isinstance(content, dict):
69+
# For dicts, we need to convert both keys and values
70+
new_content = {}
71+
for key, val in content.items():
72+
if dataclasses.is_dataclass(val) and not isinstance(
73+
val, (Node, znflow.Connection, znflow.CombinedConnections)
74+
):
75+
new_content[key] = _dataclass_to_dict(val)
76+
elif isinstance(val, (znflow.Connection, znflow.CombinedConnections)):
77+
pass
78+
else:
79+
raise ValueError(
80+
f"Found unsupported type '{type(val)}' ({val}) for DEPS"
81+
f" field '{field.name}' in dict"
82+
)
83+
else:
84+
# For lists, tuples, sets
85+
new_content = []
86+
for val in content:
87+
if dataclasses.is_dataclass(val) and not isinstance(
88+
val, (Node, znflow.Connection, znflow.CombinedConnections)
89+
):
90+
# We save the values of the passed dataclasses
91+
# to the params.yaml file to be later used
92+
# by the DataclassContainer to recreate the
93+
# instance with the correct parameters.
94+
new_content.append(_dataclass_to_dict(val))
95+
elif isinstance(val, (znflow.Connection, znflow.CombinedConnections)):
96+
pass
97+
else:
98+
raise ValueError(
99+
f"Found unsupported type '{type(val)}' ({val}) for DEPS"
100+
f" field '{field.name}' in collection"
101+
)
102+
# Preserve the original collection type
103+
if isinstance(content, tuple):
104+
new_content = tuple(new_content)
105+
elif isinstance(content, set):
106+
# Sets cannot contain dicts (unhashable), so convert to list
107+
new_content = list(new_content)
108+
72109
if len(new_content) > 0:
73110
return new_content
74111
elif dataclasses.is_dataclass(content) and not isinstance(

0 commit comments

Comments
 (0)