Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/specify_cli/workflows/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
28 changes: 28 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down