Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/specify_cli/workflows/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,28 +139,29 @@ def _filter_from_json(value: Any) -> Any:
# against it, and the condition gate below reuses it rather than describing the
# same shape a second time, so widening what indexing accepts cannot leave the
# evaluator and the gate disagreeing.
_INDEXED_SEGMENT = re.compile(r"^([\w-]+)\[(\d+)\]$")
_INDEXED_SEGMENT = re.compile(r"^([\w-]+)\[(-?\d+)\]$")

_PLAIN_SEGMENT = re.compile(r"^[\w-]+$")


def _resolve_dot_path(obj: Any, path: str) -> Any:
"""Resolve a dotted path like ``steps.specify.output.file`` against *obj*.

Supports dict key access and list indexing (e.g., ``task_list[0]``).
Supports dict key access and list indexing, including the negative form
Python and Jinja2 both accept (e.g., ``task_list[0]``, ``task_list[-1]``).
"""
parts = path.split(".")
current = obj
for part in parts:
# Handle list indexing: name[0]
# Handle list indexing: name[0], name[-1]
idx_match = _INDEXED_SEGMENT.match(part)
if idx_match:
key, idx = idx_match.group(1), int(idx_match.group(2))
if isinstance(current, dict):
current = current.get(key)
else:
return None
if isinstance(current, list) and 0 <= idx < len(current):
if isinstance(current, list) and -len(current) <= idx < len(current):
current = current[idx]
else:
return None
Expand Down
19 changes: 19 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,25 @@ def test_list_indexing(self):
result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx)
assert result == "a.md"

def test_negative_list_indexing(self):
"""``list[-1]`` resolves from the end, as Python and Jinja2 both do.

Without it the index silently fell through to a dict lookup for the
literal key ``"task_list[-1]"`` and produced ``None``, so a template
reaching for the last element rendered empty with no error.
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext

ctx = StepContext(
steps={"tasks": {"output": {"task_list": [{"file": "a.md"}, {"file": "b.md"}]}}}
)
assert evaluate_expression("{{ steps.tasks.output.task_list[-1].file }}", ctx) == "b.md"
assert evaluate_expression("{{ steps.tasks.output.task_list[-2].file }}", ctx) == "a.md"
# Out of range in either direction stays None rather than raising.
assert evaluate_expression("{{ steps.tasks.output.task_list[-3] }}", ctx) is None
assert evaluate_expression("{{ steps.tasks.output.task_list[2] }}", ctx) is None

def test_context_run_id_resolves(self):
"""``{{ context.run_id }}`` resolves to ``StepContext.run_id``.

Expand Down
13 changes: 7 additions & 6 deletions tests/unit/test_condition_expression_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -946,16 +946,17 @@ def test_a_literal_never_reaches_the_resolver():
def test_gate_reads_the_shared_indexed_segment_definition(monkeypatch):
"""Widening `_INDEXED_SEGMENT` alone must reach the gate.

`steps.…​.task_list[-1]` is rejected today because `_INDEXED_SEGMENT` — the
one place `_resolve_dot_path` says what an index looks like — accepts digits
only. Widening it there and nowhere else must be enough; if the gate keeps
its own copy of the shape (as `_PATH_SEGMENT` used to), this fails.
`steps.…​.task_list[+1]` is rejected today because `_INDEXED_SEGMENT` — the
one place `_resolve_dot_path` says what an index looks like — accepts an
optional minus sign and digits only. Widening it there and nowhere else must
be enough; if the gate keeps its own copy of the shape (as `_PATH_SEGMENT`
used to), this fails.
"""
path = "steps.tasks.output.task_list[-1].file"
path = "steps.tasks.output.task_list[+1].file"
assert expressions._unresolvable_term(path) is not None

monkeypatch.setattr(
expressions, "_INDEXED_SEGMENT", re.compile(r"^([\w-]+)\[(-?\d+)\]$")
expressions, "_INDEXED_SEGMENT", re.compile(r"^([\w-]+)\[([+-]?\d+)\]$")
)
assert expressions._unresolvable_term(path) is None

Expand Down
Loading