fix(workflows): raise a clear error, not a cryptic crash, on non-string filter args (#3522)

The `map`, `join`, and `contains` expression filters assumed their
argument was a string. A non-string argument — an authoring mistake such
as `| map(5)`, `| join(5)`, or `| contains(5)` — reached an operation
that only strings support and raised a cryptic exception that escaped the
evaluator entirely:

  * `map(5)`      -> `attr.split(".")`  -> AttributeError
  * `join(5)`     -> `separator.join(...)` -> AttributeError
  * `contains(5)` on a string value -> `x in str` -> TypeError

The engine wraps neither expression evaluation nor `step_impl.execute()`
in a try/except, so each of these took down the whole run with a message
that names none of the real problem.

Validate the argument type up front and raise a `ValueError` naming the
filter and the offending type instead, mirroring the strict argument
handling already in `from_json`. `contains` guards only the string-value
branch: for a list value, membership of any element type is legitimate
(`5 in [1, 2, 5]`), so that branch is intentionally left unguarded.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Noor ul ain
2026-07-15 18:29:44 +05:00
committed by GitHub
parent 6688b447b7
commit c1722a425e
2 changed files with 89 additions and 4 deletions

View File

@@ -586,6 +586,51 @@ class TestExpressions:
with pytest.raises(ValueError, match="unknown filter 'upper'"):
evaluate_expression("{{ inputs.text | upper('x') }}", ctx)
def test_filter_map_non_string_attr_raises(self):
# A non-string attribute (authoring mistake like `map(5)`) must raise a
# ValueError naming the problem, not leak the cryptic AttributeError
# from attr.split() that would escape the evaluator and crash the run.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"rows": [{"id": "a"}, {"id": "b"}]})
with pytest.raises(ValueError, match="map: expected a string attribute name"):
evaluate_expression("{{ inputs.rows | map(5) }}", ctx)
def test_filter_join_non_string_separator_raises(self):
# A non-string separator (authoring mistake like `join(5)`) must raise a
# ValueError, not leak the cryptic AttributeError from str.join.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"tags": ["a", "b"]})
with pytest.raises(ValueError, match="join: expected a string separator"):
evaluate_expression("{{ inputs.tags | join(5) }}", ctx)
def test_filter_contains_non_string_arg_on_string_raises(self):
# For a string value, `contains` requires a string argument: `x in y` on
# a string needs a string left operand. A non-string argument must raise
# a ValueError, not leak the cryptic TypeError that would crash the run.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"text": "hello"})
with pytest.raises(ValueError, match="contains: expected a string argument"):
evaluate_expression("{{ inputs.text | contains(5) }}", ctx)
def test_filter_contains_non_string_arg_on_list_ok(self):
# For a list value, membership of any element type is legitimate, so a
# non-string argument stays valid and is not rejected.
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"nums": [1, 2, 5]})
assert evaluate_expression("{{ inputs.nums | contains(5) }}", ctx) is True
assert evaluate_expression("{{ inputs.nums | contains(9) }}", ctx) is False
def test_registered_filters_unaffected(self):
# Regression: all five registered filters keep working unchanged.
from specify_cli.workflows.expressions import evaluate_expression