Skip to content

Commit a4d9430

Browse files
Noor-ul-ain001claudeCopilot
authored
fix(workflows): apply chained expression filters left-to-right (#3339)
* fix(workflows): apply chained expression filters left-to-right The pipe-filter parser in `_evaluate_simple_expression` split the expression only at the *first* top-level `|` and treated the whole remainder as a single filter. So a filter chain like `{{ inputs.rows | map('name') | join(', ') }}` handed `map('name') | join(', ')` to one filter, where the `(\w+)\((.+)\)` regex mangled it and raised `ValueError`. This broke the canonical use of `map`: it returns a list, and `join` is the only filter that renders a list to a string, so the two are meant to be chained. Chaining was impossible for every registered filter. Split the pipe segments at the top level (quote/bracket aware, so a literal `|` inside a quoted argument like `join(' | ')` is preserved) and fold each filter over the running value. The single-filter logic is extracted verbatim into `_apply_filter`, so all existing strict handling (`from_json` arity, unsupported-form vs unknown-filter messages) is unchanged and now applies to every link in the chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 8eadcd7 commit a4d9430

2 files changed

Lines changed: 158 additions & 57 deletions

File tree

src/specify_cli/workflows/expressions.py

Lines changed: 91 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,26 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
242242
return "".join(out)
243243

244244

245+
def _split_top_level(text: str, sep: str) -> list[str]:
246+
"""Split *text* on each occurrence of *sep* that lies outside any quoted
247+
string or nested brackets.
248+
249+
Used to break a filter chain (``a | map('x') | join(',')``) into its
250+
individual filter segments without splitting on a ``|`` that appears inside
251+
a quoted argument. Each returned segment is a slice at a top-level
252+
boundary, so the quote/bracket scan restarts cleanly on the remainder.
253+
"""
254+
parts: list[str] = []
255+
start = 0
256+
while True:
257+
idx = _find_top_level(text[start:], sep)
258+
if idx == -1:
259+
parts.append(text[start:])
260+
return parts
261+
parts.append(text[start:start + idx])
262+
start += idx + len(sep)
263+
264+
245265
def _split_top_level_commas(text: str) -> list[str]:
246266
"""Split *text* on commas that are not inside quotes or nested brackets.
247267
@@ -305,6 +325,68 @@ def _find_top_level(text: str, token: str) -> int:
305325
return -1
306326

307327

328+
def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> Any:
329+
"""Apply a single pipe filter segment to *value*.
330+
331+
*filter_expr* is one link of a filter chain — the text between two
332+
top-level ``|`` separators, already stripped (e.g. ``map('name')``,
333+
``default('x')``, ``from_json``). Returns the filtered value so the caller
334+
can feed it into the next link.
335+
336+
Raises ``ValueError`` on any mis-wired or unknown filter rather than
337+
silently returning *value* unchanged: a passthrough would turn a mistyped
338+
or unsupported filter into a wrong result with no signal.
339+
"""
340+
# `from_json` is strict: it takes no arguments and tolerates no trailing
341+
# tokens. Match on the leading filter name and require the whole filter to
342+
# be exactly `from_json`, so every mis-wired form (`from_json()`,
343+
# `from_json('x')`, `from_json)`, `from_json extra`) fails loudly instead of
344+
# silently falling through to the unknown-filter path.
345+
leading = re.match(r"\w+", filter_expr)
346+
if leading and leading.group(0) == "from_json":
347+
if filter_expr != "from_json":
348+
raise ValueError(
349+
"from_json: expected '| from_json' with no arguments or "
350+
f"trailing tokens, got '| {filter_expr}'"
351+
)
352+
return _filter_from_json(value)
353+
354+
# Parse filter name and argument
355+
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
356+
if filter_match:
357+
fname = filter_match.group(1)
358+
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
359+
if fname == "default":
360+
return _filter_default(value, farg)
361+
if fname == "join":
362+
return _filter_join(value, farg)
363+
if fname == "map":
364+
return _filter_map(value, farg)
365+
if fname == "contains":
366+
return _filter_contains(value, farg)
367+
# Filter without args
368+
if filter_expr == "default":
369+
return _filter_default(value)
370+
# No recognized filter matched. Fail loudly rather than silently returning
371+
# the unfiltered value. Distinguish a *registered* filter used in an
372+
# unsupported form (e.g. `| join` or `| map` with no argument) from a
373+
# genuinely unknown filter name, so the message names the real problem
374+
# instead of calling a known filter "unknown".
375+
name = leading.group(0) if leading else filter_expr
376+
expected = (
377+
"expected one of default or default('x'), join('sep'), "
378+
"map('attr'), contains('s'), or from_json"
379+
)
380+
if name in _REGISTERED_FILTERS:
381+
raise ValueError(
382+
f"filter '{name}' used in an unsupported form (got "
383+
f"'| {filter_expr}'): {expected}"
384+
)
385+
raise ValueError(
386+
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
387+
)
388+
389+
308390
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
309391
"""Evaluate a simple expression against the namespace.
310392
@@ -329,65 +411,17 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
329411
# Handle pipe filters. Detect the pipe at the top level only, so a literal
330412
# '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is
331413
# not mistaken for a filter separator — mirroring the operator parsing below.
414+
# Filters chain left-to-right: `list | map('name') | join(', ')` feeds each
415+
# filter's result into the next, so `map` (which yields a list) can be
416+
# rendered by `join`. Splitting only at the first pipe would hand the whole
417+
# tail to one filter and mangle any later `|`.
332418
pipe_idx = _find_top_level(expr, "|")
333419
if pipe_idx != -1:
334-
value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace)
335-
filter_expr = expr[pipe_idx + 1:].strip()
336-
337-
# `from_json` is strict: it takes no arguments and tolerates no
338-
# trailing tokens. Match on the leading filter name and require the
339-
# whole filter to be exactly `from_json`, so every mis-wired form
340-
# (`from_json()`, `from_json('x')`, `from_json)`, `from_json extra`)
341-
# fails loudly instead of silently falling through to the
342-
# unknown-filter path and returning the unparsed value. (filter_expr
343-
# is already stripped above.)
344-
leading = re.match(r"\w+", filter_expr)
345-
if leading and leading.group(0) == "from_json":
346-
if filter_expr != "from_json":
347-
raise ValueError(
348-
"from_json: expected '| from_json' with no arguments or "
349-
f"trailing tokens, got '| {filter_expr}'"
350-
)
351-
return _filter_from_json(value)
352-
353-
# Parse filter name and argument
354-
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
355-
if filter_match:
356-
fname = filter_match.group(1)
357-
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
358-
if fname == "default":
359-
return _filter_default(value, farg)
360-
if fname == "join":
361-
return _filter_join(value, farg)
362-
if fname == "map":
363-
return _filter_map(value, farg)
364-
if fname == "contains":
365-
return _filter_contains(value, farg)
366-
# Filter without args
367-
filter_name = filter_expr.strip()
368-
if filter_name == "default":
369-
return _filter_default(value)
370-
# No recognized filter matched. Fail loudly rather than silently
371-
# returning the unfiltered value: a passthrough turns a mis-typed or
372-
# unsupported filter into a wrong result with no signal. Mirrors the
373-
# strict `from_json` handling above. Distinguish a *registered* filter
374-
# used in an unsupported form (e.g. `| join` or `| map` with no
375-
# argument) from a genuinely unknown filter name, so the message names
376-
# the real problem instead of calling a known filter "unknown".
377-
leading_name = re.match(r"\w+", filter_expr)
378-
name = leading_name.group(0) if leading_name else filter_expr
379-
expected = (
380-
"expected one of default or default('x'), join('sep'), "
381-
"map('attr'), contains('s'), or from_json"
382-
)
383-
if name in _REGISTERED_FILTERS:
384-
raise ValueError(
385-
f"filter '{name}' used in an unsupported form (got "
386-
f"'| {filter_expr}'): {expected}"
387-
)
388-
raise ValueError(
389-
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
390-
)
420+
segments = _split_top_level(expr, "|")
421+
value = _evaluate_simple_expression(segments[0].strip(), namespace)
422+
for segment in segments[1:]:
423+
value = _apply_filter(value, segment.strip(), namespace)
424+
return value
391425

