diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index b7ed17e801..b3e1de8016 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -383,9 +383,25 @@ def evaluate_expression(template: str, context: Any) -> Any: namespace = _build_namespace(context) - # Single expression: return typed value - match = _EXPR_PATTERN.fullmatch(template.strip()) - if match: + # Single expression: return typed value (preserving type). + # + # The fast path must fire only when the whole template is one ``{{ ... }}`` + # block. ``fullmatch`` cannot decide this: the pattern's non-greedy body + # ``(.+?)`` is defeated by ``fullmatch``, so ``"{{ a }} {{ b }}"`` still + # matches with the body expanded to ``"a }} {{ b"`` -- garbage that fails + # resolution and returns ``None``, bypassing the ``sub()`` interpolation + # path that handles each expression correctly (issue #3208). + # + # Anchor a single match at the start instead and require it to consume the + # entire stripped string. The non-greedy body then stops at the first + # ``}}``: a genuine two-block template leaves a trailing ``}}`` and fails the + # span check, while a lone expression -- even one with a literal ``{{`` in a + # string argument such as ``{{ inputs.text | contains('{{') }}`` -- matches + # to the end and keeps its typed return value. (Counting ``{{`` would + # misclassify that expression as multi-block and coerce it to ``str``.) + stripped = template.strip() + match = _EXPR_PATTERN.match(stripped) + if match and match.end() == len(stripped): return _evaluate_simple_expression(match.group(1).strip(), namespace) # Multi-expression: string interpolation diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 988730d783..8e6393b963 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -226,6 +226,40 @@ def test_string_interpolation(self): result = evaluate_expression("Feature: {{ inputs.name }} done", ctx) assert result == "Feature: login done" + def test_multi_expression_no_surrounding_text(self): + """Two expressions with no surrounding literal text must interpolate each, + not collapse to None via the fullmatch fast path (#3208).""" + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"issue": "23"}, run_id="47c5eb4b") + result = evaluate_expression( + "{{ context.run_id }} {{ inputs.issue }}", ctx + ) + assert result == "47c5eb4b 23" + + def test_multi_expression_adjacent_no_separator(self): + """Back-to-back expressions with no separator still interpolate (#3208).""" + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"a": "foo", "b": "bar"}) + result = evaluate_expression("{{ inputs.a }}{{ inputs.b }}", ctx) + assert result == "foobar" + + def test_single_expression_with_literal_braces_preserves_type(self): + """A lone expression whose string argument contains a literal ``{{`` or ``}}`` + must still take the typed fast path and return a bool, not a string + (the fix for #3208 must not coerce it to ``\"True\"``).""" + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"text": "uses {{ jinja }} syntax"}) + assert evaluate_expression("{{ inputs.text | contains('{{') }}", ctx) is True + + ctx = StepContext(inputs={"text": "uses }} syntax"}) + assert evaluate_expression("{{ inputs.text | contains('}}') }}", ctx) is True + def test_comparison_equals(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext