Skip to content

[🐛 Bug]: IOHandler silently drops empty list/dict/tuple (and literal False) arguments to keep.* template functions #6728

Description

@Prabal864

Describe the bug

IOHandler's template-function-call parser (keep/iohandler/iohandler.py, inside _parse_token's nested _parse()) builds the positional-argument list for a keep.* function call with this guard:

# keep/iohandler/iohandler.py, lines 334-343
# if the value is empty '', we still need to pass it to the function
# also, if the value is 0 or 0.0, we need to pass it to the function
# 0 == False, so we need to check if the value is not False explicitly
if (
    _arg
    or _arg == ""
    or (_arg == 0 or _arg == 0.0)
    and _arg is not False
):
    _args.append(_arg)

The comment says this exists specifically so "" and 0/0.0 aren't dropped as "empty" - but the guard only special-cases those two, and misses:

  1. Empty containers. A literal [], {}, or () argument (parsed a few lines above via ast.literal_eval/astunparse.unparse, producing a real empty list/dict/tuple object) is falsy, not == "", and not == 0/0.0 - so it's silently dropped from _args instead of being passed to the function.
  2. Literal False. False == 0 is True in Python, so (_arg == 0 or _arg == 0.0) is True for _arg is False too - but the guard then requires and _arg is not False, which evaluates to False, so the whole condition is False and a literal False argument is also silently dropped.

To Reproduce

The exact guard, copied verbatim, exercised against the real ast-based parsing used for []/{}/()/False template-function arguments:

import ast

def compute_arg(arg):
    if isinstance(arg, ast.Dict):
        return ast.literal_eval(arg)
    if isinstance(arg, (ast.Set, ast.List, ast.Tuple)):
        return ast.literal_eval(ast.unparse(arg).strip())
    if isinstance(arg, ast.Constant):
        return arg.value  # covers the literal `False` case
    return arg.id

def parse_args(src):
    tree = ast.parse(src, mode="eval").body
    _args = []
    for arg in tree.args:
        _arg = compute_arg(arg)
        # === exact guard from iohandler.py lines 337-343 ===
        if _arg or _arg == "" or (_arg == 0 or _arg == 0.0) and _arg is not False:
            _args.append(_arg)
        # =====================================================
    return _args

for src in ["f([], 'x')", "f({}, 'x')", "f((), 'x')", "f([1, 2], 'x')"]:
    print(src, "->", parse_args(src))

Output:

f([], 'x')     -> ['x']          # BUG: the [] vanished
f({}, 'x')     -> ['x']          # BUG: the {} vanished
f((), 'x')     -> ['x']          # BUG: the () vanished
f([1, 2], 'x') -> [[1, 2], 'x']  # correct: non-empty list preserved

Expected behavior

Any argument that was actually parsed to a real value - including [], {}, (), and False - should be passed through to the function positionally. Only a genuinely-unparseable/no-value case should be dropped.

Additional context

  • Verified against keep/iohandler/iohandler.py on current main (commit range including through PR fix(providers): don't crash on empty choices in grok #6710 / f35afdd).
  • Failure scenario: any workflow YAML step calling a keep.* template function where one positional argument is a field/step-output that legitimately renders to an empty list or dict (or the literal False), e.g. a literal {{ keep.some_function([], "fallback") }}, or a variable that resolves to []/{}/False at runtime. The empty/False argument silently vanishes, which shifts every subsequent positional argument one slot to the left - either raising a confusing TypeError about missing/extra arguments deep inside keep.functions, or (worse) silently binding the wrong value to the wrong parameter with no error at all.
  • Small, safe fix: since _arg is only ever left as its None sentinel default when none of the parsing branches produced a value (e.g. a nested keep.* call that itself returned nothing), the guard can simply become if _arg is not None: - which correctly keeps "", 0, 0.0, False, [], {}, and (), and only drops the genuine "nothing was parsed" case. Happy to send a PR for this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions