Skip to content
Merged
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
10 changes: 8 additions & 2 deletions src/specify_cli/workflows/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,14 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
)
return _filter_from_json(value)

# Parse filter name and argument
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
# Parse filter name and argument. Use fullmatch (not match) so trailing
# tokens after the closing paren — e.g. a comparison/boolean operator that
# binds looser than the pipe, as in ``count | default(0) > 5`` — are not
# silently discarded but fall through to the "unsupported form" ValueError
# below, mirroring the strict trailing-token handling of the from_json
# branch above. The greedy ``.+`` still handles literal ``)`` and ``|``
# inside quoted args.
filter_match = re.fullmatch(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
Expand Down
22 changes: 22 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,28 @@ def test_registered_filter_unsupported_form_raises(self):
):
evaluate_expression("{{ inputs.tags | map }}", ctx)

def test_filter_call_with_trailing_tokens_fails_loudly(self):
# A trailing operator/token after a filter's closing paren must not be
# silently discarded (the parser used an unanchored regex). It must
# fall through to the "unsupported form" ValueError, like the from_json
# branch's strict trailing-token handling.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext

# A comparison after a filter (binds looser than the pipe) was dropped,
# so `default('7') > '5'` silently returned '7'.
with pytest.raises(ValueError, match="unsupported form"):
evaluate_expression(
"{{ inputs.missing | default('7') > '5' }}", StepContext(inputs={})
)
# Trailing garbage after a valid filter call.
with pytest.raises(ValueError, match="unsupported form"):
evaluate_expression(
"{{ inputs.tags | join(',') extra }}",
StepContext(inputs={"tags": ["a", "b"]}),
)

def test_chained_filters_apply_left_to_right(self):
# Filters chain: each filter's result feeds the next. `map` yields a
# list and `join` is the only filter that renders a list to a string,
Expand Down