Skip to content

Commit affbf5e

Browse files
doquanghuyclaude
andauthored
feat(workflows): add from_json expression filter (#2961)
* 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 * fix(expressions): make from_json strict — reject any arguments 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 <noreply@anthropic.com> * docs(workflows): document the from_json expression filter Address Copilot review: the user-facing filter references omitted the newly added `from_json` filter. Add it to the ARCHITECTURE.md filter table (with the `{{ steps.emit.output.stdout | from_json }}` example) and to the filter enumerations in workflows/README.md and docs/reference/workflows.md so the docs match the evaluator's capabilities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(workflows): make from_json strictness reject trailing tokens; fix docstring Address Copilot review: - Strictness only rejected parenthesized forms, so typos like `| from_json)` or `| from_json extra` still fell through to the unknown-filter path and silently returned the unparsed value. Match on the leading filter token and require the whole filter to be exactly `from_json`, so every mis-wired form raises. Extend the rejection test to cover the trailing-token cases. - The module docstring claimed "no imports", which is misleading now that the module imports `json`. Reword to state the actual sandbox guarantee: templates cannot do file I/O, import modules, or run arbitrary code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 00bff78 commit affbf5e

5 files changed

Lines changed: 93 additions & 4 deletions

File tree

docs/reference/workflows.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ Steps can reference inputs and previous step outputs using `{{ expression }}` sy
280280
| `steps.specify.output.file` | Output from a previous step |
281281
| `item` | Current item in a fan-out iteration |
282282

283-
Available filters: `default`, `join`, `contains`, `map`.
283+
Available filters: `default`, `join`, `contains`, `map`, `from_json`.
284284

285285
Example:
286286

src/specify_cli/workflows/expressions.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Sandboxed expression evaluator for workflow templates.
22
33
Provides a safe Jinja2 subset for evaluating expressions in workflow YAML.
4-
No file I/O, no imports, no arbitrary code execution.
4+
Templates cannot perform file I/O, import modules, or run arbitrary code —
5+
the evaluator only walks the namespace and applies a fixed set of filters.
56
"""
67

78
from __future__ import annotations
89

10+
import json
911
import re
1012
from typing import Any
1113

@@ -57,6 +59,23 @@ def _filter_contains(value: Any, substring: str) -> bool:
5759
return False
5860

5961

62+
def _filter_from_json(value: Any) -> Any:
63+
"""Parse a JSON string into a typed value (list/dict/scalar).
64+
65+
Raises ``ValueError`` on non-string input or invalid JSON — a parse
66+
failure here means the pipeline wiring is wrong, and silently
67+
passing the unparsed value through would hide it.
68+
"""
69+
if not isinstance(value, str):
70+
raise ValueError(
71+
f"from_json: expected a JSON string, got {type(value).__name__}"
72+
)
73+
try:
74+
return json.loads(value)
75+
except json.JSONDecodeError as exc:
76+
raise ValueError(f"from_json: invalid JSON: {exc}") from exc
77+
78+
6079
# -- Expression resolution ------------------------------------------------
6180

6281
_EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}")
@@ -122,7 +141,7 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
122141
- Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=``
123142
- Boolean operators: ``and``, ``or``, ``not``
124143
- ``in``, ``not in``
125-
- Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| map('...')``
144+
- Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| from_json``, ``| map('...')``
126145
- String and numeric literals
127146
"""
128147
expr = expr.strip()
@@ -140,6 +159,22 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
140159
value = _evaluate_simple_expression(parts[0].strip(), namespace)
141160
filter_expr = parts[1].strip()
142161

162+
# `from_json` is strict: it takes no arguments and tolerates no
163+
# trailing tokens. Match on the leading filter name and require the
164+
# whole filter to be exactly `from_json`, so every mis-wired form
165+
# (`from_json()`, `from_json('x')`, `from_json)`, `from_json extra`)
166+
# fails loudly instead of silently falling through to the
167+
# unknown-filter path and returning the unparsed value. (filter_expr
168+
# is already stripped above.)
169+
leading = re.match(r"\w+", filter_expr)
170+
if leading and leading.group(0) == "from_json":
171+
if filter_expr != "from_json":
172+
raise ValueError(
173+
"from_json: expected '| from_json' with no arguments or "
174+
f"trailing tokens, got '| {filter_expr}'"
175+
)
176+
return _filter_from_json(value)
177+
143178
# Parse filter name and argument
144179
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
145180
if filter_match:

tests/test_workflows.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,59 @@ def test_filter_contains(self):
289289
ctx = StepContext(inputs={"text": "hello world"})
290290
assert evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True
291291

292+
def test_filter_from_json_parses_object(self):
293+
from specify_cli.workflows.expressions import evaluate_expression
294+
from specify_cli.workflows.base import StepContext
295+
296+
ctx = StepContext(
297+
steps={"emit": {"output": {"stdout": '{"items": [1, 2, 3]}'}}}
298+
)
299+
result = evaluate_expression("{{ steps.emit.output.stdout | from_json }}", ctx)
300+
assert result == {"items": [1, 2, 3]}
301+
302+
def test_filter_from_json_invalid_json_raises(self):
303+
import pytest
304+
from specify_cli.workflows.expressions import evaluate_expression
305+
from specify_cli.workflows.base import StepContext
306+
307+
ctx = StepContext(steps={"emit": {"output": {"stdout": "not json"}}})
308+
with pytest.raises(ValueError, match="from_json: invalid JSON"):
309+
evaluate_expression("{{ steps.emit.output.stdout | from_json }}", ctx)
310+
311+
def test_filter_from_json_non_string_raises(self):
312+
import pytest
313+
from specify_cli.workflows.expressions import evaluate_expression
314+
from specify_cli.workflows.base import StepContext
315+
316+
ctx = StepContext(steps={"emit": {"output": {"exit_code": 0}}})
317+
with pytest.raises(ValueError, match="expected a JSON string"):
318+
evaluate_expression("{{ steps.emit.output.exit_code | from_json }}", ctx)
319+
320+
def test_filter_from_json_rejects_malformed_forms(self):
321+
# `from_json` is strict: no arguments and no trailing tokens. Every
322+
# mis-wired form — parenthesized, accidental arg, or trailing
323+
# garbage — must raise rather than silently fall through to the
324+
# unknown-filter path and return the unparsed value.
325+
import pytest
326+
from specify_cli.workflows.expressions import evaluate_expression
327+
from specify_cli.workflows.base import StepContext
328+
329+
ctx = StepContext(steps={"emit": {"output": {"stdout": '{"a": 1}'}}})
330+
bad_forms = (
331+
"from_json()",
332+
"from_json('x')",
333+
"from_json ()",
334+
"from_json ('x')",
335+
"from_json)",
336+
"from_json extra",
337+
"from_json 'x'",
338+
)
339+
for bad in bad_forms:
340+
with pytest.raises(ValueError, match="from_json: expected"):
341+
evaluate_expression(
342+
"{{ steps.emit.output.stdout | " + bad + " }}", ctx
343+
)
344+
292345
def test_condition_evaluation(self):
293346
from specify_cli.workflows.expressions import evaluate_condition
294347
from specify_cli.workflows.base import StepContext

workflows/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Workflow definitions use Jinja2-like `{{ expression }}` syntax for dynamic value
119119
| Filter: `join` | `{{ list \| join(', ') }}` | Join list elements |
120120
| Filter: `contains` | `{{ text \| contains('sub') }}` | Substring/membership check |
121121
| Filter: `map` | `{{ list \| map('attr') }}` | Extract attribute from each item |
122+
| Filter: `from_json` | `{{ steps.emit.output.stdout \| from_json }}` | Parse a JSON string into a typed value (raises on invalid JSON) |
122123

123124
**Single expressions** (`{{ expr }}` only) return typed values. **Mixed templates** (`"text {{ expr }} more"`) return interpolated strings.
124125

workflows/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ condition: "{{ steps.run-tests.output.exit_code != 0 }}"
332332
message: "{{ status | default('pending') }}"
333333
```
334334

335-
Supported filters: `default`, `join`, `contains`, `map`.
335+
Supported filters: `default`, `join`, `contains`, `map`, `from_json`.
336336

337337
### Runtime Context
338338

0 commit comments

Comments
 (0)