Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.18.2] - 2024-06-01
### Changed:
- Tree Search: Standardize handling of singular and plural search.
- Tree Search: Added `find_relative_path` that return a single node from search and
rename existing `find_relative_path` to `find_relative_paths`.
**This might not be backwards-compatible!**

## [0.18.1] - 2024-05-30
### Changed:
- Misc: Remove support of Python 3.7 due to incompatibility with polars.
Expand Down Expand Up @@ -572,7 +579,8 @@ ignore null attribute columns.
- Utility Iterator: Tree traversal methods.
- Workflow To Do App: Tree use case with to-do list implementation.

[Unreleased]: https://github.com/kayjan/bigtree/compare/0.18.1...HEAD
[Unreleased]: https://github.com/kayjan/bigtree/compare/0.18.2...HEAD
[0.18.2]: https://github.com/kayjan/bigtree/compare/0.18.1...0.18.2
[0.18.1]: https://github.com/kayjan/bigtree/compare/0.18.0...0.18.1
[0.18.0]: https://github.com/kayjan/bigtree/compare/0.17.2...0.18.0
[0.17.2]: https://github.com/kayjan/bigtree/compare/0.17.1...0.17.2
Expand Down
3 changes: 2 additions & 1 deletion bigtree/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "0.18.1"
__version__ = "0.18.2"

from bigtree.binarytree.construct import list_to_binarytree
from bigtree.dag.construct import dataframe_to_dag, dict_to_dag, list_to_dag
Expand Down Expand Up @@ -63,6 +63,7 @@
find_path,
find_paths,
find_relative_path,
find_relative_paths,
findall,
)
from bigtree.utils.groot import speak_like_groot, whoami
Expand Down
138 changes: 97 additions & 41 deletions bigtree/tree/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"find_name",
"find_names",
"find_relative_path",
"find_relative_paths",
"find_full_path",
"find_path",
"find_paths",
Expand All @@ -28,6 +29,28 @@
DAGNodeT = TypeVar("DAGNodeT", bound=DAGNode)


def __check_result_count(
result: Tuple[Any, ...], min_count: int, max_count: int
) -> None:
"""Check result fulfil min_count and max_count requirements

Args:
result (Tuple[Any]): result of search
min_count (int): checks for minimum number of occurrences,
raise SearchError if the number of results do not meet min_count, defaults to None
max_count (int): checks for maximum number of occurrences,
raise SearchError if the number of results do not meet min_count, defaults to None
"""
if min_count and len(result) < min_count:
raise SearchError(
f"Expected more than or equal to {min_count} element(s), found {len(result)} elements\n{result}"
)
if max_count and len(result) > max_count:
raise SearchError(
f"Expected less than or equal to {max_count} element(s), found {len(result)} elements\n{result}"
)


