diff --git a/lighttree/interactive.py b/lighttree/interactive.py index 9387132..e9d9018 100644 --- a/lighttree/interactive.py +++ b/lighttree/interactive.py @@ -4,10 +4,11 @@ import re import unicodedata +from typing import Optional, Union, List, Any, Dict from lighttree.tree import Tree -def is_valid_attr_name(item): +def is_valid_attr_name(item) -> bool: if not isinstance(item, str): return False if item.startswith("__"): @@ -19,7 +20,7 @@ def is_valid_attr_name(item): return True -def _coerce_attr(attr): +def _coerce_attr(attr) -> Union[str, None]: if not len(attr): return None new_attr = unicodedata.normalize("NFD", attr).encode("ASCII", "ignore").decode() @@ -54,9 +55,9 @@ class Obj(object): _STRING_KEY_CONSTRAINT = True _COERCE_ATTR = False - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: # will store non-valid names - self.__d = dict() + self.__d: Dict[str, Any] = dict() for k, v in kwargs.items(): if not ( isinstance(k, str) @@ -95,21 +96,21 @@ def __setitem__(self, key, value): else: super(Obj, self).__setattr__(key, value) - def __keys(self): + def __keys(self) -> List[Any]: return list(self.__d.keys()) + [ k for k in self.__dict__.keys() if k not in ("_REPR_NAME", "_Obj__d") ] - def __contains__(self, item): + def __contains__(self, item) -> bool: return item in self.__keys() - def __str__(self): + def __str__(self) -> str: return "<%s> %s" % ( str(self.__class__._REPR_NAME or self.__class__.__name__), str(sorted(map(str, self.__keys()))), ) - def __repr__(self): + def __repr__(self) -> str: return self.__str__() @@ -124,18 +125,20 @@ class TreeBasedObj(Obj): _COERCE_ATTR = False _ATTR = None - def __init__(self, tree, root_path=None, depth=1, initial_tree=None): + def __init__( + self, + tree: Tree, + root_path: Optional[str] = None, + depth: int = 1, + initial_tree: Optional[Tree] = None, + ) -> None: super(TreeBasedObj, self).__init__() - if not isinstance(tree, Tree): - raise ValueError( - 'tree must be an instance of "lighttree.tree.Tree", got %s' % type(tree) - ) self._tree = tree self._root_path = root_path self._initial_tree = initial_tree if initial_tree is not None else tree self._expand_attrs(depth) - def _clone(self, nid, root_path, depth): + def _clone(self, nid: str, root_path: str, depth: int) -> "TreeBasedObj": _, st = self._tree.subtree(nid) return self.__class__( tree=st, @@ -144,11 +147,15 @@ def _clone(self, nid, root_path, depth): initial_tree=self._initial_tree, ) - def _expand_attrs(self, depth): + def _expand_attrs(self, depth: int) -> None: if depth: - for child_key, child_node in self._tree.children(nid=self._tree.root): + r = self._tree.root + if r is None: + return + for child_key, child_node in self._tree.children(nid=r): if self._ATTR: child_key = getattr(child_node, self._ATTR) + str_child_key: str if isinstance(child_key, str): if self._COERCE_ATTR: # if invalid coercion, coerce returns None, in this case we keep inital naming @@ -156,12 +163,13 @@ def _expand_attrs(self, depth): else: str_child_key = child_key else: - str_child_key = "i%d" % child_key + str_child_key = "i%s" % child_key + child_root: str if self._root_path is not None: child_root = "%s.%s" % (self._root_path, child_key) else: - child_root = child_key + child_root = str(child_key) if child_key else "" self[str_child_key] = self._clone( child_node.identifier, root_path=child_root, depth=depth - 1 ) @@ -182,7 +190,7 @@ def __getitem__(self, item): r._expand_attrs(depth=1) return r - def _show(self, *args, **kwargs): + def _show(self, *args, **kwargs) -> str: tree_repr = self._tree.show(*args, **kwargs) if self._root_path is None: return "<%s>\n%s" % ( @@ -196,8 +204,8 @@ def _show(self, *args, **kwargs): str(tree_repr), ) - def __call__(self, *args, **kwargs): + def __call__(self, *args, **kwargs) -> Tree: return self._tree - def __str__(self): + def __str__(self) -> str: return self._show() diff --git a/lighttree/node.py b/lighttree/node.py index 525e4cd..28802f5 100644 --- a/lighttree/node.py +++ b/lighttree/node.py @@ -2,37 +2,33 @@ # -*- coding: utf-8 -*- import uuid +from typing import Optional, Any, Tuple class Node(object): def __init__( self, - identifier=None, - auto_uuid=True, - keyed=True, - accept_children=True, - repr_=None, - data=None, - ): + identifier: Optional[str] = None, + auto_uuid: bool = True, + keyed: bool = True, + accept_children: bool = True, + repr_: Optional[str] = None, + data: Any = None, + ) -> None: """ :param identifier: node identifier, must be unique per tree """ - if identifier is not None and not isinstance(identifier, str): - raise ValueError( - "Identifier must be a string type, provided type is <%s>" - % type(identifier) - ) if identifier is None: if not auto_uuid: raise ValueError("Required identifier") - identifier = uuid.uuid4() + identifier = str(uuid.uuid4()) self.identifier = identifier self.keyed = keyed self.accept_children = accept_children self.repr = repr_ self.data = data - def line_repr(self, depth, **kwargs): + def line_repr(self, depth: int, **kwargs) -> Tuple[str, str]: """Control how node is displayed in tree representation. _ ├── one end @@ -47,13 +43,13 @@ def line_repr(self, depth, **kwargs): return "{}", "" return "[]", "" - def __eq__(self, other): + def __eq__(self, other) -> bool: if not isinstance(other, self.__class__): return False return self.identifier == other.identifier - def __str__(self): + def __str__(self) -> str: return "%s, id=%s" % (self.__class__.__name__, self.identifier) - def __repr__(self): + def __repr__(self) -> str: return self.__str__() diff --git a/lighttree/tree.py b/lighttree/tree.py index a6066fe..6c37bbe 100644 --- a/lighttree/tree.py +++ b/lighttree/tree.py @@ -2,6 +2,18 @@ # -*- coding: utf-8 -*- import copy + +from typing import ( + Tuple, + Union, + Optional, + List, + Sequence, + Callable, + Iterable, + cast, + Dict, +) from collections import defaultdict from operator import itemgetter @@ -26,28 +38,28 @@ class Tree(object): """ - def __init__(self, path_separator="."): - if not isinstance(path_separator, str): - raise ValueError( - "path_separator must be a string, got %s" % type(path_separator) - ) + def __init__(self, path_separator: str = ".") -> None: self.path_separator = path_separator # nodes references and hierarchy in tree - self.root = None + self.root: Optional[str] = None # node identifier -> node - self._nodes_map = {} + self._nodes_map: Dict[str, Node] = {} # node identifier -> parent node identifier - self._nodes_parent = defaultdict(lambda: None) + self._nodes_parent: Dict[str, Optional[str]] = defaultdict(lambda: None) # "map" node identifier -> map of children nodes identifier -> key - self._nodes_children_map = defaultdict(dict) + self._nodes_children_map: Dict[str, Dict[str, Union[int, str]]] = defaultdict( + dict + ) # "list" node identifier -> children nodes identifiers - self._nodes_children_list = defaultdict(list) + self._nodes_children_list: Dict[str, List[str]] = defaultdict(list) - def __contains__(self, identifier): + def __contains__(self, identifier: str) -> bool: return identifier in self._nodes_map - def get(self, nid, by_path=False): + def get( + self, nid: str, by_path: bool = False + ) -> Tuple[Union[None, str, int], Node]: """Get a node by its id. :param nid: str, identifier of node to fetch :param by_path: bool, if True nid is the path to the node @@ -58,7 +70,7 @@ def get(self, nid, by_path=False): self._ensure_present(nid) return self.get_key(nid), self._nodes_map[nid] - def child_id(self, nid, key, by_path=False): + def child_id(self, nid: str, key: Union[str, int], by_path: bool = False) -> str: _, node = self.get(nid, by_path=by_path) if node.keyed: child_id = next( @@ -70,23 +82,29 @@ def child_id(self, nid, key, by_path=False): return child_id try: int(key) - except: + except (ValueError, TypeError): raise ValueError("Expected integer key, got %s" % key) return self._nodes_children_list[nid][int(key)] - def child(self, nid, key, by_path=False): + def child( + self, nid: str, key: Union[int, str], by_path: bool = False + ) -> Tuple[Union[None, str, int], Node]: return self.get(self.child_id(nid, key, by_path=by_path)) - def get_node_id_by_path(self, path): + def get_node_id_by_path(self, path: str) -> str: nid = self.root + if nid is None: + raise ValueError("Empty tree") if path == "": return nid keys = str(path).split(self.path_separator) for k in keys: nid = self.child_id(nid, k) + if nid is None: + raise ValueError("Empty tree") return nid - def get_path(self, nid): + def get_path(self, nid: str) -> str: return self.path_separator.join( [ str(k) @@ -96,7 +114,7 @@ def get_path(self, nid): ] ) - def get_key(self, nid): + def get_key(self, nid: str) -> Union[int, str, None]: """Get a node's key. :param nid: str, identifier of node @@ -112,7 +130,12 @@ def get_key(self, nid): return self._nodes_children_map[parent_node.identifier][nid] return self._nodes_children_list[parent_node.identifier].index(nid) - def list(self, id_in=None, depth_in=None, filter_=None): + def list( + self, + id_in: Optional[Sequence[str]] = None, + depth_in: Optional[Sequence[int]] = None, + filter_: Optional[Callable[[Node], bool]] = None, + ) -> List[Tuple[Union[None, int, str], Node]]: """List nodes. :param id_in: list of str, optional, filter nodes among provided identifiers :param depth_in: list of int, optional, filter nodes whose depth in tree is among provided values @@ -127,13 +150,18 @@ def list(self, id_in=None, depth_in=None, filter_=None): and (depth_in is None or self.depth(nid) in depth_in) ] - def is_empty(self): + def is_empty(self) -> bool: """Return whether tree is empty (contains nodes) or not. :rtype: bool """ return self.root is None - def _ensure_present(self, nid, defaults_to_root=False, allow_empty=False): + def _ensure_present( + self, + nid: Optional[str], + defaults_to_root: bool = False, + allow_empty: bool = False, + ) -> Union[None, str]: if nid is None: if not self.is_empty() and defaults_to_root: return self.root @@ -144,25 +172,18 @@ def _ensure_present(self, nid, defaults_to_root=False, allow_empty=False): raise NotFoundNodeError("Node id <%s> doesn't exist in tree" % nid) return nid - def _validate_node_insertion(self, node): - if not isinstance(node, Node): - raise ValueError("Node must be instance of , got <%s>." % type(node)) + def _validate_node_insertion(self, node: Node) -> None: if node.identifier in self._nodes_map.keys(): raise DuplicatedNodeError( "Can't create node with id '%s'" % node.identifier ) - def _validate_tree_insertion(self, tree): - if not isinstance(tree, Tree): - raise ValueError( - "Tree must be instance of <%s>, got <%s>" - % (self.__class__.__name__, type(tree)) - ) + def _validate_tree_insertion(self, tree: "Tree") -> None: for node_key, node in tree.list(): # todo validate key self._validate_node_insertion(node) - def _clone_init(self, deep): + def _clone_init(self, deep: bool) -> "Tree": """Method intended to be overloaded, to avoid rewriting whole methods relying on `clone` method when inheriting from Tree, so that the way a tree is duplicated is explicit. @@ -187,7 +208,9 @@ def _clone_init(self, deep): """ return self.__class__() - def clone(self, with_nodes=True, deep=False, new_root=None): + def clone( + self, with_nodes: bool = True, deep: bool = False, new_root: str = None + ) -> "Tree": """Clone current instance, with or without nodes. :rtype: :class:`lighttree.tree.Tree` """ @@ -197,7 +220,7 @@ def clone(self, with_nodes=True, deep=False, new_root=None): # remove eventual created nodes at init if new_tree.root: - new_tree.drop_node(new_tree.root, return_subtree=False) + new_tree.drop_node(new_tree.root) for i, (key, node) in enumerate(self.expand_tree(nid=new_root)): nid = node.identifier if deep: @@ -211,53 +234,70 @@ def clone(self, with_nodes=True, deep=False, new_root=None): new_tree.insert_node(node, parent_id=pid, key=key) return new_tree - def parent(self, nid): + def parent(self, nid: str) -> Tuple[Union[None, str, int], Node]: """Return parent node. Return None if given node id is root. """ pid = self.parent_id(nid) if pid is None: - return None, None + raise NotFoundNodeError("Node <%s> has no parent" % nid) return self.get(pid) - def parent_id(self, nid, by_path=False): + def parent_id(self, nid: str, by_path: bool = False) -> str: if nid == self.root: - return None + raise NotFoundNodeError("Root node has not parent") if by_path: nid = self.get_node_id_by_path(nid) self._ensure_present(nid) - return self._nodes_parent[nid] + parent_id = self._nodes_parent[nid] + if parent_id is None: + # cannot happen, only for typing + raise NotFoundNodeError() + return parent_id - def children(self, nid, by_path=False): + def children( + self, nid: str, by_path: bool = False + ) -> List[Tuple[Union[None, str, int], Node]]: """Return set of given node children node ids.""" return [self.get(id_) for id_ in self.children_ids(nid, by_path=by_path)] - def children_ids(self, nid, by_path=False): + def children_ids(self, nid: str, by_path: bool = False) -> List[str]: if self.get(nid, by_path=by_path)[1].keyed: return list(self._nodes_children_map[nid].keys()) return list(self._nodes_children_list[nid]) - def siblings(self, nid, by_path=False): + def siblings( + self, nid: str, by_path: bool = False + ) -> List[Tuple[Union[None, str, int], Node]]: """Return set of ids of nodes that share the provided node's parent.""" return [self.get(id_) for id_ in self.siblings_ids(nid, by_path=by_path)] - def siblings_ids(self, nid, by_path=False): + def siblings_ids(self, nid: str, by_path: bool = False) -> List[str]: if by_path: nid = self.get_node_id_by_path(nid) self._ensure_present(nid) if nid == self.root: return [] - return list(set(self.children_ids(self.parent_id(nid))).difference({nid})) + parent_id = self.parent_id(nid) + if parent_id is None: + return [] + return list(set(self.children_ids(parent_id)).difference({nid})) - def is_leaf(self, nid, by_path=False): + def is_leaf(self, nid: str, by_path: bool = False) -> bool: """Return is node is a leaf in this tree.""" return len(self.children_ids(nid, by_path=by_path)) == 0 - def depth(self, nid, by_path=False): + def depth(self, nid: str, by_path: bool = False) -> int: """Return node depth, 0 means root.""" return len(self.ancestors_ids(nid, by_path=by_path)) - def ancestors(self, nid, from_root=False, include_current=False, by_path=False): + def ancestors( + self, + nid: str, + from_root: bool = False, + include_current: bool = False, + by_path: bool = False, + ) -> List[Tuple[Union[None, str, int], Node]]: """From element to root. :param nid: :param from_root: @@ -272,7 +312,13 @@ def ancestors(self, nid, from_root=False, include_current=False, by_path=False): ) ] - def ancestors_ids(self, nid, from_root=False, include_current=False, by_path=False): + def ancestors_ids( + self, + nid: str, + from_root: bool = False, + include_current: bool = False, + by_path: bool = False, + ) -> List[str]: if by_path: nid = self.get_node_id_by_path(nid) self._ensure_present(nid) @@ -286,31 +332,35 @@ def ancestors_ids(self, nid, from_root=False, include_current=False, by_path=Fal ancestor_ids = list(reversed(ancestor_ids)) return ancestor_ids - def subtree(self, nid, deep=False, by_path=False): + def subtree( + self, nid: str, deep: bool = False, by_path: bool = False + ) -> Tuple[Union[None, str, int], "Tree"]: if by_path: nid = self.get_node_id_by_path(nid) t = self.clone(with_nodes=True, new_root=nid, deep=deep) - if t.is_empty(): + if t.root is None: return None, t return self.get_key(t.root), t - def leaves(self, nid=None, by_path=False): + def leaves( + self, nid: Optional[str] = None, by_path: bool = False + ) -> List[Tuple[Union[None, str, int], Node]]: """Return leaves under a node subtree.""" return [self.get(id_) for id_ in self.leaves_ids(nid, by_path=by_path)] - def leaves_ids(self, nid=None, by_path=False): + def leaves_ids(self, nid: Optional[str] = None, by_path: bool = False) -> List[str]: tree = self if nid is None else self.subtree(nid, by_path=by_path)[1] return [id_ for id_ in tree._nodes_map.keys() if tree.is_leaf(id_)] def insert( self, - item, - parent_id=None, - child_id=None, - child_id_below=None, - key=None, - by_path=False, - ): + item: Union[Node, "Tree"], + parent_id: Optional[str] = None, + child_id: Optional[str] = None, + child_id_below: Optional[str] = None, + key: Optional[Union[int, str]] = None, + by_path: bool = False, + ) -> "Tree": if isinstance(item, Node): if child_id_below is not None: raise ValueError( @@ -338,7 +388,14 @@ def insert( '"item" parameter must either be a Node, or a Tree, got <%s>.' % type(item) ) - def insert_node(self, node, parent_id=None, child_id=None, key=None, by_path=False): + def insert_node( + self, + node: Node, + parent_id: Optional[str] = None, + child_id: Optional[str] = None, + key: Optional[Union[int, str]] = None, + by_path: bool = False, + ) -> Union[None, int, str]: """Insert node, return key :param node: :param parent_id: @@ -351,11 +408,17 @@ def insert_node(self, node, parent_id=None, child_id=None, key=None, by_path=Fal raise ValueError('Can declare at most "parent_id" or "child_id"') if child_id is not None: self._insert_node_above(node, child_id=child_id, key=key, by_path=by_path) - return self + return None self._insert_node_below(node, parent_id=parent_id, key=key, by_path=by_path) return self.get_key(node.identifier) - def _insert_node_below(self, node, parent_id, key, by_path): + def _insert_node_below( + self, + node: Node, + parent_id: Optional[str], + key: Union[None, int, str], + by_path: bool, + ) -> None: # insertion at root if parent_id is None: if not self.is_empty(): @@ -367,7 +430,7 @@ def _insert_node_below(self, node, parent_id, key, by_path): return if by_path: - parent_id = self.get_node_id_by_path(parent_id) + parent_id = self.get_node_id_by_path(path=parent_id) self._ensure_present(parent_id) node_id = node.identifier @@ -401,36 +464,50 @@ def _insert_node_below(self, node, parent_id, key, by_path): self._nodes_map[node_id] = node self._nodes_parent[node_id] = parent_id - def _insert_node_above(self, node, child_id, key, by_path): + def _insert_node_above( + self, node: Node, child_id: str, key: Union[None, int, str], by_path: bool + ) -> None: if by_path: - child_id = self.get_node_id_by_path(child_id) + child_id = self.get_node_id_by_path(path=child_id) self._ensure_present(child_id) # get parent_id before dropping subtree - parent_id = self.parent_id(child_id) - subtree_key, child_subtree = self.drop_subtree(child_id) - self._insert_node_below( - node, parent_id=parent_id, key=subtree_key, by_path=False + try: + parent_id = self.parent_id(nid=child_id) + has_parent = True + except NotFoundNodeError: + parent_id = "fake-for-typing" + has_parent = False + subtree_key, child_subtree = self.drop_subtree(nid=child_id) + if has_parent: + self._insert_node_below( + node=node, parent_id=parent_id, key=subtree_key, by_path=False + ) + else: + self._insert_node_below( + node=node, parent_id=None, key=subtree_key, by_path=False + ) + self._insert_tree_below( + new_tree=child_subtree, parent_id=node.identifier, key=key, by_path=False ) - self._insert_tree_below(child_subtree, node.identifier, key=key, by_path=False) def insert_tree( self, - new_tree, - parent_id=None, - child_id=None, - child_id_below=None, - key=None, - by_path=False, - ): + new_tree: "Tree", + parent_id: Optional[str] = None, + child_id: Optional[str] = None, + child_id_below: Optional[str] = None, + key: Optional[Union[str, int]] = None, + by_path: bool = False, + ) -> Union[int, str, None]: """Return new key""" self._validate_tree_insertion(new_tree) - if new_tree.is_empty(): - return + if new_tree.root is None: + raise ValueError("Empty inserted tree") if parent_id is not None and child_id is not None: raise ValueError('Can declare at most "parent_id" or "child_id"') if child_id is not None: self._insert_tree_above( - new_tree, + new_tree=new_tree, child_id=child_id, child_id_below=child_id_below, key=key, @@ -440,9 +517,18 @@ def insert_tree( self._insert_tree_below( new_tree, parent_id=parent_id, key=key, by_path=by_path ) + if new_tree.root is None: + # not possible, but for typing + raise ValueError("Empty inserted tree") return self.get_key(new_tree.root) - def _insert_tree_below(self, new_tree, parent_id, key, by_path): + def _insert_tree_below( + self, + new_tree: "Tree", + parent_id: Optional[str], + key: Union[None, str, int], + by_path: bool, + ) -> "Tree": if parent_id is None: # insertion at root requires tree to be empty if not self.is_empty(): @@ -462,8 +548,16 @@ def _insert_tree_below(self, new_tree, parent_id, key, by_path): nid = new_node.identifier pid = parent_id if nid == new_tree.root else new_tree.parent_id(nid) self.insert_node(new_node, parent_id=pid, key=new_key) + return self - def _insert_tree_above(self, new_tree, child_id, child_id_below, key, by_path): + def _insert_tree_above( + self, + new_tree: "Tree", + child_id: str, + child_id_below: Optional[str], + key: Union[None, str, int], + by_path: bool, + ) -> None: # make all checks before modifying tree if by_path: child_id = self.get_node_id_by_path(child_id) @@ -486,7 +580,7 @@ def _insert_tree_above(self, new_tree, child_id, child_id_below, key, by_path): self._insert_tree_below(new_tree, parent_id, key=subtree_key, by_path=False) self._insert_tree_below(child_subtree, child_id_below, key=key, by_path=False) - def _drop_node(self, nid): + def _drop_node(self, nid: str) -> Tuple[Union[None, str, int], Node]: """Return key, node""" if self.children_ids(nid): raise ValueError("Cannot drop node having children.") @@ -512,7 +606,12 @@ def _drop_node(self, nid): self.root = None return key, node - def drop_node(self, nid, with_children=True, by_path=False, return_subtree=True): + def drop_node( + self, + nid: str, + with_children: bool = True, + by_path: bool = False, + ) -> Tuple[Union[None, str, int], Node]: """If with_children is False, children of this node will take as new parent the dropped node parent. Possible only if node type is same as parent node type. @@ -523,16 +622,13 @@ def drop_node(self, nid, with_children=True, by_path=False, return_subtree=True) self._ensure_present(nid) children_ids = self.children_ids(nid) - if return_subtree: - removed_key, removed_subtree = self.subtree(nid) - else: - removed_key, removed_subtree = None, None - if with_children: for cid in children_ids: - self.drop_node(cid, with_children=True) - return self._drop_node(nid) + self.drop_node(nid=cid, with_children=True) + return self._drop_node(nid=nid) + # drop a single node, and re-attach children to parent + removed_key, removed_subtree = self.subtree(nid) if nid == self.root and len(children_ids) > 1: raise MultipleRootError( "Cannot drop current root <%s> without its children, else tree would have " @@ -547,10 +643,11 @@ def drop_node(self, nid, with_children=True, by_path=False, return_subtree=True) for cid in children_ids: k, st = removed_subtree.subtree(cid) self._insert_tree_below(new_tree=st, parent_id=pid, key=k, by_path=False) - if return_subtree: - return removed_key, removed_subtree.get(nid)[1] + return removed_key, node - def drop_subtree(self, nid, by_path=False): + def drop_subtree( + self, nid: str, by_path: bool = False + ) -> Tuple[Union[None, str, int], "Tree"]: if by_path: nid = self.get_node_id_by_path(nid) self._ensure_present(nid) @@ -560,14 +657,13 @@ def drop_subtree(self, nid, by_path=False): def expand_tree( self, - nid=None, - by_path=False, - mode="depth", - filter_=None, - filter_through=False, - sort_key=None, - reverse=False, - ): + nid: Optional[str] = None, + by_path: bool = False, + mode: str = "depth", + filter_: Optional[Callable[[Union[None, str, int], Node], bool]] = None, + filter_through: bool = False, + reverse: bool = False, + ) -> Iterable[Tuple[Union[None, int, str], Node]]: """Python generator traversing the tree (or a subtree) with optional node filtering. Inspired by treelib implementation https://github.com/caesar0301/treelib/blob/master/treelib/tree.py#L374 @@ -577,7 +673,6 @@ def expand_tree( :param filter_: filter function performed on nodes. Node excluded from filter function won't be yielded. :param filter_through: if True, excluded nodes don't exclude their children. :param reverse: the ``reverse`` param for sorting :class:`Node` objects in the same level - :param sort_key: key used to order nodes of same parent :return: node ids that satisfy the conditions if ``id_only`` is True, else nodes. :rtype: generator """ @@ -586,7 +681,6 @@ def expand_tree( if nid is not None and by_path: nid = self.get_node_id_by_path(nid) nid = self._ensure_present(nid, defaults_to_root=True, allow_empty=True) - sort_key = itemgetter(0) if sort_key is None else sort_key if nid is not None: key, node = self.get(nid) filter_pass_node = filter_ is None or filter_(key, node) @@ -600,7 +694,7 @@ def expand_tree( or filter_through or filter_(child_key, child_node) ] - queue.sort(key=sort_key, reverse=reverse) + queue.sort(key=itemgetter(0), reverse=reverse) while queue: current_key, current_node = queue.pop(0) if filter_ is None or filter_(current_key, current_node): @@ -614,7 +708,7 @@ def expand_tree( or filter_through or filter_(gchild_key, gchild_node) ] - expansion.sort(key=sort_key, reverse=reverse) + expansion.sort(key=itemgetter(0), reverse=reverse) if mode == "depth": queue = expansion + queue # depth-first elif mode == "width": @@ -622,24 +716,22 @@ def expand_tree( def show( self, - nid=None, - by_path=False, - filter_=None, - sort_key=None, - display_key=True, - reverse=False, - line_type="ascii-ex", - limit=None, - line_max_length=60, - key_delimiter=": ", + nid: Optional[str] = None, + by_path: bool = False, + filter_: Optional[Callable[[Node], bool]] = None, + display_key: bool = True, + reverse: bool = False, + line_type: str = "ascii-ex", + limit: Optional[int] = None, + line_max_length: int = 60, + key_delimiter: str = ": ", **kwargs - ): + ) -> str: """Return tree structure in hierarchy style. :param nid: Node identifier from which tree traversal will start. If None tree root will be used :param filter\_: filter function performed on nodes. Nodes excluded from filter function nor their children won't be displayed :param reverse: the ``reverse`` param for sorting :class:`Node` objects in the same level - :param sort_key: key used to order nodes of same parent :param display_key: boolean, if True display keyed nodes keys :param reverse: reverse parameter applied at sorting :param line_type: display type choice @@ -654,23 +746,23 @@ def show( nid = self.get_node_id_by_path(nid) for is_last_list, key, node in self._iter_nodes_with_location( - nid, filter_, sort_key, reverse + nid, filter_, reverse ): prefix = self._line_prefix_repr(line_type, is_last_list) - display_key_ = isinstance(key, str) and display_key - if display_key_: + # do not display nb in list in case of non-keyed children (int key) + if isinstance(key, str) and display_key: prefix += key node_start, node_end = node.line_repr( depth=len(is_last_list), prefix_len=len(prefix), **kwargs ) line = self._line_repr( - prefix, - display_key_, - key_delimiter, - node_start, - node_end, - line_max_length, + prefix=prefix, + is_key_displayed=isinstance(key, str) and display_key, + key_delimiter=key_delimiter, + node_start=node_start, + node_end=node_end, + line_max_length=line_max_length, ) output += "%s\n" % line @@ -684,18 +776,20 @@ def show( return output def _iter_nodes_with_location( - self, nid, filter_, sort_key, reverse, is_last_list=None - ): + self, + nid: Optional[str], + filter_: Optional[Callable[[Node], bool]], + reverse: bool, + is_last_list: Optional[List[bool]] = None, + ) -> Iterable[Tuple[Tuple[bool, ...], Union[None, int, str], Node]]: """Yield nodes with information on how they are placed. :param nid: starting node identifier :param filter_: filter function applied on nodes - :param sort_key: key used to order nodes of same parent :param reverse: reverse parameter applied at sorting :param is_last_list: list of booleans, each indicating if node is the last yielded one at this depth :return: tuple of booleans, node """ is_last_list = is_last_list or [] - sort_key = itemgetter(0) if sort_key is None else sort_key nid = self._ensure_present(nid, defaults_to_root=True, allow_empty=True) if nid is not None: @@ -707,25 +801,29 @@ def _iter_nodes_with_location( for child_key, child_node in self.children(nid) if filter_ is None or filter_(child_node) ] - idxlast = len(children) - 1 - children.sort(key=sort_key, reverse=reverse) + idxlast: int = len(children) - 1 + children.sort(key=itemgetter(0), reverse=reverse) for idx, (child_k, child) in enumerate(children): is_last_list.append(idx == idxlast) for item in self._iter_nodes_with_location( - child.identifier, - filter_, - sort_key, - reverse, - is_last_list, + nid=child.identifier, + filter_=filter_, + reverse=reverse, + is_last_list=is_last_list, ): yield item is_last_list.pop() @staticmethod def _line_repr( - prefix, is_key_displayed, key_delimiter, node_start, node_end, line_max_length - ): - line = prefix + prefix: str, + is_key_displayed: bool, + key_delimiter: str, + node_start: str, + node_end: str, + line_max_length: int, + ) -> str: + line: str = prefix if node_start and is_key_displayed: line += key_delimiter line += node_start @@ -737,20 +835,22 @@ def _line_repr( return line @staticmethod - def _line_prefix_repr(line_type, is_last_list): + def _line_prefix_repr(line_type: str, is_last_list: Tuple[bool, ...]) -> str: if not is_last_list: return "" dt_vertical_line, dt_line_box, dt_line_corner = STYLES[line_type] leading = "".join( - map( - lambda x: dt_vertical_line + " " * 3 if not x else " " * 4, - is_last_list[0:-1], - ) + [ + dt_vertical_line + " " * 3 if not is_last else " " * 4 + for is_last in cast(Iterable[bool], is_last_list[0:-1]) + ] ) lasting = dt_line_corner if is_last_list[-1] else dt_line_box return leading + lasting - def merge(self, new_tree, nid=None, by_path=False): + def merge( + self, new_tree: "Tree", nid: Optional[str] = None, by_path: bool = False + ) -> "Tree": """Merge "new_tree" on current tree by pasting its root children on current tree "nid" node. Consider the following trees: @@ -794,12 +894,15 @@ def merge(self, new_tree, nid=None, by_path=False): nid = self._ensure_present(nid, defaults_to_root=True) + if new_tree.root is None: + # not possible, only for typing + raise ValueError("Inserted tree is empty") for ckey, cnode in new_tree.children(new_tree.root): self.insert(new_tree.subtree(cnode.identifier)[1], nid, key=ckey) return self - def __str__(self): + def __str__(self) -> str: return self.show() - def __repr__(self): + def __repr__(self) -> str: return self.__str__() diff --git a/tests/test_tree.py b/tests/test_tree.py index 870e248..b4183dc 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -51,7 +51,7 @@ def test_insert_root(self): tree_sanity_check(t) # wrong node insertion - with self.assertRaises(ValueError): + with self.assertRaises(AttributeError): Tree().insert_node({"key": "a"}) def test_insert_node_below(self): @@ -124,9 +124,9 @@ def test_validate_node_insertion(self): class MyNotValidClass(object): pass - with self.assertRaises(ValueError): + with self.assertRaises(AttributeError): t._validate_node_insertion(MyNotValidClass()) - with self.assertRaises(ValueError): + with self.assertRaises(AttributeError): t.insert_node({}) # cannot add node with similar id @@ -297,7 +297,8 @@ def test_empty_clone(self): def test_parent(self): t = get_sample_tree() - self.assertEqual(t.parent_id("root"), None) + with self.assertRaises(NotFoundNodeError): + t.parent_id("root") self.assertEqual(t.parent_id("a"), "root") self.assertEqual(t.parent_id("ab"), "a") self.assertEqual(t.parent_id("c1"), "c") @@ -482,11 +483,7 @@ def tuple_extend(item, tup): # full self.assertEqual( - list( - t._iter_nodes_with_location( - nid=None, filter_=None, sort_key=None, reverse=False - ) - ), + list(t._iter_nodes_with_location(nid=None, filter_=None, reverse=False)), [ tuple_extend((), t.get("root")), tuple_extend((False,), t.get("a")), @@ -502,11 +499,7 @@ def tuple_extend(item, tup): # subtree self.assertEqual( - list( - t._iter_nodes_with_location( - nid="aa", filter_=None, sort_key=None, reverse=False - ) - ), + list(t._iter_nodes_with_location(nid="aa", filter_=None, reverse=False)), [ tuple_extend((), t.get("aa")), tuple_extend((False,), t.get("aa0")),