fix(workflows): don't crash on membership test against a non-iterable (#3448)

* fix(workflows): don't crash on membership test against a non-iterable

the `in` / `not in` operators in _evaluate_simple_expression only guarded
`right is not None`, so `left in right` still raised a raw TypeError when the
right operand was any other non-iterable (int, bool, float). a condition like
`{{ inputs.tag in inputs.count }}` where count is a number crashed the whole
workflow run instead of evaluating.

nothing is contained in a non-iterable, so treat membership as False (`not in`
as True) via a new _safe_membership helper that swallows TypeError. this
generalizes the old None guard and mirrors _safe_compare, which already
catches TypeError for the ordering operators.

added a regression test; confirmed it fails on the pre-fix code (raw
TypeError) and that genuine list/substring membership still works.

* address review: float membership case + broaden _safe_membership docstring

- add a float right-operand assertion so the test matches its comment (was
  claiming float coverage while only exercising int/bool/None).
- reword the _safe_membership docstring to describe TypeError generally
  (non-iterable right is the common case, but also e.g. an unhashable left
  against a set) rather than implying only the right operand matters.
This commit is contained in:
Quratulain-bilal
2026-07-13 20:20:48 +05:00
committed by GitHub
parent 55c66125f0
commit 86d769b47c
2 changed files with 50 additions and 2 deletions

View File

@@ -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_membership(left, right, negate=False)
if op == " not in ":
return left not in right if right is not None else True
return _safe_membership(left, right, negate=True)
# Numeric literal
try:
@@ -511,6 +511,26 @@ def _coerce_number(value: Any) -> Any:
return value
def _safe_membership(left: Any, right: Any, *, negate: bool) -> bool:
"""Safely evaluate ``left in right`` (or ``not in``) without crashing.
``left in right`` raises ``TypeError`` whenever the operands don't support
membership testing — most commonly a non-iterable right operand (``None``,
an int, a bool), but also cases like an unhashable ``left`` against a set.
In every such case the membership relation is undefined, so treat it as
``False`` (``not in`` as ``True``) rather than leaking the error out of the
evaluator and crashing the whole workflow. Mirrors the graceful
``TypeError`` handling in ``_safe_compare`` for the ordering operators, and
generalizes the previous ``right is not None`` guard to any operand pair
that can't be membership-tested.
"""
try:
contained = left in right
except TypeError:
contained = False
return not contained if negate else contained
def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Compare two values for ordering, coercing numeric strings when possible.

View File

@@ -460,6 +460,34 @@ class TestExpressions:
assert evaluate_expression("{{ inputs.s | contains('ab') }}", ctx2) is True
assert evaluate_expression("{{ inputs.missing | default('a|b') }}", ctx2) == "a|b"
def test_membership_against_non_iterable_is_false_not_error(self):
from specify_cli.workflows.expressions import (
evaluate_condition,
evaluate_expression,
)
from specify_cli.workflows.base import StepContext
# A non-iterable right operand (int, bool, None, float) makes a raw
# `x in y` raise TypeError in Python. The evaluator must treat it as
# "not contained" (False, and `not in` as True) instead of leaking the
# TypeError and crashing the whole workflow run. This generalizes the
# previous `right is not None` guard and mirrors _safe_compare, which
# already swallows TypeError for the ordering operators.
ctx = StepContext(inputs={"tag": "x", "count": 5, "ratio": 1.5, "flag": True})
assert evaluate_expression("{{ inputs.tag in inputs.count }}", ctx) is False
assert evaluate_expression("{{ inputs.tag not in inputs.count }}", ctx) is True
assert evaluate_expression("{{ 'a' in inputs.ratio }}", ctx) is False
assert evaluate_expression("{{ 'a' in inputs.flag }}", ctx) is False
assert evaluate_expression("{{ inputs.tag in inputs.missing }}", ctx) is False
# A condition that would otherwise crash the run now evaluates cleanly.
assert evaluate_condition("{{ inputs.tag in inputs.count }}", ctx) is False
# Regression: genuine membership over a real iterable still works.
ok = StepContext(inputs={"items": ["x", "y"], "s": "xyz"})
assert evaluate_expression("{{ 'x' in inputs.items }}", ok) is True
assert evaluate_expression("{{ 'z' not in inputs.items }}", ok) is True
assert evaluate_expression("{{ 'y' in inputs.s }}", ok) is True
def test_filter_default(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext