From 39316e5962f3d1c2d5262d11362b9b90d94264ad Mon Sep 17 00:00:00 2001 From: doquanghuy Date: Sat, 13 Jun 2026 00:29:42 +0700 Subject: [PATCH 1/2] feat(workflows): add from_json expression filter Step outputs captured as strings could never become typed values in templates - the filter set was default/join/map/contains only, so e.g. a fan-out items: could never consume a step's JSON stdout. Add an arg-less from_json pipe filter with parse-or-raise semantics: invalid JSON or non-string input raises a clear ValueError rather than passing through silently. Fixes #2960 --- src/specify_cli/workflows/expressions.py | 22 ++++++++++++++++++- tests/test_workflows.py | 28 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 3cc74c7646..b7532ca06c 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -6,6 +6,7 @@ from __future__ import annotations +import json import re from typing import Any @@ -57,6 +58,23 @@ def _filter_contains(value: Any, substring: str) -> bool: return False +def _filter_from_json(value: Any) -> Any: + """Parse a JSON string into a typed value (list/dict/scalar). + + Raises ``ValueError`` on non-string input or invalid JSON — a parse + failure here means the pipeline wiring is wrong, and silently + passing the unparsed value through would hide it. + """ + if not isinstance(value, str): + raise ValueError( + f"from_json: expected a JSON string, got {type(value).__name__}" + ) + try: + return json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError(f"from_json: invalid JSON: {exc}") from exc + + # -- Expression resolution ------------------------------------------------ _EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}") @@ -122,7 +140,7 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: - Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=`` - Boolean operators: ``and``, ``or``, ``not`` - ``in``, ``not in`` - - Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| map('...')`` + - Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| from_json``, ``| map('...')`` - String and numeric literals """ expr = expr.strip() @@ -157,6 +175,8 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: filter_name = filter_expr.strip() if filter_name == "default": return _filter_default(value) + if filter_name == "from_json": + return _filter_from_json(value) return value # Boolean operators — parse 'or' first (lower precedence) so that diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 51da5cc86b..af1ebd5c46 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -286,6 +286,34 @@ def test_filter_contains(self): ctx = StepContext(inputs={"text": "hello world"}) assert evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True + def test_filter_from_json_parses_list(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"emit": {"output": {"stdout": '{"items": [1, 2, 3]}'}}} + ) + result = evaluate_expression("{{ steps.emit.output.stdout | from_json }}", ctx) + assert result == {"items": [1, 2, 3]} + + def test_filter_from_json_invalid_json_raises(self): + import pytest + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(steps={"emit": {"output": {"stdout": "not json"}}}) + with pytest.raises(ValueError, match="from_json: invalid JSON"): + evaluate_expression("{{ steps.emit.output.stdout | from_json }}", ctx) + + def test_filter_from_json_non_string_raises(self): + import pytest + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(steps={"emit": {"output": {"exit_code": 0}}}) + with pytest.raises(ValueError, match="expected a JSON string"): + evaluate_expression("{{ steps.emit.output.exit_code | from_json }}", ctx) + def test_condition_evaluation(self): from specify_cli.workflows.expressions import evaluate_condition from specify_cli.workflows.base import StepContext From 3751f5fefdd3f7bf46f0085885237b7b7e680d73 Mon Sep 17 00:00:00 2001 From: doquanghuy Date: Wed, 17 Jun 2026 09:05:12 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(expressions):=20make=20from=5Fjson=20st?= =?UTF-8?q?rict=20=E2=80=94=20reject=20any=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (#2961): from_json('x') and from_json() previously fell through to a silent passthrough of the unparsed value. Reject any parenthesized form with a clear error so mis-wired templates fail loudly. Rename test to ...parses_object (JSON under test is an object) and add coverage for the strict no-arguments behavior. Co-Authored-By: Claude Fable 5 --- src/specify_cli/workflows/expressions.py | 14 ++++++++++++-- tests/test_workflows.py | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index b7532ca06c..85164daae8 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -158,6 +158,18 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: value = _evaluate_simple_expression(parts[0].strip(), namespace) filter_expr = parts[1].strip() + # `from_json` is strict and takes no arguments. Match the filter name + # tolerant of whitespace and reject any parenthesized form + # (`from_json()`, `from_json('x')`, `from_json ()`) so a mis-wired + # template fails loudly instead of silently returning the unparsed + # value. + if filter_expr.split("(", 1)[0].strip() == "from_json": + if "(" in filter_expr: + raise ValueError( + "from_json: filter takes no arguments (use '| from_json')" + ) + return _filter_from_json(value) + # Parse filter name and argument filter_match = re.match(r"(\w+)\((.+)\)", filter_expr) if filter_match: @@ -175,8 +187,6 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: filter_name = filter_expr.strip() if filter_name == "default": return _filter_default(value) - if filter_name == "from_json": - return _filter_from_json(value) return value # Boolean operators — parse 'or' first (lower precedence) so that diff --git a/tests/test_workflows.py b/tests/test_workflows.py index af1ebd5c46..ece3e44eba 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -286,7 +286,7 @@ def test_filter_contains(self): ctx = StepContext(inputs={"text": "hello world"}) assert evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True - def test_filter_from_json_parses_list(self): + def test_filter_from_json_parses_object(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext @@ -314,6 +314,21 @@ def test_filter_from_json_non_string_raises(self): with pytest.raises(ValueError, match="expected a JSON string"): evaluate_expression("{{ steps.emit.output.exit_code | from_json }}", ctx) + def test_filter_from_json_rejects_arguments(self): + # `from_json` is strict and takes no arguments. Both the empty-parens + # and accidental-arg forms must raise rather than silently return the + # unparsed value, so a mis-wired template is caught immediately. + import pytest + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(steps={"emit": {"output": {"stdout": '{"a": 1}'}}}) + for bad in ("from_json()", "from_json('x')", "from_json ()", "from_json ('x')"): + with pytest.raises(ValueError, match="from_json: filter takes no arguments"): + evaluate_expression( + "{{ steps.emit.output.stdout | " + bad + " }}", ctx + ) + def test_condition_evaluation(self): from specify_cli.workflows.expressions import evaluate_condition from specify_cli.workflows.base import StepContext