Skip to content

Commit c1722a4

Browse files
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>
1 parent 6688b44 commit c1722a4

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

src/specify_cli/workflows/expressions.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,38 @@ def _filter_default(value: Any, default_value: Any = "") -> Any:
3535

3636

3737
def _filter_join(value: Any, separator: str = ", ") -> str:
38-
"""Join a list into a string with *separator*."""
38+
"""Join a list into a string with *separator*.
39+
40+
Raises ``ValueError`` when *separator* is not a string. Without the guard a
41+
non-string separator (an authoring mistake like ``| join(5)``) reaches
42+
``str.join`` and raises a cryptic ``AttributeError: 'int' object has no
43+
attribute 'join'`` that escapes the evaluator and crashes the whole run,
44+
since the engine wraps neither expression evaluation nor ``execute`` in a
45+
try/except. Mirrors the strict argument handling in ``from_json``.
46+
"""
47+
if not isinstance(separator, str):
48+
raise ValueError(
49+
f"join: expected a string separator, got {type(separator).__name__}"
50+
)
3951
if isinstance(value, list):
4052
return separator.join(str(v) for v in value)
4153
return str(value)
4254

4355

4456
def _filter_map(value: Any, attr: str) -> list[Any]:
45-
"""Map a list of dicts to a specific attribute."""
57+
"""Map a list of dicts to a specific attribute.
58+
59+
Raises ``ValueError`` when *attr* is not a string. Without the guard a
60+
non-string attribute (an authoring mistake like ``| map(5)``) reaches
61+
``attr.split(".")`` and raises a cryptic ``AttributeError: 'int' object has
62+
no attribute 'split'`` that escapes the evaluator and crashes the whole run,
63+
since the engine wraps neither expression evaluation nor ``execute`` in a
64+
try/except. Mirrors the strict argument handling in ``from_json``.
65+
"""
66+
if not isinstance(attr, str):
67+
raise ValueError(
68+
f"map: expected a string attribute name, got {type(attr).__name__}"
69+
)
4670
if isinstance(value, list):
4771
result = []
4872
for item in value:
@@ -63,9 +87,25 @@ def _filter_map(value: Any, attr: str) -> list[Any]:
6387
return []
6488

6589

66-
def _filter_contains(value: Any, substring: str) -> bool:
67-
"""Check if a string or list contains *substring*."""
90+
def _filter_contains(value: Any, substring: Any) -> bool:
91+
"""Check if a string or list contains *substring*.
92+
93+
For a string *value*, *substring* must itself be a string: ``x in y`` on a
94+
string requires a string left operand, so a non-string argument (an
95+
authoring mistake like ``| contains(5)``) would otherwise raise a cryptic
96+
``TypeError`` that escapes the evaluator and crashes the whole run, since
97+
the engine wraps neither expression evaluation nor ``execute`` in a
98+
try/except. Raise a ``ValueError`` naming the problem instead, mirroring the
99+
strict argument handling in ``from_json``. For a list *value*, membership of
100+
any element type is legitimate (``5 in [1, 2, 5]``), so that branch is left
101+
unguarded.
102+
"""
68103
if isinstance(value, str):
104+
if not isinstance(substring, str):
105+
raise ValueError(
106+
"contains: expected a string argument when the value is a "
107+
f"string, got {type(substring).__name__}"
108+
)
69109
return substring in value
70110
if isinstance(value, list):
71111
return substring in value

tests/test_workflows.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,51 @@ def test_filter_unknown_name_with_args_raises(self):
586586
with pytest.raises(ValueError, match="unknown filter 'upper'"):
587587
evaluate_expression("{{ inputs.text | upper('x') }}", ctx)
588588

589+
def test_filter_map_non_string_attr_raises(self):
590+
# A non-string attribute (authoring mistake like `map(5)`) must raise a
591+
# ValueError naming the problem, not leak the cryptic AttributeError
592+
# from attr.split() that would escape the evaluator and crash the run.
593+
import pytest
594+
from specify_cli.workflows.expressions import evaluate_expression
595+
from specify_cli.workflows.base import StepContext
596+
597+
ctx = StepContext(inputs={"rows": [{"id": "a"}, {"id": "b"}]})
598+
with pytest.raises(ValueError, match="map: expected a string attribute name"):
599+
evaluate_expression("{{ inputs.rows | map(5) }}", ctx)
600+
601+
def test_filter_join_non_string_separator_raises(self):
602+
# A non-string separator (authoring mistake like `join(5)`) must raise a
603+
# ValueError, not leak the cryptic AttributeError from str.join.
604+
import pytest
605+
from specify_cli.workflows.expressions import evaluate_expression
606+
from specify_cli.workflows.base import StepContext
607+
608+
ctx = StepContext(inputs={"tags": ["a", "b"]})
609+
with pytest.raises(ValueError, match="join: expected a string separator"):
610+
evaluate_expression("{{ inputs.tags | join(5) }}", ctx)
611+
612+
def test_filter_contains_non_string_arg_on_string_raises(self):
613+
# For a string value, `contains` requires a string argument: `x in y` on
614+
# a string needs a string left operand. A non-string argument must raise
615+
# a ValueError, not leak the cryptic TypeError that would crash the run.
616+
import pytest
617+
from specify_cli.workflows.expressions import evaluate_expression
618+
from specify_cli.workflows.base import StepContext
619+
620+
ctx = StepContext(inputs={"text": "hello"})
621+
with pytest.raises(ValueError, match="contains: expected a string argument"):
622+
evaluate_expression("{{ inputs.text | contains(5) }}", ctx)
623+
624+
def test_filter_contains_non_string_arg_on_list_ok(self):
625+
# For a list value, membership of any element type is legitimate, so a
626+
# non-string argument stays valid and is not rejected.
627+
from specify_cli.workflows.expressions import evaluate_expression
628+
from specify_cli.workflows.base import StepContext
629+
630+
ctx = StepContext(inputs={"nums": [1, 2, 5]})
631+
assert evaluate_expression("{{ inputs.nums | contains(5) }}", ctx) is True
632+
assert evaluate_expression("{{ inputs.nums | contains(9) }}", ctx) is False
633+
589634
def test_registered_filters_unaffected(self):
590635
# Regression: all five registered filters keep working unchanged.
591636
from specify_cli.workflows.expressions import evaluate_expression

0 commit comments

Comments
 (0)