diff --git a/README.md b/README.md index bb2d05c..e495a7c 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,17 @@ These nodes are very lightweight and require no additional dependencies. - Comparison: is_subset, is_superset, is_disjoint - **data list**: ComfyUI list manipulation nodes (for processing individual items) - - Conversion: convert to LIST - Creation: create_empty - Modification: append, filter, extend, insert, remove, pop, clear, set_item - Access: get_item, slice, index, contains - Information: length, count - Operations: sort, reverse, copy, zip, min, max +- **cast**: Type conversion nodes for ComfyUI data types + - Basic conversions: to STRING, to INT, to FLOAT, to BOOLEAN + - Collection conversions: to LIST, to SET, to DICT + - Special conversions: data list to LIST, data list to SET + - **DICT**: Dictionary manipulation nodes - Creation: create, from_items, from_lists, fromkeys, any_to_DICT - Access: get, get_multiple, keys, values, items diff --git a/pyproject.toml b/pyproject.toml index 836555a..bae629a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "basic_data_handling" -version = "0.0.6" +version = "0.0.7" 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. diff --git a/src/basic_data_handling/__init__.py b/src/basic_data_handling/__init__.py index 6cdeaf9..462436a 100644 --- a/src/basic_data_handling/__init__.py +++ b/src/basic_data_handling/__init__.py @@ -1,8 +1,10 @@ -from . import (boolean_nodes, comparison_nodes, control_flow, data_list_nodes, dict_nodes, - float_nodes, int_nodes, list_nodes, math_nodes, set_nodes, string_nodes) +from . import (boolean_nodes, casting_nodes, comparison_nodes, control_flow, + data_list_nodes, dict_nodes, float_nodes, int_nodes, list_nodes, + math_nodes, regex_nodes, set_nodes, string_nodes) NODE_CLASS_MAPPINGS = {} NODE_CLASS_MAPPINGS.update(boolean_nodes.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(casting_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) @@ -10,12 +12,14 @@ NODE_CLASS_MAPPINGS.update(dict_nodes.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(float_nodes.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(int_nodes.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(list_nodes.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(regex_nodes.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(set_nodes.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(math_nodes.NODE_CLASS_MAPPINGS) 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(casting_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) @@ -23,6 +27,7 @@ NODE_DISPLAY_NAME_MAPPINGS.update(dict_nodes.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(float_nodes.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(int_nodes.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(list_nodes.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(regex_nodes.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(set_nodes.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(math_nodes.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(string_nodes.NODE_DISPLAY_NAME_MAPPINGS) diff --git a/src/basic_data_handling/boolean_nodes.py b/src/basic_data_handling/boolean_nodes.py index e49b217..ddd7a28 100644 --- a/src/basic_data_handling/boolean_nodes.py +++ b/src/basic_data_handling/boolean_nodes.py @@ -10,8 +10,8 @@ class BooleanAnd: def INPUT_TYPES(cls): return { "required": { - "input1": ("BOOLEAN", {"default": False}), - "input2": ("BOOLEAN", {"default": False}), + "input1": ("BOOLEAN", {"default": False, "forceInput": True}), + "input2": ("BOOLEAN", {"default": False, "forceInput": True}), } } @@ -34,8 +34,8 @@ class BooleanOr: def INPUT_TYPES(cls): return { "required": { - "input1": ("BOOLEAN", {"default": False}), - "input2": ("BOOLEAN", {"default": False}), + "input1": ("BOOLEAN", {"default": False, "forceInput": True}), + "input2": ("BOOLEAN", {"default": False, "forceInput": True}), } } @@ -58,7 +58,7 @@ class BooleanNot: def INPUT_TYPES(cls): return { "required": { - "input": ("BOOLEAN", {"default": False}), + "input": ("BOOLEAN", {"default": False, "forceInput": True}), } } @@ -81,8 +81,8 @@ class BooleanXor: def INPUT_TYPES(cls): return { "required": { - "input1": ("BOOLEAN", {"default": False}), - "input2": ("BOOLEAN", {"default": False}), + "input1": ("BOOLEAN", {"default": False, "forceInput": True}), + "input2": ("BOOLEAN", {"default": False, "forceInput": True}), } } @@ -105,8 +105,8 @@ class BooleanNand: def INPUT_TYPES(cls): return { "required": { - "input1": ("BOOLEAN", {"default": False}), - "input2": ("BOOLEAN", {"default": False}), + "input1": ("BOOLEAN", {"default": False, "forceInput": True}), + "input2": ("BOOLEAN", {"default": False, "forceInput": True}), } } @@ -129,8 +129,8 @@ class BooleanNor: def INPUT_TYPES(cls): return { "required": { - "input1": ("BOOLEAN", {"default": False}), - "input2": ("BOOLEAN", {"default": False}), + "input1": ("BOOLEAN", {"default": False, "forceInput": True}), + "input2": ("BOOLEAN", {"default": False, "forceInput": True}), } } diff --git a/src/basic_data_handling/casting_nodes.py b/src/basic_data_handling/casting_nodes.py new file mode 100644 index 0000000..8681b06 --- /dev/null +++ b/src/basic_data_handling/casting_nodes.py @@ -0,0 +1,275 @@ +from typing import Any +from inspect import cleandoc + + +class CastToString: + """ + Converts any input to a STRING. Non-string values are converted using str(). + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("*", {}) + } + } + + RETURN_TYPES = ("STRING",) + CATEGORY = "Basic/cast" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "convert_to_string" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def convert_to_string(self, input: Any) -> tuple[str]: + return (str(input),) + + +class CastToInt: + """ + Converts any numeric input to an INT. Non-numeric or invalid inputs raise a ValueError. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("*", {}) + } + } + + RETURN_TYPES = ("INT",) + CATEGORY = "Basic/cast" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "convert_to_int" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def convert_to_int(self, input: Any) -> tuple[int]: + try: + return (int(input),) + except ValueError: + raise ValueError(f"Cannot convert {input} to an INT.") + + +class CastToFloat: + """ + Converts any numeric input to a FLOAT. Non-numeric or invalid inputs raise a ValueError. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("*", {}) + } + } + + RETURN_TYPES = ("FLOAT",) + CATEGORY = "Basic/cast" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "convert_to_float" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def convert_to_float(self, input: Any) -> tuple[float]: + try: + return (float(input),) + except ValueError: + raise ValueError(f"Cannot convert {input} to a FLOAT.") + + +class CastToBoolean: + """ + Converts any input to a BOOLEAN. Follows standard Python truthy/falsy rules. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("*", {}) + } + } + + RETURN_TYPES = ("BOOLEAN",) + CATEGORY = "Basic/cast" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "convert_to_boolean" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def convert_to_boolean(self, input: Any) -> tuple[bool]: + return (bool(input),) + + +class CastToList: + """ + Converts any input to a LIST. Non-list inputs are wrapped in a list. If input is a ComfyUI data list, + it converts the individual items into a Python LIST. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("*", {}) + } + } + + RETURN_TYPES = ("LIST",) + CATEGORY = "Basic/cast" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "convert_to_list" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def convert_to_list(self, input: Any) -> tuple[list]: + if isinstance(input, list): + return (input,) + return ([input],) + + +class CastDataListToList: + """ + Converts a ComfyUI data list into a LIST object. + + This node takes a data list input (which is typically a list of items with the same type) + and converts it to a LIST object (a Python list as a single variable). + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "list": ("*", {}), + } + } + + RETURN_TYPES = ("LIST",) + CATEGORY = "Basic/cast" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "convert" + INPUT_IS_LIST = True + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def convert(self, **kwargs: dict[str, list]) -> tuple[list[Any]]: + return (kwargs.get('list', []).copy(),) + + +class CastToSet: + """ + Converts any input to a SET. Non-set inputs are converted into a set. If input is a ComfyUI data list, + it casts the individual items into a SET. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("*", {}) + } + } + + RETURN_TYPES = ("SET",) + CATEGORY = "Basic/cast" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "convert_to_set" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def convert_to_set(self, input: Any) -> tuple[set]: + if isinstance(input, set): + return (input,) + return ({input,} if not isinstance(input, list) else set(input),) + + +class CastDataListToSet: + """ + Converts a ComfyUI data list into a LIST object. + + This node takes a data list input (which is typically a list of items with the same type) + and converts it to a LIST object (a Python list as a single variable). + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "list": ("*", {}), + } + } + + RETURN_TYPES = ("SET",) + CATEGORY = "Basic/cast" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "convert" + INPUT_IS_LIST = True + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def convert(self, **kwargs: dict[str, list]) -> tuple[list[Any]]: + return (set(kwargs.get('list', [])),) + + +class CastToDict: + """ + Converts compatible inputs to a DICT. Input must be a mapping or a list of key-value pairs. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("*", {}) + } + } + + RETURN_TYPES = ("DICT",) + CATEGORY = "Basic/cast" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "convert_to_dict" + + @classmethod + def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: + return True + + def convert_to_dict(self, input: Any) -> tuple[dict]: + try: + return (dict(input),) + except (ValueError, TypeError): + raise ValueError(f"Cannot convert {input} to a DICT. Ensure it is a mapping or list of key-value pairs.") + + +NODE_CLASS_MAPPINGS = { + "CastToString": CastToString, + "CastToInt": CastToInt, + "CastToFloat": CastToFloat, + "CastToBoolean": CastToBoolean, + "CastToList": CastToList, + "CastDataListToList": CastDataListToList, + "CastToSet": CastToSet, + "CastDataListToSet": CastDataListToSet, + "CastToDict": CastToDict, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "CastToString": "to STRING", + "CastToInt": "to INT", + "CastToFloat": "to FLOAT", + "CastToBoolean": "to BOOLEAN", + "CastToList": "to LIST", + "CastDataListToList": "data list to LIST", + "CastToSet": "to SET", + "CastDataListToSet": "data list to SET", + "CastToDict": "to DICT", +} diff --git a/src/basic_data_handling/data_list_nodes.py b/src/basic_data_handling/data_list_nodes.py index a9f5f24..40d3198 100644 --- a/src/basic_data_handling/data_list_nodes.py +++ b/src/basic_data_handling/data_list_nodes.py @@ -664,7 +664,7 @@ class DataListFilter: return { "required": { "value": ("*", {}), - "filter": ("BOOLEAN", {}), + "filter": ("BOOLEAN", {"forceInput": True}), } } @@ -692,35 +692,6 @@ class DataListFilter: return (result,) -class DataListToList: - """ - Converts a ComfyUI data list into a LIST object. - - This node takes a data list input (which is typically a list of items with the same type) - and converts it to a LIST object (a Python list as a single variable). - """ - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "list": ("*", {}), - } - } - - RETURN_TYPES = ("LIST",) - CATEGORY = "Basic/data list" - DESCRIPTION = cleandoc(__doc__ or "") - FUNCTION = "convert" - INPUT_IS_LIST = True - - @classmethod - def VALIDATE_INPUTS(cls, input_types: dict[str, str]) -> bool: - return True - - def convert(self, **kwargs: dict[str, list]) -> tuple[list[Any]]: - return (kwargs.get('list', []).copy(),) - - class DataListMin: """ Finds the minimum value in a list of numbers. @@ -825,7 +796,6 @@ NODE_CLASS_MAPPINGS = { "DataListCreateEmpty": DataListCreateEmpty, "DataListZip": DataListZip, "DataListFilter": DataListFilter, - "DataListToList": DataListToList, "DataListMin": DataListMin, "DataListMax": DataListMax, } @@ -844,13 +814,12 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DataListCopy": "copy", "DataListLength": "length", "DataListSlice": "slice", - "DataListGetItem": "get_item", - "DataListSetItem": "set_item", + "DataListGetItem": "get item", + "DataListSetItem": "set item", "DataListContains": "contains", - "DataListCreateEmpty": "create_empty", + "DataListCreateEmpty": "create empty", "DataListZip": "zip", "DataListFilter": "filter", - "DataListToList": "convert to LIST", "DataListMin": "min", "DataListMax": "max", } diff --git a/src/basic_data_handling/regex_nodes.py b/src/basic_data_handling/regex_nodes.py new file mode 100644 index 0000000..e762e53 --- /dev/null +++ b/src/basic_data_handling/regex_nodes.py @@ -0,0 +1,165 @@ +from typing import Any +from inspect import cleandoc +import re + +class RegexSearchGroups: + """ + Searches the string for a match to the pattern and returns a LIST of match groups. + If no match is found, it returns an empty LIST. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "pattern": ("STRING", {}), + "string": ("STRING", {}), + } + } + + RETURN_TYPES = ("LIST",) + CATEGORY = "Basic/STRING/regex" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "search_groups" + + def search_groups(self, pattern: str, string: str) -> tuple[list[str]]: + match = re.search(pattern, string) + if match: + return (list(match.groups()),) + return ([],) + + +class RegexGroupDict: + """ + Searches the string with the given pattern and returns a DICT of named groups. + If no match is found, it returns an empty DICT. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "pattern": ("STRING", {}), + "string": ("STRING", {}), + } + } + + RETURN_TYPES = ("DICT",) + CATEGORY = "Basic/STRING/regex" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "groupdict" + + def groupdict(self, pattern: str, string: str) -> tuple[dict]: + match = re.search(pattern, string) + if match: + return (match.groupdict(),) + return ({},) + + +class RegexFindall: + """ + Returns all non-overlapping matches of a pattern in the string as a list of strings. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "pattern": ("STRING", {}), + "string": ("STRING", {}), + } + } + + RETURN_TYPES = ("LIST",) + CATEGORY = "Basic/STRING/regex" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "findall" + + def findall(self, pattern: str, string: str) -> tuple[list[str]]: + return (re.findall(pattern, string),) + + +class RegexSplit: + """ + Splits the string at each match of the pattern and returns a list of substrings. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "pattern": ("STRING", {}), + "string": ("STRING", {}), + } + } + + RETURN_TYPES = ("LIST",) + CATEGORY = "Basic/STRING/regex" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "split" + + def split(self, pattern: str, string: str) -> tuple[list[str]]: + return (re.split(pattern, string),) + + +class RegexSub: + """ + Substitutes matches of the pattern in the string with a replacement string. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "pattern": ("STRING", {}), + "repl": ("STRING", {}), + "string": ("STRING", {}), + "count": ("INT", {"default": 0}), # 0 means replace all occurrences + } + } + + RETURN_TYPES = ("STRING",) + CATEGORY = "Basic/STRING/regex" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "sub" + + def sub(self, pattern: str, repl: str, string: str, count: int = 0) -> tuple[str]: + return (re.sub(pattern, repl, string, count),) + + +class RegexTest: + """ + Tests whether a given regex pattern matches any part of the input string. + Returns True if a match is found, otherwise False. + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "pattern": ("STRING", {}), + "string": ("STRING", {}), + } + } + + RETURN_TYPES = ("BOOLEAN",) + CATEGORY = "Basic/STRING/regex" + DESCRIPTION = cleandoc(__doc__ or "") + FUNCTION = "test" + + def test(self, pattern: str, string: str) -> tuple[bool]: + match = re.search(pattern, string) + return (match is not None,) + + +NODE_CLASS_MAPPINGS = { + "RegexSearchGroups": RegexSearchGroups, + "RegexGroupDict": RegexGroupDict, + "RegexFindall": RegexFindall, + "RegexSplit": RegexSplit, + "RegexSub": RegexSub, + "RegexTest": RegexTest, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "RegexSearchGroups": "search groups", + "RegexGroupDict": "search named groups", + "RegexFindall": "find all", + "RegexSplit": "split", + "RegexSub": "substitute", + "RegexTest": "test", +}