Skip to content

Commit 2a9db1d

Browse files
jawwad-aliclaude
andauthored
fix(workflows): make expression operator/literal parsing quote-aware (#3197)
_evaluate_simple_expression split on operator keywords using naive str.find/split, so a keyword INSIDE a quoted operand was treated as an operator: `inputs.mode == 'read and write'` split on the inner ' and ' and evaluated as `(inputs.mode == 'read) and (write')`. The literal short-circuit was also too greedy -- `'a' == 'b'` matched startswith("'")/endswith("'") and was stripped to the garbage truthy string `a' == 'b`, so `'done' == 'failed'` evaluated truthy and gated the wrong branch. Add a quote/bracket-aware _find_top_level helper (mirroring the existing _split_top_level_commas) and use it for the and/or/comparison/in/not-in splits; tighten the literal short-circuit to fire only when the opening quote's match is the final char. The docstring already lists comparisons + and/or/not + in/not-in + string literals as supported, so this restores the documented contract. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fd185c1 commit 2a9db1d

2 files changed

Lines changed: 89 additions & 19 deletions

File tree

‎src/specify_cli/workflows/expressions.py‎

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,35 @@ def _split_top_level_commas(text: str) -> list[str]:
180180
return parts
181181

182182

183+
def _find_top_level(text: str, token: str) -> int:
184+
"""Return the index of the first occurrence of *token* in *text* that lies
185+
outside any quoted string or nested bracket, or ``-1`` if there is none.
186+
187+
Used so operator/keyword splitting (``and``/``or``/``in``/comparisons) does
188+
not match a separator that appears *inside* a quoted operand -- e.g. the
189+
``and`` in ``mode == 'read and write'`` or the ``or`` in ``'approve or reject'``.
190+
"""
191+
quote: str | None = None
192+
depth = 0
193+
i = 0
194+
n = len(text)
195+
while i < n:
196+
ch = text[i]
197+
if quote is not None:
198+
if ch == quote:
199+
quote = None
200+
elif ch in ("'", '"'):
201+
quote = ch
202+
elif ch in "([{":
203+
depth += 1
204+
elif ch in ")]}":
205+
depth = max(0, depth - 1)
206+
elif depth == 0 and text.startswith(token, i):
207+
return i
208+
i += 1
209+
return -1
210+
211+
183212
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
184213
"""Evaluate a simple expression against the namespace.
185214
@@ -193,11 +222,12 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
193222
"""
194223
expr = expr.strip()
195224

196-
# String literal — check before pipes and operators so quoted strings
197-
# containing | or operator keywords are not mis-parsed.
198-
if (expr.startswith("'") and expr.endswith("'")) or (
199-
expr.startswith('"') and expr.endswith('"')
200-
):
225+
# String literal — only when the WHOLE expression is one quoted string,
226+
# i.e. the opening quote's matching close is the final character. Checking
227+
# startswith/endswith alone would also grab `'a' == 'b'` and strip it to the
228+
# garbage `a' == 'b`; a genuine single literal short-circuits here so quoted
229+
# strings containing `|` or operator keywords are not mis-parsed downstream.
230+
if expr[:1] in ("'", '"') and expr.find(expr[0], 1) == len(expr) - 1:
201231
return expr[1:-1]
202232

203233
# Handle pipe filters
@@ -262,29 +292,33 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
262292
)
263293

264294
# Boolean operators — parse 'or' first (lower precedence) so that
265-
# 'a or b and c' is evaluated as 'a or (b and c)'.
266-
if " or " in expr:
267-
parts = expr.split(" or ", 1)
268-
left = _evaluate_simple_expression(parts[0].strip(), namespace)
269-
right = _evaluate_simple_expression(parts[1].strip(), namespace)
295+
# 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket
296+
# aware so a keyword inside a quoted operand (e.g. the 'and' in
297+
# 'read and write') is not mistaken for an operator.
298+
or_idx = _find_top_level(expr, " or ")
299+
if or_idx != -1:
300+
left = _evaluate_simple_expression(expr[:or_idx].strip(), namespace)
301+
right = _evaluate_simple_expression(expr[or_idx + 4:].strip(), namespace)
270302
return bool(left) or bool(right)
271303

