- 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:
StableLlama
2026-10-16 17:22:16 +02:00
parent 678e48fcf6
commit 26878b18f1
7 changed files with 187 additions and 1 deletions
+20
View File
@@ -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:
+2 -1
View File
@@ -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
+39
View File
@@ -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",
+47
View File
@@ -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",
+16
View File
@@ -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,)
+14
View File
@@ -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,)