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
29 changes: 13 additions & 16 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 _safe_membership(left, right, negate=False)
return _safe_contains(left, right)
if op == " not in ":
return _safe_membership(left, right, negate=True)
return not _safe_contains(left, right)
Comment on lines +467 to +469

# Numeric literal
try:
Expand Down Expand Up @@ -511,24 +511,21 @@ 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.
def _safe_contains(left: Any, right: Any) -> bool:
"""Return ``left in right`` safely.

``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.
Returns ``False`` when *right* is ``None`` or when the membership test raises
``TypeError`` (e.g. non-iterable *right*, unhashable *left*, or incompatible
operand types), instead of propagating the exception and crashing the workflow.

``not in`` is derived by negating this result.
"""
if right is None:
return False
try:
contained = left in right
return left in right
except TypeError:
contained = False
return not contained if negate else contained
return False


def _safe_compare(left: Any, right: Any, op: str) -> bool:
Expand Down
23 changes: 23 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +442 to +443

`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
Expand Down
Loading