diff --git a/CHANGELOG.md b/CHANGELOG.md index b979efab..f75b5fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.0] - 2023-09-09 +### Changed +- Tree/DAG Constructor, Tree/DAG Exporter: Make `pandas` optional dependency. +### Fixed +- Misc: Fixed Calendar workflow to throw error when `to_dataframe` method is called on empty calendar. +- Tree/DAGNode Exporter, Tree Helper, Tree Search: Relax type hinting using TypeVar. + ## [0.11.0] - 2023-09-08 ### Added - Tree Helper: Pruning tree to allow pruning by `prune_path` and `max_depth`. @@ -303,6 +310,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Utility Iterator: Tree traversal methods. - Workflow To Do App: Tree use case with to-do list implementation. +[0.12.0]: https://github.com/kayjan/bigtree/compare/0.11.0...0.12.0 [0.11.0]: https://github.com/kayjan/bigtree/compare/0.10.3...0.11.0 [0.10.3]: https://github.com/kayjan/bigtree/compare/0.10.2...0.10.3 [0.10.2]: https://github.com/kayjan/bigtree/compare/0.10.1...0.10.2 diff --git a/README.md b/README.md index f2d3c908..b90071bc 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,13 @@ To install `bigtree`, run the following line in command prompt: $ pip install bigtree ``` +If tree needs to use pandas methods, it requires additional dependencies. +Run the following line in command prompt: + +```shell +$ pip install 'bigtree[pandas]' +``` + If tree needs to be exported to image, it requires additional dependencies. Run the following lines in command prompt: @@ -110,6 +117,12 @@ $ brew install gprof2dot # for MacOS $ conda install graphviz # for Windows ``` +Alternatively, install all optional dependencies with the following line in command prompt: + +```shell +$ pip install 'bigtree[all]' +``` + ---- ## Tree Demonstration diff --git a/bigtree/__init__.py b/bigtree/__init__.py index 53e07378..a97dbf8c 100644 --- a/bigtree/__init__.py +++ b/bigtree/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.11.0" +__version__ = "0.12.0" from bigtree.binarytree.construct import list_to_binarytree from bigtree.dag.construct import dataframe_to_dag, dict_to_dag, list_to_dag diff --git a/bigtree/dag/construct.py b/bigtree/dag/construct.py index 9fb0d3cd..1ad157a5 100644 --- a/bigtree/dag/construct.py +++ b/bigtree/dag/construct.py @@ -1,12 +1,19 @@ -from typing import Any, Dict, List, Tuple, Type +from __future__ import annotations -import pandas as pd +from typing import Any, Dict, List, Tuple, Type from bigtree.node.dagnode import DAGNode +from bigtree.utils.exceptions import optional_dependencies_pandas + +try: + import pandas as pd +except ImportError: # pragma: no cover + pd = None __all__ = ["list_to_dag", "dict_to_dag", "dataframe_to_dag"] +@optional_dependencies_pandas def list_to_dag( relations: List[Tuple[str, str]], node_type: Type[DAGNode] = DAGNode, @@ -85,6 +92,7 @@ def dict_to_dag( ) +@optional_dependencies_pandas def dataframe_to_dag( data: pd.DataFrame, child_col: str = "", diff --git a/bigtree/dag/export.py b/bigtree/dag/export.py index 4f6ee265..463fb3a4 100644 --- a/bigtree/dag/export.py +++ b/bigtree/dag/export.py @@ -1,15 +1,32 @@ -from typing import Any, Dict, List, Tuple, Union +from __future__ import annotations -import pandas as pd +from typing import Any, Dict, List, Tuple, TypeVar, Union from bigtree.node.dagnode import DAGNode +from bigtree.utils.exceptions import ( + optional_dependencies_image, + optional_dependencies_pandas, +) from bigtree.utils.iterators import dag_iterator +try: + import pandas as pd +except ImportError: # pragma: no cover + pd = None + +try: + import pydot +except ImportError: # pragma: no cover + pydot = None + __all__ = ["dag_to_list", "dag_to_dict", "dag_to_dataframe", "dag_to_dot"] +T = TypeVar("T", bound=DAGNode) + + def dag_to_list( - dag: DAGNode, + dag: T, ) -> List[Tuple[str, str]]: """Export DAG to list of tuple containing parent-child names @@ -35,7 +52,7 @@ def dag_to_list( def dag_to_dict( - dag: DAGNode, + dag: T, parent_key: str = "parents", attr_dict: Dict[str, str] = {}, all_attrs: bool = False, @@ -95,8 +112,9 @@ def dag_to_dict( return data_dict +@optional_dependencies_pandas def dag_to_dataframe( - dag: DAGNode, + dag: T, name_col: str = "name", parent_col: str = "parent", attr_dict: Dict[str, str] = {}, @@ -160,8 +178,9 @@ def dag_to_dataframe( return pd.DataFrame(data_list).drop_duplicates().reset_index(drop=True) -def dag_to_dot( # type: ignore[no-untyped-def] - dag: Union[DAGNode, List[DAGNode]], +@optional_dependencies_image("pydot") +def dag_to_dot( + dag: Union[T, List[T]], rankdir: str = "TB", bg_colour: str = "", node_colour: str = "", @@ -169,7 +188,7 @@ def dag_to_dot( # type: ignore[no-untyped-def] edge_colour: str = "", node_attr: str = "", edge_attr: str = "", -): +) -> pydot.Dot: r"""Export DAG tree or list of DAG trees to image. Note that node names must be unique. Posible node attributes include style, fillcolor, shape. @@ -208,13 +227,6 @@ def dag_to_dot( # type: ignore[no-untyped-def] Returns: (pydot.Dot) """ - try: - import pydot - except ImportError: # pragma: no cover - raise ImportError( - "pydot not available. Please perform a\n\npip install 'bigtree[image]'\n\nto install required dependencies" - ) - # Get style if bg_colour: graph_style = dict(bgcolor=bg_colour) diff --git a/bigtree/tree/construct.py b/bigtree/tree/construct.py index 6c39ebdb..2624deb2 100644 --- a/bigtree/tree/construct.py +++ b/bigtree/tree/construct.py @@ -1,13 +1,22 @@ +from __future__ import annotations + import re from collections import OrderedDict from typing import Any, Dict, Iterable, List, Optional, Tuple, Type -import pandas as pd - from bigtree.node.node import Node from bigtree.tree.export import tree_to_dataframe from bigtree.tree.search import find_child_by_name, find_name -from bigtree.utils.exceptions import DuplicatedNodeError, TreeError +from bigtree.utils.exceptions import ( + DuplicatedNodeError, + TreeError, + optional_dependencies_pandas, +) + +try: + import pandas as pd +except ImportError: # pragma: no cover + pd = None __all__ = [ "add_path_to_tree", @@ -172,6 +181,7 @@ def add_dict_to_tree_by_path( return tree_root +@optional_dependencies_pandas def add_dict_to_tree_by_name( tree: Node, name_attrs: Dict[str, Dict[str, Any]], join_type: str = "left" ) -> Node: @@ -320,6 +330,7 @@ def add_dataframe_to_tree_by_path( return tree_root +@optional_dependencies_pandas def add_dataframe_to_tree_by_name( tree: Node, data: pd.DataFrame, @@ -541,6 +552,7 @@ def list_to_tree( return root_node +@optional_dependencies_pandas def list_to_tree_by_relation( relations: Iterable[Tuple[str, str]], allow_duplicates: bool = False, @@ -587,6 +599,7 @@ def list_to_tree_by_relation( ) +@optional_dependencies_pandas def dict_to_tree( path_attrs: Dict[str, Any], sep: str = "/", diff --git a/bigtree/tree/export.py b/bigtree/tree/export.py index ba4ff322..92d85ddc 100644 --- a/bigtree/tree/export.py +++ b/bigtree/tree/export.py @@ -1,12 +1,32 @@ -import collections -from typing import Any, Dict, Iterable, List, Optional, Tuple, Union +from __future__ import annotations -import pandas as pd +import collections +from typing import Any, Dict, Iterable, List, Optional, Tuple, TypeVar, Union from bigtree.node.node import Node from bigtree.tree.search import find_path +from bigtree.utils.exceptions import ( + optional_dependencies_image, + optional_dependencies_pandas, +) from bigtree.utils.iterators import preorder_iter +try: + import pandas as pd +except ImportError: # pragma: no cover + pd = None + +try: + import pydot +except ImportError: # pragma: no cover + pydot = None + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: # pragma: no cover + Image = ImageDraw = ImageFont = None + + __all__ = [ "print_tree", "yield_tree", @@ -17,6 +37,9 @@ "tree_to_pillow", ] +T = TypeVar("T", bound=Node) + + available_styles = { "ansi": ("| ", "|-- ", "`-- "), "ascii": ("| ", "|-- ", "+-- "), @@ -29,7 +52,7 @@ def print_tree( - tree: Node, + tree: T, node_name_or_path: str = "", max_depth: int = 0, all_attrs: bool = False, @@ -188,12 +211,12 @@ def print_tree( def yield_tree( - tree: Node, + tree: T, node_name_or_path: str = "", max_depth: int = 0, style: str = "const", custom_style: List[str] = [], -) -> Iterable[Tuple[str, str, Node]]: +) -> Iterable[Tuple[str, str, T]]: """Generator method for customizing printing of tree, starting from `tree`. - Able to select which node to print from, resulting in a subtree, using `node_name_or_path` @@ -358,7 +381,7 @@ def yield_tree( def tree_to_dict( - tree: Node, + tree: T, name_key: str = "name", parent_key: str = "", attr_dict: Dict[str, str] = {}, @@ -404,7 +427,7 @@ def tree_to_dict( tree = tree.copy() data_dict = {} - def recursive_append(node: Node) -> None: + def recursive_append(node: T) -> None: if node: if ( (not max_depth or node.depth <= max_depth) @@ -439,7 +462,7 @@ def recursive_append(node: Node) -> None: def tree_to_nested_dict( - tree: Node, + tree: T, name_key: str = "name", child_key: str = "children", attr_dict: Dict[str, str] = {}, @@ -476,7 +499,7 @@ def tree_to_nested_dict( tree = tree.copy() data_dict: Dict[str, List[Dict[str, Any]]] = {} - def recursive_append(node: Node, parent_dict: Dict[str, Any]) -> None: + def recursive_append(node: T, parent_dict: Dict[str, Any]) -> None: if node: if not max_depth or node.depth <= max_depth: data_child = {name_key: node.node_name} @@ -503,8 +526,9 @@ def recursive_append(node: Node, parent_dict: Dict[str, Any]) -> None: return data_dict[child_key][0] +@optional_dependencies_pandas def tree_to_dataframe( - tree: Node, + tree: T, path_col: str = "path", name_col: str = "name", parent_col: str = "", @@ -559,7 +583,7 @@ def tree_to_dataframe( tree = tree.copy() data_list = [] - def recursive_append(node: Node) -> None: + def recursive_append(node: T) -> None: if node: if ( (not max_depth or node.depth <= max_depth) @@ -592,8 +616,9 @@ def recursive_append(node: Node) -> None: return pd.DataFrame(data_list) -def tree_to_dot( # type: ignore[no-untyped-def] - tree: Union[Node, List[Node]], +@optional_dependencies_image("pydot") +def tree_to_dot( + tree: Union[T, List[T]], directed: bool = True, rankdir: str = "TB", bg_colour: str = "", @@ -602,7 +627,7 @@ def tree_to_dot( # type: ignore[no-untyped-def] edge_colour: str = "", node_attr: str = "", edge_attr: str = "", -): +) -> pydot.Dot: r"""Export tree or list of trees to image. Posible node attributes include style, fillcolor, shape. @@ -673,13 +698,6 @@ def tree_to_dot( # type: ignore[no-untyped-def] Returns: (pydot.Dot) """ - try: - import pydot - except ImportError: # pragma: no cover - raise ImportError( - "pydot not available. Please perform a\n\npip install 'bigtree[image]'\n\nto install required dependencies" - ) - # Get style if bg_colour: graph_style = dict(bgcolor=bg_colour) @@ -720,7 +738,7 @@ def tree_to_dot( # type: ignore[no-untyped-def] name_dict: Dict[str, List[str]] = collections.defaultdict(list) def recursive_create_node_and_edges( - parent_name: Optional[str], child_node: Node + parent_name: Optional[str], child_node: T ) -> None: _node_style = node_style.copy() _edge_style = edge_style.copy() @@ -748,8 +766,9 @@ def recursive_create_node_and_edges( return _graph -def tree_to_pillow( # type: ignore[no-untyped-def] - tree: Node, +@optional_dependencies_image("Pillow") +def tree_to_pillow( + tree: T, width: int = 0, height: int = 0, start_pos: Tuple[int, int] = (10, 10), @@ -758,7 +777,7 @@ def tree_to_pillow( # type: ignore[no-untyped-def] font_colour: Union[Tuple[int, int, int], str] = "black", bg_colour: Union[Tuple[int, int, int], str] = "white", **kwargs: Any, -): +) -> Image.Image: """Export tree to image (JPG, PNG). Image will be similar format as `print_tree`, accepts additional keyword arguments as input to `yield_tree` @@ -788,13 +807,6 @@ def tree_to_pillow( # type: ignore[no-untyped-def] Returns: (PIL.Image.Image) """ - try: - from PIL import Image, ImageDraw, ImageFont - except ImportError: # pragma: no cover - raise ImportError( - "Pillow not available. Please perform a\n\npip install 'bigtree[image]'\n\nto install required dependencies" - ) - # Initialize font font = ImageFont.truetype(font_family, font_size) diff --git a/bigtree/tree/helper.py b/bigtree/tree/helper.py index f3777f9a..18b89c6e 100644 --- a/bigtree/tree/helper.py +++ b/bigtree/tree/helper.py @@ -1,4 +1,4 @@ -from typing import Type, Union +from typing import Type, TypeVar, Union from bigtree.node.basenode import BaseNode from bigtree.node.binarynode import BinaryNode @@ -10,6 +10,9 @@ from bigtree.utils.iterators import levelordergroup_iter __all__ = ["clone_tree", "prune_tree", "get_tree_diff"] +BaseNodeT = TypeVar("BaseNodeT", bound=BaseNode) +BinaryNodeT = TypeVar("BinaryNodeT", bound=BinaryNode) +NodeT = TypeVar("NodeT", bound=Node) def clone_tree(tree: BaseNode, node_type: Type[BaseNode]) -> BaseNode: @@ -51,11 +54,11 @@ def recursive_add_child(_new_parent_node: BaseNode, _parent_node: BaseNode) -> N def prune_tree( - tree: Union[BinaryNode, Node], + tree: Union[BinaryNodeT, NodeT], prune_path: str = "", sep: str = "/", max_depth: int = 0, -) -> Union[BinaryNode, Node]: +) -> Union[BinaryNodeT, NodeT]: """Prune tree by path or depth, returns the root of a *copy* of the original tree. For pruning by `prune_path`, diff --git a/bigtree/tree/search.py b/bigtree/tree/search.py index 1dcc1ad2..9d14a2a4 100644 --- a/bigtree/tree/search.py +++ b/bigtree/tree/search.py @@ -23,6 +23,7 @@ T = TypeVar("T", bound=BaseNode) +NodeT = TypeVar("NodeT", bound=Node) def findall( @@ -97,7 +98,7 @@ def find(tree: T, condition: Callable[[T], bool], max_depth: int = 0) -> T: return result[0] -def find_name(tree: Node, name: str, max_depth: int = 0) -> Node: +def find_name(tree: NodeT, name: str, max_depth: int = 0) -> NodeT: """ Search tree for single node matching name attribute. @@ -120,7 +121,7 @@ def find_name(tree: Node, name: str, max_depth: int = 0) -> Node: return find(tree, lambda node: node.node_name == name, max_depth) -def find_names(tree: Node, name: str, max_depth: int = 0) -> Iterable[Node]: +def find_names(tree: NodeT, name: str, max_depth: int = 0) -> Iterable[NodeT]: """ Search tree for multiple node(s) matching name attribute. @@ -145,7 +146,7 @@ def find_names(tree: Node, name: str, max_depth: int = 0) -> Iterable[Node]: return findall(tree, lambda node: node.node_name == name, max_depth) -def find_relative_path(tree: Node, path_name: str) -> Iterable[Node]: +def find_relative_path(tree: NodeT, path_name: str) -> Iterable[NodeT]: """ Search tree for single node matching relative path attribute. - Supports unix folder expression for relative path, i.e., '../../node_name' @@ -178,9 +179,9 @@ def find_relative_path(tree: Node, path_name: str) -> Iterable[Node]: path_name = path_name.rstrip(sep).lstrip(sep) path_list = path_name.split(sep) wildcard_indicator = "*" in path_name - resolved_nodes: List[Node] = [] + resolved_nodes: List[NodeT] = [] - def resolve(node: Node, path_idx: int) -> None: + def resolve(node: NodeT, path_idx: int) -> None: """Resolve node based on path name Args: @@ -215,7 +216,7 @@ def resolve(node: Node, path_idx: int) -> None: return tuple(resolved_nodes) -def find_full_path(tree: Node, path_name: str) -> Node: +def find_full_path(tree: NodeT, path_name: str) -> NodeT: """ Search tree for single node matching path attribute. - Path name can be with or without leading tree path separator symbol. @@ -253,7 +254,7 @@ def find_full_path(tree: Node, path_name: str) -> Node: return child_node -def find_path(tree: Node, path_name: str) -> Node: +def find_path(tree: NodeT, path_name: str) -> NodeT: """ Search tree for single node matching path attribute. - Path name can be with or without leading tree path separator symbol. @@ -280,7 +281,7 @@ def find_path(tree: Node, path_name: str) -> Node: return find(tree, lambda node: node.path_name.endswith(path_name)) -def find_paths(tree: Node, path_name: str) -> Tuple[Node, ...]: +def find_paths(tree: NodeT, path_name: str) -> Tuple[NodeT, ...]: """ Search tree for multiple nodes matching path attribute. - Path name can be with or without leading tree path separator symbol. @@ -434,7 +435,7 @@ def find_child( return result[0] -def find_child_by_name(tree: Node, name: str) -> Node: +def find_child_by_name(tree: NodeT, name: str) -> NodeT: """ Search tree for single node matching name attribute. diff --git a/bigtree/utils/exceptions.py b/bigtree/utils/exceptions.py index c0b32b7e..f04a9132 100644 --- a/bigtree/utils/exceptions.py +++ b/bigtree/utils/exceptions.py @@ -1,3 +1,10 @@ +from functools import wraps +from typing import Any, Callable, TypeVar +from warnings import simplefilter, warn + +T = TypeVar("T") + + class TreeError(Exception): pass @@ -30,3 +37,88 @@ class SearchError(TreeError): """Error during tree search""" pass + + +def deprecated( + alias: str, +) -> Callable[[Callable[..., T]], Callable[..., T]]: # pragma: no cover + def decorator(func: Callable[..., T]) -> Callable[..., T]: + """ + This is a decorator which can be used to mark functions as deprecated. + It will raise a DeprecationWarning when the function is used. + Source: https://stackoverflow.com/a/30253848 + """ + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> T: + simplefilter("always", DeprecationWarning) + warn( + "{old_func} is going to be deprecated, use {new_func} instead".format( + old_func=func.__name__, + new_func=alias, + ), + category=DeprecationWarning, + stacklevel=2, + ) + simplefilter("default", DeprecationWarning) # reset filter + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def optional_dependencies_pandas( + func: Callable[..., T] +) -> Callable[..., T]: # pragma: no cover + """ + This is a decorator which can be used to import optional pandas dependency. + It will raise a ImportError if the module is not found. + """ + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> T: + try: + import pandas as pd # noqa: F401 + except ImportError: + raise ImportError( + "pandas not available. Please perform a\n\n" + "pip install 'bigtree[pandas]'\n\nto install required dependencies" + ) from None + return func(*args, **kwargs) + + return wrapper + + +def optional_dependencies_image( + package_name: str = "", +) -> Callable[[Callable[..., T]], Callable[..., T]]: + def decorator(func: Callable[..., T]) -> Callable[..., T]: + """ + This is a decorator which can be used to import optional image dependency. + It will raise a ImportError if the module is not found. + """ + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> T: + if not package_name or package_name == "pydot": + try: + import pydot # noqa: F401 + except ImportError: # pragma: no cover + raise ImportError( + "pydot not available. Please perform a\n\n" + "pip install 'bigtree[image]'\n\nto install required dependencies" + ) from None + if not package_name or package_name == "Pillow": + try: + from PIL import Image, ImageDraw, ImageFont # noqa: F401 + except ImportError: # pragma: no cover + raise ImportError( + "Pillow not available. Please perform a\n\n" + "pip install 'bigtree[image]'\n\nto install required dependencies" + ) from None + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/bigtree/workflows/app_calendar.py b/bigtree/workflows/app_calendar.py index e8436301..03fa2142 100644 --- a/bigtree/workflows/app_calendar.py +++ b/bigtree/workflows/app_calendar.py @@ -1,13 +1,18 @@ +from __future__ import annotations + import datetime as dt from typing import Any, Optional, Union -import pandas as pd - from bigtree.node.node import Node from bigtree.tree.construct import add_path_to_tree from bigtree.tree.export import tree_to_dataframe from bigtree.tree.search import find_full_path, findall +try: + import pandas as pd +except ImportError: # pragma: no cover + pd = None + class Calendar: """ @@ -145,6 +150,8 @@ def to_dataframe(self) -> pd.DataFrame: Returns: (pd.DataFrame) """ + if not len(self.calendar.children): + raise Exception("Calendar is empty!") data = tree_to_dataframe(self.calendar, all_attrs=True, leaf_only=True) compulsory_cols = ["path", "name", "date", "time"] other_cols = list(set(data.columns) - set(compulsory_cols)) diff --git a/pyproject.toml b/pyproject.toml index 097d56ef..7919fa6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bigtree" -description = "Tree Implementation for Python, integrated with Python list, dictionary, and pandas DataFrame." +description = "Tree Implementation and Methods for Python, integrated with Python list, dictionary, and pandas DataFrame." readme = "README.md" requires-python = ">=3.7" license = {text = "MIT"} @@ -26,9 +26,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] -dependencies = [ - "pandas", -] +dependencies = [] dynamic = ["version"] [project.urls] @@ -38,23 +36,30 @@ Discussions = "https://github.com/kayjan/bigtree/discussions" Source = "https://github.com/kayjan/bigtree" [project.optional-dependencies] +all = [ + "pandas", + "pydot", + "Pillow", +] image = [ "pydot", "Pillow", ] +pandas = ["pandas"] [tool.hatch.version] path = "bigtree/__init__.py" [tool.hatch.envs.default] dependencies = [ - "pytest", - "pytest-cov", - "coverage", "black", + "coverage", "mypy", - "pydot", + "pandas", "Pillow", + "pydot", + "pytest", + "pytest-cov", ] [tool.hatch.envs.default.scripts] @@ -67,11 +72,11 @@ mypy-type = "mypy bigtree" [tool.hatch.envs.docs] dependencies = [ - "autodocsumm==0.2.9", - "karma-sphinx-theme==0.0.8", - "myst-parser==0.18.1", - "pandas==1.5.1", - "sphinxemoji==0.2.0" + "autodocsumm", + "karma-sphinx-theme", + "myst-parser", + "pandas", + "sphinxemoji" ] [tool.hatch.envs.docs.scripts]