Skip to content

Commit 1cb9359

Browse files
authored
fix: fail loudly on an unknown workflow expression filter (#3074)
* fix: fail loudly on an unknown workflow expression filter The expression evaluator's filter dispatch fell through to `return value` for any unregistered filter, so a typo'd or unsupported filter such as `{{ items | length }}` rendered the value unchanged with no error and the run completed — a silent wrong result. Raise a clear ValueError instead, naming the offending filter and the valid ones, mirroring the strict handling already used for `from_json`. The five registered filters (default/join/map/contains/from_json) are unchanged; the `name(arg)` form of an unknown filter is now caught too. * fix: distinguish a misused registered filter from an unknown one; cover map Address the review feedback on the unknown-filter fail-loud path: - A *registered* filter used in an unsupported form (e.g. `| join` or `| map` with no argument) raised the misleading "unknown filter '<name>'" — the filter is registered, the syntax isn't. It now raises a message naming it as a known filter misused. A new `_REGISTERED_FILTERS` constant drives the distinction. - `test_registered_filters_unaffected` now also exercises `map('attr')`, which it previously claimed to cover but didn't. Add `test_registered_filter_unsupported_form_raises` to pin the new path. * fix: include the no-arg default form in the filter-error hint Copilot review: the hint listed default('x') but omitted the valid no-argument default form (| default), which this module supports.
1 parent f63c3d7 commit 1cb9359

2 files changed

Lines changed: 101 additions & 1 deletion

File tree

src/specify_cli/workflows/expressions.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@
1212
from typing import Any
1313

1414

15+
# The filters the expression evaluator recognizes. Used to tell a
16+
# *registered* filter used in an unsupported form (e.g. `| join` with no
17+
# argument) apart from a genuinely unknown filter name, so each raises an
18+
# error that names the real problem.
19+
_REGISTERED_FILTERS: tuple[str, ...] = (
20+
"default",
21+
"join",
22+
"map",
23+
"contains",
24+
"from_json",
25+
)
26+
27+
1528
# -- Custom filters -------------------------------------------------------
1629

1730
def _filter_default(value: Any, default_value: Any = "") -> Any:
@@ -192,7 +205,27 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
192205
filter_name = filter_expr.strip()
193206
if filter_name == "default":
194207
return _filter_default(value)
195-
return value
208+
# No recognized filter matched. Fail loudly rather than silently
209+
# returning the unfiltered value: a passthrough turns a mis-typed or
210+
# unsupported filter into a wrong result with no signal. Mirrors the
211+
# strict `from_json` handling above. Distinguish a *registered* filter
212+
# used in an unsupported form (e.g. `| join` or `| map` with no
213+
# argument) from a genuinely unknown filter name, so the message names
214+
# the real problem instead of calling a known filter "unknown".
215+
leading_name = re.match(r"\w+", filter_expr)
216+
name = leading_name.group(0) if leading_name else filter_expr
217+
expected = (
218+
"expected one of default or default('x'), join('sep'), "
219+
"map('attr'), contains('s'), or from_json"
220+
)
221+
if name in _REGISTERED_FILTERS:
222+
raise ValueError(
223+
f"filter '{name}' used in an unsupported form (got "
224+
f"'| {filter_expr}'): {expected}"
225+
)
226+
raise ValueError(
227+
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
228+
)
196229

197230
# Boolean operators — parse 'or' first (lower precedence) so that
198231
# 'a or b and c' is evaluated as 'a or (b and c)'.

tests/test_workflows.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,73 @@ def test_filter_from_json_rejects_malformed_forms(self):
342342
"{{ steps.emit.output.stdout | " + bad + " }}", ctx
343343
)
344344

345+
def test_filter_unknown_name_raises(self):
346+
# An unregistered filter name must fail loudly rather than silently
347+
# returning the unfiltered value (which hides a typo / unsupported
348+
# filter as a wrong result).
349+
import pytest
350+
from specify_cli.workflows.expressions import evaluate_expression
351+
from specify_cli.workflows.base import StepContext
352+
353+
ctx = StepContext(inputs={"items": [1, 2, 3]})
354+
with pytest.raises(ValueError, match="unknown filter 'length'"):
355+
evaluate_expression("{{ inputs.items | length }}", ctx)
356+
357+
def test_filter_unknown_name_with_args_raises(self):
358+
# The unknown-filter path must also catch the `name(arg)` form, which
359+
# otherwise falls through the recognized-args branch silently.
360+
import pytest
361+
from specify_cli.workflows.expressions import evaluate_expression
362+
from specify_cli.workflows.base import StepContext
363+
364+
ctx = StepContext(inputs={"text": "hello"})
365+
with pytest.raises(ValueError, match="unknown filter 'upper'"):
366+
evaluate_expression("{{ inputs.text | upper('x') }}", ctx)
367+
368+
def test_registered_filters_unaffected(self):
369+
# Regression: all five registered filters keep working unchanged.
370+
from specify_cli.workflows.expressions import evaluate_expression
371+
from specify_cli.workflows.base import StepContext
372+
373+
ctx = StepContext(
374+
inputs={
375+
"tags": ["a", "b", "c"],
376+
"text": "hello world",
377+
"missing": "",
378+
"rows": [{"id": "a"}, {"id": "b"}],
379+
},
380+
steps={"emit": {"output": {"stdout": '{"n": 1}'}}},
381+
)
382+
assert (
383+
evaluate_expression("{{ inputs.missing | default('fb') }}", ctx) == "fb"
384+
)
385+
assert evaluate_expression("{{ inputs.tags | join(', ') }}", ctx) == "a, b, c"
386+
assert evaluate_expression("{{ inputs.rows | map('id') }}", ctx) == ["a", "b"]
387+
assert (
388+
evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True
389+
)
390+
assert evaluate_expression(
391+
"{{ steps.emit.output.stdout | from_json }}", ctx
392+
) == {"n": 1}
393+
394+
def test_registered_filter_unsupported_form_raises(self):
395+
# A *registered* filter used in an unsupported form (e.g. `| join` with
396+
# no argument) must fail loudly with a message that names it as a known
397+
# filter misused, not as an "unknown filter".
398+
import pytest
399+
from specify_cli.workflows.expressions import evaluate_expression
400+
from specify_cli.workflows.base import StepContext
401+
402+
ctx = StepContext(inputs={"tags": ["a", "b", "c"]})
403+
with pytest.raises(
404+
ValueError, match="filter 'join' used in an unsupported form"
405+
):
406+
evaluate_expression("{{ inputs.tags | join }}", ctx)
407+
with pytest.raises(
408+
ValueError, match="filter 'map' used in an unsupported form"
409+
):
410+
evaluate_expression("{{ inputs.tags | map }}", ctx)
411+
345412
def test_condition_evaluation(self):
346413
from specify_cli.workflows.expressions import evaluate_condition
347414
from specify_cli.workflows.base import StepContext

0 commit comments

Comments
 (0)