Skip to content

Commit ba61a3e

Browse files
authored
Merge pull request #106 from kayjan/feat-replace-nodes
Added: Replace node modification
2 parents 8c9cf93 + 6050f97 commit ba61a3e

File tree

4 files changed

+828
-2
lines changed

4 files changed

+828
-2
lines changed

bigtree/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@
3333
)
3434
from bigtree.tree.helper import clone_tree, get_tree_diff, prune_tree
3535
from bigtree.tree.modify import (
36+
copy_and_replace_nodes_from_tree_to_tree,
3637
copy_nodes,
3738
copy_nodes_from_tree_to_tree,
3839
copy_or_shift_logic,
40+
replace_logic,
41+
shift_and_replace_nodes,
3942
shift_nodes,
4043
)
4144
from bigtree.tree.search import (

bigtree/tree/modify.py

Lines changed: 339 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
__all__ = [
1212
"shift_nodes",
1313
"copy_nodes",
14+
"shift_and_replace_nodes",
1415
"copy_nodes_from_tree_to_tree",
16+
"copy_and_replace_nodes_from_tree_to_tree",
1517
"copy_or_shift_logic",
18+
"replace_logic",
1619
]
1720

1821

@@ -494,6 +497,97 @@ def copy_nodes(
494497
) # pragma: no cover
495498

496499

500+
def shift_and_replace_nodes(
501+
tree: Node,
502+
from_paths: List[str],
503+
to_paths: List[str],
504+
sep: str = "/",
505+
skippable: bool = False,
506+
delete_children: bool = False,
507+
with_full_path: bool = False,
508+
) -> None:
509+
"""Shift nodes from `from_paths` to *replace* `to_paths` *in-place*.
510+
511+
- Creates intermediate nodes if to path is not present
512+
- Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable).
513+
- Able to shift node only and delete children, defaults to False (nodes are shifted together with children).
514+
515+
For paths in `from_paths` and `to_paths`,
516+
- Path name can be with or without leading tree path separator symbol.
517+
518+
For paths in `from_paths`,
519+
- Path name can be partial path (trailing part of path) or node name.
520+
- If ``with_full_path=True``, path name must be full path.
521+
- Path name must be unique to one node.
522+
523+
For paths in `to_paths`,
524+
- Path name must be full path.
525+
- Path must exist, node-to-be-replaced must be present.
526+
527+
>>> from bigtree import Node, shift_and_replace_nodes
528+
>>> root = Node("a")
529+
>>> b = Node("b", parent=root)
530+
>>> c = Node("c", parent=b)
531+
>>> d = Node("d", parent=root)
532+
>>> e = Node("e", parent=d)
533+
>>> root.show()
534+
a
535+
├── b
536+
│ └── c
537+
└── d
538+
└── e
539+
540+
>>> shift_and_replace_nodes(root, ["a/b"], ["a/d/e"])
541+
>>> root.show()
542+
a
543+
└── d
544+
└── b
545+
└── c
546+
547+
In ``delete_children=True`` case, only the node is shifted without its accompanying children/descendants.
548+
549+
>>> from bigtree import Node, shift_and_replace_nodes
550+
>>> root = Node("a")
551+
>>> b = Node("b", parent=root)
552+
>>> c = Node("c", parent=b)
553+
>>> d = Node("d", parent=root)
554+
>>> e = Node("e", parent=d)
555+
>>> root.show()
556+
a
557+
├── b
558+
│ └── c
559+
└── d
560+
└── e
561+
562+
>>> shift_and_replace_nodes(root, ["a/b"], ["a/d/e"], delete_children=True)
563+
>>> root.show()
564+
a
565+
└── d
566+
└── b
567+
568+
Args:
569+
tree (Node): tree to modify
570+
from_paths (List[str]): original paths to shift nodes from
571+
to_paths (List[str]): new paths to shift nodes to
572+
sep (str): path separator for input paths, applies to `from_path` and `to_path`
573+
skippable (bool): indicator to skip if from path is not found, defaults to False
574+
delete_children (bool): indicator to shift node only without children, defaults to False
575+
with_full_path (bool): indicator to shift/copy node with full path in `from_paths`, results in faster search,
576+
defaults to False
577+
"""
578+
return replace_logic(
579+
tree=tree,
580+
from_paths=from_paths,
581+
to_paths=to_paths,
582+
sep=sep,
583+
copy=False,
584+
skippable=skippable,
585+
delete_children=delete_children,
586+
to_tree=None,
587+
with_full_path=with_full_path,
588+
) # pragma: no cover
589+
590+
497591
def copy_nodes_from_tree_to_tree(
498592
from_tree: Node,
499593
to_tree: Node,
@@ -664,6 +758,114 @@ def copy_nodes_from_tree_to_tree(
664758
) # pragma: no cover
665759

666760

761+
def copy_and_replace_nodes_from_tree_to_tree(
762+
from_tree: Node,
763+
to_tree: Node,
764+
from_paths: List[str],
765+
to_paths: List[str],
766+
sep: str = "/",
767+
skippable: bool = False,
768+
delete_children: bool = False,
769+
with_full_path: bool = False,
770+
) -> None:
771+
"""Copy nodes from `from_paths` to *replace* `to_paths` *in-place*.
772+
773+
- Creates intermediate nodes if to path is not present
774+
- Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable).
775+
- Able to copy node only and delete children, defaults to False (nodes are copied together with children).
776+
777+
For paths in `from_paths` and `to_paths`,
778+
- Path name can be with or without leading tree path separator symbol.
779+
780+
For paths in `from_paths`,
781+
- Path name can be partial path (trailing part of path) or node name.
782+
- If ``with_full_path=True``, path name must be full path.
783+
- Path name must be unique to one node.
784+
785+
For paths in `to_paths`,
786+
- Path name must be full path.
787+
- Path must exist, node-to-be-replaced must be present.
788+
789+
>>> from bigtree import Node, copy_and_replace_nodes_from_tree_to_tree
790+
>>> root = Node("a")
791+
>>> b = Node("b", parent=root)
792+
>>> c = Node("c", parent=root)
793+
>>> d = Node("d", parent=c)
794+
>>> e = Node("e", parent=root)
795+
>>> f = Node("f", parent=e)
796+
>>> g = Node("g", parent=f)
797+
>>> root.show()
798+
a
799+
├── b
800+
├── c
801+
│ └── d
802+
└── e
803+
└── f
804+
└── g
805+
806+
>>> root_other = Node("aa")
807+
>>> bb = Node("bb", parent=root_other)
808+
>>> cc = Node("cc", parent=bb)
809+
>>> dd = Node("dd", parent=root_other)
810+
>>> root_other.show()
811+
aa
812+
├── bb
813+
│ └── cc
814+
└── dd
815+
816+
>>> copy_and_replace_nodes_from_tree_to_tree(root, root_other, ["a/c", "a/e"], ["aa/bb/cc", "aa/dd"])
817+
>>> root_other.show()
818+
aa
819+
├── bb
820+
│ └── c
821+
│ └── d
822+
└── e
823+
└── f
824+
└── g
825+
826+
In ``delete_children=True`` case, only the node is copied without its accompanying children/descendants.
827+
828+
>>> root_other = Node("aa")
829+
>>> bb = Node("bb", parent=root_other)
830+
>>> cc = Node("cc", parent=bb)
831+
>>> dd = Node("dd", parent=root_other)
832+
>>> root_other.show()
833+
aa
834+
├── bb
835+
│ └── cc
836+
└── dd
837+
838+
>>> copy_and_replace_nodes_from_tree_to_tree(root, root_other, ["a/c", "a/e"], ["aa/bb/cc", "aa/dd"], delete_children=True)
839+
>>> root_other.show()
840+
aa
841+
├── bb
842+
│ └── c
843+
└── e
844+
845+
Args:
846+
from_tree (Node): tree to copy nodes from
847+
to_tree (Node): tree to copy nodes to
848+
from_paths (List[str]): original paths to shift nodes from
849+
to_paths (List[str]): new paths to shift nodes to
850+
sep (str): path separator for input paths, applies to `from_path` and `to_path`
851+
skippable (bool): indicator to skip if from path is not found, defaults to False
852+
delete_children (bool): indicator to copy node only without children, defaults to False
853+
with_full_path (bool): indicator to shift/copy node with full path in `from_paths`, results in faster search,
854+
defaults to False
855+
"""
856+
return replace_logic(
857+
tree=from_tree,
858+
from_paths=from_paths,
859+
to_paths=to_paths,
860+
sep=sep,
861+
copy=True,
862+
skippable=skippable,
863+
delete_children=delete_children,
864+
to_tree=to_tree,
865+
with_full_path=with_full_path,
866+
) # pragma: no cover
867+
868+
667869
def copy_or_shift_logic(
668870
tree: Node,
669871
from_paths: List[str],
@@ -786,7 +988,7 @@ def copy_or_shift_logic(
786988
"Invalid path in `to_paths` not starting with the root node. Check your `to_paths` parameter."
787989
)
788990

789-
# Perform shifting
991+
# Perform shifting/copying
790992
for from_path, to_path in zip(from_paths, to_paths):
791993
if with_full_path:
792994
from_node = find_full_path(tree, from_path)
@@ -895,3 +1097,139 @@ def copy_or_shift_logic(
8951097
if delete_children:
8961098
del from_node.children
8971099
from_node.parent = to_node
1100+
1101+
1102+
def replace_logic(
1103+
tree: Node,
1104+
from_paths: List[str],
1105+
to_paths: List[str],
1106+
sep: str = "/",
1107+
copy: bool = False,
1108+
skippable: bool = False,
1109+
delete_children: bool = False,
1110+
to_tree: Optional[Node] = None,
1111+
with_full_path: bool = False,
1112+
) -> None:
1113+
"""Shift or copy nodes from `from_paths` to *replace* `to_paths` *in-place*.
1114+
1115+
- Creates intermediate nodes if to path is not present
1116+
- Able to copy node, defaults to False (nodes are shifted; not copied).
1117+
- Able to skip nodes if from path is not found, defaults to False (from-nodes must be found; not skippable)
1118+
- Able to replace node only and delete children, defaults to False (nodes are shifted/copied together with children).
1119+
- Able to shift/copy nodes from one tree to another tree, defaults to None (shifting/copying happens within same tree)
1120+
1121+
For paths in `from_paths` and `to_paths`,
1122+
- Path name can be with or without leading tree path separator symbol.
1123+
1124+
For paths in `from_paths`,
1125+
- Path name can be partial path (trailing part of path) or node name.
1126+
- If ``with_full_path=True``, path name must be full path.
1127+
- Path name must be unique to one node.
1128+
1129+
For paths in `to_paths`,
1130+
- Path name must be full path.
1131+
- Path must exist, node-to-be-replaced must be present.
1132+
1133+
Args:
1134+
tree (Node): tree to modify
1135+
from_paths (List[str]): original paths to shift nodes from
1136+
to_paths (List[str]): new paths to shift nodes to
1137+
sep (str): path separator for input paths, applies to `from_path` and `to_path`
1138+
copy (bool): indicator to copy node, defaults to False
1139+
skippable (bool): indicator to skip if from path is not found, defaults to False
1140+
delete_children (bool): indicator to shift/copy node only without children, defaults to False
1141+
to_tree (Node): tree to copy to, defaults to None
1142+
with_full_path (bool): indicator to shift/copy node with full path in `from_paths`, results in faster search,
1143+
defaults to False
1144+
"""
1145+
if not (isinstance(from_paths, list) and isinstance(to_paths, list)):
1146+
raise ValueError(
1147+
"Invalid type, `from_paths` and `to_paths` should be list type"
1148+
)
1149+
if len(from_paths) != len(to_paths):
1150+
raise ValueError(
1151+
f"Paths are different length, input `from_paths` have {len(from_paths)} entries, "
1152+
f"while output `to_paths` have {len(to_paths)} entries"
1153+
)
1154+
1155+
# Modify `sep` of from_paths and to_paths
1156+
if not to_tree:
1157+
to_tree = tree
1158+
tree_sep = to_tree.sep
1159+
from_paths = [path.rstrip(sep).replace(sep, tree.sep) for path in from_paths]
1160+
to_paths = [
1161+
path.rstrip(sep).replace(sep, tree_sep) if path else None for path in to_paths
1162+
]
1163+
1164+
if with_full_path:
1165+
if not all(
1166+
[
1167+
path.lstrip(tree.sep).split(tree.sep)[0] == tree.root.node_name
1168+
for path in from_paths
1169+
]
1170+
):
1171+
raise ValueError(
1172+
"Invalid path in `from_paths` not starting with the root node. "
1173+
"Check your `from_paths` parameter, alternatively set `with_full_path=False` to shift "
1174+
"partial path instead of full path."
1175+
)
1176+
if not all(
1177+
[
1178+
path.lstrip(tree_sep).split(tree_sep)[0] == to_tree.root.node_name
1179+
for path in to_paths
1180+
if path
1181+
]
1182+
):
1183+
raise ValueError(
1184+
"Invalid path in `to_paths` not starting with the root node. Check your `to_paths` parameter."
1185+
)
1186+
1187+
# Perform shifting/copying to replace destination node
1188+
for from_path, to_path in zip(from_paths, to_paths):
1189+
if with_full_path:
1190+
from_node = find_full_path(tree, from_path)
1191+
else:
1192+
from_node = find_path(tree, from_path)
1193+
1194+
# From node not found
1195+
if not from_node:
1196+
if not skippable:
1197+
raise NotFoundError(
1198+
f"Unable to find from_path {from_path}\n"
1199+
f"Set `skippable` to True to skip shifting for nodes not found"
1200+
)
1201+
else:
1202+
logging.info(f"Unable to find from_path {from_path}")
1203+
1204+
# From node found
1205+
else:
1206+
to_node = find_full_path(to_tree, to_path)
1207+
1208+
# To node found
1209+
if to_node:
1210+
if from_node == to_node:
1211+
raise TreeError(
1212+
f"Attempting to replace the same node {from_node.node_name}\n"
1213+
f"Check from path {from_path} and to path {to_path}"
1214+
)
1215+
1216+
# To node not found
1217+
else:
1218+
raise NotFoundError(f"Unable to find to_path {to_path}")
1219+
1220+
# Replace to_node with from_node
1221+
if copy:
1222+
logging.debug(f"Copying {from_node.node_name}")
1223+
from_node = from_node.copy()
1224+
if delete_children:
1225+
del from_node.children
1226+
parent = to_node.parent
1227+
to_node_siblings = parent.children
1228+
to_node_idx = to_node_siblings.index(to_node)
1229+
for node in to_node_siblings[to_node_idx:]:
1230+
if node == to_node:
1231+
to_node.parent = None
1232+
from_node.parent = parent
1233+
else:
1234+
node.parent = None
1235+
node.parent = parent

0 commit comments

Comments
 (0)