392426
# Boolean operators — parse 'or' first (lower precedence) so that
393427
# 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket

tests/test_workflows.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,73 @@ def test_registered_filter_unsupported_form_raises(self):
601601
):
602602
evaluate_expression("{{ inputs.tags | map }}", ctx)
603603

604+
def test_chained_filters_apply_left_to_right(self):
605+
# Filters chain: each filter's result feeds the next. `map` yields a
606+
# list and `join` is the only filter that renders a list to a string,
607+
# so `map('name') | join(', ')` is the canonical pairing — it must not
608+
# raise. Previously the pipe parser split only at the first `|` and
609+
# handed the whole tail (`map('name') | join(', ')`) to one filter,
610+
# which the `name(arg)` regex mangled into a ValueError.
611+
from specify_cli.workflows.expressions import evaluate_expression
612+
from specify_cli.workflows.base import StepContext
613+
614+
ctx = StepContext(
615+
inputs={
616+
"rows": [{"name": "a"}, {"name": "b"}],
617+
"tags": ["x", "y"],
618+
"missing": None,
619+
}
620+
)
621+
assert (
622+
evaluate_expression(
623+
"{{ inputs.rows | map('name') | join(', ') }}", ctx
624+
)
625+
== "a, b"
626+
)
627+
# A three-link chain: map -> join -> contains.
628+
assert (
629+
evaluate_expression(
630+
"{{ inputs.rows | map('name') | join(', ') | contains('a') }}",
631+
ctx,
632+
)
633+
is True
634+
)
635+
# default's fallback then flows into the next filter.
636+
assert (
637+
evaluate_expression(
638+
"{{ inputs.missing | default('x') | contains('x') }}", ctx
639+
)
640+
is True
641+
)
642+
643+
def test_chained_filter_error_in_later_link_raises(self):
644+
# A mis-wired filter anywhere in the chain must fail loudly, not just
645+
# the first link.
646+
import pytest
647+
from specify_cli.workflows.expressions import evaluate_expression
648+
from specify_cli.workflows.base import StepContext
649+
650+
ctx = StepContext(inputs={"rows": [{"name": "a"}]})
651+
with pytest.raises(ValueError, match="unknown filter 'bogus'"):
652+
evaluate_expression(
653+
"{{ inputs.rows | map('name') | bogus }}", ctx
654+
)
655+
656+
def test_pipe_in_quoted_arg_is_not_a_filter_separator(self):
657+
# A literal `|` inside a quoted operand or filter argument must not be
658+
# mistaken for a filter-chain separator — the top-level split has to
659+
# respect quotes.
660+
from specify_cli.workflows.expressions import evaluate_expression
661+
from specify_cli.workflows.base import StepContext
662+
663+
ctx = StepContext(inputs={"mode": "a|b", "tags": ["a|b", "c"]})
664+
assert evaluate_expression("{{ inputs.mode == 'a|b' }}", ctx) is True
665+
# `|` inside a filter argument stays part of the argument.
666+
assert (
667+
evaluate_expression("{{ inputs.tags | join(' | ') }}", ctx)
668+
== "a|b | c"
669+
)
670+
604671
def test_condition_evaluation(self):
605672
from specify_cli.workflows.expressions import evaluate_condition
606673
from specify_cli.workflows.base import StepContext

0 commit comments

Comments
 (0)