diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index d6736bc321..9953f5ff61 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_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. diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 00e0bafedb..1791f3192f 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -460,6 +460,34 @@ def test_pipe_detection_is_quote_aware(self): 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