272-
if " and " in expr:
273-
parts = expr.split(" and ", 1)
274-
left = _evaluate_simple_expression(parts[0].strip(), namespace)
275-
right = _evaluate_simple_expression(parts[1].strip(), namespace)
304+
and_idx = _find_top_level(expr, " and ")
305+
if and_idx != -1:
306+
left = _evaluate_simple_expression(expr[:and_idx].strip(), namespace)
307+
right = _evaluate_simple_expression(expr[and_idx + 5:].strip(), namespace)
276308
return bool(left) and bool(right)
277309

278310
if expr.startswith("not "):
279311
inner = _evaluate_simple_expression(expr[4:].strip(), namespace)
280312
return not bool(inner)
281313

282-
# Comparison operators (order matters — check multi-char ops first)
314+
# Comparison operators (order matters — check multi-char ops first). Split at
315+
# the first top-level occurrence so an operator inside a quoted operand is
316+
# ignored.
283317
for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "):
284-
if op in expr:
285-
parts = expr.split(op, 1)
286-
left = _evaluate_simple_expression(parts[0].strip(), namespace)
287-
right = _evaluate_simple_expression(parts[1].strip(), namespace)
318+
op_idx = _find_top_level(expr, op)
319+
if op_idx != -1:
320+
left = _evaluate_simple_expression(expr[:op_idx].strip(), namespace)
321+
right = _evaluate_simple_expression(expr[op_idx + len(op):].strip(), namespace)
288322
if op == "==":
289323
return left == right
290324
if op == "!=":

‎tests/test_workflows.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,42 @@ def test_list_literal_preserves_quoted_commas(self):
286286
assert evaluate_expression('{{ [["a", "b"], "c"] }}', ctx) == [["a", "b"], "c"]
287287
assert evaluate_expression("{{ [[1, 2], [3, 4]] }}", ctx) == [[1, 2], [3, 4]]
288288

289+
def test_operator_splitting_is_quote_aware(self):
290+
from specify_cli.workflows.expressions import (
291+
evaluate_condition,
292+
evaluate_expression,
293+
)
294+
from specify_cli.workflows.base import StepContext
295+
296+
# An 'and'/'or'/'in' keyword INSIDE a quoted operand must not be treated
297+
# as a boolean/membership operator: the comparison applies to the whole
298+
# string literal.
299+
ctx = StepContext(inputs={"mode": "read and write"})
300+
assert evaluate_expression("{{ inputs.mode == 'read and write' }}", ctx) is True
301+
assert evaluate_expression("{{ inputs.mode == 'read or write' }}", ctx) is False
302+
# ...also when the quoted literal is on the left of the operator.
303+
left_ctx = StepContext(inputs={"x": "approve or reject"})
304+
assert evaluate_expression("{{ 'approve or reject' == inputs.x }}", left_ctx) is True
305+
# membership against a literal that contains a keyword
306+
assert evaluate_expression("{{ 'cat' in 'cat and dog' }}", StepContext()) is True
307+
308+
# Literal-vs-literal equality no longer mis-strips to a garbage string
309+
# (previously `'done' == 'failed'` short-circuited to the truthy string
310+
# "done' == 'failed").
311+
assert evaluate_condition("{{ 'done' == 'failed' }}", StepContext()) is False
312+
assert evaluate_condition("{{ 'done' == 'done' }}", StepContext()) is True
313+
314+
# A single quoted literal that itself contains operator text is preserved.
315+
assert evaluate_expression("{{ 'a == b' }}", StepContext()) == "a == b"
316+
assert evaluate_expression("{{ 'x and y' }}", StepContext()) == "x and y"
317+
318+
# Regression: ordinary (unquoted-keyword) parsing still works.
319+
plain = StepContext(inputs={"a": 1, "b": 2, "mode": "read"})
320+
assert evaluate_expression("{{ inputs.mode == 'read' }}", plain) is True
321+
assert evaluate_expression("{{ inputs.a == 1 and inputs.b == 2 }}", plain) is True
322+
assert evaluate_expression("{{ inputs.a == 9 or inputs.b == 2 }}", plain) is True
323+
assert evaluate_expression("{{ inputs.missing | default('a and b') }}", plain) == "a and b"
324+
289325
def test_filter_default(self):
290326
from specify_cli.workflows.expressions import evaluate_expression
291327
from specify_cli.workflows.base import StepContext

0 commit comments

Comments
 (0)