Skip to content
Open
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
22 changes: 19 additions & 3 deletions src/specify_cli/workflows/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading