From d31a8b4f3d7282d3bf037382f12907a8c93db510 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 7 May 2025 23:54:32 +0200 Subject: [PATCH] Version 0.0.6 Add data list filtering, control flow and comparison. --- README.md | 12 +- pyproject.toml | 5 +- src/basic_data_handling/__init__.py | 8 +- src/basic_data_handling/comparison_nodes.py | 404 ++++++++++++++++++++ src/basic_data_handling/control_flow.py | 124 ++++++ src/basic_data_handling/data_list_nodes.py | 48 ++- 6 files changed, 595 insertions(+), 6 deletions(-) create mode 100644 src/basic_data_handling/comparison_nodes.py create mode 100644 src/basic_data_handling/control_flow.py diff --git a/README.md b/README.md index 764d9b3..7f400a0 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ These nodes are very lightweight and require no additional dependencies. - **data list**: ComfyUI list manipulation nodes (for processing individual items) - Conversion: convert to LIST - Creation: create_empty - - Modification: append, extend, insert, remove, pop, clear, set_item + - Modification: append, filter, extend, insert, remove, pop, clear, set_item - Access: get_item, slice, index, contains - Information: length, count - Operations: sort, reverse, copy, zip @@ -66,6 +66,16 @@ These nodes are very lightweight and require no additional dependencies. - **BOOLEAN**: Boolean logic nodes - Logic operations: and, or, not, xor, nand, nor +- **Comparison**: Value comparison nodes + - Basic comparisons: equal (==), not equal (!=), greater than (>), less than (<), etc. + - String comparison: case-sensitive/insensitive string comparison with various operators + - Special comparisons: number in range, is null, compare length + - Container operations: length comparison for strings, lists, and other containers + +- **Flow Control**: Workflow control nodes + - Conditional: if/else for branching logic based on conditions + - Selection: switch/case for selecting from multiple options based on an index + ## Understanding LIST vs. data list vs. SET ComfyUI has different data types that serve different purposes: diff --git a/pyproject.toml b/pyproject.toml index 5c7d898..836555a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "basic_data_handling" -version = "0.0.5" +version = "0.0.6" description = """Basic Python functions for manipulating data that every programmer is used to. Currently supported ComfyUI data types: BOOLEAN, FLOAT, INT, STRING and data lists. -Additionally supported Python data types as custom data types: DICT, LIST.""" +Additionally supported Python data types as custom data types: DICT, LIST. +Data handling is supported with comparisons and control flow operations.""" authors = [ {name = "Basic data handling"} ] diff --git a/src/basic_data_handling/__init__.py b/src/basic_data_handling/__init__.py index 0197e6b..a509ec8 100644 --- a/src/basic_data_handling/__init__.py +++ b/src/basic_data_handling/__init__.py @@ -1,8 +1,10 @@ -from . import (boolean_nodes, data_list_nodes, dict_nodes, float_nodes, int_nodes, - list_nodes, set_nodes, string_nodes) +from . import (boolean_nodes, comparison_nodes, control_flow, data_list_nodes, dict_nodes, + float_nodes, int_nodes, list_nodes, set_nodes, string_nodes) NODE_CLASS_MAPPINGS = {} NODE_CLASS_MAPPINGS.update(boolean_nodes.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(comparison_nodes.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(control_flow.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(data_list_nodes.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(dict_nodes.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(float_nodes.NODE_CLASS_MAPPINGS) @@ -13,6 +15,8 @@ NODE_CLASS_MAPPINGS.update(string_nodes.NODE_CLASS_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS.update(boolean_nodes.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(comparison_nodes.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(control_flow.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(data_list_nodes.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(dict_nodes.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(float_nodes.NODE_DISPLAY_NAME_MAPPINGS) diff --git a/src/basic_data_handling/comparison_nodes.py b/src/basic_data_handling/comparison_nodes.py new file mode 100644 index 0000000..83708ac --- /dev/null +++ b/src/basic_data_handling/comparison_nodes.py @@ -0,0 +1,404 @@ +from typing import Any +from inspect import cleandoc + +class Equal: + """ + Checks if two values are equal. + + This node takes two inputs of any type and returns True if they are equal, + and False otherwise. For complex objects, structural equality is tested. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value1": ("*", {}), + "value2": ("*", {}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("result",) + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "compare" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def compare(self, value1: Any, value2: Any) -> tuple[bool]: + return (value1 == value2,) + + +class NotEqual: + """ + Checks if two values are not equal. + + This node takes two inputs of any type and returns True if they are not equal, + and False otherwise. For complex objects, structural inequality is tested. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value1": ("*", {}), + "value2": ("*", {}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("result",) + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "compare" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def compare(self, value1: Any, value2: Any) -> tuple[bool]: + return (value1 != value2,) + + +class GreaterThan: + """ + Checks if the first value is greater than the second. + + This node takes two numerical inputs and returns True if the first value + is greater than the second value, and False otherwise. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value1": (["FLOAT", "INT"], {}), + "value2": (["FLOAT", "INT"], {}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("result",) + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "compare" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types["value1"] not in ("FLOAT", "INT"): + return "value1 must be a FLOAT or INT type" + if input_types["value2"] not in ("FLOAT", "INT"): + return "value2 must be a FLOAT or INT type" + return True + + def compare(self, value1: float, value2: float) -> tuple[bool]: + return (value1 > value2,) + + +class LessThan: + """ + Checks if the first value is less than the second. + + This node takes two numerical inputs and returns True if the first value + is less than the second value, and False otherwise. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value1": (["FLOAT", "INT"], {}), + "value2": (["FLOAT", "INT"], {}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("result",) + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "compare" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types["value1"] not in ("FLOAT", "INT"): + return "value1 must be a FLOAT or INT type" + if input_types["value2"] not in ("FLOAT", "INT"): + return "value2 must be a FLOAT or INT type" + return True + + def compare(self, value1: float, value2: float) -> tuple[bool]: + return (value1 < value2,) + + +class GreaterThanOrEqual: + """ + Checks if the first value is greater than or equal to the second. + + This node takes two numerical inputs and returns True if the first value + is greater than or equal to the second value, and False otherwise. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value1": (["FLOAT", "INT"], {}), + "value2": (["FLOAT", "INT"], {}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("result",) + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "compare" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types["value1"] not in ("FLOAT", "INT"): + return "value1 must be a FLOAT or INT type" + if input_types["value2"] not in ("FLOAT", "INT"): + return "value2 must be a FLOAT or INT type" + return True + + def compare(self, value1: float, value2: float) -> tuple[bool]: + return (value1 >= value2,) + + +class LessThanOrEqual: + """ + Checks if the first value is less than or equal to the second. + + This node takes two numerical inputs and returns True if the first value + is less than or equal to the second value, and False otherwise. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value1": (["FLOAT", "INT"], {}), + "value2": (["FLOAT", "INT"], {}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("result",) + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "compare" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types["value1"] not in ("FLOAT", "INT"): + return "value1 must be a FLOAT or INT type" + if input_types["value2"] not in ("FLOAT", "INT"): + return "value2 must be a FLOAT or INT type" + return True + + def compare(self, value1: float, value2: float) -> tuple[bool]: + return (value1 <= value2,) + + +class StringComparison: + """ + Compares two strings using a selected comparison operator. + + This node takes two string inputs and a comparison operator, and returns + a boolean result based on the selected comparison. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "string1": ("STRING", {"default": ""}), + "string2": ("STRING", {"default": ""}), + "operator": (["==", "!=", ">", "<", ">=", "<="], {"default": "=="}), + "case_sensitive": (["True", "False"], {"default": "True"}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("result",) + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "compare" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types["string1"] != "STRING": + return "string1 must be a STRING type" + if input_types["string2"] != "STRING": + return "string2 must be a STRING type" + return True + + def compare(self, string1: str, string2: str, operator: str, case_sensitive: str) -> tuple[bool]: + if case_sensitive == "False": + string1 = string1.lower() + string2 = string2.lower() + + if operator == "==": + return (string1 == string2,) + elif operator == "!=": + return (string1 != string2,) + elif operator == ">": + return (string1 > string2,) + elif operator == "<": + return (string1 < string2,) + elif operator == ">=": + return (string1 >= string2,) + elif operator == "<=": + return (string1 <= string2,) + else: + raise ValueError(f"Unknown operator: {operator}") + + +class NumberInRange: + """ + Checks if a number is within a specified range. + + This node takes a number and range bounds, and returns True if the number + is within the specified range, and False otherwise. The user can specify + whether the bounds are inclusive or exclusive. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": (["FLOAT", "INT"], {}), + "min_value": (["FLOAT", "INT"], {"default": 0}), + "max_value": (["FLOAT", "INT"], {"default": 100}), + }, + "optional": { + "include_min": (["True", "False"], {"default": "True"}), + "include_max": (["True", "False"], {"default": "True"}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("in_range",) + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "check_range" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types["value"] not in ("FLOAT", "INT"): + return "value must be a FLOAT or INT type" + if input_types["min_value"] not in ("FLOAT", "INT"): + return "min_value must be a FLOAT or INT type" + if input_types["max_value"] not in ("FLOAT", "INT"): + return "max_value must be a FLOAT or INT type" + return True + + def check_range(self, value: float, min_value: float, max_value: float, + include_min: str = "True", include_max: str = "True") -> tuple[bool]: + min_check = value >= min_value if include_min == "True" else value > min_value + max_check = value <= max_value if include_max == "True" else value < max_value + + return (min_check and max_check,) + + +class IsNull: + """ + Checks if a value is None/null. + + This node takes any input value and returns True if the value is None, + and False otherwise. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": ("*", {}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("is_null",) + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "check_null" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def check_null(self, value: Any) -> tuple[bool]: + return (value is None,) + + +class CompareLength: + """ + Compares the length of a container (string, list, etc) with a value. + + This node takes a container and a comparison value, and returns a boolean + result based on the comparison of the container's length with the value. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "container": ("*", {}), + "operator": (["==", "!=", ">", "<", ">=", "<="], {"default": "=="}), + "length": ("INT", {"default": 0, "min": 0}), + } + } + + RETURN_TYPES = ("BOOLEAN", "INT") + RETURN_NAMES = ("result", "actual_length") + CATEGORY = "Basic/comparison" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "compare_length" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types["length"] != "INT": + return "length must be an INT type" + return True + + def compare_length(self, container: Any, operator: str, length: int) -> tuple[bool, int]: + try: + actual_length = len(container) + except (TypeError, AttributeError): + # If the object doesn't have a length, return False and -1 + return False, -1 + + if operator == "==": + return (actual_length == length, actual_length) + elif operator == "!=": + return (actual_length != length, actual_length) + elif operator == ">": + return (actual_length > length, actual_length) + elif operator == "<": + return (actual_length < length, actual_length) + elif operator == ">=": + return (actual_length >= length, actual_length) + elif operator == "<=": + return (actual_length <= length, actual_length) + else: + raise ValueError(f"Unknown operator: {operator}") + + +NODE_CLASS_MAPPINGS = { + "Equal": Equal, + "NotEqual": NotEqual, + "GreaterThan": GreaterThan, + "LessThan": LessThan, + "GreaterThanOrEqual": GreaterThanOrEqual, + "LessThanOrEqual": LessThanOrEqual, + "StringComparison": StringComparison, + "NumberInRange": NumberInRange, + "IsNull": IsNull, + "CompareLength": CompareLength, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Equal": "==", + "NotEqual": "!=", + "GreaterThan": ">", + "LessThan": "<", + "GreaterThanOrEqual": ">=", + "LessThanOrEqual": "<=", + "StringComparison": "string compare", + "NumberInRange": "in range", + "IsNull": "is null", + "CompareLength": "compare length", +} diff --git a/src/basic_data_handling/control_flow.py b/src/basic_data_handling/control_flow.py new file mode 100644 index 0000000..85bf8d9 --- /dev/null +++ b/src/basic_data_handling/control_flow.py @@ -0,0 +1,124 @@ +from typing import Any, Dict, List, Union +from inspect import cleandoc + +class IfElse: + """ + Implements a conditional branch (if/else) in the workflow. + + This node takes a condition input and two value inputs. If the condition + evaluates to True, the first value is returned; otherwise, the second value + is returned. This allows conditional data flow in ComfyUI workflows. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "condition": ("BOOLEAN", {}), + "if_true": ("*", {"lazy": True}), + "if_false": ("*", {"lazy": True}), + } + } + + RETURN_TYPES = ("*",) + RETURN_NAMES = ("result",) + CATEGORY = "Basic/ ⎇ Flow Control" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "execute" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types["condition"] not in ("BOOLEAN"): + return "condition must be a BOOLEAN type" + return True + + def check_lazy_status(self, condition: bool, if_true: Any, if_false: Any) -> list[str]: + needed = [] + if if_true is None and condition is True: + needed.append("if_true") + if if_false is None and condition is False: + needed.append("if_false") + return needed + + def execute(self, condition: bool, if_true: Any, if_false: Any) -> tuple[Any]: + """Execute the if/else statement and return the appropriate value.""" + return (if_true if condition else if_false,) + + +class SwitchCase: + """ + Implements a switch/case selection in the workflow. + + This node takes a selector input (an integer) and multiple case value inputs. + It returns the value corresponding to the provided index. If the index is out + of range, the default value is returned. This allows for selection from multiple + options based on a computed index. + + NOTE: This version of the node will most likely be deprecated in the future. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "selector": ("INT", {"default": 0, "min": 0}), + "case_0": ("*", {"lazy": True}), + "case_1": ("*", {"lazy": True}), + }, + "optional": { + "case_2": ("*", {"lazy": True}), + "case_3": ("*", {"lazy": True}), + "case_4": ("*", {"lazy": True}), + "default": ("*", {"lazy": True}), + } + } + + RETURN_TYPES = ("*",) + RETURN_NAMES = ("result",) + CATEGORY = "Basic/ ⎇ Flow Control" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "execute" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types["selector"] not in ("INT"): + return "selector must be a INT type" + return True + + def check_lazy_status(self, condition: int, case_0: Any, case_1: Any, + case_2: Any, case_3: Any, case_4: Any, default: Any) -> list[str]: + needed = [] + if case_0 is None and condition == 0: + needed.append("case_0") + if case_1 is None and condition == 1: + needed.append("case_1") + if case_2 is None and condition == 2: + needed.append("case_2") + if case_3 is None and condition == 3: + needed.append("case_3") + if case_4 is None and condition == 4: + needed.append("case_4") + if default is None and not 0 <= condition <= 4: + needed.append("default") + return needed + + def execute(self, selector: int, case_0: Any, case_1: Any, + case_2: Any = None, case_3: Any = None, + case_4: Any = None, default: Any = None) -> tuple[Any]: + """Execute the switch/case statement and return the appropriate value.""" + cases = [case_0, case_1, case_2, case_3, case_4] + + if 0 <= selector < len(cases) and cases[selector] is not None: + return (cases[selector],) + + # If selector is out of range or the selected case is None, return default + return (default,) + + +NODE_CLASS_MAPPINGS = { + "IfElse": IfElse, + "SwitchCase": SwitchCase, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "IfElse": "if/else", + "SwitchCase": "switch/case", +} diff --git a/src/basic_data_handling/data_list_nodes.py b/src/basic_data_handling/data_list_nodes.py index fc49457..a8bcee4 100644 --- a/src/basic_data_handling/data_list_nodes.py +++ b/src/basic_data_handling/data_list_nodes.py @@ -173,7 +173,7 @@ class DataListPop: @classmethod def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: - if input_types["index"] not in ("INT"): + if input_types[0].get("index", "INT") not in ("INT"): return "index must be an INT type" return True @@ -648,6 +648,50 @@ class DataListZip: return (result,) +class DataListFilter: + """ + Filters a data list using boolean values. + + This node takes a value data list and a filter data list (containing only boolean values). + It returns a new data list containing only the elements from the value list where the + corresponding element in the filter list is False. + + If the lists have different lengths, the last element of the shorter list is repeated + till the lengths are matching. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "value": ("*", {}), + "filter": ("BOOLEAN", {}), + } + } + + RETURN_TYPES = ("*",) + RETURN_NAMES = ("filtered_list",) + CATEGORY = "Basic/data list" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "filter_data" + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True,) + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool|str: + if input_types[0]["filter"] not in ("BOOLEAN"): + return "filter must be a BOOLEAN type" + return True + + def filter_data(self, **kwargs: dict[str, list]) -> tuple[list[Any]]: + values = kwargs.get('value', []) + filters = kwargs.get('filter', []) + + # Create a new list with only items where the filter is False + result = [_val for _val, _filter in zip(values, filters) if not _filter] + + return (result,) + + class DataListToList: """ Converts a ComfyUI data list into a LIST object. @@ -696,6 +740,7 @@ NODE_CLASS_MAPPINGS = { "DataListContains": DataListContains, "DataListCreateEmpty": DataListCreateEmpty, "DataListZip": DataListZip, + "DataListFilter": DataListFilter, "DataListToList": DataListToList, } @@ -718,5 +763,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DataListContains": "contains", "DataListCreateEmpty": "create_empty", "DataListZip": "zip", + "DataListFilter": "filter", "DataListToList": "convert to LIST", }