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
22 changes: 20 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_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:
Expand Down Expand Up @@ -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.

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.

`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