From f7cdf92fe84f4c61cd349df382841c0b2956d651 Mon Sep 17 00:00:00 2001 From: Kay Date: Tue, 24 Dec 2024 01:46:18 +0800 Subject: [PATCH 1/7] feat: add merge_attribute as parameter, rephrase documentation and docstring --- CHANGELOG.md | 2 + bigtree/tree/modify.py | 131 ++++++++++++++++++++++++++------------ tests/tree/test_modify.py | 18 +++--- 3 files changed, 103 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e08870d9..6b1b2049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed: +- Tree Modify: Update documentation and docstring with some rephrasing. ## [0.22.3] - 2024-11-14 ### Added: diff --git a/bigtree/tree/modify.py b/bigtree/tree/modify.py index b53e36f0..86a079d9 100644 --- a/bigtree/tree/modify.py +++ b/bigtree/tree/modify.py @@ -27,6 +27,7 @@ def shift_nodes( sep: str = "/", skippable: bool = False, overriding: bool = False, + merge_attribute: bool = False, merge_children: bool = False, merge_leaves: bool = False, delete_children: bool = False, @@ -34,11 +35,12 @@ def shift_nodes( ) -> None: """Shift nodes from `from_paths` to `to_paths` *in-place*. - - Creates intermediate nodes if to path is not present - - Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable). + - Creates intermediate nodes if to-path is not present + - Able to skip nodes if from-path is not found, defaults to False (from-nodes must be found; not skippable). - Able to override existing node if it exists, defaults to False (to-nodes must not exist; not overridden). + - Able to merge attributes of node if it exists, defaults to False (to-nodes must not exist; no attributes to merge). - Able to merge children and remove intermediate parent node, defaults to False (nodes are shifted; not merged). - - Able to merge only leaf nodes and remove all intermediate nodes, defaults to False (nodes are shifted; not merged) + - Able to merge leaf nodes and remove all intermediate nodes, defaults to False (nodes are shifted; not merged) - Able to shift node only and delete children, defaults to False (nodes are shifted together with children). For paths in `from_paths` and `to_paths`, @@ -69,6 +71,11 @@ def shift_nodes( - If `to_path` is present and ``overriding=True``, it behaves like overriding and only new leaves are retained, original non-leaf nodes in `from_path` are retained. + Note: + + - `merge_children` and `merge_leaves` cannot be both True at the same time. + - `overriding` and `merge_attribute` cannot be both True at the same time. + Examples: >>> from bigtree import list_to_tree, str_to_tree, shift_nodes >>> root = list_to_tree(["Downloads/photo1.jpg", "Downloads/file1.doc"]) @@ -130,7 +137,7 @@ def shift_nodes( In ``merge_children=True`` case, child nodes are shifted instead of the parent node. - If the path already exists, child nodes are merged with existing children. - - If same node is shifted, the child nodes of the node are merged with the node's parent. + - Otherwise, the child nodes of the node are merged with the node's parent. >>> root = str_to_tree( ... "Downloads\\n" @@ -174,10 +181,10 @@ def shift_nodes( └── Files └── file1.doc - In ``merge_leaves=True`` case, leaf nodes are copied instead of the parent node. + In ``merge_leaves=True`` case, leaf nodes are shifted instead of the parent node. - If the path already exists, leaf nodes are merged with existing children. - - If same node is copied, the leaf nodes of the node are merged with the node's parent. + - Otherwise, the leaf nodes of the node are merged with the node's parent. >>> root = str_to_tree( ... "Downloads\\n" @@ -255,12 +262,16 @@ def shift_nodes( from_paths (List[str]): original paths to shift nodes from to_paths (List[str]): new paths to shift nodes to sep (str): path separator for input paths, applies to `from_path` and `to_path` - skippable (bool): indicator to skip if from path is not found, defaults to False - overriding (bool): indicator to override existing to path if there is clashes, defaults to False - merge_children (bool): indicator to merge children and remove intermediate parent node, defaults to False - merge_leaves (bool): indicator to merge leaf nodes and remove intermediate parent node(s), defaults to False + skippable (bool): indicator to skip if from-path is not found, defaults to False + overriding (bool): indicator to override existing to-path if there are clashes, defaults to False + merge_attribute (bool): indicator to merge attributes of from-path and to-path if there are clashes, defaults + to False + merge_children (bool): indicator to merge from-path's children and remove intermediate parent node, defaults to + False + merge_leaves (bool): indicator to merge from-path's leaf nodes and remove intermediate parent node(s), defaults + to False delete_children (bool): indicator to shift node only without children, defaults to False - with_full_path (bool): indicator to shift/copy node with full path in `from_paths`, results in faster search, + with_full_path (bool): indicator to shift node with full path in `from_paths`, results in faster search, defaults to False """ return copy_or_shift_logic( @@ -271,6 +282,7 @@ def shift_nodes( copy=False, skippable=skippable, overriding=overriding, + merge_attribute=merge_attribute, merge_children=merge_children, merge_leaves=merge_leaves, delete_children=delete_children, @@ -286,6 +298,7 @@ def copy_nodes( sep: str = "/", skippable: bool = False, overriding: bool = False, + merge_attribute: bool = False, merge_children: bool = False, merge_leaves: bool = False, delete_children: bool = False, @@ -293,11 +306,12 @@ def copy_nodes( ) -> None: """Copy nodes from `from_paths` to `to_paths` *in-place*. - - Creates intermediate nodes if to path is not present - - Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable). + - Creates intermediate nodes if to-path is not present + - Able to skip nodes if from-path is not found, defaults to False (from-nodes must be found; not skippable). - Able to override existing node if it exists, defaults to False (to-nodes must not exist; not overridden). - - Able to merge children and remove intermediate parent node, defaults to False (nodes are shifted; not merged). - - Able to merge only leaf nodes and remove all intermediate nodes, defaults to False (nodes are shifted; not merged) + - Able to merge attributes of node if it exists, defaults to False (to-nodes must not exist; no attributes to merge). + - Able to merge children and remove intermediate parent node, defaults to False (nodes are copied; not merged). + - Able to merge only leaf nodes and remove all intermediate nodes, defaults to False (nodes are copied; not merged) - Able to copy node only and delete children, defaults to False (nodes are copied together with children). For paths in `from_paths` and `to_paths`, @@ -326,6 +340,11 @@ def copy_nodes( - If `to_path` is present, and ``overriding=False``, original children and leaves are merged. - If `to_path` is present and ``overriding=True``, it behaves like overriding and only new leaves are retained. + Note: + + - `merge_children` and `merge_leaves` cannot be both True at the same time. + - `overriding` and `merge_attribute` cannot be both True at the same time. + Examples: >>> from bigtree import list_to_tree, str_to_tree, copy_nodes >>> root = list_to_tree(["Downloads/Pictures", "Downloads/photo1.jpg", "Downloads/file1.doc"]) @@ -379,7 +398,7 @@ def copy_nodes( In ``merge_children=True`` case, child nodes are copied instead of the parent node. - If the path already exists, child nodes are merged with existing children. - - If same node is copied, the child nodes of the node are merged with the node's parent. + - Otherwise, the child nodes of the node are merged with the node's parent. >>> root = str_to_tree( ... "Downloads\\n" @@ -430,7 +449,7 @@ def copy_nodes( In ``merge_leaves=True`` case, leaf nodes are copied instead of the parent node. - If the path already exists, leaf nodes are merged with existing children. - - If same node is copied, the leaf nodes of the node are merged with the node's parent. + - Otherwise, the leaf nodes of the node are merged with the node's parent. >>> root = str_to_tree( ... "Downloads\\n" @@ -515,10 +534,14 @@ def copy_nodes( sep (str): path separator for input paths, applies to `from_path` and `to_path` skippable (bool): indicator to skip if from path is not found, defaults to False overriding (bool): indicator to override existing to path if there is clashes, defaults to False - merge_children (bool): indicator to merge children and remove intermediate parent node, defaults to False - merge_leaves (bool): indicator to merge leaf nodes and remove intermediate parent node(s), defaults to False + merge_attribute (bool): indicator to merge attributes of from-path and to-path if there are clashes, defaults + to False + merge_children (bool): indicator to merge from-path's children and remove intermediate parent node, defaults to + False + merge_leaves (bool): indicator to merge from-path's leaf nodes and remove intermediate parent node(s), defaults + to False delete_children (bool): indicator to copy node only without children, defaults to False - with_full_path (bool): indicator to shift/copy node with full path in `from_paths`, results in faster search, + with_full_path (bool): indicator to copy node with full path in `from_paths`, results in faster search, defaults to False """ return copy_or_shift_logic( @@ -529,6 +552,7 @@ def copy_nodes( copy=True, skippable=skippable, overriding=overriding, + merge_attribute=merge_attribute, merge_children=merge_children, merge_leaves=merge_leaves, delete_children=delete_children, @@ -548,8 +572,8 @@ def shift_and_replace_nodes( ) -> None: """Shift nodes from `from_paths` to *replace* `to_paths` *in-place*. - - Creates intermediate nodes if to path is not present - - Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable). + - Creates intermediate nodes if to-path is not present + - Able to skip nodes if from-path is not found, defaults to False (from-nodes must be found; not skippable). - Able to shift node only and delete children, defaults to False (nodes are shifted together with children). For paths in `from_paths` and `to_paths`, @@ -619,7 +643,7 @@ def shift_and_replace_nodes( sep (str): path separator for input paths, applies to `from_path` and `to_path` skippable (bool): indicator to skip if from path is not found, defaults to False delete_children (bool): indicator to shift node only without children, defaults to False - with_full_path (bool): indicator to shift/copy node with full path in `from_paths`, results in faster search, + with_full_path (bool): indicator to shift node with full path in `from_paths`, results in faster search, defaults to False """ return replace_logic( @@ -643,6 +667,7 @@ def copy_nodes_from_tree_to_tree( sep: str = "/", skippable: bool = False, overriding: bool = False, + merge_attribute: bool = False, merge_children: bool = False, merge_leaves: bool = False, delete_children: bool = False, @@ -650,9 +675,10 @@ def copy_nodes_from_tree_to_tree( ) -> None: """Copy nodes from `from_paths` to `to_paths` *in-place*. - - Creates intermediate nodes if to path is not present - - Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable). + - Creates intermediate nodes if to-path is not present + - Able to skip nodes if from-path is not found, defaults to False (from-nodes must be found; not skippable). - Able to override existing node if it exists, defaults to False (to-nodes must not exist; not overridden). + - Able to merge attributes of node if it exists, defaults to False (to-nodes must not exist; no attributes to merge). - Able to merge children and remove intermediate parent node, defaults to False (nodes are shifted; not merged). - Able to merge only leaf nodes and remove all intermediate nodes, defaults to False (nodes are shifted; not merged) - Able to copy node only and delete children, defaults to False (nodes are copied together with children). @@ -683,6 +709,11 @@ def copy_nodes_from_tree_to_tree( - If `to_path` is present, and ``overriding=False``, original children and leaves are merged. - If `to_path` is present and ``overriding=True``, it behaves like overriding and only new leaves are retained. + Note: + + - `merge_children` and `merge_leaves` cannot be both True at the same time. + - `overriding` and `merge_attribute` cannot be both True at the same time. + Examples: >>> from bigtree import Node, str_to_tree, copy_nodes_from_tree_to_tree >>> root = str_to_tree( @@ -749,6 +780,7 @@ def copy_nodes_from_tree_to_tree( In ``merge_children=True`` case, child nodes are copied instead of the parent node. - If the path already exists, child nodes are merged with existing children. + - Otherwise, the child nodes of the node are merged with the node's parent. >>> root_other = str_to_tree( ... "Documents\\n" @@ -778,6 +810,7 @@ def copy_nodes_from_tree_to_tree( In ``merge_leaves=True`` case, leaf nodes are copied instead of the parent node. - If the path already exists, leaf nodes are merged with existing children. + - Otherwise, the leaf nodes of the node are merged with the node's parent. >>> root_other = str_to_tree( ... "Documents\\n" @@ -829,10 +862,14 @@ def copy_nodes_from_tree_to_tree( sep (str): path separator for input paths, applies to `from_path` and `to_path` skippable (bool): indicator to skip if from path is not found, defaults to False overriding (bool): indicator to override existing to path if there is clashes, defaults to False - merge_children (bool): indicator to merge children and remove intermediate parent node, defaults to False - merge_leaves (bool): indicator to merge leaf nodes and remove intermediate parent node(s), defaults to False + merge_attribute (bool): indicator to merge attributes of from-path and to-path if there are clashes, defaults + to False + merge_children (bool): indicator to merge from-path's children and remove intermediate parent node, defaults to + False + merge_leaves (bool): indicator to merge from-path's leaf nodes and remove intermediate parent node(s), defaults + to False delete_children (bool): indicator to copy node only without children, defaults to False - with_full_path (bool): indicator to shift/copy node with full path in `from_paths`, results in faster search, + with_full_path (bool): indicator to copy node with full path in `from_paths`, results in faster search, defaults to False """ return copy_or_shift_logic( @@ -843,6 +880,7 @@ def copy_nodes_from_tree_to_tree( copy=True, skippable=skippable, overriding=overriding, + merge_attribute=merge_attribute, merge_children=merge_children, merge_leaves=merge_leaves, delete_children=delete_children, @@ -863,8 +901,8 @@ def copy_and_replace_nodes_from_tree_to_tree( ) -> None: """Copy nodes from `from_paths` to *replace* `to_paths` *in-place*. - - Creates intermediate nodes if to path is not present - - Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable). + - Creates intermediate nodes if to-path is not present + - Able to skip nodes if from-path is not found, defaults to False (from-nodes must be found; not skippable). - Able to copy node only and delete children, defaults to False (nodes are copied together with children). For paths in `from_paths` and `to_paths`, @@ -959,12 +997,12 @@ def copy_and_replace_nodes_from_tree_to_tree( Args: from_tree (Node): tree to copy nodes from to_tree (Node): tree to copy nodes to - from_paths (List[str]): original paths to shift nodes from - to_paths (List[str]): new paths to shift nodes to + from_paths (List[str]): original paths to copy nodes from + to_paths (List[str]): new paths to copy nodes to sep (str): path separator for input paths, applies to `from_path` and `to_path` skippable (bool): indicator to skip if from path is not found, defaults to False delete_children (bool): indicator to copy node only without children, defaults to False - with_full_path (bool): indicator to shift/copy node with full path in `from_paths`, results in faster search, + with_full_path (bool): indicator to copy node with full path in `from_paths`, results in faster search, defaults to False """ return replace_logic( @@ -988,6 +1026,7 @@ def copy_or_shift_logic( copy: bool = False, skippable: bool = False, overriding: bool = False, + merge_attribute: bool = False, merge_children: bool = False, merge_leaves: bool = False, delete_children: bool = False, @@ -996,10 +1035,11 @@ def copy_or_shift_logic( ) -> None: """Shift or copy nodes from `from_paths` to `to_paths` *in-place*. - - Creates intermediate nodes if to path is not present + - Creates intermediate nodes if to-path is not present - Able to copy node, defaults to False (nodes are shifted; not copied). - - Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable) + - Able to skip nodes if from-path is not found, defaults to False (from-nodes must be found; not skippable) - Able to override existing node if it exists, defaults to False (to-nodes must not exist; not overridden) + - Able to merge attributes of node if it exists, defaults to False (to-nodes must not exist; no attributes to merge). - Able to merge children and remove intermediate parent node, defaults to False (nodes are shifted; not merged) - Able to merge only leaf nodes and remove all intermediate nodes, defaults to False (nodes are shifted; not merged) - Able to shift/copy node only and delete children, defaults to False (nodes are shifted/copied together with children). @@ -1033,6 +1073,11 @@ def copy_or_shift_logic( - If `to_path` is present and ``overriding=True``, it behaves like overriding and only new leaves are retained, original non-leaf nodes in `from_path` are retained. + Note: + + - `merge_children` and `merge_leaves` cannot be both True at the same time. + - `overriding` and `merge_attribute` cannot be both True at the same time. + Args: tree (Node): tree to modify from_paths (List[str]): original paths to shift nodes from @@ -1041,8 +1086,12 @@ def copy_or_shift_logic( copy (bool): indicator to copy node, defaults to False skippable (bool): indicator to skip if from path is not found, defaults to False overriding (bool): indicator to override existing to path if there is clashes, defaults to False - merge_children (bool): indicator to merge children and remove intermediate parent node, defaults to False - merge_leaves (bool): indicator to merge leaf nodes and remove intermediate parent node(s), defaults to False + merge_attribute (bool): indicator to merge attributes of from-path and to-path if there are clashes, defaults + to False + merge_children (bool): indicator to merge from-path's children and remove intermediate parent node, defaults to + False + merge_leaves (bool): indicator to merge from-path's leaf nodes and remove intermediate parent node(s), defaults + to False delete_children (bool): indicator to shift/copy node only without children, defaults to False to_tree (Node): tree to copy to, defaults to None with_full_path (bool): indicator to shift/copy node with full path in `from_paths`, results in faster search, @@ -1052,6 +1101,10 @@ def copy_or_shift_logic( raise ValueError( "Invalid shifting, can only specify one type of merging, check `merge_children` and `merge_leaves`" ) + if overriding and merge_attribute: + raise ValueError( + "Invalid shifting, can only specify one type of merging, check `overriding` and `merge_attribute`" + ) if not (isinstance(from_paths, list) and isinstance(to_paths, list)): raise ValueError( "Invalid type, `from_paths` and `to_paths` should be list type" @@ -1233,9 +1286,9 @@ def replace_logic( ) -> None: """Shift or copy nodes from `from_paths` to *replace* `to_paths` *in-place*. - - Creates intermediate nodes if to path is not present + - Creates intermediate nodes if to-path is not present - Able to copy node, defaults to False (nodes are shifted; not copied). - - Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable) + - Able to skip nodes if from-path is not found, defaults to False (from-nodes must be found; not skippable) - Able to replace node only and delete children, defaults to False (nodes are shifted/copied together with children). - Able to shift/copy nodes from one tree to another tree, defaults to None (shifting/copying happens within same tree) diff --git a/tests/tree/test_modify.py b/tests/tree/test_modify.py index c3bd74f1..b43c47e1 100644 --- a/tests/tree/test_modify.py +++ b/tests/tree/test_modify.py @@ -267,7 +267,7 @@ def test_copy_nodes_skippable_error(self): assert_tree_structure_node_root(self.root) # overriding - def test_copy_nodes_delete_and_overriding_error(self): + def test_copy_nodes_overriding_error(self): to_path = "a/b/d" new_aa = node.Node("aa", parent=self.root) new_d = node.Node("d") @@ -280,7 +280,7 @@ def test_copy_nodes_delete_and_overriding_error(self): to_path=to_path ) - def test_copy_nodes_delete_and_overriding(self): + def test_copy_nodes_overriding(self): new_aa = node.Node("aa", parent=self.root) new_d = node.Node("d", age=1) new_d.parent = new_aa @@ -297,7 +297,7 @@ def test_copy_nodes_delete_and_overriding(self): assert_tree_structure_basenode_root_attr(self.root, d=("d", 1)) assert_tree_structure_node_root(self.root) - def test_copy_nodes_overriding(self): + def test_copy_nodes_overriding_manual_check(self): from_paths = ["a/aa/bb"] to_paths = ["/a/bb"] modify.copy_nodes( @@ -839,7 +839,7 @@ def test_shift_nodes_skippable_error(self): assert_tree_structure_node_root(self.root) # overriding - def test_shift_nodes_delete_and_overriding_error(self): + def test_shift_nodes_overriding_error(self): to_path = "a/b/d" new_aa = node.Node("aa", parent=self.root) new_d = node.Node("d") @@ -852,7 +852,7 @@ def test_shift_nodes_delete_and_overriding_error(self): to_path=to_path ) - def test_shift_nodes_delete_and_overriding(self): + def test_shift_nodes_overriding(self): new_aa = node.Node("aa", parent=self.root) new_d = node.Node("d", age=1) new_d.parent = new_aa @@ -863,7 +863,7 @@ def test_shift_nodes_delete_and_overriding(self): assert_tree_structure_basenode_root_attr(self.root, d=("d", 1)) assert_tree_structure_node_root(self.root) - def test_shift_nodes_overriding(self): + def test_shift_nodes_overriding_manual_check(self): from_paths = ["a/aa/bb"] to_paths = ["/a/bb"] modify.shift_nodes( @@ -1748,7 +1748,7 @@ def test_copy_nodes_skippable_error(self): assert_tree_structure_node_root(self.root_other) # overriding - def test_copy_nodes_from_tree_to_tree_delete_and_overriding_error(self): + def test_copy_nodes_from_tree_to_tree_overriding_error(self): new_aa = node.Node("aa", parent=self.root) new_d = node.Node("d") new_d.parent = new_aa @@ -1766,7 +1766,7 @@ def test_copy_nodes_from_tree_to_tree_delete_and_overriding_error(self): to_path=to_path ) - def test_copy_nodes_from_tree_to_tree_delete_and_overriding(self): + def test_copy_nodes_from_tree_to_tree_overriding(self): new_aa = node.Node("aa", parent=self.root) new_d = node.Node("d", age=1) new_d.parent = new_aa @@ -1792,7 +1792,7 @@ def test_copy_nodes_from_tree_to_tree_delete_and_overriding(self): assert_tree_structure_basenode_root_attr(self.root_other, d=("d", 1)) assert_tree_structure_node_root(self.root_other) - def test_copy_nodes_from_tree_to_tree_overriding(self): + def test_copy_nodes_from_tree_to_tree_overriding_manual_check(self): from_paths = ["d", "e", "g", "h", "f"] to_paths = ["a/b/d", "a/b/e", "a/b/e/g", "a/b/e/h", "a/c/f"] modify.shift_nodes(self.root, from_paths, to_paths) From cd77deede0a3313d6e0cb72f3aff95aaa4e0c128 Mon Sep 17 00:00:00 2001 From: Kay Date: Wed, 25 Dec 2024 04:02:06 +0800 Subject: [PATCH 2/7] feat: merge_attribute done, tests WIP --- CHANGELOG.md | 2 + bigtree/tree/modify.py | 61 ++++++++++++++++--- docs/bigtree/tree/modify.md | 45 ++++++++------ tests/test_constants.py | 3 +- tests/tree/test_modify.py | 118 ++++++++++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1b2049..c2dac739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed: - Tree Modify: Update documentation and docstring with some rephrasing. +### Added: +- Tree Modify: Add parameter `merge_attribute` to allow from-node and to-node attributes to be merged if there are clashes. ## [0.22.3] - 2024-11-14 ### Added: diff --git a/bigtree/tree/modify.py b/bigtree/tree/modify.py index 86a079d9..4c47ec6d 100644 --- a/bigtree/tree/modify.py +++ b/bigtree/tree/modify.py @@ -1227,18 +1227,63 @@ def copy_or_shift_logic( ) del to_node.children else: - if not overriding: + if not (overriding or merge_attribute): raise exceptions.TreeError( f"Path {to_path} already exists and unable to override\n" - f"Set `overriding` to True to perform overrides\n" + f"Set `overriding` or `merge_attribute` to True to handle node name clashes\n" f"Alternatively, set `merge_children` to True if nodes are to be merged" ) - logging.info( - f"Path {to_path} already exists and will be overridden" - ) - parent = to_node.parent - to_node.parent = None - to_node = parent + if overriding: + logging.info( + f"Path {to_path} already exists and will be overridden" + ) + parent = to_node.parent + to_node.parent = None + to_node = parent + else: + logging.info( + f"Path {to_path} already exists and attributes will be upserted" + ) + import pandas as pd + + from bigtree.tree import export + + dummy_to_node = to_node.copy() + dummy_to_node.parent = None + to_node_data = export.tree_to_dataframe( + dummy_to_node, all_attrs=True + ) + + dummy_from_node = from_node.copy() + dummy_from_node.parent = None + from_node_data = export.tree_to_dataframe( + dummy_from_node, all_attrs=True + ) + + common_attributes = set(to_node_data.columns).intersection( + set(from_node_data.columns) + ) - {"path", "name"} + merged_data = pd.merge( + to_node_data, + from_node_data, + on=["path", "name"], + how="outer", + suffixes=("_x", "_y"), + ) + for common_attribute in common_attributes: + merged_data[common_attribute] = merged_data[ + common_attribute + "_y" + ].fillna(merged_data[common_attribute + "_x"]) + merged_data.drop( + columns=[ + common_attribute + "_x", + common_attribute + "_y", + ] + ) + from_node = construct.dataframe_to_tree(merged_data) + parent = to_node.parent + to_node.parent = None + to_node = parent # To node not found else: diff --git a/docs/bigtree/tree/modify.md b/docs/bigtree/tree/modify.md index 58c8b562..2133d3f6 100644 --- a/docs/bigtree/tree/modify.md +++ b/docs/bigtree/tree/modify.md @@ -14,19 +14,26 @@ There are two types of modification available In **non-replacing scenario**, there are several configurations available for customization. -| Configuration | Description | Default Value | -|-------------------|----------------------------------------------------------------------------------------|---------------------------------------------------------| -| `copy` | Indicates whether it is to shift the nodes, or copy the nodes | False (nodes are shifted, not copied) | -| `to_tree` | Indicates whether shifting/copying is within the same tree, or between different trees | None (nodes are shifted/copied within the same tree) | -| `skippable` | Skip shifting/copying of nodes if from_path cannot be found | False (from-node must be found) | -| `overriding` | Override existing node if it exists | False (to-node must not exist) | -| `merge_children` | Shift/copy children of from-node and remove intermediate parent node | False (children are not merged) | - | `merge_leaves` | Shift/copy leaves of from-node and remove all intermediate nodes | False (leaves are not merged) | -| `delete_children` | Shift/copy node only and delete its children | False (nodes are shifted/copied together with children) | - -In **replacing scenario**, all the configurations are also available except `overriding`, `merge_children`, and `merge_leaves` as it is doing a one-to-one replacement. +| Configuration | Description | Default Value | +|-------------------|----------------------------------------------------------------------------------------|-----------------------------------------------------------| +| `copy` | Indicates whether it is to shift the nodes, or copy the nodes | False (nodes are shifted, not copied) | +| `to_tree` | Indicates whether shifting/copying is within the same tree, or between different trees | None (nodes are shifted/copied within the same tree) | +| `skippable` | Skip shifting/copying of nodes if from_path cannot be found | False (from-node must be found) | +| `overriding` | Override existing node if it exists | False (to-node must not exist) | +| `merge_attribute` | Merge attributes of existing node if it exists | False (to-node must not exist, attributes are not merged) | +| `merge_children` | Shift/copy children of from-node and remove intermediate parent node | False (children are not merged) | +| `merge_leaves` | Shift/copy leaves of from-node and remove all intermediate nodes | False (leaves are not merged) | +| `delete_children` | Shift/copy node only and delete its children | False (nodes are shifted/copied together with children) | + +In **replacing scenario**, all the configurations are also available except `overriding`, `merge_attribute`, +`merge_children`, and `merge_leaves` as it is doing a one-to-one replacement. It is by default overriding, and there is nothing to merge. +!!! note + + `overriding` and `merge_attribute` cannot be simultaneously set to `True`. One deals with clashing nodes by + overriding, another deals with it by merging attributes of both nodes. + !!! note `merge_children` and `merge_leaves` cannot be simultaneously set to `True`. @@ -71,14 +78,14 @@ All other methods calls these 2 methods directly. ### Sample Tree Modification (Advanced) -| Setting | Sample path in `from_paths` | Sample path in `to_paths` | Description | -|-----------------------------|-----------------------------|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| overriding | "a/b/c" | "a/d/c" | Shift/copy node `c`, override if "a/d/c" exists | -| merge_children | "a/b/c" | "a/d/c" | **If path not present**: Shift/copy children of node `c` to be children of node `d`, removing node `c`
**If path present**: Shift/copy children of node `c` to be merged with existing "a/d/c" children | -| merge_children + overriding | "a/b/c" | "a/d/c" | **If path not present**: Behaves like merge_children
**If path present**: Behaves like overriding | -| merge_leaves | "a/b/c" | "a/d/c" | **If path not present**: Shift/copy leaves of node `c` to be children of node `d`
**If path present**: Shift/copy leaves of node `c` to be merged with existing "a/d/c" children | -| merge_leaves + overriding | "a/b/c" | "a/d/c" | **If path not present**: Behaves like merge_leaves
**If path present**: Behaves like overriding, but original node `c` remains | -| delete_children | "a/b" | "a/d/b" | Shift/copy node `b` only without any node `b` children | +| Setting | Sample path in `from_paths` | Sample path in `to_paths` | Description | +|---------------------------------------------|-----------------------------|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| overriding | "a/b/c" | "a/d/c" | Shift/copy node `c`, override if "a/d/c" exists | +| merge_children | "a/b/c" | "a/d/c" | **If path not present**: Shift/copy children of node `c` to be children of node `d`, removing node `c`
**If path present**: Shift/copy children of node `c` to be merged with existing "a/d/c" children | +| merge_children + overriding/merge_attribute | "a/b/c" | "a/d/c" | **If path not present**: Behaves like merge_children
**If path present**: Behaves like overriding/merge_attribute | +| merge_leaves | "a/b/c" | "a/d/c" | **If path not present**: Shift/copy leaves of node `c` to be children of node `d`
**If path present**: Shift/copy leaves of node `c` to be merged with existing "a/d/c" children | +| merge_leaves + overriding/merge_attribute | "a/b/c" | "a/d/c" | **If path not present**: Behaves like merge_leaves
**If path present**: Behaves like overriding/merge_attribute, but original node `c` remains | +| delete_children | "a/b" | "a/d/b" | Shift/copy node `b` only without any node `b` children | ----- diff --git a/tests/test_constants.py b/tests/test_constants.py index 20b3428d..7484d925 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -179,6 +179,7 @@ class Constants: "Paths are different length, input `from_paths` have {n1} entries, " "while output `to_paths` have {n2} entries" ) + ERROR_MODIFY_PARAM_OVERRIDING_OR_MERGE_ATTRIBUTE = "Invalid shifting, can only specify one type of merging, check `overriding` and `merge_attribute`" ERROR_MODIFY_PARAM_MERGE_CHILDREN_OR_LEAVES = "Invalid shifting, can only specify one type of merging, check `merge_children` and `merge_leaves`" ERROR_MODIFY_PARAM_DELETE_AND_COPY = ( "Deletion of node will not happen if `copy=True`, check your `copy` parameter." @@ -196,7 +197,7 @@ class Constants: ) ERROR_MODIFY_OVERRIDING = ( "Path {to_path} already exists and unable to override\n" - "Set `overriding` to True to perform overrides\n" + "Set `overriding` or `merge_attribute` to True to handle node name clashes\n" "Alternatively, set `merge_children` to True if nodes are to be merged" ) diff --git a/tests/tree/test_modify.py b/tests/tree/test_modify.py index b43c47e1..a80d3d8a 100644 --- a/tests/tree/test_modify.py +++ b/tests/tree/test_modify.py @@ -322,6 +322,104 @@ def test_copy_nodes_overriding_manual_check(self): list(search.find_path(self.root_overriding, "/a/aa").children) ), "Children of node parent deleted (not copied)" + # merge_attribute + def test_copy_nodes_merge_attribute(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node + self.root_overriding["aa"]["bb"].set_attrs({"gender": "b"}) + self.root_overriding["bb"].set_attrs({"age": 1}) + modify.copy_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + ) + assert ( + len(list(self.root_overriding.children)) == 2 + ), f"Node children not merged, {export.print_tree(self.root_overriding)}" + assert ( + self.root_overriding["bb"].get_attr("age") == 1 + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert ( + len(self.root_overriding["bb"].children) == 2 + ), "Original node children not combined with copied node" + + def test_copy_nodes_merge_attribute_update(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node + self.root_overriding["aa"]["bb"].set_attrs({"age": 2, "gender": "b"}) + self.root_overriding["bb"].set_attrs({"age": 1, "hello": "world"}) + modify.copy_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + ) + assert ( + len(list(self.root_overriding.children)) == 2 + ), f"Node children not merged, {export.print_tree(self.root_overriding)}" + assert ( + self.root_overriding["bb"].get_attr("age") == 2 + ), "Original attribute not updated" + assert ( + self.root_overriding["bb"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert ( + len(self.root_overriding["bb"].children) == 2 + ), "Original node children not combined with copied node" + + def test_copy_nodes_merge_attribute_children(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node and child node + self.root_overriding["aa"]["bb"]["cc"].set_attrs({"age": 2, "gender": "c"}) + self.root_overriding["aa"]["bb"].set_attrs({"age": 2, "gender": "b"}) + self.root_overriding["bb"].set_attrs({"age": 1, "hello": "world"}) + self.root_overriding["bb"]["cc2"].set_attrs( + {"name": "cc", "age": 1, "hello": "world"} + ) + modify.copy_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + ) + assert ( + len(list(self.root_overriding.children)) == 2 + ), f"Node children not merged, {export.print_tree(self.root_overriding)}" + assert ( + self.root_overriding["bb"].get_attr("age") == 2 + ), "Original attribute not updated" + assert ( + self.root_overriding["bb"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert ( + self.root_overriding["bb"]["cc"].get_attr("age") == 2 + ), "Original attribute of children not present" + assert ( + self.root_overriding["bb"]["cc"].get_attr("hello") == "world" + ), "Original attribute of children not present" + assert ( + self.root_overriding["bb"]["cc"].get_attr("gender") == "c" + ), "Merge attribute of children not present" + assert ( + len(self.root_overriding["bb"].children) == 1 + ), "Original node children changed" + # merge_children def test_copy_nodes_merge_children(self): from_paths = ["aa/bb/cc"] @@ -557,6 +655,26 @@ def test_copy_nodes_merge_leaves_overriding(self): list(search.find_path(self.root, "/a/aa").children) ), "Node parent deleted" + # overriding, merge_attribute + def test_copy_nodes_overriding_and_merge_attribute_error(self): + a = node.Node("a", age=90) + self.root_other.parent = a + + from_paths = ["a/aa/d", "a/aa/e", "a/aa/g", "a/aa/h", "a/aa/f"] + to_paths = ["a/aa/b/d", "a/aa/b/e", "a/aa/b/e/g", "a/aa/b/e/h", "a/aa/c/f"] + modify.shift_nodes(a, from_paths, to_paths) + + from_paths = ["a/aa"] + to_paths = ["a/bb/aa"] + with pytest.raises(ValueError) as exc_info: + modify.copy_nodes( + a, from_paths, to_paths, overriding=True, merge_attribute=True + ) + assert ( + str(exc_info.value) + == Constants.ERROR_MODIFY_PARAM_OVERRIDING_OR_MERGE_ATTRIBUTE + ) + # merge_children, merge_leaves def test_copy_nodes_merge_children_and_leaf_error(self): a = node.Node("a", age=90) From fb0cfad21ce90871adc75e08a4b070c5a644d5f5 Mon Sep 17 00:00:00 2001 From: Kay Date: Wed, 25 Dec 2024 11:54:45 +0800 Subject: [PATCH 3/7] feat: merge_attribute done, tests done for copying --- bigtree/tree/modify.py | 140 +++++++++++++++++++++++++------------- tests/tree/test_modify.py | 97 +++++++++++++++++++++----- 2 files changed, 173 insertions(+), 64 deletions(-) diff --git a/bigtree/tree/modify.py b/bigtree/tree/modify.py index 4c47ec6d..a3f4b25e 100644 --- a/bigtree/tree/modify.py +++ b/bigtree/tree/modify.py @@ -1018,6 +1018,57 @@ def copy_and_replace_nodes_from_tree_to_tree( ) # pragma: no cover +def _merge_attribute( + from_node: T, to_node: T, merge_children: bool, merge_leaves: bool +) -> T: + import pandas as pd + + from bigtree.tree import export + + dummy_to_node = to_node.copy() + dummy_to_node.parent = None + to_node_data = export.tree_to_dataframe(dummy_to_node, all_attrs=True) + + dummy_from_node = from_node.copy() + dummy_from_node.parent = None + if merge_leaves: + dummy_from_node.children = list(dummy_from_node.leaves) # type: ignore + from_node_data = export.tree_to_dataframe(dummy_from_node, all_attrs=True) + + common_attributes = set(to_node_data.columns).intersection( + set(from_node_data.columns) + ) - {"path", "name"} + if merge_children or merge_leaves: + merged_data = pd.merge( + to_node_data, + from_node_data, + on=["path", "name"], + how="outer", + suffixes=("_x", "_y"), + ) + else: + merged_data = pd.merge( + to_node_data, + from_node_data, + on=["path", "name"], + how="left", + suffixes=("_x", "_y"), + ) + + # Upsert data + for common_attribute in common_attributes: + merged_data[common_attribute] = merged_data[common_attribute + "_y"].fillna( + merged_data[common_attribute + "_x"] + ) + merged_data = merged_data.drop( + columns=[ + common_attribute + "_x", + common_attribute + "_y", + ] + ) + return construct.dataframe_to_tree(merged_data) + + def copy_or_shift_logic( tree: T, from_paths: List[str], @@ -1202,12 +1253,7 @@ def copy_or_shift_logic( f"Alternatively, set `merge_children` or `merge_leaves` to True if intermediate node is to be removed" ) elif merge_children: - # Specify override to remove existing node, else children are merged - if not overriding: - logging.info( - f"Path {to_path} already exists and children are merged" - ) - else: + if overriding: logging.info( f"Path {to_path} already exists and its children be overridden by the merge" ) @@ -1215,17 +1261,48 @@ def copy_or_shift_logic( to_node.parent = None to_node = parent merge_children = False - elif merge_leaves: - # Specify override to remove existing node, else leaves are merged - if not overriding: + elif merge_attribute: logging.info( - f"Path {to_path} already exists and leaves are merged" + f"Path {to_path} already exists and their attributes will be merged" + ) + from_node = _merge_attribute( + from_node, + to_node, + merge_children=merge_children, + merge_leaves=merge_leaves, ) + parent = to_node.parent + to_node.parent = None + to_node = parent + merge_children = False else: + logging.info( + f"Path {to_path} already exists and children are merged" + ) + elif merge_leaves: + if overriding: logging.info( f"Path {to_path} already exists and its leaves be overridden by the merge" ) del to_node.children + elif merge_attribute: + logging.info( + f"Path {to_path} already exists and their attributes will be merged" + ) + from_node = _merge_attribute( + from_node, + to_node, + merge_children=merge_children, + merge_leaves=merge_leaves, + ) + parent = to_node.parent + to_node.parent = None + to_node = parent + merge_leaves = False + else: + logging.info( + f"Path {to_path} already exists and leaves are merged" + ) else: if not (overriding or merge_attribute): raise exceptions.TreeError( @@ -1240,47 +1317,16 @@ def copy_or_shift_logic( parent = to_node.parent to_node.parent = None to_node = parent - else: + elif merge_attribute: logging.info( f"Path {to_path} already exists and attributes will be upserted" ) - import pandas as pd - - from bigtree.tree import export - - dummy_to_node = to_node.copy() - dummy_to_node.parent = None - to_node_data = export.tree_to_dataframe( - dummy_to_node, all_attrs=True - ) - - dummy_from_node = from_node.copy() - dummy_from_node.parent = None - from_node_data = export.tree_to_dataframe( - dummy_from_node, all_attrs=True - ) - - common_attributes = set(to_node_data.columns).intersection( - set(from_node_data.columns) - ) - {"path", "name"} - merged_data = pd.merge( - to_node_data, - from_node_data, - on=["path", "name"], - how="outer", - suffixes=("_x", "_y"), + from_node = _merge_attribute( + from_node, + to_node, + merge_children=merge_children, + merge_leaves=merge_leaves, ) - for common_attribute in common_attributes: - merged_data[common_attribute] = merged_data[ - common_attribute + "_y" - ].fillna(merged_data[common_attribute + "_x"]) - merged_data.drop( - columns=[ - common_attribute + "_x", - common_attribute + "_y", - ] - ) - from_node = construct.dataframe_to_tree(merged_data) parent = to_node.parent to_node.parent = None to_node = parent diff --git a/tests/tree/test_modify.py b/tests/tree/test_modify.py index a80d3d8a..d1a8d454 100644 --- a/tests/tree/test_modify.py +++ b/tests/tree/test_modify.py @@ -336,9 +336,6 @@ def test_copy_nodes_merge_attribute(self): to_paths, merge_attribute=True, ) - assert ( - len(list(self.root_overriding.children)) == 2 - ), f"Node children not merged, {export.print_tree(self.root_overriding)}" assert ( self.root_overriding["bb"].get_attr("age") == 1 ), "Original attribute not present" @@ -346,8 +343,8 @@ def test_copy_nodes_merge_attribute(self): self.root_overriding["bb"].get_attr("gender") == "b" ), "Merge attribute not present" assert ( - len(self.root_overriding["bb"].children) == 2 - ), "Original node children not combined with copied node" + len(self.root_overriding["bb"].children) == 1 + ), "Original node children changed" def test_copy_nodes_merge_attribute_update(self): from_paths = ["a/aa/bb"] @@ -362,9 +359,6 @@ def test_copy_nodes_merge_attribute_update(self): to_paths, merge_attribute=True, ) - assert ( - len(list(self.root_overriding.children)) == 2 - ), f"Node children not merged, {export.print_tree(self.root_overriding)}" assert ( self.root_overriding["bb"].get_attr("age") == 2 ), "Original attribute not updated" @@ -375,8 +369,8 @@ def test_copy_nodes_merge_attribute_update(self): self.root_overriding["bb"].get_attr("gender") == "b" ), "Merge attribute not present" assert ( - len(self.root_overriding["bb"].children) == 2 - ), "Original node children not combined with copied node" + len(self.root_overriding["bb"].children) == 1 + ), "Original node children changed" def test_copy_nodes_merge_attribute_children(self): from_paths = ["a/aa/bb"] @@ -395,9 +389,6 @@ def test_copy_nodes_merge_attribute_children(self): to_paths, merge_attribute=True, ) - assert ( - len(list(self.root_overriding.children)) == 2 - ), f"Node children not merged, {export.print_tree(self.root_overriding)}" assert ( self.root_overriding["bb"].get_attr("age") == 2 ), "Original attribute not updated" @@ -407,18 +398,18 @@ def test_copy_nodes_merge_attribute_children(self): assert ( self.root_overriding["bb"].get_attr("gender") == "b" ), "Merge attribute not present" + assert ( + len(self.root_overriding["bb"].children) == 1 + ), "Original node children changed" assert ( self.root_overriding["bb"]["cc"].get_attr("age") == 2 - ), "Original attribute of children not present" + ), "Original attribute of children not updated" assert ( self.root_overriding["bb"]["cc"].get_attr("hello") == "world" ), "Original attribute of children not present" assert ( self.root_overriding["bb"]["cc"].get_attr("gender") == "c" ), "Merge attribute of children not present" - assert ( - len(self.root_overriding["bb"].children) == 1 - ), "Original node children changed" # merge_children def test_copy_nodes_merge_children(self): @@ -619,6 +610,34 @@ def test_copy_nodes_merge_children_overriding(self): list(search.find_path(self.root, "/a/aa").children) ), "Node parent deleted" + # merge_children, merge_attribute + def test_copy_nodes_merge_children_merge_attribute(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node + self.root_overriding["aa"]["bb"].set_attrs({"age": 2, "gender": "b"}) + self.root_overriding["bb"].set_attrs({"age": 1, "hello": "world"}) + modify.copy_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + merge_children=True, + ) + assert ( + self.root_overriding["bb"].get_attr("age") == 2 + ), "Original attribute not updated" + assert ( + self.root_overriding["bb"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert ( + len(self.root_overriding["bb"].children) == 2 + ), "Original node children not combined with copied node" + # merge_leaves, overriding def test_copy_nodes_merge_leaves_overriding(self): new_aa = node.Node("aa", parent=self.root) @@ -655,6 +674,50 @@ def test_copy_nodes_merge_leaves_overriding(self): list(search.find_path(self.root, "/a/aa").children) ), "Node parent deleted" + # merge_leaves, merge_attribute + def test_copy_nodes_merge_leaves_merge_attribute(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node + self.root_overriding["aa"]["bb"].set_attrs({"age": 2, "gender": "b"}) + self.root_overriding["aa"]["bb"]["cc"].extend( + [node.Node("cc2", age=2, gender="c")] + ) + self.root_overriding["bb"].set_attrs({"age": 1, "hello": "world"}) + self.root_overriding["bb"]["cc2"].set_attrs({"age": 1, "hello": "world"}) + modify.copy_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + merge_leaves=True, + ) + assert ( + self.root_overriding["bb"].get_attr("age") == 2 + ), "Original attribute not updated" + assert ( + self.root_overriding["bb"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert ( + len(self.root_overriding["bb"].children) == 2 + ), "Original node children not combined with copied node" + assert ( + self.root_overriding["bb"]["cc2"].get_attr("age") == 2 + ), "Original attribute of leaf not updated" + assert ( + self.root_overriding["bb"]["cc2"].get_attr("hello") == "world" + ), "Original attribute of leaf not present" + assert ( + self.root_overriding["bb"]["cc2"].get_attr("gender") == "c" + ), "Merge attribute of leaf not present" + assert ( + self.root_overriding["bb"]["dd"].get_attr("age") == 2 + ), "New leaf not copied over" + # overriding, merge_attribute def test_copy_nodes_overriding_and_merge_attribute_error(self): a = node.Node("a", age=90) From 61070f45bfd26a83e7926cc0f8fdf8be34604b4c Mon Sep 17 00:00:00 2001 From: Kay Date: Wed, 25 Dec 2024 19:12:40 +0800 Subject: [PATCH 4/7] test: for shifting --- bigtree/tree/modify.py | 10 +++- tests/tree/test_modify.py | 100 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/bigtree/tree/modify.py b/bigtree/tree/modify.py index a3f4b25e..dcad2310 100644 --- a/bigtree/tree/modify.py +++ b/bigtree/tree/modify.py @@ -1019,17 +1019,20 @@ def copy_and_replace_nodes_from_tree_to_tree( def _merge_attribute( - from_node: T, to_node: T, merge_children: bool, merge_leaves: bool + from_node: T, to_node: T, copy: bool, merge_children: bool, merge_leaves: bool ) -> T: import pandas as pd from bigtree.tree import export + if copy: + from_node = from_node.copy() + dummy_to_node = to_node.copy() dummy_to_node.parent = None to_node_data = export.tree_to_dataframe(dummy_to_node, all_attrs=True) - dummy_from_node = from_node.copy() + dummy_from_node = from_node dummy_from_node.parent = None if merge_leaves: dummy_from_node.children = list(dummy_from_node.leaves) # type: ignore @@ -1268,6 +1271,7 @@ def copy_or_shift_logic( from_node = _merge_attribute( from_node, to_node, + copy=copy, merge_children=merge_children, merge_leaves=merge_leaves, ) @@ -1292,6 +1296,7 @@ def copy_or_shift_logic( from_node = _merge_attribute( from_node, to_node, + copy=copy, merge_children=merge_children, merge_leaves=merge_leaves, ) @@ -1324,6 +1329,7 @@ def copy_or_shift_logic( from_node = _merge_attribute( from_node, to_node, + copy=copy, merge_children=merge_children, merge_leaves=merge_leaves, ) diff --git a/tests/tree/test_modify.py b/tests/tree/test_modify.py index d1a8d454..07127a2b 100644 --- a/tests/tree/test_modify.py +++ b/tests/tree/test_modify.py @@ -1069,6 +1069,106 @@ def test_shift_nodes_overriding_manual_check(self): list(search.find_path(self.root_overriding, "a/aa").children) ), "Children of node parent not deleted" + # merge_attribute + def test_shift_nodes_merge_attribute(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node + self.root_overriding["aa"]["bb"].set_attrs({"gender": "b"}) + self.root_overriding["bb"].set_attrs({"age": 1}) + + modify.shift_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + ) + assert ( + self.root_overriding["bb"].get_attr("age") == 1 + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert search.find_path( + self.root_overriding, "a/bb/cc2" + ), "Original node children changed" + assert not len( + list(search.find_path(self.root_overriding, "a/aa").children) + ), "Children of node parent not deleted" + + def test_shift_nodes_merge_attribute_update(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node + self.root_overriding["aa"]["bb"].set_attrs({"age": 2, "gender": "b"}) + self.root_overriding["bb"].set_attrs({"age": 1, "hello": "world"}) + + modify.shift_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + ) + assert ( + self.root_overriding["bb"].get_attr("age") == 2 + ), "Original attribute not updated" + assert ( + self.root_overriding["bb"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert search.find_path( + self.root_overriding, "a/bb/cc2" + ), "Original node children changed" + assert not len( + list(search.find_path(self.root_overriding, "a/aa").children) + ), "Children of node parent not deleted" + + def test_shift_nodes_merge_attribute_children(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node + self.root_overriding["aa"]["bb"]["cc"].set_attrs({"age": 2, "gender": "c"}) + self.root_overriding["aa"]["bb"].set_attrs({"age": 2, "gender": "b"}) + self.root_overriding["bb"].set_attrs({"age": 1, "hello": "world"}) + self.root_overriding["bb"]["cc2"].set_attrs( + {"name": "cc", "age": 1, "hello": "world"} + ) + modify.shift_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + ) + assert ( + self.root_overriding["bb"].get_attr("age") == 2 + ), "Original attribute not updated" + assert ( + self.root_overriding["bb"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert search.find_path( + self.root_overriding, "a/bb/cc" + ), "Original node children changed" + assert not len( + list(search.find_path(self.root_overriding, "a/aa").children) + ), "Children of node parent not deleted" + assert ( + self.root_overriding["bb"]["cc"].get_attr("age") == 2 + ), "Original attribute of children not updated" + assert ( + self.root_overriding["bb"]["cc"].get_attr("hello") == "world" + ), "Original attribute of children not present" + assert ( + self.root_overriding["bb"]["cc"].get_attr("gender") == "c" + ), "Merge attribute of children not present" + # merge_children def test_shift_nodes_merge_children(self): from_paths = ["aa/bb/cc"] From f3214c32663204b58e940439f854e9f72fbf911d Mon Sep 17 00:00:00 2001 From: Kay Date: Wed, 25 Dec 2024 19:28:53 +0800 Subject: [PATCH 5/7] test: for shifting --- tests/tree/test_modify.py | 84 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/tests/tree/test_modify.py b/tests/tree/test_modify.py index 07127a2b..7a972276 100644 --- a/tests/tree/test_modify.py +++ b/tests/tree/test_modify.py @@ -1095,7 +1095,7 @@ def test_shift_nodes_merge_attribute(self): ), "Original node children changed" assert not len( list(search.find_path(self.root_overriding, "a/aa").children) - ), "Children of node parent not deleted" + ), "Original node not deleted" def test_shift_nodes_merge_attribute_update(self): from_paths = ["a/aa/bb"] @@ -1125,7 +1125,7 @@ def test_shift_nodes_merge_attribute_update(self): ), "Original node children changed" assert not len( list(search.find_path(self.root_overriding, "a/aa").children) - ), "Children of node parent not deleted" + ), "Original node not deleted" def test_shift_nodes_merge_attribute_children(self): from_paths = ["a/aa/bb"] @@ -1158,7 +1158,7 @@ def test_shift_nodes_merge_attribute_children(self): ), "Original node children changed" assert not len( list(search.find_path(self.root_overriding, "a/aa").children) - ), "Children of node parent not deleted" + ), "Original node not deleted" assert ( self.root_overriding["bb"]["cc"].get_attr("age") == 2 ), "Original attribute of children not updated" @@ -1369,6 +1369,37 @@ def test_shift_nodes_merge_children_overriding(self): list(search.find_path(self.root, "a/aa").children) ), "Node parent not deleted" + # merge_children, merge_attribute + def test_shift_nodes_merge_children_merge_attribute(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node + self.root_overriding["aa"]["bb"].set_attrs({"age": 2, "gender": "b"}) + self.root_overriding["bb"].set_attrs({"age": 1, "hello": "world"}) + modify.shift_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + merge_children=True, + ) + assert ( + self.root_overriding["bb"].get_attr("age") == 2 + ), "Original attribute not updated" + assert ( + self.root_overriding["bb"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert ( + len(self.root_overriding["bb"].children) == 2 + ), "Original node children not combined with copied node" + assert not len( + list(search.find_path(self.root_overriding, "a/aa").children) + ), "Original node not deleted" + # merge_leaves, overriding def test_shift_nodes_merge_leaves_overriding(self): new_aa = node.Node("aa", parent=self.root) @@ -1405,6 +1436,53 @@ def test_shift_nodes_merge_leaves_overriding(self): list(search.find_path(self.root, "a/aa").children) ), "Node parent deleted" + # merge_leaves, merge_attribute + def test_shift_nodes_merge_leaves_merge_attribute(self): + from_paths = ["a/aa/bb"] + to_paths = ["/a/bb"] + + # Set attribute for node + self.root_overriding["aa"]["bb"].set_attrs({"age": 2, "gender": "b"}) + self.root_overriding["aa"]["bb"]["cc"].extend( + [node.Node("cc2", age=2, gender="c")] + ) + self.root_overriding["bb"].set_attrs({"age": 1, "hello": "world"}) + self.root_overriding["bb"]["cc2"].set_attrs({"age": 1, "hello": "world"}) + modify.shift_nodes( + self.root_overriding, + from_paths, + to_paths, + merge_attribute=True, + merge_leaves=True, + ) + assert ( + self.root_overriding["bb"].get_attr("age") == 2 + ), "Original attribute not updated" + assert ( + self.root_overriding["bb"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_overriding["bb"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert ( + len(self.root_overriding["bb"].children) == 2 + ), "Original node children not combined with copied node" + assert not len( + list(search.find_path(self.root_overriding, "a/aa").children) + ), "Original node not deleted" + assert ( + self.root_overriding["bb"]["cc2"].get_attr("age") == 2 + ), "Original attribute of leaf not updated" + assert ( + self.root_overriding["bb"]["cc2"].get_attr("hello") == "world" + ), "Original attribute of leaf not present" + assert ( + self.root_overriding["bb"]["cc2"].get_attr("gender") == "c" + ), "Merge attribute of leaf not present" + assert ( + self.root_overriding["bb"]["dd"].get_attr("age") == 2 + ), "New leaf not copied over" + # merge_children, merge_leaves def test_shift_nodes_merge_children_and_leaf_error(self): a = node.Node("a", age=90) From 6078bef1e232e12fe4987f8d4eed18171163e18a Mon Sep 17 00:00:00 2001 From: Kay Date: Thu, 26 Dec 2024 02:16:03 +0800 Subject: [PATCH 6/7] fix: modify logic is wrong when overriding is used with merge_children --- CHANGELOG.md | 2 + bigtree/tree/modify.py | 19 +++-- tests/tree/test_modify.py | 170 +++++++++++++++++++++++++++++++++++++- 3 files changed, 185 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2dac739..7ac5efa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tree Modify: Update documentation and docstring with some rephrasing. ### Added: - Tree Modify: Add parameter `merge_attribute` to allow from-node and to-node attributes to be merged if there are clashes. +### Fixed: +- Tree Modify: Fixed bug when `merge_children` is used with `overriding` as the `merge_children` value is changed in for-loop (bad move, literally). ## [0.22.3] - 2024-11-14 ### Added: diff --git a/bigtree/tree/modify.py b/bigtree/tree/modify.py index dcad2310..98b1e098 100644 --- a/bigtree/tree/modify.py +++ b/bigtree/tree/modify.py @@ -1216,6 +1216,10 @@ def copy_or_shift_logic( # Perform shifting/copying for from_path, to_path in zip(from_paths, to_paths): + # Reset parameters + merge_children2 = merge_children + merge_leaves2 = merge_leaves + if with_full_path: from_node = search.find_full_path(tree, from_path) else: @@ -1263,7 +1267,7 @@ def copy_or_shift_logic( parent = to_node.parent to_node.parent = None to_node = parent - merge_children = False + merge_children2 = False elif merge_attribute: logging.info( f"Path {to_path} already exists and their attributes will be merged" @@ -1278,7 +1282,7 @@ def copy_or_shift_logic( parent = to_node.parent to_node.parent = None to_node = parent - merge_children = False + merge_children2 = False else: logging.info( f"Path {to_path} already exists and children are merged" @@ -1303,7 +1307,7 @@ def copy_or_shift_logic( parent = to_node.parent to_node.parent = None to_node = parent - merge_leaves = False + merge_leaves2 = False else: logging.info( f"Path {to_path} already exists and leaves are merged" @@ -1346,10 +1350,15 @@ def copy_or_shift_logic( ) # Reassign from_node to new parent + # if merge_children and (overriding or merge_attribute): + # merge_children = False + # if merge_leaves and merge_attribute: + # merge_leaves = False if copy: logging.debug(f"Copying {from_node.node_name}") from_node = from_node.copy() - if merge_children: + if merge_children2: + # overriding / merge_attribute handled merge_children, set merge_children=False logging.debug( f"Reassigning children from {from_node.node_name} to {to_node.node_name}" ) @@ -1358,7 +1367,7 @@ def copy_or_shift_logic( del children.children children.parent = to_node from_node.parent = None - elif merge_leaves: + elif merge_leaves2: logging.debug( f"Reassigning leaf nodes from {from_node.node_name} to {to_node.node_name}" ) diff --git a/tests/tree/test_modify.py b/tests/tree/test_modify.py index 7a972276..0297bc98 100644 --- a/tests/tree/test_modify.py +++ b/tests/tree/test_modify.py @@ -610,6 +610,43 @@ def test_copy_nodes_merge_children_overriding(self): list(search.find_path(self.root, "/a/aa").children) ), "Node parent deleted" + def test_copy_nodes_merge_children_overriding_multiple(self): + new_aa = node.Node("aa", parent=self.root) + new_bb = node.Node("bb", parent=new_aa) + new_cc = node.Node("cc", age=1) + new_cc.parent = new_bb + new_dd = node.Node("dd", parent=new_aa) + new_ee = node.Node("ee", age=1) + new_ee.parent = new_dd + bb2 = node.Node("bb", parent=self.root) + cc2 = node.Node("cc2") + cc2.parent = bb2 + + from_paths = ["/d", "/e", "/g", "/h", "/f"] + to_paths = ["a/b/d", "a/b/e", "a/b/e/g", "a/b/e/h", "a/c/f"] + modify.shift_nodes(self.root, from_paths, to_paths) + + from_paths = ["a/aa/bb", "a/aa/dd"] + to_paths = ["/a/bb", "a/dd"] + modify.copy_nodes( + self.root, from_paths, to_paths, overriding=True, merge_children=True + ) + assert search.find_path( + self.root, "/a/bb/cc" + ), "Node children not merged, new children not present" + assert not search.find_path( + self.root, "/a/bb/cc2" + ), "Node children not merged, original children not overridden" + assert ( + search.find_path(self.root, "/a/bb/cc").get_attr("age") == 1 + ), f"Merged children not overridden, {export.print_tree(self.root)}" + assert len( + list(search.find_path(self.root, "/a/aa").children) + ), "Node parent deleted" + assert search.find_path( + self.root, "/a/ee" + ), "Node children not merged, new children not present" + # merge_children, merge_attribute def test_copy_nodes_merge_children_merge_attribute(self): from_paths = ["a/aa/bb"] @@ -2169,6 +2206,67 @@ def test_copy_nodes_from_tree_to_tree_overriding_manual_check(self): assert_tree_structure_basenode_root_attr(self.root_other_full_wrong) assert_tree_structure_node_root(self.root_other_full_wrong) + # merge_attribute + def test_copy_nodes_from_tree_to_tree_merge_attribute(self): + from_paths = ["d", "e", "g", "h", "f"] + to_paths = ["a/b/d", "a/b/e", "a/b/e/g", "a/b/e/h", "a/c/f"] + modify.shift_nodes(self.root, from_paths, to_paths) + + from_paths = ["a/b", "a/c"] + to_paths = ["a/b", "a/c"] + + # Set attribute for node + self.root_other_full_wrong["b"].set_attrs({"hello": "world"}) + self.root["b"].set_attrs({"gender": "b"}) + + modify.copy_nodes_from_tree_to_tree( + from_tree=self.root, + to_tree=self.root_other_full_wrong, + from_paths=from_paths, + to_paths=to_paths, + merge_attribute=True, + ) + assert ( + self.root_other_full_wrong["b"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_other_full_wrong["b"].get_attr("gender") == "b" + ), "Merge attribute not present" + + def test_copy_nodes_from_tree_to_tree_merge_attribute_children(self): + from_paths = ["d", "e", "g", "h", "f"] + to_paths = ["a/b/d", "a/b/e", "a/b/e/g", "a/b/e/h", "a/c/f"] + modify.shift_nodes(self.root, from_paths, to_paths) + + from_paths = ["a/b", "a/c"] + to_paths = ["a/b", "a/c"] + + # Set attribute for node + self.root_other_full_wrong["b"].set_attrs({"hello": "world"}) + self.root_other_full_wrong["c"]["f"].set_attrs({"hello": "world"}) + self.root["b"].set_attrs({"gender": "b"}) + self.root["c"]["f"].set_attrs({"gender": "b"}) + + modify.copy_nodes_from_tree_to_tree( + from_tree=self.root, + to_tree=self.root_other_full_wrong, + from_paths=from_paths, + to_paths=to_paths, + merge_attribute=True, + ) + assert ( + self.root_other_full_wrong["b"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_other_full_wrong["b"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert ( + self.root_other_full_wrong["c"]["f"].get_attr("hello") == "world" + ), "Original attribute of children not present" + assert ( + self.root_other_full_wrong["c"]["f"].get_attr("gender") == "b" + ), "Merge attribute of children not present" + # merge_children def test_copy_nodes_from_tree_to_tree_merge_children(self): from_paths = ["e", "g", "h"] @@ -2330,10 +2428,40 @@ def test_copy_nodes_from_tree_to_tree_merge_children_overriding(self): assert_tree_structure_basenode_root_attr(self.root_other_full_wrong) assert_tree_structure_node_root(self.root_other_full_wrong) + # merge_children, merge_attribute + def test_copy_nodes_from_tree_to_tree_merge_children_merge_attribute(self): + from_paths = ["d", "e", "g", "h", "f"] + to_paths = ["a/b/d", "a/b/e", "a/b/e/g", "a/b/e/h", "a/c/f"] + modify.shift_nodes(self.root, from_paths, to_paths) + + from_paths = ["a/b", "a/c"] + to_paths = ["a/b", "a/c"] + + # Set attribute for node + self.root_other_full_wrong["b"].set_attrs({"hello": "world"}) + self.root["b"].set_attrs({"gender": "b"}) + + modify.copy_nodes_from_tree_to_tree( + from_tree=self.root, + to_tree=self.root_other_full_wrong, + from_paths=from_paths, + to_paths=to_paths, + merge_attribute=True, + merge_children=True, + ) + assert_tree_structure_basenode_root(self.root_other_full_wrong) + assert_tree_structure_node_root(self.root_other_full_wrong) + assert ( + self.root_other_full_wrong["b"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_other_full_wrong["b"].get_attr("gender") == "b" + ), "Merge attribute not present" + # merge_leaves, overriding def test_copy_nodes_from_tree_to_tree_merge_leaves_overriding(self): from_paths = ["d", "e", "g", "h", "f"] - to_paths = ["a/bb/d", "a/bb/e", "a/cc/g", "a/cc/h", "a/c/f"] + to_paths = ["a/bb/bb2/d", "a/bb/e", "a/cc/g", "a/cc/cc2/h", "a/c/f"] modify.shift_nodes(self.root, from_paths, to_paths) from_paths = ["a/bb", "a/cc", "a/c"] @@ -2350,6 +2478,46 @@ def test_copy_nodes_from_tree_to_tree_merge_leaves_overriding(self): assert_tree_structure_basenode_root_attr(self.root_other_full_wrong, c=("c", 1)) assert_tree_structure_node_root(self.root_other_full_wrong) + # merge_leaves, merge_attribute + def test_copy_nodes_from_tree_to_tree_merge_leaves_merge_attribute(self): + from_paths = ["d", "e", "g", "h", "f"] + to_paths = ["a/b/b2/d", "a/b/e", "a/cc/e/g", "a/cc/e/cc2/h", "a/c/f"] + modify.shift_nodes(self.root, from_paths, to_paths) + + from_paths = ["a/b", "a/cc/e", "a/c"] + to_paths = ["a/b", "a/b/e", "a/c"] + + # Set attribute for node + self.root_other_full_wrong["b"].set_attrs({"hello": "world"}) + self.root_other_full_wrong["b"].extend([node.Node("e")]) + self.root["b"].set_attrs({"gender": "b"}) + self.root_other_full_wrong["c"]["f"].set_attrs({"hello": "world"}) + self.root["c"]["f"].set_attrs({"gender": "b"}) + + modify.copy_nodes_from_tree_to_tree( + from_tree=self.root, + to_tree=self.root_other_full_wrong, + from_paths=from_paths, + to_paths=to_paths, + merge_leaves=True, + merge_attribute=True, + ) + assert_tree_structure_basenode_root(self.root_other_full_wrong) + assert_tree_structure_basenode_root_attr(self.root_other_full_wrong) + assert_tree_structure_node_root(self.root_other_full_wrong) + assert ( + self.root_other_full_wrong["b"].get_attr("hello") == "world" + ), "Original attribute not present" + assert ( + self.root_other_full_wrong["b"].get_attr("gender") == "b" + ), "Merge attribute not present" + assert ( + self.root_other_full_wrong["c"]["f"].get_attr("hello") == "world" + ), "Original attribute of leaf not present" + assert ( + self.root_other_full_wrong["c"]["f"].get_attr("gender") == "b" + ), "Merge attribute of leaf not present" + # merge_children, merge_leaves def test_copy_nodes_from_tree_to_tree_merge_children_and_leaf_error(self): from_paths = ["a"] From a2d79e752d0e28cc2491abd3788ccdfee3d7099d Mon Sep 17 00:00:00 2001 From: Kay Date: Thu, 26 Dec 2024 02:42:49 +0800 Subject: [PATCH 7/7] docs: update shifting with guideline --- docs/bigtree/tree/modify.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/bigtree/tree/modify.md b/docs/bigtree/tree/modify.md index 2133d3f6..b158788b 100644 --- a/docs/bigtree/tree/modify.md +++ b/docs/bigtree/tree/modify.md @@ -87,6 +87,41 @@ All other methods calls these 2 methods directly. | merge_leaves + overriding/merge_attribute | "a/b/c" | "a/d/c" | **If path not present**: Behaves like merge_leaves
**If path present**: Behaves like overriding/merge_attribute, but original node `c` remains | | delete_children | "a/b" | "a/d/b" | Shift/copy node `b` only without any node `b` children | +## Guideline + +If you're still feeling lost over the parameters, here are some guiding questions to ask yourself. + +- Do I want to retain the original node where they are? + - Yes: Set `copy=True` + - Default performs a shift instead of copy +- Am I unsure of what nodes I am going to shift, they may or may not exist and this is perfectly fine? + - Yes: Set `skippable=True` + - Default throws error if origin node is not found +- The origin node (and its descendants) may clash with the destination node(s), how do I want to handle it? + - Set `overriding=True` to overwrite origin node + - Set `merge_attribute=True` to combine both nodes' attributes + - Default throws error about the clash in node name +- I want to shift everything under the node, but not the node itself + - Set `merge_children=True` or `merge_leaves=True` to shift the children and leaf nodes respectively + - Default shifts the node itself, and everything under it +- I want to shift the node and only the node, and not everything under it + - Yes: Set `delete_children=True` + - Default shifts the node itself, and everything under it +- I want to shift things from one tree to another tree + - Specify `to_tree` + - Default shifts nodes within the same tree + +What about the permutations between the parameters? + +- These parameters are standalone and does not produce any interaction effect + - `copy`, `skippable`, `delete_children` +- These parameters have some interaction: + - `overriding` and `merge_attribute` with `merge_children` and `merge_leaves` + - `overriding` + `merge_children`: Behaves like `merge_children` when there is no clash in node name, otherwise behaves like `overriding`. Note that clashes will preserve destination nodes' children only. + - `overriding` + `merge_leaves`: Behaves like `merge_leaves` when there is no clash in node name, otherwise behaves like `overriding`. Note that clashes will preserve destination nodes' leaves only. + - `merge_attribute` + `merge_children`: Behaves like `merge_children` when there is no clash in node name, otherwise behaves like `merge_attribute`. Note that attributes will be merged for node and all descendants, and will preserve origin and destination nodes' children. + - `merge_attribute` + `merge_leaves`: Behaves like `merge_leaves` when there is no clash in node name, otherwise behaves like `merge_attribute`. Note that attributes will be merged for node and all descendants, and will preserve origin nodes' children and destination nodes' leaves. + ----- ::: bigtree.tree.modify