diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 6ea3a5f494..5264ce55ea 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -230,11 +230,14 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: if expr[:1] in ("'", '"') and expr.find(expr[0], 1) == len(expr) - 1: return expr[1:-1] - # Handle pipe filters - if "|" in expr: - parts = expr.split("|", 1) - value = _evaluate_simple_expression(parts[0].strip(), namespace) - filter_expr = parts[1].strip() + # Handle pipe filters. Split at the first *top-level* ``|`` so a pipe inside + # a quoted operand (e.g. the ``|`` in ``mode == 'read|write'``) or nested + # brackets is not mistaken for a filter separator. A naive ``"|" in expr`` + # check would mis-parse such operands and raise a spurious "unknown filter". + pipe_idx = _find_top_level(expr, "|") + if pipe_idx != -1: + value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace) + filter_expr = expr[pipe_idx + 1:].strip() # `from_json` is strict: it takes no arguments and tolerates no # trailing tokens. Match on the leading filter name and require the diff --git a/tests/test_workflows.py b/tests/test_workflows.py index cee02c46ba..ebb1475aec 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -322,6 +322,28 @@ 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_pipe_splitting_is_quote_aware(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + # A '|' INSIDE a quoted operand must not be treated as a filter + # separator: the comparison applies to the whole string literal. + # Previously this raised ValueError("unknown filter 'write'") because + # the pipe split was not quote-aware (unlike and/or/comparison). + ctx = StepContext(inputs={"mode": "read|write"}) + assert evaluate_expression("{{ inputs.mode == 'read|write' }}", ctx) is True + assert evaluate_expression("{{ inputs.mode != 'a|b' }}", ctx) is True + + # A single quoted literal that itself contains a pipe is preserved. + assert evaluate_expression("{{ 'read|write' }}", StepContext()) == "read|write" + + # A pipe used as a real filter argument still works (separator is '|'). + joined = StepContext(inputs={"tags": ["a", "b", "c"]}) + assert evaluate_expression("{{ inputs.tags | join('|') }}", joined) == "a|b|c" + + # Regression: an ordinary (top-level) filter still applies. + assert evaluate_expression("{{ inputs.missing | default('x') }}", StepContext()) == "x" + def test_filter_default(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext