From 1a8fdbc5be882f578d15771033481921934305fc Mon Sep 17 00:00:00 2001 From: Noor-ul-ain001 Date: Sat, 11 Jul 2026 10:55:04 +0500 Subject: [PATCH] fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) The `in` / `not in` operators in `_evaluate_simple_expression` only guarded `right is not None`, but `left in right` also raises `TypeError` for any other non-iterable right operand (int, bool, float). So a workflow condition like `{{ inputs.tag in inputs.count }}` where `count` is a number leaked a raw `TypeError: argument of type 'int' is not iterable` and crashed the whole run, instead of evaluating like the None case beside it. This was asymmetric with `_safe_compare`, which already swallows `TypeError` and returns False for the ordering operators. Add a `_safe_contains` helper (mirroring `_safe_compare`) that treats both a None and a non-container right operand as "nothing is contained": `in` -> False, `not in` -> True. Add a regression test covering int/bool/float/None right operands and asserting genuine containment against iterables still works. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/workflows/expressions.py | 22 ++++++++++++++++++++-- tests/test_workflows.py | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index d6736bc321..45826d0295 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -464,9 +464,9 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: if op == "<=": return _safe_compare(left, right, "<=") if op == " in ": - return left in right if right is not None else False + return _safe_contains(left, right) if op == " not in ": - return left not in right if right is not None else True + return not _safe_contains(left, right) # Numeric literal try: @@ -511,6 +511,24 @@ def _coerce_number(value: Any) -> Any: return value +def _safe_contains(left: Any, right: Any) -> bool: + """Return ``left in right``, treating a non-container *right* as empty. + + ``left in right`` raises ``TypeError`` when *right* is not a container or + iterable (int, bool, float, ...) and raises no membership at all when it is + ``None``. Both cases mean "nothing is contained", so return ``False`` rather + than leaking a raw ``TypeError`` that crashes the workflow run. This mirrors + ``_safe_compare``, which already swallows ``TypeError`` for the ordering + operators. ``not in`` is derived by negating this result. + """ + if right is None: + return False + try: + return left in right + except TypeError: + return False + + def _safe_compare(left: Any, right: Any, op: str) -> bool: """Compare two values for ordering, coercing numeric strings when possible. diff --git a/tests/test_workflows.py b/tests/test_workflows.py index d7cff20f6d..e214468f13 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -439,6 +439,29 @@ def test_operator_splitting_is_quote_aware(self): assert evaluate_expression("{{ inputs.a == 9 or inputs.b == 2 }}", plain) is True assert evaluate_expression("{{ inputs.missing | default('a and b') }}", plain) == "a and b" + def test_in_operator_non_iterable_right_operand(self): + """`in`/`not in` against a non-iterable right operand must not crash. + + `left in right` raises TypeError when right is an int/bool/float, which + used to leak a raw traceback and crash the run. It should evaluate like + the `right is None` branch beside it: nothing is contained in a + non-container, so `in` -> False and `not in` -> True (issue #3447). + """ + from specify_cli.workflows.expressions import evaluate_condition + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"tag": "x", "count": 5, "flag": True, "ratio": 1.5}) + # in -> False, not in -> True for every non-iterable right operand. + for right in ("count", "flag", "ratio"): + assert evaluate_condition(f"{{{{ inputs.tag in inputs.{right} }}}}", ctx) is False + assert evaluate_condition(f"{{{{ inputs.tag not in inputs.{right} }}}}", ctx) is True + # A missing (None) right operand keeps the same behavior. + assert evaluate_condition("{{ inputs.tag in inputs.missing }}", ctx) is False + assert evaluate_condition("{{ inputs.tag not in inputs.missing }}", ctx) is True + # Genuine containment against iterables still works (no regression). + assert evaluate_condition("{{ 'cat' in 'cat and dog' }}", StepContext()) is True + assert evaluate_condition("{{ 'zzz' not in 'cat and dog' }}", StepContext()) is True + def test_pipe_detection_is_quote_aware(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext