Import with DictImporter fails on custom classes when attributes dont match the constructors signature
When using custom NodeMixin subclasses with attributes that don't match the constructors signature the import will fail. The DictImporter expects, that all attributes are present in the classes constructor.
See the following code, which reproduces the bug, I copied most of it from the documentation and annotated the changes.
from anytree import NodeMixin
from anytree.render import RenderTree
from anytree.exporter import DictExporter
from anytree.importer import DictImporter
class MyBaseClass(object):
foo = 4
class MyClass(MyBaseClass, NodeMixin):
def __init__(self, name, length, width, parent=None, children=None):
super(MyClass, self).__init__()
self.name = name
self.length = length
self._width = width # private argument, will fail (does not match the signature)
self.width2 = width # name different, would also fail (does not match the signature)
self.parent = parent
if children:
self.children = children
@property
def width(self): #private argument may be a property, but this doesn't matter.
return self._width
# taken from documentation, create a tree
my0 = MyClass('my0', 0, 0)
my1 = MyClass('my1', 1, 0, parent=my0)
my2 = MyClass('my2', 0, 2, parent=my0)
for pre, _, node in RenderTree(my0):
treestr = u"%s%s" % (pre, node.name)
print(treestr.ljust(8), node.length, node.width)
# export works
exporter = DictExporter()
my0_dict = exporter.export(my0)
print(my0_dict) # dict shows attribute _width
importer = DictImporter(nodecls=MyClass)
my0_new = importer.import_(my0_dict) # fails
This seems to be a pretty tricky problem and may not be solved.
There are some seperate cases:
- It is never possible to reimport the export of the following class. If not all constructor arguments are saved as instance attributes, it will always fail. See for example the following class. This applies also to renamed attributes.
class MyClass(MyBaseClass, NodeMixin):
def __init__(self, name, length, parent=None, children=None):
super(MyClass, self).__init__()
self.name = name
# self.length = length # length is not saved but required during initialization
self.parent = parent
if children:
self.children = children
- Not all attributes are in the construcor. See the following code, where the
widthattribute is computed from thelengthattribute.
class MyClass(MyBaseClass, NodeMixin):
def __init__(self, name, length, parent=None, children=None):
super(MyClass, self).__init__()
self.name = name
self.length = length
self.width = length * 2 # some other argument, which is not in the constructor
self.parent = parent
if children:
self.children = children
# taken from documentation, create a tree
my0 = MyClass('my0', 0)
my1 = MyClass('my1', 1, parent=my0)
my2 = MyClass('my2', 0, parent=my0)
However the call to the constructor of MyClass with the width attribute will produce the following error:
> python anytree_bug.py
my0 0 0
├── my1 1 2
└── my2 0 0
{'name': 'my0', 'length': 0, 'width': 0, 'children': [{'name': 'my1', 'length': 1, 'width': 2}, {'name': 'my2', 'length': 0, 'width': 0}]}
Traceback (most recent call last):
File "anytree_bug.py", line 43, in <module>
my0_new = importer.import_(my0_dict) # fails
File "python3.7/site-packages/anytree/importer/dictimporter.py", line 38, in import_
return self.__import(data)
File "python3.7/site-packages/anytree/importer/dictimporter.py", line 45, in __import
node = self.nodecls(parent=parent, **attrs)
TypeError: __init__() got an unexpected keyword argument 'width'
This case can be solved with a smarter attriter in DictExporter. I think this is the most common case and should be fixed in anytree. Here is an iterator that does so:
def matching_attriter(attrs, constructor):
import inspect
constructor_signature = inspect.signature(constructor) # <Signature (name, length, parent=None, children=None)>
# add all attributes that appear as arguments in the constructors signature
matched_arguments = {k: v for k, v in attrs if k in constructor_signature.parameters}
# check if all arguments are matched. Not necessary, but could prevent an error bacause of case 1 later on.
try:
constructor_signature.bind(**matched_arguments)
except TypeError:
pass
yield from matched_arguments.items()
from functools import partial
# export works
exporter = DictExporter(attriter=partial(matching_attriter, constructor=my0.__init__))
my0_dict = exporter.export(my0)
print(my0_dict)
importer = DictImporter(nodecls=MyClass)
my0_new = importer.import_(my0_dict) # works
- Arguments are saved as private arguments/properties. Here one could try to remove underscores and then use the
matching_attriter. I'm not sure how feasible that is.
Thanks for posting this! I just ran into the same issue. Just to get unblocked, I was wondering if I should just fork it and modify the base class for my own use.