From 608e18feedb5053c54f1d10e664795984874a3fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard=20Binet?= Date: Wed, 28 Jul 2021 09:21:21 +0200 Subject: [PATCH 1/4] usage of generic Node in Tree definition, dataclass Node --- lighttree/__init__.py | 13 +++- lighttree/implementations/json_tree.py | 10 ++-- lighttree/node.py | 83 ++++++++++++++------------ lighttree/tree.py | 66 ++++++++++---------- 4 files changed, 92 insertions(+), 80 deletions(-) diff --git a/lighttree/__init__.py b/lighttree/__init__.py index 578dd7c..5cd887f 100644 --- a/lighttree/__init__.py +++ b/lighttree/__init__.py @@ -1,4 +1,13 @@ from .interactive import TreeBasedObj -from .tree import Tree, Node, Key, KeyedNode, KeyedTree +from .tree import Tree, Key, KeyedNode, KeyedTree +from .node import Node, AutoIdNode -__all__ = ["Tree", "Node", "TreeBasedObj", "Key", "KeyedNode", "KeyedTree"] +__all__ = [ + "Tree", + "Node", + "AutoIdNode", + "TreeBasedObj", + "Key", + "KeyedNode", + "KeyedTree", +] diff --git a/lighttree/implementations/json_tree.py b/lighttree/implementations/json_tree.py index acf1ea2..1fe19c0 100644 --- a/lighttree/implementations/json_tree.py +++ b/lighttree/implementations/json_tree.py @@ -1,5 +1,5 @@ from typing import Dict, Optional, Any, Union, List -from lighttree import Node, Tree, Key +from lighttree import Tree, Key, AutoIdNode from lighttree.node import NodeId from lighttree.interactive import TreeBasedObj @@ -32,26 +32,26 @@ def _fill(self, data: Any, key: Key, strict: bool, path: str = "") -> None: else: pid = self.get_node_id_by_path(path=path) if isinstance(data, list) or not strict and isinstance(data, tuple): - k = self.insert_node(Node(keyed=False), parent_id=pid, key=key) + k = self.insert_node(AutoIdNode(keyed=False), parent_id=pid, key=key) path = self._concat(path, k) for el in data: self._fill(el, strict=strict, path=path, key=None) return if isinstance(data, dict): - k = self.insert_node(Node(keyed=True), key=key, parent_id=pid) + k = self.insert_node(AutoIdNode(keyed=True), key=key, parent_id=pid) path = self._concat(path, k) for sk, el in data.items(): self._fill(el, strict=strict, path=path, key=sk) return if isinstance(data, (str, int, float)): self.insert_node( - Node(accept_children=False, repr_=str(data), data=data), + AutoIdNode(accept_children=False, repr_=str(data), data=data), parent_id=pid, key=key, ) return if data is None: - self.insert_node(Node(accept_children=False), parent_id=pid) + self.insert_node(AutoIdNode(accept_children=False), parent_id=pid) return raise TypeError("Unsupported type %s" % type(data)) diff --git a/lighttree/node.py b/lighttree/node.py index c93dbb0..7120865 100644 --- a/lighttree/node.py +++ b/lighttree/node.py @@ -1,54 +1,63 @@ import uuid from typing import Optional, Any, Tuple +from dataclasses import dataclass NodeId = str -class Node(object): - def __init__( - self, - identifier: Optional[NodeId] = 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 None: - if not auto_uuid: - raise ValueError("Required identifier") - identifier = str(uuid.uuid4()) - self.identifier = identifier - self.keyed = keyed - self.accept_children = accept_children - self.repr = repr_ - self.data = data +@dataclass +class Node: + + identifier: NodeId + keyed: bool = True + accept_children: bool = True + repr_: Optional[str] = None + data: Any = None def line_repr(self, depth: int, **kwargs: Any) -> Tuple[str, str]: """Control how node is displayed in tree representation. - _ - ├── one end - │ └── two myEnd - └── three + First returned string is how node is represented on left, second string is how node is represented on right. + + MyTree + ├── one OneEnd + │ └── two twoEnd + └── three threeEnd """ - if self.repr is not None: - return self.repr, "" + if self.repr_ is not None: + return self.repr_, "" if not self.accept_children: - return str(self.data), "" + if hasattr(self.data, "__str__"): + return str(self.data), "" + return "", "" if self.keyed: return "{}", "" return "[]", "" - def __eq__(self, other: Any) -> bool: - if not isinstance(other, self.__class__): - return False - return self.identifier == other.identifier - def __str__(self) -> str: - return "%s, id=%s" % (self.__class__.__name__, self.identifier) +class AutoIdNode(Node): + def __init__( + self, + identifier: Optional[NodeId] = None, + keyed: bool = True, + accept_children: bool = True, + repr_: Optional[str] = None, + data: Any = None, + ): + + self._auto_generated_id: bool + identifier_: NodeId + + if identifier is None: + identifier_ = str(uuid.uuid4()) + self._auto_generated_id = True + else: + identifier_ = identifier + self._auto_generated_id = False - def __repr__(self) -> str: - return self.__str__() + super(AutoIdNode, self).__init__( + identifier=identifier_, + keyed=keyed, + accept_children=accept_children, + repr_=repr_, + data=data, + ) diff --git a/lighttree/tree.py b/lighttree/tree.py index ebde452..05aa765 100644 --- a/lighttree/tree.py +++ b/lighttree/tree.py @@ -11,6 +11,9 @@ cast, Dict, Any, + Generic, + TypeVar, + Iterator, ) from collections import defaultdict from operator import itemgetter @@ -22,12 +25,14 @@ # root has no key (None), keyed node has children with str keys, unkeyed node has children with int keys Key = Union[None, str, int] -KeyedNode = Tuple[Key, Node] KeyedTree = Tuple[Key, "Tree"] Path = str +GenericNode = TypeVar("GenericNode", bound=Node) +KeyedNode = Tuple[Key, GenericNode] -class Tree(object): + +class Tree(Generic[GenericNode]): """Principles: - each node is identified by an id @@ -49,7 +54,7 @@ def __init__(self, path_separator: str = ".") -> None: # nodes references and hierarchy in tree self.root: Optional[NodeId] = None # node identifier -> node - self._nodes_map: Dict[NodeId, Node] = {} + self._nodes_map: Dict[NodeId, GenericNode] = {} # node identifier -> parent node identifier self._nodes_parent: Dict[NodeId, Optional[NodeId]] = defaultdict(lambda: None) # "map" node identifier -> map of children nodes identifier -> key @@ -61,10 +66,6 @@ def __contains__(self, identifier: NodeId) -> bool: return identifier in self._nodes_map def get(self, nid: NodeId) -> KeyedNode: - """Get a node by its id. - :param nid: str, identifier of node to fetch - :rtype: lighttree.node.Node - """ self._ensure_present(nid) return self.get_key(nid), self._nodes_map[nid] @@ -128,13 +129,12 @@ def list( self, id_in: Optional[Sequence[NodeId]] = None, depth_in: Optional[Sequence[int]] = None, - filter_: Optional[Callable[[Node], bool]] = None, + filter_: Optional[Callable[[GenericNode], bool]] = None, ) -> List[KeyedNode]: """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 :param filter\_: function, optional, filtering function to apply to each node - :rtype: list of lighttree.node.Node """ return [ (self.get_key(nid), node) @@ -167,7 +167,7 @@ def _ensure_present( raise NotFoundNodeError("Node id <%s> doesn't exist in tree" % nid) return nid - def _validate_node_insertion(self, node: Node) -> None: + def _validate_node_insertion(self, node: GenericNode) -> None: if node.identifier in self._nodes_map.keys(): raise DuplicatedNodeError( "Can't create node with id '%s'" % node.identifier @@ -330,24 +330,12 @@ def leaves_ids(self, nid: Optional[NodeId] = None) -> List[NodeId]: def insert( self, - item: Union[Node, "Tree"], + item: Union[GenericNode, "Tree"], parent_id: Optional[NodeId] = None, child_id: Optional[NodeId] = None, child_id_below: Optional[NodeId] = None, key: Key = None, ) -> "Tree": - if isinstance(item, Node): - if child_id_below is not None: - raise ValueError( - '"child_id_below" parameter is reserved to Tree insertion.' - ) - self.insert_node( - node=item, - parent_id=parent_id, - child_id=child_id, - key=key, - ) - return self if isinstance(item, Tree): self.insert_tree( new_tree=item, @@ -357,13 +345,22 @@ def insert( key=key, ) return self - raise ValueError( - '"item" parameter must either be a Node, or a Tree, got <%s>.' % type(item) + # item is GenericNode + if child_id_below is not None: + raise ValueError( + '"child_id_below" parameter is reserved to Tree insertion.' + ) + self.insert_node( + node=item, + parent_id=parent_id, + child_id=child_id, + key=key, ) + return self def insert_node( self, - node: Node, + node: GenericNode, parent_id: Optional[NodeId] = None, child_id: Optional[NodeId] = None, key: Key = None, @@ -386,7 +383,7 @@ def insert_node( def _insert_node_below( self, - node: Node, + node: GenericNode, parent_id: Optional[NodeId], key: Key, ) -> None: @@ -433,7 +430,7 @@ def _insert_node_below( self._nodes_map[node_id] = node self._nodes_parent[node_id] = parent_id - def _insert_node_above(self, node: Node, child_id: NodeId, key: Key) -> None: + def _insert_node_above(self, node: GenericNode, child_id: NodeId, key: Key) -> None: self._ensure_present(child_id) # get parent_id before dropping subtree try: @@ -601,10 +598,10 @@ def expand_tree( self, nid: Optional[NodeId] = None, mode: str = "depth", - filter_: Optional[Callable[[Union[None, str, int], Node], bool]] = None, + filter_: Optional[Callable[[Union[None, str, int], GenericNode], bool]] = None, filter_through: bool = False, reverse: bool = False, - ) -> Iterable[KeyedNode]: + ) -> Iterator[KeyedNode]: """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 @@ -615,7 +612,6 @@ def expand_tree( :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 :return: node ids that satisfy the conditions if ``id_only`` is True, else nodes. - :rtype: generator """ if mode not in ("depth", "width"): raise NotImplementedError("Traversal mode '%s' is not supported" % mode) @@ -656,7 +652,7 @@ def expand_tree( def show( self, nid: Optional[NodeId] = None, - filter_: Optional[Callable[[Node], bool]] = None, + filter_: Optional[Callable[[GenericNode], bool]] = None, display_key: bool = True, reverse: bool = False, line_type: str = "ascii-ex", @@ -676,8 +672,6 @@ def show( :param limit: int, truncate tree display to this number of lines :param kwargs: kwargs params passed to node ``line_repr`` method :param line_max_length - :rtype: unicode in python2, str in python3 - """ output = "" @@ -714,10 +708,10 @@ def show( def _iter_nodes_with_location( self, nid: Optional[NodeId], - filter_: Optional[Callable[[Node], bool]], + filter_: Optional[Callable[[GenericNode], bool]], reverse: bool, is_last_list: Optional[List[bool]] = None, - ) -> Iterable[Tuple[Tuple[bool, ...], Key, Node]]: + ) -> Iterable[Tuple[Tuple[bool, ...], Key, GenericNode]]: """Yield nodes with information on how they are placed. :param nid: starting node identifier :param filter_: filter function applied on nodes From fb4b9b6731be423be633e8df30d94c5f6b986193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard=20Binet?= Date: Wed, 28 Jul 2021 09:22:02 +0200 Subject: [PATCH 2/4] v1.3.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d5be2aa..81ff3c4 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import setup -__version__ = "1.2.0" +__version__ = "1.3.0" develop_requires = [ "pre-commit", From ef1725c9e64c212370c8d5b4fd5242821fb5cf9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard=20Binet?= Date: Wed, 28 Jul 2021 09:52:47 +0200 Subject: [PATCH 3/4] Path now an iterator of Keys --- lighttree/implementations/json_tree.py | 29 ++++----- lighttree/tree.py | 82 ++++++++++++-------------- tests/test_tree.py | 20 +++---- tests/testing_utils.py | 4 +- 4 files changed, 61 insertions(+), 74 deletions(-) diff --git a/lighttree/implementations/json_tree.py b/lighttree/implementations/json_tree.py index 1fe19c0..46ab117 100644 --- a/lighttree/implementations/json_tree.py +++ b/lighttree/implementations/json_tree.py @@ -5,43 +5,34 @@ class JsonTree(Tree): - def __init__( - self, d: Optional[Dict] = None, strict: bool = True, path_separator: str = "." - ) -> None: + def __init__(self, d: Optional[Dict] = None, strict: bool = True) -> None: """ :param d: :param strict: if False, will convert tuples into arrays, else raise error - :param path_separator: separator used to build path """ - super(JsonTree, self).__init__(path_separator=path_separator) + super(JsonTree, self).__init__() if d is not None: self._fill(d, strict=strict, key=None) - @staticmethod - def _concat(a: Any, b: Any) -> str: - if not a and not b: - return "" - if not a: - return str(b) - return ".".join([str(a), str(b)]) - - def _fill(self, data: Any, key: Key, strict: bool, path: str = "") -> None: + def _fill( + self, data: Any, key: Optional[Key], strict: bool, path: Optional[List] = None + ) -> None: pid: Optional[NodeId] + path_: List = path or [] if self.is_empty(): pid = None else: - pid = self.get_node_id_by_path(path=path) + pid = self.get_node_id_by_path(path=path_) + if isinstance(data, list) or not strict and isinstance(data, tuple): k = self.insert_node(AutoIdNode(keyed=False), parent_id=pid, key=key) - path = self._concat(path, k) for el in data: - self._fill(el, strict=strict, path=path, key=None) + self._fill(el, strict=strict, path=path_ + ([k] if k else []), key=None) return if isinstance(data, dict): k = self.insert_node(AutoIdNode(keyed=True), key=key, parent_id=pid) - path = self._concat(path, k) for sk, el in data.items(): - self._fill(el, strict=strict, path=path, key=sk) + self._fill(el, strict=strict, path=path_ + ([k] if k else []), key=sk) return if isinstance(data, (str, int, float)): self.insert_node( diff --git a/lighttree/tree.py b/lighttree/tree.py index 05aa765..62fdaad 100644 --- a/lighttree/tree.py +++ b/lighttree/tree.py @@ -23,13 +23,14 @@ from .utils import STYLES -# root has no key (None), keyed node has children with str keys, unkeyed node has children with int keys -Key = Union[None, str, int] -KeyedTree = Tuple[Key, "Tree"] -Path = str +# keyed node has children with str keys, unkeyed node has children with int keys +# note: root has no key (None value) +Key = Union[str, int] +KeyedTree = Tuple[Optional[Key], "Tree"] +Path = Iterable[Key] GenericNode = TypeVar("GenericNode", bound=Node) -KeyedNode = Tuple[Key, GenericNode] +KeyedNode = Tuple[Optional[Key], GenericNode] class Tree(Generic[GenericNode]): @@ -48,9 +49,7 @@ class Tree(Generic[GenericNode]): """ - def __init__(self, path_separator: str = ".") -> None: - self.path_separator = path_separator - + def __init__(self) -> None: # nodes references and hierarchy in tree self.root: Optional[NodeId] = None # node identifier -> node @@ -79,37 +78,40 @@ def child_id(self, nid: NodeId, key: Key) -> NodeId: if child_id is None: raise ValueError("No child of key %s below %s" % (key, nid)) return child_id - if not isinstance(key, (str, int)): - raise ValueError("Expected integer 'castable' key, got %s" % key) - return self._nodes_children_list[nid][int(key)] + try: + return self._nodes_children_list[nid][int(key)] + except (KeyError, ValueError, TypeError): + raise ValueError("No child of key %s below %s" % (key, nid)) def child(self, nid: NodeId, key: Key) -> KeyedNode: return self.get(self.child_id(nid, key)) - def get_node_id_by_path(self, path: Path) -> NodeId: + def get_node_id_by_path(self, path: Path, strict: bool = True) -> NodeId: 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) + for k in path: + try: + nid = self.child_id(nid, k) + except ValueError: + if strict or not isinstance(k, str) or not k.isdigit(): + raise + nid = self.child_id(nid, int(k)) if nid is None: - raise ValueError("Empty tree") + raise ValueError("Not found") return nid def get_path(self, nid: NodeId) -> Path: - return self.path_separator.join( - [ - str(k) - for k, _ in self.ancestors(nid, from_root=True, include_current=True)[ - 1: - ] - ] - ) + return [ + # ignore typing warning of potential None value, since None only applies at root node which is excluded + # [1:] -> exclude root node key + k # type: ignore + for k, _ in self.ancestors(nid, from_root=True, include_current=True)[1:] + ] - def get_key(self, nid: NodeId) -> Key: + def get_key(self, nid: NodeId) -> Optional[Key]: """Get a node's key. :param nid: str, identifier of node @@ -334,7 +336,7 @@ def insert( parent_id: Optional[NodeId] = None, child_id: Optional[NodeId] = None, child_id_below: Optional[NodeId] = None, - key: Key = None, + key: Optional[Key] = None, ) -> "Tree": if isinstance(item, Tree): self.insert_tree( @@ -363,15 +365,8 @@ def insert_node( node: GenericNode, parent_id: Optional[NodeId] = None, child_id: Optional[NodeId] = None, - key: Key = None, - ) -> Key: - """Insert node, return key - :param node: - :param parent_id: - :param child_id: - :param key: - :return: - """ + key: Optional[Key] = None, + ) -> Optional[Key]: self._validate_node_insertion(node) if parent_id is not None and child_id is not None: raise ValueError('Can declare at most "parent_id" or "child_id"') @@ -385,7 +380,7 @@ def _insert_node_below( self, node: GenericNode, parent_id: Optional[NodeId], - key: Key, + key: Optional[Key], ) -> None: # insertion at root if parent_id is None: @@ -430,7 +425,9 @@ def _insert_node_below( self._nodes_map[node_id] = node self._nodes_parent[node_id] = parent_id - def _insert_node_above(self, node: GenericNode, child_id: NodeId, key: Key) -> None: + def _insert_node_above( + self, node: GenericNode, child_id: NodeId, key: Optional[Key] + ) -> None: self._ensure_present(child_id) # get parent_id before dropping subtree try: @@ -454,9 +451,8 @@ def insert_tree( parent_id: Optional[NodeId] = None, child_id: Optional[NodeId] = None, child_id_below: Optional[NodeId] = None, - key: Key = None, - ) -> Key: - """Return new key""" + key: Optional[Key] = None, + ) -> Optional[Key]: self._validate_tree_insertion(new_tree) if new_tree.root is None: raise ValueError("Empty inserted tree") @@ -480,7 +476,7 @@ def _insert_tree_below( self, new_tree: "Tree", parent_id: Optional[NodeId], - key: Key, + key: Optional[Key], ) -> "Tree": if parent_id is None: # insertion at root requires tree to be empty @@ -506,7 +502,7 @@ def _insert_tree_above( new_tree: "Tree", child_id: NodeId, child_id_below: Optional[NodeId], - key: Key, + key: Optional[Key], ) -> None: # make all checks before modifying tree self._ensure_present(child_id) @@ -711,7 +707,7 @@ def _iter_nodes_with_location( filter_: Optional[Callable[[GenericNode], bool]], reverse: bool, is_last_list: Optional[List[bool]] = None, - ) -> Iterable[Tuple[Tuple[bool, ...], Key, GenericNode]]: + ) -> Iterable[Tuple[Tuple[bool, ...], Optional[Key], GenericNode]]: """Yield nodes with information on how they are placed. :param nid: starting node identifier :param filter_: filter function applied on nodes diff --git a/tests/test_tree.py b/tests/test_tree.py index dda3b09..389a453 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -883,16 +883,16 @@ def test_drop_subtree(self): def test_get_node_id_by_path(self): t = get_sample_tree() - self.assertEqual(t.get_node_id_by_path("a"), "a") - self.assertEqual(t.get_node_id_by_path("a.b"), "ab") - self.assertEqual(t.get_node_id_by_path("a.a.1"), "aa1") - self.assertEqual(t.get_node_id_by_path("c.1"), "c1") + self.assertEqual(t.get_node_id_by_path(["a"]), "a") + self.assertEqual(t.get_node_id_by_path(["a", "b"]), "ab") + self.assertEqual(t.get_node_id_by_path(["a", "a", 1]), "aa1") + self.assertEqual(t.get_node_id_by_path(["c", 1]), "c1") def test_subtree(self): t = get_sample_tree() # by id - nid = t.get_node_id_by_path("a.a") + nid = t.get_node_id_by_path(["a", "a"]) k, st = t.subtree(nid=nid) self.assertEqual(k, "a") self.assertEqual( @@ -905,11 +905,11 @@ def test_subtree(self): def test_path(self): t = get_sample_tree() - for p in ("a.a", "a.b", "a", "", "a.a.1"): + for p in [["a", "a"], ["a", "b"], ["a"], [], ["a", "a", 1]]: nid = t.get_node_id_by_path(p) self.assertEqual(t.get_path(nid), p) - t = get_sample_tree(path_separator="|") - for p in ("a|a", "a|b", "a", "", "a|a|1"): - nid = t.get_node_id_by_path(p) - self.assertEqual(t.get_path(nid), p) + # strict = False -> coerce "1" -> int + t = get_sample_tree() + nid = t.get_node_id_by_path(["a", "a", "1"]) + self.assertEqual(t.get_path(nid), ["a", "a", 1]) diff --git a/tests/testing_utils.py b/tests/testing_utils.py index 9b3394f..89ecacd 100644 --- a/tests/testing_utils.py +++ b/tests/testing_utils.py @@ -42,7 +42,7 @@ def tree_sanity_check(tree): # testing samples -def get_sample_tree(path_separator="."): +def get_sample_tree(): """ root {} ├── a {} @@ -54,7 +54,7 @@ def get_sample_tree(path_separator="."): ├── c0 └── c1 """ - t = Tree(path_separator=path_separator) + t = Tree() t.insert_node(Node(identifier="root")) t.insert_node(Node(identifier="a"), parent_id="root", key="a") t.insert_node(Node(identifier="aa", keyed=False), parent_id="a", key="a") From 7f79e4b7d789bfcdc2befccb15f8b69dc7a07a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard=20Binet?= Date: Wed, 28 Jul 2021 10:05:30 +0200 Subject: [PATCH 4/4] mypy auto install types --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3d49f96..0c75fee 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -40,7 +40,7 @@ jobs: - name: Mypy check run: | pip install mypy - mypy lighttree + mypy --install-types --non-interactive lighttree - name: Test with pytest run: | pytest