import copy from typing import Any, Dict, Iterable, List from bigtree.utils.exceptions import CorruptedTreeError, LoopError, TreeError from bigtree.utils.iterators import preorder_iter class BaseNode: """ BaseNode extends any Python class to a tree node. Nodes can have attributes if they are initialized from `Node`, *dictionary*, or *pandas DataFrame*. Nodes can be linked to each other with `parent` and `children` setter methods, or using bitshift operator with the convention `parent_node >> child_node` or `child_node << parent_node`. >>> from bigtree import Node, print_tree >>> root = Node("a", age=90) >>> b = Node("b", age=65) >>> c = Node("c", age=60) >>> d = Node("d", age=40) >>> root.children = [b, c] >>> d.parent = b >>> print_tree(root, attr_list=["age"]) a [age=90] ├── b [age=65] │ └── d [age=40] └── c [age=60] >>> from bigtree import Node >>> root = Node("a", age=90) >>> b = Node("b", age=65) >>> c = Node("c", age=60) >>> d = Node("d", age=40) >>> root >> b >>> root >> c >>> d << b >>> print_tree(root, attr_list=["age"]) a [age=90] ├── b [age=65] │ └── d [age=40] └── c [age=60] Directly passing `parent` argument. >>> from bigtree import Node >>> root = Node("a") >>> b = Node("b", parent=root) >>> c = Node("c", parent=root) >>> d = Node("d", parent=b) Directly passing `children` argument. >>> from bigtree import Node >>> d = Node("d") >>> c = Node("c") >>> b = Node("b", children=[d]) >>> a = Node("a", children=[b, c]) **BaseNode Creation** Node can be created by instantiating a `BaseNode` class or by using a *dictionary*. If node is created with dictionary, all keys of dictionary will be stored as class attributes. >>> from bigtree import Node >>> root = Node.from_dict({"name": "a", "age": 90}) **BaseNode Attributes** These are node attributes that have getter and/or setter methods. Get and set other `BaseNode` 1. ``parent``: Get/set parent node 2. ``children``: Get/set child nodes Get other `BaseNode` 1. ``ancestors``: Get ancestors of node excluding self, iterator 2. ``descendants``: Get descendants of node excluding self, iterator 3. ``leaves``: Get all leaf node(s) from self, iterator 4. ``siblings``: Get siblings of self 5. ``left_sibling``: Get sibling left of self 6. ``right_sibling``: Get sibling right of self Get `BaseNode` configuration 1. ``node_path``: Get tuple of nodes from root 2. ``is_root``: Get indicator if self is root node 3. ``is_leaf``: Get indicator if self is leaf node 4. ``root``: Get root node of tree 5. ``depth``: Get depth of self 6. ``max_depth``: Get maximum depth from root to leaf node **BaseNode Methods** These are methods available to be performed on `BaseNode`. Constructor methods 1. ``from_dict()``: Create BaseNode from dictionary `BaseNode` methods 1. ``describe()``: Get node information sorted by attributes, returns list of tuples 2. ``get_attr(attr_name: str)``: Get value of node attribute 3. ``set_attrs(attrs: dict)``: Set node attribute name(s) and value(s) 4. ``go_to(node: BaseNode)``: Get a path from own node to another node from same tree 5. ``copy()``: Deep copy BaseNode 6. ``sort()``: Sort child nodes ---- """ def __init__(self, parent=None, children: List = None, **kwargs): self.__parent = None self.__children = [] if children is None: children = [] self.parent = parent self.children = children if "parents" in kwargs: raise ValueError( "Attempting to set `parents` attribute, do you mean `parent`?" ) self.__dict__.update(**kwargs) @property def parent(self): """Get parent node Returns: (Self) """ return self.__parent @staticmethod def __check_parent_type(new_parent): """Check parent type Args: new_parent (Self): parent node """ if not (isinstance(new_parent, BaseNode) or new_parent is None): raise TypeError( f"Expect input to be BaseNode type or NoneType, received input type {type(new_parent)}" ) def __check_parent_loop(self, new_parent): """Check parent type Args: new_parent (Self): parent node """ if new_parent is not None: if new_parent is self: raise LoopError("Error setting parent: Node cannot be parent of itself") if any( ancestor is self for ancestor in new_parent.ancestors if new_parent.ancestors ): raise LoopError( "Error setting parent: Node cannot be ancestor of itself" ) @parent.setter def parent(self, new_parent): """Set parent node Args: new_parent (Self): parent node """ self.__check_parent_type(new_parent) self.__check_parent_loop(new_parent) current_parent = self.parent current_child_idx = None # Assign new parent - rollback if error self.__pre_assign_parent(new_parent) try: # Remove self from old parent if current_parent is not None: if not any( child is self for child in current_parent.children ): # pragma: no cover raise CorruptedTreeError( "Error setting parent: Node does not exist as children of its parent" ) current_child_idx = current_parent.__children.index(self) current_parent.__children.remove(self) # Assign self to new parent self.__parent = new_parent if new_parent is not None: new_parent.__children.append(self) self.__post_assign_parent(new_parent) except Exception as exc_info: # Remove self from new parent if new_parent is not None: new_parent.__children.remove(self) # Reassign self to old parent self.__parent = current_parent if current_child_idx is not None: current_parent.__children.insert(current_child_idx, self) raise TreeError(exc_info) def __pre_assign_parent(self, new_parent): """Custom method to check before attaching parent Can be overriden with `_BaseNode__pre_assign_parent()` Args: new_parent (Self): new parent to be added """ pass def __post_assign_parent(self, new_parent): """Custom method to check after attaching parent Can be overriden with `_BaseNode__post_assign_parent()` Args: new_parent (Self): new parent to be added """ pass @property def parents(self) -> None: """Do not allow `parents` attribute to be accessed""" raise ValueError( "Attempting to access `parents` attribute, do you mean `parent`?" ) @parents.setter def parents(self, new_parent): """Do not allow `parents` attribute to be set Args: new_parent (Self): parent node """ raise ValueError("Attempting to set `parents` attribute, do you mean `parent`?") @property def children(self) -> Iterable: """Get child nodes Returns: (Iterable[Self]) """ return tuple(self.__children) def __check_children_type(self, new_children: List): """Check child type Args: new_children (List[Self]): child node """ if not isinstance(new_children, list): raise TypeError( f"Children input should be list type, received input type {type(new_children)}" ) def __check_children_loop(self, new_children: List): """Check child loop Args: new_children (List[Self]): child node """ seen_children = [] for new_child in new_children: # Check type if not isinstance(new_child, BaseNode): raise TypeError( f"Expect input to be BaseNode type, received input type {type(new_child)}" ) # Check for loop and tree structure if new_child is self: raise LoopError("Error setting child: Node cannot be child of itself") if any(child is new_child for child in self.ancestors): raise LoopError( "Error setting child: Node cannot be ancestors of itself" ) # Check for duplicate children if id(new_child) in seen_children: raise TreeError( "Error setting child: Node cannot be added multiple times as a child" ) else: seen_children.append(id(new_child)) @children.setter def children(self, new_children: List): """Set child nodes Args: new_children (List[Self]): child node """ self.__check_children_type(new_children) self.__check_children_loop(new_children) current_new_children = { new_child: (new_child.parent.__children.index(new_child), new_child.parent) for new_child in new_children if new_child.parent is not None } current_new_orphan = [ new_child for new_child in new_children if new_child.parent is None ] current_children = list(self.children) # Assign new children - rollback if error self.__pre_assign_children(new_children) try: # Remove old children from self del self.children # Assign new children to self self.__children = new_children for new_child in new_children: if new_child.parent: new_child.parent.__children.remove(new_child) new_child.__parent = self self.__post_assign_children(new_children) except Exception as exc_info: # Reassign new children to their original parent for child, idx_parent in current_new_children.items(): child_idx, parent = idx_parent child.__parent = parent parent.__children.insert(child_idx, child) for child in current_new_orphan: child.__parent = None # Reassign old children to self self.__children = current_children for child in current_children: child.__parent = self raise TreeError(exc_info) @children.deleter def children(self): """Delete child node(s)""" for child in self.children: child.parent.__children.remove(child) child.__parent = None def __pre_assign_children(self, new_children: List): """Custom method to check before attaching children Can be overriden with `_BaseNode__pre_assign_children()` Args: new_children (List[Self]): new children to be added """ pass def __post_assign_children(self, new_children: List): """Custom method to check after attaching children Can be overriden with `_BaseNode__post_assign_children()` Args: new_children (List[Self]): new children to be added """ pass @property def ancestors(self) -> Iterable: """Get iterator to yield all ancestors of self, does not include self Returns: (Iterable[Self]) """ node = self.parent while node is not None: yield node node = node.parent @property def descendants(self) -> Iterable: """Get iterator to yield all descendants of self, does not include self Returns: (Iterable[Self]) """ yield from preorder_iter(self, filter_condition=lambda _node: _node != self) @property def leaves(self) -> Iterable: """Get iterator to yield all leaf nodes from self Returns: (Iterable[Self]) """ yield from preorder_iter(self, filter_condition=lambda _node: _node.is_leaf) @property def siblings(self) -> Iterable: """Get siblings of self Returns: (Iterable[Self]) """ if self.is_root: return () return tuple(child for child in self.parent.children if child is not self) @property def left_sibling(self): """Get sibling left of self Returns: (Self) """ if self.parent: children = self.parent.children child_idx = children.index(self) if child_idx: return self.parent.children[child_idx - 1] return None @property def right_sibling(self): """Get sibling right of self Returns: (Self) """ if self.parent: children = self.parent.children child_idx = children.index(self) if child_idx + 1 < len(children): return self.parent.children[child_idx + 1] return None @property def node_path(self) -> Iterable: """Get tuple of nodes starting from root Returns: (Iterable[Self]) """ if self.is_root: return [self] return tuple(list(self.parent.node_path) + [self]) @property def is_root(self) -> bool: """Get indicator if self is root node Returns: (bool) """ return self.parent is None @property def is_leaf(self) -> bool: """Get indicator if self is leaf node Returns: (bool) """ return not len(list(self.children)) @property def root(self): """Get root node of tree Returns: (Self) """ if self.is_root: return self return self.parent.root @property def depth(self) -> int: """Get depth of self, indexing starts from 1 Returns: (int) """ if self.is_root: return 1 return self.parent.depth + 1 @property def max_depth(self) -> int: """Get maximum depth from root to leaf node Returns: (int) """ return max(node.depth for node in list(preorder_iter(self.root))) @classmethod def from_dict(cls, input_dict: Dict[str, Any]): """Construct node from dictionary, all keys of dictionary will be stored as class attributes Input dictionary must have key `name` if not `Node` will not have any name >>> from bigtree import Node >>> a = Node.from_dict({"name": "a", "age": 90}) Args: input_dict (Dict[str, Any]): dictionary with node information, key: attribute name, value: attribute value Returns: (Self) """ return cls(**input_dict) def describe(self, exclude_attributes: List[str] = [], exclude_prefix: str = ""): """Get node information sorted by attribute name, returns list of tuples >>> from bigtree.node.node import Node >>> a = Node('a', age=90) >>> a.describe() [('_BaseNode__children', []), ('_BaseNode__parent', None), ('_sep', '/'), ('age', 90), ('name', 'a')] >>> a.describe(exclude_prefix="_") [('age', 90), ('name', 'a')] >>> a.describe(exclude_prefix="_", exclude_attributes=["name"]) [('age', 90)] Args: exclude_attributes (List[str]): list of attributes to exclude exclude_prefix (str): prefix of attributes to exclude Returns: (List[str]) """ return [ item for item in sorted(self.__dict__.items(), key=lambda item: item[0]) if (item[0] not in exclude_attributes) and (not len(exclude_prefix) or not item[0].startswith(exclude_prefix)) ] def get_attr(self, attr_name: str) -> Any: """Get value of node attribute Returns None if attribute name does not exist >>> from bigtree.node.node import Node >>> a = Node('a', age=90) >>> a.get_attr("age") 90 Args: attr_name (str): attribute name Returns: (Any) """ try: return self.__getattribute__(attr_name) except AttributeError: return None def set_attrs(self, attrs: Dict[str, Any]): """Set node attributes >>> from bigtree.node.node import Node >>> a = Node('a') >>> a.set_attrs({"age": 90}) >>> a Node(/a, age=90) Args: attrs (Dict[str, Any]): attribute dictionary, key: attribute name, value: attribute value """ self.__dict__.update(attrs) def go_to(self, node) -> Iterable: """Get path from current node to specified node from same tree >>> from bigtree import Node, print_tree >>> a = Node(name="a") >>> b = Node(name="b", parent=a) >>> c = Node(name="c", parent=a) >>> d = Node(name="d", parent=b) >>> e = Node(name="e", parent=b) >>> f = Node(name="f", parent=c) >>> g = Node(name="g", parent=e) >>> h = Node(name="h", parent=e) >>> print_tree(a) a ├── b │ ├── d │ └── e │ ├── g │ └── h └── c └── f >>> d.go_to(d) [Node(/a/b/d, )] >>> d.go_to(g) [Node(/a/b/d, ), Node(/a/b, ), Node(/a/b/e, ), Node(/a/b/e/g, )] >>> d.go_to(f) [Node(/a/b/d, ), Node(/a/b, ), Node(/a, ), Node(/a/c, ), Node(/a/c/f, )] Args: node (Self): node to travel to from current node, inclusive of start and end node Returns: (Iterable) """ if not isinstance(node, BaseNode): raise TypeError( f"Expect node to be BaseNode type, received input type {type(node)}" ) if self.root != node.root: raise TreeError( f"Nodes are not from the same tree. Check {self} and {node}" ) if self == node: return [self] self_path = [self] + list(self.ancestors) node_path = ([node] + list(node.ancestors))[::-1] common_nodes = set(self_path).intersection(set(node_path)) self_min_index, min_common_node = sorted( [(self_path.index(_node), _node) for _node in common_nodes] )[0] node_min_index = node_path.index(min_common_node) return self_path[:self_min_index] + node_path[node_min_index:] def copy(self): """Deep copy self; clone BaseNode >>> from bigtree.node.node import Node >>> a = Node('a') >>> a_copy = a.copy() Returns: (Self) """ return copy.deepcopy(self) def sort(self, **kwargs): """Sort children, possible keyword arguments include ``key=lambda node: node.name``, ``reverse=True`` >>> from bigtree import Node, print_tree >>> a = Node('a') >>> c = Node("c", parent=a) >>> b = Node("b", parent=a) >>> print_tree(a) a ├── c └── b >>> a.sort(key=lambda node: node.name) >>> print_tree(a) a ├── b └── c """ children = list(self.children) children.sort(**kwargs) self.__children = children def __copy__(self): """Shallow copy self >>> import copy >>> from bigtree.node.node import Node >>> a = Node('a') >>> a_copy = copy.deepcopy(a) Returns: (Self) """ obj = type(self).__new__(self.__class__) obj.__dict__.update(self.__dict__) return obj def __repr__(self): class_name = self.__class__.__name__ node_dict = self.describe(exclude_prefix="_") node_description = ", ".join([f"{k}={v}" for k, v in node_dict]) return f"{class_name}({node_description})" def __rshift__(self, other): """Set children using >> bitshift operator for self >> other Args: other (Self): other node, children """ other.parent = self def __lshift__(self, other): """Set parent using << bitshift operator for self << other Args: other (Self): other node, parent """ self.parent = other