def findall(
tree: T,
condition: Callable[[T], bool],
Expand All @@ -36,7 +59,7 @@ def findall(
max_count: int = 0,
) -> Tuple[T, ...]:
"""
Search tree for nodes matching condition (callable function).
Search tree for one or more nodes matching condition (callable function).

Examples:
>>> from bigtree import Node, findall
Expand All @@ -60,20 +83,13 @@ def findall(
(Tuple[BaseNode, ...])
"""
result = tuple(preorder_iter(tree, filter_condition=condition, max_depth=max_depth))
if min_count and len(result) < min_count:
raise SearchError(
f"Expected more than {min_count} element(s), found {len(result)} elements\n{result}"
)
if max_count and len(result) > max_count:
raise SearchError(
f"Expected less than {max_count} element(s), found {len(result)} elements\n{result}"
)
__check_result_count(result, min_count, max_count)
return result


def find(tree: T, condition: Callable[[T], bool], max_depth: int = 0) -> T:
"""
Search tree for *single node* matching condition (callable function).
Search tree for a single node matching condition (callable function).

Examples:
>>> from bigtree import Node, find
Expand All @@ -86,7 +102,7 @@ def find(tree: T, condition: Callable[[T], bool], max_depth: int = 0) -> T:
>>> find(root, lambda node: node.age > 5)
Traceback (most recent call last):
...
bigtree.utils.exceptions.SearchError: Expected less than 1 element(s), found 4 elements
bigtree.utils.exceptions.SearchError: Expected less than or equal to 1 element(s), found 4 elements
(Node(/a, age=90), Node(/a/b, age=65), Node(/a/c, age=60), Node(/a/c/d, age=40))

Args:
Expand All @@ -104,7 +120,7 @@ def find(tree: T, condition: Callable[[T], bool], max_depth: int = 0) -> T:

def find_name(tree: NodeT, name: str, max_depth: int = 0) -> NodeT:
"""
Search tree for single node matching name attribute.
Search tree for a single node matching name attribute.

Examples:
>>> from bigtree import Node, find_name
Expand All @@ -128,7 +144,7 @@ def find_name(tree: NodeT, name: str, max_depth: int = 0) -> NodeT:

def find_names(tree: NodeT, name: str, max_depth: int = 0) -> Iterable[NodeT]:
"""
Search tree for multiple node(s) matching name attribute.
Search tree for one or more nodes matching name attribute.

Examples:
>>> from bigtree import Node, find_names
Expand All @@ -152,9 +168,9 @@ def find_names(tree: NodeT, name: str, max_depth: int = 0) -> Iterable[NodeT]:
return findall(tree, lambda node: node.node_name == name, max_depth)


def find_relative_path(tree: NodeT, path_name: str) -> Iterable[NodeT]:
def find_relative_path(tree: NodeT, path_name: str) -> NodeT:
r"""
Search tree for single node matching relative path attribute.
Search tree for a single node matching relative path attribute.

- Supports unix folder expression for relative path, i.e., '../../node_name'
- Supports wildcards, i.e., '\*/node_name'
Expand All @@ -167,18 +183,64 @@ def find_relative_path(tree: NodeT, path_name: str) -> Iterable[NodeT]:
>>> c = Node("c", age=60, parent=root)
>>> d = Node("d", age=40, parent=c)
>>> find_relative_path(d, "..")
(Node(/a/c, age=60),)
Node(/a/c, age=60)
>>> find_relative_path(d, "../../b")
(Node(/a/b, age=65),)
Node(/a/b, age=65)
>>> find_relative_path(d, "../../*")
Traceback (most recent call last):
...
bigtree.utils.exceptions.SearchError: Expected less than or equal to 1 element(s), found 2 elements
(Node(/a/b, age=65), Node(/a/c, age=60))

Args:
tree (Node): tree to search
path_name (str): value to match (relative path) of path_name attribute

Returns:
(Iterable[Node])
(Node)
"""
result = find_relative_paths(tree, path_name, max_count=1)

if result:
return result[0]


def find_relative_paths(
tree: NodeT,
path_name: str,
min_count: int = 0,
max_count: int = 0,
) -> Tuple[NodeT, ...]:
r"""
Search tree for one or more nodes matching relative path attribute.

- Supports unix folder expression for relative path, i.e., '../../node_name'
- Supports wildcards, i.e., '\*/node_name'
- If path name starts with leading separator symbol, it will start at root node.

Examples:
>>> from bigtree import Node, find_relative_paths
>>> root = Node("a", age=90)
>>> b = Node("b", age=65, parent=root)
>>> c = Node("c", age=60, parent=root)
>>> d = Node("d", age=40, parent=c)
>>> find_relative_paths(d, "..")
(Node(/a/c, age=60),)
>>> find_relative_paths(d, "../../b")
(Node(/a/b, age=65),)
>>> find_relative_paths(d, "../../*")
(Node(/a/b, age=65), Node(/a/c, age=60))

Args:
tree (Node): tree to search
path_name (str): value to match (relative path) of path_name attribute
min_count (int): checks for minimum number of occurrences,
raise SearchError if the number of results do not meet min_count, defaults to None
max_count (int): checks for maximum number of occurrences,
raise SearchError if the number of results do not meet min_count, defaults to None

Returns:
(Tuple[Node, ...])
"""
sep = tree.sep
if path_name.startswith(sep):
Expand Down Expand Up @@ -220,13 +282,14 @@ def resolve(node: NodeT, path_idx: int) -> None:
resolve(node, path_idx + 1)

resolve(tree, 0)

return tuple(resolved_nodes)
result = tuple(resolved_nodes)
__check_result_count(result, min_count, max_count)
return result


def find_full_path(tree: NodeT, path_name: str) -> NodeT:
"""
Search tree for single node matching path attribute.
Search tree for a single node matching path attribute.

- Path name can be with or without leading tree path separator symbol.
- Path name must be full path, works similar to `find_path` but faster.
Expand Down Expand Up @@ -265,7 +328,7 @@ def find_full_path(tree: NodeT, path_name: str) -> NodeT:

def find_path(tree: NodeT, path_name: str) -> NodeT:
"""
Search tree for single node matching path attribute.
Search tree for a single node matching path attribute.

- Path name can be with or without leading tree path separator symbol.
- Path name can be full path or partial path (trailing part of path) or node name.
Expand All @@ -292,9 +355,9 @@ def find_path(tree: NodeT, path_name: str) -> NodeT:
return find(tree, lambda node: node.path_name.endswith(path_name))


def find_paths(tree: NodeT, path_name: str) -> Tuple[NodeT, ...]:
def find_paths(tree: NodeT, path_name: str) -> Iterable[NodeT]:
"""
Search tree for multiple nodes matching path attribute.
Search tree for one or more nodes matching path attribute.

- Path name can be with or without leading tree path separator symbol.
- Path name can be partial path (trailing part of path) or node name.
Expand All @@ -315,7 +378,7 @@ def find_paths(tree: NodeT, path_name: str) -> Tuple[NodeT, ...]:
path_name (str): value to match (full path) or trailing part (partial path) of path_name attribute

Returns:
(Tuple[Node, ...])
(Iterable[Node])
"""
path_name = path_name.rstrip(tree.sep)
return findall(tree, lambda node: node.path_name.endswith(path_name))
Expand All @@ -325,7 +388,7 @@ def find_attr(
tree: BaseNode, attr_name: str, attr_value: Any, max_depth: int = 0
) -> BaseNode:
"""
Search tree for single node matching custom attribute.
Search tree for a single node matching custom attribute.

Examples:
>>> from bigtree import Node, find_attr
Expand Down Expand Up @@ -354,9 +417,9 @@ def find_attr(

def find_attrs(
tree: BaseNode, attr_name: str, attr_value: Any, max_depth: int = 0
) -> Tuple[BaseNode, ...]:
) -> Iterable[BaseNode]:
"""
Search tree for node(s) matching custom attribute.
Search tree for one or more nodes matching custom attribute.

Examples:
>>> from bigtree import Node, find_attrs
Expand All @@ -374,7 +437,7 @@ def find_attrs(
max_depth (int): maximum depth to search for, based on the `depth` attribute, defaults to None

Returns:
(Tuple[BaseNode, ...])
(Iterable[BaseNode])
"""
return findall(
tree,
Expand All @@ -390,7 +453,7 @@ def find_children(
max_count: int = 0,
) -> Tuple[Union[T, DAGNodeT], ...]:
"""
Search children for nodes matching condition (callable function).
Search children for one or more nodes matching condition (callable function).

Examples:
>>> from bigtree import Node, find_children
Expand All @@ -410,17 +473,10 @@ def find_children(
raise SearchError if the number of results do not meet min_count, defaults to None

Returns:
(BaseNode/DAGNode)
(Tuple[Union[BaseNode, DAGNode], ...])
"""
result = tuple([node for node in tree.children if node and condition(node)])
if min_count and len(result) < min_count:
raise SearchError(
f"Expected more than {min_count} element(s), found {len(result)} elements\n{result}"
)
if max_count and len(result) > max_count:
raise SearchError(
f"Expected less than {max_count} element(s), found {len(result)} elements\n{result}"
)
__check_result_count(result, min_count, max_count)
return result


Expand All @@ -429,7 +485,7 @@ def find_child(
condition: Callable[[Union[T, DAGNodeT]], bool],
) -> Union[T, DAGNodeT]:
"""
Search children for *single node* matching condition (callable function).
Search children for a single node matching condition (callable function).

Examples:
>>> from bigtree import Node, find_child
Expand All @@ -456,7 +512,7 @@ def find_child_by_name(
tree: Union[NodeT, DAGNodeT], name: str
) -> Union[NodeT, DAGNodeT]:
"""
Search tree for single node matching name attribute.
Search tree for a single node matching name attribute.

Examples:
>>> from bigtree import Node, find_child_by_name
Expand Down
12 changes: 6 additions & 6 deletions docs/bigtree/tree/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ title: Tree Search

Search methods for Trees.

| Search by | One node | One or more nodes |
|---------------------|-----------------------------------------------------|------------------------------------|
| General method | `find`, `find_child` | `findall`, `find_children` |
| Node name | `find_name`, `find_child_by_name` | `find_names` |
| Node path | `find_path`, `find_full_path`, `find_relative_path` | `find_paths`, `find_relative_path` |
| Node attributes | `find_attr` | `find_attrs` |
| Search by | One node | One or more nodes |
|---------------------|-----------------------------------------------------|-------------------------------------|
| General method | `find`, `find_child` | `findall`, `find_children` |
| Node name | `find_name`, `find_child_by_name` | `find_names` |
| Node path | `find_path`, `find_full_path`, `find_relative_path` | `find_paths`, `find_relative_paths` |
| Node attributes | `find_attr` | `find_attrs` |

-----

Expand Down
6 changes: 3 additions & 3 deletions docs/demo/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ it does not require traversing the whole tree to find the node(s).
# Node(/a/c/d, age=40)

find_relative_path(c, "../b") # relative path
# (Node(/a/b, age=65),)
# Node(/a/b, age=65)

find_path(root, "/c/d") # partial path
# Node(/a/c/d, age=40)
Expand All @@ -743,7 +743,7 @@ it does not require traversing the whole tree to find the node(s).

=== "Find multiple nodes"
```python hl_lines="12 15 18 21 24"
from bigtree import Node, findall, find_names, find_relative_path, find_paths, find_attrs
from bigtree import Node, findall, find_names, find_relative_paths, find_paths, find_attrs
root = Node("a", age=90)
b = Node("b", age=65, parent=root)
c = Node("c", age=60, parent=root)
Expand All @@ -760,7 +760,7 @@ it does not require traversing the whole tree to find the node(s).
find_names(root, "c")
# (Node(/a/c, age=60), Node(/a/c/c, age=40))

find_relative_path(c, "../*") # relative path
find_relative_paths(c, "../*") # relative path
# (Node(/a/b, age=65), Node(/a/c, age=60))

find_paths(root, "/c") # partial path
Expand Down
8 changes: 6 additions & 2 deletions tests/test_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,12 @@ class Constants:
"Path {path_name} does not match the root node name {root_name}"
)

ERROR_SEARCH_LESS_THAN_N_ELEMENT = "Expected less than {count} element(s), found "
ERROR_SEARCH_MORE_THAN_N_ELEMENT = "Expected more than {count} element(s), found "
ERROR_SEARCH_LESS_THAN_N_ELEMENT = (
"Expected less than or equal to {count} element(s), found "
)
ERROR_SEARCH_MORE_THAN_N_ELEMENT = (
"Expected more than or equal to {count} element(s), found "
)

# workflow/todo
ERROR_WORKFLOW_TODO_TYPE = "Invalid data type for item"
Loading