Added:
- Data List and LIST: new "filter empty" node that removes every empty entry (`None`, empty STRING, empty LIST/DICT/SET, ...); `0` and `False` are kept. An optional boolean switch also treats a STRING consisting only of whitespace as empty.
This commit is contained in:
@@ -5,6 +5,26 @@ 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.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Added:
|
||||
- Data List and LIST: new "filter empty" node that removes every empty entry
|
||||
(`None`, empty STRING, empty LIST/DICT/SET, ...); `0` and `False` are kept.
|
||||
An optional boolean switch also treats a STRING consisting only of whitespace
|
||||
as empty.
|
||||
|
||||
## [2.1.1] - 2026-10-07
|
||||
|
||||
Fixed:
|
||||
- "save IMAGE to file" and "save IMAGE+MASK to file" no longer fail with
|
||||
"EXIF data is too long" when saving a JPG whose embedded workflow metadata is
|
||||
larger than a single JPEG EXIF block. A JPEG can only carry one 16-bit-sized
|
||||
EXIF segment, so oversized metadata is now reduced step by step - first the
|
||||
full workflow graph, then the workflow prompt info, then the prompt metadata -
|
||||
and the reduction is reported; the image itself is always written. Should
|
||||
embedding metadata ever fail for another reason, the image is written again
|
||||
without metadata instead of not at all.
|
||||
|
||||
## [2.1.0] - 2026-09-19
|
||||
|
||||
Backward compatible changes:
|
||||
|
||||
@@ -61,7 +61,7 @@ ComfyUI list manipulation nodes (for processing individual items):
|
||||
|
||||
- **Creation**: create Data List (generic and type-specific versions)
|
||||
- **Modification**: append, extend, insert, set item, shuffle, remove, pop, pop random
|
||||
- **Filtering**: filter, filter select
|
||||
- **Filtering**: filter, filter empty, filter select
|
||||
- **Access**: get item, first, last, slice, index, contains
|
||||
- **Information**: length, count
|
||||
- **Operations**: sort, reverse, zip, min, max
|
||||
@@ -106,6 +106,7 @@ Python list manipulation nodes (as a single variable):
|
||||
- **Modification**: append, extend, insert, remove, pop, pop random, set_item, shuffle
|
||||
- **Access**: get_item, first, last, slice, index, contains
|
||||
- **Information**: length, count
|
||||
- **Filtering**: filter empty (optionally treating whitespace-only STRINGs as empty)
|
||||
- **Operations**: sort, reverse, min, max
|
||||
- **Conversion**: convert to data list, convert to SET
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Small helpers shared by the node modules (this is not a ComfyUI node module)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def as_bool(value: Any) -> bool:
|
||||
"""Normalize a ComfyUI BOOLEAN widget/input value into a real bool.
|
||||
|
||||
ComfyUI hands over real booleans, but legacy combo widgets ("True" / "False"
|
||||
strings) and nodes with ``INPUT_IS_LIST`` (values wrapped into a list) also
|
||||
occur, so all of them are accepted here.
|
||||
"""
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = value[0] if value else False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
return bool(value)
|
||||
|
||||
|
||||
def is_empty_entry(value: Any, treat_whitespace_as_empty: bool = False) -> bool:
|
||||
"""Tell whether an entry of a Data List / LIST has to be regarded as empty.
|
||||
|
||||
Empty are ``None``, an empty STRING and an empty collection (empty LIST,
|
||||
DICT, SET, ...). Numbers and booleans are never empty, so ``0`` and ``False``
|
||||
are kept.
|
||||
|
||||
When ``treat_whitespace_as_empty`` is enabled, a STRING that consists only of
|
||||
whitespace characters (spaces, tabs, line breaks, ...) counts as empty as
|
||||
well. The whitespace rule applies to STRINGs only; objects without a length
|
||||
(e.g. plain numbers or 0-dimensional TENSORs) are never empty.
|
||||
"""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
return not value.strip() if treat_whitespace_as_empty else not value
|
||||
try:
|
||||
return len(value) == 0
|
||||
except TypeError:
|
||||
return False
|
||||
@@ -16,6 +16,7 @@ except:
|
||||
ComfyNodeABC = object
|
||||
|
||||
from ._dynamic_input import ContainsDynamicDict
|
||||
from ._value_utils import as_bool, is_empty_entry
|
||||
|
||||
INT_MAX = 2**15 - 1 # the computer can do more but be nice to the eyes
|
||||
|
||||
@@ -501,6 +502,52 @@ class DataListFilter(ComfyNodeABC):
|
||||
return (result,)
|
||||
|
||||
|
||||
class DataListFilterEmpty(ComfyNodeABC):
|
||||
"""
|
||||
Removes every empty entry of a Data List.
|
||||
|
||||
This node takes a Data List and returns a new Data List without the entries
|
||||
that are empty, i.e. ``None``, an empty STRING and an empty collection
|
||||
(empty LIST, DICT, SET, ...). Numbers and booleans are never empty, so ``0``
|
||||
and ``False`` are kept.
|
||||
|
||||
When ``treat_whitespace_as_empty`` is enabled, a STRING that consists only of
|
||||
whitespace characters (spaces, tabs, line breaks, ...) counts as empty as
|
||||
well and is removed, too.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"list": (IO.ANY, {"tooltip": "The Data List whose empty entries are removed."}),
|
||||
},
|
||||
"optional": {
|
||||
"treat_whitespace_as_empty": (
|
||||
IO.BOOLEAN,
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "When enabled, STRINGs that consist only of whitespace (spaces, tabs, line breaks) are removed as well.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (IO.ANY,)
|
||||
RETURN_NAMES = ("list",)
|
||||
OUTPUT_TOOLTIPS = ("A new Data List without the empty entries.",)
|
||||
CATEGORY = "Basic/Data List"
|
||||
DESCRIPTION = cleandoc(__doc__ or "")
|
||||
FUNCTION = "filter_empty"
|
||||
INPUT_IS_LIST = True
|
||||
OUTPUT_IS_LIST = (True,)
|
||||
|
||||
def filter_empty(self, **kwargs: list[Any]) -> tuple[list[Any]]:
|
||||
treat_whitespace_as_empty = as_bool(kwargs.get("treat_whitespace_as_empty"))
|
||||
result = [value for value in (kwargs.get("list") or []) if not is_empty_entry(value, treat_whitespace_as_empty)]
|
||||
return (result,)
|
||||
|
||||
|
||||
class DataListFilterSelect(ComfyNodeABC):
|
||||
"""
|
||||
Filters a Data List using boolean values.
|
||||
@@ -1334,6 +1381,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"Basic data handling: DataListEnumerate": DataListEnumerate,
|
||||
"Basic data handling: DataListExtend": DataListExtend,
|
||||
"Basic data handling: DataListFilter": DataListFilter,
|
||||
"Basic data handling: DataListFilterEmpty": DataListFilterEmpty,
|
||||
"Basic data handling: DataListFilterSelect": DataListFilterSelect,
|
||||
"Basic data handling: DataListFirst": DataListFirst,
|
||||
"Basic data handling: DataListGetItem": DataListGetItem,
|
||||
@@ -1373,6 +1421,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Basic data handling: DataListEnumerate": "enumerate",
|
||||
"Basic data handling: DataListExtend": "extend",
|
||||
"Basic data handling: DataListFilter": "filter",
|
||||
"Basic data handling: DataListFilterEmpty": "filter empty",
|
||||
"Basic data handling: DataListFilterSelect": "filter select",
|
||||
"Basic data handling: DataListFirst": "first",
|
||||
"Basic data handling: DataListGetItem": "get item",
|
||||
|
||||
@@ -17,6 +17,7 @@ except:
|
||||
ComfyNodeABC = object
|
||||
|
||||
from ._dynamic_input import ContainsDynamicDict
|
||||
from ._value_utils import as_bool, is_empty_entry
|
||||
|
||||
INT_MAX = 2**15 - 1 # the computer can do more but be nice to the eyes
|
||||
|
||||
@@ -404,6 +405,50 @@ class ListExtend(ComfyNodeABC):
|
||||
return (result,)
|
||||
|
||||
|
||||
class ListFilterEmpty(ComfyNodeABC):
|
||||
"""
|
||||
Removes every empty entry of a LIST.
|
||||
|
||||
This node takes a LIST and returns a new LIST without the entries that are
|
||||
empty, i.e. ``None``, an empty STRING and an empty collection (empty LIST,
|
||||
DICT, SET, ...). Numbers and booleans are never empty, so ``0`` and ``False``
|
||||
are kept.
|
||||
|
||||
When ``treat_whitespace_as_empty`` is enabled, a STRING that consists only of
|
||||
whitespace characters (spaces, tabs, line breaks, ...) counts as empty as
|
||||
well and is removed, too.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"list": ("LIST", {"tooltip": "The LIST whose empty entries are removed."}),
|
||||
},
|
||||
"optional": {
|
||||
"treat_whitespace_as_empty": (
|
||||
IO.BOOLEAN,
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "When enabled, STRINGs that consist only of whitespace (spaces, tabs, line breaks) are removed as well.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LIST",)
|
||||
RETURN_NAMES = ("list",)
|
||||
OUTPUT_TOOLTIPS = ("A new LIST without the empty entries.",)
|
||||
CATEGORY = "Basic/LIST"
|
||||
DESCRIPTION = cleandoc(__doc__ or "")
|
||||
FUNCTION = "filter_empty"
|
||||
|
||||
def filter_empty(self, list: list[Any], treat_whitespace_as_empty: bool = False) -> tuple[list[Any]]:
|
||||
list = list if list is not None else []
|
||||
treat = as_bool(treat_whitespace_as_empty)
|
||||
return ([value for value in list if not is_empty_entry(value, treat)],)
|
||||
|
||||
|
||||
class ListFirst(ComfyNodeABC):
|
||||
"""
|
||||
Returns the first element in a LIST.
|
||||
@@ -1135,6 +1180,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"Basic data handling: ListCount": ListCount,
|
||||
"Basic data handling: ListEnumerate": ListEnumerate,
|
||||
"Basic data handling: ListExtend": ListExtend,
|
||||
"Basic data handling: ListFilterEmpty": ListFilterEmpty,
|
||||
"Basic data handling: ListFirst": ListFirst,
|
||||
"Basic data handling: ListGetItem": ListGetItem,
|
||||
"Basic data handling: ListIndex": ListIndex,
|
||||
@@ -1171,6 +1217,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Basic data handling: ListCount": "count",
|
||||
"Basic data handling: ListEnumerate": "enumerate",
|
||||
"Basic data handling: ListExtend": "extend",
|
||||
"Basic data handling: ListFilterEmpty": "filter empty",
|
||||
"Basic data handling: ListFirst": "first",
|
||||
"Basic data handling: ListGetItem": "get item",
|
||||
"Basic data handling: ListIndex": "index",
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.basic_data_handling.data_list_nodes import (
|
||||
DataListEnumerate,
|
||||
DataListExtend,
|
||||
DataListFilter,
|
||||
DataListFilterEmpty,
|
||||
DataListFilterSelect,
|
||||
DataListFirst,
|
||||
DataListGetItem,
|
||||
@@ -185,6 +186,20 @@ def test_filter():
|
||||
assert node.filter_data(value=[1, 2, 3], filter=[False, False, False]) == ([1, 2, 3],)
|
||||
|
||||
|
||||
def test_filter_empty():
|
||||
node = DataListFilterEmpty()
|
||||
assert node.filter_empty(list=[1, 2, 3]) == ([1, 2, 3],)
|
||||
assert node.filter_empty(list=[None, 1, "", 2, [], 3, {}, set()]) == ([1, 2, 3],)
|
||||
assert node.filter_empty(list=[0, False, 0.0, "", None]) == ([0, False, 0.0],) # 0 and False are not empty
|
||||
assert node.filter_empty(list=["", " ", "\t", "a"]) == ([" ", "\t", "a"],) # whitespace is kept by default
|
||||
assert node.filter_empty(list=["", " ", "\t", "a"], treat_whitespace_as_empty=[True]) == (["a"],)
|
||||
assert node.filter_empty(list=[], treat_whitespace_as_empty=[True]) == ([],)
|
||||
|
||||
# Entries without a length (e.g. TENSORs) are never regarded as empty
|
||||
opaque = object()
|
||||
assert node.filter_empty(list=[opaque, None, ""]) == ([opaque],)
|
||||
|
||||
|
||||
def test_min():
|
||||
node = DataListMin()
|
||||
assert node.find_min(list=[3, 1, 2]) == (1,)
|
||||
@@ -436,6 +451,7 @@ def test_data_list_none_source():
|
||||
assert DataListEnumerate().enumerate_list(list=None) == ([],)
|
||||
assert DataListExtend().extend(list_a=None, list_b=None) == ([],)
|
||||
assert DataListFilter().filter_data(value=None, filter=None) == ([],)
|
||||
assert DataListFilterEmpty().filter_empty(list=None) == ([],)
|
||||
assert DataListFilterSelect().select(value=None, select=None) == ([], [])
|
||||
assert DataListFirst().get_first_element(list=None) == (None,)
|
||||
assert DataListGetItem().get_item(list=None, index=[0]) == (None,)
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.basic_data_handling.list_nodes import (
|
||||
ListCreateFromJSONString,
|
||||
ListEnumerate,
|
||||
ListExtend,
|
||||
ListFilterEmpty,
|
||||
ListFirst,
|
||||
ListGetItem,
|
||||
ListIndex,
|
||||
@@ -406,6 +407,18 @@ def test_list_sum():
|
||||
assert float_result == 6.5
|
||||
|
||||
|
||||
def test_list_filter_empty():
|
||||
node = ListFilterEmpty()
|
||||
assert node.filter_empty([1, None, 2, "", 3]) == ([1, 2, 3],)
|
||||
assert node.filter_empty([None, [], {}, set(), "a"]) == (["a"],)
|
||||
assert node.filter_empty([0, False, 0.0, [], None]) == ([0, False, 0.0],) # 0 and False are not empty
|
||||
assert node.filter_empty(["", " ", "\t", "a"]) == ([" ", "\t", "a"],) # whitespace is kept by default
|
||||
assert node.filter_empty(["", " ", "\t", "a"], True) == (["a"],)
|
||||
assert node.filter_empty([" \n "], "True") == ([],) # tolerant with legacy combo widgets
|
||||
assert node.filter_empty([]) == ([],)
|
||||
assert node.filter_empty(None) == ([],)
|
||||
|
||||
|
||||
def test_list_none_source():
|
||||
"""Graceful behaviour when the input LIST source is None."""
|
||||
assert ListAll().check_all(None) == (True,)
|
||||
@@ -415,6 +428,7 @@ def test_list_none_source():
|
||||
assert ListCount().count(None, "value") == (0,)
|
||||
assert ListEnumerate().enumerate_list(None) == ([[]],)
|
||||
assert ListExtend().extend(None, None) == ([],)
|
||||
assert ListFilterEmpty().filter_empty(None) == ([],)
|
||||
assert ListFirst().get_first_element(None) == (None,)
|
||||
assert ListGetItem().get_item(None, 0) == (None,)
|
||||
assert ListIndex().index(None, "value") == (-1,)
|
||||
|
||||
Reference in New Issue
Block a user