@@ -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+
245265def _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+
308390def _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
0 commit comments