From 1b74dcb482da245c89c28f7287292a9616690a31 Mon Sep 17 00:00:00 2001 From: Martin Wendt Date: Tue, 25 Feb 2025 20:53:53 +0100 Subject: [PATCH 1/7] Squashed commit of the following: commit f72e456a6a34f42094becfbcc4982520c1711be9 Author: Martin Wendt Date: Tue Feb 25 20:52:39 2025 +0100 Update CHANGELOG.md --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5dce7f..a6aaa7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog -## 1.1.0 (unreleased) +## 1.1.1 (unreleased) + +## 1.1.0 (2025-02-09) - DEPRECATE: `TypedTree.iter_by_type()`. Use `iterator(.., kind)`instead. - New methods `TypedTree.iterator(..., kind=ANY_KIND)`, From 3669fbb656e5648147f6c87e4661af802c5e75a1 Mon Sep 17 00:00:00 2001 From: Martin Wendt Date: Tue, 25 Feb 2025 21:18:33 +0100 Subject: [PATCH 2/7] WiP --- CHANGELOG.md | 2 +- nutree/__init__.py | 12 +++++++++--- nutree/common.py | 20 ++++++++++++++++++-- nutree/node.py | 15 +++++++++++++++ nutree/tree.py | 15 ++++++++++++++- nutree/typed_tree.py | 5 +++-- 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6aaa7d..92426ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.1.1 (unreleased) +## 1.2.0 (unreleased) ## 1.1.0 (2025-02-09) diff --git a/nutree/__init__.py b/nutree/__init__.py index eed4834..17468fa 100644 --- a/nutree/__init__.py +++ b/nutree/__init__.py @@ -20,11 +20,14 @@ from nutree.common import ( AmbiguousMatchError, + CycleDetectedError, DictWrapper, + DuplicateNodeIdError, IterMethod, SelectBranch, SkipBranch, StopTraversal, + StructureError, TreeError, UniqueConstraintError, ) @@ -35,17 +38,20 @@ from nutree.typed_tree import TypedNode, TypedTree __all__ = [ # type: ignore - Tree, - Node, AmbiguousMatchError, + CycleDetectedError, + DictWrapper, diff_node_formatter, DiffClassification, - DictWrapper, + DuplicateNodeIdError, IterMethod, load_tree_from_fs, + Node, SelectBranch, SkipBranch, StopTraversal, + StructureError, + Tree, TreeError, TypedNode, TypedTree, diff --git a/nutree/common.py b/nutree/common.py index 42ac785..14dbaeb 100644 --- a/nutree/common.py +++ b/nutree/common.py @@ -52,8 +52,24 @@ class TreeError(RuntimeError): """Base class for all `nutree` errors.""" -class UniqueConstraintError(TreeError): - """Thrown when trying to add the same node_id to the same parent""" +class StructureError(TreeError): + """Base class for errors thrown when the tree structure is invalid.""" + + def __init__(self, message: str, node: Node | None = None): + super().__init__(message) + self.node = node + + +class DuplicateNodeIdError(StructureError): + """Thrown when trying to add the same node_id to the tree.""" + + +class UniqueConstraintError(StructureError): + """Thrown when trying to add the same ref_key to the same parent.""" + + +class CycleDetectedError(StructureError): + """Thrown when trying to add the same ref_key to the same ancestor chain.""" class AmbiguousMatchError(TreeError): diff --git a/nutree/node.py b/nutree/node.py index 07fbfd5..c376212 100644 --- a/nutree/node.py +++ b/nutree/node.py @@ -564,6 +564,21 @@ def get_parent_list(self, *, add_self=False, bottom_up=False) -> list[Self]: res.reverse() return res + def parent_iterator(self, *, add_self=False, bottom_up=False) -> Iterator[Self]: + """Generator that walks the parent chain. + + Note: This is mostly efficient for `bottom_up=True`, because for + `bottom_up=False` we convert to a list and revert. + """ + if not bottom_up: + yield from self.get_parent_list(add_self=add_self, bottom_up=False) + return + # Bottom-up iteration: + parent = self if add_self else self._parent + while parent is not None and parent._parent is not None: + yield parent + parent = parent._parent + def get_path( self, *, add_self: bool = True, separator: str = "/", repr: str = "{node.name}" ) -> str: diff --git a/nutree/tree.py b/nutree/tree.py index 275e12e..a8f365c 100644 --- a/nutree/tree.py +++ b/nutree/tree.py @@ -37,6 +37,7 @@ CalcIdCallbackType, DataIdType, DeserializeMapperType, + DuplicateNodeIdError, FlatJsonDictType, IterMethod, KeyMapType, @@ -107,6 +108,9 @@ class Tree(Generic[TData, TNode]): Set `forward_attrs` to true, to enable aliasing of node attributes, i.e. make `node.data.NAME` accessible as `node.NAME`. |br| **Note:** Use with care, see also :ref:`forward-attributes`. + + `structure_guard` enables checks to prevent node structures that would + violate the Directed Acyclic Graph (DAG) format. """ node_factory: type[TNode] = cast(type[TNode], Node) @@ -129,6 +133,7 @@ def __init__( *, calc_data_id: CalcIdCallbackType | None = None, forward_attrs: bool = False, + structure_guard: bool = True, ): self._lock = threading.RLock() #: Tree name used for logging @@ -141,6 +146,8 @@ def __init__( self._calc_data_id_hook: CalcIdCallbackType | None = calc_data_id # Enable aliasing when accessing node instances. self._forward_attrs: bool = forward_attrs + # Enable cycle detection in add_child() + self.structure_guard = structure_guard def __repr__(self): return f"{self.__class__.__name__}<{self.name!r}>" @@ -222,9 +229,15 @@ def calc_data_id(self, data: Any) -> DataIdType: return self._calc_data_id_hook(self, data) # type: ignore return hash(data) + def _check_insert(self, parent: TNode, node: TNode): + """Raise error if inserting a node would violate restrictions.""" + if node._node_id in self._node_by_id: + raise DuplicateNodeIdError(f"Node ID already registered: {node}") + if parent._node_id in self._node_by_id: + raise DuplicateNodeIdError(f"Node ID already registered: {node}") + def _register(self, node: TNode) -> None: assert node._tree is self - # node._tree = self assert node._node_id and node._node_id not in self._node_by_id, f"{node}" self._node_by_id[node._node_id] = node try: diff --git a/nutree/typed_tree.py b/nutree/typed_tree.py index a4b37d2..e97e47c 100644 --- a/nutree/typed_tree.py +++ b/nutree/typed_tree.py @@ -83,11 +83,12 @@ def __init__( node_id: int | None = None, meta: dict | None = None, ): - self._kind: str = kind # tree._register() checks for this attribute + # tree._register() checks for this attribute in the next line: + self._kind: str = kind super().__init__( data, parent=parent, data_id=data_id, node_id=node_id, meta=meta ) - assert isinstance(kind, str) and kind != ANY_KIND, f"Unsupported `kind`: {kind}" + assert isinstance(kind, str), f"Unsupported `kind`: {kind}" # del self._children # self._child_map: Dict[Node] = None From 80f8dc67c4eb3c257791df65e1e34b1b4dbcac8e Mon Sep 17 00:00:00 2001 From: Martin Wendt Date: Wed, 26 Feb 2025 08:30:34 +0100 Subject: [PATCH 3/7] WiP --- CHANGELOG.md | 3 +++ nutree/node.py | 15 ++++++++----- nutree/tree.py | 51 +++++++++++++++++++++++++------------------- nutree/typed_tree.py | 26 ++++++++++++++++++++-- 4 files changed, 66 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92426ee..c658a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 1.2.0 (unreleased) +- New method `node.parent_iterator()`. +- New option `Tree(..., structure_checks=True)` to enforce DAG compliance. + ## 1.1.0 (2025-02-09) - DEPRECATE: `TypedTree.iter_by_type()`. Use `iterator(.., kind)`instead. diff --git a/nutree/node.py b/nutree/node.py index c376212..4792eda 100644 --- a/nutree/node.py +++ b/nutree/node.py @@ -554,21 +554,26 @@ def get_common_ancestor(self, other: Self) -> Self | None: return None def get_parent_list(self, *, add_self=False, bottom_up=False) -> list[Self]: - """Return ordered list of all parent nodes.""" + """Return an ordered list of all parent nodes (top-down by default).""" res = [] parent = self if add_self else self._parent while parent is not None and parent._parent is not None: res.append(parent) parent = parent._parent if not bottom_up: + # Note: it is more efficient to reverse the list than to append + # in reverse order! res.reverse() return res - def parent_iterator(self, *, add_self=False, bottom_up=False) -> Iterator[Self]: - """Generator that walks the parent chain. + def parent_iterator(self, *, add_self=False, bottom_up=True) -> Iterator[Self]: + """Generator that walks the parent chain bottom-up. - Note: This is mostly efficient for `bottom_up=True`, because for - `bottom_up=False` we convert to a list and revert. + Note: top-down requires to convert to a list and revert anyway, + so it can also be implemented as + ```py + for p in self.get_parent_list(add_self=add_self, bottom_up=False): + ``` """ if not bottom_up: yield from self.get_parent_list(add_self=add_self, bottom_up=False) diff --git a/nutree/tree.py b/nutree/tree.py index a8f365c..dc92cc8 100644 --- a/nutree/tree.py +++ b/nutree/tree.py @@ -35,6 +35,7 @@ ROOT_NODE_ID, AmbiguousMatchError, CalcIdCallbackType, + CycleDetectedError, DataIdType, DeserializeMapperType, DuplicateNodeIdError, @@ -109,8 +110,8 @@ class Tree(Generic[TData, TNode]): i.e. make `node.data.NAME` accessible as `node.NAME`. |br| **Note:** Use with care, see also :ref:`forward-attributes`. - `structure_guard` enables checks to prevent node structures that would - violate the Directed Acyclic Graph (DAG) format. + `structure_checks` enables validations to ensure that the node structure is + compliant with Directed Acyclic Graphs (DAG). """ node_factory: type[TNode] = cast(type[TNode], Node) @@ -133,7 +134,7 @@ def __init__( *, calc_data_id: CalcIdCallbackType | None = None, forward_attrs: bool = False, - structure_guard: bool = True, + structure_checks: bool = True, ): self._lock = threading.RLock() #: Tree name used for logging @@ -147,7 +148,7 @@ def __init__( # Enable aliasing when accessing node instances. self._forward_attrs: bool = forward_attrs # Enable cycle detection in add_child() - self.structure_guard = structure_guard + self.structure_checks = structure_checks def __repr__(self): return f"{self.__class__.__name__}<{self.name!r}>" @@ -229,30 +230,36 @@ def calc_data_id(self, data: Any) -> DataIdType: return self._calc_data_id_hook(self, data) # type: ignore return hash(data) - def _check_insert(self, parent: TNode, node: TNode): - """Raise error if inserting a node would violate restrictions.""" - if node._node_id in self._node_by_id: - raise DuplicateNodeIdError(f"Node ID already registered: {node}") - if parent._node_id in self._node_by_id: - raise DuplicateNodeIdError(f"Node ID already registered: {node}") + def _check_insert(self, node: TNode): + """Raise error if inserting a node would violate DAG restrictions.""" + # We can assume that node.parent is set and that node already has at + # least one clone registered in self._nodes_by_data_id, when this is + # called from _register() + ref_key = node._data_id + if node._parent._children: + for sibling in node._parent._children: + if sibling._data_id == ref_key: + raise UniqueConstraintError( + f"Node with data_id {ref_key} already exists under same parent" + ) + for n in self._nodes_by_data_id[ref_key]: + if node.is_descendant_of(n): + raise CycleDetectedError( + f"Inserting {node} would create a cycle with {n}" + ) def _register(self, node: TNode) -> None: assert node._tree is self - assert node._node_id and node._node_id not in self._node_by_id, f"{node}" + assert node._node_id is not None + if node._node_id in self._node_by_id: + raise DuplicateNodeIdError(f"Node ID already registered: {node}") + self._node_by_id[node._node_id] = node try: clone_list = self._nodes_by_data_id[node._data_id] # may raise KeyError - for clone in clone_list: - if clone.parent is node.parent: - is_same_kind = getattr(clone, "kind", None) == getattr( - node, "kind", None - ) - if is_same_kind: - del self._node_by_id[node._node_id] - raise UniqueConstraintError( - f"Node.data already exists in parent: {clone=}, " - f"{clone.parent=}" - ) + # if we get here, we are adding a clone and should check DAG compliance + if self.structure_checks and node.parent: + self._check_insert(node) clone_list.append(node) except KeyError: self._nodes_by_data_id[node._data_id] = [node] diff --git a/nutree/typed_tree.py b/nutree/typed_tree.py index e97e47c..6431dbc 100644 --- a/nutree/typed_tree.py +++ b/nutree/typed_tree.py @@ -25,6 +25,7 @@ from nutree.common import ( ROOT_DATA_ID, ROOT_NODE_ID, + CycleDetectedError, DataIdType, DeserializeMapperType, IterMethod, @@ -83,11 +84,12 @@ def __init__( node_id: int | None = None, meta: dict | None = None, ): - # tree._register() checks for this attribute in the next line: - self._kind: str = kind + # # tree._register() checks for this attribute in the next line: + # self._kind: str = kind super().__init__( data, parent=parent, data_id=data_id, node_id=node_id, meta=meta ) + self._kind: str = kind assert isinstance(kind, str), f"Unsupported `kind`: {kind}" # del self._children @@ -605,6 +607,26 @@ def deserialize_mapper(cls, parent: Node, data: dict) -> str | object | None: f"Override this method or pass a mapper callback to evaluate {data}." ) + def _check_insert(self, node: Node): + """Raise error if inserting a node would violate DAG restrictions.""" + # We can assume that node.parent is set and that node already has at + # least one clone registered in self._nodes_by_data_id, when this is + # called from _register() + assert node._kind, node + ref_key = node._data_id + kind = node._kind + if node._parent._children: + for sibling in node._parent._children: + if sibling._ref_key == ref_key and sibling._kind == kind: + raise UniqueConstraintError( + f"Node with data_id {ref_key} and kind {kind} already exists" + ) + for n in self._nodes_by_data_id[ref_key]: + if node.is_descendant_of(n) and n._kind == kind: + raise CycleDetectedError( + f"Inserting {node} would create a cycle with {n}" + ) + def add_child( self, child: TypedNode[TData] | Self | TData, From 1e98f72a7124db7b02f2e8f07417c0c961ecf7f6 Mon Sep 17 00:00:00 2001 From: Martin Wendt Date: Wed, 26 Feb 2025 22:03:01 +0100 Subject: [PATCH 4/7] Fix tests --- nutree/common.py | 17 +++++++++++++++-- nutree/tree.py | 12 ++++++------ nutree/typed_tree.py | 10 +++++----- tests/test_clones.py | 21 +++++++++++++++++---- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/nutree/common.py b/nutree/common.py index 14dbaeb..0f6ffd2 100644 --- a/nutree/common.py +++ b/nutree/common.py @@ -65,11 +65,24 @@ class DuplicateNodeIdError(StructureError): class UniqueConstraintError(StructureError): - """Thrown when trying to add the same ref_key to the same parent.""" + """Thrown when trying to add the same data_id to the same parent. + + This would violate the constraint of the tree being a 'SIMPLE directed + acyclic graph'. + Note that the tree allows to add the same data_id to different parent nodes. + In TypedTrees, the data_id may be added to the same parent twice, as long as + it has a different kind. + """ class CycleDetectedError(StructureError): - """Thrown when trying to add the same ref_key to the same ancestor chain.""" + """Thrown when trying to add the same data_id to the same ancestor chain. + + This would violate the constraint of the tree being a 'simple directed + ACYCLIC graph' and create a cycle. + In TypedTrees, the data_id may be added to the same ancestor chain more than + once, as long as it has a different kind. + """ class AmbiguousMatchError(TreeError): diff --git a/nutree/tree.py b/nutree/tree.py index dc92cc8..fa641f6 100644 --- a/nutree/tree.py +++ b/nutree/tree.py @@ -235,14 +235,14 @@ def _check_insert(self, node: TNode): # We can assume that node.parent is set and that node already has at # least one clone registered in self._nodes_by_data_id, when this is # called from _register() - ref_key = node._data_id + data_id = node._data_id if node._parent._children: for sibling in node._parent._children: - if sibling._data_id == ref_key: + if sibling._data_id == data_id: raise UniqueConstraintError( - f"Node with data_id {ref_key} already exists under same parent" + f"Node with data_id {data_id} already exists under same parent" ) - for n in self._nodes_by_data_id[ref_key]: + for n in self._nodes_by_data_id[data_id]: if node.is_descendant_of(n): raise CycleDetectedError( f"Inserting {node} would create a cycle with {n}" @@ -254,15 +254,15 @@ def _register(self, node: TNode) -> None: if node._node_id in self._node_by_id: raise DuplicateNodeIdError(f"Node ID already registered: {node}") - self._node_by_id[node._node_id] = node try: clone_list = self._nodes_by_data_id[node._data_id] # may raise KeyError # if we get here, we are adding a clone and should check DAG compliance - if self.structure_checks and node.parent: + if self.structure_checks and node._parent: self._check_insert(node) clone_list.append(node) except KeyError: self._nodes_by_data_id[node._data_id] = [node] + self._node_by_id[node._node_id] = node def _unregister(self, node: TNode, *, clear: bool = True) -> None: """Unlink node from this tree (children must be unregistered first).""" diff --git a/nutree/typed_tree.py b/nutree/typed_tree.py index 6431dbc..d9d9bfa 100644 --- a/nutree/typed_tree.py +++ b/nutree/typed_tree.py @@ -84,12 +84,11 @@ def __init__( node_id: int | None = None, meta: dict | None = None, ): - # # tree._register() checks for this attribute in the next line: - # self._kind: str = kind + # tree._register() checks for this attribute in __init__(): + self._kind: str = kind super().__init__( data, parent=parent, data_id=data_id, node_id=node_id, meta=meta ) - self._kind: str = kind assert isinstance(kind, str), f"Unsupported `kind`: {kind}" # del self._children @@ -617,9 +616,10 @@ def _check_insert(self, node: Node): kind = node._kind if node._parent._children: for sibling in node._parent._children: - if sibling._ref_key == ref_key and sibling._kind == kind: + if sibling._data_id == ref_key and sibling._kind == kind: raise UniqueConstraintError( - f"Node with data_id {ref_key} and kind {kind} already exists" + f"Node with data_id {ref_key} and kind {kind} " + f"already exists in parent {node._parent}" ) for n in self._nodes_by_data_id[ref_key]: if node.is_descendant_of(n) and n._kind == kind: diff --git a/tests/test_clones.py b/tests/test_clones.py index e23a062..1acf7dc 100644 --- a/tests/test_clones.py +++ b/tests/test_clones.py @@ -5,7 +5,11 @@ import pytest from nutree import AmbiguousMatchError, Node, Tree -from nutree.common import DictWrapper, UniqueConstraintError +from nutree.common import ( + CycleDetectedError, + DictWrapper, + UniqueConstraintError, +) from . import fixture @@ -39,11 +43,13 @@ def test_clones(self): with pytest.raises(AmbiguousMatchError): tree["a1"] - # # Not allowed to add two clones to same parent + # Not allowed to add two clones to same parent with pytest.raises(UniqueConstraintError): tree.add("A") with pytest.raises(UniqueConstraintError): tree.add(tree["A"]) + with pytest.raises(CycleDetectedError): + tree["a2"].add(tree["A"]) res = tree.find("a1") assert res @@ -93,12 +99,19 @@ def test_clones_typed(self): assert tree.count_unique == 8 fail1 = tree["fail1"] - # Not allowed to add two clones to same parent + # Not allowed to add two clones with the same kind to same parent with pytest.raises(UniqueConstraintError): fail1.add("cause1", kind="cause") fail1.add("cause1", kind="other") + + # Not allowed to add two clones with the same kind to ancestor chain + eff2 = tree["eff2"] + with pytest.raises(CycleDetectedError): + eff2.add("func1", kind="function") + eff2.add("func1", kind="other") + tree.print() - assert tree.count == 9 + assert tree.count == 10 assert tree.count_unique == 8 def test_dict(self): From 754c0460ec355cd93e16105e247bf6741cd50f2b Mon Sep 17 00:00:00 2001 From: Martin Wendt Date: Thu, 27 Feb 2025 21:28:00 +0100 Subject: [PATCH 5/7] Add tests --- nutree/common.py | 3 +-- nutree/tree.py | 4 +++- tests/test_core.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/nutree/common.py b/nutree/common.py index 0f6ffd2..5b28f35 100644 --- a/nutree/common.py +++ b/nutree/common.py @@ -55,9 +55,8 @@ class TreeError(RuntimeError): class StructureError(TreeError): """Base class for errors thrown when the tree structure is invalid.""" - def __init__(self, message: str, node: Node | None = None): + def __init__(self, message: str): super().__init__(message) - self.node = node class DuplicateNodeIdError(StructureError): diff --git a/nutree/tree.py b/nutree/tree.py index fa641f6..8c4f6dc 100644 --- a/nutree/tree.py +++ b/nutree/tree.py @@ -252,7 +252,9 @@ def _register(self, node: TNode) -> None: assert node._tree is self assert node._node_id is not None if node._node_id in self._node_by_id: - raise DuplicateNodeIdError(f"Node ID already registered: {node}") + raise DuplicateNodeIdError( + f"Node ID already registered: {node}" + ) # pragma: no cover try: clone_list = self._nodes_by_data_id[node._data_id] # may raise KeyError diff --git a/tests/test_core.py b/tests/test_core.py index f77682d..6e91abe 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -565,6 +565,37 @@ def test_iter(self): s = [n.data for n in tree.iterator(IterMethod.RANDOM_ORDER)] assert len(s) == 8 + def test_iter_parents(self): + """ + Tree<'fixture'> + ├── A + │ ├── a1 + │ │ ├── a11 + │ │ ╰── a12 + │ ╰── a2 + ╰── B + ╰── b1 + ╰── b11 + """ + tree = fixture.create_tree_simple() + + # print(tree.format(repr="{node.data}")) + a11 = tree["a12"] + + s = ",".join(n.data for n in a11.parent_iterator()) + assert s == "a1,A" + + s = ",".join(n.data for n in a11.parent_iterator(add_self=True)) + assert s == "a12,a1,A" + + s = ",".join(n.data for n in a11.parent_iterator(bottom_up=False)) + assert s == "A,a1" + + s = ",".join( + n.data for n in a11.parent_iterator(bottom_up=False, add_self=True) + ) + assert s == "A,a1,a12" + def test_visit(self): """ Tree<'fixture'> From c6682fabb93583311ddacefd56ec67af7e3f2e10 Mon Sep 17 00:00:00 2001 From: Martin Wendt Date: Thu, 27 Feb 2025 21:56:44 +0100 Subject: [PATCH 6/7] Add docs --- CHANGELOG.md | 2 +- nutree/common.py | 4 ++++ nutree/tree.py | 19 ++++++++++++------- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c658a7f..c24d3fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 1.2.0 (unreleased) - New method `node.parent_iterator()`. -- New option `Tree(..., structure_checks=True)` to enforce DAG compliance. +- New option `Tree(..., check_dag=True)` to enforce DAG compliance. ## 1.1.0 (2025-02-09) diff --git a/nutree/common.py b/nutree/common.py index 5b28f35..69e5aac 100644 --- a/nutree/common.py +++ b/nutree/common.py @@ -71,6 +71,8 @@ class UniqueConstraintError(StructureError): Note that the tree allows to add the same data_id to different parent nodes. In TypedTrees, the data_id may be added to the same parent twice, as long as it has a different kind. + + Pass `check_dag=False` to the tree constructor to suppress this restriction. """ @@ -81,6 +83,8 @@ class CycleDetectedError(StructureError): ACYCLIC graph' and create a cycle. In TypedTrees, the data_id may be added to the same ancestor chain more than once, as long as it has a different kind. + + Pass `check_dag=False` to the tree constructor to suppress this restriction. """ diff --git a/nutree/tree.py b/nutree/tree.py index 8c4f6dc..31e206f 100644 --- a/nutree/tree.py +++ b/nutree/tree.py @@ -110,8 +110,9 @@ class Tree(Generic[TData, TNode]): i.e. make `node.data.NAME` accessible as `node.NAME`. |br| **Note:** Use with care, see also :ref:`forward-attributes`. - `structure_checks` enables validations to ensure that the node structure is - compliant with Directed Acyclic Graphs (DAG). + `check_dag` enables validations to ensure that the node structure is + compliant with Directed Acyclic Graphs (DAG). This means that no nodes + with the same data_id cannot be added as descendants of each other. """ node_factory: type[TNode] = cast(type[TNode], Node) @@ -134,7 +135,7 @@ def __init__( *, calc_data_id: CalcIdCallbackType | None = None, forward_attrs: bool = False, - structure_checks: bool = True, + check_dag: bool = True, ): self._lock = threading.RLock() #: Tree name used for logging @@ -145,10 +146,10 @@ def __init__( # Optional callback that calculates data_ids from data objects # hash(data) is used by default self._calc_data_id_hook: CalcIdCallbackType | None = calc_data_id - # Enable aliasing when accessing node instances. + #: Enable aliasing when accessing node instances. self._forward_attrs: bool = forward_attrs - # Enable cycle detection in add_child() - self.structure_checks = structure_checks + #: Enable cycle detection in add_child() + self.check_dag = check_dag def __repr__(self): return f"{self.__class__.__name__}<{self.name!r}>" @@ -241,11 +242,15 @@ def _check_insert(self, node: TNode): if sibling._data_id == data_id: raise UniqueConstraintError( f"Node with data_id {data_id} already exists under same parent" + "Pass `check_dag=False` to the tree constructor to suppress " + "this restriction." ) for n in self._nodes_by_data_id[data_id]: if node.is_descendant_of(n): raise CycleDetectedError( f"Inserting {node} would create a cycle with {n}" + "Pass `check_dag=False` to the tree constructor to suppress " + "this restriction." ) def _register(self, node: TNode) -> None: @@ -259,7 +264,7 @@ def _register(self, node: TNode) -> None: try: clone_list = self._nodes_by_data_id[node._data_id] # may raise KeyError # if we get here, we are adding a clone and should check DAG compliance - if self.structure_checks and node._parent: + if self.check_dag and node._parent: self._check_insert(node) clone_list.append(node) except KeyError: From 5e55c5205a65a09af2ebfecdb6b3b9e21bac18e9 Mon Sep 17 00:00:00 2001 From: Martin Wendt Date: Thu, 27 Feb 2025 22:09:22 +0100 Subject: [PATCH 7/7] Update test_mermaid.py --- tests/test_mermaid.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_mermaid.py b/tests/test_mermaid.py index fb93500..1db85c2 100644 --- a/tests/test_mermaid.py +++ b/tests/test_mermaid.py @@ -3,6 +3,7 @@ """ """ # ruff: noqa: T201, T203 `print` found +import io import shutil from pathlib import Path @@ -76,6 +77,16 @@ def test_serialize_mermaid_mappers(self): assert '7-. "1" .->8' in buffer assert "classDef default fill" in buffer + def test_serialize_mermaid_errors(self): + """Save/load as object tree with clones.""" + tree = fixture.create_typed_tree_simple(clones=True, name="Root") + + with pytest.raises(ValueError, match="target must be a Path, str, or"): + tree.to_mermaid_flowchart(15) # type: ignore + + with pytest.raises(RuntimeError, match="Need a filepath to"): + tree.to_mermaid_flowchart(io.StringIO("x"), format="png") + def test_serialize_mermaid_typed(self): """Save/load as object tree with clones.""" KEEP_FILES = not fixture.is_running_on_ci() and False